From 123aafd772663e663fcc959eb037254105617b19 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Jan 2023 18:39:20 +0100 Subject: [PATCH 0001/2810] Bumped version to 2023.2.0b0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 44cec9fb86..e5025c35e2 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 = 2 -PATCH_VERSION: Final = "0.dev0" +PATCH_VERSION: Final = "0b0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 48ebcd4146..aa0af6037c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.2.0.dev0" +version = "2023.2.0b0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From a1416b904492005e5a2b623622a698d6d347c6f4 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Wed, 25 Jan 2023 20:45:50 +0100 Subject: [PATCH 0002/2810] Print expected device class units in error log (#86125) --- homeassistant/components/sensor/__init__.py | 2 ++ tests/components/sensor/test_init.py | 14 +++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 6dd745861e..351976db16 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -665,6 +665,7 @@ class SensorEntity(Entity): ( "Entity %s (%s) is using native unit of measurement '%s' which " "is not a valid unit for the device class ('%s') it is using; " + "expected one of %s; " "Please update your configuration if your entity is manually " "configured, otherwise %s" ), @@ -672,6 +673,7 @@ class SensorEntity(Entity): type(self), native_unit_of_measurement, device_class, + [str(unit) if unit else "no unit of measurement" for unit in units], report_issue, ) diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index cd62228f83..e65ae8ae7b 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -9,7 +9,11 @@ import pytest from pytest import approx from homeassistant.components.number import NumberDeviceClass -from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.components.sensor import ( + DEVICE_CLASS_UNITS, + SensorDeviceClass, + SensorStateClass, +) from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, @@ -1403,13 +1407,17 @@ async def test_device_classes_with_invalid_unit_of_measurement( device_class=device_class, native_unit_of_measurement="INVALID!", ) - + units = [ + str(unit) if unit else "no unit of measurement" + for unit in DEVICE_CLASS_UNITS.get(device_class, set()) + ] assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) await hass.async_block_till_done() assert ( "is using native unit of measurement 'INVALID!' which is not a valid " - f"unit for the device class ('{device_class}') it is using" + f"unit for the device class ('{device_class}') it is using; " + f"expected one of {units}" ) in caplog.text From e1c8dff536ddcfd5ada6bc4a365a6d4799389cd2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 25 Jan 2023 14:50:16 -0500 Subject: [PATCH 0003/2810] Fix oauth2 error (#86634) --- homeassistant/helpers/config_entry_oauth2_flow.py | 12 +++++++++--- tests/helpers/test_config_entry_oauth2_flow.py | 7 +++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 0a6356d310..552fa29eb8 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -13,7 +13,7 @@ from collections.abc import Awaitable, Callable import logging import secrets import time -from typing import Any, cast +from typing import Any, Optional, cast from aiohttp import client, web import async_timeout @@ -437,7 +437,10 @@ class OAuth2AuthorizeCallbackView(http.HomeAssistantView): state = _decode_jwt(hass, request.query["state"]) if state is None: - return web.Response(text="Invalid state") + return web.Response( + text="Invalid state. Is My Home Assistant configured to go to the right instance?", + status=400, + ) user_input: dict[str, Any] = {"state": state} @@ -538,7 +541,10 @@ def _encode_jwt(hass: HomeAssistant, data: dict) -> str: @callback def _decode_jwt(hass: HomeAssistant, encoded: str) -> dict | None: """JWT encode data.""" - secret = cast(str, hass.data.get(DATA_JWT_SECRET)) + secret = cast(Optional[str], hass.data.get(DATA_JWT_SECRET)) + + if secret is None: + return None try: return jwt.decode(encoded, secret, algorithms=["HS256"]) diff --git a/tests/helpers/test_config_entry_oauth2_flow.py b/tests/helpers/test_config_entry_oauth2_flow.py index f64525ecdd..3b94f3d80c 100644 --- a/tests/helpers/test_config_entry_oauth2_flow.py +++ b/tests/helpers/test_config_entry_oauth2_flow.py @@ -726,3 +726,10 @@ async def test_oauth_session_refresh_failure( session = config_entry_oauth2_flow.OAuth2Session(hass, config_entry, local_impl) with pytest.raises(aiohttp.client_exceptions.ClientResponseError): await session.async_request("post", "https://example.com") + + +async def test_oauth2_without_secret_init(local_impl, hass_client_no_auth): + """Check authorize callback without secret initalizated.""" + client = await hass_client_no_auth() + resp = await client.get("/auth/external/callback?code=abcd&state=qwer") + assert resp.status == 400 From 9ca04dbfa10576960ba7eb161ef85d6fefd0f8e0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 25 Jan 2023 21:33:30 -0500 Subject: [PATCH 0004/2810] Google Assistant: unset agent on unload (#86635) --- homeassistant/components/google_assistant_sdk/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/google_assistant_sdk/__init__.py b/homeassistant/components/google_assistant_sdk/__init__.py index 257562dc53..93699321ed 100644 --- a/homeassistant/components/google_assistant_sdk/__init__.py +++ b/homeassistant/components/google_assistant_sdk/__init__.py @@ -98,6 +98,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for service_name in hass.services.async_services()[DOMAIN]: hass.services.async_remove(DOMAIN, service_name) + if entry.options.get(CONF_ENABLE_CONVERSATION_AGENT, False): + conversation.async_unset_agent(hass, entry) + return True From a6fdf1d09aa9d29e2c27457e59f110f0b86c19ae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Jan 2023 15:14:59 -1000 Subject: [PATCH 0005/2810] Correct units on mopeka battery voltage sensor (#86663) --- homeassistant/components/mopeka/sensor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mopeka/sensor.py b/homeassistant/components/mopeka/sensor.py index f885ec8954..26965cd9a1 100644 --- a/homeassistant/components/mopeka/sensor.py +++ b/homeassistant/components/mopeka/sensor.py @@ -19,6 +19,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + UnitOfElectricPotential, UnitOfLength, UnitOfTemperature, ) @@ -41,7 +42,7 @@ SENSOR_DESCRIPTIONS = { "battery_voltage": SensorEntityDescription( key="battery_voltage", device_class=SensorDeviceClass.VOLTAGE, - native_unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, From ea2bf34647303f3db7e071740a93f414d4c40e73 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Wed, 25 Jan 2023 22:15:09 -0500 Subject: [PATCH 0006/2810] Bump ZHA quirks lib (#86669) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index a8a7ffc7c0..0392368070 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -7,7 +7,7 @@ "bellows==0.34.6", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.91", + "zha-quirks==0.0.92", "zigpy-deconz==0.19.2", "zigpy==0.53.0", "zigpy-xbee==0.16.2", diff --git a/requirements_all.txt b/requirements_all.txt index 863f775ef1..a9776bbc7f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2693,7 +2693,7 @@ zeroconf==0.47.1 zeversolar==0.2.0 # homeassistant.components.zha -zha-quirks==0.0.91 +zha-quirks==0.0.92 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8256fd4062..8c0de222d9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1906,7 +1906,7 @@ zeroconf==0.47.1 zeversolar==0.2.0 # homeassistant.components.zha -zha-quirks==0.0.91 +zha-quirks==0.0.92 # homeassistant.components.zha zigpy-deconz==0.19.2 From 07a1259db9ed61d8b15646ee9445d04aeeb26d72 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 25 Jan 2023 22:17:19 -0500 Subject: [PATCH 0007/2810] Add error handling for OpenAI (#86671) * Add error handling for OpenAI * Simplify area filtering * better prompt --- .../openai_conversation/__init__.py | 43 +++++++++---------- .../components/openai_conversation/const.py | 23 ++++++---- .../openai_conversation/test_init.py | 43 +++++++++++++++---- 3 files changed, 70 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 78cdc927c1..3f71537a9d 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations from functools import partial import logging -from typing import cast import openai from openai import error @@ -13,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, TemplateError -from homeassistant.helpers import area_registry, device_registry, intent, template +from homeassistant.helpers import area_registry, intent, template from homeassistant.util import ulid from .const import DEFAULT_MODEL, DEFAULT_PROMPT @@ -97,15 +96,26 @@ class OpenAIAgent(conversation.AbstractConversationAgent): _LOGGER.debug("Prompt for %s: %s", model, prompt) - result = await self.hass.async_add_executor_job( - partial( - openai.Completion.create, - engine=model, - prompt=prompt, - max_tokens=150, - user=conversation_id, + try: + result = await self.hass.async_add_executor_job( + partial( + openai.Completion.create, + engine=model, + prompt=prompt, + max_tokens=150, + user=conversation_id, + ) ) - ) + except error.OpenAIError as err: + intent_response = intent.IntentResponse(language=user_input.language) + intent_response.async_set_error( + intent.IntentResponseErrorCode.UNKNOWN, + f"Sorry, I had a problem talking to OpenAI: {err}", + ) + return conversation.ConversationResult( + response=intent_response, conversation_id=conversation_id + ) + _LOGGER.debug("Response %s", result) response = result["choices"][0]["text"].strip() self.history[conversation_id] = prompt + response @@ -122,20 +132,9 @@ class OpenAIAgent(conversation.AbstractConversationAgent): def _async_generate_prompt(self) -> str: """Generate a prompt for the user.""" - dev_reg = device_registry.async_get(self.hass) return template.Template(DEFAULT_PROMPT, self.hass).async_render( { "ha_name": self.hass.config.location_name, - "areas": [ - area - for area in area_registry.async_get(self.hass).areas.values() - # Filter out areas without devices - if any( - not dev.disabled_by - for dev in device_registry.async_entries_for_area( - dev_reg, cast(str, area.id) - ) - ) - ], + "areas": list(area_registry.async_get(self.hass).areas.values()), } ) diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 035a02a5b2..edad9574f7 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -3,19 +3,26 @@ DOMAIN = "openai_conversation" CONF_PROMPT = "prompt" DEFAULT_MODEL = "text-davinci-003" -DEFAULT_PROMPT = """ -You are a conversational AI for a smart home named {{ ha_name }}. -If a user wants to control a device, reject the request and suggest using the Home Assistant UI. +DEFAULT_PROMPT = """This smart home is controlled by Home Assistant. An overview of the areas and the devices in this smart home: -{% for area in areas %} +{%- for area in areas %} +{%- set area_info = namespace(printed=false) %} +{%- for device in area_devices(area.name) -%} +{%- if not device_attr(device, "disabled_by") and not device_attr(device, "entry_type") %} +{%- if not area_info.printed %} + {{ area.name }}: -{% for device in area_devices(area.name) -%} -{%- if not device_attr(device, "disabled_by") %} -- {{ device_attr(device, "name") }} ({{ device_attr(device, "model") }} by {{ device_attr(device, "manufacturer") }}) +{%- set area_info.printed = true %} +{%- endif %} +- {{ device_attr(device, "name") }}{% if device_attr(device, "model") not in device_attr(device, "name") %} ({{ device_attr(device, "model") }}){% endif %} {%- endif %} {%- endfor %} -{% endfor %} +{%- endfor %} + +Answer the users questions about the world truthfully. + +If the user wants to control a device, reject the request and suggest using the Home Assistant UI. Now finish this conversation: diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index 6597d81bff..eb6afebd80 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -1,14 +1,20 @@ """Tests for the OpenAI integration.""" from unittest.mock import patch +from openai import error + from homeassistant.components import conversation from homeassistant.core import Context -from homeassistant.helpers import device_registry +from homeassistant.helpers import area_registry, device_registry, intent async def test_default_prompt(hass, mock_init_component): """Test that the default prompt works.""" device_reg = device_registry.async_get(hass) + area_reg = area_registry.async_get(hass) + + for i in range(3): + area_reg.async_create(f"{i}Empty Area") device_reg.async_get_or_create( config_entry_id="1234", @@ -18,12 +24,22 @@ async def test_default_prompt(hass, mock_init_component): model="Test Model", suggested_area="Test Area", ) + for i in range(3): + device_reg.async_get_or_create( + config_entry_id="1234", + connections={("test", f"{i}abcd")}, + name="Test Service", + manufacturer="Test Manufacturer", + model="Test Model", + suggested_area="Test Area", + entry_type=device_registry.DeviceEntryType.SERVICE, + ) device_reg.async_get_or_create( config_entry_id="1234", connections={("test", "5678")}, name="Test Device 2", manufacturer="Test Manufacturer 2", - model="Test Model 2", + model="Device 2", suggested_area="Test Area 2", ) device_reg.async_get_or_create( @@ -31,7 +47,7 @@ async def test_default_prompt(hass, mock_init_component): connections={("test", "9876")}, name="Test Device 3", manufacturer="Test Manufacturer 3", - model="Test Model 3", + model="Test Model 3A", suggested_area="Test Area 2", ) @@ -40,20 +56,20 @@ async def test_default_prompt(hass, mock_init_component): assert ( mock_create.mock_calls[0][2]["prompt"] - == """You are a conversational AI for a smart home named test home. -If a user wants to control a device, reject the request and suggest using the Home Assistant UI. + == """This smart home is controlled by Home Assistant. An overview of the areas and the devices in this smart home: Test Area: - -- Test Device (Test Model by Test Manufacturer) +- Test Device (Test Model) Test Area 2: +- Test Device 2 +- Test Device 3 (Test Model 3A) -- Test Device 2 (Test Model 2 by Test Manufacturer 2) -- Test Device 3 (Test Model 3 by Test Manufacturer 3) +Answer the users questions about the world truthfully. +If the user wants to control a device, reject the request and suggest using the Home Assistant UI. Now finish this conversation: @@ -61,3 +77,12 @@ Smart home: How can I assist? User: hello Smart home: """ ) + + +async def test_error_handling(hass, mock_init_component): + """Test that the default prompt works.""" + with patch("openai.Completion.create", side_effect=error.ServiceUnavailableError): + result = await conversation.async_converse(hass, "hello", None, Context()) + + assert result.response.response_type == intent.IntentResponseType.ERROR, result + assert result.response.error_code == "unknown", result From 8f684e962a6cb57143af70707f784c5ddecd2e90 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 25 Jan 2023 23:00:35 -0500 Subject: [PATCH 0008/2810] Bumped version to 2023.2.0b1 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index e5025c35e2..fdfd223ff8 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 = 2 -PATCH_VERSION: Final = "0b0" +PATCH_VERSION: Final = "0b1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index aa0af6037c..bc20cf67de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.2.0b0" +version = "2023.2.0b1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From bd1371680f2aa10c14c1ecc468d818a3eb3f7661 Mon Sep 17 00:00:00 2001 From: Pascal Reeb Date: Thu, 26 Jan 2023 12:38:10 +0100 Subject: [PATCH 0009/2810] Add device registration to the Nuki component (#79806) * Add device registration to the Nuki component * Name is always given by the API * implement pvizeli's suggestions * switch device_registry to snake_case * fix entity naming * unify manufacturer names --- homeassistant/components/nuki/__init__.py | 171 +++++++++++------- .../components/nuki/binary_sensor.py | 5 - homeassistant/components/nuki/lock.py | 5 - 3 files changed, 105 insertions(+), 76 deletions(-) diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index 4598d43b4d..2030933945 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -13,7 +13,7 @@ from homeassistant import exceptions from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry, entity_registry from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -41,37 +41,6 @@ def _get_bridge_devices(bridge: NukiBridge) -> tuple[list[NukiLock], list[NukiOp return bridge.locks, bridge.openers -def _update_devices(devices: list[NukiDevice]) -> dict[str, set[str]]: - """ - Update the Nuki devices. - - Returns: - A dict with the events to be fired. The event type is the key and the device ids are the value - """ - - events: dict[str, set[str]] = defaultdict(set) - - for device in devices: - for level in (False, True): - try: - if isinstance(device, NukiOpener): - last_ring_action_state = device.ring_action_state - - device.update(level) - - if not last_ring_action_state and device.ring_action_state: - events["ring"].add(device.nuki_id) - else: - device.update(level) - except RequestException: - continue - - if device.state not in ERROR_STATES: - break - - return events - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the Nuki entry.""" @@ -101,42 +70,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except RequestException as err: raise exceptions.ConfigEntryNotReady from err - async def async_update_data() -> None: - """Fetch data from Nuki bridge.""" - try: - # Note: asyncio.TimeoutError and aiohttp.ClientError are already - # handled by the data update coordinator. - async with async_timeout.timeout(10): - events = await hass.async_add_executor_job( - _update_devices, locks + openers - ) - except InvalidCredentialsException as err: - raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err - except RequestException as err: - raise UpdateFailed(f"Error communicating with Bridge: {err}") from err - - ent_reg = er.async_get(hass) - for event, device_ids in events.items(): - for device_id in device_ids: - entity_id = ent_reg.async_get_entity_id( - Platform.LOCK, DOMAIN, device_id - ) - event_data = { - "entity_id": entity_id, - "type": event, - } - hass.bus.async_fire("nuki_event", event_data) - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - # Name of the data. For logging purposes. - name="nuki devices", - update_method=async_update_data, - # Polling interval. Will only be polled if there are subscribers. - update_interval=UPDATE_INTERVAL, + # Device registration for the bridge + info = bridge.info() + bridge_id = parse_id(info["ids"]["hardwareId"]) + dev_reg = device_registry.async_get(hass) + dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, bridge_id)}, + manufacturer="Nuki Home Solutions GmbH", + name=f"Nuki Bridge {bridge_id}", + model="Hardware Bridge", + sw_version=info["versions"]["firmwareVersion"], ) + coordinator = NukiCoordinator(hass, bridge, locks, openers) + hass.data[DOMAIN][entry.entry_id] = { DATA_COORDINATOR: coordinator, DATA_BRIDGE: bridge, @@ -178,3 +126,94 @@ class NukiEntity(CoordinatorEntity[DataUpdateCoordinator[None]]): """Pass coordinator to CoordinatorEntity.""" super().__init__(coordinator) self._nuki_device = nuki_device + + @property + def device_info(self): + """Device info for Nuki entities.""" + return { + "identifiers": {(DOMAIN, parse_id(self._nuki_device.nuki_id))}, + "name": self._nuki_device.name, + "manufacturer": "Nuki Home Solutions GmbH", + "model": self._nuki_device.device_type_str.capitalize(), + "sw_version": self._nuki_device.firmware_version, + "via_device": (DOMAIN, self.coordinator.bridge_id), + } + + +class NukiCoordinator(DataUpdateCoordinator): + """Data Update Coordinator for the Nuki integration.""" + + def __init__(self, hass, bridge, locks, openers): + """Initialize my coordinator.""" + super().__init__( + hass, + _LOGGER, + # Name of the data. For logging purposes. + name="nuki devices", + # Polling interval. Will only be polled if there are subscribers. + update_interval=UPDATE_INTERVAL, + ) + self.bridge = bridge + self.locks = locks + self.openers = openers + + @property + def bridge_id(self): + """Return the parsed id of the Nuki bridge.""" + return parse_id(self.bridge.info()["ids"]["hardwareId"]) + + async def _async_update_data(self) -> None: + """Fetch data from Nuki bridge.""" + try: + # Note: asyncio.TimeoutError and aiohttp.ClientError are already + # handled by the data update coordinator. + async with async_timeout.timeout(10): + events = await self.hass.async_add_executor_job( + self.update_devices, self.locks + self.openers + ) + except InvalidCredentialsException as err: + raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err + except RequestException as err: + raise UpdateFailed(f"Error communicating with Bridge: {err}") from err + + ent_reg = entity_registry.async_get(self.hass) + for event, device_ids in events.items(): + for device_id in device_ids: + entity_id = ent_reg.async_get_entity_id( + Platform.LOCK, DOMAIN, device_id + ) + event_data = { + "entity_id": entity_id, + "type": event, + } + self.hass.bus.async_fire("nuki_event", event_data) + + def update_devices(self, devices: list[NukiDevice]) -> dict[str, set[str]]: + """ + Update the Nuki devices. + + Returns: + A dict with the events to be fired. The event type is the key and the device ids are the value + """ + + events: dict[str, set[str]] = defaultdict(set) + + for device in devices: + for level in (False, True): + try: + if isinstance(device, NukiOpener): + last_ring_action_state = device.ring_action_state + + device.update(level) + + if not last_ring_action_state and device.ring_action_state: + events["ring"].add(device.nuki_id) + else: + device.update(level) + except RequestException: + continue + + if device.state not in ERROR_STATES: + break + + return events diff --git a/homeassistant/components/nuki/binary_sensor.py b/homeassistant/components/nuki/binary_sensor.py index 6c03cef366..ae861609e1 100644 --- a/homeassistant/components/nuki/binary_sensor.py +++ b/homeassistant/components/nuki/binary_sensor.py @@ -36,11 +36,6 @@ class NukiDoorsensorEntity(NukiEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.DOOR - @property - def name(self): - """Return the name of the lock.""" - return self._nuki_device.name - @property def unique_id(self) -> str: """Return a unique ID.""" diff --git a/homeassistant/components/nuki/lock.py b/homeassistant/components/nuki/lock.py index 4a8643e77a..87cc6f4084 100644 --- a/homeassistant/components/nuki/lock.py +++ b/homeassistant/components/nuki/lock.py @@ -69,11 +69,6 @@ class NukiDeviceEntity(NukiEntity, LockEntity, ABC): _attr_supported_features = LockEntityFeature.OPEN - @property - def name(self) -> str | None: - """Return the name of the lock.""" - return self._nuki_device.name - @property def unique_id(self) -> str | None: """Return a unique ID.""" From 8cb8ecdae971edd35bb25acde8603d0b1a7afccb Mon Sep 17 00:00:00 2001 From: Patrick ZAJDA Date: Thu, 26 Jan 2023 13:50:19 +0100 Subject: [PATCH 0010/2810] Migrate Nuki to new entity naming style (#80021) Co-authored-by: Pascal Vizeli --- homeassistant/components/nuki/binary_sensor.py | 2 ++ homeassistant/components/nuki/lock.py | 1 + 2 files changed, 3 insertions(+) diff --git a/homeassistant/components/nuki/binary_sensor.py b/homeassistant/components/nuki/binary_sensor.py index ae861609e1..6e73d9c320 100644 --- a/homeassistant/components/nuki/binary_sensor.py +++ b/homeassistant/components/nuki/binary_sensor.py @@ -34,6 +34,8 @@ async def async_setup_entry( class NukiDoorsensorEntity(NukiEntity, BinarySensorEntity): """Representation of a Nuki Lock Doorsensor.""" + _attr_has_entity_name = True + _attr_name = "Door sensor" _attr_device_class = BinarySensorDeviceClass.DOOR @property diff --git a/homeassistant/components/nuki/lock.py b/homeassistant/components/nuki/lock.py index 87cc6f4084..45fbf726e7 100644 --- a/homeassistant/components/nuki/lock.py +++ b/homeassistant/components/nuki/lock.py @@ -67,6 +67,7 @@ async def async_setup_entry( class NukiDeviceEntity(NukiEntity, LockEntity, ABC): """Representation of a Nuki device.""" + _attr_has_entity_name = True _attr_supported_features = LockEntityFeature.OPEN @property From c8c3f4bef6ab7abe045ebbc133886990f2ac407f Mon Sep 17 00:00:00 2001 From: Andrey Kupreychik Date: Thu, 26 Jan 2023 11:53:20 +0400 Subject: [PATCH 0011/2810] Update ndms2_client to 0.1.2 (#86624) fix https://github.com/home-assistant/core/issues/86379 fixes undefined --- homeassistant/components/keenetic_ndms2/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/keenetic_ndms2/manifest.json b/homeassistant/components/keenetic_ndms2/manifest.json index be1ffd1f0b..acb92dffe5 100644 --- a/homeassistant/components/keenetic_ndms2/manifest.json +++ b/homeassistant/components/keenetic_ndms2/manifest.json @@ -3,7 +3,7 @@ "name": "Keenetic NDMS2 Router", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/keenetic_ndms2", - "requirements": ["ndms2_client==0.1.1"], + "requirements": ["ndms2_client==0.1.2"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:InternetGatewayDevice:1", diff --git a/requirements_all.txt b/requirements_all.txt index a9776bbc7f..04f1557d25 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1168,7 +1168,7 @@ mycroftapi==2.0 nad_receiver==0.3.0 # homeassistant.components.keenetic_ndms2 -ndms2_client==0.1.1 +ndms2_client==0.1.2 # homeassistant.components.ness_alarm nessclient==0.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8c0de222d9..0a1d47aac4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -867,7 +867,7 @@ mutagen==1.46.0 mutesync==0.0.1 # homeassistant.components.keenetic_ndms2 -ndms2_client==0.1.1 +ndms2_client==0.1.2 # homeassistant.components.ness_alarm nessclient==0.10.0 From 41add96bab63d4f9df93fb759bc7b8d66f94eebd Mon Sep 17 00:00:00 2001 From: MHFDoge Date: Thu, 26 Jan 2023 05:55:07 -0600 Subject: [PATCH 0012/2810] Add known webostv button to list (#86674) Add known button to list. --- homeassistant/components/webostv/services.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/webostv/services.yaml b/homeassistant/components/webostv/services.yaml index 0fb3cd1ae1..1985857d12 100644 --- a/homeassistant/components/webostv/services.yaml +++ b/homeassistant/components/webostv/services.yaml @@ -17,7 +17,7 @@ button: description: >- Name of the button to press. Known possible values are LEFT, RIGHT, DOWN, UP, HOME, MENU, BACK, ENTER, DASH, INFO, ASTERISK, CC, EXIT, - MUTE, RED, GREEN, BLUE, VOLUMEUP, VOLUMEDOWN, CHANNELUP, CHANNELDOWN, + MUTE, RED, GREEN, BLUE, YELLOW, VOLUMEUP, VOLUMEDOWN, CHANNELUP, CHANNELDOWN, PLAY, PAUSE, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 required: true example: "LEFT" From ba82f138216fc311edb44a54bd5ce08d70a2c0b6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 26 Jan 2023 05:04:15 -0500 Subject: [PATCH 0013/2810] Make openai conversation prompt template more readable + test case (#86676) --- .../components/openai_conversation/const.py | 16 ++++++++-------- .../components/openai_conversation/test_init.py | 11 +++++++++++ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index edad9574f7..34516cbb10 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -7,17 +7,17 @@ DEFAULT_PROMPT = """This smart home is controlled by Home Assistant. An overview of the areas and the devices in this smart home: {%- for area in areas %} -{%- set area_info = namespace(printed=false) %} -{%- for device in area_devices(area.name) -%} -{%- if not device_attr(device, "disabled_by") and not device_attr(device, "entry_type") %} -{%- if not area_info.printed %} + {%- set area_info = namespace(printed=false) %} + {%- for device in area_devices(area.name) -%} + {%- if not device_attr(device, "disabled_by") and not device_attr(device, "entry_type") %} + {%- if not area_info.printed %} {{ area.name }}: -{%- set area_info.printed = true %} -{%- endif %} + {%- set area_info.printed = true %} + {%- endif %} - {{ device_attr(device, "name") }}{% if device_attr(device, "model") not in device_attr(device, "name") %} ({{ device_attr(device, "model") }}){% endif %} -{%- endif %} -{%- endfor %} + {%- endif %} + {%- endfor %} {%- endfor %} Answer the users questions about the world truthfully. diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index eb6afebd80..ac5be9f611 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -50,6 +50,17 @@ async def test_default_prompt(hass, mock_init_component): model="Test Model 3A", suggested_area="Test Area 2", ) + device = device_reg.async_get_or_create( + config_entry_id="1234", + connections={("test", "9876-disabled")}, + name="Test Device 3", + manufacturer="Test Manufacturer 3", + model="Test Model 3A", + suggested_area="Test Area 2", + ) + device_reg.async_update_device( + device.id, disabled_by=device_registry.DeviceEntryDisabler.USER + ) with patch("openai.Completion.create") as mock_create: await conversation.async_converse(hass, "hello", None, Context()) From 22afc7c7fbc7e6242dd53c736f743d5f8da4a564 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Thu, 26 Jan 2023 11:44:01 +0100 Subject: [PATCH 0014/2810] Fix missing interface key in deCONZ logbook (#86684) fixes undefined --- homeassistant/components/deconz/logbook.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/deconz/logbook.py b/homeassistant/components/deconz/logbook.py index 67801b8434..39fe7e98a5 100644 --- a/homeassistant/components/deconz/logbook.py +++ b/homeassistant/components/deconz/logbook.py @@ -17,6 +17,10 @@ from .device_trigger import ( CONF_BUTTON_2, CONF_BUTTON_3, CONF_BUTTON_4, + CONF_BUTTON_5, + CONF_BUTTON_6, + CONF_BUTTON_7, + CONF_BUTTON_8, CONF_CLOSE, CONF_DIM_DOWN, CONF_DIM_UP, @@ -95,6 +99,10 @@ INTERFACES = { CONF_BUTTON_2: "Button 2", CONF_BUTTON_3: "Button 3", CONF_BUTTON_4: "Button 4", + CONF_BUTTON_5: "Button 5", + CONF_BUTTON_6: "Button 6", + CONF_BUTTON_7: "Button 7", + CONF_BUTTON_8: "Button 8", CONF_SIDE_1: "Side 1", CONF_SIDE_2: "Side 2", CONF_SIDE_3: "Side 3", From 1dc3bb6eb16f33b6e7a1ede5026a8d6d5265dd82 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Jan 2023 11:11:03 +0100 Subject: [PATCH 0015/2810] Terminate strings at NUL when recording states and events (#86687) --- homeassistant/components/recorder/core.py | 6 ++- .../components/recorder/db_schema.py | 17 +++++++-- homeassistant/helpers/json.py | 34 +++++++++++++++++ tests/components/recorder/db_schema_30.py | 9 ++++- tests/components/recorder/test_init.py | 37 +++++++++++++++++++ tests/helpers/test_json.py | 17 +++++++++ 6 files changed, 113 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 20a98af4b2..a97eed8eff 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -836,7 +836,9 @@ class Recorder(threading.Thread): return try: - shared_data_bytes = EventData.shared_data_bytes_from_event(event) + shared_data_bytes = EventData.shared_data_bytes_from_event( + event, self.dialect_name + ) except JSON_ENCODE_EXCEPTIONS as ex: _LOGGER.warning("Event is not JSON serializable: %s: %s", event, ex) return @@ -869,7 +871,7 @@ class Recorder(threading.Thread): try: dbstate = States.from_event(event) shared_attrs_bytes = StateAttributes.shared_attrs_bytes_from_event( - event, self._exclude_attributes_by_domain + event, self._exclude_attributes_by_domain, self.dialect_name ) except JSON_ENCODE_EXCEPTIONS as ex: _LOGGER.warning( diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 1b5ac87c24..47b9658b05 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -43,11 +43,12 @@ from homeassistant.helpers.json import ( JSON_DECODE_EXCEPTIONS, JSON_DUMP, json_bytes, + json_bytes_strip_null, json_loads, ) import homeassistant.util.dt as dt_util -from .const import ALL_DOMAIN_EXCLUDE_ATTRS +from .const import ALL_DOMAIN_EXCLUDE_ATTRS, SupportedDialect from .models import StatisticData, StatisticMetaData, process_timestamp # SQLAlchemy Schema @@ -251,8 +252,12 @@ class EventData(Base): # type: ignore[misc,valid-type] ) @staticmethod - def shared_data_bytes_from_event(event: Event) -> bytes: + def shared_data_bytes_from_event( + event: Event, dialect: SupportedDialect | None + ) -> bytes: """Create shared_data from an event.""" + if dialect == SupportedDialect.POSTGRESQL: + return json_bytes_strip_null(event.data) return json_bytes(event.data) @staticmethod @@ -416,7 +421,9 @@ class StateAttributes(Base): # type: ignore[misc,valid-type] @staticmethod def shared_attrs_bytes_from_event( - event: Event, exclude_attrs_by_domain: dict[str, set[str]] + event: Event, + exclude_attrs_by_domain: dict[str, set[str]], + dialect: SupportedDialect | None, ) -> bytes: """Create shared_attrs from a state_changed event.""" state: State | None = event.data.get("new_state") @@ -427,6 +434,10 @@ class StateAttributes(Base): # type: ignore[misc,valid-type] exclude_attrs = ( exclude_attrs_by_domain.get(domain, set()) | ALL_DOMAIN_EXCLUDE_ATTRS ) + if dialect == SupportedDialect.POSTGRESQL: + return json_bytes_strip_null( + {k: v for k, v in state.attributes.items() if k not in exclude_attrs} + ) return json_bytes( {k: v for k, v in state.attributes.items() if k not in exclude_attrs} ) diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py index 74a2f54291..2a499dc0d9 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -71,6 +71,40 @@ def json_bytes(data: Any) -> bytes: ) +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) + + return result + + def json_dumps(data: Any) -> str: """Dump json string. diff --git a/tests/components/recorder/db_schema_30.py b/tests/components/recorder/db_schema_30.py index 8854cd33a6..01c31807ff 100644 --- a/tests/components/recorder/db_schema_30.py +++ b/tests/components/recorder/db_schema_30.py @@ -34,6 +34,7 @@ from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.orm import aliased, declarative_base, relationship from sqlalchemy.orm.session import Session +from homeassistant.components.recorder.const import SupportedDialect from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_RESTORED, @@ -287,7 +288,9 @@ class EventData(Base): # type: ignore[misc,valid-type] ) @staticmethod - def shared_data_bytes_from_event(event: Event) -> bytes: + def shared_data_bytes_from_event( + event: Event, dialect: SupportedDialect | None + ) -> bytes: """Create shared_data from an event.""" return json_bytes(event.data) @@ -438,7 +441,9 @@ class StateAttributes(Base): # type: ignore[misc,valid-type] @staticmethod def shared_attrs_bytes_from_event( - event: Event, exclude_attrs_by_domain: dict[str, set[str]] + event: Event, + exclude_attrs_by_domain: dict[str, set[str]], + dialect: SupportedDialect | None, ) -> bytes: """Create shared_attrs from a state_changed event.""" state: State | None = event.data.get("new_state") diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 8f32cfb6a6..c06865fb5a 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -31,6 +31,7 @@ from homeassistant.components.recorder.const import ( EVENT_RECORDER_5MIN_STATISTICS_GENERATED, EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, KEEPALIVE_TIME, + SupportedDialect, ) from homeassistant.components.recorder.db_schema import ( SCHEMA_VERSION, @@ -223,6 +224,42 @@ async def test_saving_state(recorder_mock, hass: HomeAssistant): assert state == _state_with_context(hass, entity_id) +@pytest.mark.parametrize( + "dialect_name, expected_attributes", + ( + (SupportedDialect.MYSQL, {"test_attr": 5, "test_attr_10": "silly\0stuff"}), + (SupportedDialect.POSTGRESQL, {"test_attr": 5, "test_attr_10": "silly"}), + (SupportedDialect.SQLITE, {"test_attr": 5, "test_attr_10": "silly\0stuff"}), + ), +) +async def test_saving_state_with_nul( + recorder_mock, hass: HomeAssistant, dialect_name, expected_attributes +): + """Test saving and restoring a state with nul in attributes.""" + entity_id = "test.recorder" + state = "restoring_from_db" + attributes = {"test_attr": 5, "test_attr_10": "silly\0stuff"} + + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", dialect_name + ): + hass.states.async_set(entity_id, state, attributes) + await async_wait_recording_done(hass) + + with session_scope(hass=hass) as session: + db_states = [] + for db_state, db_state_attributes in session.query(States, StateAttributes): + db_states.append(db_state) + state = db_state.to_native() + state.attributes = db_state_attributes.to_native() + assert len(db_states) == 1 + assert db_states[0].event_id is None + + expected = _state_with_context(hass, entity_id) + expected.attributes = expected_attributes + assert state == expected + + async def test_saving_many_states( async_setup_recorder_instance: SetupRecorderInstanceT, hass: HomeAssistant ): diff --git a/tests/helpers/test_json.py b/tests/helpers/test_json.py index 1e85338f15..92583fcfba 100644 --- a/tests/helpers/test_json.py +++ b/tests/helpers/test_json.py @@ -10,6 +10,7 @@ from homeassistant import core from homeassistant.helpers.json import ( ExtendedJSONEncoder, JSONEncoder, + json_bytes_strip_null, json_dumps, json_dumps_sorted, ) @@ -118,3 +119,19 @@ def test_json_dumps_rgb_color_subclass(): rgb = RGBColor(4, 2, 1) assert json_dumps(rgb) == "[4,2,1]" + + +def test_json_bytes_strip_null(): + """Test stripping nul from strings.""" + + assert json_bytes_strip_null("\0") == b'""' + assert json_bytes_strip_null("silly\0stuff") == b'"silly"' + assert json_bytes_strip_null(["one", "two\0", "three"]) == b'["one","two","three"]' + assert ( + json_bytes_strip_null({"k1": "one", "k2": "two\0", "k3": "three"}) + == b'{"k1":"one","k2":"two","k3":"three"}' + ) + assert ( + json_bytes_strip_null([[{"k1": {"k2": ["silly\0stuff"]}}]]) + == b'[[{"k1":{"k2":["silly"]}}]]' + ) From d211603ba7128527aaf8120fea6ddae793d8f35c Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Thu, 26 Jan 2023 08:27:44 -0500 Subject: [PATCH 0016/2810] Update Inovelli Blue Series switch support in ZHA (#86711) --- .../zha/core/channels/manufacturerspecific.py | 3 ++- homeassistant/components/zha/switch.py | 16 ++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/channels/manufacturerspecific.py index 427579cfb5..e6b88a6c9a 100644 --- a/homeassistant/components/zha/core/channels/manufacturerspecific.py +++ b/homeassistant/components/zha/core/channels/manufacturerspecific.py @@ -224,7 +224,8 @@ class InovelliConfigEntityChannel(ZigbeeChannel): "switch_type": False, "button_delay": False, "smart_bulb_mode": False, - "double_tap_up_for_full_brightness": True, + "double_tap_up_for_max_brightness": True, + "double_tap_down_for_min_brightness": True, "led_color_when_on": True, "led_color_when_off": True, "led_intensity_when_on": True, diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 85a8a0959b..f7a16c83b6 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -372,14 +372,26 @@ class InovelliSmartBulbMode(ZHASwitchConfigurationEntity, id_suffix="smart_bulb_ channel_names=CHANNEL_INOVELLI, ) class InovelliDoubleTapForFullBrightness( - ZHASwitchConfigurationEntity, id_suffix="double_tap_up_for_full_brightness" + ZHASwitchConfigurationEntity, id_suffix="double_tap_up_for_max_brightness" ): """Inovelli double tap for full brightness control.""" - _zcl_attribute: str = "double_tap_up_for_full_brightness" + _zcl_attribute: str = "double_tap_up_for_max_brightness" _attr_name: str = "Double tap full brightness" +@CONFIG_DIAGNOSTIC_MATCH( + channel_names=CHANNEL_INOVELLI, +) +class InovelliDoubleTapForMinBrightness( + ZHASwitchConfigurationEntity, id_suffix="double_tap_down_for_min_brightness" +): + """Inovelli double tap down for minimum brightness control.""" + + _zcl_attribute: str = "double_tap_down_for_min_brightness" + _attr_name: str = "Double tap minimum brightness" + + @CONFIG_DIAGNOSTIC_MATCH( channel_names=CHANNEL_INOVELLI, ) From 77bd23899fc200595dac219db5751f0f05085e0a Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 26 Jan 2023 15:43:12 +0100 Subject: [PATCH 0017/2810] Bump python-matter-server to 2.0.2 (#86712) --- homeassistant/components/matter/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 91bf823e5f..46fe45873b 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -3,7 +3,7 @@ "name": "Matter (BETA)", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/matter", - "requirements": ["python-matter-server==2.0.1"], + "requirements": ["python-matter-server==2.0.2"], "dependencies": ["websocket_api"], "codeowners": ["@home-assistant/matter"], "iot_class": "local_push" diff --git a/requirements_all.txt b/requirements_all.txt index 04f1557d25..f424e8df2f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2072,7 +2072,7 @@ python-kasa==0.5.0 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==2.0.1 +python-matter-server==2.0.2 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0a1d47aac4..887f6d7671 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1468,7 +1468,7 @@ python-juicenet==1.1.0 python-kasa==0.5.0 # homeassistant.components.matter -python-matter-server==2.0.1 +python-matter-server==2.0.2 # homeassistant.components.xiaomi_miio python-miio==0.5.12 From cd59705c4b886f8bb98fee68bf90eeceaad09a3f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 26 Jan 2023 16:44:52 +0100 Subject: [PATCH 0018/2810] Remove gas device class from current sensor in dsmr_reader (#86725) --- homeassistant/components/dsmr_reader/definitions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index cc0c851ebd..ddf149d680 100644 --- a/homeassistant/components/dsmr_reader/definitions.py +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -209,7 +209,6 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( DSMRReaderSensorEntityDescription( key="dsmr/consumption/gas/currently_delivered", name="Current gas usage", - device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, state_class=SensorStateClass.MEASUREMENT, ), From b464179eaca489ce3a05e44b8a728a3311ee8ca4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 26 Jan 2023 17:26:52 +0100 Subject: [PATCH 0019/2810] Fix state classes for duration device class (#86727) --- homeassistant/components/sensor/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 93fccbab12..4fb6314050 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -513,7 +513,7 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass | None] SensorDeviceClass.DATA_SIZE: set(SensorStateClass), SensorDeviceClass.DATE: set(), SensorDeviceClass.DISTANCE: set(SensorStateClass), - SensorDeviceClass.DURATION: set(), + SensorDeviceClass.DURATION: set(SensorStateClass), SensorDeviceClass.ENERGY: { SensorStateClass.TOTAL, SensorStateClass.TOTAL_INCREASING, From 4f2966674a0cc9f1e9e1f6c0a38de65366d770eb Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 27 Jan 2023 00:34:31 +0200 Subject: [PATCH 0020/2810] Bump aioshelly to 5.3.1 (#86751) --- homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index ea981b58ff..ff5de47200 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -3,7 +3,7 @@ "name": "Shelly", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/shelly", - "requirements": ["aioshelly==5.3.0"], + "requirements": ["aioshelly==5.3.1"], "dependencies": ["bluetooth", "http"], "zeroconf": [ { diff --git a/requirements_all.txt b/requirements_all.txt index f424e8df2f..91724ff8cf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -267,7 +267,7 @@ aiosenseme==0.6.1 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==5.3.0 +aioshelly==5.3.1 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 887f6d7671..677fa5087d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -245,7 +245,7 @@ aiosenseme==0.6.1 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==5.3.0 +aioshelly==5.3.1 # homeassistant.components.skybell aioskybell==22.7.0 From c7665b479a01004710cae7b98b55829fb197c387 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 26 Jan 2023 17:25:02 -0500 Subject: [PATCH 0021/2810] OpenAI: Fix device without model (#86754) --- .../components/openai_conversation/__init__.py | 1 + .../components/openai_conversation/const.py | 4 ++-- tests/components/openai_conversation/test_init.py | 13 +++++++++++-- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 3f71537a9d..c9d92f554e 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -73,6 +73,7 @@ class OpenAIAgent(conversation.AbstractConversationAgent): try: prompt = self._async_generate_prompt() except TemplateError as err: + _LOGGER.error("Error rendering prompt: %s", err) intent_response = intent.IntentResponse(language=user_input.language) intent_response.async_set_error( intent.IntentResponseErrorCode.UNKNOWN, diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 34516cbb10..378548173b 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -15,14 +15,14 @@ An overview of the areas and the devices in this smart home: {{ area.name }}: {%- set area_info.printed = true %} {%- endif %} -- {{ device_attr(device, "name") }}{% if device_attr(device, "model") not in device_attr(device, "name") %} ({{ device_attr(device, "model") }}){% endif %} +- {{ device_attr(device, "name") }}{% if device_attr(device, "model") and device_attr(device, "model") not in device_attr(device, "name") %} ({{ device_attr(device, "model") }}){% endif %} {%- endif %} {%- endfor %} {%- endfor %} Answer the users questions about the world truthfully. -If the user wants to control a device, reject the request and suggest using the Home Assistant UI. +If the user wants to control a device, reject the request and suggest using the Home Assistant app. Now finish this conversation: diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index ac5be9f611..551d493df8 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -50,6 +50,12 @@ async def test_default_prompt(hass, mock_init_component): model="Test Model 3A", suggested_area="Test Area 2", ) + device_reg.async_get_or_create( + config_entry_id="1234", + connections={("test", "qwer")}, + name="Test Device 4", + suggested_area="Test Area 2", + ) device = device_reg.async_get_or_create( config_entry_id="1234", connections={("test", "9876-disabled")}, @@ -63,7 +69,9 @@ async def test_default_prompt(hass, mock_init_component): ) with patch("openai.Completion.create") as mock_create: - await conversation.async_converse(hass, "hello", None, Context()) + result = await conversation.async_converse(hass, "hello", None, Context()) + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert ( mock_create.mock_calls[0][2]["prompt"] @@ -77,10 +85,11 @@ Test Area: Test Area 2: - Test Device 2 - Test Device 3 (Test Model 3A) +- Test Device 4 Answer the users questions about the world truthfully. -If the user wants to control a device, reject the request and suggest using the Home Assistant UI. +If the user wants to control a device, reject the request and suggest using the Home Assistant app. Now finish this conversation: From 8cbefd5f97a65d1951ee81dc22c668844c09bab3 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 26 Jan 2023 16:14:46 -0700 Subject: [PATCH 0022/2810] Fix state class issues in Ambient PWS (#86758) fixes undefined --- homeassistant/components/ambient_station/sensor.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/ambient_station/sensor.py b/homeassistant/components/ambient_station/sensor.py index eb01fd379b..0fc6e7643d 100644 --- a/homeassistant/components/ambient_station/sensor.py +++ b/homeassistant/components/ambient_station/sensor.py @@ -128,7 +128,6 @@ SENSOR_DESCRIPTIONS = ( key=TYPE_AQI_PM25_24H, name="AQI PM2.5 24h avg", device_class=SensorDeviceClass.AQI, - state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key=TYPE_AQI_PM25_IN, @@ -140,7 +139,6 @@ SENSOR_DESCRIPTIONS = ( key=TYPE_AQI_PM25_IN_24H, name="AQI PM2.5 indoor 24h avg", device_class=SensorDeviceClass.AQI, - state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key=TYPE_BAROMABSIN, @@ -182,7 +180,7 @@ SENSOR_DESCRIPTIONS = ( name="Event rain", native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, device_class=SensorDeviceClass.PRECIPITATION, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key=TYPE_FEELSLIKE, @@ -287,7 +285,6 @@ SENSOR_DESCRIPTIONS = ( name="Last rain", icon="mdi:water", device_class=SensorDeviceClass.TIMESTAMP, - state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_LIGHTNING_PER_DAY, @@ -315,7 +312,7 @@ SENSOR_DESCRIPTIONS = ( name="Monthly rain", native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, device_class=SensorDeviceClass.PRECIPITATION, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key=TYPE_PM25_24H, @@ -586,7 +583,7 @@ SENSOR_DESCRIPTIONS = ( name="Lifetime rain", native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, device_class=SensorDeviceClass.PRECIPITATION, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key=TYPE_UV, @@ -599,7 +596,7 @@ SENSOR_DESCRIPTIONS = ( name="Weekly rain", native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, device_class=SensorDeviceClass.PRECIPITATION, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key=TYPE_WINDDIR, From e20c7491c1fc87bd9beb27f24cd971aeee149927 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 26 Jan 2023 21:45:42 -0500 Subject: [PATCH 0023/2810] ESPHome update: Store reference to runtime data, not one of its values (#86762) Store reference to runtime data, not one of its values --- homeassistant/components/esphome/update.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py index 0f4836d0c6..de7a746319 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -72,15 +72,13 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity): _attr_title = "ESPHome" _attr_name = "Firmware" - _device_info: ESPHomeDeviceInfo - def __init__( self, entry_data: RuntimeEntryData, coordinator: ESPHomeDashboard ) -> None: """Initialize the update entity.""" super().__init__(coordinator=coordinator) assert entry_data.device_info is not None - self._device_info = entry_data.device_info + self._entry_data = entry_data self._attr_unique_id = entry_data.device_info.mac_address self._attr_device_info = DeviceInfo( connections={ @@ -88,6 +86,12 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity): } ) + @property + def _device_info(self) -> ESPHomeDeviceInfo: + """Return the device info.""" + assert self._entry_data.device_info is not None + return self._entry_data.device_info + @property def available(self) -> bool: """Return if update is available.""" From b7311dc6555314ef1da4294ec523c427313365ee Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 27 Jan 2023 15:45:51 +1300 Subject: [PATCH 0024/2810] Remove esphome password from config flow data if not needed (#86763) * Remove esphome password if not needed * Add test Co-authored-by: Paulus Schoutsen --- .../components/esphome/config_flow.py | 1 + tests/components/esphome/test_config_flow.py | 48 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index ee8da40d0b..61eb97a365 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -153,6 +153,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): if self._device_info.uses_password: return await self.async_step_authenticate() + self._password = "" return self._async_get_entry() async def async_step_discovery_confirm( diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index dda9c88cd1..a2f51f2526 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -518,6 +518,54 @@ async def test_reauth_fixed_via_dashboard( assert len(mock_get_encryption_key.mock_calls) == 1 +async def test_reauth_fixed_via_dashboard_remove_password( + hass, mock_client, mock_zeroconf, mock_dashboard +): + """Test reauth fixed automatically via dashboard with password removed.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "hello", + CONF_DEVICE_NAME: "test", + }, + ) + entry.add_to_hass(hass) + + mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test") + + mock_dashboard["configured"].append( + { + "name": "test", + "configuration": "test.yaml", + } + ) + + await dashboard.async_get_dashboard(hass).async_refresh() + + with patch( + "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key", + return_value=VALID_NOISE_PSK, + ) as mock_get_encryption_key: + result = await hass.config_entries.flow.async_init( + "esphome", + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, + ) + + assert result["type"] == FlowResultType.ABORT, result + assert result["reason"] == "reauth_successful" + assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK + assert entry.data[CONF_PASSWORD] == "" + + assert len(mock_get_encryption_key.mock_calls) == 1 + + async def test_reauth_confirm_invalid(hass, mock_client, mock_zeroconf): """Test reauth initiation with invalid PSK.""" entry = MockConfigEntry( From 6397cc5d0408fd744b674a2553372852aaadffa9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 26 Jan 2023 21:47:21 -0500 Subject: [PATCH 0025/2810] Bumped version to 2023.2.0b2 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index fdfd223ff8..59e7bf3039 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 = 2 -PATCH_VERSION: Final = "0b1" +PATCH_VERSION: Final = "0b2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index bc20cf67de..1e76d932ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.2.0b1" +version = "2023.2.0b2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 0a6ce35e301b3f0c62a0e825ee8a0ef208644f74 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Jan 2023 17:39:45 -1000 Subject: [PATCH 0026/2810] Chunk MariaDB and Postgresql data migration to avoid running out of buffer space (#86680) * Chunk MariaDB data migration to avoid running out of buffer space This will make the migration slower but since the innodb_buffer_pool_size is using the defaul to 128M and not tuned to the db size there is a risk of running out of buffer space for large databases * Update homeassistant/components/recorder/migration.py * hard code since bandit thinks its an injection * Update homeassistant/components/recorder/migration.py * guard against manually modified data/corrupt db * adjust to 10k per chunk * adjust to 50k per chunk * memory still just fine at 250k * but slower * commit after each chunk to reduce lock pressure * adjust * set to 0 if null so we do not loop forever (this should only happen if the data is missing) * set to 0 if null so we do not loop forever (this should only happen if the data is missing) * tweak * tweak * limit cleanup * lower limit to give some more buffer * lower limit to give some more buffer * where required for sqlite * sqlite can wipe as many as needed with no limit * limit on mysql only * chunk postgres * fix limit * tweak * fix reference * fix * tweak for ram * postgres memory reduction * defer cleanup * fix * same order --- homeassistant/components/recorder/core.py | 4 +- .../components/recorder/migration.py | 165 +++++++++++++----- 2 files changed, 127 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index a97eed8eff..62c1213a4a 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -1026,7 +1026,9 @@ class Recorder(threading.Thread): def _post_schema_migration(self, old_version: int, new_version: int) -> None: """Run post schema migration tasks.""" - migration.post_schema_migration(self.event_session, old_version, new_version) + migration.post_schema_migration( + self.engine, self.event_session, old_version, new_version + ) def _send_keep_alive(self) -> None: """Send a keep alive to keep the db connection open.""" diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 9bddf11fca..746adf11c1 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING import sqlalchemy from sqlalchemy import ForeignKeyConstraint, MetaData, Table, func, text -from sqlalchemy.engine import Engine +from sqlalchemy.engine import CursorResult, Engine from sqlalchemy.exc import ( DatabaseError, InternalError, @@ -43,7 +43,7 @@ from .statistics import ( get_start_time, validate_db_schema as statistics_validate_db_schema, ) -from .tasks import PostSchemaMigrationTask +from .tasks import CommitTask, PostSchemaMigrationTask from .util import session_scope if TYPE_CHECKING: @@ -166,6 +166,9 @@ def migrate_schema( if current_version != SCHEMA_VERSION: instance.queue_task(PostSchemaMigrationTask(current_version, SCHEMA_VERSION)) + # Make sure the post schema migration task is committed in case + # the next task does not have commit_before = True + instance.queue_task(CommitTask()) def _create_index( @@ -846,8 +849,7 @@ def _apply_update( # noqa: C901 _create_index(session_maker, "events", "ix_events_event_type_time_fired_ts") _create_index(session_maker, "states", "ix_states_entity_id_last_updated_ts") _create_index(session_maker, "states", "ix_states_last_updated_ts") - with session_scope(session=session_maker()) as session: - _migrate_columns_to_timestamp(hass, session, engine) + _migrate_columns_to_timestamp(session_maker, engine) elif new_version == 32: # Migration is done in two steps to ensure we can start using # the new columns before we wipe the old ones. @@ -860,6 +862,7 @@ def _apply_update( # noqa: C901 def post_schema_migration( + engine: Engine, session: Session, old_version: int, new_version: int, @@ -878,62 +881,142 @@ def post_schema_migration( # In version 31 we migrated all the time_fired, last_updated, and last_changed # columns to be timestamps. In version 32 we need to wipe the old columns # since they are no longer used and take up a significant amount of space. - _wipe_old_string_time_columns(session) + _wipe_old_string_time_columns(engine, session) -def _wipe_old_string_time_columns(session: Session) -> None: +def _wipe_old_string_time_columns(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 # Wipe States.last_changed since its been replaced by States.last_changed_ts - session.execute(text("UPDATE events set time_fired=NULL;")) - session.execute(text("UPDATE states set last_updated=NULL, last_changed=NULL;")) - session.commit() + # + if engine.dialect.name == SupportedDialect.SQLITE: + session.execute(text("UPDATE events set time_fired=NULL;")) + session.commit() + session.execute(text("UPDATE states set last_updated=NULL, last_changed=NULL;")) + session.commit() + elif engine.dialect.name == SupportedDialect.MYSQL: + # + # Since this is only to save space we limit the number of rows we update + # to 10,000,000 per table since we do not want to block the database for too long + # or run out of innodb_buffer_pool_size on MySQL. The old data will eventually + # be cleaned up by the recorder purge if we do not do it now. + # + session.execute(text("UPDATE events set time_fired=NULL LIMIT 10000000;")) + session.commit() + session.execute( + text( + "UPDATE states set last_updated=NULL, last_changed=NULL " + " LIMIT 10000000;" + ) + ) + session.commit() + elif engine.dialect.name == SupportedDialect.POSTGRESQL: + # + # Since this is only to save space we limit the number of rows we update + # to 250,000 per table since we do not want to block the database for too long + # or run out ram with postgresql. The old data will eventually + # be cleaned up by the recorder purge if we do not do it now. + # + session.execute( + text( + "UPDATE events set time_fired=NULL " + "where event_id in " + "(select event_id from events where time_fired_ts is NOT NULL LIMIT 250000);" + ) + ) + session.commit() + session.execute( + text( + "UPDATE states set last_updated=NULL, last_changed=NULL " + "where state_id in " + "(select state_id from states where last_updated_ts is NOT NULL LIMIT 250000);" + ) + ) + session.commit() def _migrate_columns_to_timestamp( - hass: HomeAssistant, session: Session, engine: Engine + session_maker: Callable[[], Session], engine: Engine ) -> None: """Migrate columns to use timestamp.""" # Migrate all data in Events.time_fired to Events.time_fired_ts # Migrate all data in States.last_updated to States.last_updated_ts # Migrate all data in States.last_changed to States.last_changed_ts - connection = session.connection() + result: CursorResult | None = None if engine.dialect.name == SupportedDialect.SQLITE: - connection.execute( - text( - 'UPDATE events set time_fired_ts=strftime("%s",time_fired) + ' - "cast(substr(time_fired,-7) AS FLOAT);" + # With SQLite we do this in one go since it is faster + with session_scope(session=session_maker()) as session: + connection = session.connection() + connection.execute( + text( + 'UPDATE events set time_fired_ts=strftime("%s",time_fired) + ' + "cast(substr(time_fired,-7) AS FLOAT);" + ) ) - ) - connection.execute( - text( - 'UPDATE states set last_updated_ts=strftime("%s",last_updated) + ' - "cast(substr(last_updated,-7) AS FLOAT), " - 'last_changed_ts=strftime("%s",last_changed) + ' - "cast(substr(last_changed,-7) AS FLOAT);" + connection.execute( + text( + 'UPDATE states set last_updated_ts=strftime("%s",last_updated) + ' + "cast(substr(last_updated,-7) AS FLOAT), " + 'last_changed_ts=strftime("%s",last_changed) + ' + "cast(substr(last_changed,-7) AS FLOAT);" + ) ) - ) elif engine.dialect.name == SupportedDialect.MYSQL: - connection.execute( - text("UPDATE events set time_fired_ts=UNIX_TIMESTAMP(time_fired);") - ) - connection.execute( - text( - "UPDATE states set last_updated_ts=UNIX_TIMESTAMP(last_updated), " - "last_changed_ts=UNIX_TIMESTAMP(last_changed);" - ) - ) + # With MySQL we do this in chunks to avoid hitting the `innodb_buffer_pool_size` limit + # We also need to do this in a loop since we can't be sure that we have + # updated all rows in the table until the rowcount is 0 + while result is None or result.rowcount > 0: + with session_scope(session=session_maker()) as session: + result = session.connection().execute( + text( + "UPDATE events set time_fired_ts=" + "IF(time_fired is NULL,0,UNIX_TIMESTAMP(time_fired)) " + "where time_fired_ts is NULL " + "LIMIT 250000;" + ) + ) + result = None + while result is None or result.rowcount > 0: + with session_scope(session=session_maker()) as session: + result = session.connection().execute( + text( + "UPDATE states set last_updated_ts=" + "IF(last_updated is NULL,0,UNIX_TIMESTAMP(last_updated)), " + "last_changed_ts=UNIX_TIMESTAMP(last_changed) " + "where last_updated_ts is NULL " + "LIMIT 250000;" + ) + ) elif engine.dialect.name == SupportedDialect.POSTGRESQL: - connection.execute( - text("UPDATE events set time_fired_ts=EXTRACT(EPOCH FROM time_fired);") - ) - connection.execute( - text( - "UPDATE states set last_updated_ts=EXTRACT(EPOCH FROM last_updated), " - "last_changed_ts=EXTRACT(EPOCH FROM last_changed);" - ) - ) + # With Postgresql we do this in chunks to avoid using too much memory + # We also need to do this in a loop since we can't be sure that we have + # updated all rows in the table until the rowcount is 0 + while result is None or result.rowcount > 0: + with session_scope(session=session_maker()) as session: + result = session.connection().execute( + text( + "UPDATE events SET " + "time_fired_ts= " + "(case when time_fired is NULL then 0 else EXTRACT(EPOCH FROM time_fired) end) " + "WHERE event_id IN ( " + "SELECT event_id FROM events where time_fired_ts is NULL LIMIT 250000 " + " );" + ) + ) + result = None + while result is None or result.rowcount > 0: + with session_scope(session=session_maker()) as session: + result = session.connection().execute( + text( + "UPDATE states set last_updated_ts=" + "(case when last_updated is NULL then 0 else EXTRACT(EPOCH FROM last_updated) end), " + "last_changed_ts=EXTRACT(EPOCH FROM last_changed) " + "where state_id IN ( " + "SELECT state_id FROM states where last_updated_ts is NULL LIMIT 250000 " + " );" + ) + ) def _initialize_database(session: Session) -> bool: From 60b96f19b79be05e46e68c5a031ca92eb948c285 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Jan 2023 17:16:16 -1000 Subject: [PATCH 0027/2810] Fix Bluetooth discoveries missing between restarts (#86808) * Fix Bluetooth discoveries missing between restarts * do not load other integrations * coverage --- homeassistant/components/bluetooth/manager.py | 14 ++++++++++++++ tests/components/bluetooth/conftest.py | 15 +++++++++++++++ tests/components/bluetooth/test_base_scanner.py | 4 +++- tests/components/bluetooth/test_manager.py | 10 +++++++--- 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index a8b890116d..1523f41bf1 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -209,6 +209,20 @@ class BluetoothManager: self._bluetooth_adapters, self.storage ) self.async_setup_unavailable_tracking() + seen: set[str] = set() + for address, service_info in itertools.chain( + self._connectable_history.items(), self._all_history.items() + ): + if address in seen: + continue + seen.add(address) + for domain in self._integration_matcher.match_domains(service_info): + discovery_flow.async_create_flow( + self.hass, + domain, + {"source": config_entries.SOURCE_BLUETOOTH}, + service_info, + ) @hass_callback def async_stop(self, event: Event) -> None: diff --git a/tests/components/bluetooth/conftest.py b/tests/components/bluetooth/conftest.py index ffa353fd0c..59c5cc822d 100644 --- a/tests/components/bluetooth/conftest.py +++ b/tests/components/bluetooth/conftest.py @@ -186,3 +186,18 @@ def one_adapter_old_bluez(): }, ): yield + + +@pytest.fixture(name="disable_new_discovery_flows") +def disable_new_discovery_flows_fixture(): + """Fixture that disables new discovery flows. + + We want to disable new discovery flows as we are testing the + BluetoothManager and not the discovery flows. This fixture + will patch the discovery_flow.async_create_flow method to + ensure we do not load other integrations. + """ + with patch( + "homeassistant.components.bluetooth.manager.discovery_flow.async_create_flow" + ) as mock_create_flow: + yield mock_create_flow diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index 935b35b586..5c6bbccd44 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -345,7 +345,9 @@ async def test_base_scanner_connecting_behavior(hass, enable_bluetooth): unsetup() -async def test_restore_history_remote_adapter(hass, hass_storage): +async def test_restore_history_remote_adapter( + hass, hass_storage, disable_new_discovery_flows +): """Test we can restore history for a remote adapter.""" data = hass_storage[storage.REMOTE_SCANNER_STORAGE_KEY] = json_loads( diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index d0ae23a522..12e4831333 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -282,7 +282,9 @@ async def test_switching_adapters_based_on_stale( ) -async def test_restore_history_from_dbus(hass, one_adapter): +async def test_restore_history_from_dbus( + hass, one_adapter, disable_new_discovery_flows +): """Test we can restore history from dbus.""" address = "AA:BB:CC:CC:CC:FF" @@ -304,7 +306,7 @@ async def test_restore_history_from_dbus(hass, one_adapter): async def test_restore_history_from_dbus_and_remote_adapters( - hass, one_adapter, hass_storage + hass, one_adapter, hass_storage, disable_new_discovery_flows ): """Test we can restore history from dbus along with remote adapters.""" address = "AA:BB:CC:CC:CC:FF" @@ -337,10 +339,11 @@ async def test_restore_history_from_dbus_and_remote_adapters( assert ( bluetooth.async_ble_device_from_address(hass, "EB:0B:36:35:6F:A4") is not None ) + assert disable_new_discovery_flows.call_count > 1 async def test_restore_history_from_dbus_and_corrupted_remote_adapters( - hass, one_adapter, hass_storage + hass, one_adapter, hass_storage, disable_new_discovery_flows ): """Test we can restore history from dbus when the remote adapters data is corrupted.""" address = "AA:BB:CC:CC:CC:FF" @@ -371,6 +374,7 @@ async def test_restore_history_from_dbus_and_corrupted_remote_adapters( assert bluetooth.async_ble_device_from_address(hass, address) is not None assert bluetooth.async_ble_device_from_address(hass, "EB:0B:36:35:6F:A4") is None + assert disable_new_discovery_flows.call_count >= 1 async def test_switching_adapters_based_on_rssi_connectable_to_non_connectable( From 29eb7e8f9e0607108a941d8035cc4205cc0d977e Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Fri, 27 Jan 2023 20:00:44 +0100 Subject: [PATCH 0028/2810] Bump plugwise to v0.27.4 (#86812) fixes undefined --- homeassistant/components/plugwise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index d5c3d41ce9..e3ac555a00 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -2,7 +2,7 @@ "domain": "plugwise", "name": "Plugwise", "documentation": "https://www.home-assistant.io/integrations/plugwise", - "requirements": ["plugwise==0.27.1"], + "requirements": ["plugwise==0.27.4"], "codeowners": ["@CoMPaTech", "@bouwew", "@brefra", "@frenck"], "zeroconf": ["_plugwise._tcp.local."], "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index 91724ff8cf..a106d3c509 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1373,7 +1373,7 @@ plexauth==0.0.6 plexwebsocket==0.0.13 # homeassistant.components.plugwise -plugwise==0.27.1 +plugwise==0.27.4 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 677fa5087d..29f58cfde4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1003,7 +1003,7 @@ plexauth==0.0.6 plexwebsocket==0.0.13 # homeassistant.components.plugwise -plugwise==0.27.1 +plugwise==0.27.4 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 From bedf5fe6cdcbe7237cabbaefe0dedf142cdeea32 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Fri, 27 Jan 2023 21:38:25 -0500 Subject: [PATCH 0029/2810] Fix D-Link config flow auth (#86824) --- homeassistant/components/dlink/__init__.py | 2 +- homeassistant/components/dlink/config_flow.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/dlink/__init__.py b/homeassistant/components/dlink/__init__.py index 528e5182b3..40fce4acf7 100644 --- a/homeassistant/components/dlink/__init__.py +++ b/homeassistant/components/dlink/__init__.py @@ -23,7 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_USERNAME], entry.data[CONF_USE_LEGACY_PROTOCOL], ) - if not smartplug.authenticated and entry.data[CONF_USE_LEGACY_PROTOCOL]: + if not smartplug.authenticated and smartplug.use_legacy_protocol: raise ConfigEntryNotReady("Cannot connect/authenticate") hass.data.setdefault(DOMAIN, {})[entry.entry_id] = SmartPlugData(smartplug) diff --git a/homeassistant/components/dlink/config_flow.py b/homeassistant/components/dlink/config_flow.py index 686448cfd8..4499e2efff 100644 --- a/homeassistant/components/dlink/config_flow.py +++ b/homeassistant/components/dlink/config_flow.py @@ -131,6 +131,6 @@ class DLinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): except Exception as ex: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception: %s", ex) return "unknown" - if smartplug.authenticated: - return None - return "cannot_connect" + if not smartplug.authenticated and smartplug.use_legacy_protocol: + return "cannot_connect" + return None From d33373f6ee605f562e772ef07fd6fe5d2ebc7d31 Mon Sep 17 00:00:00 2001 From: shbatm Date: Fri, 27 Jan 2023 21:16:28 -0600 Subject: [PATCH 0030/2810] Check for missing ISY994 Z-Wave Properties (#86829) * Check for missing Z-Wave Properties * Fix black from mobile --- homeassistant/components/isy994/helpers.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/isy994/helpers.py b/homeassistant/components/isy994/helpers.py index e3e21dbfd4..b190638fb3 100644 --- a/homeassistant/components/isy994/helpers.py +++ b/homeassistant/components/isy994/helpers.py @@ -313,7 +313,11 @@ def _generate_device_info(node: Node) -> DeviceInfo: model += f" ({node.type})" # Get extra information for Z-Wave Devices - if node.protocol == PROTO_ZWAVE and node.zwave_props.mfr_id != "0": + if ( + node.protocol == PROTO_ZWAVE + and node.zwave_props + and node.zwave_props.mfr_id != "0" + ): device_info[ ATTR_MANUFACTURER ] = f"Z-Wave MfrID:{int(node.zwave_props.mfr_id):#0{6}x}" From 69ed30f743e97862bdfc6fbbc762c150363c3e7b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 27 Jan 2023 22:54:05 -0500 Subject: [PATCH 0031/2810] Bumped version to 2023.2.0b3 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 59e7bf3039..0cd0e9b518 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 = 2 -PATCH_VERSION: Final = "0b2" +PATCH_VERSION: Final = "0b3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 1e76d932ae..f4207bfb6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.2.0b2" +version = "2023.2.0b3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 9adaf270640f5c7502f1e89fa9479141be074725 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Sun, 29 Jan 2023 00:49:29 +0100 Subject: [PATCH 0032/2810] Update frontend to 20230128.0 (#86838) --- 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 345daa5777..0c337ca1f6 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -2,7 +2,7 @@ "domain": "frontend", "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", - "requirements": ["home-assistant-frontend==20230125.0"], + "requirements": ["home-assistant-frontend==20230128.0"], "dependencies": [ "api", "auth", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8aead1bbaf..44bc99ea03 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==0.2.5 home-assistant-bluetooth==1.9.2 -home-assistant-frontend==20230125.0 +home-assistant-frontend==20230128.0 home-assistant-intents==2023.1.25 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index a106d3c509..78c35f7055 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==20230125.0 +home-assistant-frontend==20230128.0 # homeassistant.components.conversation home-assistant-intents==2023.1.25 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 29f58cfde4..13baca898c 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==20230125.0 +home-assistant-frontend==20230128.0 # homeassistant.components.conversation home-assistant-intents==2023.1.25 From 6db9653a87031c8b431080b59ada6ddffb229e73 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Sat, 28 Jan 2023 22:03:58 -0500 Subject: [PATCH 0033/2810] Fix D-Link attributes (#86842) * Fix D-Link attributes * fix blocking call --- homeassistant/components/dlink/data.py | 6 +++--- homeassistant/components/dlink/switch.py | 23 ++++++++++++++--------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/dlink/data.py b/homeassistant/components/dlink/data.py index c9e3b20a0b..b93cd21916 100644 --- a/homeassistant/components/dlink/data.py +++ b/homeassistant/components/dlink/data.py @@ -19,9 +19,9 @@ class SmartPlugData: """Initialize the data object.""" self.smartplug = smartplug self.state: str | None = None - self.temperature: str | None = None - self.current_consumption = None - self.total_consumption: str | None = None + self.temperature: str = "" + self.current_consumption: str = "" + self.total_consumption: str = "" self.available = False self._n_tried = 0 self._last_tried: datetime | None = None diff --git a/homeassistant/components/dlink/switch.py b/homeassistant/components/dlink/switch.py index 9827c1c13a..e6b9a4c788 100644 --- a/homeassistant/components/dlink/switch.py +++ b/homeassistant/components/dlink/switch.py @@ -94,17 +94,22 @@ class SmartPlugSwitch(DLinkEntity, SwitchEntity): @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the device.""" - attrs: dict[str, Any] = {} - if self.data.temperature and self.data.temperature.isnumeric(): - attrs[ATTR_TEMPERATURE] = self.hass.config.units.temperature( + try: + temperature = self.hass.config.units.temperature( int(self.data.temperature), UnitOfTemperature.CELSIUS ) - else: - attrs[ATTR_TEMPERATURE] = None - if self.data.total_consumption and self.data.total_consumption.isnumeric(): - attrs[ATTR_TOTAL_CONSUMPTION] = float(self.data.total_consumption) - else: - attrs[ATTR_TOTAL_CONSUMPTION] = None + except ValueError: + temperature = None + + try: + total_consumption = float(self.data.total_consumption) + except ValueError: + total_consumption = None + + attrs = { + ATTR_TOTAL_CONSUMPTION: total_consumption, + ATTR_TEMPERATURE: temperature, + } return attrs From c9cf3c29f8884e151a91d495844edef12f2d318c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Jan 2023 17:05:06 -1000 Subject: [PATCH 0034/2810] Improve websocket throughput of state changes (#86855) After the start event we tend to get an event storm of state changes which can get the websocket behind. #86854 will help with that a bit, but we can reduce the overhead to build a state diff when the attributes have not changed --- homeassistant/components/websocket_api/messages.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index 5c01484f91..15965b37fa 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -161,12 +161,14 @@ def _state_diff( additions[COMPRESSED_STATE_CONTEXT]["id"] = new_state.context.id else: additions[COMPRESSED_STATE_CONTEXT] = new_state.context.id - old_attributes = old_state.attributes - for key, value in new_state.attributes.items(): - if old_attributes.get(key) != value: - additions.setdefault(COMPRESSED_STATE_ATTRIBUTES, {})[key] = value - if removed := set(old_attributes).difference(new_state.attributes): - diff[STATE_DIFF_REMOVALS] = {COMPRESSED_STATE_ATTRIBUTES: removed} + if (old_attributes := old_state.attributes) != ( + new_attributes := new_state.attributes + ): + for key, value in new_attributes.items(): + if old_attributes.get(key) != value: + additions.setdefault(COMPRESSED_STATE_ATTRIBUTES, {})[key] = value + if removed := set(old_attributes).difference(new_attributes): + diff[STATE_DIFF_REMOVALS] = {COMPRESSED_STATE_ATTRIBUTES: removed} return {ENTITY_EVENT_CHANGE: {new_state.entity_id: diff}} From 55b5b36c47c3cf6385f10701192952ca46928e28 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sun, 29 Jan 2023 04:05:31 +0100 Subject: [PATCH 0035/2810] Fix tradfri air quality device class (#86861) --- homeassistant/components/tradfri/sensor.py | 2 +- tests/components/tradfri/test_sensor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tradfri/sensor.py b/homeassistant/components/tradfri/sensor.py index 3b92abfad7..689964cb15 100644 --- a/homeassistant/components/tradfri/sensor.py +++ b/homeassistant/components/tradfri/sensor.py @@ -89,9 +89,9 @@ SENSOR_DESCRIPTIONS_FAN: tuple[TradfriSensorEntityDescription, ...] = ( TradfriSensorEntityDescription( key="aqi", name="air quality", - device_class=SensorDeviceClass.AQI, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + icon="mdi:air-filter", value=_get_air_quality, ), TradfriSensorEntityDescription( diff --git a/tests/components/tradfri/test_sensor.py b/tests/components/tradfri/test_sensor.py index 6408613f4e..10904b8ffa 100644 --- a/tests/components/tradfri/test_sensor.py +++ b/tests/components/tradfri/test_sensor.py @@ -91,8 +91,8 @@ async def test_air_quality_sensor(hass, mock_gateway, mock_api_factory): assert sensor_1 is not None assert sensor_1.state == "42" assert sensor_1.attributes["unit_of_measurement"] == "µg/m³" - assert sensor_1.attributes["device_class"] == "aqi" assert sensor_1.attributes["state_class"] == "measurement" + assert "device_class" not in sensor_1.attributes async def test_filter_time_left_sensor(hass, mock_gateway, mock_api_factory): From 85d5ea2eca8be72c3ae656a315ec45ccc3d26690 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Jan 2023 17:06:07 -1000 Subject: [PATCH 0036/2810] Fix v32 schema migration when MySQL global.time_zone is configured with non-UTC timezone (#86867) * Fix v32 schema migration when MySQL timezone is not UTC * tweak --- homeassistant/components/recorder/migration.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 746adf11c1..af1446400c 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -971,7 +971,9 @@ def _migrate_columns_to_timestamp( result = session.connection().execute( text( "UPDATE events set time_fired_ts=" - "IF(time_fired is NULL,0,UNIX_TIMESTAMP(time_fired)) " + "IF(time_fired is NULL,0," + "UNIX_TIMESTAMP(CONVERT_TZ(time_fired,'+00:00',@@global.time_zone))" + ") " "where time_fired_ts is NULL " "LIMIT 250000;" ) @@ -982,8 +984,11 @@ def _migrate_columns_to_timestamp( result = session.connection().execute( text( "UPDATE states set last_updated_ts=" - "IF(last_updated is NULL,0,UNIX_TIMESTAMP(last_updated)), " - "last_changed_ts=UNIX_TIMESTAMP(last_changed) " + "IF(last_updated is NULL,0," + "UNIX_TIMESTAMP(CONVERT_TZ(last_updated,'+00:00',@@global.time_zone)) " + "), " + "last_changed_ts=" + "UNIX_TIMESTAMP(CONVERT_TZ(last_changed,'+00:00',@@global.time_zone)) " "where last_updated_ts is NULL " "LIMIT 250000;" ) From 8a9de2671b3b9cda2a2808396f52bfaaf60ef896 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Jan 2023 22:07:57 -0500 Subject: [PATCH 0037/2810] Bumped version to 2023.2.0b4 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 0cd0e9b518..2b85d085bb 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 = 2 -PATCH_VERSION: Final = "0b3" +PATCH_VERSION: Final = "0b4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index f4207bfb6a..329efa0059 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.2.0b3" +version = "2023.2.0b4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 63c218060bd2645d79ec4e8a5e062109372675a7 Mon Sep 17 00:00:00 2001 From: Tom Puttemans Date: Mon, 30 Jan 2023 14:56:57 +0100 Subject: [PATCH 0038/2810] Ignore empty payloads from DSMR Reader (#86841) * Ignore empty payloads from DSMR Reader * Simplify empty payload handling If the native value hasn't changed, requesting to store it won't have a performance impact. Co-authored-by: Franck Nijhof --------- Co-authored-by: Franck Nijhof --- homeassistant/components/dsmr_reader/sensor.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/dsmr_reader/sensor.py b/homeassistant/components/dsmr_reader/sensor.py index 7130380cbf..72e24c5272 100644 --- a/homeassistant/components/dsmr_reader/sensor.py +++ b/homeassistant/components/dsmr_reader/sensor.py @@ -69,7 +69,10 @@ class DSMRSensor(SensorEntity): @callback def message_received(message): """Handle new MQTT messages.""" - if self.entity_description.state is not None: + if message.payload == "": + self._attr_native_value = None + elif self.entity_description.state is not None: + # Perform optional additional parsing self._attr_native_value = self.entity_description.state(message.payload) else: self._attr_native_value = message.payload From 71b13d8f3e234715489755c5811cd942bef76472 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Mon, 30 Jan 2023 08:18:56 -0500 Subject: [PATCH 0039/2810] Address Google mail late review (#86847) --- .../components/google_mail/__init__.py | 14 +++++-- .../components/google_mail/config_flow.py | 32 +++++++++------ homeassistant/components/google_mail/const.py | 1 + .../components/google_mail/entity.py | 2 + .../components/google_mail/manifest.json | 2 +- .../components/google_mail/notify.py | 9 ++--- .../components/google_mail/strings.json | 3 +- .../google_mail/translations/en.json | 3 +- homeassistant/generated/integrations.json | 2 +- tests/components/google_mail/conftest.py | 2 +- .../google_mail/fixtures/get_profile_2.json | 6 +++ .../google_mail/test_config_flow.py | 40 ++++++++++++++++--- tests/components/google_mail/test_init.py | 3 +- tests/components/google_mail/test_notify.py | 2 +- 14 files changed, 88 insertions(+), 33 deletions(-) create mode 100644 tests/components/google_mail/fixtures/get_profile_2.json diff --git a/homeassistant/components/google_mail/__init__.py b/homeassistant/components/google_mail/__init__.py index e769bc239f..a24d5c1787 100644 --- a/homeassistant/components/google_mail/__init__.py +++ b/homeassistant/components/google_mail/__init__.py @@ -13,14 +13,22 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) +from homeassistant.helpers.typing import ConfigType from .api import AsyncConfigEntryAuth -from .const import DATA_AUTH, DOMAIN +from .const import DATA_AUTH, DATA_HASS_CONFIG, DOMAIN from .services import async_setup_services PLATFORMS = [Platform.NOTIFY, Platform.SENSOR] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Google Mail platform.""" + hass.data.setdefault(DOMAIN, {})[DATA_HASS_CONFIG] = config + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Google Mail from a config entry.""" implementation = await async_get_config_entry_implementation(hass, entry) @@ -36,7 +44,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady from err except ClientError as err: raise ConfigEntryNotReady from err - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = auth + hass.data[DOMAIN][entry.entry_id] = auth hass.async_create_task( discovery.async_load_platform( @@ -44,7 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: Platform.NOTIFY, DOMAIN, {DATA_AUTH: auth, CONF_NAME: entry.title}, - {}, + hass.data[DOMAIN][DATA_HASS_CONFIG], ) ) diff --git a/homeassistant/components/google_mail/config_flow.py b/homeassistant/components/google_mail/config_flow.py index e7631199dd..0552f57bf5 100644 --- a/homeassistant/components/google_mail/config_flow.py +++ b/homeassistant/components/google_mail/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Mapping import logging -from typing import Any +from typing import Any, cast from google.oauth2.credentials import Credentials from googleapiclient.discovery import build @@ -57,23 +57,29 @@ class OAuth2FlowHandler( async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: """Create an entry for the flow, or update existing entry.""" - if self.reauth_entry: - self.hass.config_entries.async_update_entry(self.reauth_entry, data=data) - await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) - return self.async_abort(reason="reauth_successful") - credentials = Credentials(data[CONF_TOKEN][CONF_ACCESS_TOKEN]) - - def _get_profile() -> dict[str, Any]: + def _get_profile() -> str: """Get profile from inside the executor.""" users = build( # pylint: disable=no-member "gmail", "v1", credentials=credentials ).users() - return users.getProfile(userId="me").execute() + return users.getProfile(userId="me").execute()["emailAddress"] - email = (await self.hass.async_add_executor_job(_get_profile))["emailAddress"] + credentials = Credentials(data[CONF_TOKEN][CONF_ACCESS_TOKEN]) + email = await self.hass.async_add_executor_job(_get_profile) - await self.async_set_unique_id(email) - self._abort_if_unique_id_configured() + if not self.reauth_entry: + await self.async_set_unique_id(email) + self._abort_if_unique_id_configured() - return self.async_create_entry(title=email, data=data) + return self.async_create_entry(title=email, data=data) + + if self.reauth_entry.unique_id == email: + self.hass.config_entries.async_update_entry(self.reauth_entry, data=data) + await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_abort( + reason="wrong_account", + description_placeholders={"email": cast(str, self.reauth_entry.unique_id)}, + ) diff --git a/homeassistant/components/google_mail/const.py b/homeassistant/components/google_mail/const.py index b9c2157e03..6e70ea9838 100644 --- a/homeassistant/components/google_mail/const.py +++ b/homeassistant/components/google_mail/const.py @@ -16,6 +16,7 @@ ATTR_START = "start" ATTR_TITLE = "title" DATA_AUTH = "auth" +DATA_HASS_CONFIG = "hass_config" DEFAULT_ACCESS = [ "https://www.googleapis.com/auth/gmail.compose", "https://www.googleapis.com/auth/gmail.settings.basic", diff --git a/homeassistant/components/google_mail/entity.py b/homeassistant/components/google_mail/entity.py index bfa93f4810..5e447125e8 100644 --- a/homeassistant/components/google_mail/entity.py +++ b/homeassistant/components/google_mail/entity.py @@ -1,6 +1,7 @@ """Entity representing a Google Mail account.""" from __future__ import annotations +from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription from .api import AsyncConfigEntryAuth @@ -24,6 +25,7 @@ class GoogleMailEntity(Entity): f"{auth.oauth_session.config_entry.entry_id}_{description.key}" ) self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, auth.oauth_session.config_entry.entry_id)}, manufacturer=MANUFACTURER, name=auth.oauth_session.config_entry.unique_id, diff --git a/homeassistant/components/google_mail/manifest.json b/homeassistant/components/google_mail/manifest.json index 3693e8ac61..6e4757aa61 100644 --- a/homeassistant/components/google_mail/manifest.json +++ b/homeassistant/components/google_mail/manifest.json @@ -7,5 +7,5 @@ "requirements": ["google-api-python-client==2.71.0"], "codeowners": ["@tkdrob"], "iot_class": "cloud_polling", - "integration_type": "device" + "integration_type": "service" } diff --git a/homeassistant/components/google_mail/notify.py b/homeassistant/components/google_mail/notify.py index 9abf75ea1e..eba38c3249 100644 --- a/homeassistant/components/google_mail/notify.py +++ b/homeassistant/components/google_mail/notify.py @@ -3,10 +3,9 @@ from __future__ import annotations import base64 from email.message import EmailMessage -from typing import Any, cast +from typing import Any from googleapiclient.http import HttpRequest -import voluptuous as vol from homeassistant.components.notify import ( ATTR_DATA, @@ -27,9 +26,9 @@ async def async_get_service( hass: HomeAssistant, config: ConfigType, discovery_info: DiscoveryInfoType | None = None, -) -> GMailNotificationService: +) -> GMailNotificationService | None: """Get the notification service.""" - return GMailNotificationService(cast(DiscoveryInfoType, discovery_info)) + return GMailNotificationService(discovery_info) if discovery_info else None class GMailNotificationService(BaseNotificationService): @@ -61,6 +60,6 @@ class GMailNotificationService(BaseNotificationService): msg = users.drafts().create(userId=email["From"], body={ATTR_MESSAGE: body}) else: if not to_addrs: - raise vol.Invalid("recipient address required") + raise ValueError("recipient address required") msg = users.messages().send(userId=email["From"], body=body) await self.hass.async_add_executor_job(msg.execute) diff --git a/homeassistant/components/google_mail/strings.json b/homeassistant/components/google_mail/strings.json index eaebca01e5..eb44bffb13 100644 --- a/homeassistant/components/google_mail/strings.json +++ b/homeassistant/components/google_mail/strings.json @@ -21,7 +21,8 @@ "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "wrong_account": "Wrong account: Please authenticate with {email}." }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/google_mail/translations/en.json b/homeassistant/components/google_mail/translations/en.json index 51d69638ed..9f9495d0ec 100644 --- a/homeassistant/components/google_mail/translations/en.json +++ b/homeassistant/components/google_mail/translations/en.json @@ -12,7 +12,8 @@ "oauth_error": "Received invalid token data.", "reauth_successful": "Re-authentication was successful", "timeout_connect": "Timeout establishing connection", - "unknown": "Unexpected error" + "unknown": "Unexpected error", + "wrong_account": "Wrong account: Please authenticate with {email}." }, "create_entry": { "default": "Successfully authenticated" diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 8beb5db0e6..8ceabb723f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2007,7 +2007,7 @@ "name": "Google Domains" }, "google_mail": { - "integration_type": "device", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling", "name": "Google Mail" diff --git a/tests/components/google_mail/conftest.py b/tests/components/google_mail/conftest.py index 3ecaf5b5d0..d0b7afae78 100644 --- a/tests/components/google_mail/conftest.py +++ b/tests/components/google_mail/conftest.py @@ -114,6 +114,6 @@ async def mock_setup_integration( ), ): assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await hass.async_block_till_done() yield func diff --git a/tests/components/google_mail/fixtures/get_profile_2.json b/tests/components/google_mail/fixtures/get_profile_2.json new file mode 100644 index 0000000000..3b36d57618 --- /dev/null +++ b/tests/components/google_mail/fixtures/get_profile_2.json @@ -0,0 +1,6 @@ +{ + "emailAddress": "example2@gmail.com", + "messagesTotal": 35308, + "threadsTotal": 33901, + "historyId": "4178212" +} diff --git a/tests/components/google_mail/test_config_flow.py b/tests/components/google_mail/test_config_flow.py index b0814c4b64..08f71368cc 100644 --- a/tests/components/google_mail/test_config_flow.py +++ b/tests/components/google_mail/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import patch from httplib2 import Response +import pytest from homeassistant import config_entries from homeassistant.components.google_mail.const import DOMAIN @@ -68,14 +69,36 @@ async def test_full_flow( ) +@pytest.mark.parametrize( + "fixture,abort_reason,placeholders,calls,access_token", + [ + ("get_profile", "reauth_successful", None, 1, "updated-access-token"), + ( + "get_profile_2", + "wrong_account", + {"email": "example@gmail.com"}, + 0, + "mock-access-token", + ), + ], +) async def test_reauth( hass: HomeAssistant, hass_client_no_auth, aioclient_mock: AiohttpClientMocker, current_request_with_host, config_entry: MockConfigEntry, + fixture: str, + abort_reason: str, + placeholders: dict[str, str], + calls: int, + access_token: str, ) -> None: - """Test the reauthentication case updates the existing config entry.""" + """Test the re-authentication case updates the correct config entry. + + Make sure we abort if the user selects the + wrong account on the consent screen. + """ config_entry.add_to_hass(hass) config_entry.async_start_reauth(hass) @@ -118,19 +141,26 @@ async def test_reauth( with patch( "homeassistant.components.google_mail.async_setup_entry", return_value=True - ) as mock_setup: + ) as mock_setup, patch( + "httplib2.Http.request", + return_value=( + Response({}), + bytes(load_fixture(f"google_mail/{fixture}.json"), encoding="UTF-8"), + ), + ): result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert len(mock_setup.mock_calls) == 1 assert result.get("type") == "abort" - assert result.get("reason") == "reauth_successful" + assert result["reason"] == abort_reason + assert result["description_placeholders"] == placeholders + assert len(mock_setup.mock_calls) == calls assert config_entry.unique_id == TITLE assert "token" in config_entry.data # Verify access token is refreshed - assert config_entry.data["token"].get("access_token") == "updated-access-token" + assert config_entry.data["token"].get("access_token") == access_token assert config_entry.data["token"].get("refresh_token") == "mock-refresh-token" diff --git a/tests/components/google_mail/test_init.py b/tests/components/google_mail/test_init.py index b57547cfd7..239c7f9d51 100644 --- a/tests/components/google_mail/test_init.py +++ b/tests/components/google_mail/test_init.py @@ -29,7 +29,7 @@ async def test_setup_success( await hass.config_entries.async_unload(entries[0].entry_id) await hass.async_block_till_done() - assert not len(hass.services.async_services().get(DOMAIN, {})) + assert not hass.services.async_services().get(DOMAIN) @pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"]) @@ -125,6 +125,7 @@ async def test_device_info( entry = hass.config_entries.async_entries(DOMAIN)[0] device = device_registry.async_get_device({(DOMAIN, entry.entry_id)}) + assert device.entry_type is dr.DeviceEntryType.SERVICE assert device.identifiers == {(DOMAIN, entry.entry_id)} assert device.manufacturer == "Google, Inc." assert device.name == "example@gmail.com" diff --git a/tests/components/google_mail/test_notify.py b/tests/components/google_mail/test_notify.py index c95d0fa8df..1e9a174d81 100644 --- a/tests/components/google_mail/test_notify.py +++ b/tests/components/google_mail/test_notify.py @@ -52,7 +52,7 @@ async def test_notify_voluptuous_error( """Test voluptuous error thrown when drafting email.""" await setup_integration() - with pytest.raises(Invalid) as ex: + with pytest.raises(ValueError) as ex: await hass.services.async_call( NOTIFY_DOMAIN, "example_gmail_com", From 0d27ee4fd8648e51571558422bfc907cdd4aaa13 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jan 2023 02:16:29 -1000 Subject: [PATCH 0040/2810] Cache the names and area lists in the default agent (#86874) * Cache the names and area lists in the default agent fixes #86803 * add coverage to make sure the entity cache busts * add areas test * cover the last line --- .../components/conversation/default_agent.py | 50 ++++- tests/components/conversation/test_init.py | 212 +++++++++++++++++- 2 files changed, 256 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 7be37062d1..68fa891bb8 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -71,6 +71,8 @@ class DefaultAgent(AbstractConversationAgent): # intent -> [sentences] self._config_intents: dict[str, Any] = {} + self._areas_list: TextSlotList | None = None + self._names_list: TextSlotList | None = None async def async_initialize(self, config_intents): """Initialize the default agent.""" @@ -81,6 +83,22 @@ class DefaultAgent(AbstractConversationAgent): if config_intents: self._config_intents = config_intents + self.hass.bus.async_listen( + area_registry.EVENT_AREA_REGISTRY_UPDATED, + self._async_handle_area_registry_changed, + run_immediately=True, + ) + self.hass.bus.async_listen( + entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, + self._async_handle_entity_registry_changed, + run_immediately=True, + ) + self.hass.bus.async_listen( + core.EVENT_STATE_CHANGED, + self._async_handle_state_changed, + run_immediately=True, + ) + async def async_process(self, user_input: ConversationInput) -> ConversationResult: """Process a sentence.""" language = user_input.language or self.hass.config.language @@ -310,8 +328,29 @@ class DefaultAgent(AbstractConversationAgent): return lang_intents + @core.callback + def _async_handle_area_registry_changed(self, event: core.Event) -> None: + """Clear area area cache when the area registry has changed.""" + self._areas_list = None + + @core.callback + def _async_handle_entity_registry_changed(self, event: core.Event) -> None: + """Clear names list cache when an entity changes aliases.""" + if event.data["action"] == "update" and "aliases" not in event.data["changes"]: + return + self._names_list = None + + @core.callback + def _async_handle_state_changed(self, event: core.Event) -> None: + """Clear names list cache when a state is added or removed from the state machine.""" + if event.data.get("old_state") and event.data.get("new_state"): + return + self._names_list = None + def _make_areas_list(self) -> TextSlotList: """Create slot list mapping area names/aliases to area ids.""" + if self._areas_list is not None: + return self._areas_list registry = area_registry.async_get(self.hass) areas = [] for entry in registry.async_list_areas(): @@ -320,16 +359,18 @@ class DefaultAgent(AbstractConversationAgent): for alias in entry.aliases: areas.append((alias, entry.id)) - return TextSlotList.from_tuples(areas) + self._areas_list = TextSlotList.from_tuples(areas) + return self._areas_list def _make_names_list(self) -> TextSlotList: """Create slot list mapping entity names/aliases to entity ids.""" + if self._names_list is not None: + return self._names_list states = self.hass.states.async_all() registry = entity_registry.async_get(self.hass) names = [] for state in states: - domain = state.entity_id.split(".", maxsplit=1)[0] - context = {"domain": domain} + context = {"domain": state.domain} entry = registry.async_get(state.entity_id) if entry is not None: @@ -344,7 +385,8 @@ class DefaultAgent(AbstractConversationAgent): # Default name names.append((state.name, state.entity_id, context)) - return TextSlotList.from_tuples(names) + self._names_list = TextSlotList.from_tuples(names) + return self._names_list def _get_error_text( self, response_type: ResponseType, lang_intents: LanguageIntents diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index e79cd69475..f4b386cbe4 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -7,10 +7,15 @@ import pytest from homeassistant.components import conversation from homeassistant.components.cover import SERVICE_OPEN_COVER from homeassistant.core import DOMAIN as HASS_DOMAIN, Context -from homeassistant.helpers import entity_registry, intent +from homeassistant.helpers import ( + area_registry, + device_registry, + entity_registry, + intent, +) from homeassistant.setup import async_setup_component -from tests.common import async_mock_service +from tests.common import MockConfigEntry, async_mock_service class OrderBeerIntentHandler(intent.IntentHandler): @@ -75,6 +80,143 @@ async def test_http_processing_intent( } +async def test_http_processing_intent_entity_added( + hass, init_components, hass_client, hass_admin_user +): + """Test processing intent via HTTP API with entities added later. + + We want to ensure that adding an entity later busts the cache + so that the new entity is available as well as any aliases. + """ + er = entity_registry.async_get(hass) + er.async_get_or_create("light", "demo", "1234", suggested_object_id="kitchen") + er.async_update_entity("light.kitchen", aliases={"my cool light"}) + hass.states.async_set("light.kitchen", "off") + + client = await hass_client() + resp = await client.post( + "/api/conversation/process", json={"text": "turn on my cool light"} + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data == { + "response": { + "response_type": "action_done", + "card": {}, + "speech": { + "plain": { + "extra_data": None, + "speech": "Turned on my cool light", + } + }, + "language": hass.config.language, + "data": { + "targets": [], + "success": [ + {"id": "light.kitchen", "name": "kitchen", "type": "entity"} + ], + "failed": [], + }, + }, + "conversation_id": None, + } + + # Add an alias + er.async_get_or_create("light", "demo", "5678", suggested_object_id="late") + hass.states.async_set("light.late", "off", {"friendly_name": "friendly light"}) + + client = await hass_client() + resp = await client.post( + "/api/conversation/process", json={"text": "turn on friendly light"} + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data == { + "response": { + "response_type": "action_done", + "card": {}, + "speech": { + "plain": { + "extra_data": None, + "speech": "Turned on friendly light", + } + }, + "language": hass.config.language, + "data": { + "targets": [], + "success": [ + {"id": "light.late", "name": "friendly light", "type": "entity"} + ], + "failed": [], + }, + }, + "conversation_id": None, + } + + # Now add an alias + er.async_update_entity("light.late", aliases={"late added light"}) + + client = await hass_client() + resp = await client.post( + "/api/conversation/process", json={"text": "turn on late added light"} + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data == { + "response": { + "response_type": "action_done", + "card": {}, + "speech": { + "plain": { + "extra_data": None, + "speech": "Turned on late added light", + } + }, + "language": hass.config.language, + "data": { + "targets": [], + "success": [ + {"id": "light.late", "name": "friendly light", "type": "entity"} + ], + "failed": [], + }, + }, + "conversation_id": None, + } + + # Now delete the entity + er.async_remove("light.late") + + client = await hass_client() + resp = await client.post( + "/api/conversation/process", json={"text": "turn on late added light"} + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + assert data == { + "conversation_id": None, + "response": { + "card": {}, + "data": {"code": "no_intent_match"}, + "language": hass.config.language, + "response_type": "error", + "speech": { + "plain": { + "extra_data": None, + "speech": "Sorry, I couldn't understand " "that", + } + }, + }, + } + + @pytest.mark.parametrize("sentence", ("turn on kitchen", "turn kitchen on")) async def test_turn_on_intent(hass, init_components, sentence): """Test calling the turn on intent.""" @@ -569,3 +711,69 @@ async def test_non_default_response(hass, init_components): ) ) assert result.response.speech["plain"]["speech"] == "Opened front door" + + +async def test_turn_on_area(hass, init_components): + """Test turning on an area.""" + er = entity_registry.async_get(hass) + dr = device_registry.async_get(hass) + ar = area_registry.async_get(hass) + entry = MockConfigEntry(domain="test") + + device = dr.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + kitchen_area = ar.async_create("kitchen") + dr.async_update_device(device.id, area_id=kitchen_area.id) + + er.async_get_or_create("light", "demo", "1234", suggested_object_id="stove") + er.async_update_entity( + "light.stove", aliases={"my stove light"}, area_id=kitchen_area.id + ) + hass.states.async_set("light.stove", "off") + + calls = async_mock_service(hass, HASS_DOMAIN, "turn_on") + + await hass.services.async_call( + "conversation", + "process", + {conversation.ATTR_TEXT: "turn on lights in the kitchen"}, + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + call = calls[0] + assert call.domain == HASS_DOMAIN + assert call.service == "turn_on" + assert call.data == {"entity_id": "light.stove"} + + basement_area = ar.async_create("basement") + dr.async_update_device(device.id, area_id=basement_area.id) + er.async_update_entity("light.stove", area_id=basement_area.id) + calls.clear() + + # Test that the area is updated + await hass.services.async_call( + "conversation", + "process", + {conversation.ATTR_TEXT: "turn on lights in the kitchen"}, + ) + await hass.async_block_till_done() + + assert len(calls) == 0 + + # Test the new area works + await hass.services.async_call( + "conversation", + "process", + {conversation.ATTR_TEXT: "turn on lights in the basement"}, + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + call = calls[0] + assert call.domain == HASS_DOMAIN + assert call.service == "turn_on" + assert call.data == {"entity_id": "light.stove"} From 07e9b0e98bc659b17f96e99d07157a7f66bc020b Mon Sep 17 00:00:00 2001 From: Thomas Schamm Date: Sun, 29 Jan 2023 15:00:36 +0100 Subject: [PATCH 0041/2810] Add Bosch SHC description and host form strings (#86897) * Add description to setup SHC II. Add missing host info in reauth_confirm * Remove template value in en.json --- homeassistant/components/bosch_shc/strings.json | 7 +++++-- homeassistant/components/bosch_shc/translations/de.json | 7 +++++-- homeassistant/components/bosch_shc/translations/en.json | 7 +++++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/bosch_shc/strings.json b/homeassistant/components/bosch_shc/strings.json index 15fb061ef2..2b5720f084 100644 --- a/homeassistant/components/bosch_shc/strings.json +++ b/homeassistant/components/bosch_shc/strings.json @@ -14,11 +14,14 @@ } }, "confirm_discovery": { - "description": "Please press the Bosch Smart Home Controller's front-side button until LED starts flashing.\nReady to continue to set up {model} @ {host} with Home Assistant?" + "description": "Smart Home Controller I: Please press the front-side button until LED starts flashing.\nSmart Home Controller II: Press the function button shortly. Cloud and network lights start blinking orange.\nDevice is now ready to be paired.\n\nReady to continue to set up {model} @ {host} with Home Assistant?" }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "The bosch_shc integration needs to re-authenticate your account" + "description": "The bosch_shc integration needs to re-authenticate your account", + "data": { + "host": "[%key:common::config_flow::data::host%]" + } } }, "error": { diff --git a/homeassistant/components/bosch_shc/translations/de.json b/homeassistant/components/bosch_shc/translations/de.json index 6ace3cedc9..9c0a9a359f 100644 --- a/homeassistant/components/bosch_shc/translations/de.json +++ b/homeassistant/components/bosch_shc/translations/de.json @@ -14,7 +14,7 @@ "flow_title": "Bosch SHC: {name}", "step": { "confirm_discovery": { - "description": "Bitte dr\u00fccke die frontseitige Taste des Bosch Smart Home Controllers, bis die LED zu blinken beginnt.\nBist du bereit, mit der Einrichtung von {model} @ {host} in Home Assistant fortzufahren?" + "description": "Smart Home Controller I: Bitte dr\u00fccke die frontseitige Taste, bis die LED zu blinken beginnt.\nSmart Home Controller II: Dr\u00fccke kurz die Funktionstaste. Die Cloud- und Netzwerkleuchten beginnen orange zu blinken.\nDas Ger\u00e4t ist nun f\u00fcr die Kopplung bereit.\n\nBist du bereit, mit der Einrichtung von {model} @ {host} in Home Assistant fortzufahren?" }, "credentials": { "data": { @@ -23,7 +23,10 @@ }, "reauth_confirm": { "description": "Die bosch_shc Integration muss dein Konto neu authentifizieren", - "title": "Integration erneut authentifizieren" + "title": "Integration erneut authentifizieren", + "data": { + "host": "Host" + } }, "user": { "data": { diff --git a/homeassistant/components/bosch_shc/translations/en.json b/homeassistant/components/bosch_shc/translations/en.json index ab5cde9ef2..ad7859e80e 100644 --- a/homeassistant/components/bosch_shc/translations/en.json +++ b/homeassistant/components/bosch_shc/translations/en.json @@ -14,7 +14,7 @@ "flow_title": "Bosch SHC: {name}", "step": { "confirm_discovery": { - "description": "Please press the Bosch Smart Home Controller's front-side button until LED starts flashing.\nReady to continue to set up {model} @ {host} with Home Assistant?" + "description": "Smart Home Controller I: Please press the front-side button until LED starts flashing.\nSmart Home Controller II: Press the function button shortly. Cloud and network lights start blinking orange.\nDevice is now ready to be paired.\n\nReady to continue to set up {model} @ {host} with Home Assistant?" }, "credentials": { "data": { @@ -23,7 +23,10 @@ }, "reauth_confirm": { "description": "The bosch_shc integration needs to re-authenticate your account", - "title": "Reauthenticate Integration" + "title": "Reauthenticate Integration", + "data": { + "host": "Host" + } }, "user": { "data": { From f14771ccf2108b2b87194890b4e7cfbcee50dad8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Jan 2023 16:33:23 -1000 Subject: [PATCH 0042/2810] Fix old indices not being removed in schema migration leading to slow MySQL queries (#86917) fixes #83787 --- .../components/recorder/db_schema.py | 2 +- .../components/recorder/migration.py | 25 +++++++++++-------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 47b9658b05..1fef18573e 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -55,7 +55,7 @@ from .models import StatisticData, StatisticMetaData, process_timestamp # pylint: disable=invalid-name Base = declarative_base() -SCHEMA_VERSION = 32 +SCHEMA_VERSION = 33 _StatisticsBaseSelfT = TypeVar("_StatisticsBaseSelfT", bound="StatisticsBase") diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index af1446400c..85459e5acc 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -505,12 +505,12 @@ def _apply_update( # noqa: C901 timestamp_type = "FLOAT" if new_version == 1: - _create_index(session_maker, "events", "ix_events_time_fired") + # This used to create ix_events_time_fired, but it was removed in version 32 + pass elif new_version == 2: # Create compound start/end index for recorder_runs _create_index(session_maker, "recorder_runs", "ix_recorder_runs_start_end") - # Create indexes for states - _create_index(session_maker, "states", "ix_states_last_updated") + # This used to create ix_states_last_updated bit it was removed in version 32 elif new_version == 3: # There used to be a new index here, but it was removed in version 4. pass @@ -529,8 +529,7 @@ def _apply_update( # noqa: C901 _drop_index(session_maker, "states", "states__state_changes") _drop_index(session_maker, "states", "states__significant_changes") _drop_index(session_maker, "states", "ix_states_entity_id_created") - - _create_index(session_maker, "states", "ix_states_entity_id_last_updated") + # This used to create ix_states_entity_id_last_updated, but it was removed in version 32 elif new_version == 5: # Create supporting index for States.event_id foreign key _create_index(session_maker, "states", "ix_states_event_id") @@ -541,20 +540,21 @@ def _apply_update( # noqa: C901 ["context_id CHARACTER(36)", "context_user_id CHARACTER(36)"], ) _create_index(session_maker, "events", "ix_events_context_id") - _create_index(session_maker, "events", "ix_events_context_user_id") + # This used to create ix_events_context_user_id, but it was removed in version 28 _add_columns( session_maker, "states", ["context_id CHARACTER(36)", "context_user_id CHARACTER(36)"], ) _create_index(session_maker, "states", "ix_states_context_id") - _create_index(session_maker, "states", "ix_states_context_user_id") + # This used to create ix_states_context_user_id, but it was removed in version 28 elif new_version == 7: - _create_index(session_maker, "states", "ix_states_entity_id") + # There used to be a ix_states_entity_id index here, but it was removed in later schema + pass elif new_version == 8: _add_columns(session_maker, "events", ["context_parent_id CHARACTER(36)"]) _add_columns(session_maker, "states", ["old_state_id INTEGER"]) - _create_index(session_maker, "events", "ix_events_context_parent_id") + # This used to create ix_events_context_parent_id, but it was removed in version 28 elif new_version == 9: # We now get the context from events with a join # since its always there on state_changed events @@ -572,7 +572,7 @@ def _apply_update( # noqa: C901 # Redundant keys on composite index: # We already have ix_states_entity_id_last_updated _drop_index(session_maker, "states", "ix_states_entity_id") - _create_index(session_maker, "events", "ix_events_event_type_time_fired") + # This used to create ix_events_event_type_time_fired, but it was removed in version 32 _drop_index(session_maker, "events", "ix_events_event_type") elif new_version == 10: # Now done in step 11 @@ -857,6 +857,11 @@ def _apply_update( # noqa: C901 _drop_index(session_maker, "events", "ix_events_event_type_time_fired") _drop_index(session_maker, "states", "ix_states_last_updated") _drop_index(session_maker, "events", "ix_events_time_fired") + elif new_version == 33: + # This index is no longer used and can cause MySQL to use the wrong index + # when querying the states table. + # https://github.com/home-assistant/core/issues/83787 + _drop_index(session_maker, "states", "ix_states_entity_id") else: raise ValueError(f"No schema migration defined for version {new_version}") From 423acfa93b857a57d6ddbd519a97bc9658ec04a6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 30 Jan 2023 14:31:27 +0100 Subject: [PATCH 0043/2810] Drop minus sign on negative zero (#86939) * Drop minus sign on negative zero * Add tests --- homeassistant/components/sensor/__init__.py | 9 +++++++++ tests/components/sensor/test_init.py | 17 +++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 351976db16..f61be0193f 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -9,6 +9,7 @@ from datetime import date, datetime, timedelta, timezone from decimal import Decimal, InvalidOperation as DecimalInvalidOperation import logging from math import ceil, floor, log10 +import re from typing import Any, Final, cast, final from homeassistant.config_entries import ConfigEntry @@ -84,6 +85,8 @@ _LOGGER: Final = logging.getLogger(__name__) ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" +NEGATIVE_ZERO_PATTERN = re.compile(r"^-(0\.?0*)$") + SCAN_INTERVAL: Final = timedelta(seconds=30) __all__ = [ @@ -647,8 +650,14 @@ class SensorEntity(Entity): unit_of_measurement, ) value = f"{converted_numerical_value:.{precision}f}" + # This can be replaced with adding the z option when we drop support for + # Python 3.10 + value = NEGATIVE_ZERO_PATTERN.sub(r"\1", value) elif precision is not None: value = f"{numerical_value:.{precision}f}" + # This can be replaced with adding the z option when we drop support for + # Python 3.10 + value = NEGATIVE_ZERO_PATTERN.sub(r"\1", value) # Validate unit of measurement used for sensors with a device class if ( diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index e65ae8ae7b..89501cf37d 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -538,6 +538,15 @@ async def test_custom_unit( "29.921", # Native precision is 3 "1013.24", # One digit of precision removed when converting ), + ( + SensorDeviceClass.ATMOSPHERIC_PRESSURE, + UnitOfPressure.INHG, + UnitOfPressure.HPA, + -0.0001, + 3, + "0.000", # Native precision is 3 + "0.00", # One digit of precision removed when converting + ), ], ) async def test_native_precision_scaling( @@ -595,6 +604,14 @@ async def test_native_precision_scaling( "1000.000", "1000.0000", ), + ( + SensorDeviceClass.DISTANCE, + UnitOfLength.KILOMETERS, + 1, + -0.04, + "-0.040", + "0.0", # Make sure minus is dropped + ), ], ) async def test_custom_precision_native_precision( From a491bfe84c4070badb612cb8fbd55609fec1024d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 30 Jan 2023 09:13:30 -0500 Subject: [PATCH 0044/2810] Bumped version to 2023.2.0b5 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 2b85d085bb..44128d3f47 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 = 2 -PATCH_VERSION: Final = "0b4" +PATCH_VERSION: Final = "0b5" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 329efa0059..381c0c34df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.2.0b4" +version = "2023.2.0b5" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 3f717ae8545e952cceaff0611adf238cb8a69a3b Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 30 Jan 2023 19:15:11 +0100 Subject: [PATCH 0045/2810] Fix MQTT discovery failing after bad config update (#86935) * Fix MQTT discovery failing after bad config update * Update last discovery payload after update success * Improve test, correct update assignment * send_discovery_done to finally-catch vol.Error * Just use try..finally * Remove extra line * use elif to avoid log confusion --- homeassistant/components/mqtt/mixins.py | 16 +++++-- tests/components/mqtt/test_discovery.py | 57 ++++++++++++++++++++++++- tests/components/mqtt/test_tag.py | 46 ++++++++++++++++++++ 3 files changed, 114 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 51d7ca401c..e90301875e 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -744,8 +744,12 @@ class MqttDiscoveryDeviceUpdate(ABC): self.log_name, discovery_hash, ) - await self.async_update(discovery_payload) - if not discovery_payload: + try: + await self.async_update(discovery_payload) + finally: + send_discovery_done(self.hass, self._discovery_data) + self._discovery_data[ATTR_DISCOVERY_PAYLOAD] = discovery_payload + elif not discovery_payload: # Unregister and clean up the current discovery instance stop_discovery_updates( self.hass, self._discovery_data, self._remove_discovery_updated @@ -869,15 +873,19 @@ class MqttDiscoveryUpdate(Entity): _LOGGER.info("Removing component: %s", self.entity_id) self._cleanup_discovery_on_remove() await _async_remove_state_and_registry_entry(self) + send_discovery_done(self.hass, self._discovery_data) elif self._discovery_update: if old_payload != self._discovery_data[ATTR_DISCOVERY_PAYLOAD]: # Non-empty, changed payload: Notify component _LOGGER.info("Updating component: %s", self.entity_id) - await self._discovery_update(payload) + try: + await self._discovery_update(payload) + finally: + send_discovery_done(self.hass, self._discovery_data) else: # Non-empty, unchanged payload: Ignore to avoid changing states _LOGGER.debug("Ignoring unchanged update for: %s", self.entity_id) - send_discovery_done(self.hass, self._discovery_data) + send_discovery_done(self.hass, self._discovery_data) if discovery_hash: assert self._discovery_data is not None diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 89a56903c3..251c0af24a 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -6,6 +6,7 @@ import re from unittest.mock import AsyncMock, call, patch import pytest +from voluptuous import MultipleInvalid from homeassistant import config_entries from homeassistant.components import mqtt @@ -1494,7 +1495,7 @@ async def test_clean_up_registry_monitoring( async def test_unique_id_collission_has_priority( hass, mqtt_mock_entry_no_yaml_config, entity_reg ): - """Test tehe unique_id collision detection has priority over registry disabled items.""" + """Test the unique_id collision detection has priority over registry disabled items.""" await mqtt_mock_entry_no_yaml_config() config = { "name": "sbfspot_12345", @@ -1534,3 +1535,57 @@ async def test_unique_id_collission_has_priority( assert entity_reg.async_get("sensor.sbfspot_12345_1") is not None # Verify the second entity is not created because it is not unique assert entity_reg.async_get("sensor.sbfspot_12345_2") is None + + +@pytest.mark.xfail(raises=MultipleInvalid) +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SENSOR]) +async def test_update_with_bad_config_not_breaks_discovery( + hass: ha.HomeAssistant, mqtt_mock_entry_no_yaml_config, entity_reg +) -> None: + """Test a bad update does not break discovery.""" + await mqtt_mock_entry_no_yaml_config() + # discover a sensor + config1 = { + "name": "sbfspot_12345", + "state_topic": "homeassistant_test/sensor/sbfspot_0/state", + } + async_fire_mqtt_message( + hass, + "homeassistant/sensor/sbfspot_0/config", + json.dumps(config1), + ) + await hass.async_block_till_done() + assert hass.states.get("sensor.sbfspot_12345") is not None + # update with a breaking config + config2 = { + "name": "sbfspot_12345", + "availability": 1, + "state_topic": "homeassistant_test/sensor/sbfspot_0/state", + } + async_fire_mqtt_message( + hass, + "homeassistant/sensor/sbfspot_0/config", + json.dumps(config2), + ) + await hass.async_block_till_done() + # update the state topic + config3 = { + "name": "sbfspot_12345", + "state_topic": "homeassistant_test/sensor/sbfspot_0/new_state_topic", + } + async_fire_mqtt_message( + hass, + "homeassistant/sensor/sbfspot_0/config", + json.dumps(config3), + ) + await hass.async_block_till_done() + + # Send an update for the state + async_fire_mqtt_message( + hass, + "homeassistant_test/sensor/sbfspot_0/new_state_topic", + "new_value", + ) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sbfspot_12345").state == "new_value" diff --git a/tests/components/mqtt/test_tag.py b/tests/components/mqtt/test_tag.py index ed33ed0dcd..9276f9658b 100644 --- a/tests/components/mqtt/test_tag.py +++ b/tests/components/mqtt/test_tag.py @@ -4,10 +4,12 @@ import json from unittest.mock import ANY, patch import pytest +from voluptuous import MultipleInvalid from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.mqtt.const import DOMAIN as MQTT_DOMAIN from homeassistant.const import Platform +from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component @@ -805,6 +807,50 @@ async def test_cleanup_device_with_entity2( assert device_entry is None +@pytest.mark.xfail(raises=MultipleInvalid) +async def test_update_with_bad_config_not_breaks_discovery( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config, + tag_mock, +) -> None: + """Test a bad update does not break discovery.""" + await mqtt_mock_entry_no_yaml_config() + config1 = { + "topic": "test-topic", + "device": {"identifiers": ["helloworld"]}, + } + config2 = { + "topic": "test-topic", + "device": {"bad_key": "some bad value"}, + } + + config3 = { + "topic": "test-topic-update", + "device": {"identifiers": ["helloworld"]}, + } + + data1 = json.dumps(config1) + data2 = json.dumps(config2) + data3 = json.dumps(config3) + + async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", data1) + await hass.async_block_till_done() + + # Update with bad identifier + async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", data2) + await hass.async_block_till_done() + + # Topic update + async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", data3) + await hass.async_block_till_done() + + # Fake tag scan. + async_fire_mqtt_message(hass, "test-topic-update", "12345") + + await hass.async_block_till_done() + tag_mock.assert_called_once_with(ANY, "12345", ANY) + + async def test_unload_entry(hass, device_reg, mqtt_mock, tag_mock, tmp_path) -> None: """Test unloading the MQTT entry.""" From 2e26a40bba097e7316c6cbf3d0506778c683d5aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 Jan 2023 08:00:34 -1000 Subject: [PATCH 0046/2810] Speed up live history setup if there is no pending data to commit (#86942) --- homeassistant/components/recorder/core.py | 2 ++ tests/components/logbook/test_websocket_api.py | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 62c1213a4a..ddca32e697 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -1044,6 +1044,8 @@ class Recorder(threading.Thread): async def async_block_till_done(self) -> None: """Async version of block_till_done.""" + if self._queue.empty() and not self._event_session_has_pending_writes(): + return event = asyncio.Event() self.queue_task(SynchronizeTask(event)) await event.wait() diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index 91d1a95f75..805213cf3b 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -2303,6 +2303,11 @@ async def test_recorder_is_far_behind(recorder_mock, hass, hass_ws_client, caplo hass.bus.async_fire("mock_event", {"device_id": device.id, "message": "1"}) await hass.async_block_till_done() + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [] + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) assert msg["id"] == 7 assert msg["type"] == "event" From 171acc22ca6f0520291ae9c23e0e386717c4c162 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 30 Jan 2023 16:15:53 +0100 Subject: [PATCH 0047/2810] Fix ThreeWayHandle sensor in Overkiz integration (#86953) Fix typo in sensor.py Fixes https://github.com/home-assistant/core/issues/85913 --- homeassistant/components/overkiz/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index e6a0e04deb..7e7c8ad262 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -386,7 +386,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ key=OverkizState.CORE_THREE_WAY_HANDLE_DIRECTION, name="Three way handle direction", device_class=SensorDeviceClass.ENUM, - options=["open", "tilt", "close"], + options=["open", "tilt", "closed"], translation_key="three_way_handle_direction", ), ] From 0713e034b9f6530a7bf8815faccf30ed74474457 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 Jan 2023 07:38:33 -1000 Subject: [PATCH 0048/2810] Silence spurious warnings about removing ix_states_entity_id with newer installs (#86961) * Silence spurious warnings about removing ix_states_entity_id with newer installs https://ptb.discord.com/channels/330944238910963714/427516175237382144/1069648035459641465 * Silence spurious warnings about removing ix_states_entity_id with newer installs https://ptb.discord.com/channels/330944238910963714/427516175237382144/1069648035459641465 --- homeassistant/components/recorder/migration.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 85459e5acc..5b1f048aea 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -274,9 +274,13 @@ def _drop_index( "Finished dropping index %s from table %s", index_name, table_name ) else: - if index_name == "ix_states_context_parent_id": - # Was only there on nightly so we do not want + if index_name in ("ix_states_entity_id", "ix_states_context_parent_id"): + # ix_states_context_parent_id was only there on nightly so we do not want # to generate log noise or issues about it. + # + # ix_states_entity_id was only there for users who upgraded from schema + # version 8 or earlier. Newer installs will not have it so we do not + # want to generate log noise or issues about it. return _LOGGER.warning( From 0b015d46c330937e8819e4e0ca65b08c10c44c7a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 30 Jan 2023 14:08:19 -0500 Subject: [PATCH 0049/2810] Fix some mobile app sensor registration/update issues (#86965) Co-authored-by: Franck Nijhof --- homeassistant/components/mobile_app/sensor.py | 6 +- .../components/mobile_app/webhook.py | 42 +++++----- tests/components/mobile_app/test_webhook.py | 80 +++++++++++++++++++ 3 files changed, 107 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/mobile_app/sensor.py b/homeassistant/components/mobile_app/sensor.py index 0d2c05df51..fc325b1b6e 100644 --- a/homeassistant/components/mobile_app/sensor.py +++ b/homeassistant/components/mobile_app/sensor.py @@ -1,6 +1,7 @@ """Sensor platform for mobile_app.""" from __future__ import annotations +from datetime import date, datetime from typing import Any from homeassistant.components.sensor import RestoreSensor, SensorDeviceClass @@ -10,6 +11,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util from .const import ( @@ -99,7 +101,7 @@ class MobileAppSensor(MobileAppEntity, RestoreSensor): self._config[ATTR_SENSOR_UOM] = last_sensor_data.native_unit_of_measurement @property - def native_value(self): + def native_value(self) -> StateType | date | datetime: """Return the state of the sensor.""" if (state := self._config[ATTR_SENSOR_STATE]) in (None, STATE_UNKNOWN): return None @@ -122,7 +124,7 @@ class MobileAppSensor(MobileAppEntity, RestoreSensor): return state @property - def native_unit_of_measurement(self): + def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement this sensor expresses itself in.""" return self._config.get(ATTR_SENSOR_UOM) diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 7a86755bc5..6476b68125 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -22,9 +22,7 @@ from homeassistant.components import ( notify as hass_notify, tag, ) -from homeassistant.components.binary_sensor import ( - DEVICE_CLASSES as BINARY_SENSOR_CLASSES, -) +from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.camera import CameraEntityFeature from homeassistant.components.device_tracker import ( ATTR_BATTERY, @@ -33,10 +31,7 @@ from homeassistant.components.device_tracker import ( ATTR_LOCATION_NAME, ) from homeassistant.components.frontend import MANIFEST_JSON -from homeassistant.components.sensor import ( - DEVICE_CLASSES as SENSOR_CLASSES, - STATE_CLASSES as SENSOSR_STATE_CLASSES, -) +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -58,7 +53,7 @@ from homeassistant.helpers import ( template, ) from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA +from homeassistant.helpers.entity import EntityCategory from homeassistant.util.decorator import Registry from .const import ( @@ -131,8 +126,7 @@ WEBHOOK_COMMANDS: Registry[ str, Callable[[HomeAssistant, ConfigEntry, Any], Coroutine[Any, Any, Response]] ] = Registry() -COMBINED_CLASSES = set(BINARY_SENSOR_CLASSES + SENSOR_CLASSES) -SENSOR_TYPES = [ATTR_SENSOR_TYPE_BINARY_SENSOR, ATTR_SENSOR_TYPE_SENSOR] +SENSOR_TYPES = (ATTR_SENSOR_TYPE_BINARY_SENSOR, ATTR_SENSOR_TYPE_SENSOR) WEBHOOK_PAYLOAD_SCHEMA = vol.Schema( { @@ -507,19 +501,27 @@ def _extract_sensor_unique_id(webhook_id: str, unique_id: str) -> str: vol.All( { vol.Optional(ATTR_SENSOR_ATTRIBUTES, default={}): dict, - vol.Optional(ATTR_SENSOR_DEVICE_CLASS): vol.All( - vol.Lower, vol.In(COMBINED_CLASSES) + vol.Optional(ATTR_SENSOR_DEVICE_CLASS): vol.Any( + None, + vol.All(vol.Lower, vol.Coerce(BinarySensorDeviceClass)), + vol.All(vol.Lower, vol.Coerce(SensorDeviceClass)), ), vol.Required(ATTR_SENSOR_NAME): cv.string, vol.Required(ATTR_SENSOR_TYPE): vol.In(SENSOR_TYPES), vol.Required(ATTR_SENSOR_UNIQUE_ID): cv.string, - vol.Optional(ATTR_SENSOR_UOM): cv.string, + vol.Optional(ATTR_SENSOR_UOM): vol.Any(None, cv.string), vol.Optional(ATTR_SENSOR_STATE, default=None): vol.Any( - None, bool, str, int, float + None, bool, int, float, str + ), + vol.Optional(ATTR_SENSOR_ENTITY_CATEGORY): vol.Any( + None, vol.Coerce(EntityCategory) + ), + vol.Optional(ATTR_SENSOR_ICON, default="mdi:cellphone"): vol.Any( + None, cv.icon + ), + vol.Optional(ATTR_SENSOR_STATE_CLASS): vol.Any( + None, vol.Coerce(SensorStateClass) ), - vol.Optional(ATTR_SENSOR_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA, - vol.Optional(ATTR_SENSOR_ICON, default="mdi:cellphone"): cv.icon, - vol.Optional(ATTR_SENSOR_STATE_CLASS): vol.In(SENSOSR_STATE_CLASSES), vol.Optional(ATTR_SENSOR_DISABLED): bool, }, _validate_state_class_sensor, @@ -619,8 +621,10 @@ async def webhook_update_sensor_states( sensor_schema_full = vol.Schema( { vol.Optional(ATTR_SENSOR_ATTRIBUTES, default={}): dict, - vol.Optional(ATTR_SENSOR_ICON, default="mdi:cellphone"): cv.icon, - vol.Required(ATTR_SENSOR_STATE): vol.Any(None, bool, str, int, float), + vol.Optional(ATTR_SENSOR_ICON, default="mdi:cellphone"): vol.Any( + None, cv.icon + ), + vol.Required(ATTR_SENSOR_STATE): vol.Any(None, bool, int, float, str), vol.Required(ATTR_SENSOR_TYPE): vol.In(SENSOR_TYPES), vol.Required(ATTR_SENSOR_UNIQUE_ID): cv.string, } diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 996471c939..a6ab5797a1 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -979,6 +979,32 @@ async def test_reregister_sensor(hass, create_registrations, webhook_client): entry = ent_reg.async_get("sensor.test_1_battery_state") assert entry.disabled_by is None + reg_resp = await webhook_client.post( + webhook_url, + json={ + "type": "register_sensor", + "data": { + "name": "New Name 2", + "state": 100, + "type": "sensor", + "unique_id": "abcd", + "state_class": None, + "device_class": None, + "entity_category": None, + "icon": None, + "unit_of_measurement": None, + }, + }, + ) + + assert reg_resp.status == HTTPStatus.CREATED + entry = ent_reg.async_get("sensor.test_1_battery_state") + assert entry.original_name == "Test 1 New Name 2" + assert entry.device_class is None + assert entry.unit_of_measurement is None + assert entry.entity_category is None + assert entry.original_icon is None + async def test_webhook_handle_conversation_process( hass, create_registrations, webhook_client, mock_agent @@ -1017,3 +1043,57 @@ async def test_webhook_handle_conversation_process( }, "conversation_id": None, } + + +async def test_sending_sensor_state(hass, create_registrations, webhook_client, caplog): + """Test that we can register and send sensor state as number and None.""" + webhook_id = create_registrations[1]["webhook_id"] + webhook_url = f"/api/webhook/{webhook_id}" + + reg_resp = await webhook_client.post( + webhook_url, + json={ + "type": "register_sensor", + "data": { + "name": "Battery State", + "state": 100, + "type": "sensor", + "unique_id": "abcd", + }, + }, + ) + + assert reg_resp.status == HTTPStatus.CREATED + + ent_reg = er.async_get(hass) + entry = ent_reg.async_get("sensor.test_1_battery_state") + assert entry.original_name == "Test 1 Battery State" + assert entry.device_class is None + assert entry.unit_of_measurement is None + assert entry.entity_category is None + assert entry.original_icon == "mdi:cellphone" + assert entry.disabled_by is None + + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_1_battery_state") + assert state is not None + assert state.state == "100" + + reg_resp = await webhook_client.post( + webhook_url, + json={ + "type": "update_sensor_states", + "data": { + "state": 50.0000, + "type": "sensor", + "unique_id": "abcd", + }, + }, + ) + + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_1_battery_state") + assert state is not None + assert state.state == "50.0" From 81de0bba22c14e5ac4182f8b882af40d52cdc2d7 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 30 Jan 2023 12:25:22 -0600 Subject: [PATCH 0050/2810] Performance improvements for Assist (#86966) * Move hassil recognize into executor * Bump hassil to 0.2.6 * Disable template parsing in name/area lists * Don't iterate over hass.config.components directly --- .../components/conversation/default_agent.py | 18 +++++++++++------- .../components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 15 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 68fa891bb8..2702372a66 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -127,7 +127,9 @@ class DefaultAgent(AbstractConversationAgent): "name": self._make_names_list(), } - result = recognize(user_input.text, lang_intents.intents, slot_lists=slot_lists) + result = await self.hass.async_add_executor_job( + recognize, user_input.text, lang_intents.intents, slot_lists + ) if result is None: _LOGGER.debug("No intent was matched for '%s'", user_input.text) return _make_error_result( @@ -214,13 +216,15 @@ class DefaultAgent(AbstractConversationAgent): async def async_get_or_load_intents(self, language: str) -> LanguageIntents | None: """Load all intents of a language with lock.""" + hass_components = set(self.hass.config.components) async with self._lang_lock[language]: return await self.hass.async_add_executor_job( - self._get_or_load_intents, - language, + self._get_or_load_intents, language, hass_components ) - def _get_or_load_intents(self, language: str) -> LanguageIntents | None: + def _get_or_load_intents( + self, language: str, hass_components: set[str] + ) -> LanguageIntents | None: """Load all intents for language (run inside executor).""" lang_intents = self._lang_intents.get(language) @@ -233,7 +237,7 @@ class DefaultAgent(AbstractConversationAgent): # Check if any new components have been loaded intents_changed = False - for component in self.hass.config.components: + for component in hass_components: if component in loaded_components: continue @@ -359,7 +363,7 @@ class DefaultAgent(AbstractConversationAgent): for alias in entry.aliases: areas.append((alias, entry.id)) - self._areas_list = TextSlotList.from_tuples(areas) + self._areas_list = TextSlotList.from_tuples(areas, allow_template=False) return self._areas_list def _make_names_list(self) -> TextSlotList: @@ -385,7 +389,7 @@ class DefaultAgent(AbstractConversationAgent): # Default name names.append((state.name, state.entity_id, context)) - self._names_list = TextSlotList.from_tuples(names) + self._names_list = TextSlotList.from_tuples(names, allow_template=False) return self._names_list def _get_error_text( diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index e7445b190e..ad49516fe5 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -2,7 +2,7 @@ "domain": "conversation", "name": "Conversation", "documentation": "https://www.home-assistant.io/integrations/conversation", - "requirements": ["hassil==0.2.5", "home-assistant-intents==2023.1.25"], + "requirements": ["hassil==0.2.6", "home-assistant-intents==2023.1.25"], "dependencies": ["http"], "codeowners": ["@home-assistant/core"], "quality_scale": "internal", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 44bc99ea03..373253def9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -21,7 +21,7 @@ cryptography==39.0.0 dbus-fast==1.84.0 fnvhash==0.1.0 hass-nabucasa==0.61.0 -hassil==0.2.5 +hassil==0.2.6 home-assistant-bluetooth==1.9.2 home-assistant-frontend==20230128.0 home-assistant-intents==2023.1.25 diff --git a/requirements_all.txt b/requirements_all.txt index 78c35f7055..4afb7ae443 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==0.2.5 +hassil==0.2.6 # homeassistant.components.tasmota hatasmota==0.6.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 13baca898c..fccfe27d37 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==0.2.5 +hassil==0.2.6 # homeassistant.components.tasmota hatasmota==0.6.3 From 0702314dcba072572ddf9cebb34287c48c0ea96b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 30 Jan 2023 14:39:37 -0500 Subject: [PATCH 0051/2810] Bumped version to 2023.2.0b6 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 44128d3f47..9f185951d1 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 = 2 -PATCH_VERSION: Final = "0b5" +PATCH_VERSION: Final = "0b6" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 381c0c34df..24f24ba97a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.2.0b5" +version = "2023.2.0b6" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From c7b944ca758da8ef2222ed349558549f9f2d0406 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 26 Jan 2023 09:48:49 -0600 Subject: [PATCH 0052/2810] Use device area id in intent matching (#86678) * Use device area id when matching * Normalize whitespace in response * Add extra test entity --- .../components/conversation/default_agent.py | 18 ++++---- homeassistant/helpers/intent.py | 22 +++++++++- tests/helpers/test_intent.py | 42 ++++++++++++++++++- 3 files changed, 71 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 2702372a66..c897d2e3b8 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -180,17 +180,19 @@ class DefaultAgent(AbstractConversationAgent): ).get(response_key) if response_str: response_template = template.Template(response_str, self.hass) - intent_response.async_set_speech( - response_template.async_render( - { - "slots": { - entity_name: entity_value.text or entity_value.value - for entity_name, entity_value in result.entities.items() - } + speech = response_template.async_render( + { + "slots": { + entity_name: entity_value.text or entity_value.value + for entity_name, entity_value in result.entities.items() } - ) + } ) + # Normalize whitespace + speech = " ".join(speech.strip().split()) + intent_response.async_set_speech(speech) + return ConversationResult( response=intent_response, conversation_id=conversation_id ) diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index f7ee7008b6..511c2b2c00 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -20,7 +20,7 @@ from homeassistant.core import Context, HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass -from . import area_registry, config_validation as cv, entity_registry +from . import area_registry, config_validation as cv, device_registry, entity_registry _LOGGER = logging.getLogger(__name__) _SlotsType = dict[str, Any] @@ -159,6 +159,7 @@ def async_match_states( states: Iterable[State] | None = None, entities: entity_registry.EntityRegistry | None = None, areas: area_registry.AreaRegistry | None = None, + devices: device_registry.DeviceRegistry | None = None, ) -> Iterable[State]: """Find states that match the constraints.""" if states is None: @@ -206,11 +207,28 @@ def async_match_states( assert area is not None, f"No area named {area_name}" if area is not None: + if devices is None: + devices = device_registry.async_get(hass) + + entity_area_ids: dict[str, str | None] = {} + for _state, entity in states_and_entities: + if entity is None: + continue + + if entity.area_id: + # Use entity's area id first + entity_area_ids[entity.id] = entity.area_id + elif entity.device_id: + # Fall back to device area if not set on entity + device = devices.async_get(entity.device_id) + if device is not None: + entity_area_ids[entity.id] = device.area_id + # Filter by area states_and_entities = [ (state, entity) for state, entity in states_and_entities - if (entity is not None) and (entity.area_id == area.id) + if (entity is not None) and (entity_area_ids.get(entity.id) == area.id) ] if name is not None: diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py index f190f41072..11a54b3b52 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -9,6 +9,7 @@ from homeassistant.core import State from homeassistant.helpers import ( area_registry, config_validation as cv, + device_registry, entity_registry, intent, ) @@ -41,7 +42,7 @@ async def test_async_match_states(hass): entities.async_update_entity(state1.entity_id, area_id=area_kitchen.id) entities.async_get_or_create( - "switch", "demo", "1234", suggested_object_id="bedroom" + "switch", "demo", "5678", suggested_object_id="bedroom" ) entities.async_update_entity( state2.entity_id, @@ -92,6 +93,45 @@ async def test_async_match_states(hass): ) +async def test_match_device_area(hass): + """Test async_match_state with a device in an area.""" + areas = area_registry.async_get(hass) + area_kitchen = areas.async_get_or_create("kitchen") + area_bedroom = areas.async_get_or_create("bedroom") + + devices = device_registry.async_get(hass) + kitchen_device = devices.async_get_or_create( + config_entry_id="1234", connections=set(), identifiers={("demo", "id-1234")} + ) + devices.async_update_device(kitchen_device.id, area_id=area_kitchen.id) + + state1 = State( + "light.kitchen", "on", attributes={ATTR_FRIENDLY_NAME: "kitchen light"} + ) + state2 = State( + "light.bedroom", "on", attributes={ATTR_FRIENDLY_NAME: "bedroom light"} + ) + state3 = State( + "light.living_room", "on", attributes={ATTR_FRIENDLY_NAME: "living room light"} + ) + entities = entity_registry.async_get(hass) + entities.async_get_or_create("light", "demo", "1234", suggested_object_id="kitchen") + entities.async_update_entity(state1.entity_id, device_id=kitchen_device.id) + + entities.async_get_or_create("light", "demo", "5678", suggested_object_id="bedroom") + entities.async_update_entity(state2.entity_id, area_id=area_bedroom.id) + + # Match on area/domain + assert [state1] == list( + intent.async_match_states( + hass, + domains={"light"}, + area_name="kitchen", + states=[state1, state2, state3], + ) + ) + + def test_async_validate_slots(): """Test async_validate_slots of IntentHandler.""" handler1 = MockIntentHandler({vol.Required("name"): cv.string}) From ba966bd0f7fcc64f37004d134c689364c4409a96 Mon Sep 17 00:00:00 2001 From: mkmer Date: Mon, 30 Jan 2023 18:04:00 -0500 Subject: [PATCH 0053/2810] Honeywell auto mode invalid attribute (#86728) fixes undefined --- homeassistant/components/honeywell/climate.py | 69 +++++++++++-------- 1 file changed, 42 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 97d6c2f888..4a77320117 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -169,10 +169,17 @@ class HoneywellUSThermostat(ClimateEntity): @property def min_temp(self) -> float: """Return the minimum temperature.""" - if self.hvac_mode in [HVACMode.COOL, HVACMode.HEAT_COOL]: + if self.hvac_mode == HVACMode.COOL: return self._device.raw_ui_data["CoolLowerSetptLimit"] if self.hvac_mode == HVACMode.HEAT: return self._device.raw_ui_data["HeatLowerSetptLimit"] + if self.hvac_mode == HVACMode.HEAT_COOL: + return min( + [ + self._device.raw_ui_data["CoolLowerSetptLimit"], + self._device.raw_ui_data["HeatLowerSetptLimit"], + ] + ) return DEFAULT_MIN_TEMP @property @@ -180,8 +187,15 @@ class HoneywellUSThermostat(ClimateEntity): """Return the maximum temperature.""" if self.hvac_mode == HVACMode.COOL: return self._device.raw_ui_data["CoolUpperSetptLimit"] - if self.hvac_mode in [HVACMode.HEAT, HVACMode.HEAT_COOL]: + if self.hvac_mode == HVACMode.HEAT: return self._device.raw_ui_data["HeatUpperSetptLimit"] + if self.hvac_mode == HVACMode.HEAT_COOL: + return max( + [ + self._device.raw_ui_data["CoolUpperSetptLimit"], + self._device.raw_ui_data["HeatUpperSetptLimit"], + ] + ) return DEFAULT_MAX_TEMP @property @@ -257,42 +271,43 @@ class HoneywellUSThermostat(ClimateEntity): # Get current mode mode = self._device.system_mode # Set hold if this is not the case - if getattr(self._device, f"hold_{mode}", None) is False: - # Get next period key - next_period_key = f"{mode.capitalize()}NextPeriod" - # Get next period raw value - next_period = self._device.raw_ui_data.get(next_period_key) + if self._device.hold_heat is False and self._device.hold_cool is False: # Get next period time - hour, minute = divmod(next_period * 15, 60) + hour_heat, minute_heat = divmod( + self._device.raw_ui_data["HEATNextPeriod"] * 15, 60 + ) + hour_cool, minute_cool = divmod( + self._device.raw_ui_data["COOLNextPeriod"] * 15, 60 + ) # Set hold time if mode in COOLING_MODES: - await self._device.set_hold_cool(datetime.time(hour, minute)) - elif mode in HEATING_MODES: - await self._device.set_hold_heat(datetime.time(hour, minute)) + await self._device.set_hold_cool( + datetime.time(hour_cool, minute_cool) + ) + if mode in HEATING_MODES: + await self._device.set_hold_heat( + datetime.time(hour_heat, minute_heat) + ) - # Set temperature - if mode in COOLING_MODES: + # Set temperature if not in auto + elif mode == "cool": await self._device.set_setpoint_cool(temperature) - elif mode in HEATING_MODES: + elif mode == "heat": await self._device.set_setpoint_heat(temperature) + elif mode == "auto": + if temperature := kwargs.get(ATTR_TARGET_TEMP_HIGH): + await self._device.set_setpoint_cool(temperature) + if temperature := kwargs.get(ATTR_TARGET_TEMP_LOW): + await self._device.set_setpoint_heat(temperature) - except AIOSomecomfort.SomeComfortError: - _LOGGER.error("Temperature %.1f out of range", temperature) + except AIOSomecomfort.SomeComfortError as err: + _LOGGER.error("Invalid temperature %.1f: %s", temperature, err) async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if {HVACMode.COOL, HVACMode.HEAT} & set(self._hvac_mode_map): await self._set_temperature(**kwargs) - try: - if HVACMode.HEAT_COOL in self._hvac_mode_map: - if temperature := kwargs.get(ATTR_TARGET_TEMP_HIGH): - await self._device.set_setpoint_cool(temperature) - if temperature := kwargs.get(ATTR_TARGET_TEMP_LOW): - await self._device.set_setpoint_heat(temperature) - except AIOSomecomfort.SomeComfortError as err: - _LOGGER.error("Invalid temperature %s: %s", temperature, err) - async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" await self._device.set_fan_mode(self._fan_mode_map[fan_mode]) @@ -322,7 +337,7 @@ class HoneywellUSThermostat(ClimateEntity): if mode in COOLING_MODES: await self._device.set_hold_cool(True) await self._device.set_setpoint_cool(self._cool_away_temp) - elif mode in HEATING_MODES: + if mode in HEATING_MODES: await self._device.set_hold_heat(True) await self._device.set_setpoint_heat(self._heat_away_temp) @@ -349,7 +364,7 @@ class HoneywellUSThermostat(ClimateEntity): # Set permanent hold if mode in COOLING_MODES: await self._device.set_hold_cool(True) - elif mode in HEATING_MODES: + if mode in HEATING_MODES: await self._device.set_hold_heat(True) except AIOSomecomfort.SomeComfortError: From 565a9735fc9424d685a2ae38a923c4db92e11fa4 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 30 Jan 2023 16:21:34 -0500 Subject: [PATCH 0054/2810] ZHA config flow cleanup (#86742) fixes undefined --- homeassistant/components/zha/config_flow.py | 25 ++++- homeassistant/components/zha/radio_manager.py | 4 +- homeassistant/components/zha/strings.json | 8 +- .../components/zha/translations/en.json | 10 +- tests/components/zha/test_config_flow.py | 103 +++++++++++++++--- 5 files changed, 125 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 8f4c9ee433..a92a2c13a7 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -37,6 +37,7 @@ DECONZ_DOMAIN = "deconz" FORMATION_STRATEGY = "formation_strategy" FORMATION_FORM_NEW_NETWORK = "form_new_network" +FORMATION_FORM_INITIAL_NETWORK = "form_initial_network" FORMATION_REUSE_SETTINGS = "reuse_settings" FORMATION_CHOOSE_AUTOMATIC_BACKUP = "choose_automatic_backup" FORMATION_UPLOAD_MANUAL_BACKUP = "upload_manual_backup" @@ -270,8 +271,21 @@ class BaseZhaFlow(FlowHandler): strategies.append(FORMATION_REUSE_SETTINGS) strategies.append(FORMATION_UPLOAD_MANUAL_BACKUP) - strategies.append(FORMATION_FORM_NEW_NETWORK) + # Do not show "erase network settings" if there are none to erase + if self._radio_mgr.current_settings is None: + strategies.append(FORMATION_FORM_INITIAL_NETWORK) + else: + strategies.append(FORMATION_FORM_NEW_NETWORK) + + # Automatically form a new network if we're onboarding with a brand new radio + if not onboarding.async_is_onboarded(self.hass) and set(strategies) == { + FORMATION_UPLOAD_MANUAL_BACKUP, + FORMATION_FORM_INITIAL_NETWORK, + }: + return await self.async_step_form_initial_network() + + # Otherwise, let the user choose return self.async_show_menu( step_id="choose_formation_strategy", menu_options=strategies, @@ -283,6 +297,13 @@ class BaseZhaFlow(FlowHandler): """Reuse the existing network settings on the stick.""" return await self._async_create_radio_entry() + async def async_step_form_initial_network( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Form an initial network.""" + # This step exists only for translations, it does nothing new + return await self.async_step_form_new_network(user_input) + async def async_step_form_new_network( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -439,7 +460,7 @@ class ZhaConfigFlowHandler(BaseZhaFlow, config_entries.ConfigFlow, domain=DOMAIN return self.async_abort(reason="single_instance_allowed") # Without confirmation, discovery can automatically progress into parts of the - # config flow logic that interacts with hardware! + # config flow logic that interacts with hardware. if user_input is not None or not onboarding.async_is_onboarded(self.hass): # Probe the radio type if we don't have one yet if ( diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index 9b7493b9bd..c68e65fd48 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -11,7 +11,7 @@ from typing import Any import voluptuous as vol from zigpy.application import ControllerApplication import zigpy.backups -from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH +from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH, CONF_NWK_BACKUP_ENABLED from zigpy.exceptions import NetworkNotFormed from homeassistant import config_entries @@ -126,6 +126,7 @@ class ZhaRadioManager: app_config[CONF_DATABASE] = database_path app_config[CONF_DEVICE] = self.device_settings + app_config[CONF_NWK_BACKUP_ENABLED] = False app_config = self.radio_type.controller.SCHEMA(app_config) app = await self.radio_type.controller.new( @@ -206,6 +207,7 @@ class ZhaRadioManager: # The list of backups will always exist self.backups = app.backups.backups.copy() + self.backups.sort(reverse=True, key=lambda b: b.backup_time) return backup diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 36bd5a38ec..132f6ed9d9 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -31,7 +31,8 @@ "title": "Network Formation", "description": "Choose the network settings for your radio.", "menu_options": { - "form_new_network": "Erase network settings and form a new network", + "form_new_network": "Erase network settings and create a new network", + "form_initial_network": "Create a network", "reuse_settings": "Keep radio network settings", "choose_automatic_backup": "Restore an automatic backup", "upload_manual_backup": "Upload a manual backup" @@ -86,11 +87,11 @@ }, "intent_migrate": { "title": "Migrate to a new radio", - "description": "Your old radio will be factory reset. If you are using a combined Z-Wave and Zigbee adapter like the HUSBZB-1, this will only reset the Zigbee portion.\n\nDo you wish to continue?" + "description": "Before plugging in your new radio, your old radio needs to be reset. An automatic backup will be performed. If you are using a combined Z-Wave and Zigbee adapter like the HUSBZB-1, this will only reset the Zigbee portion.\n\n*Note: if you are migrating from a **ConBee/RaspBee**, make sure it is running firmware `0x26720700` or newer! Otherwise, some devices may not be controllable after migrating until they are power cycled.*\n\nDo you wish to continue?" }, "instruct_unplug": { "title": "Unplug your old radio", - "description": "Your old radio has been reset. If the hardware is no longer needed, you can now unplug it." + "description": "Your old radio has been reset. If the hardware is no longer needed, you can now unplug it.\n\nYou can now plug in your new radio." }, "choose_serial_port": { "title": "[%key:component::zha::config::step::choose_serial_port::title%]", @@ -120,6 +121,7 @@ "description": "[%key:component::zha::config::step::choose_formation_strategy::description%]", "menu_options": { "form_new_network": "[%key:component::zha::config::step::choose_formation_strategy::menu_options::form_new_network%]", + "form_initial_network": "[%key:component::zha::config::step::choose_formation_strategy::menu_options::form_initial_network%]", "reuse_settings": "[%key:component::zha::config::step::choose_formation_strategy::menu_options::reuse_settings%]", "choose_automatic_backup": "[%key:component::zha::config::step::choose_formation_strategy::menu_options::choose_automatic_backup%]", "upload_manual_backup": "[%key:component::zha::config::step::choose_formation_strategy::menu_options::upload_manual_backup%]" diff --git a/homeassistant/components/zha/translations/en.json b/homeassistant/components/zha/translations/en.json index a9d13a7a3a..59e8004ad3 100644 --- a/homeassistant/components/zha/translations/en.json +++ b/homeassistant/components/zha/translations/en.json @@ -22,7 +22,8 @@ "description": "Choose the network settings for your radio.", "menu_options": { "choose_automatic_backup": "Restore an automatic backup", - "form_new_network": "Erase network settings and form a new network", + "form_initial_network": "Create a network", + "form_new_network": "Erase network settings and create a new network", "reuse_settings": "Keep radio network settings", "upload_manual_backup": "Upload a manual backup" }, @@ -174,7 +175,8 @@ "description": "Choose the network settings for your radio.", "menu_options": { "choose_automatic_backup": "Restore an automatic backup", - "form_new_network": "Erase network settings and form a new network", + "form_initial_network": "Create a network", + "form_new_network": "Erase network settings and create a new network", "reuse_settings": "Keep radio network settings", "upload_manual_backup": "Upload a manual backup" }, @@ -192,11 +194,11 @@ "title": "Reconfigure ZHA" }, "instruct_unplug": { - "description": "Your old radio has been reset. If the hardware is no longer needed, you can now unplug it.", + "description": "Your old radio has been reset. If the hardware is no longer needed, you can now unplug it.\n\nYou can now plug in your new radio.", "title": "Unplug your old radio" }, "intent_migrate": { - "description": "Your old radio will be factory reset. If you are using a combined Z-Wave and Zigbee adapter like the HUSBZB-1, this will only reset the Zigbee portion.\n\nDo you wish to continue?", + "description": "Before plugging in your new radio, your old radio needs to be reset. An automatic backup will be performed. If you are using a combined Z-Wave and Zigbee adapter like the HUSBZB-1, this will only reset the Zigbee portion.\n\n*Note: if you are migrating from a **ConBee/RaspBee**, make sure it is running firmware `0x26720700` or newer! Otherwise, some devices may not be controllable after migrating until they are power cycled.*\n\nDo you wish to continue?", "title": "Migrate to a new radio" }, "manual_pick_radio_type": { diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index d457e0b6b8..acff888dfd 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -1,6 +1,7 @@ """Tests for ZHA config flow.""" import copy +from datetime import timedelta import json from unittest.mock import AsyncMock, MagicMock, PropertyMock, create_autospec, patch import uuid @@ -67,12 +68,27 @@ def mock_app(): @pytest.fixture -def backup(): - """Zigpy network backup with non-default settings.""" - backup = zigpy.backups.NetworkBackup() - backup.node_info.ieee = zigpy.types.EUI64.convert("AA:BB:CC:DD:11:22:33:44") +def make_backup(): + """Zigpy network backup factory that creates unique backups with each call.""" + num_calls = 0 - return backup + def inner(*, backup_time_offset=0): + nonlocal num_calls + + backup = zigpy.backups.NetworkBackup() + backup.backup_time += timedelta(seconds=backup_time_offset) + backup.node_info.ieee = zigpy.types.EUI64.convert(f"AABBCCDDEE{num_calls:06X}") + num_calls += 1 + + return backup + + return inner + + +@pytest.fixture +def backup(make_backup): + """Zigpy network backup with non-default settings.""" + return make_backup() def mock_detect_radio_type(radio_type=RadioType.ezsp, ret=True): @@ -1101,6 +1117,56 @@ async def test_formation_strategy_form_new_network(pick_radio, mock_app, hass): assert result2["type"] == FlowResultType.CREATE_ENTRY +async def test_formation_strategy_form_initial_network(pick_radio, mock_app, hass): + """Test forming a new network, with no previous settings on the radio.""" + mock_app.load_network_info = AsyncMock(side_effect=NetworkNotFormed()) + + result, port = await pick_radio(RadioType.ezsp) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"next_step_id": config_flow.FORMATION_FORM_INITIAL_NETWORK}, + ) + await hass.async_block_till_done() + + # A new network will be formed + mock_app.form_network.assert_called_once() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + + +@patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) +async def test_onboarding_auto_formation_new_hardware(mock_app, hass): + """Test auto network formation with new hardware during onboarding.""" + mock_app.load_network_info = AsyncMock(side_effect=NetworkNotFormed()) + discovery_info = usb.UsbServiceInfo( + device="/dev/ttyZIGBEE", + pid="AAAA", + vid="AAAA", + serial_number="1234", + description="zigbee radio", + manufacturer="test", + ) + + with patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=False + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USB}, data=discovery_info + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "zigbee radio" + assert result["data"] == { + "device": { + "baudrate": 115200, + "flow_control": None, + "path": "/dev/ttyZIGBEE", + }, + CONF_RADIO_TYPE: "znp", + } + + async def test_formation_strategy_reuse_settings(pick_radio, mock_app, hass): """Test reusing existing network settings.""" result, port = await pick_radio(RadioType.ezsp) @@ -1298,13 +1364,13 @@ def test_format_backup_choice(): ) @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) async def test_formation_strategy_restore_automatic_backup_ezsp( - pick_radio, mock_app, hass + pick_radio, mock_app, make_backup, hass ): """Test restoring an automatic backup (EZSP radio).""" mock_app.backups.backups = [ - MagicMock(), - MagicMock(), - MagicMock(), + make_backup(), + make_backup(), + make_backup(), ] backup = mock_app.backups.backups[1] # pick the second one backup.is_compatible_with = MagicMock(return_value=False) @@ -1347,13 +1413,13 @@ async def test_formation_strategy_restore_automatic_backup_ezsp( @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @pytest.mark.parametrize("is_advanced", [True, False]) async def test_formation_strategy_restore_automatic_backup_non_ezsp( - is_advanced, pick_radio, mock_app, hass + is_advanced, pick_radio, mock_app, make_backup, hass ): """Test restoring an automatic backup (non-EZSP radio).""" mock_app.backups.backups = [ - MagicMock(), - MagicMock(), - MagicMock(), + make_backup(backup_time_offset=5), + make_backup(backup_time_offset=-3), + make_backup(backup_time_offset=2), ] backup = mock_app.backups.backups[1] # pick the second one backup.is_compatible_with = MagicMock(return_value=False) @@ -1375,13 +1441,20 @@ async def test_formation_strategy_restore_automatic_backup_non_ezsp( assert result2["type"] == FlowResultType.FORM assert result2["step_id"] == "choose_automatic_backup" - # We must prompt for overwriting the IEEE address + # We don't prompt for overwriting the IEEE address, since only EZSP needs this assert config_flow.OVERWRITE_COORDINATOR_IEEE not in result2["data_schema"].schema + # The backup choices are ordered by date + assert result2["data_schema"].schema["choose_automatic_backup"].container == [ + f"choice:{mock_app.backups.backups[0]!r}", + f"choice:{mock_app.backups.backups[2]!r}", + f"choice:{mock_app.backups.backups[1]!r}", + ] + result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], user_input={ - config_flow.CHOOSE_AUTOMATIC_BACKUP: "choice:" + repr(backup), + config_flow.CHOOSE_AUTOMATIC_BACKUP: f"choice:{backup!r}", }, ) From 6a1710063aecbb8a35284bbd7e6e83170dc6c540 Mon Sep 17 00:00:00 2001 From: ollo69 <60491700+ollo69@users.noreply.github.com> Date: Mon, 30 Jan 2023 22:42:32 +0100 Subject: [PATCH 0055/2810] Catch AndroidTV exception on setup (#86819) fixes undefined --- .../components/androidtv/__init__.py | 34 +++++++++++++++++-- .../components/androidtv/media_player.py | 22 ++---------- tests/components/androidtv/patchers.py | 4 +-- .../components/androidtv/test_media_player.py | 16 +++++---- 4 files changed, 46 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/androidtv/__init__.py b/homeassistant/components/androidtv/__init__.py index c2d83ab05e..d10b1161da 100644 --- a/homeassistant/components/androidtv/__init__.py +++ b/homeassistant/components/androidtv/__init__.py @@ -6,6 +6,13 @@ import os from typing import Any from adb_shell.auth.keygen import keygen +from adb_shell.exceptions import ( + AdbTimeoutError, + InvalidChecksumError, + InvalidCommandError, + InvalidResponseError, + TcpTimeoutException, +) from androidtv.adb_manager.adb_manager_sync import ADBPythonSync, PythonRSASigner from androidtv.setup_async import ( AndroidTVAsync, @@ -43,6 +50,18 @@ from .const import ( SIGNAL_CONFIG_ENTITY, ) +ADB_PYTHON_EXCEPTIONS: tuple = ( + AdbTimeoutError, + BrokenPipeError, + ConnectionResetError, + ValueError, + InvalidChecksumError, + InvalidCommandError, + InvalidResponseError, + TcpTimeoutException, +) +ADB_TCP_EXCEPTIONS: tuple = (ConnectionResetError, RuntimeError) + PLATFORMS = [Platform.MEDIA_PLAYER] RELOAD_OPTIONS = [CONF_STATE_DETECTION_RULES] @@ -132,9 +151,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Android TV platform.""" state_det_rules = entry.options.get(CONF_STATE_DETECTION_RULES) - aftv, error_message = await async_connect_androidtv( - hass, entry.data, state_detection_rules=state_det_rules - ) + if CONF_ADB_SERVER_IP not in entry.data: + exceptions = ADB_PYTHON_EXCEPTIONS + else: + exceptions = ADB_TCP_EXCEPTIONS + + try: + aftv, error_message = await async_connect_androidtv( + hass, entry.data, state_detection_rules=state_det_rules + ) + except exceptions as exc: + raise ConfigEntryNotReady(exc) from exc + if not aftv: raise ConfigEntryNotReady(error_message) diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index d77c755110..f4be6d6eea 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -7,13 +7,6 @@ import functools import logging from typing import Any, Concatenate, ParamSpec, TypeVar -from adb_shell.exceptions import ( - AdbTimeoutError, - InvalidChecksumError, - InvalidCommandError, - InvalidResponseError, - TcpTimeoutException, -) from androidtv.constants import APPS, KEYS from androidtv.exceptions import LockNotAcquiredException import voluptuous as vol @@ -42,7 +35,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import get_androidtv_mac +from . import ADB_PYTHON_EXCEPTIONS, ADB_TCP_EXCEPTIONS, get_androidtv_mac from .const import ( ANDROID_DEV, ANDROID_DEV_OPT, @@ -252,19 +245,10 @@ class ADBDevice(MediaPlayerEntity): # ADB exceptions to catch if not aftv.adb_server_ip: # Using "adb_shell" (Python ADB implementation) - self.exceptions = ( - AdbTimeoutError, - BrokenPipeError, - ConnectionResetError, - ValueError, - InvalidChecksumError, - InvalidCommandError, - InvalidResponseError, - TcpTimeoutException, - ) + self.exceptions = ADB_PYTHON_EXCEPTIONS else: # Using "pure-python-adb" (communicate with ADB server) - self.exceptions = (ConnectionResetError, RuntimeError) + self.exceptions = ADB_TCP_EXCEPTIONS # Property attributes self._attr_extra_state_attributes = { diff --git a/tests/components/androidtv/patchers.py b/tests/components/androidtv/patchers.py index 31e9a9c82c..5ebd95ccac 100644 --- a/tests/components/androidtv/patchers.py +++ b/tests/components/androidtv/patchers.py @@ -111,7 +111,7 @@ def patch_connect(success): } -def patch_shell(response=None, error=False, mac_eth=False): +def patch_shell(response=None, error=False, mac_eth=False, exc=None): """Mock the `AdbDeviceTcpAsyncFake.shell` and `DeviceAsyncFake.shell` methods.""" async def shell_success(self, cmd, *args, **kwargs): @@ -128,7 +128,7 @@ def patch_shell(response=None, error=False, mac_eth=False): async def shell_fail_python(self, cmd, *args, **kwargs): """Mock the `AdbDeviceTcpAsyncFake.shell` method when it fails.""" self.shell_cmd = cmd - raise ValueError + raise exc or ValueError async def shell_fail_server(self, cmd): """Mock the `DeviceAsyncFake.shell` method when it fails.""" diff --git a/tests/components/androidtv/test_media_player.py b/tests/components/androidtv/test_media_player.py index 5aaf0e1b9c..a0d6230ed6 100644 --- a/tests/components/androidtv/test_media_player.py +++ b/tests/components/androidtv/test_media_player.py @@ -2,6 +2,7 @@ import logging from unittest.mock import Mock, patch +from adb_shell.exceptions import TcpTimeoutException as AdbShellTimeoutException from androidtv.constants import APPS as ANDROIDTV_APPS, KEYS from androidtv.exceptions import LockNotAcquiredException import pytest @@ -538,25 +539,28 @@ async def test_select_source_firetv(hass, source, expected_arg, method_patch): @pytest.mark.parametrize( - "config", + ["config", "connect"], [ - CONFIG_ANDROIDTV_DEFAULT, - CONFIG_FIRETV_DEFAULT, + (CONFIG_ANDROIDTV_DEFAULT, False), + (CONFIG_FIRETV_DEFAULT, False), + (CONFIG_ANDROIDTV_DEFAULT, True), + (CONFIG_FIRETV_DEFAULT, True), ], ) -async def test_setup_fail(hass, config): +async def test_setup_fail(hass, config, connect): """Test that the entity is not created when the ADB connection is not established.""" patch_key, entity_id, config_entry = _setup(config) config_entry.add_to_hass(hass) - with patchers.patch_connect(False)[patch_key], patchers.patch_shell( - SHELL_RESPONSE_OFF + with patchers.patch_connect(connect)[patch_key], patchers.patch_shell( + SHELL_RESPONSE_OFF, error=True, exc=AdbShellTimeoutException )[patch_key]: assert await hass.config_entries.async_setup(config_entry.entry_id) is False await hass.async_block_till_done() await async_update_entity(hass, entity_id) state = hass.states.get(entity_id) + assert config_entry.state == ConfigEntryState.SETUP_RETRY assert state is None From d39d4d6b7f38897e393f408d3eb7a45830df7c14 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 30 Jan 2023 22:10:55 +0100 Subject: [PATCH 0056/2810] Uses PolledSmartEnergySummation for ZLinky (#86960) --- homeassistant/components/zha/sensor.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index bd6161cbb1..eb952d4461 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -511,7 +511,7 @@ class PolledSmartEnergySummation(SmartEnergySummation): models={"ZLinky_TIC"}, ) class Tier1SmartEnergySummation( - SmartEnergySummation, id_suffix="tier1_summation_delivered" + PolledSmartEnergySummation, id_suffix="tier1_summation_delivered" ): """Tier 1 Smart Energy Metering summation sensor.""" @@ -524,7 +524,7 @@ class Tier1SmartEnergySummation( models={"ZLinky_TIC"}, ) class Tier2SmartEnergySummation( - SmartEnergySummation, id_suffix="tier2_summation_delivered" + PolledSmartEnergySummation, id_suffix="tier2_summation_delivered" ): """Tier 2 Smart Energy Metering summation sensor.""" @@ -537,7 +537,7 @@ class Tier2SmartEnergySummation( models={"ZLinky_TIC"}, ) class Tier3SmartEnergySummation( - SmartEnergySummation, id_suffix="tier3_summation_delivered" + PolledSmartEnergySummation, id_suffix="tier3_summation_delivered" ): """Tier 3 Smart Energy Metering summation sensor.""" @@ -550,7 +550,7 @@ class Tier3SmartEnergySummation( models={"ZLinky_TIC"}, ) class Tier4SmartEnergySummation( - SmartEnergySummation, id_suffix="tier4_summation_delivered" + PolledSmartEnergySummation, id_suffix="tier4_summation_delivered" ): """Tier 4 Smart Energy Metering summation sensor.""" @@ -563,7 +563,7 @@ class Tier4SmartEnergySummation( models={"ZLinky_TIC"}, ) class Tier5SmartEnergySummation( - SmartEnergySummation, id_suffix="tier5_summation_delivered" + PolledSmartEnergySummation, id_suffix="tier5_summation_delivered" ): """Tier 5 Smart Energy Metering summation sensor.""" @@ -576,7 +576,7 @@ class Tier5SmartEnergySummation( models={"ZLinky_TIC"}, ) class Tier6SmartEnergySummation( - SmartEnergySummation, id_suffix="tier6_summation_delivered" + PolledSmartEnergySummation, id_suffix="tier6_summation_delivered" ): """Tier 6 Smart Energy Metering summation sensor.""" From dc50a6899aca55d96c93438fd9d7c47c3e694a5e Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Mon, 30 Jan 2023 22:43:58 +0100 Subject: [PATCH 0057/2810] Fix error on empty location in ssdp messages (#86970) --- homeassistant/components/dlna_dmr/manifest.json | 2 +- homeassistant/components/dlna_dms/manifest.json | 2 +- homeassistant/components/samsungtv/manifest.json | 2 +- homeassistant/components/ssdp/manifest.json | 2 +- homeassistant/components/upnp/manifest.json | 2 +- homeassistant/components/yeelight/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index acb2cfd440..460e50d18e 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -3,7 +3,7 @@ "name": "DLNA Digital Media Renderer", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", - "requirements": ["async-upnp-client==0.33.0", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.33.1", "getmac==0.8.2"], "dependencies": ["ssdp"], "after_dependencies": ["media_source"], "ssdp": [ diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index f141b2c151..f740719596 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -3,7 +3,7 @@ "name": "DLNA Digital Media Server", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dlna_dms", - "requirements": ["async-upnp-client==0.33.0"], + "requirements": ["async-upnp-client==0.33.1"], "dependencies": ["ssdp"], "after_dependencies": ["media_source"], "ssdp": [ diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index e1c326ba36..96dd8093cf 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -8,7 +8,7 @@ "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.5.0", "wakeonlan==2.1.0", - "async-upnp-client==0.33.0" + "async-upnp-client==0.33.1" ], "ssdp": [ { diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 10f7a8e49a..4f3c56965c 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -2,7 +2,7 @@ "domain": "ssdp", "name": "Simple Service Discovery Protocol (SSDP)", "documentation": "https://www.home-assistant.io/integrations/ssdp", - "requirements": ["async-upnp-client==0.33.0"], + "requirements": ["async-upnp-client==0.33.1"], "dependencies": ["network"], "after_dependencies": ["zeroconf"], "codeowners": [], diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index ff683c883b..c5a872cd20 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -3,7 +3,7 @@ "name": "UPnP/IGD", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/upnp", - "requirements": ["async-upnp-client==0.33.0", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.33.1", "getmac==0.8.2"], "dependencies": ["network", "ssdp"], "codeowners": ["@StevenLooman"], "ssdp": [ diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index c72a9401c5..62106f99d0 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -2,7 +2,7 @@ "domain": "yeelight", "name": "Yeelight", "documentation": "https://www.home-assistant.io/integrations/yeelight", - "requirements": ["yeelight==0.7.10", "async-upnp-client==0.33.0"], + "requirements": ["yeelight==0.7.10", "async-upnp-client==0.33.1"], "codeowners": ["@zewelor", "@shenxn", "@starkillerOG", "@alexyao2015"], "config_flow": true, "dependencies": ["network"], diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 373253def9..3b29316b5b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,7 +4,7 @@ aiodiscover==1.4.13 aiohttp==3.8.1 aiohttp_cors==0.7.0 astral==2.2 -async-upnp-client==0.33.0 +async-upnp-client==0.33.1 async_timeout==4.0.2 atomicwrites-homeassistant==1.4.1 attrs==22.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 4afb7ae443..461216d072 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -371,7 +371,7 @@ asterisk_mbox==0.5.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.33.0 +async-upnp-client==0.33.1 # homeassistant.components.supla asyncpysupla==0.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fccfe27d37..0c2bbec239 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -328,7 +328,7 @@ arcam-fmj==1.0.1 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.33.0 +async-upnp-client==0.33.1 # homeassistant.components.sleepiq asyncsleepiq==1.2.3 From 6a9f06d36ea18f97f903cf1cce898e16533d1097 Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Mon, 30 Jan 2023 22:14:48 +0100 Subject: [PATCH 0058/2810] Ensure a proper scope_id is given for IPv6 addresses when initializing the SSDP component (#86975) fixes undefined --- homeassistant/components/ssdp/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index 18ed063c8b..c2f56bb7b4 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -202,7 +202,9 @@ async def async_build_source_set(hass: HomeAssistant) -> set[IPv4Address | IPv6A return { source_ip for source_ip in await network.async_get_enabled_source_ips(hass) - if not source_ip.is_loopback and not source_ip.is_global + if not source_ip.is_loopback + and not source_ip.is_global + and (source_ip.version == 6 and source_ip.scope_id or source_ip.version == 4) } From f6230e2d710edb00a21061f58c7925427c4aef1c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 30 Jan 2023 22:43:23 +0100 Subject: [PATCH 0059/2810] Allow any state class when using the precipitation device class (#86977) --- homeassistant/components/sensor/const.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 4fb6314050..c8402a28ff 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -535,10 +535,7 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass | None] SensorDeviceClass.PM25: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.POWER_FACTOR: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.POWER: {SensorStateClass.MEASUREMENT}, - SensorDeviceClass.PRECIPITATION: { - SensorStateClass.TOTAL, - SensorStateClass.TOTAL_INCREASING, - }, + SensorDeviceClass.PRECIPITATION: set(SensorStateClass), SensorDeviceClass.PRECIPITATION_INTENSITY: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.PRESSURE: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.REACTIVE_POWER: {SensorStateClass.MEASUREMENT}, From 688bba15ac5f27043ef5710f179f3a760d83cac3 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 31 Jan 2023 03:34:26 +0100 Subject: [PATCH 0060/2810] Update frontend to 20230130.0 (#86978) --- 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 0c337ca1f6..eeb0a906bc 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -2,7 +2,7 @@ "domain": "frontend", "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", - "requirements": ["home-assistant-frontend==20230128.0"], + "requirements": ["home-assistant-frontend==20230130.0"], "dependencies": [ "api", "auth", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3b29316b5b..f869419026 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==0.2.6 home-assistant-bluetooth==1.9.2 -home-assistant-frontend==20230128.0 +home-assistant-frontend==20230130.0 home-assistant-intents==2023.1.25 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index 461216d072..bd03e3fef7 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==20230128.0 +home-assistant-frontend==20230130.0 # homeassistant.components.conversation home-assistant-intents==2023.1.25 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0c2bbec239..3d7fdfbc68 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==20230128.0 +home-assistant-frontend==20230130.0 # homeassistant.components.conversation home-assistant-intents==2023.1.25 From 01dea7773a6c88efab7500288fcfb31e94f85e16 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 30 Jan 2023 21:35:27 -0500 Subject: [PATCH 0061/2810] Bump ZHA dependencies (#86979) Bump ZHA dependency bellows from 0.34.6 to 0.34.7 --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 0392368070..065f4aaf8a 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zha", "requirements": [ - "bellows==0.34.6", + "bellows==0.34.7", "pyserial==3.5", "pyserial-asyncio==0.6", "zha-quirks==0.0.92", diff --git a/requirements_all.txt b/requirements_all.txt index bd03e3fef7..ecbd5a9a51 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.6 +bellows==0.34.7 # homeassistant.components.bmw_connected_drive bimmer_connected==0.12.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3d7fdfbc68..f3bfce3cab 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.6 +bellows==0.34.7 # homeassistant.components.bmw_connected_drive bimmer_connected==0.12.0 From 29056f1bd79fb2fc1d6dca6e42b7bc9703c21281 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 30 Jan 2023 16:46:42 -0500 Subject: [PATCH 0062/2810] Check dashboard when showing reauth form (#86980) --- .../components/esphome/config_flow.py | 10 ++-- tests/components/esphome/test_config_flow.py | 53 +++++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 61eb97a365..550697d5d1 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -92,11 +92,6 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self._name = entry.title self._device_name = entry.data.get(CONF_DEVICE_NAME) - if await self._retrieve_encryption_key_from_dashboard(): - error = await self.fetch_device_info() - if error is None: - return await self._async_authenticate_or_add() - return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( @@ -105,6 +100,11 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): """Handle reauthorization flow.""" errors = {} + if await self._retrieve_encryption_key_from_dashboard(): + error = await self.fetch_device_info() + if error is None: + return await self._async_authenticate_or_add() + if user_input is not None: self._noise_psk = user_input[CONF_NOISE_PSK] error = await self.fetch_device_info() diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index a2f51f2526..f7326d05b8 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -566,6 +566,59 @@ async def test_reauth_fixed_via_dashboard_remove_password( assert len(mock_get_encryption_key.mock_calls) == 1 +async def test_reauth_fixed_via_dashboard_at_confirm( + hass, mock_client, mock_zeroconf, mock_dashboard +): + """Test reauth fixed automatically via dashboard at confirm step.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + ) + entry.add_to_hass(hass) + + mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test") + + result = await hass.config_entries.flow.async_init( + "esphome", + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, + ) + + assert result["type"] == FlowResultType.FORM, result + assert result["step_id"] == "reauth_confirm" + + mock_dashboard["configured"].append( + { + "name": "test", + "configuration": "test.yaml", + } + ) + + await dashboard.async_get_dashboard(hass).async_refresh() + + with patch( + "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key", + return_value=VALID_NOISE_PSK, + ) as mock_get_encryption_key: + # We just fetch the form + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.ABORT, result + assert result["reason"] == "reauth_successful" + assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK + + assert len(mock_get_encryption_key.mock_calls) == 1 + + async def test_reauth_confirm_invalid(hass, mock_client, mock_zeroconf): """Test reauth initiation with invalid PSK.""" entry = MockConfigEntry( From 32a7ae61293410ccb5dd850df602616c08011387 Mon Sep 17 00:00:00 2001 From: shbatm Date: Mon, 30 Jan 2023 20:36:51 -0600 Subject: [PATCH 0063/2810] Bump pyisy to 3.1.11 (#86981) * Bump pyisy to 3.1.10 * Bump pyisy to 3.1.11 --- homeassistant/components/isy994/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/isy994/manifest.json b/homeassistant/components/isy994/manifest.json index e1ba8e4e21..8fa77cd126 100644 --- a/homeassistant/components/isy994/manifest.json +++ b/homeassistant/components/isy994/manifest.json @@ -3,7 +3,7 @@ "name": "Universal Devices ISY/IoX", "integration_type": "hub", "documentation": "https://www.home-assistant.io/integrations/isy994", - "requirements": ["pyisy==3.1.9"], + "requirements": ["pyisy==3.1.11"], "codeowners": ["@bdraco", "@shbatm"], "config_flow": true, "ssdp": [ diff --git a/requirements_all.txt b/requirements_all.txt index ecbd5a9a51..b254ab847a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1702,7 +1702,7 @@ pyirishrail==0.0.2 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.1.9 +pyisy==3.1.11 # homeassistant.components.itach pyitachip2ir==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f3bfce3cab..5fe7fd1cf5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1221,7 +1221,7 @@ pyiqvia==2022.04.0 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.1.9 +pyisy==3.1.11 # homeassistant.components.kaleidescape pykaleidescape==1.0.1 From edf02b70ea75d669b7b3aa11284396252bf54a2b Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 30 Jan 2023 22:46:25 -0600 Subject: [PATCH 0064/2810] Prioritize entity names over area names in Assist matching (#86982) * Refactor async_match_states * Check entity name after state, before aliases * Give entity name matches priority over area names * Don't force result to have area * Add area alias in tests * Move name/area list creation back * Clean up PR * More clean up --- .../components/conversation/default_agent.py | 39 ++++++-- homeassistant/helpers/intent.py | 89 +++++++++++++------ tests/components/conversation/test_init.py | 49 ++++++++++ tests/helpers/test_intent.py | 8 ++ 4 files changed, 148 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index c897d2e3b8..cabf9089b1 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -11,7 +11,7 @@ import re from typing import IO, Any from hassil.intents import Intents, ResponseType, SlotList, TextSlotList -from hassil.recognize import recognize +from hassil.recognize import RecognizeResult, recognize_all from hassil.util import merge_dict from home_assistant_intents import get_intents import yaml @@ -128,7 +128,10 @@ class DefaultAgent(AbstractConversationAgent): } result = await self.hass.async_add_executor_job( - recognize, user_input.text, lang_intents.intents, slot_lists + self._recognize, + user_input, + lang_intents, + slot_lists, ) if result is None: _LOGGER.debug("No intent was matched for '%s'", user_input.text) @@ -197,6 +200,26 @@ class DefaultAgent(AbstractConversationAgent): response=intent_response, conversation_id=conversation_id ) + def _recognize( + self, + user_input: ConversationInput, + lang_intents: LanguageIntents, + slot_lists: dict[str, SlotList], + ) -> RecognizeResult | None: + """Search intents for a match to user input.""" + # Prioritize matches with entity names above area names + maybe_result: RecognizeResult | None = None + for result in recognize_all( + user_input.text, lang_intents.intents, slot_lists=slot_lists + ): + if "name" in result.entities: + return result + + # Keep looking in case an entity has the same name + maybe_result = result + + return maybe_result + async def async_reload(self, language: str | None = None): """Clear cached intents for a language.""" if language is None: @@ -373,19 +396,19 @@ class DefaultAgent(AbstractConversationAgent): if self._names_list is not None: return self._names_list states = self.hass.states.async_all() - registry = entity_registry.async_get(self.hass) + entities = entity_registry.async_get(self.hass) names = [] for state in states: context = {"domain": state.domain} - entry = registry.async_get(state.entity_id) - if entry is not None: - if entry.entity_category: + entity = entities.async_get(state.entity_id) + if entity is not None: + if entity.entity_category: # Skip configuration/diagnostic entities continue - if entry.aliases: - for alias in entry.aliases: + if entity.aliases: + for alias in entity.aliases: names.append((alias, state.entity_id, context)) # Default name diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 511c2b2c00..58252da482 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -138,15 +138,62 @@ def _has_name( if name in (state.entity_id, state.name.casefold()): return True - # Check aliases - if (entity is not None) and entity.aliases: - for alias in entity.aliases: - if name == alias.casefold(): - return True + # Check name/aliases + if (entity is None) or (not entity.aliases): + return False + + for alias in entity.aliases: + if name == alias.casefold(): + return True return False +def _find_area( + id_or_name: str, areas: area_registry.AreaRegistry +) -> area_registry.AreaEntry | None: + """Find an area by id or name, checking aliases too.""" + area = areas.async_get_area(id_or_name) or areas.async_get_area_by_name(id_or_name) + if area is not None: + return area + + # Check area aliases + for maybe_area in areas.areas.values(): + if not maybe_area.aliases: + continue + + for area_alias in maybe_area.aliases: + if id_or_name == area_alias.casefold(): + return maybe_area + + return None + + +def _filter_by_area( + states_and_entities: list[tuple[State, entity_registry.RegistryEntry | None]], + area: area_registry.AreaEntry, + devices: device_registry.DeviceRegistry, +) -> Iterable[tuple[State, entity_registry.RegistryEntry | None]]: + """Filter state/entity pairs by an area.""" + entity_area_ids: dict[str, str | None] = {} + for _state, entity in states_and_entities: + if entity is None: + continue + + if entity.area_id: + # Use entity's area id first + entity_area_ids[entity.id] = entity.area_id + elif entity.device_id: + # Fall back to device area if not set on entity + device = devices.async_get(entity.device_id) + if device is not None: + entity_area_ids[entity.id] = device.area_id + + for state, entity in states_and_entities: + if (entity is not None) and (entity_area_ids.get(entity.id) == area.id): + yield (state, entity) + + @callback @bind_hass def async_match_states( @@ -200,45 +247,29 @@ def async_match_states( if areas is None: areas = area_registry.async_get(hass) - # id or name - area = areas.async_get_area(area_name) or areas.async_get_area_by_name( - area_name - ) + area = _find_area(area_name, areas) assert area is not None, f"No area named {area_name}" if area is not None: + # Filter by states/entities by area if devices is None: devices = device_registry.async_get(hass) - entity_area_ids: dict[str, str | None] = {} - for _state, entity in states_and_entities: - if entity is None: - continue - - if entity.area_id: - # Use entity's area id first - entity_area_ids[entity.id] = entity.area_id - elif entity.device_id: - # Fall back to device area if not set on entity - device = devices.async_get(entity.device_id) - if device is not None: - entity_area_ids[entity.id] = device.area_id - - # Filter by area - states_and_entities = [ - (state, entity) - for state, entity in states_and_entities - if (entity is not None) and (entity_area_ids.get(entity.id) == area.id) - ] + states_and_entities = list(_filter_by_area(states_and_entities, area, devices)) if name is not None: + if devices is None: + devices = device_registry.async_get(hass) + # Filter by name name = name.casefold() + # Check states for state, entity in states_and_entities: if _has_name(state, entity, name): yield state break + else: # Not filtered by name for state, _entity in states_and_entities: diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index f4b386cbe4..54fed8a613 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -6,6 +6,7 @@ import pytest from homeassistant.components import conversation from homeassistant.components.cover import SERVICE_OPEN_COVER +from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import DOMAIN as HASS_DOMAIN, Context from homeassistant.helpers import ( area_registry, @@ -777,3 +778,51 @@ async def test_turn_on_area(hass, init_components): assert call.domain == HASS_DOMAIN assert call.service == "turn_on" assert call.data == {"entity_id": "light.stove"} + + +async def test_light_area_same_name(hass, init_components): + """Test turning on a light with the same name as an area.""" + entities = entity_registry.async_get(hass) + devices = device_registry.async_get(hass) + areas = area_registry.async_get(hass) + entry = MockConfigEntry(domain="test") + + device = devices.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + kitchen_area = areas.async_create("kitchen") + devices.async_update_device(device.id, area_id=kitchen_area.id) + + kitchen_light = entities.async_get_or_create( + "light", "demo", "1234", original_name="kitchen light" + ) + entities.async_update_entity(kitchen_light.entity_id, area_id=kitchen_area.id) + hass.states.async_set( + kitchen_light.entity_id, "off", attributes={ATTR_FRIENDLY_NAME: "kitchen light"} + ) + + ceiling_light = entities.async_get_or_create( + "light", "demo", "5678", original_name="ceiling light" + ) + entities.async_update_entity(ceiling_light.entity_id, area_id=kitchen_area.id) + hass.states.async_set( + ceiling_light.entity_id, "off", attributes={ATTR_FRIENDLY_NAME: "ceiling light"} + ) + + calls = async_mock_service(hass, HASS_DOMAIN, "turn_on") + + await hass.services.async_call( + "conversation", + "process", + {conversation.ATTR_TEXT: "turn on kitchen light"}, + ) + await hass.async_block_till_done() + + # Should only turn on one light instead of all lights in the kitchen + assert len(calls) == 1 + call = calls[0] + assert call.domain == HASS_DOMAIN + assert call.service == "turn_on" + assert call.data == {"entity_id": kitchen_light.entity_id} diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py index 11a54b3b52..14ada0b967 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -27,6 +27,7 @@ async def test_async_match_states(hass): """Test async_match_state helper.""" areas = area_registry.async_get(hass) area_kitchen = areas.async_get_or_create("kitchen") + areas.async_update(area_kitchen.id, aliases={"food room"}) area_bedroom = areas.async_get_or_create("bedroom") state1 = State( @@ -68,6 +69,13 @@ async def test_async_match_states(hass): ) ) + # Test area alias + assert [state1] == list( + intent.async_match_states( + hass, name="kitchen light", area_name="food room", states=[state1, state2] + ) + ) + # Wrong area assert not list( intent.async_match_states( From 876022729641f85d60bcca2b7cdef7b276c44e6f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 30 Jan 2023 21:32:52 -0500 Subject: [PATCH 0065/2810] ESPHome discovered dashboard checks reauth flows (#86993) --- homeassistant/components/esphome/dashboard.py | 12 +++-- tests/components/esphome/__init__.py | 1 + tests/components/esphome/conftest.py | 16 ++++-- tests/components/esphome/test_config_flow.py | 3 +- tests/components/esphome/test_dashboard.py | 54 ++++++++++++++++++- tests/components/esphome/test_diagnostics.py | 3 +- 6 files changed, 77 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/esphome/dashboard.py b/homeassistant/components/esphome/dashboard.py index 3ce07d683b..052d6161cf 100644 --- a/homeassistant/components/esphome/dashboard.py +++ b/homeassistant/components/esphome/dashboard.py @@ -8,7 +8,7 @@ import logging import aiohttp from esphome_dashboard_api import ConfiguredDevice, ESPHomeDashboardAPI -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -52,8 +52,14 @@ async def async_set_dashboard_info( for entry in hass.config_entries.async_entries(DOMAIN) if entry.state == ConfigEntryState.LOADED ] - if reloads: - await asyncio.gather(*reloads) + # Re-auth flows will check the dashboard for encryption key when the form is requested + reauths = [ + hass.config_entries.flow.async_configure(flow["flow_id"]) + for flow in hass.config_entries.flow.async_progress() + if flow["handler"] == DOMAIN and flow["context"]["source"] == SOURCE_REAUTH + ] + if reloads or reauths: + await asyncio.gather(*reloads, *reauths) class ESPHomeDashboard(DataUpdateCoordinator[dict[str, ConfiguredDevice]]): diff --git a/tests/components/esphome/__init__.py b/tests/components/esphome/__init__.py index 764a06f3bb..a44db03f84 100644 --- a/tests/components/esphome/__init__.py +++ b/tests/components/esphome/__init__.py @@ -3,3 +3,4 @@ DASHBOARD_SLUG = "mock-slug" DASHBOARD_HOST = "mock-host" DASHBOARD_PORT = 1234 +VALID_NOISE_PSK = "bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=" diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 6febe15389..f53e513e6b 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -7,7 +7,12 @@ from aioesphomeapi import APIClient, DeviceInfo import pytest from zeroconf import Zeroconf -from homeassistant.components.esphome import CONF_NOISE_PSK, DOMAIN, dashboard +from homeassistant.components.esphome import ( + CONF_DEVICE_NAME, + CONF_NOISE_PSK, + DOMAIN, + dashboard, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant @@ -27,9 +32,9 @@ def esphome_mock_async_zeroconf(mock_async_zeroconf): @pytest.fixture -def mock_config_entry() -> MockConfigEntry: +def mock_config_entry(hass) -> MockConfigEntry: """Return the default mocked config entry.""" - return MockConfigEntry( + config_entry = MockConfigEntry( title="ESPHome Device", domain=DOMAIN, data={ @@ -37,9 +42,12 @@ def mock_config_entry() -> MockConfigEntry: CONF_PORT: 6053, CONF_PASSWORD: "pwd", CONF_NOISE_PSK: "12345678123456781234567812345678", + CONF_DEVICE_NAME: "test", }, unique_id="11:22:33:44:55:aa", ) + config_entry.add_to_hass(hass) + return config_entry @pytest.fixture @@ -59,8 +67,6 @@ async def init_integration( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> MockConfigEntry: """Set up the ESPHome integration for testing.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index f7326d05b8..92e2df5ca3 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -24,9 +24,10 @@ from homeassistant.components.hassio import HassioServiceInfo from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.data_entry_flow import FlowResultType +from . import VALID_NOISE_PSK + from tests.common import MockConfigEntry -VALID_NOISE_PSK = "bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=" INVALID_NOISE_PSK = "lSYBYEjQI1bVL8s2Vask4YytGMj1f1epNtmoim2yuTM=" diff --git a/tests/components/esphome/test_dashboard.py b/tests/components/esphome/test_dashboard.py index 7a5486d520..c14bb06d3d 100644 --- a/tests/components/esphome/test_dashboard.py +++ b/tests/components/esphome/test_dashboard.py @@ -1,8 +1,13 @@ """Test ESPHome dashboard features.""" from unittest.mock import patch -from homeassistant.components.esphome import dashboard -from homeassistant.config_entries import ConfigEntryState +from aioesphomeapi import DeviceInfo + +from homeassistant.components.esphome import CONF_NOISE_PSK, dashboard +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.data_entry_flow import FlowResultType + +from . import VALID_NOISE_PSK async def test_new_info_reload_config_entries(hass, init_integration, mock_dashboard): @@ -20,3 +25,48 @@ async def test_new_info_reload_config_entries(hass, init_integration, mock_dashb await dashboard.async_set_dashboard_info(hass, "test-slug", "test-host", 6052) assert len(mock_setup.mock_calls) == 0 + + +async def test_new_dashboard_fix_reauth( + hass, mock_client, mock_config_entry, mock_dashboard +): + """Test config entries waiting for reauth are triggered.""" + mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test") + + with patch( + "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key", + return_value=VALID_NOISE_PSK, + ) as mock_get_encryption_key: + result = await hass.config_entries.flow.async_init( + "esphome", + context={ + "source": SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + "unique_id": mock_config_entry.unique_id, + }, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert len(mock_get_encryption_key.mock_calls) == 0 + + mock_dashboard["configured"].append( + { + "name": "test", + "configuration": "test.yaml", + } + ) + + await dashboard.async_get_dashboard(hass).async_refresh() + + with patch( + "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key", + return_value=VALID_NOISE_PSK, + ) as mock_get_encryption_key, patch( + "homeassistant.components.esphome.async_setup_entry", return_value=True + ) as mock_setup: + await dashboard.async_set_dashboard_info(hass, "test-slug", "test-host", 6052) + await hass.async_block_till_done() + + assert len(mock_get_encryption_key.mock_calls) == 1 + assert len(mock_setup.mock_calls) == 1 + assert mock_config_entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index 959d49c4ee..6a08a47e6e 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -3,7 +3,7 @@ from aiohttp import ClientSession import pytest -from homeassistant.components.esphome import CONF_NOISE_PSK +from homeassistant.components.esphome import CONF_DEVICE_NAME, CONF_NOISE_PSK from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant @@ -25,6 +25,7 @@ async def test_diagnostics( assert isinstance(result, dict) assert result["config"]["data"] == { + CONF_DEVICE_NAME: "test", CONF_HOST: "192.168.1.2", CONF_PORT: 6053, CONF_PASSWORD: "**REDACTED**", From c9e86ccd380956064844322ab59309628841be06 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 30 Jan 2023 23:05:48 -0500 Subject: [PATCH 0066/2810] ESPHome handle remove password and no encryption (#86995) * ESPHome handle remove password and no encryption * Start reauth for invalid api password --------- Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- homeassistant/components/esphome/__init__.py | 10 ++++- .../components/esphome/config_flow.py | 13 ++++++ tests/components/esphome/test_config_flow.py | 45 +++++++++++-------- tests/components/esphome/test_dashboard.py | 7 ++- 4 files changed, 54 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 8cefac4185..4c9d8b6362 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -17,6 +17,7 @@ from aioesphomeapi import ( EntityInfo, EntityState, HomeassistantServiceCall, + InvalidAuthAPIError, InvalidEncryptionKeyAPIError, ReconnectLogic, RequiresEncryptionAPIError, @@ -347,7 +348,14 @@ async def async_setup_entry( # noqa: C901 async def on_connect_error(err: Exception) -> None: """Start reauth flow if appropriate connect error type.""" - if isinstance(err, (RequiresEncryptionAPIError, InvalidEncryptionKeyAPIError)): + if isinstance( + err, + ( + RequiresEncryptionAPIError, + InvalidEncryptionKeyAPIError, + InvalidAuthAPIError, + ), + ): entry.async_start_reauth(hass) reconnect_logic = ReconnectLogic( diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 550697d5d1..acc94bc7ea 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -92,6 +92,19 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self._name = entry.title self._device_name = entry.data.get(CONF_DEVICE_NAME) + # Device without encryption allows fetching device info. We can then check + # if the device is no longer using a password. If we did try with a password, + # we know setting password to empty will allow us to authenticate. + error = await self.fetch_device_info() + if ( + error is None + and self._password + and self._device_info + and not self._device_info.uses_password + ): + self._password = "" + return await self._async_authenticate_or_add() + return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 92e2df5ca3..b629e60441 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -519,23 +519,14 @@ async def test_reauth_fixed_via_dashboard( assert len(mock_get_encryption_key.mock_calls) == 1 -async def test_reauth_fixed_via_dashboard_remove_password( - hass, mock_client, mock_zeroconf, mock_dashboard +async def test_reauth_fixed_via_dashboard_add_encryption_remove_password( + hass, mock_client, mock_zeroconf, mock_dashboard, mock_config_entry ): """Test reauth fixed automatically via dashboard with password removed.""" - - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_HOST: "127.0.0.1", - CONF_PORT: 6053, - CONF_PASSWORD: "hello", - CONF_DEVICE_NAME: "test", - }, + mock_client.device_info.side_effect = ( + InvalidAuthAPIError, + DeviceInfo(uses_password=False, name="test"), ) - entry.add_to_hass(hass) - - mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test") mock_dashboard["configured"].append( { @@ -554,19 +545,37 @@ async def test_reauth_fixed_via_dashboard_remove_password( "esphome", context={ "source": config_entries.SOURCE_REAUTH, - "entry_id": entry.entry_id, - "unique_id": entry.unique_id, + "entry_id": mock_config_entry.entry_id, + "unique_id": mock_config_entry.unique_id, }, ) assert result["type"] == FlowResultType.ABORT, result assert result["reason"] == "reauth_successful" - assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK - assert entry.data[CONF_PASSWORD] == "" + assert mock_config_entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK + assert mock_config_entry.data[CONF_PASSWORD] == "" assert len(mock_get_encryption_key.mock_calls) == 1 +async def test_reauth_fixed_via_remove_password(hass, mock_client, mock_config_entry): + """Test reauth fixed automatically by seeing password removed.""" + mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test") + + result = await hass.config_entries.flow.async_init( + "esphome", + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + "unique_id": mock_config_entry.unique_id, + }, + ) + + assert result["type"] == FlowResultType.ABORT, result + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_PASSWORD] == "" + + async def test_reauth_fixed_via_dashboard_at_confirm( hass, mock_client, mock_zeroconf, mock_dashboard ): diff --git a/tests/components/esphome/test_dashboard.py b/tests/components/esphome/test_dashboard.py index c14bb06d3d..0960556503 100644 --- a/tests/components/esphome/test_dashboard.py +++ b/tests/components/esphome/test_dashboard.py @@ -1,7 +1,7 @@ """Test ESPHome dashboard features.""" from unittest.mock import patch -from aioesphomeapi import DeviceInfo +from aioesphomeapi import DeviceInfo, InvalidAuthAPIError from homeassistant.components.esphome import CONF_NOISE_PSK, dashboard from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState @@ -31,7 +31,10 @@ async def test_new_dashboard_fix_reauth( hass, mock_client, mock_config_entry, mock_dashboard ): """Test config entries waiting for reauth are triggered.""" - mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test") + mock_client.device_info.side_effect = ( + InvalidAuthAPIError, + DeviceInfo(uses_password=False, name="test"), + ) with patch( "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key", From 2f896c5df8cfefce128a739c558c0f7d85e049f7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 30 Jan 2023 23:47:52 -0500 Subject: [PATCH 0067/2810] Bumped version to 2023.2.0b7 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 9f185951d1..6c42db059a 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 = 2 -PATCH_VERSION: Final = "0b6" +PATCH_VERSION: Final = "0b7" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 24f24ba97a..6ea38a4e9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.2.0b6" +version = "2023.2.0b7" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From ac6fa3275b318328d3d767288e085dad7f10f170 Mon Sep 17 00:00:00 2001 From: Michael Davie Date: Tue, 31 Jan 2023 02:56:27 -0500 Subject: [PATCH 0068/2810] Bump env_canada to 0.5.27 (#86996) fixes undefined --- 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 83c7ca455c..b7d3644d48 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -2,7 +2,7 @@ "domain": "environment_canada", "name": "Environment Canada", "documentation": "https://www.home-assistant.io/integrations/environment_canada", - "requirements": ["env_canada==0.5.22"], + "requirements": ["env_canada==0.5.27"], "codeowners": ["@gwww", "@michaeldavie"], "config_flow": true, "iot_class": "cloud_polling", diff --git a/requirements_all.txt b/requirements_all.txt index b254ab847a..255905f2e1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -658,7 +658,7 @@ enocean==0.50 enturclient==0.2.4 # homeassistant.components.environment_canada -env_canada==0.5.22 +env_canada==0.5.27 # homeassistant.components.enphase_envoy envoy_reader==0.20.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5fe7fd1cf5..4f68733a61 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -511,7 +511,7 @@ energyzero==0.3.1 enocean==0.50 # homeassistant.components.environment_canada -env_canada==0.5.22 +env_canada==0.5.27 # homeassistant.components.enphase_envoy envoy_reader==0.20.1 From be69e9579c61334a99a218ca417befed4c325ccd Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 31 Jan 2023 00:05:59 -0500 Subject: [PATCH 0069/2810] Bump ESPHome Dashboard API 1.2.3 (#86997) --- 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 b07e0f3a47..2ae066266e 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -3,7 +3,7 @@ "name": "ESPHome", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/esphome", - "requirements": ["aioesphomeapi==13.1.0", "esphome-dashboard-api==1.2.1"], + "requirements": ["aioesphomeapi==13.1.0", "esphome-dashboard-api==1.2.3"], "zeroconf": ["_esphomelib._tcp.local."], "dhcp": [{ "registered_devices": true }], "codeowners": ["@OttoWinter", "@jesserockz"], diff --git a/requirements_all.txt b/requirements_all.txt index 255905f2e1..68f87342ef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -673,7 +673,7 @@ epson-projector==0.5.0 epsonprinter==0.0.9 # homeassistant.components.esphome -esphome-dashboard-api==1.2.1 +esphome-dashboard-api==1.2.3 # homeassistant.components.netgear_lte eternalegypt==0.0.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4f68733a61..d0d8295271 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -523,7 +523,7 @@ ephem==4.1.2 epson-projector==0.5.0 # homeassistant.components.esphome -esphome-dashboard-api==1.2.1 +esphome-dashboard-api==1.2.3 # homeassistant.components.eufylife_ble eufylife_ble_client==0.1.7 From c34eb1ad9d2af2f88c12445874befc4e253d5d50 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Tue, 31 Jan 2023 09:32:39 +0100 Subject: [PATCH 0070/2810] Bump plugwise to v0.27.5 (#87001) fixes undefined --- homeassistant/components/plugwise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index e3ac555a00..a69aaae8ee 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -2,7 +2,7 @@ "domain": "plugwise", "name": "Plugwise", "documentation": "https://www.home-assistant.io/integrations/plugwise", - "requirements": ["plugwise==0.27.4"], + "requirements": ["plugwise==0.27.5"], "codeowners": ["@CoMPaTech", "@bouwew", "@brefra", "@frenck"], "zeroconf": ["_plugwise._tcp.local."], "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index 68f87342ef..0ac0b98867 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1373,7 +1373,7 @@ plexauth==0.0.6 plexwebsocket==0.0.13 # homeassistant.components.plugwise -plugwise==0.27.4 +plugwise==0.27.5 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d0d8295271..6d293def0f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1003,7 +1003,7 @@ plexauth==0.0.6 plexwebsocket==0.0.13 # homeassistant.components.plugwise -plugwise==0.27.4 +plugwise==0.27.5 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 From 1859dcf99b84e773a6b69b379e5c87e55d195388 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 31 Jan 2023 12:44:18 +0100 Subject: [PATCH 0071/2810] Only report invalid numeric value for sensors once (#87010) --- homeassistant/components/sensor/__init__.py | 31 +++++++++++---------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index f61be0193f..35ffc1c3d2 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -599,21 +599,22 @@ class SensorEntity(Entity): f"({type(value)})" ) from err # This should raise in Home Assistant Core 2023.4 - self._invalid_numeric_value_reported = True - report_issue = self._suggest_report_issue() - _LOGGER.warning( - "Sensor %s has device class %s, state class %s and unit %s " - "thus indicating it has a numeric value; however, it has the " - "non-numeric value: %s (%s); Please update your configuration " - "if your entity is manually configured, otherwise %s", - self.entity_id, - device_class, - state_class, - unit_of_measurement, - value, - type(value), - report_issue, - ) + if not self._invalid_numeric_value_reported: + self._invalid_numeric_value_reported = True + report_issue = self._suggest_report_issue() + _LOGGER.warning( + "Sensor %s has device class %s, state class %s and unit %s " + "thus indicating it has a numeric value; however, it has the " + "non-numeric value: %s (%s); Please update your configuration " + "if your entity is manually configured, otherwise %s", + self.entity_id, + device_class, + state_class, + unit_of_measurement, + value, + type(value), + report_issue, + ) return value else: numerical_value = value From 1caca9117475b4cc69c59947cb5316850c49cace Mon Sep 17 00:00:00 2001 From: mkmer Date: Tue, 31 Jan 2023 12:59:06 -0500 Subject: [PATCH 0072/2810] Honeywell Correct key name (#87018) * Correct key name * Logic error around setpoint and auto mode * Set tempurature and setpoints correctly * Only high/low in auto. --- homeassistant/components/honeywell/climate.py | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 4a77320117..0267eb32e4 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -274,10 +274,10 @@ class HoneywellUSThermostat(ClimateEntity): if self._device.hold_heat is False and self._device.hold_cool is False: # Get next period time hour_heat, minute_heat = divmod( - self._device.raw_ui_data["HEATNextPeriod"] * 15, 60 + self._device.raw_ui_data["HeatNextPeriod"] * 15, 60 ) hour_cool, minute_cool = divmod( - self._device.raw_ui_data["COOLNextPeriod"] * 15, 60 + self._device.raw_ui_data["CoolNextPeriod"] * 15, 60 ) # Set hold time if mode in COOLING_MODES: @@ -290,15 +290,10 @@ class HoneywellUSThermostat(ClimateEntity): ) # Set temperature if not in auto - elif mode == "cool": + if mode == "cool": await self._device.set_setpoint_cool(temperature) - elif mode == "heat": + if mode == "heat": await self._device.set_setpoint_heat(temperature) - elif mode == "auto": - if temperature := kwargs.get(ATTR_TARGET_TEMP_HIGH): - await self._device.set_setpoint_cool(temperature) - if temperature := kwargs.get(ATTR_TARGET_TEMP_LOW): - await self._device.set_setpoint_heat(temperature) except AIOSomecomfort.SomeComfortError as err: _LOGGER.error("Invalid temperature %.1f: %s", temperature, err) @@ -307,6 +302,14 @@ class HoneywellUSThermostat(ClimateEntity): """Set new target temperature.""" if {HVACMode.COOL, HVACMode.HEAT} & set(self._hvac_mode_map): await self._set_temperature(**kwargs) + try: + if temperature := kwargs.get(ATTR_TARGET_TEMP_HIGH): + await self._device.set_setpoint_cool(temperature) + if temperature := kwargs.get(ATTR_TARGET_TEMP_LOW): + await self._device.set_setpoint_heat(temperature) + + except AIOSomecomfort.SomeComfortError as err: + _LOGGER.error("Invalid temperature %.1f: %s", temperature, err) async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" From 2f403b712c0e45ad6c93e9e409e8045bdd8d05ef Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 31 Jan 2023 10:23:03 -0600 Subject: [PATCH 0073/2810] Bump home-assistant-intents to 2023.1.31 (#87034) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index ad49516fe5..f44bcda8f0 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -2,7 +2,7 @@ "domain": "conversation", "name": "Conversation", "documentation": "https://www.home-assistant.io/integrations/conversation", - "requirements": ["hassil==0.2.6", "home-assistant-intents==2023.1.25"], + "requirements": ["hassil==0.2.6", "home-assistant-intents==2023.1.31"], "dependencies": ["http"], "codeowners": ["@home-assistant/core"], "quality_scale": "internal", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f869419026..689b8f28f2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -24,7 +24,7 @@ hass-nabucasa==0.61.0 hassil==0.2.6 home-assistant-bluetooth==1.9.2 home-assistant-frontend==20230130.0 -home-assistant-intents==2023.1.25 +home-assistant-intents==2023.1.31 httpx==0.23.3 ifaddr==0.1.7 janus==1.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index 0ac0b98867..246c2f8dbf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -910,7 +910,7 @@ holidays==0.18.0 home-assistant-frontend==20230130.0 # homeassistant.components.conversation -home-assistant-intents==2023.1.25 +home-assistant-intents==2023.1.31 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6d293def0f..a84f9ea6e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -693,7 +693,7 @@ holidays==0.18.0 home-assistant-frontend==20230130.0 # homeassistant.components.conversation -home-assistant-intents==2023.1.25 +home-assistant-intents==2023.1.31 # homeassistant.components.home_connect homeconnect==0.7.2 From 3d6ced2a16d9f9812495b7f51e5bb4121efe678d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Jan 2023 13:42:07 -0600 Subject: [PATCH 0074/2810] Add a repair issue when using MariaDB is affected by MDEV-25020 (#87040) closes https://github.com/home-assistant/core/issues/83787 --- .../components/recorder/strings.json | 6 + .../components/recorder/translations/en.json | 6 + homeassistant/components/recorder/util.py | 72 ++++++++++- tests/components/recorder/test_util.py | 115 +++++++++++++++++- 4 files changed, 195 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/recorder/strings.json b/homeassistant/components/recorder/strings.json index 9b616372ad..7af67f10e2 100644 --- a/homeassistant/components/recorder/strings.json +++ b/homeassistant/components/recorder/strings.json @@ -7,5 +7,11 @@ "database_engine": "Database Engine", "database_version": "Database Version" } + }, + "issues": { + "maria_db_range_index_regression": { + "title": "Update MariaDB to {min_version} or later resolve a significant performance issue", + "description": "Older versions of MariaDB suffer from a significant performance regression when retrieving history data or purging the database. Update to MariaDB version {min_version} or later and restart Home Assistant. If you are using the MariaDB core add-on, make sure to update it to the latest version." + } } } diff --git a/homeassistant/components/recorder/translations/en.json b/homeassistant/components/recorder/translations/en.json index c9ceffc739..30c17b854c 100644 --- a/homeassistant/components/recorder/translations/en.json +++ b/homeassistant/components/recorder/translations/en.json @@ -1,4 +1,10 @@ { + "issues": { + "maria_db_range_index_regression": { + "description": "Older versions of MariaDB suffer from a significant performance regression when retrieving history data or purging the database. Update to MariaDB version {min_version} or later and restart Home Assistant. If you are using the MariaDB core add-on, make sure to update it to the latest version.", + "title": "Update MariaDB to {min_version} or later resolve a significant performance issue" + } + }, "system_health": { "info": { "current_recorder_run": "Current Run Start Time", diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 0e2b1f4d51..0469a71009 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -25,11 +25,11 @@ from sqlalchemy.orm.session import Session from sqlalchemy.sql.lambdas import StatementLambdaElement import voluptuous as vol -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, issue_registry as ir import homeassistant.util.dt as dt_util -from .const import DATA_INSTANCE, SQLITE_URL_PREFIX, SupportedDialect +from .const import DATA_INSTANCE, DOMAIN, SQLITE_URL_PREFIX, SupportedDialect from .db_schema import ( TABLE_RECORDER_RUNS, TABLE_SCHEMA_CHANGES, @@ -51,9 +51,35 @@ QUERY_RETRY_WAIT = 0.1 SQLITE3_POSTFIXES = ["", "-wal", "-shm"] DEFAULT_YIELD_STATES_ROWS = 32768 +# Our minimum versions for each database +# +# Older MariaDB suffers https://jira.mariadb.org/browse/MDEV-25020 +# which is fixed in 10.5.17, 10.6.9, 10.7.5, 10.8.4 +# MIN_VERSION_MARIA_DB = AwesomeVersion( "10.3.0", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER ) +RECOMMENDED_MIN_VERSION_MARIA_DB = AwesomeVersion( + "10.5.17", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER +) +MARIA_DB_106 = AwesomeVersion( + "10.6.0", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER +) +RECOMMENDED_MIN_VERSION_MARIA_DB_106 = AwesomeVersion( + "10.6.9", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER +) +MARIA_DB_107 = AwesomeVersion( + "10.7.0", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER +) +RECOMMENDED_MIN_VERSION_MARIA_DB_107 = AwesomeVersion( + "10.7.5", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER +) +MARIA_DB_108 = AwesomeVersion( + "10.8.0", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER +) +RECOMMENDED_MIN_VERSION_MARIA_DB_108 = AwesomeVersion( + "10.8.4", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER +) MIN_VERSION_MYSQL = AwesomeVersion( "8.0.0", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER ) @@ -408,6 +434,34 @@ def build_mysqldb_conv() -> dict: return {**conversions, FIELD_TYPE.DATETIME: _datetime_or_none} +@callback +def _async_create_mariadb_range_index_regression_issue( + hass: HomeAssistant, version: AwesomeVersion +) -> None: + """Create an issue for the index range regression in older MariaDB. + + The range scan issue was fixed in MariaDB 10.5.17, 10.6.9, 10.7.5, 10.8.4 and later. + """ + if version >= MARIA_DB_108: + min_version = RECOMMENDED_MIN_VERSION_MARIA_DB_108 + elif version >= MARIA_DB_107: + min_version = RECOMMENDED_MIN_VERSION_MARIA_DB_107 + elif version >= MARIA_DB_106: + min_version = RECOMMENDED_MIN_VERSION_MARIA_DB_106 + else: + min_version = RECOMMENDED_MIN_VERSION_MARIA_DB + ir.async_create_issue( + hass, + DOMAIN, + "maria_db_range_index_regression", + is_fixable=False, + severity=ir.IssueSeverity.CRITICAL, + learn_more_url="https://jira.mariadb.org/browse/MDEV-25020", + translation_key="maria_db_range_index_regression", + translation_placeholders={"min_version": str(min_version)}, + ) + + def setup_connection_for_dialect( instance: Recorder, dialect_name: str, @@ -464,6 +518,18 @@ def setup_connection_for_dialect( _fail_unsupported_version( version or version_string, "MariaDB", MIN_VERSION_MARIA_DB ) + if version and ( + (version < RECOMMENDED_MIN_VERSION_MARIA_DB) + or (MARIA_DB_106 <= version < RECOMMENDED_MIN_VERSION_MARIA_DB_106) + or (MARIA_DB_107 <= version < RECOMMENDED_MIN_VERSION_MARIA_DB_107) + or (MARIA_DB_108 <= version < RECOMMENDED_MIN_VERSION_MARIA_DB_108) + ): + instance.hass.add_job( + _async_create_mariadb_range_index_regression_issue, + instance.hass, + version, + ) + else: if not version or version < MIN_VERSION_MYSQL: _fail_unsupported_version( diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index ecdd729a16..3f5ba6d40e 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -14,7 +14,7 @@ from sqlalchemy.sql.lambdas import StatementLambdaElement from homeassistant.components import recorder from homeassistant.components.recorder import history, util -from homeassistant.components.recorder.const import SQLITE_URL_PREFIX +from homeassistant.components.recorder.const import DOMAIN, SQLITE_URL_PREFIX from homeassistant.components.recorder.db_schema import RecorderRuns from homeassistant.components.recorder.models import UnsupportedDialect from homeassistant.components.recorder.util import ( @@ -25,6 +25,7 @@ from homeassistant.components.recorder.util import ( ) from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant +from homeassistant.helpers.issue_registry import async_get as async_get_issue_registry from homeassistant.util import dt as dt_util from .common import corrupt_db_file, run_information_with_session, wait_recording_done @@ -550,6 +551,118 @@ def test_warn_unsupported_dialect(caplog, dialect, message): assert message in caplog.text +@pytest.mark.parametrize( + "mysql_version,min_version", + [ + ( + "10.5.16-MariaDB", + "10.5.17", + ), + ( + "10.6.8-MariaDB", + "10.6.9", + ), + ( + "10.7.1-MariaDB", + "10.7.5", + ), + ( + "10.8.0-MariaDB", + "10.8.4", + ), + ], +) +async def test_issue_for_mariadb_with_MDEV_25020( + hass, caplog, mysql_version, min_version +): + """Test we create an issue for MariaDB versions affected. + + See https://jira.mariadb.org/browse/MDEV-25020. + """ + instance_mock = MagicMock() + instance_mock.hass = hass + execute_args = [] + close_mock = MagicMock() + + def execute_mock(statement): + nonlocal execute_args + execute_args.append(statement) + + def fetchall_mock(): + nonlocal execute_args + if execute_args[-1] == "SELECT VERSION()": + return [[mysql_version]] + return None + + def _make_cursor_mock(*_): + return MagicMock(execute=execute_mock, close=close_mock, fetchall=fetchall_mock) + + dbapi_connection = MagicMock(cursor=_make_cursor_mock) + + await hass.async_add_executor_job( + util.setup_connection_for_dialect, + instance_mock, + "mysql", + dbapi_connection, + True, + ) + await hass.async_block_till_done() + + registry = async_get_issue_registry(hass) + issue = registry.async_get_issue(DOMAIN, "maria_db_range_index_regression") + assert issue is not None + assert issue.translation_placeholders == {"min_version": min_version} + + +@pytest.mark.parametrize( + "mysql_version", + [ + "10.5.17-MariaDB", + "10.6.9-MariaDB", + "10.7.5-MariaDB", + "10.8.4-MariaDB", + "10.9.1-MariaDB", + ], +) +async def test_no_issue_for_mariadb_with_MDEV_25020(hass, caplog, mysql_version): + """Test we do not create an issue for MariaDB versions not affected. + + See https://jira.mariadb.org/browse/MDEV-25020. + """ + instance_mock = MagicMock() + instance_mock.hass = hass + execute_args = [] + close_mock = MagicMock() + + def execute_mock(statement): + nonlocal execute_args + execute_args.append(statement) + + def fetchall_mock(): + nonlocal execute_args + if execute_args[-1] == "SELECT VERSION()": + return [[mysql_version]] + return None + + def _make_cursor_mock(*_): + return MagicMock(execute=execute_mock, close=close_mock, fetchall=fetchall_mock) + + dbapi_connection = MagicMock(cursor=_make_cursor_mock) + + await hass.async_add_executor_job( + util.setup_connection_for_dialect, + instance_mock, + "mysql", + dbapi_connection, + True, + ) + await hass.async_block_till_done() + + registry = async_get_issue_registry(hass) + issue = registry.async_get_issue(DOMAIN, "maria_db_range_index_regression") + assert issue is None + + def test_basic_sanity_check(hass_recorder, recorder_db_url): """Test the basic sanity checks with a missing table.""" if recorder_db_url.startswith("mysql://"): From c7871d13cff4aea81b19ea0a9cabe545169d3665 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 31 Jan 2023 20:37:11 +0100 Subject: [PATCH 0075/2810] Fix Yamaha MusicCast zone sleep select entity (#87041) --- .../components/yamaha_musiccast/const.py | 9 -------- .../components/yamaha_musiccast/manifest.json | 2 +- .../components/yamaha_musiccast/select.py | 23 ++----------------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/yamaha_musiccast/const.py b/homeassistant/components/yamaha_musiccast/const.py index 5984d73980..49234ac38e 100644 --- a/homeassistant/components/yamaha_musiccast/const.py +++ b/homeassistant/components/yamaha_musiccast/const.py @@ -55,12 +55,3 @@ TRANSLATION_KEY_MAPPING = { "zone_LINK_CONTROL": "zone_link_control", "zone_LINK_AUDIO_DELAY": "zone_link_audio_delay", } - -ZONE_SLEEP_STATE_MAPPING = { - "off": "off", - "30 min": "30_min", - "60 min": "60_min", - "90 min": "90_min", - "120 min": "120_min", -} -STATE_ZONE_SLEEP_MAPPING = {val: key for key, val in ZONE_SLEEP_STATE_MAPPING.items()} diff --git a/homeassistant/components/yamaha_musiccast/manifest.json b/homeassistant/components/yamaha_musiccast/manifest.json index 8c0b55def6..afcc64985d 100644 --- a/homeassistant/components/yamaha_musiccast/manifest.json +++ b/homeassistant/components/yamaha_musiccast/manifest.json @@ -3,7 +3,7 @@ "name": "MusicCast", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/yamaha_musiccast", - "requirements": ["aiomusiccast==0.14.4"], + "requirements": ["aiomusiccast==0.14.7"], "ssdp": [ { "manufacturer": "Yamaha Corporation" diff --git a/homeassistant/components/yamaha_musiccast/select.py b/homeassistant/components/yamaha_musiccast/select.py index 200d62bde3..a8ca6162c9 100644 --- a/homeassistant/components/yamaha_musiccast/select.py +++ b/homeassistant/components/yamaha_musiccast/select.py @@ -9,11 +9,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN, MusicCastCapabilityEntity, MusicCastDataUpdateCoordinator -from .const import ( - STATE_ZONE_SLEEP_MAPPING, - TRANSLATION_KEY_MAPPING, - ZONE_SLEEP_STATE_MAPPING, -) +from .const import TRANSLATION_KEY_MAPPING async def async_setup_entry( @@ -48,10 +44,6 @@ class SelectableCapapility(MusicCastCapabilityEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Select the given option.""" value = {val: key for key, val in self.capability.options.items()}[option] - # If the translation key is "zone_sleep", we need to translate - # Home Assistant state back to the MusicCast value - if self.translation_key == "zone_sleep": - value = STATE_ZONE_SLEEP_MAPPING[value] await self.capability.set(value) @property @@ -62,20 +54,9 @@ class SelectableCapapility(MusicCastCapabilityEntity, SelectEntity): @property def options(self) -> list[str]: """Return the list possible options.""" - # If the translation key is "zone_sleep", we need to translate - # the options to make them compatible with Home Assistant - if self.translation_key == "zone_sleep": - return list(STATE_ZONE_SLEEP_MAPPING) return list(self.capability.options.values()) @property def current_option(self) -> str | None: """Return the currently selected option.""" - # If the translation key is "zone_sleep", we need to translate - # the value to make it compatible with Home Assistant - if ( - value := self.capability.current - ) is not None and self.translation_key == "zone_sleep": - return ZONE_SLEEP_STATE_MAPPING[value] - - return value + return self.capability.options.get(self.capability.current) diff --git a/requirements_all.txt b/requirements_all.txt index 246c2f8dbf..ea93d704e0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -214,7 +214,7 @@ aiolyric==1.0.9 aiomodernforms==0.1.8 # homeassistant.components.yamaha_musiccast -aiomusiccast==0.14.4 +aiomusiccast==0.14.7 # homeassistant.components.nanoleaf aionanoleaf==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a84f9ea6e9..2f10742006 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -195,7 +195,7 @@ aiolyric==1.0.9 aiomodernforms==0.1.8 # homeassistant.components.yamaha_musiccast -aiomusiccast==0.14.4 +aiomusiccast==0.14.7 # homeassistant.components.nanoleaf aionanoleaf==0.2.1 From 0d3a368a1ff9680733a7a21be2a9ac322e5cc482 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 31 Jan 2023 14:37:26 -0500 Subject: [PATCH 0076/2810] Improve JSON errors from HTTP view (#87042) --- homeassistant/components/http/view.py | 13 +++++++++++-- tests/components/http/test_view.py | 15 ++++++++------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index 6ab3b2a84a..32582dbdc9 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -20,7 +20,11 @@ import voluptuous as vol from homeassistant import exceptions from homeassistant.const import CONTENT_TYPE_JSON from homeassistant.core import Context, is_callback -from homeassistant.helpers.json import JSON_ENCODE_EXCEPTIONS, json_bytes +from homeassistant.helpers.json import JSON_ENCODE_EXCEPTIONS, json_bytes, json_dumps +from homeassistant.util.json import ( + find_paths_unserializable_data, + format_unserializable_data, +) from .const import KEY_AUTHENTICATED, KEY_HASS @@ -54,7 +58,12 @@ class HomeAssistantView: try: msg = json_bytes(result) except JSON_ENCODE_EXCEPTIONS as err: - _LOGGER.error("Unable to serialize to JSON: %s\n%s", err, result) + _LOGGER.error( + "Unable to serialize to JSON. Bad data found at %s", + format_unserializable_data( + find_paths_unserializable_data(result, dump=json_dumps) + ), + ) raise HTTPInternalServerError from err response = web.Response( body=msg, diff --git a/tests/components/http/test_view.py b/tests/components/http/test_view.py index f6a2ff85d3..4709806fed 100644 --- a/tests/components/http/test_view.py +++ b/tests/components/http/test_view.py @@ -1,4 +1,5 @@ """Tests for Home Assistant View.""" +from decimal import Decimal from http import HTTPStatus import json from unittest.mock import AsyncMock, Mock @@ -32,18 +33,18 @@ def mock_request_with_stopping(): async def test_invalid_json(caplog): """Test trying to return invalid JSON.""" - view = HomeAssistantView() - with pytest.raises(HTTPInternalServerError): - view.json(rb"\ud800") + HomeAssistantView.json({"hello": Decimal("2.0")}) - assert "Unable to serialize to JSON" in caplog.text + assert ( + "Unable to serialize to JSON. Bad data found at $.hello=2.0(" + in caplog.text + ) -async def test_nan_serialized_to_null(caplog): +async def test_nan_serialized_to_null(): """Test nan serialized to null JSON.""" - view = HomeAssistantView() - response = view.json(float("NaN")) + response = HomeAssistantView.json(float("NaN")) assert json.loads(response.body.decode("utf-8")) is None From 0bae47c992cafec5868be6d5f4fbe8cb7ffe93ee Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 31 Jan 2023 15:16:21 -0500 Subject: [PATCH 0077/2810] Bumped version to 2023.2.0b8 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 6c42db059a..16582aa0ee 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 = 2 -PATCH_VERSION: Final = "0b7" +PATCH_VERSION: Final = "0b8" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 6ea38a4e9f..4bac03d7ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.2.0b7" +version = "2023.2.0b8" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From c43174ee4b87fd6175583d3f386fcc4bf346aeb5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Jan 2023 20:03:43 -0600 Subject: [PATCH 0078/2810] Ensure humidity is still exported to HomeKit when it is read-only (#87051) * Ensure humidity is still exported to HomeKit when is cannot be set We would only send humidity to HomeKit if the device supported changing the humidity * remove unrelated changes --- .../components/homekit/type_thermostats.py | 11 +++++++-- .../homekit/test_type_thermostats.py | 23 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index 24a137e495..c34e906616 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -188,8 +188,14 @@ class Thermostat(HomeAccessory): (CHAR_COOLING_THRESHOLD_TEMPERATURE, CHAR_HEATING_THRESHOLD_TEMPERATURE) ) + if ( + ATTR_CURRENT_HUMIDITY in attributes + or features & ClimateEntityFeature.TARGET_HUMIDITY + ): + self.chars.append(CHAR_CURRENT_HUMIDITY) + if features & ClimateEntityFeature.TARGET_HUMIDITY: - self.chars.extend((CHAR_TARGET_HUMIDITY, CHAR_CURRENT_HUMIDITY)) + self.chars.append(CHAR_TARGET_HUMIDITY) serv_thermostat = self.add_preload_service(SERV_THERMOSTAT, self.chars) self.set_primary_service(serv_thermostat) @@ -253,7 +259,6 @@ class Thermostat(HomeAccessory): properties={PROP_MIN_VALUE: hc_min_temp, PROP_MAX_VALUE: hc_max_temp}, ) self.char_target_humidity = None - self.char_current_humidity = None if CHAR_TARGET_HUMIDITY in self.chars: self.char_target_humidity = serv_thermostat.configure_char( CHAR_TARGET_HUMIDITY, @@ -265,6 +270,8 @@ class Thermostat(HomeAccessory): # of 0-80% properties={PROP_MIN_VALUE: min_humidity}, ) + self.char_current_humidity = None + if CHAR_CURRENT_HUMIDITY in self.chars: self.char_current_humidity = serv_thermostat.configure_char( CHAR_CURRENT_HUMIDITY, value=50 ) diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index 1dd191f530..1a18c7fe80 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -742,6 +742,29 @@ async def test_thermostat_humidity(hass, hk_driver, events): assert events[-1].data[ATTR_VALUE] == "35%" +async def test_thermostat_humidity_with_target_humidity(hass, hk_driver, events): + """Test if accessory and HA are updated accordingly with humidity without target hudmidity. + + This test is for thermostats that do not support target humidity but + have a current humidity sensor. + """ + entity_id = "climate.test" + + # support_auto = True + hass.states.async_set(entity_id, HVACMode.OFF, {ATTR_CURRENT_HUMIDITY: 40}) + await hass.async_block_till_done() + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + await acc.run() + await hass.async_block_till_done() + + assert acc.char_current_humidity.value == 40 + hass.states.async_set(entity_id, HVACMode.HEAT_COOL, {ATTR_CURRENT_HUMIDITY: 65}) + await hass.async_block_till_done() + assert acc.char_current_humidity.value == 65 + + async def test_thermostat_power_state(hass, hk_driver, events): """Test if accessory and HA are updated accordingly.""" entity_id = "climate.test" From fd3d76988e6a06e400ef4c2c8551acaa9fb85b66 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 31 Jan 2023 22:13:41 -0500 Subject: [PATCH 0079/2810] Trigger update of ESPHome update entity when static info updates (#87058) Trigger update of update entity when static info updates --- homeassistant/components/esphome/__init__.py | 5 +- .../components/esphome/entry_data.py | 8 +++- homeassistant/components/esphome/update.py | 21 ++++++++- tests/components/esphome/test_update.py | 47 ++++++++++++++++++- 4 files changed, 73 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 4c9d8b6362..d2c987d56b 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -646,9 +646,10 @@ async def platform_async_setup_entry( # Add entities to Home Assistant async_add_entities(add_entities) - signal = f"esphome_{entry.entry_id}_on_list" entry_data.cleanup_callbacks.append( - async_dispatcher_connect(hass, signal, async_list_entities) + async_dispatcher_connect( + hass, entry_data.signal_static_info_updated, async_list_entities + ) ) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 24322fdac4..b7443eea21 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -107,6 +107,11 @@ class RuntimeEntryData: return self.device_info.friendly_name return self.name + @property + def signal_static_info_updated(self) -> str: + """Return the signal to listen to for updates on static info.""" + return f"esphome_{self.entry_id}_on_list" + @callback def async_update_ble_connection_limits(self, free: int, limit: int) -> None: """Update the BLE connection limits.""" @@ -168,8 +173,7 @@ class RuntimeEntryData: await self._ensure_platforms_loaded(hass, entry, needed_platforms) # Then send dispatcher event - signal = f"esphome_{self.entry_id}_on_list" - async_dispatcher_send(hass, signal, infos) + async_dispatcher_send(hass, self.signal_static_info_updated, infos) @callback def async_subscribe_state_update( diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py index de7a746319..be525a6f3b 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -5,7 +5,7 @@ import asyncio import logging from typing import Any, cast -from aioesphomeapi import DeviceInfo as ESPHomeDeviceInfo +from aioesphomeapi import DeviceInfo as ESPHomeDeviceInfo, EntityInfo from homeassistant.components.update import ( UpdateDeviceClass, @@ -13,7 +13,7 @@ from homeassistant.components.update import ( UpdateEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo @@ -115,6 +115,23 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity): """URL to the full release notes of the latest version available.""" return "https://esphome.io/changelog/" + async def async_added_to_hass(self) -> None: + """Handle entity added to Home Assistant.""" + await super().async_added_to_hass() + + @callback + def _static_info_updated(infos: list[EntityInfo]) -> None: + """Handle static info update.""" + self.async_write_ha_state() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + self._entry_data.signal_static_info_updated, + _static_info_updated, + ) + ) + async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index a263f4ab0c..d7ad83697a 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -1,9 +1,11 @@ """Test ESPHome update entities.""" +import dataclasses from unittest.mock import Mock, patch import pytest from homeassistant.components.esphome.dashboard import async_get_dashboard +from homeassistant.helpers.dispatcher import async_dispatcher_send @pytest.fixture(autouse=True) @@ -57,8 +59,6 @@ async def test_update_entity( mock_dashboard["configured"] = devices_payload await async_get_dashboard(hass).async_refresh() - mock_config_entry.add_to_hass(hass) - with patch( "homeassistant.components.esphome.update.DomainData.get_entry_data", return_value=Mock(available=True, device_info=mock_device_info), @@ -93,3 +93,46 @@ async def test_update_entity( assert len(mock_upload.mock_calls) == 1 assert mock_upload.mock_calls[0][1][0] == "test.yaml" + + +async def test_update_static_info( + hass, + mock_config_entry, + mock_device_info, + mock_dashboard, +): + """Test ESPHome update entity.""" + mock_dashboard["configured"] = [ + { + "name": "test", + "current_version": "1.2.3", + }, + ] + await async_get_dashboard(hass).async_refresh() + + signal_static_info_updated = f"esphome_{mock_config_entry.entry_id}_on_list" + runtime_data = Mock( + available=True, + device_info=mock_device_info, + signal_static_info_updated=signal_static_info_updated, + ) + + with patch( + "homeassistant.components.esphome.update.DomainData.get_entry_data", + return_value=runtime_data, + ): + assert await hass.config_entries.async_forward_entry_setup( + mock_config_entry, "update" + ) + + state = hass.states.get("update.none_firmware") + assert state is not None + assert state.state == "on" + + runtime_data.device_info = dataclasses.replace( + runtime_data.device_info, esphome_version="1.2.3" + ) + async_dispatcher_send(hass, signal_static_info_updated, []) + + state = hass.states.get("update.none_firmware") + assert state.state == "off" From c786fe27d75c6ad7ffb836a38291b4e58f965b42 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 31 Jan 2023 23:15:23 -0500 Subject: [PATCH 0080/2810] Guard what version we can install ESPHome updates with (#87059) * Guard what version we can install ESPHome updates with * Update homeassistant/components/esphome/dashboard.py --- homeassistant/components/esphome/dashboard.py | 15 +++++++++++ homeassistant/components/esphome/update.py | 3 ++- tests/components/esphome/test_dashboard.py | 26 +++++++++++++++++++ tests/components/esphome/test_update.py | 17 +++++++++--- 4 files changed, 56 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/esphome/dashboard.py b/homeassistant/components/esphome/dashboard.py index 052d6161cf..7439f8946f 100644 --- a/homeassistant/components/esphome/dashboard.py +++ b/homeassistant/components/esphome/dashboard.py @@ -6,6 +6,7 @@ from datetime import timedelta import logging import aiohttp +from awesomeversion import AwesomeVersion from esphome_dashboard_api import ConfiguredDevice, ESPHomeDashboardAPI from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState @@ -83,6 +84,20 @@ class ESPHomeDashboard(DataUpdateCoordinator[dict[str, ConfiguredDevice]]): self.url = url self.api = ESPHomeDashboardAPI(url, session) + @property + def supports_update(self) -> bool: + """Return whether the dashboard supports updates.""" + if self.data is None: + raise RuntimeError("Data needs to be loaded first") + + if len(self.data) == 0: + return False + + esphome_version: str = next(iter(self.data.values()))["current_version"] + + # There is no January release + return AwesomeVersion(esphome_version) > AwesomeVersion("2023.1.0") + async def _async_update_data(self) -> dict: """Fetch device data.""" devices = await self.api.get_devices() diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py index be525a6f3b..7139c9e937 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -68,7 +68,6 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity): _attr_has_entity_name = True _attr_device_class = UpdateDeviceClass.FIRMWARE - _attr_supported_features = UpdateEntityFeature.INSTALL _attr_title = "ESPHome" _attr_name = "Firmware" @@ -85,6 +84,8 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity): (dr.CONNECTION_NETWORK_MAC, entry_data.device_info.mac_address) } ) + if coordinator.supports_update: + self._attr_supported_features = UpdateEntityFeature.INSTALL @property def _device_info(self) -> ESPHomeDeviceInfo: diff --git a/tests/components/esphome/test_dashboard.py b/tests/components/esphome/test_dashboard.py index 0960556503..ed2afb7e50 100644 --- a/tests/components/esphome/test_dashboard.py +++ b/tests/components/esphome/test_dashboard.py @@ -73,3 +73,29 @@ async def test_new_dashboard_fix_reauth( assert len(mock_get_encryption_key.mock_calls) == 1 assert len(mock_setup.mock_calls) == 1 assert mock_config_entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK + + +async def test_dashboard_supports_update(hass, mock_dashboard): + """Test dashboard supports update.""" + dash = dashboard.async_get_dashboard(hass) + + # No data + assert not dash.supports_update + + # supported version + mock_dashboard["configured"].append( + { + "name": "test", + "configuration": "test.yaml", + "current_version": "2023.2.0-dev", + } + ) + await dash.async_refresh() + + assert dash.supports_update + + # unsupported version + mock_dashboard["configured"][0]["current_version"] = "2023.1.0" + await dash.async_refresh() + + assert not dash.supports_update diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index d7ad83697a..9cfba03be9 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -5,6 +5,7 @@ from unittest.mock import Mock, patch import pytest from homeassistant.components.esphome.dashboard import async_get_dashboard +from homeassistant.components.update import UpdateEntityFeature from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -22,12 +23,16 @@ def stub_reconnect(): [ { "name": "test", - "current_version": "1.2.3", + "current_version": "2023.2.0-dev", "configuration": "test.yaml", } ], "on", - {"latest_version": "1.2.3", "installed_version": "1.0.0"}, + { + "latest_version": "2023.2.0-dev", + "installed_version": "1.0.0", + "supported_features": UpdateEntityFeature.INSTALL, + }, ), ( [ @@ -37,12 +42,16 @@ def stub_reconnect(): }, ], "off", - {"latest_version": "1.0.0", "installed_version": "1.0.0"}, + { + "latest_version": "1.0.0", + "installed_version": "1.0.0", + "supported_features": 0, + }, ), ( [], "unavailable", - {}, + {"supported_features": 0}, ), ], ) From d57ce25287935b85240d04cd39a8f170248f75e5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 31 Jan 2023 23:16:09 -0500 Subject: [PATCH 0081/2810] Bumped version to 2023.2.0b9 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 16582aa0ee..38bda17700 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 = 2 -PATCH_VERSION: Final = "0b8" +PATCH_VERSION: Final = "0b9" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 4bac03d7ba..e93ba800ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.2.0b8" +version = "2023.2.0b9" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From eabcfa419e8c60c72f34e6826de536e148dc76a4 Mon Sep 17 00:00:00 2001 From: mkmer Date: Thu, 26 Jan 2023 16:00:54 -0500 Subject: [PATCH 0082/2810] Bump AIOAladdinConnect to 0.1.54 (#86749) --- homeassistant/components/aladdin_connect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/aladdin_connect/manifest.json b/homeassistant/components/aladdin_connect/manifest.json index e9556cb1b3..74f1b46796 100644 --- a/homeassistant/components/aladdin_connect/manifest.json +++ b/homeassistant/components/aladdin_connect/manifest.json @@ -2,7 +2,7 @@ "domain": "aladdin_connect", "name": "Aladdin Connect", "documentation": "https://www.home-assistant.io/integrations/aladdin_connect", - "requirements": ["AIOAladdinConnect==0.1.53"], + "requirements": ["AIOAladdinConnect==0.1.54"], "codeowners": ["@mkmer"], "iot_class": "cloud_polling", "loggers": ["aladdin_connect"], diff --git a/requirements_all.txt b/requirements_all.txt index ea93d704e0..93e87a2b85 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -5,7 +5,7 @@ AEMET-OpenData==0.2.1 # homeassistant.components.aladdin_connect -AIOAladdinConnect==0.1.53 +AIOAladdinConnect==0.1.54 # homeassistant.components.adax Adax-local==0.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2f10742006..c6fcb8eb23 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.2.1 # homeassistant.components.aladdin_connect -AIOAladdinConnect==0.1.53 +AIOAladdinConnect==0.1.54 # homeassistant.components.adax Adax-local==0.1.5 From fe541583a8b27fa4de224f7e68b37cb546758243 Mon Sep 17 00:00:00 2001 From: mkmer Date: Wed, 1 Feb 2023 09:57:21 -0500 Subject: [PATCH 0083/2810] Bump AIOAladdinConnect to 0.1.55 (#87086) --- homeassistant/components/aladdin_connect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/aladdin_connect/manifest.json b/homeassistant/components/aladdin_connect/manifest.json index 74f1b46796..3cfe7a1416 100644 --- a/homeassistant/components/aladdin_connect/manifest.json +++ b/homeassistant/components/aladdin_connect/manifest.json @@ -2,7 +2,7 @@ "domain": "aladdin_connect", "name": "Aladdin Connect", "documentation": "https://www.home-assistant.io/integrations/aladdin_connect", - "requirements": ["AIOAladdinConnect==0.1.54"], + "requirements": ["AIOAladdinConnect==0.1.55"], "codeowners": ["@mkmer"], "iot_class": "cloud_polling", "loggers": ["aladdin_connect"], diff --git a/requirements_all.txt b/requirements_all.txt index 93e87a2b85..a9129c5679 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -5,7 +5,7 @@ AEMET-OpenData==0.2.1 # homeassistant.components.aladdin_connect -AIOAladdinConnect==0.1.54 +AIOAladdinConnect==0.1.55 # homeassistant.components.adax Adax-local==0.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c6fcb8eb23..686e12c538 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.2.1 # homeassistant.components.aladdin_connect -AIOAladdinConnect==0.1.54 +AIOAladdinConnect==0.1.55 # homeassistant.components.adax Adax-local==0.1.5 From a678eee31bfc9182da2917cca2128c0f5213764d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Feb 2023 11:34:40 -0600 Subject: [PATCH 0084/2810] Reduce chance of queue overflow during schema migration (#87090) Co-authored-by: Franck Nijhof --- homeassistant/components/recorder/const.py | 2 +- tests/components/recorder/test_websocket_api.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index 8db4b43e04..379185bec7 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -19,7 +19,7 @@ EVENT_RECORDER_HOURLY_STATISTICS_GENERATED = "recorder_hourly_statistics_generat CONF_DB_INTEGRITY_CHECK = "db_integrity_check" -MAX_QUEUE_BACKLOG = 40000 +MAX_QUEUE_BACKLOG = 65000 # The maximum number of rows (events) we purge in one delete statement diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 6aa76c19ec..935594d5a5 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -2033,7 +2033,7 @@ async def test_recorder_info(recorder_mock, hass, hass_ws_client): assert response["success"] assert response["result"] == { "backlog": 0, - "max_backlog": 40000, + "max_backlog": 65000, "migration_in_progress": False, "migration_is_live": False, "recording": True, From 65286d0544a47af4aad4fce4ef890278ffff9380 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 1 Feb 2023 11:48:04 -0500 Subject: [PATCH 0085/2810] Fix Assist skipping entities that are hidden or have entity category (#87096) Skipping entities that are hidden or have entity category --- .../components/conversation/default_agent.py | 12 +++-- .../conversation/test_default_agent.py | 44 +++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 tests/components/conversation/test_default_agent.py diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index cabf9089b1..2756998b3a 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -403,16 +403,20 @@ class DefaultAgent(AbstractConversationAgent): entity = entities.async_get(state.entity_id) if entity is not None: - if entity.entity_category: - # Skip configuration/diagnostic entities + if entity.entity_category or entity.hidden: + # Skip configuration/diagnostic/hidden entities continue if entity.aliases: for alias in entity.aliases: names.append((alias, state.entity_id, context)) - # Default name - names.append((state.name, state.entity_id, context)) + # Default name + names.append((state.name, state.entity_id, context)) + + else: + # Default name + names.append((state.name, state.entity_id, context)) self._names_list = TextSlotList.from_tuples(names, allow_template=False) return self._names_list diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py new file mode 100644 index 0000000000..591802f588 --- /dev/null +++ b/tests/components/conversation/test_default_agent.py @@ -0,0 +1,44 @@ +"""Test for the default agent.""" +import pytest + +from homeassistant.components import conversation +from homeassistant.core import DOMAIN as HASS_DOMAIN, Context +from homeassistant.helpers import entity, entity_registry, intent +from homeassistant.setup import async_setup_component + +from tests.common import async_mock_service + + +@pytest.fixture +async def init_components(hass): + """Initialize relevant components with empty configs.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "conversation", {}) + assert await async_setup_component(hass, "intent", {}) + + +@pytest.mark.parametrize( + "er_kwargs", + [ + {"hidden_by": entity_registry.RegistryEntryHider.USER}, + {"hidden_by": entity_registry.RegistryEntryHider.INTEGRATION}, + {"entity_category": entity.EntityCategory.CONFIG}, + {"entity_category": entity.EntityCategory.DIAGNOSTIC}, + ], +) +async def test_hidden_entities_skipped(hass, init_components, er_kwargs): + """Test we skip hidden entities.""" + + er = entity_registry.async_get(hass) + er.async_get_or_create( + "light", "demo", "1234", suggested_object_id="Test light", **er_kwargs + ) + hass.states.async_set("light.test_light", "off") + calls = async_mock_service(hass, HASS_DOMAIN, "turn_on") + result = await conversation.async_converse( + hass, "turn on test light", None, Context(), None + ) + + assert len(calls) == 0 + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH From 6c93b28374352d118532774d403cd09383a1308a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Wed, 1 Feb 2023 18:04:24 +0100 Subject: [PATCH 0086/2810] Update pyTibber to 0.26.12 (#87098) --- homeassistant/components/tibber/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 1636c5da4b..cb3c88532d 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -3,7 +3,7 @@ "domain": "tibber", "name": "Tibber", "documentation": "https://www.home-assistant.io/integrations/tibber", - "requirements": ["pyTibber==0.26.11"], + "requirements": ["pyTibber==0.26.12"], "codeowners": ["@danielhiversen"], "quality_scale": "silver", "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index a9129c5679..6429f03045 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1470,7 +1470,7 @@ pyRFXtrx==0.30.0 pySwitchmate==0.5.1 # homeassistant.components.tibber -pyTibber==0.26.11 +pyTibber==0.26.12 # homeassistant.components.dlink pyW215==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 686e12c538..40115b07dd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1073,7 +1073,7 @@ pyMetno==0.9.0 pyRFXtrx==0.30.0 # homeassistant.components.tibber -pyTibber==0.26.11 +pyTibber==0.26.12 # homeassistant.components.dlink pyW215==0.7.0 From 7028aa7dace49152c64209591df0a3ec131d8070 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 1 Feb 2023 18:06:12 +0100 Subject: [PATCH 0087/2810] Update frontend to 20230201.0 (#87099) --- 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 eeb0a906bc..b33668cde4 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -2,7 +2,7 @@ "domain": "frontend", "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", - "requirements": ["home-assistant-frontend==20230130.0"], + "requirements": ["home-assistant-frontend==20230201.0"], "dependencies": [ "api", "auth", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 689b8f28f2..9101375adb 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==0.2.6 home-assistant-bluetooth==1.9.2 -home-assistant-frontend==20230130.0 +home-assistant-frontend==20230201.0 home-assistant-intents==2023.1.31 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index 6429f03045..6ccb4d457e 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==20230130.0 +home-assistant-frontend==20230201.0 # homeassistant.components.conversation home-assistant-intents==2023.1.31 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 40115b07dd..b91f5d6d14 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==20230130.0 +home-assistant-frontend==20230201.0 # homeassistant.components.conversation home-assistant-intents==2023.1.31 From eed15bb9fa1366150aa380c69c00ff056f0224ba Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 1 Feb 2023 18:41:13 +0100 Subject: [PATCH 0088/2810] Bumped version to 2023.2.0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 38bda17700..ee5d60a32b 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 = 2 -PATCH_VERSION: Final = "0b9" +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index e93ba800ff..5c014aabd4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.2.0b9" +version = "2023.2.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 46414978066b431b4b741ce86dffade7f5d2f0b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sun, 29 Jan 2023 15:04:17 +0100 Subject: [PATCH 0089/2810] Bump isort from 5.11.4 to 5.12.0 (#86890) --- .pre-commit-config.yaml | 2 +- requirements_test_pre_commit.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 83329d8dfc..f7684e4d4d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -60,7 +60,7 @@ repos: - --configfile=tests/bandit.yaml files: ^(homeassistant|script|tests)/.+\.py$ - repo: https://github.com/PyCQA/isort - rev: 5.11.4 + rev: 5.12.0 hooks: - id: isort - repo: https://github.com/pre-commit/pre-commit-hooks diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 34b6858828..a852f1b316 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -8,7 +8,7 @@ flake8-comprehensions==3.10.1 flake8-docstrings==1.6.0 flake8-noqa==1.3.0 flake8==6.0.0 -isort==5.11.4 +isort==5.12.0 mccabe==0.7.0 pycodestyle==2.10.0 pydocstyle==6.2.3 From 8a7e2922c26cb5a972967fcbf06ec72ca6f68fbe Mon Sep 17 00:00:00 2001 From: shbatm Date: Thu, 2 Feb 2023 13:27:30 -0600 Subject: [PATCH 0090/2810] Support ISY994 Z-Wave motorized blinds as cover (#87102) --- homeassistant/components/isy994/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/isy994/const.py b/homeassistant/components/isy994/const.py index 211939f8eb..95e68c5fa1 100644 --- a/homeassistant/components/isy994/const.py +++ b/homeassistant/components/isy994/const.py @@ -255,7 +255,7 @@ NODE_FILTERS: dict[Platform, dict[str, list[str]]] = { FILTER_STATES: ["open", "closed", "closing", "opening", "stopped"], FILTER_NODE_DEF_ID: ["DimmerMotorSwitch_ADV"], FILTER_INSTEON_TYPE: [TYPE_CATEGORY_COVER], - FILTER_ZWAVE_CAT: [], + FILTER_ZWAVE_CAT: ["106", "107"], }, Platform.LIGHT: { FILTER_UOM: ["51"], From b24d0a86eeb095b8972de4c6ee1f1b01641475a5 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 2 Feb 2023 04:56:18 +0100 Subject: [PATCH 0091/2810] Bump reolink_aio to 0.3.1 (#87118) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 88e2e3b773..3516241fee 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -3,7 +3,7 @@ "name": "Reolink IP NVR/camera", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/reolink", - "requirements": ["reolink-aio==0.3.0"], + "requirements": ["reolink-aio==0.3.1"], "dependencies": ["webhook"], "codeowners": ["@starkillerOG"], "iot_class": "local_polling", diff --git a/requirements_all.txt b/requirements_all.txt index 6ccb4d457e..7c44269a09 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2227,7 +2227,7 @@ regenmaschine==2022.11.0 renault-api==0.1.11 # homeassistant.components.reolink -reolink-aio==0.3.0 +reolink-aio==0.3.1 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b91f5d6d14..e1a21fcf16 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1572,7 +1572,7 @@ regenmaschine==2022.11.0 renault-api==0.1.11 # homeassistant.components.reolink -reolink-aio==0.3.0 +reolink-aio==0.3.1 # homeassistant.components.python_script restrictedpython==6.0 From 264b6d4f777f0e4b3974e4cf2f55d4fa39631a70 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 2 Feb 2023 11:24:06 +0100 Subject: [PATCH 0092/2810] Bump reolink-aio to 0.3.2 (#87121) --- homeassistant/components/reolink/__init__.py | 18 ++----- homeassistant/components/reolink/host.py | 50 ++++++++++--------- .../components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 34 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index c20aff637e..56974d9ed8 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -9,14 +9,7 @@ import logging from aiohttp import ClientConnectorError import async_timeout -from reolink_aio.exceptions import ( - ApiError, - InvalidContentTypeError, - LoginError, - NoDataError, - ReolinkError, - UnexpectedDataError, -) +from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform @@ -48,17 +41,14 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b try: await host.async_init() - except UserNotAdmin as err: + except (UserNotAdmin, CredentialsInvalidError) as err: + await host.stop() raise ConfigEntryAuthFailed(err) from err except ( ClientConnectorError, asyncio.TimeoutError, - ApiError, - InvalidContentTypeError, - LoginError, - NoDataError, ReolinkException, - UnexpectedDataError, + ReolinkError, ) as err: await host.stop() raise ConfigEntryNotReady( diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 3e0731ac8c..582a52ae1b 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -9,7 +9,7 @@ from typing import Any import aiohttp from aiohttp.web import Request from reolink_aio.api import Host -from reolink_aio.exceptions import ReolinkError +from reolink_aio.exceptions import ReolinkError, SubscriptionError from homeassistant.components import webhook from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME @@ -76,7 +76,6 @@ class ReolinkHost: raise ReolinkSetupException("Could not get mac address") if not self._api.is_admin: - await self.stop() raise UserNotAdmin( f"User '{self._api.username}' has authorization level " f"'{self._api.user_level}', only admin users can change camera settings" @@ -182,22 +181,19 @@ class ReolinkHost: ) return - if await self._api.subscribe(self._webhook_url): - _LOGGER.debug( - "Host %s: subscribed successfully to webhook %s", - self._api.host, - self._webhook_url, - ) - else: - raise ReolinkWebhookException( - f"Host {self._api.host}: webhook subscription failed" - ) + await self._api.subscribe(self._webhook_url) + + _LOGGER.debug( + "Host %s: subscribed successfully to webhook %s", + self._api.host, + self._webhook_url, + ) async def renew(self) -> None: """Renew the subscription of motion events (lease time is 15 minutes).""" try: await self._renew() - except ReolinkWebhookException as err: + except SubscriptionError as err: if not self._lost_subscription: self._lost_subscription = True _LOGGER.error( @@ -220,25 +216,33 @@ class ReolinkHost: return timer = self._api.renewtimer + _LOGGER.debug( + "Host %s:%s should renew subscription in: %i seconds", + self._api.host, + self._api.port, + timer, + ) if timer > SUBSCRIPTION_RENEW_THRESHOLD: return if timer > 0: - if await self._api.renew(): + try: + await self._api.renew() + except SubscriptionError as err: + _LOGGER.debug( + "Host %s: error renewing Reolink subscription, " + "trying to subscribe again: %s", + self._api.host, + err, + ) + else: _LOGGER.debug( "Host %s successfully renewed Reolink subscription", self._api.host ) return - _LOGGER.debug( - "Host %s: error renewing Reolink subscription, " - "trying to subscribe again", - self._api.host, - ) - if not await self._api.subscribe(self._webhook_url): - raise ReolinkWebhookException( - f"Host {self._api.host}: webhook re-subscription failed" - ) + await self._api.subscribe(self._webhook_url) + _LOGGER.debug( "Host %s: Reolink re-subscription successful after it was expired", self._api.host, diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 3516241fee..ab94431405 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -3,7 +3,7 @@ "name": "Reolink IP NVR/camera", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/reolink", - "requirements": ["reolink-aio==0.3.1"], + "requirements": ["reolink-aio==0.3.2"], "dependencies": ["webhook"], "codeowners": ["@starkillerOG"], "iot_class": "local_polling", diff --git a/requirements_all.txt b/requirements_all.txt index 7c44269a09..f8abf9d975 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2227,7 +2227,7 @@ regenmaschine==2022.11.0 renault-api==0.1.11 # homeassistant.components.reolink -reolink-aio==0.3.1 +reolink-aio==0.3.2 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e1a21fcf16..18071e51a3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1572,7 +1572,7 @@ regenmaschine==2022.11.0 renault-api==0.1.11 # homeassistant.components.reolink -reolink-aio==0.3.1 +reolink-aio==0.3.2 # homeassistant.components.python_script restrictedpython==6.0 From ef8029ebbff4b1cb1c5eed20ab02e9ebf8f1cbf6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 2 Feb 2023 10:38:21 +0100 Subject: [PATCH 0093/2810] Fix invalid state class in renault (#87135) --- homeassistant/components/renault/sensor.py | 2 +- tests/components/renault/const.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index 679fbbd370..52538d1e87 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -257,7 +257,7 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( device_class=SensorDeviceClass.ENERGY, name="Battery available energy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL, ), RenaultSensorEntityDescription( key="battery_temperature", diff --git a/tests/components/renault/const.py b/tests/components/renault/const.py index 5db47c5d58..b15b00b825 100644 --- a/tests/components/renault/const.py +++ b/tests/components/renault/const.py @@ -139,7 +139,7 @@ MOCK_VEHICLES = { ATTR_DEVICE_CLASS: SensorDeviceClass.ENERGY, ATTR_ENTITY_ID: "sensor.reg_number_battery_available_energy", ATTR_STATE: "31", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.TOTAL, ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_available_energy", ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, }, @@ -368,7 +368,7 @@ MOCK_VEHICLES = { ATTR_DEVICE_CLASS: SensorDeviceClass.ENERGY, ATTR_ENTITY_ID: "sensor.reg_number_battery_available_energy", ATTR_STATE: "0", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.TOTAL, ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_available_energy", ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, }, @@ -597,7 +597,7 @@ MOCK_VEHICLES = { ATTR_DEVICE_CLASS: SensorDeviceClass.ENERGY, ATTR_ENTITY_ID: "sensor.reg_number_battery_available_energy", ATTR_STATE: "31", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.TOTAL, ATTR_UNIQUE_ID: "vf1aaaaa555777123_battery_available_energy", ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, }, From 517e89ab3caf13d8e0e69cf995081d0d505c6569 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 2 Feb 2023 09:44:26 +0100 Subject: [PATCH 0094/2810] Add missing converters to recorder statistics (#87137) --- homeassistant/components/recorder/statistics.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 4a39abd9f6..7a5fba6ea6 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -36,8 +36,12 @@ from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( BaseUnitConverter, + DataRateConverter, DistanceConverter, + ElectricCurrentConverter, + ElectricPotentialConverter, EnergyConverter, + InformationConverter, MassConverter, PowerConverter, PressureConverter, @@ -128,8 +132,15 @@ QUERY_STATISTIC_META = [ STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { + **{unit: DataRateConverter for unit in DataRateConverter.VALID_UNITS}, **{unit: DistanceConverter for unit in DistanceConverter.VALID_UNITS}, + **{unit: ElectricCurrentConverter for unit in ElectricCurrentConverter.VALID_UNITS}, + **{ + unit: ElectricPotentialConverter + for unit in ElectricPotentialConverter.VALID_UNITS + }, **{unit: EnergyConverter for unit in EnergyConverter.VALID_UNITS}, + **{unit: InformationConverter for unit in InformationConverter.VALID_UNITS}, **{unit: MassConverter for unit in MassConverter.VALID_UNITS}, **{unit: PowerConverter for unit in PowerConverter.VALID_UNITS}, **{unit: PressureConverter for unit in PressureConverter.VALID_UNITS}, From 56a583e6acb8b5268b1c763dfcf1824030e09257 Mon Sep 17 00:00:00 2001 From: Dmitry Vlasov Date: Thu, 2 Feb 2023 12:36:38 +0300 Subject: [PATCH 0095/2810] Add missing supported features to Z-Wave.Me siren (#87141) --- homeassistant/components/zwave_me/siren.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zwave_me/siren.py b/homeassistant/components/zwave_me/siren.py index f59face65d..c6757f61ad 100644 --- a/homeassistant/components/zwave_me/siren.py +++ b/homeassistant/components/zwave_me/siren.py @@ -1,7 +1,7 @@ """Representation of a sirenBinary.""" from typing import Any -from homeassistant.components.siren import SirenEntity +from homeassistant.components.siren import SirenEntity, SirenEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -41,6 +41,13 @@ async def async_setup_entry( class ZWaveMeSiren(ZWaveMeEntity, SirenEntity): """Representation of a ZWaveMe siren.""" + def __init__(self, controller, device): + """Initialize the device.""" + super().__init__(controller, device) + self._attr_supported_features = ( + SirenEntityFeature.TURN_ON | SirenEntityFeature.TURN_OFF + ) + @property def is_on(self) -> bool: """Return the state of the siren.""" From a58e4e0f8844dc092924c67a70d104b56f2fa309 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 2 Feb 2023 21:21:41 +0100 Subject: [PATCH 0096/2810] Reolink unsubscribe webhook when first refresh fails (#87147) * catch ValueError on webhook async_register * add ONVIF to webhook_id * Unsubscribe webhook when ConfigEntryNotReady for async_config_entry_first_refresh * Revert catching ValueError --- homeassistant/components/reolink/__init__.py | 6 +++++- homeassistant/components/reolink/host.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 56974d9ed8..f15ce90427 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -80,7 +80,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b update_interval=timedelta(seconds=DEVICE_UPDATE_INTERVAL), ) # Fetch initial data so we have data when entities subscribe - await coordinator_device_config_update.async_config_entry_first_refresh() + try: + await coordinator_device_config_update.async_config_entry_first_refresh() + except ConfigEntryNotReady as err: + await host.stop() + raise err hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = ReolinkData( host=host, diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 582a52ae1b..e44623e1a1 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -250,7 +250,7 @@ class ReolinkHost: async def register_webhook(self) -> None: """Register the webhook for motion events.""" - self.webhook_id = f"{DOMAIN}_{self.unique_id.replace(':', '')}" + self.webhook_id = f"{DOMAIN}_{self.unique_id.replace(':', '')}_ONVIF" event_id = self.webhook_id webhook.async_register( From be77d7daa5d0702073e27fcf781c03317b12e190 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Feb 2023 13:52:55 -0600 Subject: [PATCH 0097/2810] Fix statistics graphs not loading with data_rate, electric_current, voltage, information, and unitless units (#87202) * Add missing converts to recorder/statistics_during_period API This was resulting in the stats graphs not loading on the frontend * its in two places --- .../components/recorder/websocket_api.py | 49 ++++++++++--------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index c63e5bc43c..8733d67506 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -15,13 +15,18 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import JSON_DUMP from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( + DataRateConverter, DistanceConverter, + ElectricCurrentConverter, + ElectricPotentialConverter, EnergyConverter, + InformationConverter, MassConverter, PowerConverter, PressureConverter, SpeedConverter, TemperatureConverter, + UnitlessRatioConverter, VolumeConverter, ) @@ -47,6 +52,24 @@ from .util import ( _LOGGER: logging.Logger = logging.getLogger(__package__) +UNIT_SCHEMA = vol.Schema( + { + vol.Optional("data_rate"): vol.In(DataRateConverter.VALID_UNITS), + vol.Optional("distance"): vol.In(DistanceConverter.VALID_UNITS), + vol.Optional("electric_current"): vol.In(ElectricCurrentConverter.VALID_UNITS), + vol.Optional("voltage"): vol.In(ElectricPotentialConverter.VALID_UNITS), + vol.Optional("energy"): vol.In(EnergyConverter.VALID_UNITS), + vol.Optional("information"): vol.In(InformationConverter.VALID_UNITS), + vol.Optional("mass"): vol.In(MassConverter.VALID_UNITS), + vol.Optional("power"): vol.In(PowerConverter.VALID_UNITS), + vol.Optional("pressure"): vol.In(PressureConverter.VALID_UNITS), + vol.Optional("speed"): vol.In(SpeedConverter.VALID_UNITS), + vol.Optional("temperature"): vol.In(TemperatureConverter.VALID_UNITS), + vol.Optional("unitless"): vol.In(UnitlessRatioConverter.VALID_UNITS), + vol.Optional("volume"): vol.In(VolumeConverter.VALID_UNITS), + } +) + @callback def async_setup(hass: HomeAssistant) -> None: @@ -93,18 +116,7 @@ def _ws_get_statistic_during_period( vol.Optional("types"): vol.All( [vol.Any("max", "mean", "min", "change")], vol.Coerce(set) ), - vol.Optional("units"): vol.Schema( - { - vol.Optional("distance"): vol.In(DistanceConverter.VALID_UNITS), - vol.Optional("energy"): vol.In(EnergyConverter.VALID_UNITS), - vol.Optional("mass"): vol.In(MassConverter.VALID_UNITS), - vol.Optional("power"): vol.In(PowerConverter.VALID_UNITS), - vol.Optional("pressure"): vol.In(PressureConverter.VALID_UNITS), - vol.Optional("speed"): vol.In(SpeedConverter.VALID_UNITS), - vol.Optional("temperature"): vol.In(TemperatureConverter.VALID_UNITS), - vol.Optional("volume"): vol.In(VolumeConverter.VALID_UNITS), - } - ), + vol.Optional("units"): UNIT_SCHEMA, **PERIOD_SCHEMA.schema, } ) @@ -211,18 +223,7 @@ async def ws_handle_get_statistics_during_period( vol.Optional("end_time"): str, vol.Optional("statistic_ids"): [str], vol.Required("period"): vol.Any("5minute", "hour", "day", "week", "month"), - vol.Optional("units"): vol.Schema( - { - vol.Optional("distance"): vol.In(DistanceConverter.VALID_UNITS), - vol.Optional("energy"): vol.In(EnergyConverter.VALID_UNITS), - vol.Optional("mass"): vol.In(MassConverter.VALID_UNITS), - vol.Optional("power"): vol.In(PowerConverter.VALID_UNITS), - vol.Optional("pressure"): vol.In(PressureConverter.VALID_UNITS), - vol.Optional("speed"): vol.In(SpeedConverter.VALID_UNITS), - vol.Optional("temperature"): vol.In(TemperatureConverter.VALID_UNITS), - vol.Optional("volume"): vol.In(VolumeConverter.VALID_UNITS), - } - ), + vol.Optional("units"): UNIT_SCHEMA, vol.Optional("types"): vol.All( [vol.Any("last_reset", "max", "mean", "min", "state", "sum")], vol.Coerce(set), From bfcae4e07be9a3f9693f5104deea95d75bf90ef6 Mon Sep 17 00:00:00 2001 From: mkmer Date: Mon, 30 Jan 2023 07:57:14 -0500 Subject: [PATCH 0098/2810] Add Reauth config flow to honeywell (#86170) --- .../components/honeywell/__init__.py | 13 +- homeassistant/components/honeywell/climate.py | 1 - .../components/honeywell/config_flow.py | 58 +++++++ .../components/honeywell/test_config_flow.py | 145 +++++++++++++++++- 4 files changed, 204 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index 3316d2852e..c0ebcd9b1c 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -7,7 +7,7 @@ import AIOSomecomfort from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( @@ -57,15 +57,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b await client.login() await client.discover() - except AIOSomecomfort.AuthError as ex: - raise ConfigEntryNotReady( - "Failed to initialize the Honeywell client: " - "Check your configuration (username, password), " - ) from ex + except AIOSomecomfort.device.AuthError as ex: + raise ConfigEntryAuthFailed("Incorrect Password") from ex except ( - AIOSomecomfort.ConnectionError, - AIOSomecomfort.ConnectionTimeout, + AIOSomecomfort.device.ConnectionError, + AIOSomecomfort.device.ConnectionTimeout, asyncio.TimeoutError, ) as ex: raise ConfigEntryNotReady( diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 0267eb32e4..96fc30f57e 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -345,7 +345,6 @@ class HoneywellUSThermostat(ClimateEntity): await self._device.set_setpoint_heat(self._heat_away_temp) except AIOSomecomfort.SomeComfortError: - _LOGGER.error( "Temperature out of range. Mode: %s, Heat Temperature: %.1f, Cool Temperature: %.1f", mode, diff --git a/homeassistant/components/honeywell/config_flow.py b/homeassistant/components/honeywell/config_flow.py index 9f630d90fb..0f9d5663b3 100644 --- a/homeassistant/components/honeywell/config_flow.py +++ b/homeassistant/components/honeywell/config_flow.py @@ -2,6 +2,8 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping +from typing import Any import AIOSomecomfort import voluptuous as vol @@ -20,11 +22,67 @@ from .const import ( DOMAIN, ) +REAUTH_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) + class HoneywellConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a honeywell config flow.""" VERSION = 1 + entry: config_entries.ConfigEntry | None + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle re-authentication with Honeywell.""" + + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm re-authentication with Honeywell.""" + errors: dict[str, str] = {} + + if user_input: + assert self.entry is not None + password = user_input[CONF_PASSWORD] + data = { + CONF_USERNAME: self.entry.data[CONF_USERNAME], + CONF_PASSWORD: password, + } + + try: + await self.is_valid( + username=data[CONF_USERNAME], password=data[CONF_PASSWORD] + ) + + except AIOSomecomfort.AuthError: + errors["base"] = "invalid_auth" + + except ( + AIOSomecomfort.ConnectionError, + AIOSomecomfort.ConnectionTimeout, + asyncio.TimeoutError, + ): + errors["base"] = "cannot_connect" + + else: + + self.hass.config_entries.async_update_entry( + self.entry, + data={ + **self.entry.data, + CONF_PASSWORD: password, + }, + ) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=REAUTH_SCHEMA, + errors=errors, + ) async def async_step_user(self, user_input=None) -> FlowResult: """Create config entry. Show the setup form to the user.""" diff --git a/tests/components/honeywell/test_config_flow.py b/tests/components/honeywell/test_config_flow.py index 46ab48572f..2d76962b02 100644 --- a/tests/components/honeywell/test_config_flow.py +++ b/tests/components/honeywell/test_config_flow.py @@ -1,7 +1,9 @@ """Tests for honeywell config flow.""" +import asyncio from unittest.mock import MagicMock, patch import AIOSomecomfort +import pytest from homeassistant import data_entry_flow from homeassistant.components.honeywell.const import ( @@ -9,8 +11,10 @@ from homeassistant.components.honeywell.const import ( CONF_HEAT_AWAY_TEMPERATURE, DOMAIN, ) -from homeassistant.config_entries import SOURCE_USER, ConfigEntryState +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, ConfigEntryState +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry @@ -35,8 +39,7 @@ async def test_show_authenticate_form(hass: HomeAssistant) -> None: async def test_connection_error(hass: HomeAssistant, client: MagicMock) -> None: """Test that an error message is shown on connection fail.""" - client.login.side_effect = AIOSomecomfort.ConnectionError - + client.login.side_effect = AIOSomecomfort.device.ConnectionError result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=FAKE_CONFIG ) @@ -45,7 +48,7 @@ async def test_connection_error(hass: HomeAssistant, client: MagicMock) -> None: async def test_auth_error(hass: HomeAssistant, client: MagicMock) -> None: """Test that an error message is shown on login fail.""" - client.login.side_effect = AIOSomecomfort.AuthError + client.login.side_effect = AIOSomecomfort.device.AuthError result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=FAKE_CONFIG @@ -116,3 +119,137 @@ async def test_create_option_entry( CONF_COOL_AWAY_TEMPERATURE: 1, CONF_HEAT_AWAY_TEMPERATURE: 2, } + + +async def test_reauth_flow(hass: HomeAssistant) -> None: + """Test a successful reauth flow.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"}, + unique_id="test-username", + ) + mock_entry.add_to_hass(hass) + with patch( + "homeassistant.components.honeywell.async_setup_entry", + return_value=True, + ): + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data={CONF_USERNAME: "test-username", CONF_PASSWORD: "new-password"}, + ) + + await hass.async_block_till_done() + + assert result["step_id"] == "reauth_confirm" + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.honeywell.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert mock_entry.data == { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "new-password", + } + + +async def test_reauth_flow_auth_error(hass: HomeAssistant, client: MagicMock) -> None: + """Test an authorization error reauth flow.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"}, + unique_id="test-username", + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data={CONF_USERNAME: "test-username", CONF_PASSWORD: "new-password"}, + ) + await hass.async_block_till_done() + + assert result["step_id"] == "reauth_confirm" + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + client.login.side_effect = AIOSomecomfort.device.AuthError + with patch( + "homeassistant.components.honeywell.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + + +@pytest.mark.parametrize( + "error", + [ + AIOSomecomfort.device.ConnectionError, + AIOSomecomfort.device.ConnectionTimeout, + asyncio.TimeoutError, + ], +) +async def test_reauth_flow_connnection_error( + hass: HomeAssistant, client: MagicMock, error +) -> None: + """Test a connection error reauth flow.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"}, + unique_id="test-username", + ) + mock_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data={CONF_USERNAME: "test-username", CONF_PASSWORD: "new-password"}, + ) + await hass.async_block_till_done() + + assert result["step_id"] == "reauth_confirm" + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + client.login.side_effect = error + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} From 063bbe91d152c2dc901f96e4aa616a15c785fd8a Mon Sep 17 00:00:00 2001 From: mkmer Date: Thu, 2 Feb 2023 16:45:49 -0500 Subject: [PATCH 0099/2810] Bump AIOSomecomfort to 0.0.6 (#87203) * Bump to 0.0.5 * Bump aiosomecomfort to 0.0.6 * lower case aiosomecomfort * Fix other bad imports.... --------- Co-authored-by: Paulus Schoutsen --- .../components/honeywell/__init__.py | 14 ++++++------ homeassistant/components/honeywell/climate.py | 22 +++++++++---------- .../components/honeywell/config_flow.py | 16 +++++++------- .../components/honeywell/manifest.json | 2 +- homeassistant/components/honeywell/sensor.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/honeywell/conftest.py | 14 ++++++------ .../components/honeywell/test_config_flow.py | 12 +++++----- tests/components/honeywell/test_init.py | 4 ++-- tests/components/honeywell/test_sensor.py | 4 ++-- 11 files changed, 47 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index c0ebcd9b1c..93c29446a5 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -2,7 +2,7 @@ import asyncio from dataclasses import dataclass -import AIOSomecomfort +import aiosomecomfort from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform @@ -50,19 +50,19 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b username = config_entry.data[CONF_USERNAME] password = config_entry.data[CONF_PASSWORD] - client = AIOSomecomfort.AIOSomeComfort( + client = aiosomecomfort.AIOSomeComfort( username, password, session=async_get_clientsession(hass) ) try: await client.login() await client.discover() - except AIOSomecomfort.device.AuthError as ex: + except aiosomecomfort.device.AuthError as ex: raise ConfigEntryAuthFailed("Incorrect Password") from ex except ( - AIOSomecomfort.device.ConnectionError, - AIOSomecomfort.device.ConnectionTimeout, + aiosomecomfort.device.ConnectionError, + aiosomecomfort.device.ConnectionTimeout, asyncio.TimeoutError, ) as ex: raise ConfigEntryNotReady( @@ -114,5 +114,5 @@ class HoneywellData: """Shared data for Honeywell.""" entry_id: str - client: AIOSomecomfort.AIOSomeComfort - devices: dict[str, AIOSomecomfort.device.Device] + client: aiosomecomfort.AIOSomeComfort + devices: dict[str, aiosomecomfort.device.Device] diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 96fc30f57e..1296dfbfdb 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -4,7 +4,7 @@ from __future__ import annotations import datetime from typing import Any -import AIOSomecomfort +import aiosomecomfort from homeassistant.components.climate import ( ATTR_TARGET_TEMP_HIGH, @@ -100,7 +100,7 @@ class HoneywellUSThermostat(ClimateEntity): def __init__( self, data: HoneywellData, - device: AIOSomecomfort.device.Device, + device: aiosomecomfort.device.Device, cool_away_temp: int | None, heat_away_temp: int | None, ) -> None: @@ -295,7 +295,7 @@ class HoneywellUSThermostat(ClimateEntity): if mode == "heat": await self._device.set_setpoint_heat(temperature) - except AIOSomecomfort.SomeComfortError as err: + except aiosomecomfort.SomeComfortError as err: _LOGGER.error("Invalid temperature %.1f: %s", temperature, err) async def async_set_temperature(self, **kwargs: Any) -> None: @@ -308,7 +308,7 @@ class HoneywellUSThermostat(ClimateEntity): if temperature := kwargs.get(ATTR_TARGET_TEMP_LOW): await self._device.set_setpoint_heat(temperature) - except AIOSomecomfort.SomeComfortError as err: + except aiosomecomfort.SomeComfortError as err: _LOGGER.error("Invalid temperature %.1f: %s", temperature, err) async def async_set_fan_mode(self, fan_mode: str) -> None: @@ -330,7 +330,7 @@ class HoneywellUSThermostat(ClimateEntity): try: # Get current mode mode = self._device.system_mode - except AIOSomecomfort.SomeComfortError: + except aiosomecomfort.SomeComfortError: _LOGGER.error("Can not get system mode") return try: @@ -344,7 +344,7 @@ class HoneywellUSThermostat(ClimateEntity): await self._device.set_hold_heat(True) await self._device.set_setpoint_heat(self._heat_away_temp) - except AIOSomecomfort.SomeComfortError: + except aiosomecomfort.SomeComfortError: _LOGGER.error( "Temperature out of range. Mode: %s, Heat Temperature: %.1f, Cool Temperature: %.1f", mode, @@ -357,7 +357,7 @@ class HoneywellUSThermostat(ClimateEntity): try: # Get current mode mode = self._device.system_mode - except AIOSomecomfort.SomeComfortError: + except aiosomecomfort.SomeComfortError: _LOGGER.error("Can not get system mode") return # Check that we got a valid mode back @@ -369,7 +369,7 @@ class HoneywellUSThermostat(ClimateEntity): if mode in HEATING_MODES: await self._device.set_hold_heat(True) - except AIOSomecomfort.SomeComfortError: + except aiosomecomfort.SomeComfortError: _LOGGER.error("Couldn't set permanent hold") else: _LOGGER.error("Invalid system mode returned: %s", mode) @@ -381,7 +381,7 @@ class HoneywellUSThermostat(ClimateEntity): # Disabling all hold modes await self._device.set_hold_cool(False) await self._device.set_hold_heat(False) - except AIOSomecomfort.SomeComfortError: + except aiosomecomfort.SomeComfortError: _LOGGER.error("Can not stop hold mode") async def async_set_preset_mode(self, preset_mode: str) -> None: @@ -410,13 +410,13 @@ class HoneywellUSThermostat(ClimateEntity): try: await self._device.refresh() except ( - AIOSomecomfort.SomeComfortError, + aiosomecomfort.SomeComfortError, OSError, ): try: await self._data.client.login() - except AIOSomecomfort.SomeComfortError: + except aiosomecomfort.SomeComfortError: self._attr_available = False await self.hass.async_create_task( self.hass.config_entries.async_reload(self._data.entry_id) diff --git a/homeassistant/components/honeywell/config_flow.py b/homeassistant/components/honeywell/config_flow.py index 0f9d5663b3..76d208f72f 100644 --- a/homeassistant/components/honeywell/config_flow.py +++ b/homeassistant/components/honeywell/config_flow.py @@ -5,7 +5,7 @@ import asyncio from collections.abc import Mapping from typing import Any -import AIOSomecomfort +import aiosomecomfort import voluptuous as vol from homeassistant import config_entries @@ -56,12 +56,12 @@ class HoneywellConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): username=data[CONF_USERNAME], password=data[CONF_PASSWORD] ) - except AIOSomecomfort.AuthError: + except aiosomecomfort.AuthError: errors["base"] = "invalid_auth" except ( - AIOSomecomfort.ConnectionError, - AIOSomecomfort.ConnectionTimeout, + aiosomecomfort.ConnectionError, + aiosomecomfort.ConnectionTimeout, asyncio.TimeoutError, ): errors["base"] = "cannot_connect" @@ -90,11 +90,11 @@ class HoneywellConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: try: await self.is_valid(**user_input) - except AIOSomecomfort.AuthError: + except aiosomecomfort.AuthError: errors["base"] = "invalid_auth" except ( - AIOSomecomfort.ConnectionError, - AIOSomecomfort.ConnectionTimeout, + aiosomecomfort.ConnectionError, + aiosomecomfort.ConnectionTimeout, asyncio.TimeoutError, ): errors["base"] = "cannot_connect" @@ -115,7 +115,7 @@ class HoneywellConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def is_valid(self, **kwargs) -> bool: """Check if login credentials are valid.""" - client = AIOSomecomfort.AIOSomeComfort( + client = aiosomecomfort.AIOSomeComfort( kwargs[CONF_USERNAME], kwargs[CONF_PASSWORD], session=async_get_clientsession(self.hass), diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index 7eb07711b0..974123825f 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -3,7 +3,7 @@ "name": "Honeywell Total Connect Comfort (US)", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/honeywell", - "requirements": ["aiosomecomfort==0.0.3"], + "requirements": ["aiosomecomfort==0.0.6"], "codeowners": ["@rdfurman", "@mkmer"], "iot_class": "cloud_polling", "loggers": ["somecomfort"] diff --git a/homeassistant/components/honeywell/sensor.py b/homeassistant/components/honeywell/sensor.py index 59f0047270..9e85ba7727 100644 --- a/homeassistant/components/honeywell/sensor.py +++ b/homeassistant/components/honeywell/sensor.py @@ -5,7 +5,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Any -from AIOSomecomfort.device import Device +from aiosomecomfort.device import Device from homeassistant.components.sensor import ( SensorDeviceClass, diff --git a/requirements_all.txt b/requirements_all.txt index f8abf9d975..7f51ac3581 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.3 +aiosomecomfort==0.0.6 # homeassistant.components.steamist aiosteamist==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 18071e51a3..341b688b09 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.3 +aiosomecomfort==0.0.6 # homeassistant.components.steamist aiosteamist==0.3.2 diff --git a/tests/components/honeywell/conftest.py b/tests/components/honeywell/conftest.py index bead64c71d..95e1758ec2 100644 --- a/tests/components/honeywell/conftest.py +++ b/tests/components/honeywell/conftest.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, create_autospec, patch -import AIOSomecomfort +import aiosomecomfort import pytest from homeassistant.components.honeywell.const import DOMAIN @@ -30,7 +30,7 @@ def config_entry(config_data): @pytest.fixture def device(): """Mock a somecomfort.Device.""" - mock_device = create_autospec(AIOSomecomfort.device.Device, instance=True) + mock_device = create_autospec(aiosomecomfort.device.Device, instance=True) mock_device.deviceid = 1234567 mock_device._data = { "canControlHumidification": False, @@ -48,7 +48,7 @@ def device(): @pytest.fixture def device_with_outdoor_sensor(): """Mock a somecomfort.Device.""" - mock_device = create_autospec(AIOSomecomfort.device.Device, instance=True) + mock_device = create_autospec(aiosomecomfort.device.Device, instance=True) mock_device.deviceid = 1234567 mock_device._data = { "canControlHumidification": False, @@ -67,7 +67,7 @@ def device_with_outdoor_sensor(): @pytest.fixture def another_device(): """Mock a somecomfort.Device.""" - mock_device = create_autospec(AIOSomecomfort.device.Device, instance=True) + mock_device = create_autospec(aiosomecomfort.device.Device, instance=True) mock_device.deviceid = 7654321 mock_device._data = { "canControlHumidification": False, @@ -85,7 +85,7 @@ def another_device(): @pytest.fixture def location(device): """Mock a somecomfort.Location.""" - mock_location = create_autospec(AIOSomecomfort.location.Location, instance=True) + mock_location = create_autospec(aiosomecomfort.location.Location, instance=True) mock_location.locationid.return_value = "location1" mock_location.devices_by_id = {device.deviceid: device} return mock_location @@ -94,13 +94,13 @@ def location(device): @pytest.fixture(autouse=True) def client(location): """Mock a somecomfort.SomeComfort client.""" - client_mock = create_autospec(AIOSomecomfort.AIOSomeComfort, instance=True) + client_mock = create_autospec(aiosomecomfort.AIOSomeComfort, instance=True) client_mock.locations_by_id = {location.locationid: location} client_mock.login = AsyncMock(return_value=True) client_mock.discover = AsyncMock() with patch( - "homeassistant.components.honeywell.AIOSomecomfort.AIOSomeComfort" + "homeassistant.components.honeywell.aiosomecomfort.AIOSomeComfort" ) as sc_class_mock: sc_class_mock.return_value = client_mock yield client_mock diff --git a/tests/components/honeywell/test_config_flow.py b/tests/components/honeywell/test_config_flow.py index 2d76962b02..ff970a0e8c 100644 --- a/tests/components/honeywell/test_config_flow.py +++ b/tests/components/honeywell/test_config_flow.py @@ -2,7 +2,7 @@ import asyncio from unittest.mock import MagicMock, patch -import AIOSomecomfort +import aiosomecomfort import pytest from homeassistant import data_entry_flow @@ -39,7 +39,7 @@ async def test_show_authenticate_form(hass: HomeAssistant) -> None: async def test_connection_error(hass: HomeAssistant, client: MagicMock) -> None: """Test that an error message is shown on connection fail.""" - client.login.side_effect = AIOSomecomfort.device.ConnectionError + client.login.side_effect = aiosomecomfort.device.ConnectionError result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=FAKE_CONFIG ) @@ -48,7 +48,7 @@ async def test_connection_error(hass: HomeAssistant, client: MagicMock) -> None: async def test_auth_error(hass: HomeAssistant, client: MagicMock) -> None: """Test that an error message is shown on login fail.""" - client.login.side_effect = AIOSomecomfort.device.AuthError + client.login.side_effect = aiosomecomfort.device.AuthError result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=FAKE_CONFIG @@ -194,7 +194,7 @@ async def test_reauth_flow_auth_error(hass: HomeAssistant, client: MagicMock) -> assert result["type"] == FlowResultType.FORM assert result["errors"] == {} - client.login.side_effect = AIOSomecomfort.device.AuthError + client.login.side_effect = aiosomecomfort.device.AuthError with patch( "homeassistant.components.honeywell.async_setup_entry", return_value=True, @@ -212,8 +212,8 @@ async def test_reauth_flow_auth_error(hass: HomeAssistant, client: MagicMock) -> @pytest.mark.parametrize( "error", [ - AIOSomecomfort.device.ConnectionError, - AIOSomecomfort.device.ConnectionTimeout, + aiosomecomfort.device.ConnectionError, + aiosomecomfort.device.ConnectionTimeout, asyncio.TimeoutError, ], ) diff --git a/tests/components/honeywell/test_init.py b/tests/components/honeywell/test_init.py index 4ecd2a3172..855d503401 100644 --- a/tests/components/honeywell/test_init.py +++ b/tests/components/honeywell/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import create_autospec, patch -import AIOSomecomfort +import aiosomecomfort from homeassistant.components.honeywell.const import ( CONF_COOL_AWAY_TEMPERATURE, @@ -46,7 +46,7 @@ async def test_setup_multiple_thermostats_with_same_deviceid( hass: HomeAssistant, caplog, config_entry: MockConfigEntry, device, client ) -> None: """Test Honeywell TCC API returning duplicate device IDs.""" - mock_location2 = create_autospec(AIOSomecomfort.Location, instance=True) + mock_location2 = create_autospec(aiosomecomfort.Location, instance=True) mock_location2.locationid.return_value = "location2" mock_location2.devices_by_id = {device.deviceid: device} client.locations_by_id["location2"] = mock_location2 diff --git a/tests/components/honeywell/test_sensor.py b/tests/components/honeywell/test_sensor.py index 7ed047262b..c5367764d3 100644 --- a/tests/components/honeywell/test_sensor.py +++ b/tests/components/honeywell/test_sensor.py @@ -1,6 +1,6 @@ """Test honeywell sensor.""" -from AIOSomecomfort.device import Device -from AIOSomecomfort.location import Location +from aiosomecomfort.device import Device +from aiosomecomfort.location import Location import pytest from homeassistant.core import HomeAssistant From 75796e1f0f3ea6d65da141779121dcc1deaea08a Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 2 Feb 2023 22:11:01 +0100 Subject: [PATCH 0100/2810] Update frontend to 20230202.0 (#87208) --- 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 b33668cde4..ba4185fa83 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -2,7 +2,7 @@ "domain": "frontend", "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", - "requirements": ["home-assistant-frontend==20230201.0"], + "requirements": ["home-assistant-frontend==20230202.0"], "dependencies": [ "api", "auth", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9101375adb..8dc60525be 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==0.2.6 home-assistant-bluetooth==1.9.2 -home-assistant-frontend==20230201.0 +home-assistant-frontend==20230202.0 home-assistant-intents==2023.1.31 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index 7f51ac3581..c4c62b9c8f 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==20230201.0 +home-assistant-frontend==20230202.0 # homeassistant.components.conversation home-assistant-intents==2023.1.31 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 341b688b09..4db8dac349 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==20230201.0 +home-assistant-frontend==20230202.0 # homeassistant.components.conversation home-assistant-intents==2023.1.31 From 1d8f5b2e163ef4ec0935dc35bd86a77bcc90c352 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 2 Feb 2023 22:10:51 +0100 Subject: [PATCH 0101/2810] Bump py-synologydsm-api to 2.1.1 (#87211) bump py-synologydsm-api to 2.1.1 --- homeassistant/components/synology_dsm/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json index 6d0012156d..14f45fbf63 100644 --- a/homeassistant/components/synology_dsm/manifest.json +++ b/homeassistant/components/synology_dsm/manifest.json @@ -2,7 +2,7 @@ "domain": "synology_dsm", "name": "Synology DSM", "documentation": "https://www.home-assistant.io/integrations/synology_dsm", - "requirements": ["py-synologydsm-api==2.0.2"], + "requirements": ["py-synologydsm-api==2.1.1"], "codeowners": ["@hacf-fr", "@Quentame", "@mib1185"], "config_flow": true, "ssdp": [ diff --git a/requirements_all.txt b/requirements_all.txt index c4c62b9c8f..ac62e334ec 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1442,7 +1442,7 @@ py-schluter==0.1.7 py-sucks==0.9.8 # homeassistant.components.synology_dsm -py-synologydsm-api==2.0.2 +py-synologydsm-api==2.1.1 # homeassistant.components.zabbix py-zabbix==1.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4db8dac349..52727f66ee 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1051,7 +1051,7 @@ py-melissa-climate==2.1.4 py-nightscout==1.2.2 # homeassistant.components.synology_dsm -py-synologydsm-api==2.0.2 +py-synologydsm-api==2.1.1 # homeassistant.components.seventeentrack py17track==2021.12.2 From ed8a0ef0eae239dfe465305f5964bf8e94c7b600 Mon Sep 17 00:00:00 2001 From: Karlie Meads <68717336+karliemeads@users.noreply.github.com> Date: Thu, 2 Feb 2023 16:35:02 -0500 Subject: [PATCH 0102/2810] Fix disabled condition within an automation action (#87213) fixes undefined --- homeassistant/helpers/script.py | 2 +- tests/helpers/test_script.py | 39 +++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 34a0d4de2d..02fa9dc780 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -757,7 +757,7 @@ class _ScriptRun: with trace_path(condition_path): for idx, cond in enumerate(conditions): with trace_path(str(idx)): - if not cond(hass, variables): + if cond(hass, variables) is False: return False except exceptions.ConditionError as ex: _LOGGER.warning("Error in '%s[%s]' evaluation: %s", name, idx, ex) diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index b44e7b7c45..86172c6c7f 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -2915,6 +2915,45 @@ async def test_if( assert_action_trace(expected_trace) +async def test_if_disabled( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test if action with a disabled condition.""" + sequence = cv.SCRIPT_SCHEMA( + { + "if": { + "alias": "if condition", + "condition": "template", + "value_template": "{{ var == 1 }}", + "enabled": "false", + }, + "then": { + "alias": "if then", + "event": "test_event", + "event_data": {"if": "then"}, + }, + "else": { + "alias": "if else", + "event": "test_event", + "event_data": {"if": "else"}, + }, + } + ) + + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + await script_obj.async_run(context=Context()) + await hass.async_block_till_done() + + expected_trace = { + "0": [{"result": {"choice": "then"}}], + "0/if": [{"result": {"result": True}}], + "0/if/condition/0": [{"result": {"result": None}}], + "0/then/0": [{"result": {"event": "test_event", "event_data": {"if": "then"}}}], + } + assert_action_trace(expected_trace) + + async def test_if_condition_validation( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: From 372afc5c28483b6336f300728149d8a046e09316 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 2 Feb 2023 16:48:09 -0500 Subject: [PATCH 0103/2810] Bumped version to 2023.2.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 ee5d60a32b..070f9a3786 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 = 2 -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 5c014aabd4..affc4b6446 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.2.0" +version = "2023.2.1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 5aaddf72e6591e0f39b655608eba0370cba88eea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 Feb 2023 19:08:18 -0600 Subject: [PATCH 0104/2810] Add missing mopeka translations (#87421) fixes #87163 --- .../components/mopeka/translations/af.json | 7 ++++++ .../components/mopeka/translations/bg.json | 21 ++++++++++++++++++ .../components/mopeka/translations/ca.json | 22 +++++++++++++++++++ .../components/mopeka/translations/de.json | 22 +++++++++++++++++++ .../components/mopeka/translations/el.json | 7 ++++++ .../components/mopeka/translations/en.json | 22 +++++++++++++++++++ .../components/mopeka/translations/es.json | 22 +++++++++++++++++++ .../components/mopeka/translations/et.json | 22 +++++++++++++++++++ .../components/mopeka/translations/fr.json | 21 ++++++++++++++++++ .../components/mopeka/translations/hu.json | 22 +++++++++++++++++++ .../components/mopeka/translations/id.json | 22 +++++++++++++++++++ .../components/mopeka/translations/it.json | 22 +++++++++++++++++++ .../components/mopeka/translations/lv.json | 20 +++++++++++++++++ .../components/mopeka/translations/nl.json | 22 +++++++++++++++++++ .../components/mopeka/translations/no.json | 22 +++++++++++++++++++ .../components/mopeka/translations/pl.json | 22 +++++++++++++++++++ .../components/mopeka/translations/pt-BR.json | 22 +++++++++++++++++++ .../components/mopeka/translations/ru.json | 22 +++++++++++++++++++ .../components/mopeka/translations/sk.json | 22 +++++++++++++++++++ .../mopeka/translations/zh-Hant.json | 22 +++++++++++++++++++ 20 files changed, 406 insertions(+) create mode 100644 homeassistant/components/mopeka/translations/af.json create mode 100644 homeassistant/components/mopeka/translations/bg.json create mode 100644 homeassistant/components/mopeka/translations/ca.json create mode 100644 homeassistant/components/mopeka/translations/de.json create mode 100644 homeassistant/components/mopeka/translations/el.json create mode 100644 homeassistant/components/mopeka/translations/en.json create mode 100644 homeassistant/components/mopeka/translations/es.json create mode 100644 homeassistant/components/mopeka/translations/et.json create mode 100644 homeassistant/components/mopeka/translations/fr.json create mode 100644 homeassistant/components/mopeka/translations/hu.json create mode 100644 homeassistant/components/mopeka/translations/id.json create mode 100644 homeassistant/components/mopeka/translations/it.json create mode 100644 homeassistant/components/mopeka/translations/lv.json create mode 100644 homeassistant/components/mopeka/translations/nl.json create mode 100644 homeassistant/components/mopeka/translations/no.json create mode 100644 homeassistant/components/mopeka/translations/pl.json create mode 100644 homeassistant/components/mopeka/translations/pt-BR.json create mode 100644 homeassistant/components/mopeka/translations/ru.json create mode 100644 homeassistant/components/mopeka/translations/sk.json create mode 100644 homeassistant/components/mopeka/translations/zh-Hant.json diff --git a/homeassistant/components/mopeka/translations/af.json b/homeassistant/components/mopeka/translations/af.json new file mode 100644 index 0000000000..30d1901965 --- /dev/null +++ b/homeassistant/components/mopeka/translations/af.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e konfigurovan\u00e9" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mopeka/translations/bg.json b/homeassistant/components/mopeka/translations/bg.json new file mode 100644 index 0000000000..e899c3bcdd --- /dev/null +++ b/homeassistant/components/mopeka/translations/bg.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", + "no_devices_found": "\u041d\u044f\u043c\u0430 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0432 \u043c\u0440\u0435\u0436\u0430\u0442\u0430", + "not_supported": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u043d\u0435 \u0441\u0435 \u043f\u043e\u0434\u0434\u044a\u0440\u0436\u0430" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u0442\u0435 {name}?" + }, + "user": { + "data": { + "address": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" + }, + "description": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e, \u043a\u043e\u0435\u0442\u043e \u0434\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u0442\u0435" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mopeka/translations/ca.json b/homeassistant/components/mopeka/translations/ca.json new file mode 100644 index 0000000000..c121ff7408 --- /dev/null +++ b/homeassistant/components/mopeka/translations/ca.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "already_in_progress": "El flux de configuraci\u00f3 ja est\u00e0 en curs", + "no_devices_found": "No s'han trobat dispositius a la xarxa", + "not_supported": "Dispositiu no compatible" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Vols configurar {name}?" + }, + "user": { + "data": { + "address": "Dispositiu" + }, + "description": "Tria un dispositiu a configurar" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mopeka/translations/de.json b/homeassistant/components/mopeka/translations/de.json new file mode 100644 index 0000000000..4c5720ec6f --- /dev/null +++ b/homeassistant/components/mopeka/translations/de.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", + "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden", + "not_supported": "Ger\u00e4t nicht unterst\u00fctzt" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "M\u00f6chtest du {name} einrichten?" + }, + "user": { + "data": { + "address": "Ger\u00e4t" + }, + "description": "W\u00e4hle ein Ger\u00e4t zum Einrichten aus" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mopeka/translations/el.json b/homeassistant/components/mopeka/translations/el.json new file mode 100644 index 0000000000..d334661425 --- /dev/null +++ b/homeassistant/components/mopeka/translations/el.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "not_supported": "\u0397 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03b4\u03b5\u03bd \u03c5\u03c0\u03bf\u03c3\u03c4\u03b7\u03c1\u03af\u03b6\u03b5\u03c4\u03b1\u03b9" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mopeka/translations/en.json b/homeassistant/components/mopeka/translations/en.json new file mode 100644 index 0000000000..afe859ca76 --- /dev/null +++ b/homeassistant/components/mopeka/translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "already_in_progress": "Configuration flow is already in progress", + "no_devices_found": "No devices found on the network", + "not_supported": "Device not supported" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Do you want to set up {name}?" + }, + "user": { + "data": { + "address": "Device" + }, + "description": "Choose a device to set up" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mopeka/translations/es.json b/homeassistant/components/mopeka/translations/es.json new file mode 100644 index 0000000000..ae0ab01acd --- /dev/null +++ b/homeassistant/components/mopeka/translations/es.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "already_in_progress": "El flujo de configuraci\u00f3n ya est\u00e1 en curso", + "no_devices_found": "No se encontraron dispositivos en la red", + "not_supported": "Dispositivo no compatible" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "\u00bfQuieres configurar {name}?" + }, + "user": { + "data": { + "address": "Dispositivo" + }, + "description": "Elige un dispositivo para configurar" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mopeka/translations/et.json b/homeassistant/components/mopeka/translations/et.json new file mode 100644 index 0000000000..8f424097aa --- /dev/null +++ b/homeassistant/components/mopeka/translations/et.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "already_in_progress": "Seadistamine on juba k\u00e4imas", + "no_devices_found": "V\u00f5rgust ei leitud \u00fchtegi seadet", + "not_supported": "Seadet ei toetata" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Kas seadistada {name} ?" + }, + "user": { + "data": { + "address": "Seade" + }, + "description": "Vali h\u00e4\u00e4lestatav seade" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mopeka/translations/fr.json b/homeassistant/components/mopeka/translations/fr.json new file mode 100644 index 0000000000..2cb8c3c6ad --- /dev/null +++ b/homeassistant/components/mopeka/translations/fr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau", + "not_supported": "Appareil non pris en charge" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Voulez-vous configurer {name}\u00a0?" + }, + "user": { + "data": { + "address": "Appareil" + }, + "description": "S\u00e9lectionnez l'appareil \u00e0 configurer" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mopeka/translations/hu.json b/homeassistant/components/mopeka/translations/hu.json new file mode 100644 index 0000000000..4668ffea41 --- /dev/null +++ b/homeassistant/components/mopeka/translations/hu.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "already_in_progress": "A be\u00e1ll\u00edt\u00e1si folyamat m\u00e1r el lett kezdve", + "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton", + "not_supported": "Eszk\u00f6z nem t\u00e1mogatott" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name}?" + }, + "user": { + "data": { + "address": "Eszk\u00f6z" + }, + "description": "V\u00e1lasszon egy be\u00e1ll\u00edtand\u00f3 eszk\u00f6zt" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mopeka/translations/id.json b/homeassistant/components/mopeka/translations/id.json new file mode 100644 index 0000000000..573eb39ed1 --- /dev/null +++ b/homeassistant/components/mopeka/translations/id.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "already_in_progress": "Alur konfigurasi sedang berlangsung", + "no_devices_found": "Tidak ada perangkat yang ditemukan di jaringan", + "not_supported": "Perangkat tidak didukung" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Ingin menyiapkan {name}?" + }, + "user": { + "data": { + "address": "Perangkat" + }, + "description": "Pilih perangkat untuk disiapkan" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mopeka/translations/it.json b/homeassistant/components/mopeka/translations/it.json new file mode 100644 index 0000000000..97113c5710 --- /dev/null +++ b/homeassistant/components/mopeka/translations/it.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso", + "no_devices_found": "Nessun dispositivo trovato sulla rete", + "not_supported": "Dispositivo non supportato" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Vuoi configurare {name}?" + }, + "user": { + "data": { + "address": "Dispositivo" + }, + "description": "Scegli un dispositivo da configurare" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mopeka/translations/lv.json b/homeassistant/components/mopeka/translations/lv.json new file mode 100644 index 0000000000..cb2a17efe7 --- /dev/null +++ b/homeassistant/components/mopeka/translations/lv.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "no_devices_found": "T\u012bkl\u0101 nav atrasta neviena ier\u012bce", + "not_supported": "Ier\u012bce netiek atbalst\u012bta" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Vai v\u0113laties iestat\u012bt {name}?" + }, + "user": { + "data": { + "address": "Ier\u012bce" + }, + "description": "Izv\u0113lieties ier\u012bci, kuru iestat\u012bt" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mopeka/translations/nl.json b/homeassistant/components/mopeka/translations/nl.json new file mode 100644 index 0000000000..93f866be7d --- /dev/null +++ b/homeassistant/components/mopeka/translations/nl.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "already_in_progress": "Configuratie wordt al uitgevoerd", + "no_devices_found": "Geen apparaten gevonden op het netwerk", + "not_supported": "Apparaat wordt niet ondersteund" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Wil je {name} instellen?" + }, + "user": { + "data": { + "address": "Apparaat" + }, + "description": "Kies een apparaat om in te stellen" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mopeka/translations/no.json b/homeassistant/components/mopeka/translations/no.json new file mode 100644 index 0000000000..38ab3d096f --- /dev/null +++ b/homeassistant/components/mopeka/translations/no.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede", + "no_devices_found": "Ingen enheter funnet p\u00e5 nettverket", + "not_supported": "Enheten st\u00f8ttes ikke" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Vil du sette opp {name} ?" + }, + "user": { + "data": { + "address": "Enhet" + }, + "description": "Velg en enhet du vil konfigurere" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mopeka/translations/pl.json b/homeassistant/components/mopeka/translations/pl.json new file mode 100644 index 0000000000..4715905a2e --- /dev/null +++ b/homeassistant/components/mopeka/translations/pl.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "already_in_progress": "Konfiguracja jest ju\u017c w toku", + "no_devices_found": "Nie znaleziono urz\u0105dze\u0144 w sieci", + "not_supported": "Urz\u0105dzenie nie jest obs\u0142ugiwane" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Czy chcesz skonfigurowa\u0107 {name}?" + }, + "user": { + "data": { + "address": "Urz\u0105dzenie" + }, + "description": "Wybierz urz\u0105dzenie do skonfigurowania" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mopeka/translations/pt-BR.json b/homeassistant/components/mopeka/translations/pt-BR.json new file mode 100644 index 0000000000..5b65416320 --- /dev/null +++ b/homeassistant/components/mopeka/translations/pt-BR.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Dispositivo j\u00e1 est\u00e1 configurado", + "already_in_progress": "O fluxo de configura\u00e7\u00e3o j\u00e1 est\u00e1 em andamento", + "no_devices_found": "Nenhum dispositivo encontrado na rede", + "not_supported": "Dispositivo n\u00e3o suportado" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Deseja configurar {name}?" + }, + "user": { + "data": { + "address": "Dispositivo" + }, + "description": "Escolha um dispositivo para configurar" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mopeka/translations/ru.json b/homeassistant/components/mopeka/translations/ru.json new file mode 100644 index 0000000000..887499e5f2 --- /dev/null +++ b/homeassistant/components/mopeka/translations/ru.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", + "already_in_progress": "\u041f\u0440\u043e\u0446\u0435\u0441\u0441 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f.", + "no_devices_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b \u0432 \u0441\u0435\u0442\u0438.", + "not_supported": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f." + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c {name}?" + }, + "user": { + "data": { + "address": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0434\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mopeka/translations/sk.json b/homeassistant/components/mopeka/translations/sk.json new file mode 100644 index 0000000000..8273d877c9 --- /dev/null +++ b/homeassistant/components/mopeka/translations/sk.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Zariadenie u\u017e je nakonfigurovan\u00e9", + "already_in_progress": "Konfigur\u00e1cia u\u017e prebieha", + "no_devices_found": "V sieti sa nena\u0161li \u017eiadne zariadenia", + "not_supported": "Zariadenie nie je podporovan\u00e9" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Chcete nastavi\u0165 {name}?" + }, + "user": { + "data": { + "address": "Zaradenie" + }, + "description": "Vyberte zariadenie, ktor\u00e9 chcete nastavi\u0165" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mopeka/translations/zh-Hant.json b/homeassistant/components/mopeka/translations/zh-Hant.json new file mode 100644 index 0000000000..64ae1f1909 --- /dev/null +++ b/homeassistant/components/mopeka/translations/zh-Hant.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "already_in_progress": "\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d", + "no_devices_found": "\u7db2\u8def\u4e0a\u627e\u4e0d\u5230\u88dd\u7f6e", + "not_supported": "\u88dd\u7f6e\u4e0d\u652f\u63f4" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a {name}\uff1f" + }, + "user": { + "data": { + "address": "\u88dd\u7f6e" + }, + "description": "\u9078\u64c7\u6240\u8981\u8a2d\u5b9a\u7684\u88dd\u7f6e" + } + } + } +} \ No newline at end of file From e12425c229d2a9dd3f7fc2b37eaafeb9bb3ca55f Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 3 Feb 2023 12:10:47 +1000 Subject: [PATCH 0105/2810] Fix call values in Aussie Broadband (#87229) Fixed calls values Added lamda for International, Voicemail, and Other calls. --- homeassistant/components/aussie_broadband/sensor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/aussie_broadband/sensor.py b/homeassistant/components/aussie_broadband/sensor.py index 896930649e..1ed146b623 100644 --- a/homeassistant/components/aussie_broadband/sensor.py +++ b/homeassistant/components/aussie_broadband/sensor.py @@ -78,6 +78,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:phone-plus", + value=lambda x: x.get("calls"), ), SensorValueEntityDescription( key="sms", @@ -101,6 +102,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:phone", + value=lambda x: x.get("calls"), ), SensorValueEntityDescription( key="other", @@ -108,6 +110,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:phone", + value=lambda x: x.get("calls"), ), # Generic sensors SensorValueEntityDescription( From 012ba551540536fc4ff54ac57eeddd2ca747dcda Mon Sep 17 00:00:00 2001 From: jjlawren Date: Thu, 2 Feb 2023 22:42:36 -0600 Subject: [PATCH 0106/2810] Handle failed Sonos subscriptions better (#87240) Catch unsubscribe failure separately from ZGS poll --- homeassistant/components/sonos/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index f8047ba371..1133b5b4d9 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -11,6 +11,7 @@ import socket from typing import TYPE_CHECKING, Any, cast from urllib.parse import urlparse +from aiohttp import ClientError from requests.exceptions import Timeout from soco import events_asyncio, zonegroupstate import soco.config as soco_config @@ -229,6 +230,10 @@ class SonosDiscoveryManager: ) try: await sub.unsubscribe() + except (ClientError, OSError, Timeout) as ex: + _LOGGER.debug("Unsubscription from %s failed: %s", ip_address, ex) + + try: await self.hass.async_add_executor_job(soco.zone_group_state.poll, soco) except (OSError, SoCoException, Timeout) as ex: _LOGGER.warning( From 6543a1169ba17689bf93642bf70a8ab120d0a3ee Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 3 Feb 2023 09:15:54 +0100 Subject: [PATCH 0107/2810] Filesize timestamp remove state class (#87247) fixes undefined --- homeassistant/components/filesize/sensor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/filesize/sensor.py b/homeassistant/components/filesize/sensor.py index fb5b498e10..2152243b8d 100644 --- a/homeassistant/components/filesize/sensor.py +++ b/homeassistant/components/filesize/sensor.py @@ -56,7 +56,6 @@ SENSOR_TYPES = ( icon=ICON, name="Last Updated", device_class=SensorDeviceClass.TIMESTAMP, - state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), ) From e78dd34a451b9d6a8ddde22db7574bf4d8b863e7 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 3 Feb 2023 15:00:22 +0100 Subject: [PATCH 0108/2810] Bump reolink-aio to 0.3.4 (#87272) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index ab94431405..72ac70ef18 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -3,7 +3,7 @@ "name": "Reolink IP NVR/camera", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/reolink", - "requirements": ["reolink-aio==0.3.2"], + "requirements": ["reolink-aio==0.3.4"], "dependencies": ["webhook"], "codeowners": ["@starkillerOG"], "iot_class": "local_polling", diff --git a/requirements_all.txt b/requirements_all.txt index ac62e334ec..46eb4ac1b0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2227,7 +2227,7 @@ regenmaschine==2022.11.0 renault-api==0.1.11 # homeassistant.components.reolink -reolink-aio==0.3.2 +reolink-aio==0.3.4 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 52727f66ee..8f6d1f15a5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1572,7 +1572,7 @@ regenmaschine==2022.11.0 renault-api==0.1.11 # homeassistant.components.reolink -reolink-aio==0.3.2 +reolink-aio==0.3.4 # homeassistant.components.python_script restrictedpython==6.0 From 9769a0f0ec906254322e51e3beb1a54623f95dcf Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 3 Feb 2023 15:01:25 +0100 Subject: [PATCH 0109/2810] Fix volume state class in renault (#87280) --- homeassistant/components/renault/sensor.py | 2 +- tests/components/renault/const.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index 52538d1e87..d75dc55aa2 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -313,7 +313,7 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( icon="mdi:fuel", name="Fuel quantity", native_unit_of_measurement=UnitOfVolume.LITERS, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL, requires_fuel=True, value_lambda=_get_rounded_value, ), diff --git a/tests/components/renault/const.py b/tests/components/renault/const.py index b15b00b825..ee4f1683aa 100644 --- a/tests/components/renault/const.py +++ b/tests/components/renault/const.py @@ -671,7 +671,7 @@ MOCK_VEHICLES = { ATTR_ENTITY_ID: "sensor.reg_number_fuel_quantity", ATTR_ICON: "mdi:fuel", ATTR_STATE: "3", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.TOTAL, ATTR_UNIQUE_ID: "vf1aaaaa555777123_fuel_quantity", ATTR_UNIT_OF_MEASUREMENT: UnitOfVolume.LITERS, }, @@ -796,7 +796,7 @@ MOCK_VEHICLES = { ATTR_ENTITY_ID: "sensor.reg_number_fuel_quantity", ATTR_ICON: "mdi:fuel", ATTR_STATE: "3", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.TOTAL, ATTR_UNIQUE_ID: "vf1aaaaa555777123_fuel_quantity", ATTR_UNIT_OF_MEASUREMENT: UnitOfVolume.LITERS, }, From 4c0ea397c8422c738d195ad5c3015785e66fc774 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 3 Feb 2023 15:01:51 +0100 Subject: [PATCH 0110/2810] Bump sfrbox-api to 0.0.6 (#87281) --- homeassistant/components/sfr_box/manifest.json | 6 +++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/sfr_box/manifest.json b/homeassistant/components/sfr_box/manifest.json index 78901006d9..eb3c9cb1b6 100644 --- a/homeassistant/components/sfr_box/manifest.json +++ b/homeassistant/components/sfr_box/manifest.json @@ -1,10 +1,10 @@ { "domain": "sfr_box", "name": "SFR Box", + "codeowners": ["@epenet"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sfr_box", - "requirements": ["sfrbox-api==0.0.5"], - "codeowners": ["@epenet"], + "integration_type": "device", "iot_class": "local_polling", - "integration_type": "device" + "requirements": ["sfrbox-api==0.0.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 46eb4ac1b0..891679dcf3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2321,7 +2321,7 @@ sensorpush-ble==1.5.2 sentry-sdk==1.13.0 # homeassistant.components.sfr_box -sfrbox-api==0.0.5 +sfrbox-api==0.0.6 # homeassistant.components.sharkiq sharkiq==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8f6d1f15a5..e84d59293f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1636,7 +1636,7 @@ sensorpush-ble==1.5.2 sentry-sdk==1.13.0 # homeassistant.components.sfr_box -sfrbox-api==0.0.5 +sfrbox-api==0.0.6 # homeassistant.components.sharkiq sharkiq==0.0.1 From 54687d6b56089c76371dcbf3240ff0aebc0bec99 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 4 Feb 2023 11:49:24 +0100 Subject: [PATCH 0111/2810] Extend state class sensor warnings with expected values (#87294) --- homeassistant/components/sensor/__init__.py | 3 +++ homeassistant/components/sensor/const.py | 2 +- tests/components/sensor/test_init.py | 8 +++++++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 35ffc1c3d2..5108a16755 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -503,12 +503,15 @@ class SensorEntity(Entity): _LOGGER.warning( "Entity %s (%s) is using state class '%s' which " "is impossible considering device class ('%s') it is using; " + "expected %s%s; " "Please update your configuration if your entity is manually " "configured, otherwise %s", self.entity_id, type(self), state_class, device_class, + "None or one of " if classes else "None", + ", ".join(f"'{value.value}'" for value in classes), report_issue, ) diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index c8402a28ff..2167e1a1ba 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -501,7 +501,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { SensorDeviceClass.WIND_SPEED: set(UnitOfSpeed), } -DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass | None]] = { +DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { SensorDeviceClass.APPARENT_POWER: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.AQI: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.ATMOSPHERIC_PRESSURE: {SensorStateClass.MEASUREMENT}, diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 89501cf37d..b43c63f015 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -10,6 +10,7 @@ from pytest import approx from homeassistant.components.number import NumberDeviceClass from homeassistant.components.sensor import ( + DEVICE_CLASS_STATE_CLASSES, DEVICE_CLASS_UNITS, SensorDeviceClass, SensorStateClass, @@ -1637,7 +1638,12 @@ async def test_device_classes_with_invalid_state_class( assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) await hass.async_block_till_done() + classes = DEVICE_CLASS_STATE_CLASSES.get(device_class, set()) + one_of = ", ".join(f"'{value.value}'" for value in classes) + expected = f"None or one of {one_of}" if classes else "None" + assert ( "is using state class 'INVALID!' which is impossible considering device " - f"class ('{device_class}') it is using" + f"class ('{device_class}') it is using; " + f"expected {expected}" ) in caplog.text From a5378ec9a81b5ced636db22d6fdc677d83169fa5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 3 Feb 2023 15:59:34 +0100 Subject: [PATCH 0112/2810] Fix incorrect description in sensor group config flow (#87298) --- homeassistant/components/group/strings.json | 3 +-- homeassistant/components/group/translations/en.json | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index dcc97803ad..75a2423d93 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -68,7 +68,6 @@ }, "sensor": { "title": "[%key:component::group::config::step::user::title%]", - "description": "If \"ignore non-numeric\" is enabled, the group's state is calculated if at least one member has a numerical value. If \"ignore non-numeric\" is disabled, the group's state is calculated only if all group members have numerical values.", "data": { "ignore_non_numeric": "Ignore non-numeric", "entities": "Members", @@ -134,7 +133,7 @@ } }, "sensor": { - "description": "[%key:component::group::config::step::sensor::description%]", + "description": "If \"ignore non-numeric\" is enabled, the group's state is calculated if at least one member has a numerical value. If \"ignore non-numeric\" is disabled, the group's state is calculated only if all group members have numerical values.", "data": { "ignore_non_numeric": "[%key:component::group::config::step::sensor::data::ignore_non_numeric%]", "entities": "[%key:component::group::config::step::sensor::data::entities%]", diff --git a/homeassistant/components/group/translations/en.json b/homeassistant/components/group/translations/en.json index 97e7e23238..2a1d93566b 100644 --- a/homeassistant/components/group/translations/en.json +++ b/homeassistant/components/group/translations/en.json @@ -63,7 +63,6 @@ "type": "Type", "unit_of_measurement": "Unit of Measurement" }, - "description": "If \"ignore non-numeric\" is enabled, the group's state is calculated if at least one member has a numerical value. If \"ignore non-numeric\" is disabled, the group's state is calculated only if all group members have numerical values.", "title": "Add Group" }, "switch": { From 681de9a5168616bcad7228310eac99595368ac13 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 3 Feb 2023 16:38:05 +0100 Subject: [PATCH 0113/2810] Don't override icon in sensor group when device class is set (#87304) --- homeassistant/components/group/sensor.py | 11 ++++++++++- tests/components/group/test_sensor.py | 2 ++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py index 52ce90b253..088678b6c1 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -244,7 +244,6 @@ class SensorGroup(GroupEntity, SensorEntity): _attr_available = False _attr_should_poll = False - _attr_icon = "mdi:calculator" def __init__( self, @@ -352,6 +351,16 @@ class SensorGroup(GroupEntity, SensorEntity): return self._attr_device_class return self.calc_device_class + @property + def icon(self) -> str | None: + """Return the icon. + + Only override the icon if the device class is not set. + """ + if not self.device_class: + return "mdi:calculator" + return None + @property def state_class(self) -> SensorStateClass | str | None: """Return state class.""" diff --git a/tests/components/group/test_sensor.py b/tests/components/group/test_sensor.py index 265ee90534..87dbbccab0 100644 --- a/tests/components/group/test_sensor.py +++ b/tests/components/group/test_sensor.py @@ -25,6 +25,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, + ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, SERVICE_RELOAD, STATE_UNAVAILABLE, @@ -100,6 +101,7 @@ async def test_sensors( for key, value in attributes.items(): assert state.attributes.get(key) == value assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.VOLUME + assert state.attributes.get(ATTR_ICON) is None assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "L" From 1df7fcea09ac3fa97f5dab7213114b6ab420cf8b Mon Sep 17 00:00:00 2001 From: Artem Draft Date: Sat, 4 Feb 2023 01:22:33 +0300 Subject: [PATCH 0114/2810] Fix Bravia TV refreshing zero volume level (#87318) fixes undefined --- homeassistant/components/braviatv/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/braviatv/coordinator.py b/homeassistant/components/braviatv/coordinator.py index 6923bacc1a..9b89c667b3 100644 --- a/homeassistant/components/braviatv/coordinator.py +++ b/homeassistant/components/braviatv/coordinator.py @@ -171,7 +171,7 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]): async def async_update_volume(self) -> None: """Update volume information.""" volume_info = await self.client.get_volume_info() - if volume_level := volume_info.get("volume"): + if (volume_level := volume_info.get("volume")) is not None: self.volume_level = volume_level / 100 self.volume_muted = volume_info.get("mute", False) self.volume_target = volume_info.get("target") From 6d2a2b1c9122bf3e3e1310a7076c01ddd93ebdaf Mon Sep 17 00:00:00 2001 From: Koen van Zuijlen <8818390+kvanzuijlen@users.noreply.github.com> Date: Fri, 3 Feb 2023 18:46:11 +0100 Subject: [PATCH 0115/2810] Fixed parser for zeversolar hardware version M10 (#87319) fixes undefined --- homeassistant/components/zeversolar/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zeversolar/manifest.json b/homeassistant/components/zeversolar/manifest.json index 0d67022920..62682709b0 100644 --- a/homeassistant/components/zeversolar/manifest.json +++ b/homeassistant/components/zeversolar/manifest.json @@ -3,7 +3,7 @@ "name": "Zeversolar", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zeversolar", - "requirements": ["zeversolar==0.2.0"], + "requirements": ["zeversolar==0.3.0"], "codeowners": ["@kvanzuijlen"], "iot_class": "local_polling", "integration_type": "device" diff --git a/requirements_all.txt b/requirements_all.txt index 891679dcf3..f0b7436b99 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2690,7 +2690,7 @@ zengge==0.2 zeroconf==0.47.1 # homeassistant.components.zeversolar -zeversolar==0.2.0 +zeversolar==0.3.0 # homeassistant.components.zha zha-quirks==0.0.92 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e84d59293f..319a8c12c1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1903,7 +1903,7 @@ zamg==0.2.2 zeroconf==0.47.1 # homeassistant.components.zeversolar -zeversolar==0.2.0 +zeversolar==0.3.0 # homeassistant.components.zha zha-quirks==0.0.92 From fe7c7001ad713245dee9db0655d75e7f9bee434b Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 3 Feb 2023 20:04:51 +0100 Subject: [PATCH 0116/2810] Fix code format issue in Yale Smart Alarm (#87323) fixes undefined --- homeassistant/components/yale_smart_alarm/lock.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/yale_smart_alarm/lock.py b/homeassistant/components/yale_smart_alarm/lock.py index 807ecdecad..fde08d08fb 100644 --- a/homeassistant/components/yale_smart_alarm/lock.py +++ b/homeassistant/components/yale_smart_alarm/lock.py @@ -45,7 +45,7 @@ class YaleDoorlock(YaleEntity, LockEntity): ) -> None: """Initialize the Yale Lock Device.""" super().__init__(coordinator, data) - self._attr_code_format = f"^\\d{code_format}$" + self._attr_code_format = rf"^\d{{{code_format}}}$" self.lock_name: str = data["name"] async def async_unlock(self, **kwargs: Any) -> None: From c791a7c7fb3dde9310e97767801f56cd10a1e23b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 3 Feb 2023 18:39:30 +0100 Subject: [PATCH 0117/2810] Bump py-synologydsm-api to 2.1.2 (#87324) fixes undefined --- homeassistant/components/synology_dsm/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json index 14f45fbf63..a4b340bd3f 100644 --- a/homeassistant/components/synology_dsm/manifest.json +++ b/homeassistant/components/synology_dsm/manifest.json @@ -2,7 +2,7 @@ "domain": "synology_dsm", "name": "Synology DSM", "documentation": "https://www.home-assistant.io/integrations/synology_dsm", - "requirements": ["py-synologydsm-api==2.1.1"], + "requirements": ["py-synologydsm-api==2.1.2"], "codeowners": ["@hacf-fr", "@Quentame", "@mib1185"], "config_flow": true, "ssdp": [ diff --git a/requirements_all.txt b/requirements_all.txt index f0b7436b99..18835b9564 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1442,7 +1442,7 @@ py-schluter==0.1.7 py-sucks==0.9.8 # homeassistant.components.synology_dsm -py-synologydsm-api==2.1.1 +py-synologydsm-api==2.1.2 # homeassistant.components.zabbix py-zabbix==1.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 319a8c12c1..815b047cb2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1051,7 +1051,7 @@ py-melissa-climate==2.1.4 py-nightscout==1.2.2 # homeassistant.components.synology_dsm -py-synologydsm-api==2.1.1 +py-synologydsm-api==2.1.2 # homeassistant.components.seventeentrack py17track==2021.12.2 From 2d8589078972bc07ba64f2292ad873f8faa0e3d5 Mon Sep 17 00:00:00 2001 From: Luke Date: Sat, 4 Feb 2023 05:54:38 -0500 Subject: [PATCH 0118/2810] Bump oralb-ble to 0.17.2 (#87355) --- homeassistant/components/oralb/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/oralb/manifest.json b/homeassistant/components/oralb/manifest.json index 602c674b32..08b257c186 100644 --- a/homeassistant/components/oralb/manifest.json +++ b/homeassistant/components/oralb/manifest.json @@ -8,7 +8,7 @@ "manufacturer_id": 220 } ], - "requirements": ["oralb-ble==0.17.1"], + "requirements": ["oralb-ble==0.17.2"], "dependencies": ["bluetooth_adapters"], "codeowners": ["@bdraco", "@Lash-L"], "iot_class": "local_push" diff --git a/requirements_all.txt b/requirements_all.txt index 18835b9564..9e08434319 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1299,7 +1299,7 @@ openwrt-luci-rpc==1.1.11 openwrt-ubus-rpc==0.0.2 # homeassistant.components.oralb -oralb-ble==0.17.1 +oralb-ble==0.17.2 # homeassistant.components.oru oru==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 815b047cb2..a485802015 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -947,7 +947,7 @@ openai==0.26.2 openerz-api==0.2.0 # homeassistant.components.oralb -oralb-ble==0.17.1 +oralb-ble==0.17.2 # homeassistant.components.ovo_energy ovoenergy==1.2.0 From 30f63ae0ead6037c5f86a71058e2cbc4a7e32449 Mon Sep 17 00:00:00 2001 From: Matthew Donoughe Date: Sat, 4 Feb 2023 04:17:16 -0500 Subject: [PATCH 0119/2810] Update pylutron-caseta to 0.18.1 (#87361) update pylutron-caseta to 0.18.1 --- homeassistant/components/lutron_caseta/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json index 388ef7c1ee..5948562850 100644 --- a/homeassistant/components/lutron_caseta/manifest.json +++ b/homeassistant/components/lutron_caseta/manifest.json @@ -2,7 +2,7 @@ "domain": "lutron_caseta", "name": "Lutron Cas\u00e9ta", "documentation": "https://www.home-assistant.io/integrations/lutron_caseta", - "requirements": ["pylutron-caseta==0.18.0"], + "requirements": ["pylutron-caseta==0.18.1"], "config_flow": true, "zeroconf": [ { diff --git a/requirements_all.txt b/requirements_all.txt index 9e08434319..114e11b645 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1753,7 +1753,7 @@ pylitejet==0.5.0 pylitterbot==2023.1.1 # homeassistant.components.lutron_caseta -pylutron-caseta==0.18.0 +pylutron-caseta==0.18.1 # homeassistant.components.lutron pylutron==0.2.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a485802015..e440c70f5a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1260,7 +1260,7 @@ pylitejet==0.5.0 pylitterbot==2023.1.1 # homeassistant.components.lutron_caseta -pylutron-caseta==0.18.0 +pylutron-caseta==0.18.1 # homeassistant.components.mailgun pymailgunner==1.4 From 2d5ab5a0dc884d8d9e17465808b452419697829a Mon Sep 17 00:00:00 2001 From: Jc2k Date: Sat, 4 Feb 2023 11:38:22 +0000 Subject: [PATCH 0120/2810] Fix exception when trying to poll a HomeKit device over Thread with no active encryption context (#87379) Bump aiohomekit==2.4.5 --- homeassistant/components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 9edcba5bf7..d5dc221ad4 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit Controller", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homekit_controller", - "requirements": ["aiohomekit==2.4.4"], + "requirements": ["aiohomekit==2.4.5"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."], "bluetooth": [{ "manufacturer_id": 76, "manufacturer_data_start": [6] }], "dependencies": ["bluetooth_adapters", "zeroconf"], diff --git a/requirements_all.txt b/requirements_all.txt index 114e11b645..c7dab503ae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -174,7 +174,7 @@ aioguardian==2022.07.0 aioharmony==0.2.9 # homeassistant.components.homekit_controller -aiohomekit==2.4.4 +aiohomekit==2.4.5 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e440c70f5a..6de0ac3142 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -158,7 +158,7 @@ aioguardian==2022.07.0 aioharmony==0.2.9 # homeassistant.components.homekit_controller -aiohomekit==2.4.4 +aiohomekit==2.4.5 # homeassistant.components.emulated_hue # homeassistant.components.http From 23e04ba8915bb95ff2f849e27abb2043a2143b88 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Sat, 4 Feb 2023 20:12:34 +0200 Subject: [PATCH 0121/2810] Fix Ruuvi Gateway data being ignored when system is not using UTC time (#87384) --- homeassistant/components/ruuvi_gateway/bluetooth.py | 11 ++++++----- homeassistant/components/ruuvi_gateway/const.py | 7 ------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/ruuvi_gateway/bluetooth.py b/homeassistant/components/ruuvi_gateway/bluetooth.py index f5748b8b4e..4dd973155a 100644 --- a/homeassistant/components/ruuvi_gateway/bluetooth.py +++ b/homeassistant/components/ruuvi_gateway/bluetooth.py @@ -2,12 +2,13 @@ from __future__ import annotations from collections.abc import Callable -import datetime import logging +import time from home_assistant_bluetooth import BluetoothServiceInfoBleak from homeassistant.components.bluetooth import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, BaseHaRemoteScanner, async_get_advertisement_callback, async_register_scanner, @@ -15,7 +16,6 @@ from homeassistant.components.bluetooth import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from .const import OLD_ADVERTISEMENT_CUTOFF from .coordinator import RuuviGatewayUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -46,10 +46,11 @@ class RuuviGatewayScanner(BaseHaRemoteScanner): @callback def _async_handle_new_data(self) -> None: - now = datetime.datetime.now() + now = time.time() for tag_data in self.coordinator.data: - if now - tag_data.datetime > OLD_ADVERTISEMENT_CUTOFF: - # Don't process data that is older than 10 minutes + data_age_seconds = now - tag_data.timestamp # Both are Unix time + if data_age_seconds > FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS: + # Don't process stale data at all continue anno = tag_data.parse_announcement() self._async_on_advertisement( diff --git a/homeassistant/components/ruuvi_gateway/const.py b/homeassistant/components/ruuvi_gateway/const.py index 609bad9a22..80bebda105 100644 --- a/homeassistant/components/ruuvi_gateway/const.py +++ b/homeassistant/components/ruuvi_gateway/const.py @@ -1,12 +1,5 @@ """Constants for the Ruuvi Gateway integration.""" from datetime import timedelta -from homeassistant.components.bluetooth import ( - FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, -) - DOMAIN = "ruuvi_gateway" SCAN_INTERVAL = timedelta(seconds=5) -OLD_ADVERTISEMENT_CUTOFF = timedelta( - seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS -) From 93229196d8ea4bc8f9a0654ed6fe07a5455b143f Mon Sep 17 00:00:00 2001 From: Vincent Knoop Pathuis <48653141+vpathuis@users.noreply.github.com> Date: Sun, 5 Feb 2023 02:06:42 +0100 Subject: [PATCH 0122/2810] Fix state class in Enphase Envoy (#87397) * Change total_increasing to total * As suggested in PR: only Last Seven Days TOTAL --- homeassistant/components/enphase_envoy/const.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/enphase_envoy/const.py b/homeassistant/components/enphase_envoy/const.py index 16aae3f9a6..cd3235f1be 100644 --- a/homeassistant/components/enphase_envoy/const.py +++ b/homeassistant/components/enphase_envoy/const.py @@ -33,7 +33,7 @@ SENSORS = ( key="seven_days_production", name="Last Seven Days Energy Production", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( @@ -61,7 +61,7 @@ SENSORS = ( key="seven_days_consumption", name="Last Seven Days Energy Consumption", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( From 323ab97ff5e437ca27213a8bc99b040fd9253ce7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 Feb 2023 16:29:03 -0600 Subject: [PATCH 0123/2810] Ignore invalid zeroconf names from devices with broken firmwares (#87414) --- homeassistant/components/zeroconf/__init__.py | 15 +++++++- tests/components/zeroconf/test_init.py | 38 ++++++++++++++++++- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 6b54aa1896..088e46aa1c 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -15,7 +15,12 @@ import sys from typing import Any, Final, cast import voluptuous as vol -from zeroconf import InterfaceChoice, IPVersion, ServiceStateChange +from zeroconf import ( + BadTypeInNameException, + InterfaceChoice, + IPVersion, + ServiceStateChange, +) from zeroconf.asyncio import AsyncServiceInfo from homeassistant import config_entries @@ -399,7 +404,13 @@ class ZeroconfDiscovery: self, zeroconf: HaZeroconf, service_type: str, name: str ) -> None: """Process a zeroconf update.""" - async_service_info = AsyncServiceInfo(service_type, name) + try: + async_service_info = AsyncServiceInfo(service_type, name) + except BadTypeInNameException as ex: + # Some devices broadcast a name that is not a valid DNS name + # This is a bug in the device firmware and we should ignore it + _LOGGER.debug("Bad name in zeroconf record: %s: %s", name, ex) + return await async_service_info.async_request(zeroconf, 3000) info = info_from_service(async_service_info) diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 5e499c93ff..fd383fd653 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -3,7 +3,12 @@ from ipaddress import ip_address from typing import Any from unittest.mock import call, patch -from zeroconf import InterfaceChoice, IPVersion, ServiceStateChange +from zeroconf import ( + BadTypeInNameException, + InterfaceChoice, + IPVersion, + ServiceStateChange, +) from zeroconf.asyncio import AsyncServiceInfo from homeassistant.components import zeroconf @@ -556,6 +561,37 @@ async def test_homekit_match_partial_space(hass, mock_async_zeroconf): } +async def test_device_with_invalid_name(hass, mock_async_zeroconf, caplog): + """Test we ignore devices with an invalid name.""" + with patch.dict( + zc_gen.ZEROCONF, + {"_hap._tcp.local.": [{"domain": "homekit_controller"}]}, + clear=True, + ), patch.dict( + zc_gen.HOMEKIT, + {"LIFX": "lifx"}, + clear=True, + ), patch.object( + hass.config_entries.flow, "async_init" + ) as mock_config_flow, patch.object( + zeroconf, + "HaAsyncServiceBrowser", + side_effect=lambda *args, **kwargs: service_update_mock( + *args, **kwargs, limit_service="_hap._tcp.local." + ), + ) as mock_service_browser, patch( + "homeassistant.components.zeroconf.AsyncServiceInfo", + side_effect=BadTypeInNameException, + ): + assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert len(mock_service_browser.mock_calls) == 1 + assert len(mock_config_flow.mock_calls) == 0 + assert "Bad name in zeroconf record" in caplog.text + + async def test_homekit_match_partial_dash(hass, mock_async_zeroconf): """Test configured options for a device are loaded via config entry.""" with patch.dict( From 1f08fc72bb8036376164f61f3a81f9920606f0c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 Feb 2023 14:34:34 -0600 Subject: [PATCH 0124/2810] Disable mopeka accelerometer sensors by default (#87420) * Disable mopeka accelerometer sensors by default These generate a significant amount of noise and are only useful when placing the sensor. Disable them by default. This not a breaking change because existing preferences are preserved. * adjust tests --- homeassistant/components/mopeka/sensor.py | 2 ++ tests/components/mopeka/test_sensor.py | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mopeka/sensor.py b/homeassistant/components/mopeka/sensor.py index 26965cd9a1..f4dba535b7 100644 --- a/homeassistant/components/mopeka/sensor.py +++ b/homeassistant/components/mopeka/sensor.py @@ -76,10 +76,12 @@ SENSOR_DESCRIPTIONS = { "accelerometer_x": SensorEntityDescription( key="accelerometer_x", entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), "accelerometer_y": SensorEntityDescription( key="accelerometer_y", entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), } diff --git a/tests/components/mopeka/test_sensor.py b/tests/components/mopeka/test_sensor.py index 27704ec38e..04761ffc8d 100644 --- a/tests/components/mopeka/test_sensor.py +++ b/tests/components/mopeka/test_sensor.py @@ -31,7 +31,7 @@ async def test_sensors_bad_signal(hass): assert len(hass.states.async_all("sensor")) == 0 inject_bluetooth_service_info(hass, PRO_SERVICE_INFO) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 6 + assert len(hass.states.async_all("sensor")) == 4 temp_sensor = hass.states.get("sensor.pro_plus_eeff_temperature") temp_sensor_attrs = temp_sensor.attributes @@ -65,7 +65,7 @@ async def test_sensors_good_signal(hass): assert len(hass.states.async_all("sensor")) == 0 inject_bluetooth_service_info(hass, PRO_GOOD_SIGNAL_SERVICE_INFO) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 6 + assert len(hass.states.async_all("sensor")) == 4 temp_sensor = hass.states.get("sensor.pro_plus_eeff_temperature") temp_sensor_attrs = temp_sensor.attributes From f4f1b0dfc508d8458feab23a87aec411b005f770 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 Feb 2023 19:05:40 -0600 Subject: [PATCH 0125/2810] Bump aiohomekit to 2.4.6 (#87427) fixes #86083 changelog: https://github.com/Jc2k/aiohomekit/compare/2.4.5...2.4.6 --- homeassistant/components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index d5dc221ad4..f0a8cb74ef 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit Controller", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homekit_controller", - "requirements": ["aiohomekit==2.4.5"], + "requirements": ["aiohomekit==2.4.6"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."], "bluetooth": [{ "manufacturer_id": 76, "manufacturer_data_start": [6] }], "dependencies": ["bluetooth_adapters", "zeroconf"], diff --git a/requirements_all.txt b/requirements_all.txt index c7dab503ae..7386175145 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -174,7 +174,7 @@ aioguardian==2022.07.0 aioharmony==0.2.9 # homeassistant.components.homekit_controller -aiohomekit==2.4.5 +aiohomekit==2.4.6 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6de0ac3142..c4dc297934 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -158,7 +158,7 @@ aioguardian==2022.07.0 aioharmony==0.2.9 # homeassistant.components.homekit_controller -aiohomekit==2.4.5 +aiohomekit==2.4.6 # homeassistant.components.emulated_hue # homeassistant.components.http From 3253eeca7aac398543cf07026e58e9adf7b83350 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 4 Feb 2023 21:08:23 -0500 Subject: [PATCH 0126/2810] Bumped version to 2023.2.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 070f9a3786..168bf80aa1 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 = 2 -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 affc4b6446..825c2bc356 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.2.1" +version = "2023.2.2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From d82e409d8e18b346567721988436b7132e8543ac Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 2 Feb 2023 18:35:24 +0100 Subject: [PATCH 0127/2810] Update black to 23.1.0 (#87188) --- .pre-commit-config.yaml | 2 +- homeassistant/bootstrap.py | 1 - homeassistant/components/adax/__init__.py | 1 - .../components/aladdin_connect/config_flow.py | 1 - homeassistant/components/alert/__init__.py | 1 - homeassistant/components/alexa/auth.py | 1 - homeassistant/components/alexa/intent.py | 1 - homeassistant/components/asuswrt/router.py | 1 - .../components/axis/binary_sensor.py | 2 -- .../components/bayesian/binary_sensor.py | 1 - .../components/bluesound/media_player.py | 1 - homeassistant/components/bluetooth/models.py | 1 - homeassistant/components/bosch_shc/switch.py | 5 ---- homeassistant/components/bthome/sensor.py | 10 ++++++-- homeassistant/components/buienradar/sensor.py | 1 - .../components/control4/config_flow.py | 1 - homeassistant/components/darksky/weather.py | 1 + .../components/deconz/binary_sensor.py | 1 - .../components/deconz/config_flow.py | 1 - homeassistant/components/deconz/sensor.py | 1 - homeassistant/components/deconz/services.py | 2 -- homeassistant/components/delijn/sensor.py | 2 +- homeassistant/components/derivative/sensor.py | 2 +- .../device_sun_light_trigger/__init__.py | 1 - homeassistant/components/dnsip/config_flow.py | 1 - .../components/doods/image_processing.py | 1 - homeassistant/components/ebusd/__init__.py | 1 - homeassistant/components/ecovacs/vacuum.py | 1 - homeassistant/components/elv/switch.py | 2 +- homeassistant/components/emoncms/sensor.py | 1 - .../components/emulated_hue/hue_api.py | 6 ++--- homeassistant/components/escea/config_flow.py | 1 - homeassistant/components/ezviz/camera.py | 3 --- homeassistant/components/ezviz/config_flow.py | 1 - .../components/faa_delays/config_flow.py | 1 - homeassistant/components/filter/sensor.py | 3 --- homeassistant/components/fints/sensor.py | 1 - homeassistant/components/firmata/__init__.py | 2 +- .../components/fleetgo/device_tracker.py | 1 - homeassistant/components/flipr/__init__.py | 2 +- homeassistant/components/flo/device.py | 2 +- homeassistant/components/flume/sensor.py | 1 - homeassistant/components/fritz/switch.py | 2 -- homeassistant/components/glances/sensor.py | 1 - .../components/google_assistant/helpers.py | 1 - .../components/harmony/config_flow.py | 1 - .../components/homematicip_cloud/climate.py | 7 +++++- homeassistant/components/honeywell/climate.py | 1 - .../components/honeywell/config_flow.py | 1 - homeassistant/components/hue/migration.py | 2 -- .../hvv_departures/binary_sensor.py | 1 - .../components/hvv_departures/config_flow.py | 3 --- homeassistant/components/hyperion/__init__.py | 2 +- homeassistant/components/ifttt/__init__.py | 1 - .../components/intellifire/config_flow.py | 1 - .../components/intellifire/coordinator.py | 1 - .../components/isy994/binary_sensor.py | 2 +- homeassistant/components/isy994/helpers.py | 1 - homeassistant/components/izone/climate.py | 5 ++-- homeassistant/components/izone/config_flow.py | 1 - .../components/juicenet/config_flow.py | 1 - .../components/kitchen_sink/repairs.py | 2 +- .../landisgyr_heat_meter/__init__.py | 1 - homeassistant/components/lifx/manager.py | 4 ---- homeassistant/components/london_air/sensor.py | 1 - homeassistant/components/lovelace/__init__.py | 1 - homeassistant/components/lupusec/switch.py | 1 - .../components/lutron/binary_sensor.py | 2 +- homeassistant/components/lutron/cover.py | 2 +- homeassistant/components/lutron/light.py | 2 +- homeassistant/components/lutron/switch.py | 2 +- .../components/lutron_caseta/__init__.py | 1 - .../components/lutron_caseta/button.py | 1 - .../components/mediaroom/media_player.py | 1 - .../components/melnor/config_flow.py | 2 -- homeassistant/components/met/weather.py | 2 +- .../components/meteo_france/__init__.py | 1 - .../components/mikrotik/device_tracker.py | 2 -- .../components/minecraft_server/helpers.py | 2 +- homeassistant/components/modbus/__init__.py | 5 +++- .../components/mold_indicator/sensor.py | 1 - .../components/mqtt/binary_sensor.py | 1 - homeassistant/components/mqtt/client.py | 1 - homeassistant/components/mqtt/discovery.py | 2 +- homeassistant/components/mqtt/subscription.py | 6 ++++- homeassistant/components/nam/config_flow.py | 1 - homeassistant/components/nest/config_flow.py | 1 - .../components/nfandroidtv/config_flow.py | 1 - .../components/nibe_heatpump/__init__.py | 1 - homeassistant/components/nina/__init__.py | 1 - homeassistant/components/nina/config_flow.py | 2 -- .../components/numato/binary_sensor.py | 1 - homeassistant/components/onvif/config_flow.py | 1 - .../components/open_meteo/weather.py | 1 - .../components/opencv/image_processing.py | 2 +- .../components/openhome/media_player.py | 12 ++++++---- .../weather_update_coordinator.py | 1 - .../components/philips_js/config_flow.py | 1 - .../components/philips_js/media_player.py | 1 - homeassistant/components/plex/__init__.py | 1 - .../components/poolsense/__init__.py | 2 +- .../components/progettihwsw/config_flow.py | 1 - homeassistant/components/ps4/media_player.py | 1 - .../components/pushbullet/config_flow.py | 1 - .../components/pushover/config_flow.py | 1 - homeassistant/components/pushover/notify.py | 1 - homeassistant/components/qvr_pro/camera.py | 1 - homeassistant/components/rachio/switch.py | 2 +- .../components/rainbird/config_flow.py | 2 +- homeassistant/components/recorder/pool.py | 1 - homeassistant/components/recorder/purge.py | 1 - .../components/repairs/issue_handler.py | 2 +- .../components/rest_command/__init__.py | 1 - .../components/rfxtrx/binary_sensor.py | 1 - .../components/rss_feed_template/__init__.py | 2 +- homeassistant/components/sabnzbd/__init__.py | 1 - .../components/sabnzbd/config_flow.py | 1 - homeassistant/components/script/__init__.py | 1 - .../components/sensibo/config_flow.py | 1 - homeassistant/components/shelly/climate.py | 1 - .../components/simplepush/config_flow.py | 1 - .../components/smartthings/__init__.py | 1 + homeassistant/components/snmp/sensor.py | 1 - homeassistant/components/snmp/switch.py | 1 - homeassistant/components/sonos/speaker.py | 2 +- homeassistant/components/spc/__init__.py | 1 - homeassistant/components/sql/config_flow.py | 1 - .../components/srp_energy/__init__.py | 2 +- .../components/srp_energy/config_flow.py | 1 - homeassistant/components/srp_energy/sensor.py | 2 +- homeassistant/components/supla/__init__.py | 1 - .../components/surepetcare/binary_sensor.py | 1 - .../components/surepetcare/sensor.py | 1 - .../swiss_hydrological_data/sensor.py | 1 - homeassistant/components/switchbee/climate.py | 1 - homeassistant/components/switchbee/light.py | 1 - homeassistant/components/switchbee/switch.py | 1 - .../components/system_log/__init__.py | 2 -- homeassistant/components/tasmota/discovery.py | 4 ++-- .../components/tellduslive/config_flow.py | 1 - homeassistant/components/template/cover.py | 1 - homeassistant/components/template/fan.py | 1 - homeassistant/components/template/switch.py | 1 - .../components/tomato/device_tracker.py | 2 -- homeassistant/components/toon/coordinator.py | 1 - .../components/traccar/device_tracker.py | 6 ++++- .../components/trafikverket_train/sensor.py | 1 - .../components/transmission/config_flow.py | 1 - homeassistant/components/tuya/number.py | 1 - homeassistant/components/twinkly/light.py | 2 -- .../components/uk_transport/sensor.py | 2 +- homeassistant/components/unifi/config_flow.py | 2 -- homeassistant/components/unifi/controller.py | 3 --- .../components/unifi/device_tracker.py | 1 - homeassistant/components/unifi/services.py | 2 -- .../components/unifi_direct/device_tracker.py | 1 - .../components/unifiprotect/button.py | 1 - .../components/unifiprotect/sensor.py | 1 - .../components/w800rf32/binary_sensor.py | 2 -- homeassistant/components/whirlpool/sensor.py | 1 - homeassistant/components/ws66i/__init__.py | 1 - homeassistant/components/zamg/coordinator.py | 2 +- homeassistant/components/zamg/sensor.py | 2 +- homeassistant/components/zha/core/device.py | 6 ++--- homeassistant/components/zha/core/helpers.py | 1 - homeassistant/helpers/entity_platform.py | 6 ++--- homeassistant/helpers/network.py | 1 - homeassistant/helpers/service.py | 1 - requirements_test_pre_commit.txt | 2 +- script/translations/migrate.py | 2 -- tests/components/abode/test_config_flow.py | 1 - .../accuweather/test_config_flow.py | 4 ---- tests/components/accuweather/test_init.py | 2 -- tests/components/adax/test_config_flow.py | 5 +++- .../components/airthings/test_config_flow.py | 5 +++- .../components/aladdin_connect/test_cover.py | 2 -- tests/components/aladdin_connect/test_init.py | 5 ---- tests/components/alexa/test_smart_home.py | 10 ++++++-- tests/components/analytics/test_analytics.py | 2 -- .../components/androidtv/test_config_flow.py | 1 - .../aurora_abb_powerone/test_config_flow.py | 11 ++++++--- .../aussie_broadband/test_config_flow.py | 2 -- tests/components/aws/test_init.py | 1 - tests/components/axis/conftest.py | 1 - tests/components/backup/test_http.py | 1 - tests/components/backup/test_websocket.py | 1 - tests/components/blink/test_config_flow.py | 4 +++- .../components/bluetooth/test_base_scanner.py | 5 +++- .../components/bluetooth/test_diagnostics.py | 1 - tests/components/bluetooth/test_wrappers.py | 4 ++-- .../test_device_tracker.py | 4 ---- tests/components/brother/test_config_flow.py | 3 --- tests/components/canary/test_sensor.py | 4 ++-- tests/components/cloudflare/test_init.py | 1 - .../components/co2signal/test_config_flow.py | 15 +++++++++--- tests/components/coinbase/test_config_flow.py | 1 - tests/components/coinbase/test_diagnostics.py | 1 - .../deconz/test_alarm_control_panel.py | 2 -- tests/components/dhcp/test_init.py | 4 +++- .../components/dlna_dmr/test_media_player.py | 2 +- tests/components/ecobee/test_config_flow.py | 1 - tests/components/elkm1/__init__.py | 5 +++- tests/components/file/test_notify.py | 1 - tests/components/filesize/test_config_flow.py | 8 +++++-- .../fireservicerota/test_config_flow.py | 1 - tests/components/firmata/test_config_flow.py | 1 - .../freedompro/test_binary_sensor.py | 2 -- .../components/freedompro/test_config_flow.py | 3 --- tests/components/freedompro/test_sensor.py | 1 - tests/components/fritz/conftest.py | 1 - tests/components/fritz/test_config_flow.py | 13 ---------- tests/components/fronius/test_config_flow.py | 5 +++- tests/components/generic/test_camera.py | 2 -- tests/components/greeneye_monitor/conftest.py | 2 +- tests/components/hddtemp/test_sensor.py | 1 - tests/components/history_stats/test_sensor.py | 4 ---- .../components/honeywell/test_config_flow.py | 1 - tests/components/hue/test_config_flow.py | 2 +- tests/components/hue/test_init.py | 1 - .../components/huisbaasje/test_config_flow.py | 5 +++- tests/components/huisbaasje/test_sensor.py | 2 -- .../hvv_departures/test_config_flow.py | 5 ---- tests/components/insteon/test_config_flow.py | 17 +++++++++---- .../intellifire/test_config_flow.py | 2 -- tests/components/ipma/test_config_flow.py | 2 -- tests/components/ipma/test_init.py | 1 - tests/components/iss/test_config_flow.py | 1 - .../lacrosse_view/test_config_flow.py | 10 ++++++-- .../launch_library/test_config_flow.py | 1 - tests/components/led_ble/test_config_flow.py | 16 +++++++++---- .../litterrobot/test_config_flow.py | 2 -- tests/components/local_file/test_camera.py | 1 - tests/components/melnor/conftest.py | 1 - tests/components/melnor/test_config_flow.py | 5 ---- tests/components/melnor/test_number.py | 1 - tests/components/melnor/test_switch.py | 1 - .../minecraft_server/test_config_flow.py | 15 +++++++++--- tests/components/modbus/test_init.py | 2 +- .../moehlenhoff_alpha2/test_config_flow.py | 1 - tests/components/mqtt/test_common.py | 12 +++++----- tests/components/mqtt/test_discovery.py | 2 -- .../mqtt_json/test_device_tracker.py | 1 - tests/components/mutesync/test_config_flow.py | 5 +++- tests/components/nam/test_config_flow.py | 1 - tests/components/netatmo/test_init.py | 1 - tests/components/nexia/test_config_flow.py | 4 +++- tests/components/nina/test_binary_sensor.py | 2 -- tests/components/nina/test_config_flow.py | 5 ---- tests/components/nina/test_init.py | 1 - tests/components/nzbget/test_sensor.py | 2 +- tests/components/oncue/__init__.py | 9 ++++--- tests/components/plaato/test_config_flow.py | 2 -- tests/components/plant/test_init.py | 1 - .../prosegur/test_alarm_control_panel.py | 2 -- tests/components/prosegur/test_init.py | 1 - .../components/radiotherm/test_config_flow.py | 1 - tests/components/recorder/test_migrate.py | 5 +++- .../components/repairs/test_websocket_api.py | 2 +- tests/components/roon/test_config_flow.py | 5 ---- .../components/samsungtv/test_config_flow.py | 4 ---- .../components/samsungtv/test_media_player.py | 3 --- tests/components/scrape/test_config_flow.py | 5 +++- tests/components/script/test_blueprint.py | 1 - tests/components/senseme/__init__.py | 1 - tests/components/shelly/test_config_flow.py | 1 - tests/components/shelly/test_init.py | 2 -- tests/components/sleepiq/test_config_flow.py | 1 - tests/components/sma/test_config_flow.py | 1 - tests/components/smappee/test_config_flow.py | 5 +++- .../smartthings/test_config_flow.py | 1 - tests/components/sonarr/test_sensor.py | 2 +- tests/components/sql/test_sensor.py | 4 +++- .../components/squeezebox/test_config_flow.py | 10 ++++++-- tests/components/srp_energy/__init__.py | 1 - .../components/srp_energy/test_config_flow.py | 1 - tests/components/subaru/conftest.py | 5 +++- tests/components/subaru/test_config_flow.py | 9 +++++-- tests/components/subaru/test_init.py | 5 +++- .../components/switchbee/test_config_flow.py | 1 - .../synology_dsm/test_config_flow.py | 4 ---- .../tankerkoenig/test_config_flow.py | 1 - tests/components/tibber/test_statistics.py | 2 +- .../totalconnect/test_config_flow.py | 12 ++++++---- tests/components/upnp/conftest.py | 2 ++ tests/components/uptimerobot/common.py | 1 - .../uptimerobot/test_config_flow.py | 4 ---- .../uptimerobot/test_diagnostics.py | 2 -- tests/components/uptimerobot/test_init.py | 2 -- tests/components/uptimerobot/test_switch.py | 2 -- tests/components/vera/test_sensor.py | 2 +- tests/components/version/common.py | 2 -- tests/components/vulcan/test_config_flow.py | 6 ----- tests/components/vultr/test_binary_sensor.py | 1 - tests/components/vultr/test_sensor.py | 2 -- tests/components/wake_on_lan/test_switch.py | 7 ------ tests/components/wallbox/test_number.py | 1 - .../components/whirlpool/test_config_flow.py | 1 - tests/components/wolflink/test_config_flow.py | 1 - tests/components/ws66i/test_config_flow.py | 1 - tests/components/yeelight/test_config_flow.py | 1 - tests/components/zeroconf/test_init.py | 24 +++++++++++++++---- tests/components/zha/test_channels.py | 2 +- tests/components/zwave_me/test_config_flow.py | 1 - .../zwave_me/test_remove_stale_devices.py | 5 +++- tests/conftest.py | 1 - tests/helpers/test_config_entry_flow.py | 2 -- tests/helpers/test_device_registry.py | 2 +- tests/helpers/test_entity.py | 1 - tests/helpers/test_translation.py | 6 ++++- tests/scripts/test_check_config.py | 1 - tests/test_config_entries.py | 1 - tests/test_requirements.py | 5 ---- 312 files changed, 299 insertions(+), 478 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f7684e4d4d..4fa5a1a716 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,7 +20,7 @@ repos: - --remove-all-unused-imports stages: [manual] - repo: https://github.com/psf/black - rev: 22.12.0 + rev: 23.1.0 hooks: - id: black args: diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index e821d0de10..1cc850f31e 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -406,7 +406,6 @@ def async_enable_logging( if (err_path_exists and os.access(err_log_path, os.W_OK)) or ( not err_path_exists and os.access(err_dir, os.W_OK) ): - err_handler: ( logging.handlers.RotatingFileHandler | logging.handlers.TimedRotatingFileHandler diff --git a/homeassistant/components/adax/__init__.py b/homeassistant/components/adax/__init__.py index 438f1fc74a..511fb74621 100644 --- a/homeassistant/components/adax/__init__.py +++ b/homeassistant/components/adax/__init__.py @@ -24,7 +24,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # convert title and unique_id to string if config_entry.version == 1: if isinstance(config_entry.unique_id, int): - hass.config_entries.async_update_entry( config_entry, unique_id=str(config_entry.unique_id), diff --git a/homeassistant/components/aladdin_connect/config_flow.py b/homeassistant/components/aladdin_connect/config_flow.py index eb201182b6..89d3b0faf1 100644 --- a/homeassistant/components/aladdin_connect/config_flow.py +++ b/homeassistant/components/aladdin_connect/config_flow.py @@ -88,7 +88,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" else: - self.hass.config_entries.async_update_entry( self.entry, data={ diff --git a/homeassistant/components/alert/__init__.py b/homeassistant/components/alert/__init__.py index 4f8f1dade9..120b24dcc4 100644 --- a/homeassistant/components/alert/__init__.py +++ b/homeassistant/components/alert/__init__.py @@ -270,7 +270,6 @@ class Alert(Entity): await self._send_notification_message(message) async def _send_notification_message(self, message: Any) -> None: - if not self._notifiers: return diff --git a/homeassistant/components/alexa/auth.py b/homeassistant/components/alexa/auth.py index 1ce20d154b..2dbda64568 100644 --- a/homeassistant/components/alexa/auth.py +++ b/homeassistant/components/alexa/auth.py @@ -103,7 +103,6 @@ class Auth: return dt.utcnow() < preemptive_expire_time async def _async_request_new_token(self, lwa_params): - try: session = aiohttp_client.async_get_clientsession(self.hass) async with async_timeout.timeout(10): diff --git a/homeassistant/components/alexa/intent.py b/homeassistant/components/alexa/intent.py index ef145a9ceb..c18ffd316b 100644 --- a/homeassistant/components/alexa/intent.py +++ b/homeassistant/components/alexa/intent.py @@ -193,7 +193,6 @@ def resolve_slot_synonyms(key, request): and "resolutionsPerAuthority" in request["resolutions"] and len(request["resolutions"]["resolutionsPerAuthority"]) >= 1 ): - # Extract all of the possible values from each authority with a # successful match possible_values = [] diff --git a/homeassistant/components/asuswrt/router.py b/homeassistant/components/asuswrt/router.py index ffdec02bd3..4291c21d0e 100644 --- a/homeassistant/components/asuswrt/router.py +++ b/homeassistant/components/asuswrt/router.py @@ -274,7 +274,6 @@ class AsusWrtRouter: entity_reg, self._entry.entry_id ) for entry in track_entries: - if entry.domain != TRACKER_DOMAIN: continue device_mac = format_mac(entry.unique_id) diff --git a/homeassistant/components/axis/binary_sensor.py b/homeassistant/components/axis/binary_sensor.py index 729d69ed45..067014cc81 100644 --- a/homeassistant/components/axis/binary_sensor.py +++ b/homeassistant/components/axis/binary_sensor.py @@ -112,7 +112,6 @@ class AxisBinarySensor(AxisEventEntity, BinarySensorEntity): self._attr_name = self.device.api.vapix.ports[event.id].name elif event.group == EventGroup.MOTION: - for event_topic, event_data in ( (EventTopic.FENCE_GUARD, self.device.api.vapix.fence_guard), (EventTopic.LOITERING_GUARD, self.device.api.vapix.loitering_guard), @@ -120,7 +119,6 @@ class AxisBinarySensor(AxisEventEntity, BinarySensorEntity): (EventTopic.OBJECT_ANALYTICS, self.device.api.vapix.object_analytics), (EventTopic.MOTION_DETECTION_4, self.device.api.vapix.vmd4), ): - if ( event.topic_base == event_topic and event_data diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py index 4cd68c7a9b..5266e31042 100644 --- a/homeassistant/components/bayesian/binary_sensor.py +++ b/homeassistant/components/bayesian/binary_sensor.py @@ -399,7 +399,6 @@ class BayesianBinarySensor(BinarySensorEntity): observations_by_entity: dict[str, list[Observation]] = {} for observation in self._observations: - if (key := observation.entity_id) is None: continue observations_by_entity.setdefault(key, []).append(observation) diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 7c23e76385..69e115470a 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -396,7 +396,6 @@ class BluesoundPlayer(MediaPlayerEntity): _LOGGER.debug("Calling URL: %s", url) try: - async with async_timeout.timeout(125): response = await self._polling_session.get( url, headers={CONNECTION: KEEP_ALIVE} diff --git a/homeassistant/components/bluetooth/models.py b/homeassistant/components/bluetooth/models.py index 58067a467c..40ac86de60 100644 --- a/homeassistant/components/bluetooth/models.py +++ b/homeassistant/components/bluetooth/models.py @@ -12,7 +12,6 @@ from home_assistant_bluetooth import BluetoothServiceInfoBleak from homeassistant.util.dt import monotonic_time_coarse if TYPE_CHECKING: - from .manager import BluetoothManager diff --git a/homeassistant/components/bosch_shc/switch.py b/homeassistant/components/bosch_shc/switch.py index 1bc430c8d4..dc9159622d 100644 --- a/homeassistant/components/bosch_shc/switch.py +++ b/homeassistant/components/bosch_shc/switch.py @@ -95,7 +95,6 @@ async def async_setup_entry( session: SHCSession = hass.data[DOMAIN][config_entry.entry_id][DATA_SESSION] for switch in session.device_helper.smart_plugs: - entities.append( SHCSwitch( device=switch, @@ -113,7 +112,6 @@ async def async_setup_entry( ) for switch in session.device_helper.light_switches: - entities.append( SHCSwitch( device=switch, @@ -124,7 +122,6 @@ async def async_setup_entry( ) for switch in session.device_helper.smart_plugs_compact: - entities.append( SHCSwitch( device=switch, @@ -135,7 +132,6 @@ async def async_setup_entry( ) for switch in session.device_helper.camera_eyes: - entities.append( SHCSwitch( device=switch, @@ -146,7 +142,6 @@ async def async_setup_entry( ) for switch in session.device_helper.camera_360: - entities.append( SHCSwitch( device=switch, diff --git a/homeassistant/components/bthome/sensor.py b/homeassistant/components/bthome/sensor.py index 8cc8b10a67..2d4fb07d57 100644 --- a/homeassistant/components/bthome/sensor.py +++ b/homeassistant/components/bthome/sensor.py @@ -231,7 +231,10 @@ SENSOR_DESCRIPTIONS = { state_class=SensorStateClass.MEASUREMENT, ), # UV index (-) - (BTHomeSensorDeviceClass.UV_INDEX, None,): SensorEntityDescription( + ( + BTHomeSensorDeviceClass.UV_INDEX, + None, + ): SensorEntityDescription( key=f"{BTHomeSensorDeviceClass.UV_INDEX}", state_class=SensorStateClass.MEASUREMENT, ), @@ -256,7 +259,10 @@ SENSOR_DESCRIPTIONS = { state_class=SensorStateClass.MEASUREMENT, ), # Volume (L) - (BTHomeSensorDeviceClass.VOLUME, Units.VOLUME_LITERS,): SensorEntityDescription( + ( + BTHomeSensorDeviceClass.VOLUME, + Units.VOLUME_LITERS, + ): SensorEntityDescription( key=f"{BTHomeSensorDeviceClass.VOLUME}_{Units.VOLUME_LITERS}", device_class=SensorDeviceClass.VOLUME, native_unit_of_measurement=UnitOfVolume.LITERS, diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index 1ba51e476d..ae762efefe 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -735,7 +735,6 @@ class BrSensor(SensorEntity): or sensor_type.endswith("_4d") or sensor_type.endswith("_5d") ): - # update forecasting sensors: fcday = 0 if sensor_type.endswith("_2d"): diff --git a/homeassistant/components/control4/config_flow.py b/homeassistant/components/control4/config_flow.py index 05fd8a2b7c..06dc62d114 100644 --- a/homeassistant/components/control4/config_flow.py +++ b/homeassistant/components/control4/config_flow.py @@ -96,7 +96,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle the initial step.""" errors = {} if user_input is not None: - hub = Control4Validator( user_input[CONF_HOST], user_input[CONF_USERNAME], diff --git a/homeassistant/components/darksky/weather.py b/homeassistant/components/darksky/weather.py index 530f1788dd..2567290867 100644 --- a/homeassistant/components/darksky/weather.py +++ b/homeassistant/components/darksky/weather.py @@ -188,6 +188,7 @@ class DarkSkyWeather(WeatherEntity): @property def forecast(self): """Return the forecast array.""" + # Per conversation with Joshua Reyes of Dark Sky, to get the total # forecasted precipitation, you have to multiple the intensity by # the hours for the forecast interval diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py index ef78e8f141..0b646bedbd 100644 --- a/homeassistant/components/deconz/binary_sensor.py +++ b/homeassistant/components/deconz/binary_sensor.py @@ -271,7 +271,6 @@ class DeconzBinarySensor(DeconzDevice[SensorResources], BinarySensorEntity): attr[ATTR_TEMPERATURE] = self._device.internal_temperature if isinstance(self._device, Presence): - if self._device.dark is not None: attr[ATTR_DARK] = self._device.dark diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index d94b40e852..80f0d6f5dd 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -87,7 +87,6 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN): If no bridge is found allow user to manually input configuration. """ if user_input is not None: - if CONF_MANUAL_INPUT == user_input[CONF_HOST]: return await self.async_step_manual_input() diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index ee27beaa8e..03c9f9c70f 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -372,7 +372,6 @@ class DeconzSensor(DeconzDevice[SensorResources], SensorEntity): attr[ATTR_DAYLIGHT] = self._device.daylight elif isinstance(self._device, LightLevel): - if self._device.dark is not None: attr[ATTR_DARK] = self._device.dark diff --git a/homeassistant/components/deconz/services.py b/homeassistant/components/deconz/services.py index e4399e5352..5cea4ca3b1 100644 --- a/homeassistant/components/deconz/services.py +++ b/homeassistant/components/deconz/services.py @@ -186,10 +186,8 @@ async def async_remove_orphaned_entries_service(gateway: DeconzGateway) -> None: devices_to_be_removed.remove(event.device_id) for entry in entity_entries: - # Don't remove available entities if entry.unique_id in gateway.entities[entry.domain]: - # Don't remove devices with available entities if entry.device_id in devices_to_be_removed: devices_to_be_removed.remove(entry.device_id) diff --git a/homeassistant/components/delijn/sensor.py b/homeassistant/components/delijn/sensor.py index f0a263d0d0..2cf85b91ab 100644 --- a/homeassistant/components/delijn/sensor.py +++ b/homeassistant/components/delijn/sensor.py @@ -121,6 +121,6 @@ class DeLijnPublicTransportSensor(SensorEntity): self._attr_extra_state_attributes["next_passages"] = self.line.passages self._attr_available = True - except (KeyError) as error: + except KeyError as error: _LOGGER.error("Invalid data received from De Lijn: %s", error) self._attr_available = False diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index 85d964a84c..adf91eb706 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -245,7 +245,7 @@ class DerivativeSensor(RestoreEntity, SensorEntity): derivative = new_derivative else: derivative = Decimal(0) - for (start, end, value) in self._state_list: + for start, end, value in self._state_list: weight = calculate_weight(start, end, new_state.last_updated) derivative = derivative + (value * Decimal(weight)) diff --git a/homeassistant/components/device_sun_light_trigger/__init__.py b/homeassistant/components/device_sun_light_trigger/__init__.py index a78b343201..ea9205ebde 100644 --- a/homeassistant/components/device_sun_light_trigger/__init__.py +++ b/homeassistant/components/device_sun_light_trigger/__init__.py @@ -214,7 +214,6 @@ async def activate_automation( # noqa: C901 elif start_point and start_point < now < get_astral_event_next( hass, SUN_EVENT_SUNSET ): - # Check for every light if it would be on if someone was home # when the fading in started and turn it on if so for index, light_id in enumerate(light_ids): diff --git a/homeassistant/components/dnsip/config_flow.py b/homeassistant/components/dnsip/config_flow.py index eb48095539..6a1437d515 100644 --- a/homeassistant/components/dnsip/config_flow.py +++ b/homeassistant/components/dnsip/config_flow.py @@ -93,7 +93,6 @@ class DnsIPConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors = {} if user_input: - hostname = user_input[CONF_HOSTNAME] name = DEFAULT_NAME if hostname == DEFAULT_HOSTNAME else hostname resolver = user_input.get(CONF_RESOLVER, DEFAULT_RESOLVER) diff --git a/homeassistant/components/doods/image_processing.py b/homeassistant/components/doods/image_processing.py index 677aafc0b5..8240bb117e 100644 --- a/homeassistant/components/doods/image_processing.py +++ b/homeassistant/components/doods/image_processing.py @@ -255,7 +255,6 @@ class Doods(ImageProcessingEntity): ) for label, values in matches.items(): - # Draw custom label regions/areas if label in self._label_areas and self._label_areas[label] != [0, 0, 1, 1]: box_label = f"{label.capitalize()} Detection Area" diff --git a/homeassistant/components/ebusd/__init__.py b/homeassistant/components/ebusd/__init__.py index 14158b6c37..ab83759bb2 100644 --- a/homeassistant/components/ebusd/__init__.py +++ b/homeassistant/components/ebusd/__init__.py @@ -66,7 +66,6 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: server_address = (conf.get(CONF_HOST), conf.get(CONF_PORT)) try: - ebusdpy.init(server_address) hass.data[DOMAIN] = EbusdData(server_address, circuit) diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py index ca1a153b5a..f1bf7deb50 100644 --- a/homeassistant/components/ecovacs/vacuum.py +++ b/homeassistant/components/ecovacs/vacuum.py @@ -160,7 +160,6 @@ class EcovacsVacuum(VacuumEntity): def set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: """Set fan speed.""" if self.is_on: - self.device.run(sucks.Clean(mode=self.device.clean_status, speed=fan_speed)) def send_command( diff --git a/homeassistant/components/elv/switch.py b/homeassistant/components/elv/switch.py index d7e35f3e04..b998e2dd73 100644 --- a/homeassistant/components/elv/switch.py +++ b/homeassistant/components/elv/switch.py @@ -87,7 +87,7 @@ class SmartPlugSwitch(SwitchEntity): self._state = self._pca.get_state(self._device_id) self._available = True - except (OSError) as ex: + except OSError as ex: if self._available: _LOGGER.warning("Could not read state for %s: %s", self.name, ex) self._available = False diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py index 4a427615aa..f26ed72f44 100644 --- a/homeassistant/components/emoncms/sensor.py +++ b/homeassistant/components/emoncms/sensor.py @@ -106,7 +106,6 @@ def setup_platform( sensors = [] for elem in data.data: - if exclude_feeds is not None and int(elem["id"]) in exclude_feeds: continue diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 272645909a..a81544151e 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -379,7 +379,7 @@ class HueOneLightChangeView(HomeAssistantView): else: parsed[STATE_ON] = entity.state != STATE_OFF - for (key, attr) in ( + for key, attr in ( (HUE_API_STATE_BRI, STATE_BRIGHTNESS), (HUE_API_STATE_HUE, STATE_HUE), (HUE_API_STATE_SAT, STATE_SATURATION), @@ -587,7 +587,7 @@ class HueOneLightChangeView(HomeAssistantView): ) ] - for (key, val) in ( + for key, val in ( (STATE_BRIGHTNESS, HUE_API_STATE_BRI), (STATE_HUE, HUE_API_STATE_HUE), (STATE_SATURATION, HUE_API_STATE_SAT), @@ -705,7 +705,7 @@ def get_entity_state_dict(config: Config, entity: State) -> dict[str, Any]: data[STATE_SATURATION] = 0 # Clamp brightness, hue, saturation, and color temp to valid values - for (key, v_min, v_max) in ( + for key, v_min, v_max in ( (STATE_BRIGHTNESS, HUE_API_STATE_BRI_MIN, HUE_API_STATE_BRI_MAX), (STATE_HUE, HUE_API_STATE_HUE_MIN, HUE_API_STATE_HUE_MAX), (STATE_SATURATION, HUE_API_STATE_SAT_MIN, HUE_API_STATE_SAT_MAX), diff --git a/homeassistant/components/escea/config_flow.py b/homeassistant/components/escea/config_flow.py index 5d0dfea115..2a6e19343d 100644 --- a/homeassistant/components/escea/config_flow.py +++ b/homeassistant/components/escea/config_flow.py @@ -21,7 +21,6 @@ _LOGGER = logging.getLogger(__name__) async def _async_has_devices(hass: HomeAssistant) -> bool: - controller_ready = asyncio.Event() @callback diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index 9fb7c1f8b5..65b5df100d 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -66,7 +66,6 @@ async def async_setup_entry( camera_entities = [] for camera, value in coordinator.data.items(): - camera_rtsp_entry = [ item for item in hass.config_entries.async_entries(DOMAIN) @@ -81,7 +80,6 @@ async def async_setup_entry( ) if camera_rtsp_entry: - ffmpeg_arguments = camera_rtsp_entry[0].options[CONF_FFMPEG_ARGUMENTS] camera_username = camera_rtsp_entry[0].data[CONF_USERNAME] camera_password = camera_rtsp_entry[0].data[CONF_PASSWORD] @@ -96,7 +94,6 @@ async def async_setup_entry( ) else: - discovery_flow.async_create_flow( hass, DOMAIN, diff --git a/homeassistant/components/ezviz/config_flow.py b/homeassistant/components/ezviz/config_flow.py index 61b66280ae..4c8b1418fa 100644 --- a/homeassistant/components/ezviz/config_flow.py +++ b/homeassistant/components/ezviz/config_flow.py @@ -182,7 +182,6 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: - if user_input[CONF_URL] == CONF_CUSTOMIZE: self.context["data"] = { CONF_USERNAME: user_input[CONF_USERNAME], diff --git a/homeassistant/components/faa_delays/config_flow.py b/homeassistant/components/faa_delays/config_flow.py index 1abd1f00fa..023fe4d6a5 100644 --- a/homeassistant/components/faa_delays/config_flow.py +++ b/homeassistant/components/faa_delays/config_flow.py @@ -25,7 +25,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle the initial step.""" errors = {} if user_input is not None: - await self.async_set_unique_id(user_input[CONF_ID]) self._abort_if_unique_id_configured() diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index e4166494ce..6f290ccb29 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -507,7 +507,6 @@ class RangeFilter(Filter, SensorEntity): new_state_value = cast(float, new_state.state) if self._upper_bound is not None and new_state_value > self._upper_bound: - self._stats_internal["erasures_up"] += 1 _LOGGER.debug( @@ -519,7 +518,6 @@ class RangeFilter(Filter, SensorEntity): new_state.state = self._upper_bound elif self._lower_bound is not None and new_state_value < self._lower_bound: - self._stats_internal["erasures_low"] += 1 _LOGGER.debug( @@ -564,7 +562,6 @@ class OutlierFilter(Filter, SensorEntity): len(self.states) == self.states.maxlen and abs(new_state_value - median) > self._radius ): - self._stats_internal["erasures"] += 1 _LOGGER.debug( diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py index dc6b40982b..6ef0467f7b 100644 --- a/homeassistant/components/fints/sensor.py +++ b/homeassistant/components/fints/sensor.py @@ -208,7 +208,6 @@ class FinTsClient: holdings_accounts = [] for account in self.client.get_sepa_accounts(): - if self.is_balance_account(account): balance_accounts.append(account) diff --git a/homeassistant/components/firmata/__init__.py b/homeassistant/components/firmata/__init__.py index a58cd0591d..78b5592a54 100644 --- a/homeassistant/components/firmata/__init__.py +++ b/homeassistant/components/firmata/__init__.py @@ -213,7 +213,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> _LOGGER.debug("Closing Firmata board %s", config_entry.data[CONF_NAME]) unload_entries = [] - for (conf, platform) in CONF_PLATFORM_MAP.items(): + for conf, platform in CONF_PLATFORM_MAP.items(): if conf in config_entry.data: unload_entries.append( hass.config_entries.async_forward_entry_unload(config_entry, platform) diff --git a/homeassistant/components/fleetgo/device_tracker.py b/homeassistant/components/fleetgo/device_tracker.py index e80acae862..c901ccce3e 100644 --- a/homeassistant/components/fleetgo/device_tracker.py +++ b/homeassistant/components/fleetgo/device_tracker.py @@ -86,7 +86,6 @@ class FleetGoDeviceScanner: for device in devices: if not self._include or device.license_plate in self._include: - if device.active or device.current_address is None: device.get_map_details() diff --git a/homeassistant/components/flipr/__init__.py b/homeassistant/components/flipr/__init__.py index 3f9f70e294..f02911e30d 100644 --- a/homeassistant/components/flipr/__init__.py +++ b/homeassistant/components/flipr/__init__.py @@ -74,7 +74,7 @@ class FliprDataUpdateCoordinator(DataUpdateCoordinator): data = await self.hass.async_add_executor_job( self.client.get_pool_measure_latest, self.flipr_id ) - except (FliprError) as error: + except FliprError as error: raise UpdateFailed(error) from error return data diff --git a/homeassistant/components/flo/device.py b/homeassistant/components/flo/device.py index d6e05c1713..1b28a2552a 100644 --- a/homeassistant/components/flo/device.py +++ b/homeassistant/components/flo/device.py @@ -43,7 +43,7 @@ class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator): await self.send_presence_ping() await self._update_device() await self._update_consumption_data() - except (RequestError) as error: + except RequestError as error: raise UpdateFailed(error) from error @property diff --git a/homeassistant/components/flume/sensor.py b/homeassistant/components/flume/sensor.py index 4b5fe5bc8e..b656f5e971 100644 --- a/homeassistant/components/flume/sensor.py +++ b/homeassistant/components/flume/sensor.py @@ -97,7 +97,6 @@ async def async_setup_entry( ] flume_entity_list = [] for device in flume_devices: - device_id = device[KEY_DEVICE_ID] device_timezone = device[KEY_DEVICE_LOCATION][KEY_DEVICE_LOCATION_TIMEZONE] device_location_name = device[KEY_DEVICE_LOCATION][KEY_DEVICE_LOCATION_NAME] diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index a83b39ebbb..6072aee89a 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -102,7 +102,6 @@ async def _async_port_entities_list( _LOGGER.debug("IP source for %s is %s", avm_wrapper.host, local_ip) for i in range(port_forwards_count): - portmap = await avm_wrapper.async_get_port_mapping( avm_wrapper.device_conn_type, i ) @@ -406,7 +405,6 @@ class FritzBoxPortSwitch(FritzBoxBaseSwitch, SwitchEntity): self._attributes[attr] = self.port_mapping[key] async def _async_switch_on_off_executor(self, turn_on: bool) -> bool: - if self.port_mapping is None: return False diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index ee959943b8..e0eaf3bb38 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -262,7 +262,6 @@ async def async_setup_entry( if entity_id := ent_reg.async_get_entity_id( Platform.SENSOR, DOMAIN, old_unique_id ): - ent_reg.async_update_entity( entity_id, new_unique_id=f"{config_entry.entry_id}-{new_key}" ) diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 2a011679d0..196fa580ea 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -289,7 +289,6 @@ class AbstractConfig(ABC): return for user_agent_id, _ in self._store.agent_user_ids.items(): - if (webhook_id := self.get_local_webhook_id(user_agent_id)) is None: setup_successful = False break diff --git a/homeassistant/components/harmony/config_flow.py b/homeassistant/components/harmony/config_flow.py index 675acf600c..f74a19425a 100644 --- a/homeassistant/components/harmony/config_flow.py +++ b/homeassistant/components/harmony/config_flow.py @@ -64,7 +64,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle the initial step.""" errors = {} if user_input is not None: - try: validated = await validate_input(user_input) except CannotConnect: diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py index 737ffaf8c1..6e2ca202fd 100644 --- a/homeassistant/components/homematicip_cloud/climate.py +++ b/homeassistant/components/homematicip_cloud/climate.py @@ -316,7 +316,12 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): @property def _first_radiator_thermostat( self, - ) -> AsyncHeatingThermostat | AsyncHeatingThermostatCompact | AsyncHeatingThermostatEvo | None: + ) -> ( + AsyncHeatingThermostat + | AsyncHeatingThermostatCompact + | AsyncHeatingThermostatEvo + | None + ): """Return the first radiator thermostat from the hmip heating group.""" for device in self._device.devices: if isinstance( diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 1296dfbfdb..3ea86d7197 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -334,7 +334,6 @@ class HoneywellUSThermostat(ClimateEntity): _LOGGER.error("Can not get system mode") return try: - # Set permanent hold # and Set temperature if mode in COOLING_MODES: diff --git a/homeassistant/components/honeywell/config_flow.py b/homeassistant/components/honeywell/config_flow.py index 76d208f72f..8b24fc912f 100644 --- a/homeassistant/components/honeywell/config_flow.py +++ b/homeassistant/components/honeywell/config_flow.py @@ -67,7 +67,6 @@ class HoneywellConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" else: - self.hass.config_entries.async_update_entry( self.entry, data={ diff --git a/homeassistant/components/hue/migration.py b/homeassistant/components/hue/migration.py index 221888b085..035da145cc 100644 --- a/homeassistant/components/hue/migration.py +++ b/homeassistant/components/hue/migration.py @@ -92,7 +92,6 @@ async def handle_v2_migration(hass: core.HomeAssistant, entry: ConfigEntry) -> N # initialize bridge connection just for the migration async with HueBridgeV2(host, api_key) as api: - sensor_class_mapping = { SensorDeviceClass.BATTERY.value: ResourceTypes.DEVICE_POWER, BinarySensorDeviceClass.MOTION.value: ResourceTypes.MOTION, @@ -130,7 +129,6 @@ async def handle_v2_migration(hass: core.HomeAssistant, entry: ConfigEntry) -> N # loop through all entities for device and find match for ent in async_entries_for_device(ent_reg, hass_dev_id, True): - if ent.entity_id.startswith("light"): # migrate light # should always return one lightid here diff --git a/homeassistant/components/hvv_departures/binary_sensor.py b/homeassistant/components/hvv_departures/binary_sensor.py index a99dea5744..36b1b5f927 100644 --- a/homeassistant/components/hvv_departures/binary_sensor.py +++ b/homeassistant/components/hvv_departures/binary_sensor.py @@ -48,7 +48,6 @@ async def async_setup_entry( for partial_station in station_information.get("partialStations", []): for elevator in partial_station.get("elevators", []): - state = elevator.get("state") != "READY" available = elevator.get("state") != "UNKNOWN" label = elevator.get("label") diff --git a/homeassistant/components/hvv_departures/config_flow.py b/homeassistant/components/hvv_departures/config_flow.py index 5f07593eeb..4cbd0a8332 100644 --- a/homeassistant/components/hvv_departures/config_flow.py +++ b/homeassistant/components/hvv_departures/config_flow.py @@ -81,7 +81,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_station(self, user_input=None): """Handle the step where the user inputs his/her station.""" if user_input is not None: - errors = {} check_name = await self.hub.gti.checkName( @@ -145,7 +144,6 @@ class OptionsFlowHandler(config_entries.OptionsFlow): """Manage the options.""" errors = {} if not self.departure_filters: - departure_list = {} hub: GTIHub = self.hass.data[DOMAIN][self.config_entry.entry_id] @@ -172,7 +170,6 @@ class OptionsFlowHandler(config_entries.OptionsFlow): } if user_input is not None and not errors: - options = { CONF_FILTER: [ self.departure_filters[x] for x in user_input[CONF_FILTER] diff --git a/homeassistant/components/hyperion/__init__.py b/homeassistant/components/hyperion/__init__.py index c3cd473c3f..6c2842c190 100644 --- a/homeassistant/components/hyperion/__init__.py +++ b/homeassistant/components/hyperion/__init__.py @@ -259,7 +259,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for device_entry in dr.async_entries_for_config_entry( device_registry, entry.entry_id ): - for (kind, key) in device_entry.identifiers: + for kind, key in device_entry.identifiers: if kind == DOMAIN and key in known_devices: break else: diff --git a/homeassistant/components/ifttt/__init__.py b/homeassistant/components/ifttt/__init__.py index 40b44f00a7..0f8b7a0fa7 100644 --- a/homeassistant/components/ifttt/__init__.py +++ b/homeassistant/components/ifttt/__init__.py @@ -77,7 +77,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: target_keys[target] = api_keys[target] try: - for target, key in target_keys.items(): res = pyfttt.send_event(key, event, value1, value2, value3) if res.status_code != HTTPStatus.OK: diff --git a/homeassistant/components/intellifire/config_flow.py b/homeassistant/components/intellifire/config_flow.py index d2b019cb38..91676724a8 100644 --- a/homeassistant/components/intellifire/config_flow.py +++ b/homeassistant/components/intellifire/config_flow.py @@ -119,7 +119,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) if user_input is not None: - control_schema = vol.Schema( { vol.Required( diff --git a/homeassistant/components/intellifire/coordinator.py b/homeassistant/components/intellifire/coordinator.py index 356ddedf16..b6753adef7 100644 --- a/homeassistant/components/intellifire/coordinator.py +++ b/homeassistant/components/intellifire/coordinator.py @@ -33,7 +33,6 @@ class IntellifireDataUpdateCoordinator(DataUpdateCoordinator[IntellifirePollData self._api = api async def _async_update_data(self) -> IntellifirePollData: - if not self._api.is_polling_in_background: LOGGER.info("Starting Intellifire Background Polling Loop") await self._api.start_background_polling() diff --git a/homeassistant/components/isy994/binary_sensor.py b/homeassistant/components/isy994/binary_sensor.py index 521bfb41a8..90c2d6b381 100644 --- a/homeassistant/components/isy994/binary_sensor.py +++ b/homeassistant/components/isy994/binary_sensor.py @@ -95,7 +95,7 @@ async def async_setup_entry( entities_by_address[node.address] = entity # Handle some special child node cases for Insteon Devices - for (node, device_class, device_type, device_info) in child_nodes: + for node, device_class, device_type, device_info in child_nodes: subnode_id = int(node.address.split(" ")[-1], 16) # Handle Insteon Thermostats if device_type is not None and device_type.startswith(TYPE_CATEGORY_CLIMATE): diff --git a/homeassistant/components/isy994/helpers.py b/homeassistant/components/isy994/helpers.py index b190638fb3..53ad87c9dd 100644 --- a/homeassistant/components/isy994/helpers.py +++ b/homeassistant/components/isy994/helpers.py @@ -109,7 +109,6 @@ def _check_for_insteon_type( device_type.startswith(t) for t in set(NODE_FILTERS[platform][FILTER_INSTEON_TYPE]) ): - # Hacky special-cases for certain devices with different platforms # included as subnodes. Note that special-cases are not necessary # on ISY 5.x firmware as it uses the superior NodeDefs method diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py index 59b57f888a..306615543c 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -173,6 +173,7 @@ class ControllerDevice(ClimateEntity): async def async_added_to_hass(self) -> None: """Call on adding to hass.""" + # Register for connect/disconnect/update events @callback def controller_disconnected(ctrl: Controller, ex: Exception) -> None: @@ -290,7 +291,7 @@ class ControllerDevice(ClimateEntity): return HVACMode.OFF if (mode := self._controller.mode) == Controller.Mode.FREE_AIR: return HVACMode.FAN_ONLY - for (key, value) in self._state_to_pizone.items(): + for key, value in self._state_to_pizone.items(): if value == mode: return key assert False, "Should be unreachable" @@ -527,7 +528,7 @@ class ZoneDevice(ClimateEntity): def hvac_mode(self) -> HVACMode | None: """Return current operation ie. heat, cool, idle.""" mode = self._zone.mode - for (key, value) in self._state_to_pizone.items(): + for key, value in self._state_to_pizone.items(): if value == mode: return key return None diff --git a/homeassistant/components/izone/config_flow.py b/homeassistant/components/izone/config_flow.py index bc4fb8cedd..af5205feb0 100644 --- a/homeassistant/components/izone/config_flow.py +++ b/homeassistant/components/izone/config_flow.py @@ -17,7 +17,6 @@ _LOGGER = logging.getLogger(__name__) async def _async_has_devices(hass: HomeAssistant) -> bool: - controller_ready = asyncio.Event() @callback diff --git a/homeassistant/components/juicenet/config_flow.py b/homeassistant/components/juicenet/config_flow.py index 93c46cd483..35c1853b97 100644 --- a/homeassistant/components/juicenet/config_flow.py +++ b/homeassistant/components/juicenet/config_flow.py @@ -46,7 +46,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle the initial step.""" errors = {} if user_input is not None: - await self.async_set_unique_id(user_input[CONF_ACCESS_TOKEN]) self._abort_if_unique_id_configured() diff --git a/homeassistant/components/kitchen_sink/repairs.py b/homeassistant/components/kitchen_sink/repairs.py index 41db200dd7..51b474dcf0 100644 --- a/homeassistant/components/kitchen_sink/repairs.py +++ b/homeassistant/components/kitchen_sink/repairs.py @@ -17,7 +17,7 @@ class DemoFixFlow(RepairsFlow): ) -> data_entry_flow.FlowResult: """Handle the first step of a fix flow.""" - return await (self.async_step_confirm()) + return await self.async_step_confirm() async def async_step_confirm( self, user_input: dict[str, str] | None = None diff --git a/homeassistant/components/landisgyr_heat_meter/__init__.py b/homeassistant/components/landisgyr_heat_meter/__init__.py index 6ef17cf24d..eae5e91196 100644 --- a/homeassistant/components/landisgyr_heat_meter/__init__.py +++ b/homeassistant/components/landisgyr_heat_meter/__init__.py @@ -62,7 +62,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # Removing domain name and config entry id from entity unique id's, replacing it with device number if config_entry.version == 1: - config_entry.version = 2 device_number = config_entry.data["device_number"] diff --git a/homeassistant/components/lifx/manager.py b/homeassistant/components/lifx/manager.py index 1eb80c0c5a..9e72ded620 100644 --- a/homeassistant/components/lifx/manager.py +++ b/homeassistant/components/lifx/manager.py @@ -296,7 +296,6 @@ class LIFXManager: ) elif service == SERVICE_EFFECT_MORPH: - theme_name = kwargs.get(ATTR_THEME, "exciting") palette = kwargs.get(ATTR_PALETTE, None) @@ -336,7 +335,6 @@ class LIFXManager: ) elif service == SERVICE_EFFECT_PULSE: - effect = aiolifx_effects.EffectPulse( power_on=kwargs.get(ATTR_POWER_ON), period=kwargs.get(ATTR_PERIOD), @@ -347,7 +345,6 @@ class LIFXManager: await self.effects_conductor.start(effect, bulbs) elif service == SERVICE_EFFECT_COLORLOOP: - brightness = None saturation_max = None saturation_min = None @@ -378,7 +375,6 @@ class LIFXManager: await self.effects_conductor.start(effect, bulbs) elif service == SERVICE_EFFECT_STOP: - await self.effects_conductor.stop(bulbs) for coordinator in coordinators: diff --git a/homeassistant/components/london_air/sensor.py b/homeassistant/components/london_air/sensor.py index 45f585da39..6538c06d0b 100644 --- a/homeassistant/components/london_air/sensor.py +++ b/homeassistant/components/london_air/sensor.py @@ -218,7 +218,6 @@ def parse_api_response(response): for authority in AUTHORITIES: for entry in response["HourlyAirQualityIndex"]["LocalAuthority"]: if entry["@LocalAuthorityName"] == authority: - if isinstance(entry["Site"], dict): entry_sites_data = [entry["Site"]] else: diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 485579087a..f880f83d76 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -157,7 +157,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return if change_type == collection.CHANGE_ADDED: - existing = hass.data[DOMAIN]["dashboards"].get(url_path) if existing: diff --git a/homeassistant/components/lupusec/switch.py b/homeassistant/components/lupusec/switch.py index 546f96fd0a..b429421600 100644 --- a/homeassistant/components/lupusec/switch.py +++ b/homeassistant/components/lupusec/switch.py @@ -31,7 +31,6 @@ def setup_platform( devices = [] for device in data.lupusec.get_devices(generic_type=CONST.TYPE_SWITCH): - devices.append(LupusecSwitch(data, device)) add_entities(devices) diff --git a/homeassistant/components/lutron/binary_sensor.py b/homeassistant/components/lutron/binary_sensor.py index 4fc432bf66..9f9851fb48 100644 --- a/homeassistant/components/lutron/binary_sensor.py +++ b/homeassistant/components/lutron/binary_sensor.py @@ -24,7 +24,7 @@ def setup_platform( if discovery_info is None: return devs = [] - for (area_name, device) in hass.data[LUTRON_DEVICES]["binary_sensor"]: + for area_name, device in hass.data[LUTRON_DEVICES]["binary_sensor"]: dev = LutronOccupancySensor(area_name, device, hass.data[LUTRON_CONTROLLER]) devs.append(dev) diff --git a/homeassistant/components/lutron/cover.py b/homeassistant/components/lutron/cover.py index 65a1c737d5..57fd8ac9d5 100644 --- a/homeassistant/components/lutron/cover.py +++ b/homeassistant/components/lutron/cover.py @@ -26,7 +26,7 @@ def setup_platform( ) -> None: """Set up the Lutron shades.""" devs = [] - for (area_name, device) in hass.data[LUTRON_DEVICES]["cover"]: + for area_name, device in hass.data[LUTRON_DEVICES]["cover"]: dev = LutronCover(area_name, device, hass.data[LUTRON_CONTROLLER]) devs.append(dev) diff --git a/homeassistant/components/lutron/light.py b/homeassistant/components/lutron/light.py index 1502412233..6bd556d36d 100644 --- a/homeassistant/components/lutron/light.py +++ b/homeassistant/components/lutron/light.py @@ -19,7 +19,7 @@ def setup_platform( ) -> None: """Set up the Lutron lights.""" devs = [] - for (area_name, device) in hass.data[LUTRON_DEVICES]["light"]: + for area_name, device in hass.data[LUTRON_DEVICES]["light"]: dev = LutronLight(area_name, device, hass.data[LUTRON_CONTROLLER]) devs.append(dev) diff --git a/homeassistant/components/lutron/switch.py b/homeassistant/components/lutron/switch.py index 8595f80903..f8ca93beb2 100644 --- a/homeassistant/components/lutron/switch.py +++ b/homeassistant/components/lutron/switch.py @@ -21,7 +21,7 @@ def setup_platform( devs = [] # Add Lutron Switches - for (area_name, device) in hass.data[LUTRON_DEVICES]["switch"]: + for area_name, device in hass.data[LUTRON_DEVICES]["switch"]: dev = LutronSwitch(area_name, device, hass.data[LUTRON_CONTROLLER]) devs.append(dev) diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index b9418373b3..e1d7135342 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -256,7 +256,6 @@ def _async_setup_keypads( leap_to_keypad_button_names: dict[int, dict[int, str]] = {} for bridge_button in bridge_buttons.values(): - parent_device = cast(str, bridge_button["parent_device"]) bridge_keypad = bridge_devices[parent_device] keypad_lutron_device_id = cast(int, bridge_keypad["device_id"]) diff --git a/homeassistant/components/lutron_caseta/button.py b/homeassistant/components/lutron_caseta/button.py index ee73767308..b0e6d49de6 100644 --- a/homeassistant/components/lutron_caseta/button.py +++ b/homeassistant/components/lutron_caseta/button.py @@ -29,7 +29,6 @@ async def async_setup_entry( entities: list[LutronCasetaButton] = [] for device in button_devices.values(): - parent_keypad = keypads[device["parent_device"]] parent_device_info = parent_keypad["device_info"] diff --git a/homeassistant/components/mediaroom/media_player.py b/homeassistant/components/mediaroom/media_player.py index 09e137c96d..4dc6d677ab 100644 --- a/homeassistant/components/mediaroom/media_player.py +++ b/homeassistant/components/mediaroom/media_player.py @@ -93,7 +93,6 @@ async def async_setup_platform( async_add_entities([new_stb]) if not config[CONF_OPTIMISTIC]: - already_installed = hass.data.get(DISCOVERY_MEDIAROOM) if not already_installed: hass.data[DISCOVERY_MEDIAROOM] = await install_mediaroom_protocol( diff --git a/homeassistant/components/melnor/config_flow.py b/homeassistant/components/melnor/config_flow.py index 7e9aed24e8..7aad59acf7 100644 --- a/homeassistant/components/melnor/config_flow.py +++ b/homeassistant/components/melnor/config_flow.py @@ -80,12 +80,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): for discovery_info in async_discovered_service_info( self.hass, connectable=True ): - if discovery_info.manufacturer_id == MANUFACTURER_ID and any( manufacturer_data.startswith(MANUFACTURER_DATA_START) for manufacturer_data in discovery_info.manufacturer_data.values() ): - address = discovery_info.address if ( address not in current_addresses diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index ba8ccadfca..f507cf8cf3 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -94,7 +94,7 @@ class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): self._hourly = hourly @property - def track_home(self) -> (Any | bool): + def track_home(self) -> Any | bool: """Return if we are tracking home.""" return self._config.get(CONF_TRACK_HOME, False) diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py index 6c9af12a32..3b82399f21 100644 --- a/homeassistant/components/meteo_france/__init__.py +++ b/homeassistant/components/meteo_france/__init__.py @@ -172,7 +172,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" if hass.data[DOMAIN][entry.entry_id][COORDINATOR_ALERT]: - department = hass.data[DOMAIN][entry.entry_id][ COORDINATOR_FORECAST ].data.position.get("dept") diff --git a/homeassistant/components/mikrotik/device_tracker.py b/homeassistant/components/mikrotik/device_tracker.py index 2c3e480c29..71d94a27fe 100644 --- a/homeassistant/components/mikrotik/device_tracker.py +++ b/homeassistant/components/mikrotik/device_tracker.py @@ -35,12 +35,10 @@ async def async_setup_entry( # Restore clients that is not a part of active clients list. for entity in registry.entities.values(): - if ( entity.config_entry_id == config_entry.entry_id and entity.domain == DEVICE_TRACKER ): - if ( entity.unique_id in coordinator.api.devices or entity.unique_id not in coordinator.api.all_devices diff --git a/homeassistant/components/minecraft_server/helpers.py b/homeassistant/components/minecraft_server/helpers.py index 7153c170a6..d4a49d96f8 100644 --- a/homeassistant/components/minecraft_server/helpers.py +++ b/homeassistant/components/minecraft_server/helpers.py @@ -22,7 +22,7 @@ async def async_check_srv_record( srv_records = await aiodns.DNSResolver().query( host=f"{SRV_RECORD_PREFIX}.{host}", qtype="SRV" ) - except (aiodns.error.DNSError): + except aiodns.error.DNSError: # 'host' is not a SRV record. pass else: diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index a62d8537a3..24555704ac 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -249,7 +249,10 @@ CLIMATE_SCHEMA = vol.All( COVERS_SCHEMA = BASE_COMPONENT_SCHEMA.extend( { - vol.Optional(CONF_INPUT_TYPE, default=CALL_TYPE_REGISTER_HOLDING,): vol.In( + vol.Optional( + CONF_INPUT_TYPE, + default=CALL_TYPE_REGISTER_HOLDING, + ): vol.In( [ CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_COIL, diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py index 95cb24fe7e..ee3ab9817e 100644 --- a/homeassistant/components/mold_indicator/sensor.py +++ b/homeassistant/components/mold_indicator/sensor.py @@ -315,7 +315,6 @@ class MoldIndicator(SensorEntity): def _calc_moldindicator(self): """Calculate the humidity at the (cold) calibration point.""" if None in (self._dewpoint, self._calib_factor) or self._calib_factor == 0: - _LOGGER.debug( "Invalid inputs - dewpoint: %s, calibration-factor: %s", self._dewpoint, diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index 135151f179..d09e31f65f 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -200,7 +200,6 @@ class MqttBinarySensor(MqttEntity, BinarySensorEntity, RestoreEntity): """Handle a new received MQTT state message.""" # auto-expire enabled? if self._expire_after: - # When expire_after is set, and we receive a message, assume device is # not expired since it has to be to receive the message self._expired = False diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 755bf3636d..28d60a5fdb 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -666,7 +666,6 @@ class MQTT: subscriptions = self._matching_subscriptions(msg.topic) for subscription in subscriptions: - payload: SubscribePayloadType = msg.payload if subscription.encoding is not None: try: diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 3accb7c8ad..90f04b084b 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -279,7 +279,7 @@ async def async_start( # noqa: C901 mqtt_data.last_discovery = time.time() mqtt_integrations = await async_get_mqtt(hass) - for (integration, topics) in mqtt_integrations.items(): + for integration, topics in mqtt_integrations.items(): async def async_integration_message_received( integration: str, msg: ReceiveMessage diff --git a/homeassistant/components/mqtt/subscription.py b/homeassistant/components/mqtt/subscription.py index e3fd5e5009..29beafdbe8 100644 --- a/homeassistant/components/mqtt/subscription.py +++ b/homeassistant/components/mqtt/subscription.py @@ -64,7 +64,11 @@ class EntitySubscription: if other is None: return True - return (self.topic, self.qos, self.encoding,) != ( + return ( + self.topic, + self.qos, + self.encoding, + ) != ( other.topic, other.qos, other.encoding, diff --git a/homeassistant/components/nam/config_flow.py b/homeassistant/components/nam/config_flow.py index 20021f1e6d..6cf9dd5545 100644 --- a/homeassistant/components/nam/config_flow.py +++ b/homeassistant/components/nam/config_flow.py @@ -135,7 +135,6 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - return self.async_create_entry( title=self.host, data={**user_input, CONF_HOST: self.host}, diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py index 6c1768d985..3b777aa6f2 100644 --- a/homeassistant/components/nest/config_flow.py +++ b/homeassistant/components/nest/config_flow.py @@ -428,7 +428,6 @@ class NestFlowHandler( _LOGGER.error("Error creating subscription: %s", err) errors[CONF_CLOUD_PROJECT_ID] = "subscriber_error" if not errors: - try: device_manager = await subscriber.async_get_device_manager() except ApiException as err: diff --git a/homeassistant/components/nfandroidtv/config_flow.py b/homeassistant/components/nfandroidtv/config_flow.py index 88eebe1b4d..d4491cee48 100644 --- a/homeassistant/components/nfandroidtv/config_flow.py +++ b/homeassistant/components/nfandroidtv/config_flow.py @@ -26,7 +26,6 @@ class NFAndroidTVFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: - self._async_abort_entries_match( {CONF_HOST: user_input[CONF_HOST], CONF_NAME: user_input[CONF_NAME]} ) diff --git a/homeassistant/components/nibe_heatpump/__init__.py b/homeassistant/components/nibe_heatpump/__init__.py index a7b3c0968c..57c5f680e0 100644 --- a/homeassistant/components/nibe_heatpump/__init__.py +++ b/homeassistant/components/nibe_heatpump/__init__.py @@ -265,7 +265,6 @@ class Coordinator(ContextCoordinator[dict[int, Coil], int]): self.task = None async def _async_update_data_internal(self) -> dict[int, Coil]: - result: dict[int, Coil] = {} def _get_coils() -> Iterable[Coil]: diff --git a/homeassistant/components/nina/__init__.py b/homeassistant/components/nina/__init__.py index 0555f175cf..88d61a427b 100644 --- a/homeassistant/components/nina/__init__.py +++ b/homeassistant/components/nina/__init__.py @@ -99,7 +99,6 @@ class NINADataUpdateCoordinator( all_filtered_warnings: dict[str, list[Any]] = {} for region_id, raw_warnings in warnings.items(): - filtered_warnings: list[Any] = [] processed_details: list[tuple[str, str]] = [] diff --git a/homeassistant/components/nina/config_flow.py b/homeassistant/components/nina/config_flow.py index bdaf164fad..f1579bc05e 100644 --- a/homeassistant/components/nina/config_flow.py +++ b/homeassistant/components/nina/config_flow.py @@ -126,7 +126,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): user_input[CONF_REGIONS] += group_input if user_input[CONF_REGIONS]: - return self.async_create_entry( title="NINA", data=prepare_user_input(user_input, self._all_region_codes_sorted), @@ -203,7 +202,6 @@ class OptionsFlowHandler(config_entries.OptionsFlow): user_input[CONF_REGIONS] += group_input if user_input[CONF_REGIONS]: - user_input = prepare_user_input( user_input, self._all_region_codes_sorted ) diff --git a/homeassistant/components/numato/binary_sensor.py b/homeassistant/components/numato/binary_sensor.py index cefec42ba3..98e71b9fc2 100644 --- a/homeassistant/components/numato/binary_sensor.py +++ b/homeassistant/components/numato/binary_sensor.py @@ -52,7 +52,6 @@ def setup_platform( ports = platform[CONF_PORTS] for port, port_name in ports.items(): try: - api.setup_input(device_id, port) api.edge_detect(device_id, port, partial(read_gpio, device_id)) diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index a0dd77df53..ac0293b262 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -108,7 +108,6 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): If no device is found allow user to manually input configuration. """ if user_input: - if CONF_MANUAL_INPUT == user_input[CONF_HOST]: return await self.async_step_configure() diff --git a/homeassistant/components/open_meteo/weather.py b/homeassistant/components/open_meteo/weather.py index 614aed6eef..2d06b20a30 100644 --- a/homeassistant/components/open_meteo/weather.py +++ b/homeassistant/components/open_meteo/weather.py @@ -94,7 +94,6 @@ class OpenMeteoWeatherEntity( forecasts: list[Forecast] = [] daily = self.coordinator.data.daily for index, time in enumerate(self.coordinator.data.daily.time): - forecast = Forecast( datetime=time.isoformat(), ) diff --git a/homeassistant/components/opencv/image_processing.py b/homeassistant/components/opencv/image_processing.py index 463b5e0eed..bef163252f 100644 --- a/homeassistant/components/opencv/image_processing.py +++ b/homeassistant/components/opencv/image_processing.py @@ -189,7 +189,7 @@ class OpenCVImageProcessor(ImageProcessingEntity): ) regions = [] # pylint: disable=invalid-name - for (x, y, w, h) in detections: + for x, y, w, h in detections: regions.append((int(x), int(y), int(w), int(h))) total_matches += 1 diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index 707be76be3..33f42ea949 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -79,10 +79,12 @@ async def async_setup_platform( ) -def catch_request_errors() -> Callable[ - [Callable[Concatenate[_OpenhomeDeviceT, _P], Awaitable[_R]]], - Callable[Concatenate[_OpenhomeDeviceT, _P], Coroutine[Any, Any, _R | None]], -]: +def catch_request_errors() -> ( + Callable[ + [Callable[Concatenate[_OpenhomeDeviceT, _P], Awaitable[_R]]], + Callable[Concatenate[_OpenhomeDeviceT, _P], Coroutine[Any, Any, _R | None]], + ] +): """Catch asyncio.TimeoutError, aiohttp.ClientError, UpnpError errors.""" def call_wrapper( @@ -265,7 +267,7 @@ class OpenhomeDevice(MediaPlayerEntity): await self._device.invoke_pin(pin) else: _LOGGER.error("Pins service not supported") - except (UpnpError): + except UpnpError: _LOGGER.error("Error invoking pin %s", pin) @property diff --git a/homeassistant/components/openweathermap/weather_update_coordinator.py b/homeassistant/components/openweathermap/weather_update_coordinator.py index cedc33c1a8..90e2c426d2 100644 --- a/homeassistant/components/openweathermap/weather_update_coordinator.py +++ b/homeassistant/components/openweathermap/weather_update_coordinator.py @@ -251,7 +251,6 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): def _get_condition(self, weather_code, timestamp=None): """Get weather condition from weather data.""" if weather_code == WEATHER_CODE_SUNNY_OR_CLEAR_NIGHT: - if timestamp: timestamp = dt.utc_from_timestamp(timestamp) diff --git a/homeassistant/components/philips_js/config_flow.py b/homeassistant/components/philips_js/config_flow.py index 39f1c78b07..dab8d4fbe2 100644 --- a/homeassistant/components/philips_js/config_flow.py +++ b/homeassistant/components/philips_js/config_flow.py @@ -49,7 +49,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._pair_state: Any = None async def _async_create_current(self) -> FlowResult: - system = self._current[CONF_SYSTEM] return self.async_create_entry( title=f"{system['name']} ({system['serialnumber']})", diff --git a/homeassistant/components/philips_js/media_player.py b/homeassistant/components/philips_js/media_player.py index f88f02128e..89cb29f0a0 100644 --- a/homeassistant/components/philips_js/media_player.py +++ b/homeassistant/components/philips_js/media_player.py @@ -437,7 +437,6 @@ class PhilipsTVMediaPlayer( @callback def _update_from_coordinator(self): - if self._tv.on: if self._tv.powerstate in ("Standby", "StandbyKeep"): self._attr_state = MediaPlayerState.OFF diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index 0012a5987e..b215bc0d82 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -178,7 +178,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def plex_websocket_callback(msgtype, data, error): """Handle callbacks from plexwebsocket library.""" if msgtype == SIGNAL_CONNECTION_STATE: - if data == STATE_CONNECTED: _LOGGER.debug("Websocket to %s successful", entry.data[CONF_SERVER]) hass.async_create_task(plex_server.async_update_platforms()) diff --git a/homeassistant/components/poolsense/__init__.py b/homeassistant/components/poolsense/__init__.py index a3e9a93da3..3652883b0f 100644 --- a/homeassistant/components/poolsense/__init__.py +++ b/homeassistant/components/poolsense/__init__.py @@ -93,7 +93,7 @@ class PoolSenseDataUpdateCoordinator(DataUpdateCoordinator): async with async_timeout.timeout(10): try: data = await self.poolsense.get_poolsense_data() - except (PoolSenseError) as error: + except PoolSenseError as error: _LOGGER.error("PoolSense query did not complete") raise UpdateFailed(error) from error diff --git a/homeassistant/components/progettihwsw/config_flow.py b/homeassistant/components/progettihwsw/config_flow.py index 89d5916a3f..5b996fd5eb 100644 --- a/homeassistant/components/progettihwsw/config_flow.py +++ b/homeassistant/components/progettihwsw/config_flow.py @@ -42,7 +42,6 @@ class ProgettiHWSWConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Manage relay modes step.""" errors = {} if user_input is not None: - whole_data = user_input whole_data.update(self.s1_in) diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py index 50c11781fa..5df92fd795 100644 --- a/homeassistant/components/ps4/media_player.py +++ b/homeassistant/components/ps4/media_player.py @@ -411,7 +411,6 @@ class PS4Device(MediaPlayerEntity): == game.lower().encode(encoding="utf-8") or source == title_id ): - _LOGGER.debug( "Starting PS4 game %s (%s) using source %s", game, title_id, source ) diff --git a/homeassistant/components/pushbullet/config_flow.py b/homeassistant/components/pushbullet/config_flow.py index bfa12a911b..e6259fa8ce 100644 --- a/homeassistant/components/pushbullet/config_flow.py +++ b/homeassistant/components/pushbullet/config_flow.py @@ -36,7 +36,6 @@ class PushBulletConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: - self._async_abort_entries_match({CONF_NAME: user_input[CONF_NAME]}) try: diff --git a/homeassistant/components/pushover/config_flow.py b/homeassistant/components/pushover/config_flow.py index 5119d91a17..32f9103135 100644 --- a/homeassistant/components/pushover/config_flow.py +++ b/homeassistant/components/pushover/config_flow.py @@ -89,7 +89,6 @@ class PushBulletConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: - self._async_abort_entries_match( { CONF_USER_KEY: user_input[CONF_USER_KEY], diff --git a/homeassistant/components/pushover/notify.py b/homeassistant/components/pushover/notify.py index 9d6ae4b080..2d79a0bf65 100644 --- a/homeassistant/components/pushover/notify.py +++ b/homeassistant/components/pushover/notify.py @@ -52,7 +52,6 @@ async def async_get_service( ) -> PushoverNotificationService | None: """Get the Pushover notification service.""" if discovery_info is None: - async_create_issue( hass, DOMAIN, diff --git a/homeassistant/components/qvr_pro/camera.py b/homeassistant/components/qvr_pro/camera.py index 6ab918f26f..f3caac864d 100644 --- a/homeassistant/components/qvr_pro/camera.py +++ b/homeassistant/components/qvr_pro/camera.py @@ -30,7 +30,6 @@ def setup_platform( entities = [] for channel in hass.data[DOMAIN]["channels"]: - stream_source = get_stream_source(channel["guid"], client) entities.append( QVRProCamera(**channel, stream_source=stream_source, client=client) diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py index 5e91d339d0..bc27c0b220 100644 --- a/homeassistant/components/rachio/switch.py +++ b/homeassistant/components/rachio/switch.py @@ -122,7 +122,7 @@ async def async_setup_entry( entity.entity_id: entity.zone_id for entity in zone_entities } - for (count, data) in enumerate(entity_id): + for count, data in enumerate(entity_id): if data in entity_to_zone_id: # Time can be passed as a list per zone, # or one time for all zones diff --git a/homeassistant/components/rainbird/config_flow.py b/homeassistant/components/rainbird/config_flow.py index 057fc6fe39..3ade67c79f 100644 --- a/homeassistant/components/rainbird/config_flow.py +++ b/homeassistant/components/rainbird/config_flow.py @@ -138,7 +138,7 @@ class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): CONF_SERIAL_NUMBER: serial_number, } names: dict[str, str] = {} - for (zone, zone_config) in config.get(CONF_ZONES, {}).items(): + for zone, zone_config in config.get(CONF_ZONES, {}).items(): if name := zone_config.get(CONF_FRIENDLY_NAME): names[str(zone)] = name if names: diff --git a/homeassistant/components/recorder/pool.py b/homeassistant/components/recorder/pool.py index d839f18312..8f39bdb9b7 100644 --- a/homeassistant/components/recorder/pool.py +++ b/homeassistant/components/recorder/pool.py @@ -121,7 +121,6 @@ class MutexPool(StaticPool): # type: ignore[misc] MutexPool.pool_lock.release() def _do_get(self) -> Any: - if DEBUG_MUTEX_POOL_TRACE: trace = traceback.extract_stack() trace_msg = "".join(traceback.format_list(trace[:-1])) diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 01b10ea966..4fb52dabb2 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -383,7 +383,6 @@ def _select_unused_event_data_ids( def _purge_unused_data_ids( instance: Recorder, session: Session, data_ids_batch: set[int], using_sqlite: bool ) -> None: - if unused_data_ids_set := _select_unused_event_data_ids( session, data_ids_batch, using_sqlite ): diff --git a/homeassistant/components/repairs/issue_handler.py b/homeassistant/components/repairs/issue_handler.py index 1dda2fb87a..54b1f249ca 100644 --- a/homeassistant/components/repairs/issue_handler.py +++ b/homeassistant/components/repairs/issue_handler.py @@ -27,7 +27,7 @@ class ConfirmRepairFlow(RepairsFlow): self, user_input: dict[str, str] | None = None ) -> data_entry_flow.FlowResult: """Handle the first step of a fix flow.""" - return await (self.async_step_confirm()) + return await self.async_step_confirm() async def async_step_confirm( self, user_input: dict[str, str] | None = None diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index c26f9711e1..1007ee1d2d 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -125,7 +125,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: headers=headers, timeout=timeout, ) as response: - if response.status < HTTPStatus.BAD_REQUEST: _LOGGER.debug( "Success. Url: %s. Status code: %d. Payload: %s", diff --git a/homeassistant/components/rfxtrx/binary_sensor.py b/homeassistant/components/rfxtrx/binary_sensor.py index 03ad7ce071..b729138f73 100644 --- a/homeassistant/components/rfxtrx/binary_sensor.py +++ b/homeassistant/components/rfxtrx/binary_sensor.py @@ -104,7 +104,6 @@ async def async_setup_entry( device_id: DeviceTuple, entity_info: dict[str, Any], ) -> list[Entity]: - return [ RfxtrxBinarySensor( event.device, diff --git a/homeassistant/components/rss_feed_template/__init__.py b/homeassistant/components/rss_feed_template/__init__.py index 27a67fd664..7b7cbb7e94 100644 --- a/homeassistant/components/rss_feed_template/__init__.py +++ b/homeassistant/components/rss_feed_template/__init__.py @@ -43,7 +43,7 @@ CONFIG_SCHEMA = vol.Schema( def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the RSS feed template component.""" - for (feeduri, feedconfig) in config[DOMAIN].items(): + for feeduri, feedconfig in config[DOMAIN].items(): url = f"/api/rss_template/{feeduri}" requires_auth: bool = feedconfig["requires_api_password"] diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index fb3b4e4653..18f1ce9c45 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -225,7 +225,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: (SERVICE_RESUME, async_resume_queue, SERVICE_BASE_SCHEMA), (SERVICE_SET_SPEED, async_set_queue_speed, SERVICE_SPEED_SCHEMA), ): - if hass.services.has_service(DOMAIN, service): continue diff --git a/homeassistant/components/sabnzbd/config_flow.py b/homeassistant/components/sabnzbd/config_flow.py index 073f4a08f7..d433b56218 100644 --- a/homeassistant/components/sabnzbd/config_flow.py +++ b/homeassistant/components/sabnzbd/config_flow.py @@ -52,7 +52,6 @@ class SABnzbdConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: - errors = await self._async_validate_input(user_input) if not errors: diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 45786ae984..b23f3d916c 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -268,7 +268,6 @@ async def _create_script_entities( entities: list[ScriptEntity] = [] for script_config in script_configs: - entity = ScriptEntity( hass, script_config.key, diff --git a/homeassistant/components/sensibo/config_flow.py b/homeassistant/components/sensibo/config_flow.py index f3b413071e..b7d4bca890 100644 --- a/homeassistant/components/sensibo/config_flow.py +++ b/homeassistant/components/sensibo/config_flow.py @@ -82,7 +82,6 @@ class SensiboConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input: - api_key = user_input[CONF_API_KEY] try: username = await async_validate_api(self.hass, api_key) diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 5102ed9e4c..6cd4c19c63 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -91,7 +91,6 @@ def async_restore_climate_entities( entries = async_entries_for_config_entry(ent_reg, config_entry.entry_id) for entry in entries: - if entry.domain != CLIMATE_DOMAIN: continue diff --git a/homeassistant/components/simplepush/config_flow.py b/homeassistant/components/simplepush/config_flow.py index 01ca508a5c..702be4391e 100644 --- a/homeassistant/components/simplepush/config_flow.py +++ b/homeassistant/components/simplepush/config_flow.py @@ -45,7 +45,6 @@ class SimplePushFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a flow initiated by the user.""" errors: dict[str, str] | None = None if user_input is not None: - await self.async_set_unique_id(user_input[CONF_DEVICE_KEY]) self._abort_if_unique_id_configured() diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 60cc2cea0d..4f0c3aae2a 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -318,6 +318,7 @@ class DeviceBroker: def connect(self): """Connect handlers/listeners for device/lifecycle events.""" + # Setup interval to regenerate the refresh token on a periodic basis. # Tokens expire in 30 days and once expired, cannot be recovered. async def regenerate_refresh_token(now): diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index 7fd192304a..c20e5fe6e3 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -120,7 +120,6 @@ async def async_setup_platform( return if version == "3": - if not authkey: authproto = "none" if not privkey: diff --git a/homeassistant/components/snmp/switch.py b/homeassistant/components/snmp/switch.py index bd5078c434..4699aaefd7 100644 --- a/homeassistant/components/snmp/switch.py +++ b/homeassistant/components/snmp/switch.py @@ -210,7 +210,6 @@ class SnmpSwitch(SwitchEntity): self._payload_off = payload_off if version == "3": - if not authkey: authproto = "none" if not privkey: diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index b1dfac7bee..09a6cfdf89 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -180,7 +180,7 @@ class SonosSpeaker: (f"{SONOS_VANISHED}-{self.soco.uid}", self.async_vanished), ) - for (signal, target) in dispatch_pairs: + for signal, target in dispatch_pairs: entry.async_on_unload( async_dispatcher_connect( self.hass, diff --git a/homeassistant/components/spc/__init__.py b/homeassistant/components/spc/__init__.py index f3c829f5d0..49dc7b13fc 100644 --- a/homeassistant/components/spc/__init__.py +++ b/homeassistant/components/spc/__init__.py @@ -41,7 +41,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the SPC component.""" async def async_upate_callback(spc_object): - if isinstance(spc_object, Area): async_dispatcher_send(hass, SIGNAL_UPDATE_ALARM.format(spc_object.id)) elif isinstance(spc_object, Zone): diff --git a/homeassistant/components/sql/config_flow.py b/homeassistant/components/sql/config_flow.py index cc8bbe672b..f33aad68c3 100644 --- a/homeassistant/components/sql/config_flow.py +++ b/homeassistant/components/sql/config_flow.py @@ -90,7 +90,6 @@ class SQLConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) if user_input is not None: - db_url = user_input.get(CONF_DB_URL, db_url_default) query = user_input[CONF_QUERY] column = user_input[CONF_COLUMN_NAME] diff --git a/homeassistant/components/srp_energy/__init__.py b/homeassistant/components/srp_energy/__init__.py index bed6780e52..b98b8a39df 100644 --- a/homeassistant/components/srp_energy/__init__.py +++ b/homeassistant/components/srp_energy/__init__.py @@ -26,7 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data.get(CONF_PASSWORD), ) hass.data[SRP_ENERGY_DOMAIN] = srp_energy_client - except (Exception) as ex: + except Exception as ex: _LOGGER.error("Unable to connect to Srp Energy: %s", str(ex)) raise ConfigEntryNotReady from ex diff --git a/homeassistant/components/srp_energy/config_flow.py b/homeassistant/components/srp_energy/config_flow.py index f3df156f74..2d5505b763 100644 --- a/homeassistant/components/srp_energy/config_flow.py +++ b/homeassistant/components/srp_energy/config_flow.py @@ -34,7 +34,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=SRP_ENERGY_DOMAIN): if user_input is not None: try: - srp_client = SrpEnergyClient( user_input[CONF_ID], user_input[CONF_USERNAME], diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py index c4c7ec728e..1aaf5175e5 100644 --- a/homeassistant/components/srp_energy/sensor.py +++ b/homeassistant/components/srp_energy/sensor.py @@ -61,7 +61,7 @@ async def async_setup_entry( for _, _, _, kwh, _ in hourly_usage: previous_daily_usage += float(kwh) return previous_daily_usage - except (TimeoutError) as timeout_err: + except TimeoutError as timeout_err: raise UpdateFailed("Timeout communicating with API") from timeout_err except (ConnectError, HTTPError, Timeout, ValueError, TypeError) as err: raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/homeassistant/components/supla/__init__.py b/homeassistant/components/supla/__init__.py index 674855c620..02818ccf8d 100644 --- a/homeassistant/components/supla/__init__.py +++ b/homeassistant/components/supla/__init__.py @@ -63,7 +63,6 @@ async def async_setup(hass: HomeAssistant, base_config: ConfigType) -> bool: session = async_get_clientsession(hass) for server_conf in server_confs: - server_address = server_conf[CONF_SERVER] server = SuplaAPI(server_address, server_conf[CONF_ACCESS_TOKEN], session) diff --git a/homeassistant/components/surepetcare/binary_sensor.py b/homeassistant/components/surepetcare/binary_sensor.py index f4565ea375..9af6c307d4 100644 --- a/homeassistant/components/surepetcare/binary_sensor.py +++ b/homeassistant/components/surepetcare/binary_sensor.py @@ -31,7 +31,6 @@ async def async_setup_entry( coordinator: SurePetcareDataCoordinator = hass.data[DOMAIN][entry.entry_id] for surepy_entity in coordinator.data.values(): - # connectivity if surepy_entity.type in [ EntityType.CAT_FLAP, diff --git a/homeassistant/components/surepetcare/sensor.py b/homeassistant/components/surepetcare/sensor.py index 534f239675..b6b2b75504 100644 --- a/homeassistant/components/surepetcare/sensor.py +++ b/homeassistant/components/surepetcare/sensor.py @@ -29,7 +29,6 @@ async def async_setup_entry( coordinator: SurePetcareDataCoordinator = hass.data[DOMAIN][entry.entry_id] for surepy_entity in coordinator.data.values(): - if surepy_entity.type in [ EntityType.CAT_FLAP, EntityType.PET_FLAP, diff --git a/homeassistant/components/swiss_hydrological_data/sensor.py b/homeassistant/components/swiss_hydrological_data/sensor.py index dfa8b8fba0..d51ce18ada 100644 --- a/homeassistant/components/swiss_hydrological_data/sensor.py +++ b/homeassistant/components/swiss_hydrological_data/sensor.py @@ -125,7 +125,6 @@ class SwissHydrologicalDataSensor(SensorEntity): attrs = {} if not self._data: - return attrs attrs[ATTR_WATER_BODY_TYPE] = self._data["water-body-type"] diff --git a/homeassistant/components/switchbee/climate.py b/homeassistant/components/switchbee/climate.py index 3b42287e89..bf780e76eb 100644 --- a/homeassistant/components/switchbee/climate.py +++ b/homeassistant/components/switchbee/climate.py @@ -115,7 +115,6 @@ class SwitchBeeClimateEntity(SwitchBeeDeviceEntity[SwitchBeeThermostat], Climate super()._handle_coordinator_update() def _update_attrs_from_coordinator(self) -> None: - coordinator_device = self._get_coordinator_device() self._attr_hvac_mode: HVACMode = ( diff --git a/homeassistant/components/switchbee/light.py b/homeassistant/components/switchbee/light.py index 0ff14a45e2..7227815d97 100644 --- a/homeassistant/components/switchbee/light.py +++ b/homeassistant/components/switchbee/light.py @@ -71,7 +71,6 @@ class SwitchBeeLightEntity(SwitchBeeDeviceEntity[SwitchBeeDimmer], LightEntity): super()._handle_coordinator_update() def _update_attrs_from_coordinator(self) -> None: - coordinator_device = self._get_coordinator_device() brightness = coordinator_device.brightness diff --git a/homeassistant/components/switchbee/switch.py b/homeassistant/components/switchbee/switch.py index f8f6e30e36..d48a3e2e02 100644 --- a/homeassistant/components/switchbee/switch.py +++ b/homeassistant/components/switchbee/switch.py @@ -79,7 +79,6 @@ class SwitchBeeSwitchEntity(SwitchBeeDeviceEntity[_DeviceTypeT], SwitchEntity): coordinator_device = self._get_coordinator_device() if coordinator_device.state == -1: - self._check_if_became_offline() return diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index 9f00009b32..cd46b4fd95 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -61,7 +61,6 @@ SERVICE_WRITE_SCHEMA = vol.Schema( def _figure_out_source( record: logging.LogRecord, call_stack: list[tuple[str, int]], paths_re: re.Pattern ) -> tuple[str, int]: - # If a stack trace exists, extract file names from the entire call stack. # The other case is when a regular "log" is made (without an attached # exception). In that case, just use the file where the log was made from. @@ -82,7 +81,6 @@ def _figure_out_source( # Iterate through the stack call (in reverse) and find the last call from # a file in Home Assistant. Try to figure out where error happened. for pathname in reversed(stack): - # Try to match with a file within Home Assistant if match := paths_re.match(pathname[0]): return (cast(str, match.group(1)), pathname[1]) diff --git a/homeassistant/components/tasmota/discovery.py b/homeassistant/components/tasmota/discovery.py index 4ab3f780ad..b490b4c724 100644 --- a/homeassistant/components/tasmota/discovery.py +++ b/homeassistant/components/tasmota/discovery.py @@ -290,7 +290,7 @@ async def async_start( # noqa: C901 for platform in PLATFORMS: tasmota_entities = tasmota_get_entities_for_platform(payload, platform) - for (tasmota_entity_config, discovery_hash) in tasmota_entities: + for tasmota_entity_config, discovery_hash in tasmota_entities: _discover_entity(tasmota_entity_config, discovery_hash, platform) async def async_sensors_discovered( @@ -316,7 +316,7 @@ async def async_start( # noqa: C901 ) if entry.domain == sensor.DOMAIN and entry.platform == DOMAIN } - for (tasmota_sensor_config, discovery_hash) in sensors: + for tasmota_sensor_config, discovery_hash in sensors: if tasmota_sensor_config: orphaned_entities.discard(tasmota_sensor_config.unique_id) _discover_entity(tasmota_sensor_config, discovery_hash, platform) diff --git a/homeassistant/components/tellduslive/config_flow.py b/homeassistant/components/tellduslive/config_flow.py index 18ae324a57..18bbe59a25 100644 --- a/homeassistant/components/tellduslive/config_flow.py +++ b/homeassistant/components/tellduslive/config_flow.py @@ -42,7 +42,6 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._scan_interval = SCAN_INTERVAL def _get_auth_url(self): - self._session = Session( public_key=PUBLIC_KEY, private_key=NOT_SO_PRIVATE_KEY, diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 4c234e2187..e36ca75ed0 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -105,7 +105,6 @@ async def _async_create_entities(hass, config): covers = [] for object_id, entity_config in config[CONF_COVERS].items(): - entity_config = rewrite_common_legacy_to_modern_conf(entity_config) unique_id = entity_config.get(CONF_UNIQUE_ID) diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 3d3c17551c..88309810ad 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -93,7 +93,6 @@ async def _async_create_entities(hass, config): fans = [] for object_id, entity_config in config[CONF_FANS].items(): - entity_config = rewrite_common_legacy_to_modern_conf(entity_config) unique_id = entity_config.get(CONF_UNIQUE_ID) diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index 9f282cb9b1..b21e02e407 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -134,7 +134,6 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): async def async_added_to_hass(self) -> None: """Register callbacks.""" if self._template is None: - # restore state after startup await super().async_added_to_hass() if state := await self.async_get_last_state(): diff --git a/homeassistant/components/tomato/device_tracker.py b/homeassistant/components/tomato/device_tracker.py index c99a44120e..e10bc3b81d 100644 --- a/homeassistant/components/tomato/device_tracker.py +++ b/homeassistant/components/tomato/device_tracker.py @@ -108,9 +108,7 @@ class TomatoDeviceScanner(DeviceScanner): # Calling and parsing the Tomato api here. We only need the # wldev and dhcpd_lease values. if response.status_code == HTTPStatus.OK: - for param, value in self.parse_api_pattern.findall(response.text): - if param in ("wldev", "dhcpd_lease"): self.last_results[param] = json.loads(value.replace("'", '"')) return True diff --git a/homeassistant/components/toon/coordinator.py b/homeassistant/components/toon/coordinator.py index 5819ff1274..5816593521 100644 --- a/homeassistant/components/toon/coordinator.py +++ b/homeassistant/components/toon/coordinator.py @@ -54,7 +54,6 @@ class ToonDataUpdateCoordinator(DataUpdateCoordinator[Status]): self.hass.config_entries.async_update_entry(self.entry, data=data) if cloud.async_active_subscription(self.hass): - if CONF_CLOUDHOOK_URL not in self.entry.data: try: webhook_url = await cloud.async_create_cloudhook( diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index 601229ed2e..bbc089de0c 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -251,7 +251,11 @@ class TraccarScanner: """Update info from Traccar.""" _LOGGER.debug("Updating device data") try: - (self._devices, self._positions, self._geofences,) = await asyncio.gather( + ( + self._devices, + self._positions, + self._geofences, + ) = await asyncio.gather( self._api.get_devices(), self._api.get_positions(), self._api.get_geofences(), diff --git a/homeassistant/components/trafikverket_train/sensor.py b/homeassistant/components/trafikverket_train/sensor.py index 8ecf0a99c3..678a0af229 100644 --- a/homeassistant/components/trafikverket_train/sensor.py +++ b/homeassistant/components/trafikverket_train/sensor.py @@ -138,7 +138,6 @@ class TrainSensor(SensorEntity): self._from_station, self._to_station, when ) else: - _state = await self._train_api.async_get_next_train_stop( self._from_station, self._to_station, when ) diff --git a/homeassistant/components/transmission/config_flow.py b/homeassistant/components/transmission/config_flow.py index a21fe9b883..b7784fbe4a 100644 --- a/homeassistant/components/transmission/config_flow.py +++ b/homeassistant/components/transmission/config_flow.py @@ -62,7 +62,6 @@ class TransmissionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: - for entry in self._async_current_entries(): if ( entry.data[CONF_HOST] == user_input[CONF_HOST] diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index 8a65c06b70..f7b051f52c 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -372,7 +372,6 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity): and not self.device_class.startswith(DOMAIN) and description.native_unit_of_measurement is None ): - # We cannot have a device class, if the UOM isn't set or the # device class cannot be found in the validation mapping. if ( diff --git a/homeassistant/components/twinkly/light.py b/homeassistant/components/twinkly/light.py index 0145439493..1a04f6c712 100644 --- a/homeassistant/components/twinkly/light.py +++ b/homeassistant/components/twinkly/light.py @@ -196,7 +196,6 @@ class TwinklyLight(LightEntity): ATTR_RGBW_COLOR in kwargs and kwargs[ATTR_RGBW_COLOR] != self._attr_rgbw_color ): - await self._client.interview() if LightEntityFeature.EFFECT & self.supported_features: # Static color only supports rgb @@ -223,7 +222,6 @@ class TwinklyLight(LightEntity): self._attr_rgbw_color = kwargs[ATTR_RGBW_COLOR] if ATTR_RGB_COLOR in kwargs and kwargs[ATTR_RGB_COLOR] != self._attr_rgb_color: - await self._client.interview() if LightEntityFeature.EFFECT & self.supported_features: await self._client.set_static_colour(kwargs[ATTR_RGB_COLOR]) diff --git a/homeassistant/components/uk_transport/sensor.py b/homeassistant/components/uk_transport/sensor.py index cd1983f947..d98f9d5011 100644 --- a/homeassistant/components/uk_transport/sensor.py +++ b/homeassistant/components/uk_transport/sensor.py @@ -173,7 +173,7 @@ class UkTransportLiveBusTimeSensor(UkTransportSensor): if self._data != {}: self._next_buses = [] - for (route, departures) in self._data["departures"].items(): + for route, departures in self._data["departures"].items(): for departure in departures: if self._destination_re.search(departure["direction"]): self._next_buses.append( diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index 64ea7b3977..d4e885b295 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -86,7 +86,6 @@ class UnifiFlowHandler(config_entries.ConfigFlow, domain=UNIFI_DOMAIN): errors = {} if user_input is not None: - self.config = { CONF_HOST: user_input[CONF_HOST], CONF_USERNAME: user_input[CONF_USERNAME], @@ -148,7 +147,6 @@ class UnifiFlowHandler(config_entries.ConfigFlow, domain=UNIFI_DOMAIN): ) -> FlowResult: """Select site to control.""" if user_input is not None: - unique_id = user_input[CONF_SITE_ID] self.config[CONF_SITE_ID] = self.site_ids[unique_id] diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index d26780ab01..2721e254de 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -228,7 +228,6 @@ class UniFiController: def async_unifi_signalling_callback(self, signal, data): """Handle messages back from UniFi library.""" if signal == WebsocketSignal.CONNECTION_STATE: - if data == WebsocketState.DISCONNECTED and self.available: LOGGER.warning("Lost connection to UniFi Network") @@ -244,14 +243,12 @@ class UniFiController: LOGGER.info("Connected to UniFi Network") elif signal == WebsocketSignal.DATA and data: - if DATA_EVENT in data: clients_connected = set() devices_connected = set() wireless_clients_connected = False for event in data[DATA_EVENT]: - if event.key in CLIENT_CONNECTED: clients_connected.add(event.mac) diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index a7678445c8..c845b6d5d3 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -272,7 +272,6 @@ class UniFiClientTracker(UniFiClientBase, ScannerEntity): self.client.last_updated == SOURCE_EVENT and not self._only_listen_to_data_source ): - if (self.is_wired and self.client.event.key in WIRED_CONNECTION) or ( not self.is_wired and self.client.event.key in WIRELESS_CONNECTION ): diff --git a/homeassistant/components/unifi/services.py b/homeassistant/components/unifi/services.py index 4574a27016..cba5f0b9d0 100644 --- a/homeassistant/components/unifi/services.py +++ b/homeassistant/components/unifi/services.py @@ -89,14 +89,12 @@ async def async_remove_clients(hass, data) -> None: - Neither IP, hostname nor name is configured. """ for controller in hass.data[UNIFI_DOMAIN].values(): - if not controller.available: continue clients_to_remove = [] for client in controller.api.clients_all.values(): - if ( client.last_seen and client.first_seen diff --git a/homeassistant/components/unifi_direct/device_tracker.py b/homeassistant/components/unifi_direct/device_tracker.py index d0ac37f312..42f83dad5d 100644 --- a/homeassistant/components/unifi_direct/device_tracker.py +++ b/homeassistant/components/unifi_direct/device_tracker.py @@ -102,7 +102,6 @@ class UnifiDeviceScanner(DeviceScanner): self.connected = False def _get_update(self): - try: if not self.connected: self._connect() diff --git a/homeassistant/components/unifiprotect/button.py b/homeassistant/components/unifiprotect/button.py index 2900757192..8c620402e7 100644 --- a/homeassistant/components/unifiprotect/button.py +++ b/homeassistant/components/unifiprotect/button.py @@ -98,7 +98,6 @@ CHIME_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = ( def _async_remove_adopt_button( hass: HomeAssistant, device: ProtectAdoptableDeviceModel ) -> None: - entity_registry = er.async_get(hass) if entity_id := entity_registry.async_get_entity_id( Platform.BUTTON, DOMAIN, f"{device.mac}_adopt" diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 4be8b489de..1af4d82ff9 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -104,7 +104,6 @@ def _get_nvr_memory(obj: NVR) -> float | None: def _get_alarm_sound(obj: Sensor) -> str: - alarm_type = OBJECT_TYPE_NONE if ( obj.is_alarm_detected diff --git a/homeassistant/components/w800rf32/binary_sensor.py b/homeassistant/components/w800rf32/binary_sensor.py index ef8e02803f..0400746999 100644 --- a/homeassistant/components/w800rf32/binary_sensor.py +++ b/homeassistant/components/w800rf32/binary_sensor.py @@ -54,7 +54,6 @@ async def async_setup_platform( # device_id --> "c1 or a3" X10 device. entity (type dictionary) # --> name, device_class etc for device_id, entity in config[CONF_DEVICES].items(): - _LOGGER.debug( "Add %s w800rf32.binary_sensor (class %s)", entity[CONF_NAME], @@ -128,7 +127,6 @@ class W800rf32BinarySensor(BinarySensorEntity): self.update_state(is_on) if self.is_on and self._off_delay is not None and self._delay_listener is None: - self._delay_listener = evt.async_track_point_in_time( self.hass, self._off_delay_listener, dt_util.utcnow() + self._off_delay ) diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index 972760f5af..b026dd0ce3 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -295,6 +295,5 @@ class WasherDryerTimeClass(RestoreSensor): if isinstance(self._attr_native_value, datetime) and abs( new_timestamp - self._attr_native_value ) > timedelta(seconds=60): - self._attr_native_value = new_timestamp self._async_write_ha_state() diff --git a/homeassistant/components/ws66i/__init__.py b/homeassistant/components/ws66i/__init__.py index cffedc2f68..64f049aa9a 100644 --- a/homeassistant/components/ws66i/__init__.py +++ b/homeassistant/components/ws66i/__init__.py @@ -41,7 +41,6 @@ def _find_zones(hass: HomeAssistant, ws66i: WS66i) -> list[int]: # Zones 21,31 - 26,36 are the daisy-chained amps zone_list = [] for amp_num in range(1, 4): - if amp_num > 1: # Don't add entities that aren't present status = ws66i.zone_status(amp_num * 10 + 1) diff --git a/homeassistant/components/zamg/coordinator.py b/homeassistant/components/zamg/coordinator.py index 4b25f4f7fb..f785524e86 100644 --- a/homeassistant/components/zamg/coordinator.py +++ b/homeassistant/components/zamg/coordinator.py @@ -44,7 +44,7 @@ class ZamgDataUpdateCoordinator(DataUpdateCoordinator[ZamgDevice]): device = await self.zamg.update() except ZamgNoDataError as error: raise UpdateFailed("No response from API") from error - except (ZamgError) as error: + except ZamgError as error: raise UpdateFailed(f"Invalid response from API: {error}") from error self.data = device self.data["last_update"] = self.zamg.last_update diff --git a/homeassistant/components/zamg/sensor.py b/homeassistant/components/zamg/sensor.py index 348052bc5f..21e0dd8de1 100644 --- a/homeassistant/components/zamg/sensor.py +++ b/homeassistant/components/zamg/sensor.py @@ -214,7 +214,7 @@ class ZamgSensor(CoordinatorEntity, SensorEntity): return self.coordinator.data[self.station_id][ self.entity_description.para_name ]["data"] - except (KeyError): + except KeyError: return None @property diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index c0959cb40c..dd8b3b9c2a 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -725,10 +725,8 @@ class ZHADevice(LogMixin): response = await getattr(cluster, commands[command].name)(*args) else: assert params is not None - response = await ( - getattr(cluster, commands[command].name)( - **convert_to_zcl_values(params, commands[command].schema) - ) + response = await getattr(cluster, commands[command].name)( + **convert_to_zcl_values(params, commands[command].schema) ) self.debug( "Issued cluster command: %s %s %s %s %s %s %s %s", diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index 431ab8620f..9f472a1a93 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -338,7 +338,6 @@ def retryable_req( def decorator(func): @functools.wraps(func) async def wrapper(channel, *args, **kwargs): - exceptions = (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) try_count, errors = 1, [] for delay in itertools.chain(delays, [None]): diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 18dd9aea34..f003d75503 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -210,9 +210,9 @@ class EntityPlatform: return @callback - def async_create_setup_task() -> Coroutine[ - Any, Any, None - ] | asyncio.Future[None]: + def async_create_setup_task() -> ( + Coroutine[Any, Any, None] | asyncio.Future[None] + ): """Get task to set up platform.""" if getattr(platform, "async_setup_platform", None): return platform.async_setup_platform( # type: ignore[union-attr] diff --git a/homeassistant/helpers/network.py b/homeassistant/helpers/network.py index a6060226d7..12accf2725 100644 --- a/homeassistant/helpers/network.py +++ b/homeassistant/helpers/network.py @@ -140,7 +140,6 @@ def get_url( # Try finding an URL in the order specified for url_type in order: - if allow_internal and url_type == TYPE_URL_INTERNAL: with suppress(NoURLAvailableError): return _get_internal_url( diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index ef8bee1fc7..3c3da10db7 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -613,7 +613,6 @@ async def entity_service_call( # noqa: C901 for platform in platforms: platform_entities = [] for entity in platform.entities.values(): - if entity.entity_id not in all_referenced: continue diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index a852f1b316..569dc66370 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -2,7 +2,7 @@ autoflake==2.0.0 bandit==1.7.4 -black==22.12.0 +black==23.1.0 codespell==2.2.2 flake8-comprehensions==3.10.1 flake8-docstrings==1.6.0 diff --git a/script/translations/migrate.py b/script/translations/migrate.py index d3efdc28d1..f5bd60c30b 100644 --- a/script/translations/migrate.py +++ b/script/translations/migrate.py @@ -263,7 +263,6 @@ def find_frontend_states(): from_key_base = f"state::{domain}" if domain in STATES_WITH_DEV_CLASS: - domain_to_write = dict(states) for device_class, dev_class_states in domain_to_write.items(): @@ -338,7 +337,6 @@ def apply_data_references(to_migrate): for step_data in steps.values(): step_data = step_data.get("data", {}) for key, value in step_data.items(): - if key in to_migrate and value != to_migrate[key]: if key.split("_")[0].lower() in value.lower(): step_data[key] = to_migrate[key] diff --git a/tests/components/abode/test_config_flow.py b/tests/components/abode/test_config_flow.py index c16fea4ef2..5982a02c59 100644 --- a/tests/components/abode/test_config_flow.py +++ b/tests/components/abode/test_config_flow.py @@ -99,7 +99,6 @@ async def test_step_user(hass: HomeAssistant) -> None: conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"} with patch("homeassistant.components.abode.config_flow.Abode"): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=conf ) diff --git a/tests/components/accuweather/test_config_flow.py b/tests/components/accuweather/test_config_flow.py index 4abde4d762..b0b0def1d9 100644 --- a/tests/components/accuweather/test_config_flow.py +++ b/tests/components/accuweather/test_config_flow.py @@ -53,7 +53,6 @@ async def test_invalid_api_key(hass): "homeassistant.components.accuweather.AccuWeather._async_get_data", side_effect=InvalidApiKeyError("Invalid API key"), ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -69,7 +68,6 @@ async def test_api_error(hass): "homeassistant.components.accuweather.AccuWeather._async_get_data", side_effect=ApiError("Invalid response from AccuWeather API"), ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -87,7 +85,6 @@ async def test_requests_exceeded_error(hass): "The allowed number of requests has been exceeded" ), ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -127,7 +124,6 @@ async def test_create_entry(hass): ), patch( "homeassistant.components.accuweather.async_setup_entry", return_value=True ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, diff --git a/tests/components/accuweather/test_init.py b/tests/components/accuweather/test_init.py index ee91fa4d21..6210aba2ce 100644 --- a/tests/components/accuweather/test_init.py +++ b/tests/components/accuweather/test_init.py @@ -75,7 +75,6 @@ async def test_update_interval(hass): "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", return_value=current, ) as mock_current: - assert mock_current.call_count == 0 async_fire_time_changed(hass, future) @@ -101,7 +100,6 @@ async def test_update_interval_forecast(hass): "homeassistant.components.accuweather.AccuWeather.async_get_forecast", return_value=forecast, ) as mock_forecast: - assert mock_current.call_count == 0 assert mock_forecast.call_count == 0 diff --git a/tests/components/adax/test_config_flow.py b/tests/components/adax/test_config_flow.py index 0c12c48675..ce6bf3ac9a 100644 --- a/tests/components/adax/test_config_flow.py +++ b/tests/components/adax/test_config_flow.py @@ -41,7 +41,10 @@ async def test_form(hass: HomeAssistant) -> None: ) assert result2["type"] == FlowResultType.FORM - with patch("adax.get_adax_token", return_value="test_token",), patch( + with patch( + "adax.get_adax_token", + return_value="test_token", + ), patch( "homeassistant.components.adax.async_setup_entry", return_value=True, ) as mock_setup_entry: diff --git a/tests/components/airthings/test_config_flow.py b/tests/components/airthings/test_config_flow.py index 6e3a1579f7..3a0c852535 100644 --- a/tests/components/airthings/test_config_flow.py +++ b/tests/components/airthings/test_config_flow.py @@ -25,7 +25,10 @@ async def test_form(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.FORM assert result["errors"] is None - with patch("airthings.get_token", return_value="test_token",), patch( + with patch( + "airthings.get_token", + return_value="test_token", + ), patch( "homeassistant.components.airthings.async_setup_entry", return_value=True, ) as mock_setup_entry: diff --git a/tests/components/aladdin_connect/test_cover.py b/tests/components/aladdin_connect/test_cover.py index 2192017c26..f78bf36775 100644 --- a/tests/components/aladdin_connect/test_cover.py +++ b/tests/components/aladdin_connect/test_cover.py @@ -129,7 +129,6 @@ async def test_cover_operation( "homeassistant.components.aladdin_connect.AladdinConnectClient", return_value=mock_aladdinconnect_api, ): - await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER, @@ -183,7 +182,6 @@ async def test_cover_operation( "homeassistant.components.aladdin_connect.AladdinConnectClient", return_value=mock_aladdinconnect_api, ): - await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER, diff --git a/tests/components/aladdin_connect/test_init.py b/tests/components/aladdin_connect/test_init.py index 9084754d56..4d5e267872 100644 --- a/tests/components/aladdin_connect/test_init.py +++ b/tests/components/aladdin_connect/test_init.py @@ -49,7 +49,6 @@ async def test_setup_login_error( "homeassistant.components.aladdin_connect.cover.AladdinConnectClient", return_value=mock_aladdinconnect_api, ): - assert await hass.config_entries.async_setup(config_entry.entry_id) is False @@ -68,7 +67,6 @@ async def test_setup_connection_error( "homeassistant.components.aladdin_connect.AladdinConnectClient", return_value=mock_aladdinconnect_api, ): - assert await hass.config_entries.async_setup(config_entry.entry_id) is False @@ -84,7 +82,6 @@ async def test_setup_component_no_error(hass: HomeAssistant) -> None: "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.login", return_value=True, ): - assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -107,7 +104,6 @@ async def test_entry_password_fail( "homeassistant.components.aladdin_connect.AladdinConnectClient", return_value=mock_aladdinconnect_api, ): - await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.SETUP_ERROR @@ -128,7 +124,6 @@ async def test_load_and_unload( "homeassistant.components.aladdin_connect.AladdinConnectClient", return_value=mock_aladdinconnect_api, ): - await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index a84d734249..7ca10c5a23 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -1522,7 +1522,10 @@ async def test_media_player_speaker(hass): ) assert call.data["is_volume_muted"] - call, _, = await assert_request_calls_service( + ( + call, + _, + ) = await assert_request_calls_service( "Alexa.Speaker", "SetMute", "media_player#test_speaker", @@ -1571,7 +1574,10 @@ async def test_media_player_step_speaker(hass): ) assert call.data["is_volume_muted"] - call, _, = await assert_request_calls_service( + ( + call, + _, + ) = await assert_request_calls_service( "Alexa.StepSpeaker", "SetMute", "media_player#test_step_speaker", diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index bd16ce96ff..f4f09fc160 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -400,7 +400,6 @@ async def test_dev_url_error(hass, aioclient_mock, caplog): with patch( "homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION_DEV ): - await analytics.send_analytics() payload = aioclient_mock.mock_calls[0] @@ -420,7 +419,6 @@ async def test_nightly_endpoint(hass, aioclient_mock): with patch( "homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION_NIGHTLY ): - await analytics.send_analytics() payload = aioclient_mock.mock_calls[0] diff --git a/tests/components/androidtv/test_config_flow.py b/tests/components/androidtv/test_config_flow.py index 0310173301..856762a3d3 100644 --- a/tests/components/androidtv/test_config_flow.py +++ b/tests/components/androidtv/test_config_flow.py @@ -125,7 +125,6 @@ async def test_user_adbkey(hass): CONNECT_METHOD, return_value=(MockConfigDevice(), None), ), PATCH_ISFILE, PATCH_ACCESS, PATCH_SETUP_ENTRY as mock_setup_entry: - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER, "show_advanced_options": True}, diff --git a/tests/components/aurora_abb_powerone/test_config_flow.py b/tests/components/aurora_abb_powerone/test_config_flow.py index 73b4b67bc0..88d024356d 100644 --- a/tests/components/aurora_abb_powerone/test_config_flow.py +++ b/tests/components/aurora_abb_powerone/test_config_flow.py @@ -33,7 +33,10 @@ async def test_form(hass): assert result["type"] == "form" assert result["errors"] == {} - with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None,), patch( + with patch( + "aurorapy.client.AuroraSerialClient.connect", + return_value=None, + ), patch( "aurorapy.client.AuroraSerialClient.serial_number", return_value="9876543", ), patch( @@ -52,7 +55,6 @@ async def test_form(hass): "homeassistant.components.aurora_abb_powerone.async_setup_entry", return_value=True, ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_PORT: "/dev/ttyUSB7", CONF_ADDRESS: 7}, @@ -140,7 +142,10 @@ async def test_form_invalid_com_ports(hass): "aurorapy.client.AuroraSerialClient.connect", side_effect=AuroraError("...Some other message!!!123..."), return_value=None, - ), patch("serial.Serial.isOpen", return_value=True,), patch( + ), patch( + "serial.Serial.isOpen", + return_value=True, + ), patch( "aurorapy.client.AuroraSerialClient.close", ) as mock_clientclose: result2 = await hass.config_entries.flow.async_configure( diff --git a/tests/components/aussie_broadband/test_config_flow.py b/tests/components/aussie_broadband/test_config_flow.py index ed98924e19..9b4be6d946 100644 --- a/tests/components/aussie_broadband/test_config_flow.py +++ b/tests/components/aussie_broadband/test_config_flow.py @@ -187,7 +187,6 @@ async def test_reauth(hass: HomeAssistant) -> None: with patch("aussiebb.asyncio.AussieBB.__init__", return_value=None), patch( "aussiebb.asyncio.AussieBB.login", side_effect=AuthenticationException() ), patch("aussiebb.asyncio.AussieBB.get_services", return_value=[FAKE_SERVICES[0]]): - result6 = await hass.config_entries.flow.async_configure( result5["flow_id"], { @@ -202,7 +201,6 @@ async def test_reauth(hass: HomeAssistant) -> None: with patch("aussiebb.asyncio.AussieBB.__init__", return_value=None), patch( "aussiebb.asyncio.AussieBB.login", return_value=True ), patch("aussiebb.asyncio.AussieBB.get_services", return_value=[FAKE_SERVICES[0]]): - result7 = await hass.config_entries.flow.async_configure( result6["flow_id"], { diff --git a/tests/components/aws/test_init.py b/tests/components/aws/test_init.py index 3b1dcaea1e..7875f366fc 100644 --- a/tests/components/aws/test_init.py +++ b/tests/components/aws/test_init.py @@ -85,7 +85,6 @@ async def test_profile_credential(hass): with async_patch( "homeassistant.components.aws.AioSession", return_value=mock_session ): - await async_setup_component( hass, "aws", diff --git a/tests/components/axis/conftest.py b/tests/components/axis/conftest.py index 38f20df7ff..b86c63d2ea 100644 --- a/tests/components/axis/conftest.py +++ b/tests/components/axis/conftest.py @@ -205,7 +205,6 @@ async def setup_config_entry_fixture(hass, config_entry, setup_default_vapix_req def mock_axis_rtspclient(): """No real RTSP communication allowed.""" with patch("axis.stream_manager.RTSPClient") as rtsp_client_mock: - rtsp_client_mock.return_value.session.state = State.STOPPED async def start_stream(): diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py index 708abec057..f3c459e2ef 100644 --- a/tests/components/backup/test_http.py +++ b/tests/components/backup/test_http.py @@ -27,7 +27,6 @@ async def test_downloading_backup( "homeassistant.components.backup.http.FileResponse", return_value=web.Response(text=""), ): - resp = await client.get("/api/backup/download/abc123") assert resp.status == 200 diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 4179eb026c..2e8ac8f2f5 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -24,7 +24,6 @@ async def test_info( "homeassistant.components.backup.websocket.BackupManager.get_backups", return_value={TEST_BACKUP.slug: TEST_BACKUP}, ): - await client.send_json({"id": 1, "type": "backup/info"}) msg = await client.receive_json() diff --git a/tests/components/blink/test_config_flow.py b/tests/components/blink/test_config_flow.py index 9d5d92f8dd..22834f4831 100644 --- a/tests/components/blink/test_config_flow.py +++ b/tests/components/blink/test_config_flow.py @@ -149,7 +149,9 @@ async def test_form_2fa_invalid_key(hass): assert result2["type"] == "form" assert result2["step_id"] == "2fa" - with patch("homeassistant.components.blink.config_flow.Auth.startup",), patch( + with patch( + "homeassistant.components.blink.config_flow.Auth.startup", + ), patch( "homeassistant.components.blink.config_flow.Auth.check_key_required", return_value=False, ), patch( diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index 5c6bbccd44..b0fc9cfa40 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -361,7 +361,10 @@ async def test_restore_history_remote_adapter( if address != "E3:A5:63:3E:5E:23": timestamps[address] = now - with patch("bluetooth_adapters.systems.linux.LinuxAdapters.history", {},), patch( + with patch( + "bluetooth_adapters.systems.linux.LinuxAdapters.history", + {}, + ), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.refresh", ): assert await async_setup_component(hass, bluetooth.DOMAIN, {}) diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index f4c9beafd4..17807bf8d3 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -450,7 +450,6 @@ async def test_diagnostics_remote_adapter( "homeassistant.components.bluetooth.diagnostics.get_dbus_managed_objects", return_value={}, ): - entry1 = MockConfigEntry( domain=bluetooth.DOMAIN, data={}, unique_id="00:00:00:00:00:01" ) diff --git a/tests/components/bluetooth/test_wrappers.py b/tests/components/bluetooth/test_wrappers.py index b2be6bf728..1237fd8940 100644 --- a/tests/components/bluetooth/test_wrappers.py +++ b/tests/components/bluetooth/test_wrappers.py @@ -180,10 +180,10 @@ def _generate_scanners_with_fake_devices(hass): hass, "00:00:00:00:00:02", "hci1", new_info_callback, None, True ) - for (device, adv_data) in hci0_device_advs.values(): + for device, adv_data in hci0_device_advs.values(): scanner_hci0.inject_advertisement(device, adv_data) - for (device, adv_data) in hci1_device_advs.values(): + for device, adv_data in hci1_device_advs.values(): scanner_hci1.inject_advertisement(device, adv_data) cancel_hci0 = manager.async_register_scanner(scanner_hci0, True, 2) diff --git a/tests/components/bluetooth_le_tracker/test_device_tracker.py b/tests/components/bluetooth_le_tracker/test_device_tracker.py index 04f8c534cf..4bacd9a447 100644 --- a/tests/components/bluetooth_le_tracker/test_device_tracker.py +++ b/tests/components/bluetooth_le_tracker/test_device_tracker.py @@ -78,7 +78,6 @@ async def test_preserve_new_tracked_device_name( ) as mock_async_discovered_service_info, patch.object( device_tracker, "MIN_SEEN_NEW", 3 ): - device = BluetoothServiceInfoBleak( name=name, address=address, @@ -147,7 +146,6 @@ async def test_tracking_battery_times_out( ) as mock_async_discovered_service_info, patch.object( device_tracker, "MIN_SEEN_NEW", 3 ): - device = BluetoothServiceInfoBleak( name=name, address=address, @@ -213,7 +211,6 @@ async def test_tracking_battery_fails(hass, mock_bluetooth, mock_device_tracker_ ) as mock_async_discovered_service_info, patch.object( device_tracker, "MIN_SEEN_NEW", 3 ): - device = BluetoothServiceInfoBleak( name=name, address=address, @@ -281,7 +278,6 @@ async def test_tracking_battery_successful( ) as mock_async_discovered_service_info, patch.object( device_tracker, "MIN_SEEN_NEW", 3 ): - device = BluetoothServiceInfoBleak( name=name, address=address, diff --git a/tests/components/brother/test_config_flow.py b/tests/components/brother/test_config_flow.py index 5a145edf03..832e874a46 100644 --- a/tests/components/brother/test_config_flow.py +++ b/tests/components/brother/test_config_flow.py @@ -118,7 +118,6 @@ async def test_unsupported_model_error(hass: HomeAssistant) -> None: with patch("brother.Brother.initialize"), patch( "brother.Brother._get_data", side_effect=UnsupportedModel("error") ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=CONFIG ) @@ -149,7 +148,6 @@ async def test_zeroconf_snmp_error(hass: HomeAssistant) -> None: with patch("brother.Brother.initialize"), patch( "brother.Brother._get_data", side_effect=SnmpError("error") ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, @@ -259,7 +257,6 @@ async def test_zeroconf_confirm_create_entry(hass: HomeAssistant) -> None: "brother.Brother._get_data", return_value=json.loads(load_fixture("printer_data.json", "brother")), ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, diff --git a/tests/components/canary/test_sensor.py b/tests/components/canary/test_sensor.py index 0a895dbe29..7de6661325 100644 --- a/tests/components/canary/test_sensor.py +++ b/tests/components/canary/test_sensor.py @@ -73,7 +73,7 @@ async def test_sensors_pro(hass, canary) -> None: ), } - for (sensor_id, data) in sensors.items(): + for sensor_id, data in sensors.items(): entity_entry = registry.async_get(f"sensor.{sensor_id}") assert entity_entry assert entity_entry.original_device_class == data[3] @@ -192,7 +192,7 @@ async def test_sensors_flex(hass, canary) -> None: ), } - for (sensor_id, data) in sensors.items(): + for sensor_id, data in sensors.items(): entity_entry = registry.async_get(f"sensor.{sensor_id}") assert entity_entry assert entity_entry.original_device_class == data[3] diff --git a/tests/components/cloudflare/test_init.py b/tests/components/cloudflare/test_init.py index c476b3ef37..773d65f27d 100644 --- a/tests/components/cloudflare/test_init.py +++ b/tests/components/cloudflare/test_init.py @@ -99,7 +99,6 @@ async def test_integration_services(hass, cfupdate): True, ), ): - await hass.services.async_call( DOMAIN, SERVICE_UPDATE_RECORDS, diff --git a/tests/components/co2signal/test_config_flow.py b/tests/components/co2signal/test_config_flow.py index d85279fc30..08d87d5e98 100644 --- a/tests/components/co2signal/test_config_flow.py +++ b/tests/components/co2signal/test_config_flow.py @@ -20,7 +20,10 @@ async def test_form_home(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.FORM assert result["errors"] is None - with patch("CO2Signal.get_latest", return_value=VALID_PAYLOAD,), patch( + with patch( + "CO2Signal.get_latest", + return_value=VALID_PAYLOAD, + ), patch( "homeassistant.components.co2signal.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -59,7 +62,10 @@ async def test_form_coordinates(hass: HomeAssistant) -> None: ) assert result2["type"] == FlowResultType.FORM - with patch("CO2Signal.get_latest", return_value=VALID_PAYLOAD,), patch( + with patch( + "CO2Signal.get_latest", + return_value=VALID_PAYLOAD, + ), patch( "homeassistant.components.co2signal.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -100,7 +106,10 @@ async def test_form_country(hass: HomeAssistant) -> None: ) assert result2["type"] == FlowResultType.FORM - with patch("CO2Signal.get_latest", return_value=VALID_PAYLOAD,), patch( + with patch( + "CO2Signal.get_latest", + return_value=VALID_PAYLOAD, + ), patch( "homeassistant.components.co2signal.async_setup_entry", return_value=True, ) as mock_setup_entry: diff --git a/tests/components/coinbase/test_config_flow.py b/tests/components/coinbase/test_config_flow.py index 80d394c38e..2ffb714975 100644 --- a/tests/components/coinbase/test_config_flow.py +++ b/tests/components/coinbase/test_config_flow.py @@ -196,7 +196,6 @@ async def test_option_form(hass): ), patch( "homeassistant.components.coinbase.update_listener" ) as mock_update_listener: - config_entry = await init_mock_coinbase(hass) await hass.async_block_till_done() result = await hass.config_entries.options.async_init(config_entry.entry_id) diff --git a/tests/components/coinbase/test_diagnostics.py b/tests/components/coinbase/test_diagnostics.py index f997b02f85..726f0ef4ca 100644 --- a/tests/components/coinbase/test_diagnostics.py +++ b/tests/components/coinbase/test_diagnostics.py @@ -27,7 +27,6 @@ async def test_entry_diagnostics(hass, hass_client: ClientSession): "coinbase.wallet.client.Client.get_exchange_rates", return_value=mock_get_exchange_rates(), ): - config_entry = await init_mock_coinbase(hass) await hass.async_block_till_done() diff --git a/tests/components/deconz/test_alarm_control_panel.py b/tests/components/deconz/test_alarm_control_panel.py index 213ce3b2e0..afa6357957 100644 --- a/tests/components/deconz/test_alarm_control_panel.py +++ b/tests/components/deconz/test_alarm_control_panel.py @@ -169,7 +169,6 @@ async def test_alarm_control_panel(hass, aioclient_mock, mock_deconz_websocket): AncillaryControlPanel.ARMING_NIGHT, AncillaryControlPanel.ARMING_STAY, }: - event_changed_sensor = { "t": "event", "e": "changed", @@ -188,7 +187,6 @@ async def test_alarm_control_panel(hass, aioclient_mock, mock_deconz_websocket): AncillaryControlPanel.ENTRY_DELAY, AncillaryControlPanel.EXIT_DELAY, }: - event_changed_sensor = { "t": "event", "e": "changed", diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index ffd58ec5ea..1eb97f3e6a 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -146,7 +146,9 @@ async def _async_get_handle_dhcp_packet(hass, integration_matchers): async_handle_dhcp_packet = _async_handle_dhcp_packet return MagicMock() - with patch("homeassistant.components.dhcp._verify_l2socket_setup",), patch( + with patch( + "homeassistant.components.dhcp._verify_l2socket_setup", + ), patch( "scapy.arch.common.compile_filter" ), patch("scapy.sendrecv.AsyncSniffer", _mock_sniffer): await dhcp_watcher.async_start() diff --git a/tests/components/dlna_dmr/test_media_player.py b/tests/components/dlna_dmr/test_media_player.py index 889cc92c96..35beed727e 100644 --- a/tests/components/dlna_dmr/test_media_player.py +++ b/tests/components/dlna_dmr/test_media_player.py @@ -434,7 +434,7 @@ async def test_available_device( assert device.name == "device_name" # Check entity state gets updated when device changes state - for (dev_state, ent_state) in [ + for dev_state, ent_state in [ (None, MediaPlayerState.ON), (TransportState.STOPPED, MediaPlayerState.IDLE), (TransportState.PLAYING, MediaPlayerState.PLAYING), diff --git a/tests/components/ecobee/test_config_flow.py b/tests/components/ecobee/test_config_flow.py index ca94d05f74..95da581aaf 100644 --- a/tests/components/ecobee/test_config_flow.py +++ b/tests/components/ecobee/test_config_flow.py @@ -175,7 +175,6 @@ async def test_import_flow_triggered_with_ecobee_conf_and_invalid_data(hass): ), patch.object( flow, "async_step_user", return_value=mock_coro() ) as mock_async_step_user: - await flow.async_step_import(import_data=None) mock_async_step_user.assert_called_once_with( diff --git a/tests/components/elkm1/__init__.py b/tests/components/elkm1/__init__.py index b818570ec6..cb34f78360 100644 --- a/tests/components/elkm1/__init__.py +++ b/tests/components/elkm1/__init__.py @@ -50,7 +50,10 @@ def _patch_elk(elk=None): @contextmanager def _patcher(): - with patch("homeassistant.components.elkm1.config_flow.Elk", new=_elk,), patch( + with patch( + "homeassistant.components.elkm1.config_flow.Elk", + new=_elk, + ), patch( "homeassistant.components.elkm1.config_flow.Elk", new=_elk, ): diff --git a/tests/components/file/test_notify.py b/tests/components/file/test_notify.py index 5c91460237..ba007a9987 100644 --- a/tests/components/file/test_notify.py +++ b/tests/components/file/test_notify.py @@ -51,7 +51,6 @@ async def test_notify_file(hass: HomeAssistant, timestamp: bool): with patch("homeassistant.components.file.notify.open", m_open, create=True), patch( "homeassistant.components.file.notify.os.stat" ) as mock_st, patch("homeassistant.util.dt.utcnow", return_value=dt_util.utcnow()): - mock_st.return_value.st_size = 0 title = ( f"{ATTR_TITLE_DEFAULT} notifications " diff --git a/tests/components/filesize/test_config_flow.py b/tests/components/filesize/test_config_flow.py index 2bdc1ed82a..7010a3e9a4 100644 --- a/tests/components/filesize/test_config_flow.py +++ b/tests/components/filesize/test_config_flow.py @@ -73,7 +73,9 @@ async def test_flow_fails_on_validation(hass: HomeAssistant) -> None: await async_create_file(hass, TEST_FILE) - with patch("homeassistant.components.filesize.config_flow.pathlib.Path",), patch( + with patch( + "homeassistant.components.filesize.config_flow.pathlib.Path", + ), patch( "homeassistant.components.filesize.async_setup_entry", return_value=True, ): @@ -87,7 +89,9 @@ async def test_flow_fails_on_validation(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "not_allowed"} hass.config.allowlist_external_dirs = {TEST_DIR} - with patch("homeassistant.components.filesize.config_flow.pathlib.Path",), patch( + with patch( + "homeassistant.components.filesize.config_flow.pathlib.Path", + ), patch( "homeassistant.components.filesize.async_setup_entry", return_value=True, ): diff --git a/tests/components/fireservicerota/test_config_flow.py b/tests/components/fireservicerota/test_config_flow.py index 35467ffc44..a26f803051 100644 --- a/tests/components/fireservicerota/test_config_flow.py +++ b/tests/components/fireservicerota/test_config_flow.py @@ -81,7 +81,6 @@ async def test_step_user(hass): "homeassistant.components.fireservicerota.async_setup_entry", return_value=True, ) as mock_setup_entry: - mock_fireservicerota = mock_fsr.return_value mock_fireservicerota.request_tokens.return_value = MOCK_TOKEN_INFO diff --git a/tests/components/firmata/test_config_flow.py b/tests/components/firmata/test_config_flow.py index 22f8b32678..d4bf159c5c 100644 --- a/tests/components/firmata/test_config_flow.py +++ b/tests/components/firmata/test_config_flow.py @@ -70,7 +70,6 @@ async def test_import(hass: HomeAssistant) -> None: ) as mock_setup, patch( "homeassistant.components.firmata.async_setup_entry", return_value=True ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, diff --git a/tests/components/freedompro/test_binary_sensor.py b/tests/components/freedompro/test_binary_sensor.py index f43857038e..f366d89a9a 100644 --- a/tests/components/freedompro/test_binary_sensor.py +++ b/tests/components/freedompro/test_binary_sensor.py @@ -71,7 +71,6 @@ async def test_binary_sensor_get_state( "homeassistant.components.freedompro.get_states", return_value=[], ): - async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) await hass.async_block_till_done() @@ -98,7 +97,6 @@ async def test_binary_sensor_get_state( "homeassistant.components.freedompro.get_states", return_value=states_response, ): - async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) await hass.async_block_till_done() diff --git a/tests/components/freedompro/test_config_flow.py b/tests/components/freedompro/test_config_flow.py index d80bd22736..c06e37a033 100644 --- a/tests/components/freedompro/test_config_flow.py +++ b/tests/components/freedompro/test_config_flow.py @@ -32,7 +32,6 @@ async def test_invalid_auth(hass): "code": -201, }, ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -51,7 +50,6 @@ async def test_connection_error(hass): "code": -200, }, ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -70,7 +68,6 @@ async def test_create_entry(hass): "devices": DEVICES, }, ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, diff --git a/tests/components/freedompro/test_sensor.py b/tests/components/freedompro/test_sensor.py index 0405c5393c..47a29fa8db 100644 --- a/tests/components/freedompro/test_sensor.py +++ b/tests/components/freedompro/test_sensor.py @@ -60,7 +60,6 @@ async def test_sensor_get_state( "homeassistant.components.freedompro.get_states", return_value=states_response, ): - async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) await hass.async_block_till_done() diff --git a/tests/components/fritz/conftest.py b/tests/components/fritz/conftest.py index edbde883f5..18a6d36fe9 100644 --- a/tests/components/fritz/conftest.py +++ b/tests/components/fritz/conftest.py @@ -57,7 +57,6 @@ class FritzConnectionMock: # pylint: disable=too-few-public-methods service = service + "1" if kwargs: - if (index := kwargs.get("NewIndex")) is None: index = next(iter(kwargs.values())) diff --git a/tests/components/fritz/test_config_flow.py b/tests/components/fritz/test_config_flow.py index 1eeb779da1..80a272e947 100644 --- a/tests/components/fritz/test_config_flow.py +++ b/tests/components/fritz/test_config_flow.py @@ -49,7 +49,6 @@ async def test_user(hass: HomeAssistant, fc_class_mock, mock_get_source_ip): "homeassistant.components.fritz.config_flow.socket.gethostbyname", return_value=MOCK_IPS["fritz.box"], ): - mock_request_get.return_value.status_code = 200 mock_request_get.return_value.content = MOCK_REQUEST mock_request_post.return_value.status_code = 200 @@ -100,7 +99,6 @@ async def test_user_already_configured( "homeassistant.components.fritz.config_flow.socket.gethostbyname", return_value=MOCK_IPS["fritz.box"], ): - mock_request_get.return_value.status_code = 200 mock_request_get.return_value.content = MOCK_REQUEST mock_request_post.return_value.status_code = 200 @@ -133,7 +131,6 @@ async def test_exception_security(hass: HomeAssistant, mock_get_source_ip): "homeassistant.components.fritz.config_flow.FritzConnection", side_effect=FritzSecurityError, ): - result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_USER_DATA ) @@ -156,7 +153,6 @@ async def test_exception_connection(hass: HomeAssistant, mock_get_source_ip): "homeassistant.components.fritz.config_flow.FritzConnection", side_effect=FritzConnectionException, ): - result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_USER_DATA ) @@ -179,7 +175,6 @@ async def test_exception_unknown(hass: HomeAssistant, mock_get_source_ip): "homeassistant.components.fritz.config_flow.FritzConnection", side_effect=OSError, ): - result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_USER_DATA ) @@ -210,7 +205,6 @@ async def test_reauth_successful( ) as mock_request_get, patch( "requests.post" ) as mock_request_post: - mock_request_get.return_value.status_code = 200 mock_request_get.return_value.content = MOCK_REQUEST mock_request_post.return_value.status_code = 200 @@ -251,7 +245,6 @@ async def test_reauth_not_successful( "homeassistant.components.fritz.config_flow.FritzConnection", side_effect=FritzConnectionException, ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_REAUTH, "entry_id": mock_config.entry_id}, @@ -293,7 +286,6 @@ async def test_ssdp_already_configured( "homeassistant.components.fritz.config_flow.socket.gethostbyname", return_value=MOCK_IPS["fritz.box"], ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA ) @@ -320,7 +312,6 @@ async def test_ssdp_already_configured_host( "homeassistant.components.fritz.config_flow.socket.gethostbyname", return_value=MOCK_IPS["fritz.box"], ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA ) @@ -347,7 +338,6 @@ async def test_ssdp_already_configured_host_uuid( "homeassistant.components.fritz.config_flow.socket.gethostbyname", return_value=MOCK_IPS["fritz.box"], ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA ) @@ -363,7 +353,6 @@ async def test_ssdp_already_in_progress_host( "homeassistant.components.fritz.config_flow.FritzConnection", side_effect=fc_class_mock, ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA ) @@ -395,7 +384,6 @@ async def test_ssdp(hass: HomeAssistant, fc_class_mock, mock_get_source_ip): ) as mock_request_get, patch( "requests.post" ) as mock_request_post: - mock_request_get.return_value.status_code = 200 mock_request_get.return_value.content = MOCK_REQUEST mock_request_post.return_value.status_code = 200 @@ -429,7 +417,6 @@ async def test_ssdp_exception(hass: HomeAssistant, mock_get_source_ip): "homeassistant.components.fritz.config_flow.FritzConnection", side_effect=FritzConnectionException, ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_SSDP_DATA ) diff --git a/tests/components/fronius/test_config_flow.py b/tests/components/fronius/test_config_flow.py index 69f1dffa64..9c58635205 100644 --- a/tests/components/fronius/test_config_flow.py +++ b/tests/components/fronius/test_config_flow.py @@ -302,7 +302,10 @@ async def test_dhcp_invalid(hass, aioclient_mock): """Test starting a flow from discovery.""" with patch( "homeassistant.components.fronius.config_flow.DHCP_REQUEST_DELAY", 0 - ), patch("pyfronius.Fronius.current_logger_info", side_effect=FroniusError,), patch( + ), patch( + "pyfronius.Fronius.current_logger_info", + side_effect=FroniusError, + ), patch( "pyfronius.Fronius.inverter_info", side_effect=FroniusError, ): diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py index 04c7fcca5b..f41f4c4f59 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -486,7 +486,6 @@ async def test_no_still_image_url(hass, hass_client): "homeassistant.components.generic.camera.GenericCamera.stream_source", return_value=None, ) as mock_stream_source: - # First test when there is no stream_source should fail resp = await client.get("/api/camera_proxy/camera.config_test") await hass.async_block_till_done() @@ -494,7 +493,6 @@ async def test_no_still_image_url(hass, hass_client): assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR with patch("homeassistant.components.camera.create_stream") as mock_create_stream: - # Now test when creating the stream succeeds mock_stream = Mock() mock_stream.async_get_image = AsyncMock() diff --git a/tests/components/greeneye_monitor/conftest.py b/tests/components/greeneye_monitor/conftest.py index eabac97d1c..70b337430c 100644 --- a/tests/components/greeneye_monitor/conftest.py +++ b/tests/components/greeneye_monitor/conftest.py @@ -27,7 +27,7 @@ def assert_sensor_state( assert state actual_state = state.state assert actual_state == expected_state - for (key, value) in attributes.items(): + for key, value in attributes.items(): assert key in state.attributes assert state.attributes[key] == value diff --git a/tests/components/hddtemp/test_sensor.py b/tests/components/hddtemp/test_sensor.py index c95c6af50f..710f97cdc5 100644 --- a/tests/components/hddtemp/test_sensor.py +++ b/tests/components/hddtemp/test_sensor.py @@ -161,7 +161,6 @@ async def test_hddtemp_multiple_disks(hass, telnetmock): "sensor.hd_temperature_dev_sdb1", "sensor.hd_temperature_dev_sdc1", ]: - state = hass.states.get(sensor) reference = REFERENCE[state.attributes.get("device")] diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index 3cb3260815..96d54fcde3 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -630,7 +630,6 @@ async def test_async_start_from_history_and_switch_to_watching_state_changes_sin "homeassistant.components.recorder.history.state_changes_during_period", _fake_states, ): - with freeze_time(start_time): await async_setup_component( hass, @@ -730,7 +729,6 @@ async def test_async_start_from_history_and_switch_to_watching_state_changes_sin "homeassistant.components.recorder.history.state_changes_during_period", _fake_states, ): - with freeze_time(start_time): await async_setup_component( hass, @@ -829,7 +827,6 @@ async def test_async_start_from_history_and_switch_to_watching_state_changes_mul "homeassistant.components.recorder.history.state_changes_during_period", _fake_states, ): - with freeze_time(start_time): await async_setup_component( hass, @@ -967,7 +964,6 @@ async def test_does_not_work_into_the_future(recorder_mock, hass): "homeassistant.components.recorder.history.state_changes_during_period", _fake_states, ): - with freeze_time(start_time): await async_setup_component( hass, diff --git a/tests/components/honeywell/test_config_flow.py b/tests/components/honeywell/test_config_flow.py index ff970a0e8c..a416f030a0 100644 --- a/tests/components/honeywell/test_config_flow.py +++ b/tests/components/honeywell/test_config_flow.py @@ -134,7 +134,6 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: "homeassistant.components.honeywell.async_setup_entry", return_value=True, ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={ diff --git a/tests/components/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py index aad574bd1d..4be703c808 100644 --- a/tests/components/hue/test_config_flow.py +++ b/tests/components/hue/test_config_flow.py @@ -34,7 +34,7 @@ def create_mock_api_discovery(aioclient_mock, bridges): URL_NUPNP, json=[{"internalipaddress": host, "id": id} for (host, id) in bridges], ) - for (host, bridge_id) in bridges: + for host, bridge_id in bridges: aioclient_mock.get( f"http://{host}/api/config", json={"bridgeid": bridge_id}, diff --git a/tests/components/hue/test_init.py b/tests/components/hue/test_init.py index 3bce9f9ec8..74ece83c3d 100644 --- a/tests/components/hue/test_init.py +++ b/tests/components/hue/test_init.py @@ -151,7 +151,6 @@ async def test_security_vuln_check(hass): ) ), ): - assert await async_setup_component(hass, "hue", {}) await hass.async_block_till_done() diff --git a/tests/components/huisbaasje/test_config_flow.py b/tests/components/huisbaasje/test_config_flow.py index e270079de8..ab77729e86 100644 --- a/tests/components/huisbaasje/test_config_flow.py +++ b/tests/components/huisbaasje/test_config_flow.py @@ -206,7 +206,10 @@ async def test_form_entry_exists(hass): with patch("energyflip.EnergyFlip.authenticate", return_value=None), patch( "energyflip.EnergyFlip.customer_overview", return_value=None - ), patch("energyflip.EnergyFlip.get_user_id", return_value="test-id",), patch( + ), patch( + "energyflip.EnergyFlip.get_user_id", + return_value="test-id", + ), patch( "homeassistant.components.huisbaasje.async_setup_entry", return_value=True, ): diff --git a/tests/components/huisbaasje/test_sensor.py b/tests/components/huisbaasje/test_sensor.py index bd792f3230..16b6b87fbb 100644 --- a/tests/components/huisbaasje/test_sensor.py +++ b/tests/components/huisbaasje/test_sensor.py @@ -36,7 +36,6 @@ async def test_setup_entry(hass: HomeAssistant): "energyflip.EnergyFlip.current_measurements", return_value=MOCK_CURRENT_MEASUREMENTS, ) as mock_current_measurements: - hass.config.components.add(huisbaasje.DOMAIN) config_entry = MockConfigEntry( version=1, @@ -364,7 +363,6 @@ async def test_setup_entry_absent_measurement(hass: HomeAssistant): "energyflip.EnergyFlip.current_measurements", return_value=MOCK_LIMITED_CURRENT_MEASUREMENTS, ) as mock_current_measurements: - hass.config.components.add(huisbaasje.DOMAIN) config_entry = MockConfigEntry( version=1, diff --git a/tests/components/hvv_departures/test_config_flow.py b/tests/components/hvv_departures/test_config_flow.py index 126e61cf62..82995473b2 100644 --- a/tests/components/hvv_departures/test_config_flow.py +++ b/tests/components/hvv_departures/test_config_flow.py @@ -42,7 +42,6 @@ async def test_user_flow(hass): "homeassistant.components.hvv_departures.async_setup_entry", return_value=True, ): - # step: user result_user = await hass.config_entries.flow.async_init( @@ -103,7 +102,6 @@ async def test_user_flow_no_results(hass): "homeassistant.components.hvv_departures.async_setup_entry", return_value=True, ): - # step: user result_user = await hass.config_entries.flow.async_init( @@ -139,7 +137,6 @@ async def test_user_flow_invalid_auth(hass): "Authentication failed!", ), ): - # step: user result_user = await hass.config_entries.flow.async_init( DOMAIN, @@ -162,7 +159,6 @@ async def test_user_flow_cannot_connect(hass): "homeassistant.components.hvv_departures.hub.GTI.init", side_effect=CannotConnect(), ): - # step: user result_user = await hass.config_entries.flow.async_init( DOMAIN, @@ -188,7 +184,6 @@ async def test_user_flow_station(hass): "homeassistant.components.hvv_departures.hub.GTI.checkName", return_value={"returnCode": "OK", "results": []}, ): - # step: user result_user = await hass.config_entries.flow.async_init( diff --git a/tests/components/insteon/test_config_flow.py b/tests/components/insteon/test_config_flow.py index 99a505cc63..c18b4e4894 100644 --- a/tests/components/insteon/test_config_flow.py +++ b/tests/components/insteon/test_config_flow.py @@ -84,9 +84,10 @@ async def _init_form(hass, modem_type): async def _device_form(hass, flow_id, connection, user_input): """Test the PLM, Hub v1 or Hub v2 form.""" - with patch(PATCH_CONNECTION, new=connection,), patch( - PATCH_ASYNC_SETUP, return_value=True - ) as mock_setup, patch( + with patch( + PATCH_CONNECTION, + new=connection, + ), patch(PATCH_ASYNC_SETUP, return_value=True) as mock_setup, patch( PATCH_ASYNC_SETUP_ENTRY, return_value=True, ) as mock_setup_entry: @@ -200,7 +201,10 @@ async def test_failed_connection_hub(hass: HomeAssistant): async def _import_config(hass, config): """Run the import step.""" - with patch(PATCH_CONNECTION, new=mock_successful_connection,), patch( + with patch( + PATCH_CONNECTION, + new=mock_successful_connection, + ), patch( PATCH_ASYNC_SETUP, return_value=True ), patch(PATCH_ASYNC_SETUP_ENTRY, return_value=True): return await hass.config_entries.flow.async_init( @@ -287,7 +291,10 @@ async def test_import_existing(hass: HomeAssistant): async def test_import_failed_connection(hass: HomeAssistant): """Test a failed connection on import.""" - with patch(PATCH_CONNECTION, new=mock_failed_connection,), patch( + with patch( + PATCH_CONNECTION, + new=mock_failed_connection, + ), patch( PATCH_ASYNC_SETUP, return_value=True ), patch(PATCH_ASYNC_SETUP_ENTRY, return_value=True): result = await hass.config_entries.flow.async_init( diff --git a/tests/components/intellifire/test_config_flow.py b/tests/components/intellifire/test_config_flow.py index 9e5d5627a9..93aef0944a 100644 --- a/tests/components/intellifire/test_config_flow.py +++ b/tests/components/intellifire/test_config_flow.py @@ -144,7 +144,6 @@ async def test_manual_entry( "homeassistant.components.intellifire.config_flow.AsyncUDPFireplaceFinder.search_fireplace", return_value=["192.168.1.69", "192.168.1.33", "192.168.169"], ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -190,7 +189,6 @@ async def test_multi_discovery_cannot_connect( "homeassistant.components.intellifire.config_flow.AsyncUDPFireplaceFinder.search_fireplace", return_value=["192.168.1.69", "192.168.1.33", "192.168.169"], ): - mock_intellifire_config_flow.poll.side_effect = ConnectionError result = await hass.config_entries.flow.async_init( diff --git a/tests/components/ipma/test_config_flow.py b/tests/components/ipma/test_config_flow.py index e254ba402f..c2a590b5b3 100644 --- a/tests/components/ipma/test_config_flow.py +++ b/tests/components/ipma/test_config_flow.py @@ -89,7 +89,6 @@ async def test_flow_entry_created_from_user_input(): "async_entries", return_value=[], ) as config_entries: - result = await flow.async_step_user(user_input=test_data) assert result["type"] == "create_entry" @@ -116,7 +115,6 @@ async def test_flow_entry_config_entry_already_exists(): ) as config_form, patch.object( flow.hass.config_entries, "async_entries", return_value={"home": test_data} ) as config_entries: - await flow.async_step_user(user_input=test_data) assert len(config_form.mock_calls) == 1 diff --git a/tests/components/ipma/test_init.py b/tests/components/ipma/test_init.py index 8dd808b1b1..f80e1b0743 100644 --- a/tests/components/ipma/test_init.py +++ b/tests/components/ipma/test_init.py @@ -19,7 +19,6 @@ async def test_async_setup_raises_entry_not_ready(hass): with patch( "pyipma.location.Location.get", side_effect=IPMAException("API unavailable") ): - config_entry = MockConfigEntry( domain=DOMAIN, title="Home", diff --git a/tests/components/iss/test_config_flow.py b/tests/components/iss/test_config_flow.py index a806bea305..b73dc6681c 100644 --- a/tests/components/iss/test_config_flow.py +++ b/tests/components/iss/test_config_flow.py @@ -21,7 +21,6 @@ async def test_create_entry(hass: HomeAssistant): assert result.get("step_id") == SOURCE_USER with patch("homeassistant.components.iss.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_configure( result["flow_id"], {}, diff --git a/tests/components/lacrosse_view/test_config_flow.py b/tests/components/lacrosse_view/test_config_flow.py index 8325cec920..67c9bf5752 100644 --- a/tests/components/lacrosse_view/test_config_flow.py +++ b/tests/components/lacrosse_view/test_config_flow.py @@ -19,7 +19,10 @@ async def test_form(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.FORM assert result["errors"] is None - with patch("lacrosse_view.LaCrosse.login", return_value=True,), patch( + with patch( + "lacrosse_view.LaCrosse.login", + return_value=True, + ), patch( "lacrosse_view.LaCrosse.get_locations", return_value=[Location(id=1, name="Test")], ), patch( @@ -189,7 +192,10 @@ async def test_already_configured_device(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.FORM assert result["errors"] is None - with patch("lacrosse_view.LaCrosse.login", return_value=True,), patch( + with patch( + "lacrosse_view.LaCrosse.login", + return_value=True, + ), patch( "lacrosse_view.LaCrosse.get_locations", return_value=[Location(id=1, name="Test")], ): diff --git a/tests/components/launch_library/test_config_flow.py b/tests/components/launch_library/test_config_flow.py index a9dd794d05..a33b957b32 100644 --- a/tests/components/launch_library/test_config_flow.py +++ b/tests/components/launch_library/test_config_flow.py @@ -21,7 +21,6 @@ async def test_create_entry(hass): with patch( "homeassistant.components.launch_library.async_setup_entry", return_value=True ): - result = await hass.config_entries.flow.async_configure( result["flow_id"], {}, diff --git a/tests/components/led_ble/test_config_flow.py b/tests/components/led_ble/test_config_flow.py index 6767302af5..3948a59926 100644 --- a/tests/components/led_ble/test_config_flow.py +++ b/tests/components/led_ble/test_config_flow.py @@ -31,7 +31,9 @@ async def test_user_step_success(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert result["errors"] == {} - with patch("homeassistant.components.led_ble.config_flow.LEDBLE.update",), patch( + with patch( + "homeassistant.components.led_ble.config_flow.LEDBLE.update", + ), patch( "homeassistant.components.led_ble.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -115,7 +117,9 @@ async def test_user_step_cannot_connect(hass: HomeAssistant) -> None: assert result2["step_id"] == "user" assert result2["errors"] == {"base": "cannot_connect"} - with patch("homeassistant.components.led_ble.config_flow.LEDBLE.update",), patch( + with patch( + "homeassistant.components.led_ble.config_flow.LEDBLE.update", + ), patch( "homeassistant.components.led_ble.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -165,7 +169,9 @@ async def test_user_step_unknown_exception(hass: HomeAssistant) -> None: assert result2["step_id"] == "user" assert result2["errors"] == {"base": "unknown"} - with patch("homeassistant.components.led_ble.config_flow.LEDBLE.update",), patch( + with patch( + "homeassistant.components.led_ble.config_flow.LEDBLE.update", + ), patch( "homeassistant.components.led_ble.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -197,7 +203,9 @@ async def test_bluetooth_step_success(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert result["errors"] == {} - with patch("homeassistant.components.led_ble.config_flow.LEDBLE.update",), patch( + with patch( + "homeassistant.components.led_ble.config_flow.LEDBLE.update", + ), patch( "homeassistant.components.led_ble.async_setup_entry", return_value=True, ) as mock_setup_entry: diff --git a/tests/components/litterrobot/test_config_flow.py b/tests/components/litterrobot/test_config_flow.py index ee5b718fa6..05a114e718 100644 --- a/tests/components/litterrobot/test_config_flow.py +++ b/tests/components/litterrobot/test_config_flow.py @@ -140,7 +140,6 @@ async def test_step_reauth(hass: HomeAssistant, mock_account: Account) -> None: "homeassistant.components.litterrobot.async_setup_entry", return_value=True, ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_PASSWORD: CONFIG[litterrobot.DOMAIN][CONF_PASSWORD]}, @@ -190,7 +189,6 @@ async def test_step_reauth_failed(hass: HomeAssistant, mock_account: Account) -> "homeassistant.components.litterrobot.async_setup_entry", return_value=True, ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_PASSWORD: CONFIG[litterrobot.DOMAIN][CONF_PASSWORD]}, diff --git a/tests/components/local_file/test_camera.py b/tests/components/local_file/test_camera.py index 8432420244..56d79a0c06 100644 --- a/tests/components/local_file/test_camera.py +++ b/tests/components/local_file/test_camera.py @@ -145,7 +145,6 @@ async def test_update_file_path(hass): "homeassistant.components.local_file.camera.mimetypes.guess_type", mock.Mock(return_value=(None, None)), ): - camera_1 = {"platform": "local_file", "file_path": "mock/path.jpg"} camera_2 = { "platform": "local_file", diff --git a/tests/components/melnor/conftest.py b/tests/components/melnor/conftest.py index 2f3c765cfa..e030e19878 100644 --- a/tests/components/melnor/conftest.py +++ b/tests/components/melnor/conftest.py @@ -118,7 +118,6 @@ def mock_melnor_device(): """Return a mocked Melnor device.""" with patch("melnor_bluetooth.device.Device") as mock: - device = mock.return_value device.connect = AsyncMock(return_value=True) diff --git a/tests/components/melnor/test_config_flow.py b/tests/components/melnor/test_config_flow.py index 364531a314..9fda8dcae7 100644 --- a/tests/components/melnor/test_config_flow.py +++ b/tests/components/melnor/test_config_flow.py @@ -22,7 +22,6 @@ async def test_user_step_no_devices(hass): with patch_async_setup_entry() as mock_setup_entry, patch_async_discovered_service_info( [] ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, @@ -38,7 +37,6 @@ async def test_user_step_discovered_devices(hass): """Test we properly handle device picking.""" with patch_async_setup_entry() as mock_setup_entry, patch_async_discovered_service_info(): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, @@ -68,7 +66,6 @@ async def test_user_step_with_existing_device(hass): with patch_async_setup_entry() as mock_setup_entry, patch_async_discovered_service_info( [FAKE_SERVICE_INFO_1, FAKE_SERVICE_INFO_2] ): - # Create the config flow result = await hass.config_entries.flow.async_init( DOMAIN, @@ -105,7 +102,6 @@ async def test_bluetooth_discovered(hass): """Test we short circuit to config entry creation.""" with patch_async_setup_entry() as mock_setup_entry: - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_BLUETOOTH}, @@ -123,7 +119,6 @@ async def test_bluetooth_confirm(hass): """Test we short circuit to config entry creation.""" with patch_async_setup_entry() as mock_setup_entry: - # Create the config flow result = await hass.config_entries.flow.async_init( DOMAIN, diff --git a/tests/components/melnor/test_number.py b/tests/components/melnor/test_number.py index 9cddbfceb7..1366e428fc 100644 --- a/tests/components/melnor/test_number.py +++ b/tests/components/melnor/test_number.py @@ -16,7 +16,6 @@ async def test_manual_watering_minutes(hass): entry = mock_config_entry(hass) with patch_async_ble_device_from_address(), patch_melnor_device() as device_patch, patch_async_register_callback(): - device = device_patch.return_value assert await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/melnor/test_switch.py b/tests/components/melnor/test_switch.py index 9539b00d9c..822892dcf3 100644 --- a/tests/components/melnor/test_switch.py +++ b/tests/components/melnor/test_switch.py @@ -33,7 +33,6 @@ async def test_manual_watering_switch_on_off(hass): entry = mock_config_entry(hass) with patch_async_ble_device_from_address(), patch_melnor_device() as device_patch, patch_async_register_callback(): - device = device_patch.return_value assert await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py index 7d6b822739..25c0cec441 100644 --- a/tests/components/minecraft_server/test_config_flow.py +++ b/tests/components/minecraft_server/test_config_flow.py @@ -99,7 +99,10 @@ async def test_invalid_ip(hass: HomeAssistant) -> None: async def test_same_host(hass: HomeAssistant) -> None: """Test abort in case of same host name.""" - with patch("aiodns.DNSResolver.query", side_effect=aiodns.error.DNSError,), patch( + with patch( + "aiodns.DNSResolver.query", + side_effect=aiodns.error.DNSError, + ), patch( "mcstatus.server.MinecraftServer.status", return_value=PingResponse(STATUS_RESPONSE_RAW), ): @@ -166,7 +169,10 @@ async def test_connection_failed(hass: HomeAssistant) -> None: async def test_connection_succeeded_with_srv_record(hass: HomeAssistant) -> None: """Test config entry in case of a successful connection with a SRV record.""" - with patch("aiodns.DNSResolver.query", return_value=SRV_RECORDS,), patch( + with patch( + "aiodns.DNSResolver.query", + return_value=SRV_RECORDS, + ), patch( "mcstatus.server.MinecraftServer.status", return_value=PingResponse(STATUS_RESPONSE_RAW), ): @@ -182,7 +188,10 @@ async def test_connection_succeeded_with_srv_record(hass: HomeAssistant) -> None async def test_connection_succeeded_with_host(hass: HomeAssistant) -> None: """Test config entry in case of a successful connection with a host name.""" - with patch("aiodns.DNSResolver.query", side_effect=aiodns.error.DNSError,), patch( + with patch( + "aiodns.DNSResolver.query", + side_effect=aiodns.error.DNSError, + ), patch( "mcstatus.server.MinecraftServer.status", return_value=PingResponse(STATUS_RESPONSE_RAW), ): diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 2a21253591..7297ef2dee 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -135,7 +135,7 @@ async def test_number_validator(): try: number_validator("x15.1") - except (vol.Invalid): + except vol.Invalid: return pytest.fail("Number_validator not throwing exception") diff --git a/tests/components/moehlenhoff_alpha2/test_config_flow.py b/tests/components/moehlenhoff_alpha2/test_config_flow.py index a1f9845401..4842d64882 100644 --- a/tests/components/moehlenhoff_alpha2/test_config_flow.py +++ b/tests/components/moehlenhoff_alpha2/test_config_flow.py @@ -60,7 +60,6 @@ async def test_form_duplicate_error(hass: HomeAssistant) -> None: assert config_entry.data["host"] == MOCK_BASE_HOST with patch("moehlenhoff_alpha2.Alpha2Base.update_data", mock_update_data): - result = await hass.config_entries.flow.async_init( DOMAIN, data={"host": MOCK_BASE_HOST}, diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index f0a22987aa..654fb2009c 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -728,14 +728,14 @@ async def help_test_discovery_update( assert state.name == "Beer" if state_data1: - for (mqtt_messages, expected_state, attributes) in state_data1: - for (topic, data) in mqtt_messages: + for mqtt_messages, expected_state, attributes in state_data1: + for topic, data in mqtt_messages: async_fire_mqtt_message(hass, topic, data) state = hass.states.get(f"{domain}.beer") if expected_state: assert state.state == expected_state if attributes: - for (attr, value) in attributes: + for attr, value in attributes: assert state.attributes.get(attr) == value async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", discovery_data2) @@ -746,14 +746,14 @@ async def help_test_discovery_update( assert state.name == "Milk" if state_data2: - for (mqtt_messages, expected_state, attributes) in state_data2: - for (topic, data) in mqtt_messages: + for mqtt_messages, expected_state, attributes in state_data2: + for topic, data in mqtt_messages: async_fire_mqtt_message(hass, topic, data) state = hass.states.get(f"{domain}.beer") if expected_state: assert state.state == expected_state if attributes: - for (attr, value) in attributes: + for attr, value in attributes: assert state.attributes.get(attr) == value state = hass.states.get(f"{domain}.milk") diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 251c0af24a..c716cb4dae 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -105,7 +105,6 @@ async def test_invalid_json(hass, mqtt_mock_entry_no_yaml_config, caplog): with patch( "homeassistant.components.mqtt.discovery.async_dispatcher_send" ) as mock_dispatcher_send: - mock_dispatcher_send = AsyncMock(return_value=None) async_fire_mqtt_message( @@ -122,7 +121,6 @@ async def test_only_valid_components(hass, mqtt_mock_entry_no_yaml_config, caplo with patch( "homeassistant.components.mqtt.discovery.async_dispatcher_send" ) as mock_dispatcher_send: - invalid_component = "timer" mock_dispatcher_send = AsyncMock(return_value=None) diff --git a/tests/components/mqtt_json/test_device_tracker.py b/tests/components/mqtt_json/test_device_tracker.py index b0cba66425..03719c39c5 100644 --- a/tests/components/mqtt_json/test_device_tracker.py +++ b/tests/components/mqtt_json/test_device_tracker.py @@ -46,7 +46,6 @@ async def test_ensure_device_tracker_platform_validation(hass): autospec=True, side_effect=mock_setup_scanner, ) as mock_sp: - dev_id = "paulus" topic = "location/paulus" assert await async_setup_component( diff --git a/tests/components/mutesync/test_config_flow.py b/tests/components/mutesync/test_config_flow.py index 934eb19074..5550ed899c 100644 --- a/tests/components/mutesync/test_config_flow.py +++ b/tests/components/mutesync/test_config_flow.py @@ -19,7 +19,10 @@ async def test_form(hass: HomeAssistant) -> None: assert result["type"] == "form" assert result["errors"] is None - with patch("mutesync.authenticate", return_value="bla",), patch( + with patch( + "mutesync.authenticate", + return_value="bla", + ), patch( "homeassistant.components.mutesync.async_setup_entry", return_value=True, ) as mock_setup_entry: diff --git a/tests/components/nam/test_config_flow.py b/tests/components/nam/test_config_flow.py index aef0fa41fe..2531263e58 100644 --- a/tests/components/nam/test_config_flow.py +++ b/tests/components/nam/test_config_flow.py @@ -219,7 +219,6 @@ async def test_form_errors(hass, error): "homeassistant.components.nam.NettigoAirMonitor.initialize", side_effect=exc, ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index 65cc991ec6..137c26e1c4 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -304,7 +304,6 @@ async def test_setup_component_with_delay(hass, config_entry): ) as mock_post_api_request, patch( "homeassistant.components.netatmo.data_handler.PLATFORMS", ["light"] ): - assert await async_setup_component( hass, "netatmo", {"netatmo": {"client_id": "123", "client_secret": "abc"}} ) diff --git a/tests/components/nexia/test_config_flow.py b/tests/components/nexia/test_config_flow.py index 5bfd7fb582..cdf81ea09d 100644 --- a/tests/components/nexia/test_config_flow.py +++ b/tests/components/nexia/test_config_flow.py @@ -53,7 +53,9 @@ async def test_form_invalid_auth(hass): DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch("homeassistant.components.nexia.config_flow.NexiaHome.login",), patch( + with patch( + "homeassistant.components.nexia.config_flow.NexiaHome.login", + ), patch( "homeassistant.components.nexia.config_flow.NexiaHome.get_name", return_value=None, ): diff --git a/tests/components/nina/test_binary_sensor.py b/tests/components/nina/test_binary_sensor.py index 855b733ef4..6238496ed0 100644 --- a/tests/components/nina/test_binary_sensor.py +++ b/tests/components/nina/test_binary_sensor.py @@ -46,7 +46,6 @@ async def test_sensors(hass: HomeAssistant) -> None: "pynina.baseApi.BaseAPI._makeRequest", wraps=mocked_request_function, ): - conf_entry: MockConfigEntry = MockConfigEntry( domain=DOMAIN, title="NINA", data=ENTRY_DATA ) @@ -155,7 +154,6 @@ async def test_sensors_without_corona_filter(hass: HomeAssistant) -> None: "pynina.baseApi.BaseAPI._makeRequest", wraps=mocked_request_function, ): - conf_entry: MockConfigEntry = MockConfigEntry( domain=DOMAIN, title="NINA", data=ENTRY_DATA_NO_CORONA ) diff --git a/tests/components/nina/test_config_flow.py b/tests/components/nina/test_config_flow.py index bfea778178..e09bdfc739 100644 --- a/tests/components/nina/test_config_flow.py +++ b/tests/components/nina/test_config_flow.py @@ -54,7 +54,6 @@ async def test_show_set_form(hass: HomeAssistant) -> None: "pynina.baseApi.BaseAPI._makeRequest", wraps=mocked_request_function, ): - result: dict[str, Any] = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -69,7 +68,6 @@ async def test_step_user_connection_error(hass: HomeAssistant) -> None: "pynina.baseApi.BaseAPI._makeRequest", side_effect=ApiError("Could not connect to Api"), ): - result: dict[str, Any] = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=deepcopy(DUMMY_DATA) ) @@ -84,7 +82,6 @@ async def test_step_user_unexpected_exception(hass: HomeAssistant) -> None: "pynina.baseApi.BaseAPI._makeRequest", side_effect=Exception("DUMMY"), ): - result: dict[str, Any] = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=deepcopy(DUMMY_DATA) ) @@ -101,7 +98,6 @@ async def test_step_user(hass: HomeAssistant) -> None: "homeassistant.components.nina.async_setup_entry", return_value=True, ): - result: dict[str, Any] = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=deepcopy(DUMMY_DATA) ) @@ -116,7 +112,6 @@ async def test_step_user_no_selection(hass: HomeAssistant) -> None: "pynina.baseApi.BaseAPI._makeRequest", wraps=mocked_request_function, ): - result: dict[str, Any] = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data={} ) diff --git a/tests/components/nina/test_init.py b/tests/components/nina/test_init.py index 66e8edb280..d75e35473c 100644 --- a/tests/components/nina/test_init.py +++ b/tests/components/nina/test_init.py @@ -27,7 +27,6 @@ async def init_integration(hass) -> MockConfigEntry: "pynina.baseApi.BaseAPI._makeRequest", wraps=mocked_request_function, ): - entry: MockConfigEntry = MockConfigEntry( domain=DOMAIN, title="NINA", data=ENTRY_DATA ) diff --git a/tests/components/nzbget/test_sensor.py b/tests/components/nzbget/test_sensor.py index 524133cf95..5e82879209 100644 --- a/tests/components/nzbget/test_sensor.py +++ b/tests/components/nzbget/test_sensor.py @@ -73,7 +73,7 @@ async def test_sensors(hass, nzbget_api) -> None: ), } - for (sensor_id, data) in sensors.items(): + for sensor_id, data in sensors.items(): entity_entry = registry.async_get(f"sensor.nzbgettest_{sensor_id}") assert entity_entry assert entity_entry.original_device_class == data[3] diff --git a/tests/components/oncue/__init__.py b/tests/components/oncue/__init__.py index 2ddaf1987f..26b08460a9 100644 --- a/tests/components/oncue/__init__.py +++ b/tests/components/oncue/__init__.py @@ -801,7 +801,9 @@ MOCK_ASYNC_FETCH_ALL_UNAVAILABLE_DEVICE = { def _patch_login_and_data(): @contextmanager def _patcher(): - with patch("homeassistant.components.oncue.Oncue.async_login",), patch( + with patch( + "homeassistant.components.oncue.Oncue.async_login", + ), patch( "homeassistant.components.oncue.Oncue.async_fetch_all", return_value=MOCK_ASYNC_FETCH_ALL, ): @@ -813,7 +815,9 @@ def _patch_login_and_data(): def _patch_login_and_data_offline_device(): @contextmanager def _patcher(): - with patch("homeassistant.components.oncue.Oncue.async_login",), patch( + with patch( + "homeassistant.components.oncue.Oncue.async_login", + ), patch( "homeassistant.components.oncue.Oncue.async_fetch_all", return_value=MOCK_ASYNC_FETCH_ALL_OFFLINE_DEVICE, ): @@ -837,7 +841,6 @@ def _patch_login_and_data_unavailable(): def _patch_login_and_data_unavailable_device(): @contextmanager def _patcher(): - with patch("homeassistant.components.oncue.Oncue.async_login"), patch( "homeassistant.components.oncue.Oncue.async_fetch_all", return_value=MOCK_ASYNC_FETCH_ALL_UNAVAILABLE_DEVICE, diff --git a/tests/components/plaato/test_config_flow.py b/tests/components/plaato/test_config_flow.py index f3abd5a690..14fa1dd806 100644 --- a/tests/components/plaato/test_config_flow.py +++ b/tests/components/plaato/test_config_flow.py @@ -294,7 +294,6 @@ async def test_options(hass): with patch( "homeassistant.components.plaato.async_setup_entry", return_value=True ) as mock_setup_entry: - await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -329,7 +328,6 @@ async def test_options_webhook(hass, webhook_id): with patch( "homeassistant.components.plaato.async_setup_entry", return_value=True ) as mock_setup_entry: - await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/plant/test_init.py b/tests/components/plant/test_init.py index d143bbef00..15ad409b4c 100644 --- a/tests/components/plant/test_init.py +++ b/tests/components/plant/test_init.py @@ -152,7 +152,6 @@ async def test_load_from_db(recorder_mock, hass): """ plant_name = "wise_plant" for value in [20, 30, 10]: - hass.states.async_set( BRIGHTNESS_ENTITY, value, {ATTR_UNIT_OF_MEASUREMENT: "Lux"} ) diff --git a/tests/components/prosegur/test_alarm_control_panel.py b/tests/components/prosegur/test_alarm_control_panel.py index 8a50319047..5caaabdb3e 100644 --- a/tests/components/prosegur/test_alarm_control_panel.py +++ b/tests/components/prosegur/test_alarm_control_panel.py @@ -73,7 +73,6 @@ async def test_connection_error(hass, mock_auth): install.status = Status.ARMED with patch("pyprosegur.installation.Installation.retrieve", return_value=install): - await setup_platform(hass) await hass.async_block_till_done() @@ -81,7 +80,6 @@ async def test_connection_error(hass, mock_auth): with patch( "pyprosegur.installation.Installation.retrieve", side_effect=ConnectionError ): - await entity_component.async_update_entity(hass, PROSEGUR_ALARM_ENTITY) state = hass.states.get(PROSEGUR_ALARM_ENTITY) diff --git a/tests/components/prosegur/test_init.py b/tests/components/prosegur/test_init.py index 2079d7a2b3..da0a2d9fba 100644 --- a/tests/components/prosegur/test_init.py +++ b/tests/components/prosegur/test_init.py @@ -64,7 +64,6 @@ async def test_unload_entry(hass, aioclient_mock): "homeassistant.components.prosegur.config_flow.Installation.retrieve", return_value=install, ): - assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/radiotherm/test_config_flow.py b/tests/components/radiotherm/test_config_flow.py index 862b7b3003..cc8abef93e 100644 --- a/tests/components/radiotherm/test_config_flow.py +++ b/tests/components/radiotherm/test_config_flow.py @@ -106,7 +106,6 @@ async def test_import(hass): "homeassistant.components.radiotherm.async_setup_entry", return_value=True, ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index d04d436f72..9cf7c1d7c0 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -195,7 +195,10 @@ async def test_events_during_migration_are_queued(hass): assert recorder.util.async_migration_in_progress(hass) is False - with patch("homeassistant.components.recorder.ALLOW_IN_MEMORY_DB", True,), patch( + with patch( + "homeassistant.components.recorder.ALLOW_IN_MEMORY_DB", + True, + ), patch( "homeassistant.components.recorder.core.create_engine", new=create_engine_test, ): diff --git a/tests/components/repairs/test_websocket_api.py b/tests/components/repairs/test_websocket_api.py index 206cedbe6a..d35591b660 100644 --- a/tests/components/repairs/test_websocket_api.py +++ b/tests/components/repairs/test_websocket_api.py @@ -92,7 +92,7 @@ class MockFixFlow(RepairsFlow): assert self.issue_id in EXPECTED_DATA assert self.data == EXPECTED_DATA[self.issue_id] - return await (self.async_step_custom_step()) + return await self.async_step_custom_step() async def async_step_custom_step( self, user_input: dict[str, str] | None = None diff --git a/tests/components/roon/test_config_flow.py b/tests/components/roon/test_config_flow.py index 32810d2278..5698d1de31 100644 --- a/tests/components/roon/test_config_flow.py +++ b/tests/components/roon/test_config_flow.py @@ -81,7 +81,6 @@ async def test_successful_discovery_and_auth(hass): "homeassistant.components.roon.async_setup_entry", return_value=True, ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -120,7 +119,6 @@ async def test_unsuccessful_discovery_user_form_and_auth(hass): "homeassistant.components.roon.async_setup_entry", return_value=True, ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -165,7 +163,6 @@ async def test_duplicate_config(hass): "homeassistant.components.roon.config_flow.RoonDiscovery", return_value=RoonDiscoveryFailedMock(), ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -207,7 +204,6 @@ async def test_successful_discovery_no_auth(hass): "homeassistant.components.roon.async_setup_entry", return_value=True, ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -239,7 +235,6 @@ async def test_unexpected_exception(hass): "homeassistant.components.roon.async_setup_entry", return_value=True, ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index 6679f1d786..943f91eec1 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -822,7 +822,6 @@ async def test_ssdp_not_successful(hass: HomeAssistant) -> None: "homeassistant.components.samsungtv.bridge.SamsungTVWSBridge.async_device_info", return_value=MOCK_DEVICE_INFO, ): - # confirm to add the entry result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=MOCK_SSDP_DATA @@ -851,7 +850,6 @@ async def test_ssdp_not_successful_2(hass: HomeAssistant) -> None: "homeassistant.components.samsungtv.bridge.SamsungTVWSBridge.async_device_info", return_value=MOCK_DEVICE_INFO, ): - # confirm to add the entry result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=MOCK_SSDP_DATA @@ -874,7 +872,6 @@ async def test_ssdp_already_in_progress(hass: HomeAssistant) -> None: "homeassistant.components.samsungtv.bridge.SamsungTVWSBridge.async_device_info", return_value=MOCK_DEVICE_INFO, ): - # confirm to add the entry result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=MOCK_SSDP_DATA @@ -897,7 +894,6 @@ async def test_ssdp_already_configured(hass: HomeAssistant) -> None: "homeassistant.components.samsungtv.bridge.SamsungTVWSBridge.async_device_info", return_value=MOCK_DEVICE_INFO, ): - # entry was added result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, data=MOCK_USER_DATA diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index aec072ab44..b9fa2202af 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -292,7 +292,6 @@ async def test_update_off(hass: HomeAssistant, mock_now: datetime) -> None: "homeassistant.components.samsungtv.bridge.Remote", side_effect=[OSError("Boom"), DEFAULT_MOCK], ): - next_update = mock_now + timedelta(minutes=5) with patch("homeassistant.util.dt.utcnow", return_value=next_update): async_fire_time_changed(hass, next_update) @@ -526,7 +525,6 @@ async def test_update_unhandled_response( "homeassistant.components.samsungtv.bridge.Remote", side_effect=[exceptions.UnhandledResponse("Boom"), DEFAULT_MOCK], ): - next_update = mock_now + timedelta(minutes=5) with patch("homeassistant.util.dt.utcnow", return_value=next_update): async_fire_time_changed(hass, next_update) @@ -547,7 +545,6 @@ async def test_connection_closed_during_update_can_recover( "homeassistant.components.samsungtv.bridge.Remote", side_effect=[exceptions.ConnectionClosed(), DEFAULT_MOCK], ): - next_update = mock_now + timedelta(minutes=5) with patch("homeassistant.util.dt.utcnow", return_value=next_update): async_fire_time_changed(hass, next_update) diff --git a/tests/components/scrape/test_config_flow.py b/tests/components/scrape/test_config_flow.py index f6df8a80f2..e12a7c15a0 100644 --- a/tests/components/scrape/test_config_flow.py +++ b/tests/components/scrape/test_config_flow.py @@ -131,7 +131,10 @@ async def test_flow_fails(hass: HomeAssistant, get_data: MockRestData) -> None: assert result2["errors"] == {"base": "resource_error"} - with patch("homeassistant.components.rest.RestData", return_value=get_data,), patch( + with patch( + "homeassistant.components.rest.RestData", + return_value=get_data, + ), patch( "homeassistant.components.scrape.async_setup_entry", return_value=True, ): diff --git a/tests/components/script/test_blueprint.py b/tests/components/script/test_blueprint.py index 10e73a8093..fb056aead2 100644 --- a/tests/components/script/test_blueprint.py +++ b/tests/components/script/test_blueprint.py @@ -76,7 +76,6 @@ async def test_confirmable_notification(hass: HomeAssistant) -> None: with patch( "homeassistant.components.mobile_app.device_action.async_call_action_from_config" ) as mock_call_action: - # Trigger script await hass.services.async_call(script.DOMAIN, "confirm", context=context) diff --git a/tests/components/senseme/__init__.py b/tests/components/senseme/__init__.py index 8c9a766988..43d586328b 100644 --- a/tests/components/senseme/__init__.py +++ b/tests/components/senseme/__init__.py @@ -119,7 +119,6 @@ def _patch_discovery(device=None, no_device=None): @contextmanager def _patcher(): - with patch.object(config_flow, "DISCOVER_TIMEOUT", 0), patch( "homeassistant.components.senseme.discovery.SensemeDiscovery", return_value=mock_senseme_discovery, diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 7a8ffc87e5..10b0471aa4 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -433,7 +433,6 @@ async def test_user_setup_ignored_device(hass): "homeassistant.components.shelly.async_setup_entry", return_value=True, ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {"host": "1.1.1.1"}, diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index 707e8d5cfb..57b756402e 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -184,7 +184,6 @@ async def test_entry_unload_not_connected(hass, mock_rpc_device, monkeypatch): with patch( "homeassistant.components.shelly.coordinator.async_stop_scanner" ) as mock_stop_scanner: - entry = await init_integration( hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} ) @@ -211,7 +210,6 @@ async def test_entry_unload_not_connected_but_we_think_we_are( "homeassistant.components.shelly.coordinator.async_stop_scanner", side_effect=DeviceConnectionError, ) as mock_stop_scanner: - entry = await init_integration( hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} ) diff --git a/tests/components/sleepiq/test_config_flow.py b/tests/components/sleepiq/test_config_flow.py index 3e944ec69f..44ea613ed3 100644 --- a/tests/components/sleepiq/test_config_flow.py +++ b/tests/components/sleepiq/test_config_flow.py @@ -84,7 +84,6 @@ async def test_success(hass: HomeAssistant) -> None: "homeassistant.components.sleepiq.async_setup_entry", return_value=True, ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( result["flow_id"], SLEEPIQ_CONFIG ) diff --git a/tests/components/sma/test_config_flow.py b/tests/components/sma/test_config_flow.py index eeaa0d75f0..3fbcd2c0ed 100644 --- a/tests/components/sma/test_config_flow.py +++ b/tests/components/sma/test_config_flow.py @@ -128,7 +128,6 @@ async def test_form_already_configured(hass, mock_config_entry): ), patch( "pysma.SMA.close_session", return_value=True ), _patch_async_setup_entry() as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( result["flow_id"], MOCK_USER_INPUT, diff --git a/tests/components/smappee/test_config_flow.py b/tests/components/smappee/test_config_flow.py index b29207afbc..06c90b0c54 100644 --- a/tests/components/smappee/test_config_flow.py +++ b/tests/components/smappee/test_config_flow.py @@ -538,7 +538,10 @@ async def test_full_zeroconf_flow_next_generation(hass): """Test the full zeroconf flow.""" with patch( "pysmappee.mqtt.SmappeeLocalMqtt.start_attempt", return_value=True - ), patch("pysmappee.mqtt.SmappeeLocalMqtt.start", return_value=None,), patch( + ), patch( + "pysmappee.mqtt.SmappeeLocalMqtt.start", + return_value=None, + ), patch( "pysmappee.mqtt.SmappeeLocalMqtt.is_config_ready", return_value=None, ): diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 93b646c44d..5c277b38c9 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -363,7 +363,6 @@ async def test_entry_created_with_cloudhook( "async_create_cloudhook", AsyncMock(return_value="http://cloud.test"), ) as mock_create_cloudhook: - await smartapp.setup_smartapp_endpoint(hass) # Webhook confirmation shown diff --git a/tests/components/sonarr/test_sensor.py b/tests/components/sonarr/test_sensor.py index 9240e99d3e..c00236c54e 100644 --- a/tests/components/sonarr/test_sensor.py +++ b/tests/components/sonarr/test_sensor.py @@ -42,7 +42,7 @@ async def test_sensors( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - for (unique, oid) in sensors.items(): + for unique, oid in sensors.items(): entity = registry.async_get(f"sensor.{oid}") assert entity assert entity.unique_id == f"{mock_config_entry.entry_id}_{unique}" diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index fdcf8fe1a5..d586ccbe3b 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -202,7 +202,9 @@ async def test_invalid_url_on_update( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - with patch("homeassistant.components.recorder",), patch( + with patch( + "homeassistant.components.recorder", + ), patch( "homeassistant.components.sql.sensor.sqlalchemy.engine.cursor.CursorResult", side_effect=SQLAlchemyError( "sqlite://homeassistant:hunter2@homeassistant.local" diff --git a/tests/components/squeezebox/test_config_flow.py b/tests/components/squeezebox/test_config_flow.py index a36abcc77a..afe46ffdef 100644 --- a/tests/components/squeezebox/test_config_flow.py +++ b/tests/components/squeezebox/test_config_flow.py @@ -37,7 +37,10 @@ async def patch_async_query_unauthorized(self, *args): async def test_user_form(hass): """Test user-initiated flow, including discovery and the edit step.""" - with patch("pysqueezebox.Server.async_query", return_value={"uuid": UUID},), patch( + with patch( + "pysqueezebox.Server.async_query", + return_value={"uuid": UUID}, + ), patch( "homeassistant.components.squeezebox.async_setup_entry", return_value=True, ) as mock_setup_entry, patch( @@ -191,7 +194,10 @@ async def test_discovery_no_uuid(hass): async def test_dhcp_discovery(hass): """Test we can process discovery from dhcp.""" - with patch("pysqueezebox.Server.async_query", return_value={"uuid": UUID},), patch( + with patch( + "pysqueezebox.Server.async_query", + return_value={"uuid": UUID}, + ), patch( "homeassistant.components.squeezebox.config_flow.async_discover", mock_discover ): result = await hass.config_entries.flow.async_init( diff --git a/tests/components/srp_energy/__init__.py b/tests/components/srp_energy/__init__.py index c3966e940b..682a43c442 100644 --- a/tests/components/srp_energy/__init__.py +++ b/tests/components/srp_energy/__init__.py @@ -47,7 +47,6 @@ async def init_integration( ), patch("srpenergy.client.SrpEnergyClient.usage", return_value=usage), patch( "homeassistant.components.srp_energy.SrpEnergyClient.usage", return_value=usage ): - config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/srp_energy/test_config_flow.py b/tests/components/srp_energy/test_config_flow.py index 6eac5298ae..21d808fef5 100644 --- a/tests/components/srp_energy/test_config_flow.py +++ b/tests/components/srp_energy/test_config_flow.py @@ -24,7 +24,6 @@ async def test_form(hass): "homeassistant.components.srp_energy.async_setup_entry", return_value=True, ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=ENTRY_CONFIG, diff --git a/tests/components/subaru/conftest.py b/tests/components/subaru/conftest.py index 20d70a7d49..94b803fea0 100644 --- a/tests/components/subaru/conftest.py +++ b/tests/components/subaru/conftest.py @@ -105,7 +105,10 @@ async def setup_subaru_config_entry( MOCK_API_CONNECT, return_value=connect_effect is None, side_effect=connect_effect, - ), patch(MOCK_API_GET_VEHICLES, return_value=vehicle_list,), patch( + ), patch( + MOCK_API_GET_VEHICLES, + return_value=vehicle_list, + ), patch( MOCK_API_VIN_TO_NAME, return_value=vehicle_data[VEHICLE_NAME], ), patch( diff --git a/tests/components/subaru/test_config_flow.py b/tests/components/subaru/test_config_flow.py index 0a48c736ef..7b290a4df1 100644 --- a/tests/components/subaru/test_config_flow.py +++ b/tests/components/subaru/test_config_flow.py @@ -257,7 +257,9 @@ async def test_pin_form_init(pin_form): async def test_pin_form_bad_pin_format(hass, pin_form): """Test we handle invalid pin.""" - with patch(MOCK_API_TEST_PIN,) as mock_test_pin, patch( + with patch( + MOCK_API_TEST_PIN, + ) as mock_test_pin, patch( MOCK_API_UPDATE_SAVED_PIN, return_value=True, ) as mock_update_saved_pin: @@ -272,7 +274,10 @@ async def test_pin_form_bad_pin_format(hass, pin_form): async def test_pin_form_success(hass, pin_form): """Test successful PIN entry.""" - with patch(MOCK_API_TEST_PIN, return_value=True,) as mock_test_pin, patch( + with patch( + MOCK_API_TEST_PIN, + return_value=True, + ) as mock_test_pin, patch( MOCK_API_UPDATE_SAVED_PIN, return_value=True, ) as mock_update_saved_pin, patch( diff --git a/tests/components/subaru/test_init.py b/tests/components/subaru/test_init.py index 46a8b2e103..1e3b3d6743 100644 --- a/tests/components/subaru/test_init.py +++ b/tests/components/subaru/test_init.py @@ -122,7 +122,10 @@ async def test_update_skip_unsubscribed(hass, subaru_config_entry): async def test_update_disabled(hass, ev_entry): """Test update function disable option.""" - with patch(MOCK_API_FETCH, side_effect=SubaruException("403 Error"),), patch( + with patch( + MOCK_API_FETCH, + side_effect=SubaruException("403 Error"), + ), patch( MOCK_API_UPDATE, ) as mock_update: await hass.services.async_call( diff --git a/tests/components/switchbee/test_config_flow.py b/tests/components/switchbee/test_config_flow.py index 1745d6c5d4..80bdb18a87 100644 --- a/tests/components/switchbee/test_config_flow.py +++ b/tests/components/switchbee/test_config_flow.py @@ -35,7 +35,6 @@ async def test_form(hass): ), patch( "switchbee.api.polling.CentralUnitPolling._login", return_value=None ): - result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index 402dcd2f60..2a682dacb8 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -65,7 +65,6 @@ from tests.common import MockConfigEntry def mock_controller_service(): """Mock a successful service.""" with patch("homeassistant.components.synology_dsm.config_flow.SynologyDSM") as dsm: - dsm.login = AsyncMock(return_value=True) dsm.update = AsyncMock(return_value=True) @@ -109,7 +108,6 @@ def mock_controller_service_2sa(): def mock_controller_service_vdsm(): """Mock a successful service.""" with patch("homeassistant.components.synology_dsm.config_flow.SynologyDSM") as dsm: - dsm.login = AsyncMock(return_value=True) dsm.update = AsyncMock(return_value=True) @@ -131,7 +129,6 @@ def mock_controller_service_vdsm(): def mock_controller_service_failed(): """Mock a failed service.""" with patch("homeassistant.components.synology_dsm.config_flow.SynologyDSM") as dsm: - dsm.login = AsyncMock(return_value=True) dsm.update = AsyncMock(return_value=True) @@ -333,7 +330,6 @@ async def test_reauth(hass: HomeAssistant, service: MagicMock): "homeassistant.config_entries.ConfigEntries.async_reload", return_value=True, ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={ diff --git a/tests/components/tankerkoenig/test_config_flow.py b/tests/components/tankerkoenig/test_config_flow.py index 266f9b6737..0f814f494f 100644 --- a/tests/components/tankerkoenig/test_config_flow.py +++ b/tests/components/tankerkoenig/test_config_flow.py @@ -146,7 +146,6 @@ async def test_exception_security(hass: HomeAssistant): "homeassistant.components.tankerkoenig.config_flow.getNearbyStations", side_effect=customException, ): - result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_USER_DATA ) diff --git a/tests/components/tibber/test_statistics.py b/tests/components/tibber/test_statistics.py index 661297f9a3..002f8b7d15 100644 --- a/tests/components/tibber/test_statistics.py +++ b/tests/components/tibber/test_statistics.py @@ -22,7 +22,7 @@ async def test_async_setup_entry(recorder_mock, hass): await coordinator._async_update_data() await async_wait_recording_done(hass) - for (statistic_id, data, key) in ( + for statistic_id, data, key in ( ("tibber:energy_consumption_home_id", CONSUMPTION_DATA_1, "consumption"), ("tibber:energy_totalcost_home_id", CONSUMPTION_DATA_1, "totalCost"), ("tibber:energy_production_home_id", PRODUCTION_DATA_1, "production"), diff --git a/tests/components/totalconnect/test_config_flow.py b/tests/components/totalconnect/test_config_flow.py index 5e5124db71..eb8e1af0d9 100644 --- a/tests/components/totalconnect/test_config_flow.py +++ b/tests/components/totalconnect/test_config_flow.py @@ -54,10 +54,12 @@ async def test_user_show_locations(hass): RESPONSE_SUCCESS, ] - with patch(TOTALCONNECT_REQUEST, side_effect=responses,) as mock_request, patch( + with patch( + TOTALCONNECT_REQUEST, + side_effect=responses, + ) as mock_request, patch( "homeassistant.components.totalconnect.async_setup_entry", return_value=True ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -178,13 +180,15 @@ async def test_no_locations(hass): RESPONSE_DISARMED, ] - with patch(TOTALCONNECT_REQUEST, side_effect=responses,) as mock_request, patch( + with patch( + TOTALCONNECT_REQUEST, + side_effect=responses, + ) as mock_request, patch( "homeassistant.components.totalconnect.async_setup_entry", return_value=True ), patch( "homeassistant.components.totalconnect.TotalConnectClient.get_number_locations", return_value=0, ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, diff --git a/tests/components/upnp/conftest.py b/tests/components/upnp/conftest.py index f26fb39e42..79bd6daba1 100644 --- a/tests/components/upnp/conftest.py +++ b/tests/components/upnp/conftest.py @@ -141,6 +141,7 @@ async def silent_ssdp_scanner(hass): @pytest.fixture async def ssdp_instant_discovery(): """Instance discovery.""" + # Set up device discovery callback. async def register_callback(hass, callback, match_dict): """Immediately do callback.""" @@ -160,6 +161,7 @@ async def ssdp_instant_discovery(): @pytest.fixture async def ssdp_no_discovery(): """No discovery.""" + # Set up device discovery callback. async def register_callback(hass, callback, match_dict): """Don't do callback.""" diff --git a/tests/components/uptimerobot/common.py b/tests/components/uptimerobot/common.py index 2003f41135..6a82d75a9f 100644 --- a/tests/components/uptimerobot/common.py +++ b/tests/components/uptimerobot/common.py @@ -110,7 +110,6 @@ async def setup_uptimerobot_integration(hass: HomeAssistant) -> MockConfigEntry: "pyuptimerobot.UptimeRobot.async_get_monitors", return_value=mock_uptimerobot_api_response(data=[MOCK_UPTIMEROBOT_MONITOR]), ): - assert await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/uptimerobot/test_config_flow.py b/tests/components/uptimerobot/test_config_flow.py index b058ceecbd..804687e79d 100644 --- a/tests/components/uptimerobot/test_config_flow.py +++ b/tests/components/uptimerobot/test_config_flow.py @@ -181,7 +181,6 @@ async def test_reauthentication( "homeassistant.components.uptimerobot.async_setup_entry", return_value=True, ): - result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_API_KEY: MOCK_UPTIMEROBOT_API_KEY}, @@ -220,7 +219,6 @@ async def test_reauthentication_failure( "homeassistant.components.uptimerobot.async_setup_entry", return_value=True, ): - result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_API_KEY: MOCK_UPTIMEROBOT_API_KEY}, @@ -262,7 +260,6 @@ async def test_reauthentication_failure_no_existing_entry( "homeassistant.components.uptimerobot.async_setup_entry", return_value=True, ): - result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_API_KEY: MOCK_UPTIMEROBOT_API_KEY}, @@ -304,7 +301,6 @@ async def test_reauthentication_failure_account_not_matching( "homeassistant.components.uptimerobot.async_setup_entry", return_value=True, ): - result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_API_KEY: MOCK_UPTIMEROBOT_API_KEY}, diff --git a/tests/components/uptimerobot/test_diagnostics.py b/tests/components/uptimerobot/test_diagnostics.py index a0780f64e9..ae7371f8e9 100644 --- a/tests/components/uptimerobot/test_diagnostics.py +++ b/tests/components/uptimerobot/test_diagnostics.py @@ -33,7 +33,6 @@ async def test_entry_diagnostics( data=MOCK_UPTIMEROBOT_ACCOUNT, ), ): - result = await get_diagnostics_for_config_entry( hass, hass_client, @@ -68,7 +67,6 @@ async def test_entry_diagnostics_exception( "pyuptimerobot.UptimeRobot.async_get_account_details", side_effect=UptimeRobotException("Test exception"), ): - result = await get_diagnostics_for_config_entry( hass, hass_client, diff --git a/tests/components/uptimerobot/test_init.py b/tests/components/uptimerobot/test_init.py index 00d9b1c6a8..3133125172 100644 --- a/tests/components/uptimerobot/test_init.py +++ b/tests/components/uptimerobot/test_init.py @@ -38,7 +38,6 @@ async def test_reauthentication_trigger_in_setup( "pyuptimerobot.UptimeRobot.async_get_monitors", side_effect=UptimeRobotAuthenticationException, ): - await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -107,7 +106,6 @@ async def test_reauthentication_trigger_after_setup( "pyuptimerobot.UptimeRobot.async_get_monitors", side_effect=UptimeRobotAuthenticationException, ): - async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) await hass.async_block_till_done() diff --git a/tests/components/uptimerobot/test_switch.py b/tests/components/uptimerobot/test_switch.py index 82ea06ce83..4560909d3e 100644 --- a/tests/components/uptimerobot/test_switch.py +++ b/tests/components/uptimerobot/test_switch.py @@ -53,7 +53,6 @@ async def test_switch_off(hass: HomeAssistant) -> None: "pyuptimerobot.UptimeRobot.async_edit_monitor", return_value=mock_uptimerobot_api_response(), ): - assert await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() @@ -81,7 +80,6 @@ async def test_switch_on(hass: HomeAssistant) -> None: "pyuptimerobot.UptimeRobot.async_edit_monitor", return_value=mock_uptimerobot_api_response(), ): - assert await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/vera/test_sensor.py b/tests/components/vera/test_sensor.py index b6641402c8..9ddf05a5b0 100644 --- a/tests/components/vera/test_sensor.py +++ b/tests/components/vera/test_sensor.py @@ -40,7 +40,7 @@ async def run_sensor_test( ) update_callback = component_data.controller_data[0].update_callback - for (initial_value, state_value) in assert_states: + for initial_value, state_value in assert_states: setattr(vera_device, class_property, initial_value) update_callback(vera_device) await hass.async_block_till_done() diff --git a/tests/components/version/common.py b/tests/components/version/common.py index b210a8600b..a6b554d780 100644 --- a/tests/components/version/common.py +++ b/tests/components/version/common.py @@ -47,7 +47,6 @@ async def mock_get_version_update( return_value=(version, data), side_effect=side_effect, ): - async_fire_time_changed(hass, dt.utcnow() + UPDATE_COORDINATOR_UPDATE_INTERVAL) await hass.async_block_till_done() @@ -69,7 +68,6 @@ async def setup_version_integration( "pyhaversion.HaVersion.get_version", return_value=(MOCK_VERSION, MOCK_VERSION_DATA), ): - assert await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/vulcan/test_config_flow.py b/tests/components/vulcan/test_config_flow.py index c45ab430c2..3e6588c1ca 100644 --- a/tests/components/vulcan/test_config_flow.py +++ b/tests/components/vulcan/test_config_flow.py @@ -209,7 +209,6 @@ async def test_config_flow_reauth_with_errors(mock_account, mock_keystore, hass) "homeassistant.components.vulcan.config_flow.Account.register", side_effect=InvalidTokenException, ): - result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, @@ -223,7 +222,6 @@ async def test_config_flow_reauth_with_errors(mock_account, mock_keystore, hass) "homeassistant.components.vulcan.config_flow.Account.register", side_effect=ExpiredTokenException, ): - result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, @@ -237,7 +235,6 @@ async def test_config_flow_reauth_with_errors(mock_account, mock_keystore, hass) "homeassistant.components.vulcan.config_flow.Account.register", side_effect=InvalidPINException, ): - result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, @@ -251,7 +248,6 @@ async def test_config_flow_reauth_with_errors(mock_account, mock_keystore, hass) "homeassistant.components.vulcan.config_flow.Account.register", side_effect=InvalidSymbolException, ): - result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, @@ -265,7 +261,6 @@ async def test_config_flow_reauth_with_errors(mock_account, mock_keystore, hass) "homeassistant.components.vulcan.config_flow.Account.register", side_effect=ClientConnectionError, ): - result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, @@ -279,7 +274,6 @@ async def test_config_flow_reauth_with_errors(mock_account, mock_keystore, hass) "homeassistant.components.vulcan.config_flow.Account.register", side_effect=Exception, ): - result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_TOKEN: "token", CONF_REGION: "region", CONF_PIN: "000000"}, diff --git a/tests/components/vultr/test_binary_sensor.py b/tests/components/vultr/test_binary_sensor.py index 80cd198e37..899b36e59b 100644 --- a/tests/components/vultr/test_binary_sensor.py +++ b/tests/components/vultr/test_binary_sensor.py @@ -41,7 +41,6 @@ def test_binary_sensor(hass: HomeAssistant): assert len(hass_devices) == 3 for device in hass_devices: - # Test pre data retrieval if device.subscription == "555555": assert device.name == "Vultr {}" diff --git a/tests/components/vultr/test_sensor.py b/tests/components/vultr/test_sensor.py index 95f91270fa..ad63e02c4f 100644 --- a/tests/components/vultr/test_sensor.py +++ b/tests/components/vultr/test_sensor.py @@ -51,7 +51,6 @@ def test_sensor(hass: HomeAssistant): tested = 0 for device in hass_devices: - # Test pre update if device.subscription == "576965": assert vultr.DEFAULT_NAME == device.name @@ -74,7 +73,6 @@ def test_sensor(hass: HomeAssistant): tested += 1 elif device.unit_of_measurement == "US$": # Test Pending Charges - if device.subscription == "576965": # Default 'Vultr {} {}' assert device.name == "Vultr my new server Pending Charges" assert device.icon == "mdi:currency-usd" diff --git a/tests/components/wake_on_lan/test_switch.py b/tests/components/wake_on_lan/test_switch.py index b73f522357..8a7fe18566 100644 --- a/tests/components/wake_on_lan/test_switch.py +++ b/tests/components/wake_on_lan/test_switch.py @@ -39,7 +39,6 @@ async def test_valid_hostname( assert state.state == STATE_OFF with patch.object(subprocess, "call", return_value=0): - await hass.services.async_call( switch.DOMAIN, SERVICE_TURN_ON, @@ -87,7 +86,6 @@ async def test_broadcast_config_ip_and_port( assert state.state == STATE_OFF with patch.object(subprocess, "call", return_value=0): - await hass.services.async_call( switch.DOMAIN, SERVICE_TURN_ON, @@ -125,7 +123,6 @@ async def test_broadcast_config_ip( assert state.state == STATE_OFF with patch.object(subprocess, "call", return_value=0): - await hass.services.async_call( switch.DOMAIN, SERVICE_TURN_ON, @@ -155,7 +152,6 @@ async def test_broadcast_config_port( assert state.state == STATE_OFF with patch.object(subprocess, "call", return_value=0): - await hass.services.async_call( switch.DOMAIN, SERVICE_TURN_ON, @@ -190,7 +186,6 @@ async def test_off_script( assert state.state == STATE_OFF with patch.object(subprocess, "call", return_value=0): - await hass.services.async_call( switch.DOMAIN, SERVICE_TURN_ON, @@ -203,7 +198,6 @@ async def test_off_script( assert len(calls) == 0 with patch.object(subprocess, "call", return_value=2): - await hass.services.async_call( switch.DOMAIN, SERVICE_TURN_OFF, @@ -237,7 +231,6 @@ async def test_no_hostname_state( assert state.state == STATE_OFF with patch.object(subprocess, "call", return_value=0): - await hass.services.async_call( switch.DOMAIN, SERVICE_TURN_ON, diff --git a/tests/components/wallbox/test_number.py b/tests/components/wallbox/test_number.py index c16c85d369..eb11c4fc0c 100644 --- a/tests/components/wallbox/test_number.py +++ b/tests/components/wallbox/test_number.py @@ -65,7 +65,6 @@ async def test_wallbox_number_class_connection_error(hass: HomeAssistant) -> Non ) with pytest.raises(ConnectionError): - await hass.services.async_call( "number", SERVICE_SET_VALUE, diff --git a/tests/components/whirlpool/test_config_flow.py b/tests/components/whirlpool/test_config_flow.py index ad6620dc05..d408ac1d15 100644 --- a/tests/components/whirlpool/test_config_flow.py +++ b/tests/components/whirlpool/test_config_flow.py @@ -198,7 +198,6 @@ async def test_no_appliances_flow(hass: HomeAssistant, region) -> None: "homeassistant.components.whirlpool.config_flow.AppliancesManager.fetch_appliances", return_value=True, ): - result2 = await hass.config_entries.flow.async_configure( result["flow_id"], CONFIG_INPUT | {"region": region[0]}, diff --git a/tests/components/wolflink/test_config_flow.py b/tests/components/wolflink/test_config_flow.py index 9a6105a4b8..e09d682136 100644 --- a/tests/components/wolflink/test_config_flow.py +++ b/tests/components/wolflink/test_config_flow.py @@ -124,7 +124,6 @@ async def test_already_configured_error(hass): "homeassistant.components.wolflink.config_flow.WolfClient.fetch_system_list", return_value=[DEVICE], ), patch("homeassistant.components.wolflink.async_setup_entry", return_value=True): - MockConfigEntry( domain=DOMAIN, unique_id=CONFIG[DEVICE_ID], data=CONFIG ).add_to_hass(hass) diff --git a/tests/components/ws66i/test_config_flow.py b/tests/components/ws66i/test_config_flow.py index da5a16882e..ec09419e7f 100644 --- a/tests/components/ws66i/test_config_flow.py +++ b/tests/components/ws66i/test_config_flow.py @@ -36,7 +36,6 @@ async def test_form(hass): "homeassistant.components.ws66i.async_setup_entry", return_value=True, ) as mock_setup_entry: - ws66i_instance = mock_ws66i.return_value result2 = await hass.config_entries.flow.async_configure( diff --git a/tests/components/yeelight/test_config_flow.py b/tests/components/yeelight/test_config_flow.py index 391c969d89..44fa1263a6 100644 --- a/tests/components/yeelight/test_config_flow.py +++ b/tests/components/yeelight/test_config_flow.py @@ -844,7 +844,6 @@ async def test_discovered_during_onboarding(hass, source, data): ) as mock_async_setup_entry, patch( "homeassistant.components.onboarding.async_is_onboarded", return_value=False ) as mock_is_onboarded: - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, data=data ) diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index fd383fd653..ae461a5ca0 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -162,7 +162,11 @@ async def test_setup(hass, mock_async_zeroconf): ], "_Volumio._tcp.local.": [{"domain": "volumio"}], } - with patch.dict(zc_gen.ZEROCONF, mock_zc, clear=True,), patch.object( + with patch.dict( + zc_gen.ZEROCONF, + mock_zc, + clear=True, + ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock @@ -535,7 +539,11 @@ async def test_homekit_match_partial_space(hass, mock_async_zeroconf): zc_gen.ZEROCONF, {"_hap._tcp.local.": [{"domain": "homekit_controller"}]}, clear=True, - ), patch.dict(zc_gen.HOMEKIT, {"LIFX": "lifx"}, clear=True,), patch.object( + ), patch.dict( + zc_gen.HOMEKIT, + {"LIFX": "lifx"}, + clear=True, + ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( zeroconf, @@ -656,7 +664,11 @@ async def test_homekit_match_full(hass, mock_async_zeroconf): zc_gen.ZEROCONF, {"_hap._udp.local.": [{"domain": "homekit_controller"}]}, clear=True, - ), patch.dict(zc_gen.HOMEKIT, {"BSB002": "hue"}, clear=True,), patch.object( + ), patch.dict( + zc_gen.HOMEKIT, + {"BSB002": "hue"}, + clear=True, + ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( zeroconf, @@ -779,7 +791,11 @@ async def test_homekit_controller_still_discovered_unpaired_for_cloud( zc_gen.ZEROCONF, {"_hap._udp.local.": [{"domain": "homekit_controller"}]}, clear=True, - ), patch.dict(zc_gen.HOMEKIT, {"Rachio": "rachio"}, clear=True,), patch.object( + ), patch.dict( + zc_gen.HOMEKIT, + {"Rachio": "rachio"}, + clear=True, + ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( zeroconf, diff --git a/tests/components/zha/test_channels.py b/tests/components/zha/test_channels.py index 0ab905692c..b264213d5a 100644 --- a/tests/components/zha/test_channels.py +++ b/tests/components/zha/test_channels.py @@ -321,7 +321,7 @@ async def test_out_channel_config( def test_channel_registry(): """Test ZIGBEE Channel Registry.""" - for (cluster_id, channel) in registries.ZIGBEE_CHANNEL_REGISTRY.items(): + for cluster_id, channel in registries.ZIGBEE_CHANNEL_REGISTRY.items(): assert isinstance(cluster_id, int) assert 0 <= cluster_id <= 0xFFFF assert issubclass(channel, base_channels.ZigbeeChannel) diff --git a/tests/components/zwave_me/test_config_flow.py b/tests/components/zwave_me/test_config_flow.py index 57ead9a9e6..e3be334c3d 100644 --- a/tests/components/zwave_me/test_config_flow.py +++ b/tests/components/zwave_me/test_config_flow.py @@ -170,7 +170,6 @@ async def test_duplicate_zeroconf(hass: HomeAssistant): "homeassistant.components.zwave_me.helpers.get_uuid", return_value="test_uuid", ): - result: FlowResult = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, diff --git a/tests/components/zwave_me/test_remove_stale_devices.py b/tests/components/zwave_me/test_remove_stale_devices.py index 484c38b9f3..1ecb106d94 100644 --- a/tests/components/zwave_me/test_remove_stale_devices.py +++ b/tests/components/zwave_me/test_remove_stale_devices.py @@ -55,7 +55,10 @@ async def test_remove_stale_devices( connections={("mac", "12:34:56:AB:CD:EF")}, identifiers={("zwave_me", f"{config_entry.unique_id}-{identifier}")}, ) - with patch("zwave_me_ws.ZWaveMe.get_connection", mock_connection,), patch( + with patch( + "zwave_me_ws.ZWaveMe.get_connection", + mock_connection, + ), patch( "homeassistant.components.zwave_me.async_setup_platforms", ): await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/conftest.py b/tests/conftest.py index e131cf6cdc..2f475cc205 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -351,7 +351,6 @@ def aiohttp_client( server_kwargs: dict[str, Any] | None = None, **kwargs: Any, ) -> TestClient: - if isinstance(__param, Callable) and not isinstance( # type: ignore[arg-type] __param, (Application, BaseTestServer) ): diff --git a/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py index a4a3e2b27e..bc545a647c 100644 --- a/tests/helpers/test_config_entry_flow.py +++ b/tests/helpers/test_config_entry_flow.py @@ -370,7 +370,6 @@ async def test_webhook_create_cloudhook(hass, webhook_flow_conf): "hass_nabucasa.cloudhooks.Cloudhooks.async_delete", return_value={"cloudhook_url": "https://example.com"}, ) as mock_delete: - result = await hass.config_entries.async_remove(result["result"].entry_id) assert len(mock_delete.mock_calls) == 1 @@ -413,7 +412,6 @@ async def test_webhook_create_cloudhook_aborts_not_connected(hass, webhook_flow_ "hass_nabucasa.iot_base.BaseIoT.connected", new_callable=PropertyMock(return_value=False), ): - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] == data_entry_flow.FlowResultType.ABORT diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 2c9a795687..9d5c95ccaa 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -825,7 +825,7 @@ async def test_loading_saving_data(hass, registry, area_registry): assert orig_light4 == new_light4 # Ensure enums converted - for (old, new) in ( + for old, new in ( (orig_via, new_via), (orig_light, new_light), (orig_light4, new_light4), diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 19f5ea242b..ac337b11b5 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -484,7 +484,6 @@ async def test_async_parallel_updates_with_two(hass): await test_lock.acquire() try: - ent_1.async_schedule_update_ha_state(True) ent_2.async_schedule_update_ha_state(True) ent_3.async_schedule_update_ha_state(True) diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index d993233ac5..7bf5046216 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -42,7 +42,11 @@ async def test_component_translation_path(hass, enable_custom_integrations): ) assert await async_setup_component(hass, "test_package", {"test_package"}) - (int_test, int_test_embedded, int_test_package,) = await asyncio.gather( + ( + int_test, + int_test_embedded, + int_test_package, + ) = await asyncio.gather( async_get_integration(hass, "test"), async_get_integration(hass, "test_embedded"), async_get_integration(hass, "test_package"), diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index fc35b28f7c..89093a78f6 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -107,7 +107,6 @@ def test_secrets(mock_is_file, event_loop): } with patch_yaml_files(files): - res = check_config.check(get_test_config_dir(), True) assert res["except"] == {} diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 087ccaaae2..25501a774f 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -3555,7 +3555,6 @@ async def test_initializing_flows_canceled_on_shutdown(hass: HomeAssistant, mana with patch.dict( config_entries.HANDLERS, {"comp": MockFlowHandler, "test": MockFlowHandler} ): - task = asyncio.create_task( manager.flow.async_init("test", context={"source": "reauth"}) ) diff --git a/tests/test_requirements.py b/tests/test_requirements.py index 6dadeba379..f2362b0a3b 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -138,7 +138,6 @@ async def test_get_integration_with_requirements(hass): ) as mock_is_installed, patch( "homeassistant.util.package.install_package", return_value=True ) as mock_inst: - integration = await async_get_integration_with_requirements( hass, "test_component" ) @@ -193,7 +192,6 @@ async def test_get_integration_with_requirements_pip_install_fails_two_passes(ha ) as mock_is_installed, patch( "homeassistant.util.package.install_package", side_effect=_mock_install_package ) as mock_inst: - integration = await async_get_integration_with_requirements( hass, "test_component" ) @@ -224,7 +222,6 @@ async def test_get_integration_with_requirements_pip_install_fails_two_passes(ha ) as mock_is_installed, patch( "homeassistant.util.package.install_package", side_effect=_mock_install_package ) as mock_inst: - integration = await async_get_integration_with_requirements( hass, "test_component" ) @@ -243,7 +240,6 @@ async def test_get_integration_with_requirements_pip_install_fails_two_passes(ha ) as mock_is_installed, patch( "homeassistant.util.package.install_package", side_effect=_mock_install_package ) as mock_inst: - integration = await async_get_integration_with_requirements( hass, "test_component" ) @@ -274,7 +270,6 @@ async def test_get_integration_with_requirements_pip_install_fails_two_passes(ha ) as mock_is_installed, patch( "homeassistant.util.package.install_package", return_value=True ) as mock_inst: - integration = await async_get_integration_with_requirements( hass, "test_component" ) From 1df82f39c14db5eb382748e4da292adf598f5de7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 5 Feb 2023 16:58:34 -0600 Subject: [PATCH 0128/2810] Speed up purge time with newer MariaDB versions (#87409) * Speed up purge time with newer MariaDB versions * fix * document * document * document * rename * self review * Update homeassistant/components/recorder/util.py * fixes --- homeassistant/components/recorder/core.py | 8 +- homeassistant/components/recorder/models.py | 28 ++++++ homeassistant/components/recorder/purge.py | 76 ++++++++-------- homeassistant/components/recorder/queries.py | 4 +- .../recorder/system_health/__init__.py | 4 +- homeassistant/components/recorder/util.py | 88 +++++++++++-------- tests/components/recorder/test_util.py | 48 ++++++++-- 7 files changed, 169 insertions(+), 87 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index ddca32e697..497ae364b6 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -14,7 +14,6 @@ import time from typing import Any, TypeVar, cast import async_timeout -from awesomeversion import AwesomeVersion from lru import LRU # pylint: disable=no-name-in-module from sqlalchemy import create_engine, event as sqlalchemy_event, exc, func, select from sqlalchemy.engine import Engine @@ -67,6 +66,7 @@ from .db_schema import ( ) from .executor import DBInterruptibleThreadPoolExecutor from .models import ( + DatabaseEngine, StatisticData, StatisticMetaData, UnsupportedDialect, @@ -173,7 +173,7 @@ class Recorder(threading.Thread): self.db_url = uri self.db_max_retries = db_max_retries self.db_retry_wait = db_retry_wait - self.engine_version: AwesomeVersion | None = None + self.database_engine: DatabaseEngine | None = None # Database connection is ready, but non-live migration may be in progress db_connected: asyncio.Future[bool] = hass.data[DOMAIN].db_connected self.async_db_connected: asyncio.Future[bool] = db_connected @@ -1125,13 +1125,13 @@ class Recorder(threading.Thread): ) -> None: """Dbapi specific connection settings.""" assert self.engine is not None - if version := setup_connection_for_dialect( + if database_engine := setup_connection_for_dialect( self, self.engine.dialect.name, dbapi_connection, not self._completed_first_database_setup, ): - self.engine_version = version + self.database_engine = database_engine self._completed_first_database_setup = True if self.db_url == SQLITE_URL_PREFIX or ":memory:" in self.db_url: diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 3bbd9f173a..b071a68676 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -1,10 +1,12 @@ """Models for Recorder.""" from __future__ import annotations +from dataclasses import dataclass from datetime import datetime, timedelta import logging from typing import Any, Literal, TypedDict, overload +from awesomeversion import AwesomeVersion from sqlalchemy.engine.row import Row from homeassistant.const import ( @@ -17,6 +19,8 @@ from homeassistant.core import Context, State from homeassistant.helpers.json import json_loads import homeassistant.util.dt as dt_util +from .const import SupportedDialect + # pylint: disable=invalid-name _LOGGER = logging.getLogger(__name__) @@ -443,3 +447,27 @@ class StatisticPeriod(TypedDict, total=False): calendar: CalendarStatisticPeriod fixed_period: FixedStatisticPeriod rolling_window: RollingWindowStatisticPeriod + + +@dataclass +class DatabaseEngine: + """Properties of the database engine.""" + + dialect: SupportedDialect + optimizer: DatabaseOptimizer + version: AwesomeVersion | None + + +@dataclass +class DatabaseOptimizer: + """Properties of the database optimizer for the configured database engine.""" + + # Some MariaDB versions have a bug that causes a slow query when using + # a range in a select statement with an IN clause. + # + # https://jira.mariadb.org/browse/MDEV-25020 + # + # Historically, we have applied this logic to PostgreSQL as well, but + # it may not be necessary. We should revisit this in the future + # when we have more data. + slow_range_in_select: bool diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 4fb52dabb2..fa380e5a7e 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -14,13 +14,14 @@ from sqlalchemy.sql.expression import distinct from homeassistant.const import EVENT_STATE_CHANGED import homeassistant.util.dt as dt_util -from .const import MAX_ROWS_TO_PURGE, SupportedDialect +from .const import MAX_ROWS_TO_PURGE from .db_schema import Events, StateAttributes, States +from .models import DatabaseEngine from .queries import ( attributes_ids_exist_in_states, - attributes_ids_exist_in_states_sqlite, + attributes_ids_exist_in_states_with_fast_in_distinct, data_ids_exist_in_events, - data_ids_exist_in_events_sqlite, + data_ids_exist_in_events_with_fast_in_distinct, delete_event_data_rows, delete_event_rows, delete_recorder_runs_rows, @@ -83,8 +84,6 @@ def purge_old_data( "Purging states and events before target %s", purge_before.isoformat(sep=" ", timespec="seconds"), ) - using_sqlite = instance.dialect_name == SupportedDialect.SQLITE - with session_scope(session=instance.get_session()) as session: # Purge a max of MAX_ROWS_TO_PURGE, based on the oldest states or events record has_more_to_purge = False @@ -93,9 +92,7 @@ def purge_old_data( "Purge running in legacy format as there are states with event_id" " remaining" ) - has_more_to_purge |= _purge_legacy_format( - instance, session, purge_before, using_sqlite - ) + has_more_to_purge |= _purge_legacy_format(instance, session, purge_before) else: _LOGGER.debug( "Purge running in new format as there are NO states with event_id" @@ -103,10 +100,10 @@ def purge_old_data( ) # Once we are done purging legacy rows, we use the new method has_more_to_purge |= _purge_states_and_attributes_ids( - instance, session, states_batch_size, purge_before, using_sqlite + instance, session, states_batch_size, purge_before ) has_more_to_purge |= _purge_events_and_data_ids( - instance, session, events_batch_size, purge_before, using_sqlite + instance, session, events_batch_size, purge_before ) statistics_runs = _select_statistics_runs_to_purge(session, purge_before) @@ -140,7 +137,7 @@ def _purging_legacy_format(session: Session) -> bool: def _purge_legacy_format( - instance: Recorder, session: Session, purge_before: datetime, using_sqlite: bool + instance: Recorder, session: Session, purge_before: datetime ) -> bool: """Purge rows that are still linked by the event_ids.""" ( @@ -153,10 +150,10 @@ def _purge_legacy_format( ) if state_ids: _purge_state_ids(instance, session, state_ids) - _purge_unused_attributes_ids(instance, session, attributes_ids, using_sqlite) + _purge_unused_attributes_ids(instance, session, attributes_ids) if event_ids: _purge_event_ids(session, event_ids) - _purge_unused_data_ids(instance, session, data_ids, using_sqlite) + _purge_unused_data_ids(instance, session, data_ids) return bool(event_ids or state_ids or attributes_ids or data_ids) @@ -165,12 +162,13 @@ def _purge_states_and_attributes_ids( session: Session, states_batch_size: int, purge_before: datetime, - using_sqlite: bool, ) -> bool: """Purge states and linked attributes id in a batch. Returns true if there are more states to purge. """ + database_engine = instance.database_engine + assert database_engine is not None has_remaining_state_ids_to_purge = True # There are more states relative to attributes_ids so # we purge enough state_ids to try to generate a full @@ -187,7 +185,7 @@ def _purge_states_and_attributes_ids( _purge_state_ids(instance, session, state_ids) attributes_ids_batch = attributes_ids_batch | attributes_ids - _purge_unused_attributes_ids(instance, session, attributes_ids_batch, using_sqlite) + _purge_unused_attributes_ids(instance, session, attributes_ids_batch) _LOGGER.debug( "After purging states and attributes_ids remaining=%s", has_remaining_state_ids_to_purge, @@ -200,7 +198,6 @@ def _purge_events_and_data_ids( session: Session, events_batch_size: int, purge_before: datetime, - using_sqlite: bool, ) -> bool: """Purge states and linked attributes id in a batch. @@ -220,7 +217,7 @@ def _purge_events_and_data_ids( _purge_event_ids(session, event_ids) data_ids_batch = data_ids_batch | data_ids - _purge_unused_data_ids(instance, session, data_ids_batch, using_sqlite) + _purge_unused_data_ids(instance, session, data_ids_batch) _LOGGER.debug( "After purging event and data_ids remaining=%s", has_remaining_event_ids_to_purge, @@ -267,13 +264,13 @@ def _select_event_data_ids_to_purge( def _select_unused_attributes_ids( - session: Session, attributes_ids: set[int], using_sqlite: bool + session: Session, attributes_ids: set[int], database_engine: DatabaseEngine ) -> set[int]: """Return a set of attributes ids that are not used by any states in the db.""" if not attributes_ids: return set() - if using_sqlite: + if not database_engine.optimizer.slow_range_in_select: # # SQLite has a superior query optimizer for the distinct query below as it uses # the covering index without having to examine the rows directly for both of the @@ -290,7 +287,7 @@ def _select_unused_attributes_ids( seen_ids = { state[0] for state in session.execute( - attributes_ids_exist_in_states_sqlite(attributes_ids) + attributes_ids_exist_in_states_with_fast_in_distinct(attributes_ids) ).all() } else: @@ -340,16 +337,18 @@ def _purge_unused_attributes_ids( instance: Recorder, session: Session, attributes_ids_batch: set[int], - using_sqlite: bool, ) -> None: + """Purge unused attributes ids.""" + database_engine = instance.database_engine + assert database_engine is not None if unused_attribute_ids_set := _select_unused_attributes_ids( - session, attributes_ids_batch, using_sqlite + session, attributes_ids_batch, database_engine ): _purge_batch_attributes_ids(instance, session, unused_attribute_ids_set) def _select_unused_event_data_ids( - session: Session, data_ids: set[int], using_sqlite: bool + session: Session, data_ids: set[int], database_engine: DatabaseEngine ) -> set[int]: """Return a set of event data ids that are not used by any events in the db.""" if not data_ids: @@ -357,11 +356,11 @@ def _select_unused_event_data_ids( # See _select_unused_attributes_ids for why this function # branches for non-sqlite databases. - if using_sqlite: + if not database_engine.optimizer.slow_range_in_select: seen_ids = { state[0] for state in session.execute( - data_ids_exist_in_events_sqlite(data_ids) + data_ids_exist_in_events_with_fast_in_distinct(data_ids) ).all() } else: @@ -381,10 +380,12 @@ def _select_unused_event_data_ids( def _purge_unused_data_ids( - instance: Recorder, session: Session, data_ids_batch: set[int], using_sqlite: bool + instance: Recorder, session: Session, data_ids_batch: set[int] ) -> None: + database_engine = instance.database_engine + assert database_engine is not None if unused_data_ids_set := _select_unused_event_data_ids( - session, data_ids_batch, using_sqlite + session, data_ids_batch, database_engine ): _purge_batch_data_ids(instance, session, unused_data_ids_set) @@ -582,7 +583,8 @@ def _purge_old_recorder_runs( def _purge_filtered_data(instance: Recorder, session: Session) -> bool: """Remove filtered states and events that shouldn't be in the database.""" _LOGGER.debug("Cleanup filtered data") - using_sqlite = instance.dialect_name == SupportedDialect.SQLITE + database_engine = instance.database_engine + assert database_engine is not None # Check if excluded entity_ids are in database excluded_entity_ids: list[str] = [ @@ -591,7 +593,7 @@ def _purge_filtered_data(instance: Recorder, session: Session) -> bool: if not instance.entity_filter(entity_id) ] if len(excluded_entity_ids) > 0: - _purge_filtered_states(instance, session, excluded_entity_ids, using_sqlite) + _purge_filtered_states(instance, session, excluded_entity_ids, database_engine) return False # Check if excluded event_types are in database @@ -611,7 +613,7 @@ def _purge_filtered_states( instance: Recorder, session: Session, excluded_entity_ids: list[str], - using_sqlite: bool, + database_engine: DatabaseEngine, ) -> None: """Remove filtered states and linked events.""" state_ids: list[int] @@ -632,7 +634,7 @@ def _purge_filtered_states( _purge_state_ids(instance, session, set(state_ids)) _purge_event_ids(session, event_ids) unused_attribute_ids_set = _select_unused_attributes_ids( - session, {id_ for id_ in attributes_ids if id_ is not None}, using_sqlite + session, {id_ for id_ in attributes_ids if id_ is not None}, database_engine ) _purge_batch_attributes_ids(instance, session, unused_attribute_ids_set) @@ -641,7 +643,8 @@ def _purge_filtered_events( instance: Recorder, session: Session, excluded_event_types: list[str] ) -> None: """Remove filtered events and linked states.""" - using_sqlite = instance.dialect_name == SupportedDialect.SQLITE + database_engine = instance.database_engine + assert database_engine is not None event_ids, data_ids = zip( *( session.query(Events.event_id, Events.data_id) @@ -660,7 +663,7 @@ def _purge_filtered_events( _purge_state_ids(instance, session, state_ids) _purge_event_ids(session, event_ids) if unused_data_ids_set := _select_unused_event_data_ids( - session, set(data_ids), using_sqlite + session, set(data_ids), database_engine ): _purge_batch_data_ids(instance, session, unused_data_ids_set) if EVENT_STATE_CHANGED in excluded_event_types: @@ -671,7 +674,8 @@ def _purge_filtered_events( @retryable_database_job("purge") def purge_entity_data(instance: Recorder, entity_filter: Callable[[str], bool]) -> bool: """Purge states and events of specified entities.""" - using_sqlite = instance.dialect_name == SupportedDialect.SQLITE + database_engine = instance.database_engine + assert database_engine is not None with session_scope(session=instance.get_session()) as session: selected_entity_ids: list[str] = [ entity_id @@ -682,7 +686,9 @@ def purge_entity_data(instance: Recorder, entity_filter: Callable[[str], bool]) if len(selected_entity_ids) > 0: # Purge a max of MAX_ROWS_TO_PURGE, based on the oldest states # or events record. - _purge_filtered_states(instance, session, selected_entity_ids, using_sqlite) + _purge_filtered_states( + instance, session, selected_entity_ids, database_engine + ) _LOGGER.debug("Purging entity data hasn't fully completed yet") return False diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py index 0591fda471..29bac70eef 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -45,7 +45,7 @@ def _state_attrs_exist(attr: int | None) -> Select: return select(func.min(States.attributes_id)).where(States.attributes_id == attr) -def attributes_ids_exist_in_states_sqlite( +def attributes_ids_exist_in_states_with_fast_in_distinct( attributes_ids: Iterable[int], ) -> StatementLambdaElement: """Find attributes ids that exist in the states table.""" @@ -268,7 +268,7 @@ def attributes_ids_exist_in_states( ) -def data_ids_exist_in_events_sqlite( +def data_ids_exist_in_events_with_fast_in_distinct( data_ids: Iterable[int], ) -> StatementLambdaElement: """Find data ids that exist in the events table.""" diff --git a/homeassistant/components/recorder/system_health/__init__.py b/homeassistant/components/recorder/system_health/__init__.py index b79f526db2..da463d3861 100644 --- a/homeassistant/components/recorder/system_health/__init__.py +++ b/homeassistant/components/recorder/system_health/__init__.py @@ -49,8 +49,8 @@ def _async_get_db_engine_info(instance: Recorder) -> dict[str, Any]: db_engine_info: dict[str, Any] = {} if dialect_name := instance.dialect_name: db_engine_info["database_engine"] = dialect_name.value - if engine_version := instance.engine_version: - db_engine_info["database_version"] = str(engine_version) + if database_engine := instance.database_engine: + db_engine_info["database_version"] = str(database_engine.version) return db_engine_info diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 0469a71009..dde32bf05d 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -36,7 +36,13 @@ from .db_schema import ( TABLES_TO_CHECK, RecorderRuns, ) -from .models import StatisticPeriod, UnsupportedDialect, process_timestamp +from .models import ( + DatabaseEngine, + DatabaseOptimizer, + StatisticPeriod, + UnsupportedDialect, + process_timestamp, +) if TYPE_CHECKING: from . import Recorder @@ -51,44 +57,33 @@ QUERY_RETRY_WAIT = 0.1 SQLITE3_POSTFIXES = ["", "-wal", "-shm"] DEFAULT_YIELD_STATES_ROWS = 32768 + # Our minimum versions for each database # # Older MariaDB suffers https://jira.mariadb.org/browse/MDEV-25020 # which is fixed in 10.5.17, 10.6.9, 10.7.5, 10.8.4 # -MIN_VERSION_MARIA_DB = AwesomeVersion( - "10.3.0", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER -) -RECOMMENDED_MIN_VERSION_MARIA_DB = AwesomeVersion( - "10.5.17", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER -) -MARIA_DB_106 = AwesomeVersion( - "10.6.0", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER -) -RECOMMENDED_MIN_VERSION_MARIA_DB_106 = AwesomeVersion( - "10.6.9", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER -) -MARIA_DB_107 = AwesomeVersion( - "10.7.0", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER -) -RECOMMENDED_MIN_VERSION_MARIA_DB_107 = AwesomeVersion( - "10.7.5", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER -) -MARIA_DB_108 = AwesomeVersion( - "10.8.0", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER -) -RECOMMENDED_MIN_VERSION_MARIA_DB_108 = AwesomeVersion( - "10.8.4", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER -) -MIN_VERSION_MYSQL = AwesomeVersion( - "8.0.0", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER -) -MIN_VERSION_PGSQL = AwesomeVersion( - "12.0", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER -) -MIN_VERSION_SQLITE = AwesomeVersion( - "3.31.0", ensure_strategy=AwesomeVersionStrategy.SIMPLEVER -) +def _simple_version(version: str) -> AwesomeVersion: + """Return a simple version.""" + return AwesomeVersion(version, ensure_strategy=AwesomeVersionStrategy.SIMPLEVER) + + +MIN_VERSION_MARIA_DB = _simple_version("10.3.0") +RECOMMENDED_MIN_VERSION_MARIA_DB = _simple_version("10.5.17") +MARIADB_WITH_FIXED_IN_QUERIES_105 = _simple_version("10.5.17") +MARIA_DB_106 = _simple_version("10.6.0") +MARIADB_WITH_FIXED_IN_QUERIES_106 = _simple_version("10.6.9") +RECOMMENDED_MIN_VERSION_MARIA_DB_106 = _simple_version("10.6.9") +MARIA_DB_107 = _simple_version("10.7.0") +RECOMMENDED_MIN_VERSION_MARIA_DB_107 = _simple_version("10.7.5") +MARIADB_WITH_FIXED_IN_QUERIES_107 = _simple_version("10.7.5") +MARIA_DB_108 = _simple_version("10.8.0") +RECOMMENDED_MIN_VERSION_MARIA_DB_108 = _simple_version("10.8.4") +MARIADB_WITH_FIXED_IN_QUERIES_108 = _simple_version("10.8.4") +MIN_VERSION_MYSQL = _simple_version("8.0.0") +MIN_VERSION_PGSQL = _simple_version("12.0") +MIN_VERSION_SQLITE = _simple_version("3.31.0") + # This is the maximum time after the recorder ends the session # before we no longer consider startup to be a "restart" and we @@ -467,10 +462,12 @@ def setup_connection_for_dialect( dialect_name: str, dbapi_connection: Any, first_connection: bool, -) -> AwesomeVersion | None: +) -> DatabaseEngine | None: """Execute statements needed for dialect connection.""" version: AwesomeVersion | None = None + slow_range_in_select = True if dialect_name == SupportedDialect.SQLITE: + slow_range_in_select = False if first_connection: old_isolation = dbapi_connection.isolation_level dbapi_connection.isolation_level = None @@ -536,7 +533,19 @@ def setup_connection_for_dialect( version or version_string, "MySQL", MIN_VERSION_MYSQL ) + slow_range_in_select = bool( + not version + or version < MARIADB_WITH_FIXED_IN_QUERIES_105 + or MARIA_DB_106 <= version < MARIADB_WITH_FIXED_IN_QUERIES_106 + or MARIA_DB_107 <= version < MARIADB_WITH_FIXED_IN_QUERIES_107 + or MARIA_DB_108 <= version < MARIADB_WITH_FIXED_IN_QUERIES_108 + ) elif dialect_name == SupportedDialect.POSTGRESQL: + # Historically we have marked PostgreSQL as having slow range in select + # but this may not be true for all versions. We should investigate + # this further when we have more data and remove this if possible + # in the future so we can use the simpler purge SQL query for + # _select_unused_attributes_ids and _select_unused_events_ids if first_connection: # server_version_num was added in 2006 result = query_on_connection(dbapi_connection, "SHOW server_version") @@ -550,7 +559,14 @@ def setup_connection_for_dialect( else: _fail_unsupported_dialect(dialect_name) - return version + if not first_connection: + return None + + return DatabaseEngine( + dialect=SupportedDialect(dialect_name), + version=version, + optimizer=DatabaseOptimizer(slow_range_in_select=slow_range_in_select), + ) def end_incomplete_runs(session: Session, start_time: datetime) -> None: diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 3f5ba6d40e..932b23b115 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -231,7 +231,12 @@ def test_setup_connection_for_dialect_sqlite(sqlite_version): dbapi_connection = MagicMock(cursor=_make_cursor_mock) - util.setup_connection_for_dialect(instance_mock, "sqlite", dbapi_connection, True) + assert ( + util.setup_connection_for_dialect( + instance_mock, "sqlite", dbapi_connection, True + ) + is not None + ) assert len(execute_args) == 5 assert execute_args[0] == "PRAGMA journal_mode=WAL" @@ -241,7 +246,12 @@ def test_setup_connection_for_dialect_sqlite(sqlite_version): assert execute_args[4] == "PRAGMA foreign_keys=ON" execute_args = [] - util.setup_connection_for_dialect(instance_mock, "sqlite", dbapi_connection, False) + assert ( + util.setup_connection_for_dialect( + instance_mock, "sqlite", dbapi_connection, False + ) + is None + ) assert len(execute_args) == 3 assert execute_args[0] == "PRAGMA cache_size = -16384" @@ -276,7 +286,12 @@ def test_setup_connection_for_dialect_sqlite_zero_commit_interval( dbapi_connection = MagicMock(cursor=_make_cursor_mock) - util.setup_connection_for_dialect(instance_mock, "sqlite", dbapi_connection, True) + assert ( + util.setup_connection_for_dialect( + instance_mock, "sqlite", dbapi_connection, True + ) + is not None + ) assert len(execute_args) == 5 assert execute_args[0] == "PRAGMA journal_mode=WAL" @@ -286,7 +301,12 @@ def test_setup_connection_for_dialect_sqlite_zero_commit_interval( assert execute_args[4] == "PRAGMA foreign_keys=ON" execute_args = [] - util.setup_connection_for_dialect(instance_mock, "sqlite", dbapi_connection, False) + assert ( + util.setup_connection_for_dialect( + instance_mock, "sqlite", dbapi_connection, False + ) + is None + ) assert len(execute_args) == 3 assert execute_args[0] == "PRAGMA cache_size = -16384" @@ -444,11 +464,13 @@ def test_supported_pgsql(caplog, pgsql_version): dbapi_connection = MagicMock(cursor=_make_cursor_mock) - util.setup_connection_for_dialect( + database_engine = util.setup_connection_for_dialect( instance_mock, "postgresql", dbapi_connection, True ) assert "minimum supported version" not in caplog.text + assert database_engine is not None + assert database_engine.optimizer.slow_range_in_select is True @pytest.mark.parametrize( @@ -525,9 +547,13 @@ def test_supported_sqlite(caplog, sqlite_version): dbapi_connection = MagicMock(cursor=_make_cursor_mock) - util.setup_connection_for_dialect(instance_mock, "sqlite", dbapi_connection, True) + database_engine = util.setup_connection_for_dialect( + instance_mock, "sqlite", dbapi_connection, True + ) assert "minimum supported version" not in caplog.text + assert database_engine is not None + assert database_engine.optimizer.slow_range_in_select is False @pytest.mark.parametrize( @@ -599,7 +625,7 @@ async def test_issue_for_mariadb_with_MDEV_25020( dbapi_connection = MagicMock(cursor=_make_cursor_mock) - await hass.async_add_executor_job( + database_engine = await hass.async_add_executor_job( util.setup_connection_for_dialect, instance_mock, "mysql", @@ -613,6 +639,9 @@ async def test_issue_for_mariadb_with_MDEV_25020( assert issue is not None assert issue.translation_placeholders == {"min_version": min_version} + assert database_engine is not None + assert database_engine.optimizer.slow_range_in_select is True + @pytest.mark.parametrize( "mysql_version", @@ -649,7 +678,7 @@ async def test_no_issue_for_mariadb_with_MDEV_25020(hass, caplog, mysql_version) dbapi_connection = MagicMock(cursor=_make_cursor_mock) - await hass.async_add_executor_job( + database_engine = await hass.async_add_executor_job( util.setup_connection_for_dialect, instance_mock, "mysql", @@ -662,6 +691,9 @@ async def test_no_issue_for_mariadb_with_MDEV_25020(hass, caplog, mysql_version) issue = registry.async_get_issue(DOMAIN, "maria_db_range_index_regression") assert issue is None + assert database_engine is not None + assert database_engine.optimizer.slow_range_in_select is False + def test_basic_sanity_check(hass_recorder, recorder_db_url): """Test the basic sanity checks with a missing table.""" From 5b0c7321b5de4287991ac623887385bfbed038cc Mon Sep 17 00:00:00 2001 From: Gregory Haynes Date: Sun, 5 Feb 2023 17:34:37 +0000 Subject: [PATCH 0129/2810] Add missing name field to emulated_hue config (#87456) Co-authored-by: J. Nick Koston --- homeassistant/components/emulated_hue/hue_api.py | 1 + tests/components/emulated_hue/test_hue_api.py | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index a81544151e..b855d0c738 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -831,6 +831,7 @@ def create_hue_success_response( def create_config_model(config: Config, request: web.Request) -> dict[str, Any]: """Create a config resource.""" return { + "name": "HASS BRIDGE", "mac": "00:00:00:00:00:00", "swversion": "01003542", "apiversion": "1.17.0", diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 58303ce54a..cfadccc39c 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -465,8 +465,9 @@ async def test_discover_full_state(hue_client): # Make sure array is correct size assert len(result_json) == 2 - assert len(config_json) == 6 + assert len(config_json) == 7 assert len(lights_json) >= 1 + assert "name" in config_json # Make sure the config wrapper added to the config is there assert "mac" in config_json @@ -505,7 +506,8 @@ async def test_discover_config(hue_client): config_json = await result.json() # Make sure array is correct size - assert len(config_json) == 6 + assert len(config_json) == 7 + assert "name" in config_json # Make sure the config wrapper added to the config is there assert "mac" in config_json From 4a7aee4bde666c799e2c9438488e29fe85cb9978 Mon Sep 17 00:00:00 2001 From: majuss Date: Mon, 6 Feb 2023 13:30:35 +0100 Subject: [PATCH 0130/2810] Bump lupupy to 0.2.7 (#87469) --- homeassistant/components/lupusec/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lupusec/manifest.json b/homeassistant/components/lupusec/manifest.json index f1403e1b2d..3e47daa027 100644 --- a/homeassistant/components/lupusec/manifest.json +++ b/homeassistant/components/lupusec/manifest.json @@ -2,7 +2,7 @@ "domain": "lupusec", "name": "Lupus Electronics LUPUSEC", "documentation": "https://www.home-assistant.io/integrations/lupusec", - "requirements": ["lupupy==0.2.5"], + "requirements": ["lupupy==0.2.7"], "codeowners": ["@majuss"], "iot_class": "local_polling", "loggers": ["lupupy"] diff --git a/requirements_all.txt b/requirements_all.txt index 7386175145..43f76ad164 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1081,7 +1081,7 @@ london-tube-status==0.5 luftdaten==0.7.4 # homeassistant.components.lupusec -lupupy==0.2.5 +lupupy==0.2.7 # homeassistant.components.lw12wifi lw12==0.9.2 From b2ccf2e87e66f9f128879fbbd11a791e6dae2ad7 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 5 Feb 2023 20:06:17 +0100 Subject: [PATCH 0131/2810] Bump py-synologydsm-api to 2.1.4 (#87471) fixes undefined --- homeassistant/components/synology_dsm/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json index a4b340bd3f..a6664055bc 100644 --- a/homeassistant/components/synology_dsm/manifest.json +++ b/homeassistant/components/synology_dsm/manifest.json @@ -2,7 +2,7 @@ "domain": "synology_dsm", "name": "Synology DSM", "documentation": "https://www.home-assistant.io/integrations/synology_dsm", - "requirements": ["py-synologydsm-api==2.1.2"], + "requirements": ["py-synologydsm-api==2.1.4"], "codeowners": ["@hacf-fr", "@Quentame", "@mib1185"], "config_flow": true, "ssdp": [ diff --git a/requirements_all.txt b/requirements_all.txt index 43f76ad164..0a326a7946 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1442,7 +1442,7 @@ py-schluter==0.1.7 py-sucks==0.9.8 # homeassistant.components.synology_dsm -py-synologydsm-api==2.1.2 +py-synologydsm-api==2.1.4 # homeassistant.components.zabbix py-zabbix==1.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c4dc297934..146b2e0e1f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1051,7 +1051,7 @@ py-melissa-climate==2.1.4 py-nightscout==1.2.2 # homeassistant.components.synology_dsm -py-synologydsm-api==2.1.2 +py-synologydsm-api==2.1.4 # homeassistant.components.seventeentrack py17track==2021.12.2 From 423d8f0bca14c4cddb3c6e948e303c6f02394bc7 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Sun, 5 Feb 2023 13:28:28 -0500 Subject: [PATCH 0132/2810] Disable uptime sensor by default in Unifi (#87484) Disable Uptime sensor by default in Unifi --- homeassistant/components/unifi/sensor.py | 1 + tests/components/unifi/test_sensor.py | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index a88b750fdf..aec123ca27 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -154,6 +154,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, has_entity_name=True, + entity_registry_enabled_default=False, allowed_fn=lambda controller, _: controller.option_allow_uptime_sensors, api_handler_fn=lambda api: api.clients, available_fn=lambda controller, obj_id: controller.available, diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index 3aa4114e82..d3823c2fd9 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -193,6 +193,7 @@ async def test_uptime_sensors( hass, aioclient_mock, mock_unifi_websocket, + entity_registry_enabled_by_default, initial_uptime, event_uptime, new_uptime, @@ -263,7 +264,9 @@ async def test_uptime_sensors( assert hass.states.get("sensor.client1_uptime") is None -async def test_remove_sensors(hass, aioclient_mock, mock_unifi_websocket): +async def test_remove_sensors( + hass, aioclient_mock, mock_unifi_websocket, entity_registry_enabled_by_default +): """Verify removing of clients work as expected.""" wired_client = { "hostname": "Wired client", From e30ea3e344fbef2337420574e94fb006a58f3555 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Mon, 6 Feb 2023 17:11:21 +0100 Subject: [PATCH 0133/2810] Add the correct loggers to velbus manifest.json (#87488) --- homeassistant/components/velbus/manifest.json | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 9384947cb8..9052e73ba6 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -1,13 +1,19 @@ { "domain": "velbus", "name": "Velbus", - "documentation": "https://www.home-assistant.io/integrations/velbus", - "requirements": ["velbus-aio==2022.12.0"], - "config_flow": true, "codeowners": ["@Cereal2nd", "@brefra"], + "config_flow": true, "dependencies": ["usb"], + "documentation": "https://www.home-assistant.io/integrations/velbus", "integration_type": "hub", "iot_class": "local_push", + "loggers": [ + "velbus-parser", + "velbus-module", + "velbus-packet", + "velbus-protocol" + ], + "requirements": ["velbus-aio==2022.12.0"], "usb": [ { "vid": "10CF", @@ -25,6 +31,5 @@ "vid": "10CF", "pid": "0518" } - ], - "loggers": ["velbusaio"] + ] } From 8a257df59f17cf749740d6db9a688a5163a853e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 5 Feb 2023 21:12:41 -0600 Subject: [PATCH 0134/2810] Fix recorder run history during schema migration and startup (#87492) Fix recorder run history during schema migration RunHistory.get and RunHistory.current can be called before RunHistory.start. We need to return a RecorderRuns object with the recording_start time that will be used when start it called to ensure history queries still work as expected. fixes #87112 --- homeassistant/components/recorder/run_history.py | 9 +++++++-- tests/components/recorder/test_run_history.py | 11 +++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recorder/run_history.py b/homeassistant/components/recorder/run_history.py index fb87d9a1fa..02b2df066b 100644 --- a/homeassistant/components/recorder/run_history.py +++ b/homeassistant/components/recorder/run_history.py @@ -64,8 +64,13 @@ class RunHistory: @property def current(self) -> RecorderRuns: """Get the current run.""" - assert self._current_run_info is not None - return self._current_run_info + # If start has not been called yet because the recorder is + # still starting up we want history to use the current time + # as the created time to ensure we can still return results + # and we do not try to pull data from the previous run. + return self._current_run_info or RecorderRuns( + start=self.recording_start, created=dt_util.utcnow() + ) def get(self, start: datetime) -> RecorderRuns | None: """Return the recorder run that started before or at start. diff --git a/tests/components/recorder/test_run_history.py b/tests/components/recorder/test_run_history.py index 7504404f77..3b5bd7dda6 100644 --- a/tests/components/recorder/test_run_history.py +++ b/tests/components/recorder/test_run_history.py @@ -45,3 +45,14 @@ async def test_run_history(recorder_mock, hass): process_timestamp(instance.run_history.get(now).start) == instance.run_history.recording_start ) + + +async def test_run_history_during_schema_migration(recorder_mock, hass): + """Test the run history during schema migration.""" + instance = recorder.get_instance(hass) + run_history = instance.run_history + assert run_history.current.start == run_history.recording_start + with instance.get_session() as session: + run_history.start(session) + assert run_history.current.start == run_history.recording_start + assert run_history.current.created >= run_history.recording_start From 1a72b64024af52ed602b02b9a3887089cbaf4448 Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Mon, 6 Feb 2023 00:31:21 +0100 Subject: [PATCH 0135/2810] Bump xiaomi-ble to 0.16.1 (#87496) Co-authored-by: J. Nick Koston --- homeassistant/components/xiaomi_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index 1f36ac10d1..f390400341 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -14,7 +14,7 @@ } ], "dependencies": ["bluetooth_adapters"], - "requirements": ["xiaomi-ble==0.15.0"], + "requirements": ["xiaomi-ble==0.16.1"], "codeowners": ["@Jc2k", "@Ernst79"], "iot_class": "local_push" } diff --git a/requirements_all.txt b/requirements_all.txt index 0a326a7946..9738e08778 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2637,7 +2637,7 @@ xbox-webapi==2.0.11 xboxapi==2.0.1 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.15.0 +xiaomi-ble==0.16.1 # homeassistant.components.knx xknx==2.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 146b2e0e1f..ad76811f4c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1862,7 +1862,7 @@ wolf_smartset==0.1.11 xbox-webapi==2.0.11 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.15.0 +xiaomi-ble==0.16.1 # homeassistant.components.knx xknx==2.3.0 From 08c23dd688c9220e8cff08a5d332eaa3da2a96e6 Mon Sep 17 00:00:00 2001 From: snapcase Date: Mon, 6 Feb 2023 11:12:54 -0500 Subject: [PATCH 0136/2810] Bump jaraco.abode to 3.3.0 (#87498) Fixes https://github.com/home-assistant/core/issues/86765 fixes undefined --- homeassistant/components/abode/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/abode/manifest.json b/homeassistant/components/abode/manifest.json index 6045f8797b..771dfc581a 100644 --- a/homeassistant/components/abode/manifest.json +++ b/homeassistant/components/abode/manifest.json @@ -3,7 +3,7 @@ "name": "Abode", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/abode", - "requirements": ["jaraco.abode==3.2.1"], + "requirements": ["jaraco.abode==3.3.0"], "codeowners": ["@shred86"], "homekit": { "models": ["Abode", "Iota"] diff --git a/requirements_all.txt b/requirements_all.txt index 9738e08778..3c5fe37a04 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -997,7 +997,7 @@ ismartgate==4.0.4 janus==1.0.0 # homeassistant.components.abode -jaraco.abode==3.2.1 +jaraco.abode==3.3.0 # homeassistant.components.jellyfin jellyfin-apiclient-python==1.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ad76811f4c..772f4a10c0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -753,7 +753,7 @@ ismartgate==4.0.4 janus==1.0.0 # homeassistant.components.abode -jaraco.abode==3.2.1 +jaraco.abode==3.3.0 # homeassistant.components.jellyfin jellyfin-apiclient-python==1.9.2 From f59eb6c277ece964052202d82de51e70df03d5bc Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Mon, 6 Feb 2023 00:34:52 +0100 Subject: [PATCH 0137/2810] Bump bimmer_connected to 0.12.1 (#87506) Co-authored-by: rikroe fixes undefined --- homeassistant/components/bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index c03bdf6a26..c5fbb19284 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -2,7 +2,7 @@ "domain": "bmw_connected_drive", "name": "BMW Connected Drive", "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", - "requirements": ["bimmer_connected==0.12.0"], + "requirements": ["bimmer_connected==0.12.1"], "codeowners": ["@gerard33", "@rikroe"], "config_flow": true, "iot_class": "cloud_polling", diff --git a/requirements_all.txt b/requirements_all.txt index 3c5fe37a04..96c78fd861 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -425,7 +425,7 @@ beautifulsoup4==4.11.1 bellows==0.34.7 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.12.0 +bimmer_connected==0.12.1 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 772f4a10c0..f21105746f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -355,7 +355,7 @@ beautifulsoup4==4.11.1 bellows==0.34.7 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.12.0 +bimmer_connected==0.12.1 # homeassistant.components.bluetooth bleak-retry-connector==2.13.0 From 7fd3f404de22a7e1b5578eac77297a483fc01a26 Mon Sep 17 00:00:00 2001 From: Ben Corrado Date: Mon, 6 Feb 2023 10:00:06 -0800 Subject: [PATCH 0138/2810] Add LD2410BLE support for new firmware version (#87507) * Updated local_name to reflect the naming scheme in HLK firmware 2.01.23020209 * Adding generated bluetooth file. --- homeassistant/components/ld2410_ble/manifest.json | 9 ++++++++- homeassistant/generated/bluetooth.py | 4 ++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index df7ddff701..14af223baf 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -6,7 +6,14 @@ "requirements": ["bluetooth-data-tools==0.3.1", "ld2410-ble==0.1.1"], "dependencies": ["bluetooth_adapters"], "codeowners": ["@930913"], - "bluetooth": [{ "local_name": "HLK-LD2410B_*" }], + "bluetooth": [ + { + "local_name": "HLK-LD2410B_*" + }, + { + "local_name": "HLK-LD2410_*" + } + ], "integration_type": "device", "iot_class": "local_push" } diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index efc05ff283..ef79445ff3 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -225,6 +225,10 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ "domain": "ld2410_ble", "local_name": "HLK-LD2410B_*", }, + { + "domain": "ld2410_ble", + "local_name": "HLK-LD2410_*", + }, { "domain": "led_ble", "local_name": "LEDnet*", From bc01df6b52d0a6a48c23eedc7950e11bfb05ad4a Mon Sep 17 00:00:00 2001 From: Michael Davie Date: Mon, 6 Feb 2023 11:05:28 -0500 Subject: [PATCH 0139/2810] Bump env_canada to 0.5.28 (#87509) Co-authored-by: J. Nick Koston fixes undefined --- 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 b7d3644d48..f0e27c30f7 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -2,7 +2,7 @@ "domain": "environment_canada", "name": "Environment Canada", "documentation": "https://www.home-assistant.io/integrations/environment_canada", - "requirements": ["env_canada==0.5.27"], + "requirements": ["env_canada==0.5.28"], "codeowners": ["@gwww", "@michaeldavie"], "config_flow": true, "iot_class": "cloud_polling", diff --git a/requirements_all.txt b/requirements_all.txt index 96c78fd861..fab562589b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -658,7 +658,7 @@ enocean==0.50 enturclient==0.2.4 # homeassistant.components.environment_canada -env_canada==0.5.27 +env_canada==0.5.28 # homeassistant.components.enphase_envoy envoy_reader==0.20.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f21105746f..4e87c19e69 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -511,7 +511,7 @@ energyzero==0.3.1 enocean==0.50 # homeassistant.components.environment_canada -env_canada==0.5.27 +env_canada==0.5.28 # homeassistant.components.enphase_envoy envoy_reader==0.20.1 From 590bdc1f496eb804c62ab26f285fae2af6cd3af6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Feb 2023 07:50:44 -0600 Subject: [PATCH 0140/2810] Optimize history.get_last_state_changes query (#87554) fixes undefined --- homeassistant/components/recorder/history.py | 48 ++++++++++--------- tests/components/recorder/test_history.py | 2 +- .../recorder/test_history_db_schema_30.py | 2 +- 3 files changed, 28 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/recorder/history.py b/homeassistant/components/recorder/history.py index e4f5a39f1c..1d3716abc4 100644 --- a/homeassistant/components/recorder/history.py +++ b/homeassistant/components/recorder/history.py @@ -519,48 +519,52 @@ def state_changes_during_period( def _get_last_state_changes_stmt( - schema_version: int, number_of_states: int, entity_id: str | None + schema_version: int, number_of_states: int, entity_id: str ) -> StatementLambdaElement: stmt, join_attributes = lambda_stmt_and_join_attributes( schema_version, False, include_last_changed=False ) if schema_version >= 31: - stmt += lambda q: q.filter( - (States.last_changed_ts == States.last_updated_ts) - | States.last_changed_ts.is_(None) + stmt += lambda q: q.where( + States.state_id + == ( + select(States.state_id) + .filter(States.entity_id == entity_id) + .order_by(States.last_updated_ts.desc()) + .limit(number_of_states) + .subquery() + ).c.state_id ) else: - stmt += lambda q: q.filter( - (States.last_changed == States.last_updated) | States.last_changed.is_(None) + stmt += lambda q: q.where( + States.state_id + == ( + select(States.state_id) + .filter(States.entity_id == entity_id) + .order_by(States.last_updated.desc()) + .limit(number_of_states) + .subquery() + ).c.state_id ) - if entity_id: - stmt += lambda q: q.filter(States.entity_id == entity_id) if join_attributes: stmt += lambda q: q.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id ) - if schema_version >= 31: - stmt += lambda q: q.order_by( - States.entity_id, States.last_updated_ts.desc() - ).limit(number_of_states) - else: - stmt += lambda q: q.order_by( - States.entity_id, States.last_updated.desc() - ).limit(number_of_states) + + stmt += lambda q: q.order_by(States.state_id.desc()) return stmt def get_last_state_changes( - hass: HomeAssistant, number_of_states: int, entity_id: str | None + hass: HomeAssistant, number_of_states: int, entity_id: str ) -> MutableMapping[str, list[State]]: """Return the last number_of_states.""" - start_time = dt_util.utcnow() - entity_id = entity_id.lower() if entity_id is not None else None - entity_ids = [entity_id] if entity_id is not None else None + entity_id_lower = entity_id.lower() + entity_ids = [entity_id_lower] with session_scope(hass=hass) as session: stmt = _get_last_state_changes_stmt( - _schema_version(hass), number_of_states, entity_id + _schema_version(hass), number_of_states, entity_id_lower ) states = list(execute_stmt_lambda_element(session, stmt)) return cast( @@ -569,7 +573,7 @@ def get_last_state_changes( hass, session, reversed(states), - start_time, + dt_util.utcnow(), entity_ids, include_start_time_state=False, ), diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index c35f007584..fab2b7fbac 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -324,7 +324,7 @@ def test_get_last_state_changes(hass_recorder): start = dt_util.utcnow() - timedelta(minutes=2) point = start + timedelta(minutes=1) - point2 = point + timedelta(minutes=1) + point2 = point + timedelta(minutes=1, seconds=1) with patch( "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start diff --git a/tests/components/recorder/test_history_db_schema_30.py b/tests/components/recorder/test_history_db_schema_30.py index 5e944ce454..f1104145b1 100644 --- a/tests/components/recorder/test_history_db_schema_30.py +++ b/tests/components/recorder/test_history_db_schema_30.py @@ -249,7 +249,7 @@ def test_get_last_state_changes(hass_recorder): start = dt_util.utcnow() - timedelta(minutes=2) point = start + timedelta(minutes=1) - point2 = point + timedelta(minutes=1) + point2 = point + timedelta(minutes=1, seconds=1) with patch( "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start From 5bc49b1757563a6831040dcbf897df04710cdd55 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 6 Feb 2023 23:57:08 -0500 Subject: [PATCH 0141/2810] OpenAI: Ignore devices without a name (#87558) Ignore devices without a name --- homeassistant/components/openai_conversation/const.py | 2 +- tests/components/openai_conversation/test_init.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 378548173b..b5644915d9 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -9,7 +9,7 @@ An overview of the areas and the devices in this smart home: {%- for area in areas %} {%- set area_info = namespace(printed=false) %} {%- for device in area_devices(area.name) -%} - {%- if not device_attr(device, "disabled_by") and not device_attr(device, "entry_type") %} + {%- if not device_attr(device, "disabled_by") and not device_attr(device, "entry_type") and device_attr(device, "name") %} {%- if not area_info.printed %} {{ area.name }}: diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index 551d493df8..b64f332289 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -67,6 +67,13 @@ async def test_default_prompt(hass, mock_init_component): device_reg.async_update_device( device.id, disabled_by=device_registry.DeviceEntryDisabler.USER ) + device = device_reg.async_get_or_create( + config_entry_id="1234", + connections={("test", "9876-no-name")}, + manufacturer="Test Manufacturer NoName", + model="Test Model NoName", + suggested_area="Test Area 2", + ) with patch("openai.Completion.create") as mock_create: result = await conversation.async_converse(hass, "hello", None, Context()) From 9657567280f254bfd9f444c0a5f0c7812b8875ca Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 6 Feb 2023 14:28:53 -0500 Subject: [PATCH 0142/2810] Bump oralb-ble to 0.17.4 (#87570) closes undefined --- homeassistant/components/oralb/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/oralb/manifest.json b/homeassistant/components/oralb/manifest.json index 08b257c186..e2f1749eb7 100644 --- a/homeassistant/components/oralb/manifest.json +++ b/homeassistant/components/oralb/manifest.json @@ -8,7 +8,7 @@ "manufacturer_id": 220 } ], - "requirements": ["oralb-ble==0.17.2"], + "requirements": ["oralb-ble==0.17.4"], "dependencies": ["bluetooth_adapters"], "codeowners": ["@bdraco", "@Lash-L"], "iot_class": "local_push" diff --git a/requirements_all.txt b/requirements_all.txt index fab562589b..616ef1fd78 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1299,7 +1299,7 @@ openwrt-luci-rpc==1.1.11 openwrt-ubus-rpc==0.0.2 # homeassistant.components.oralb -oralb-ble==0.17.2 +oralb-ble==0.17.4 # homeassistant.components.oru oru==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4e87c19e69..341b5a0d26 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -947,7 +947,7 @@ openai==0.26.2 openerz-api==0.2.0 # homeassistant.components.oralb -oralb-ble==0.17.2 +oralb-ble==0.17.4 # homeassistant.components.ovo_energy ovoenergy==1.2.0 From d06e640889bd61f7292f1e3fd5de9dce961be8a2 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 6 Feb 2023 22:41:52 +0100 Subject: [PATCH 0143/2810] Fix matter remove config entry device (#87571) --- homeassistant/components/matter/__init__.py | 20 ++--- .../components/matter/diagnostics.py | 21 +---- homeassistant/components/matter/helpers.py | 42 ++++++++- tests/components/matter/test_helpers.py | 50 ++++++++++- tests/components/matter/test_init.py | 85 ++++++++++++++++++- 5 files changed, 180 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/matter/__init__.py b/homeassistant/components/matter/__init__.py index a0e4dcf748..6a11e5ded4 100644 --- a/homeassistant/components/matter/__init__.py +++ b/homeassistant/components/matter/__init__.py @@ -32,7 +32,7 @@ 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 .helpers import MatterEntryData, get_matter +from .helpers import MatterEntryData, get_matter, get_node_from_device_entry CONNECT_TIMEOUT = 10 LISTEN_READY_TIMEOUT = 30 @@ -192,23 +192,13 @@ async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry ) -> bool: """Remove a config entry from a device.""" - unique_id = None + node = await get_node_from_device_entry(hass, device_entry) - for ident in device_entry.identifiers: - if ident[0] == DOMAIN: - unique_id = ident[1] - break - - if not unique_id: + if node is None: return True - matter_entry_data: MatterEntryData = hass.data[DOMAIN][config_entry.entry_id] - matter_client = matter_entry_data.adapter.matter_client - - for node in await matter_client.get_nodes(): - if node.unique_id == unique_id: - await matter_client.remove_node(node.node_id) - break + matter = get_matter(hass) + await matter.matter_client.remove_node(node.node_id) return True diff --git a/homeassistant/components/matter/diagnostics.py b/homeassistant/components/matter/diagnostics.py index 77234ab48b..571523f7f0 100644 --- a/homeassistant/components/matter/diagnostics.py +++ b/homeassistant/components/matter/diagnostics.py @@ -11,8 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .const import DOMAIN, ID_TYPE_DEVICE_ID -from .helpers import get_device_id, get_matter +from .helpers import get_matter, get_node_from_device_entry ATTRIBUTES_TO_REDACT = {"chip.clusters.Objects.BasicInformation.Attributes.Location"} @@ -53,28 +52,14 @@ async def async_get_device_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a device.""" matter = get_matter(hass) - device_id_type_prefix = f"{ID_TYPE_DEVICE_ID}_" - device_id_full = next( - identifier[1] - for identifier in device.identifiers - if identifier[0] == DOMAIN and identifier[1].startswith(device_id_type_prefix) - ) - device_id = device_id_full.lstrip(device_id_type_prefix) - server_diagnostics = await matter.matter_client.get_diagnostics() - - node = next( - node - for node in await matter.matter_client.get_nodes() - for node_device in node.node_devices - if get_device_id(server_diagnostics.info, node_device) == device_id - ) + node = await get_node_from_device_entry(hass, device) return { "server_info": remove_serialization_type( dataclass_to_dict(server_diagnostics.info) ), "node": redact_matter_attributes( - remove_serialization_type(dataclass_to_dict(node)) + remove_serialization_type(dataclass_to_dict(node) if node else {}) ), } diff --git a/homeassistant/components/matter/helpers.py b/homeassistant/components/matter/helpers.py index 5abf81ee60..ef42f9354c 100644 --- a/homeassistant/components/matter/helpers.py +++ b/homeassistant/components/matter/helpers.py @@ -6,8 +6,9 @@ from dataclasses import dataclass from typing import TYPE_CHECKING from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr -from .const import DOMAIN +from .const import DOMAIN, ID_TYPE_DEVICE_ID if TYPE_CHECKING: from matter_server.common.models.node import MatterNode @@ -58,3 +59,42 @@ def get_device_id( # Append nodedevice(type) to differentiate between a root node # and bridge within Home Assistant devices. return f"{operational_instance_id}-{node_device.__class__.__name__}" + + +async def get_node_from_device_entry( + hass: HomeAssistant, device: dr.DeviceEntry +) -> MatterNode | None: + """Return MatterNode from device entry.""" + matter = get_matter(hass) + device_id_type_prefix = f"{ID_TYPE_DEVICE_ID}_" + device_id_full = next( + ( + identifier[1] + for identifier in device.identifiers + if identifier[0] == DOMAIN + and identifier[1].startswith(device_id_type_prefix) + ), + None, + ) + + if device_id_full is None: + raise ValueError(f"Device {device.id} is not a Matter device") + + device_id = device_id_full.lstrip(device_id_type_prefix) + matter_client = matter.matter_client + server_info = matter_client.server_info + + if server_info is None: + raise RuntimeError("Matter server information is not available") + + node = next( + ( + 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 + ), + None, + ) + + return node diff --git a/tests/components/matter/test_helpers.py b/tests/components/matter/test_helpers.py index 3da0a26b7e..8f849c8594 100644 --- a/tests/components/matter/test_helpers.py +++ b/tests/components/matter/test_helpers.py @@ -3,11 +3,20 @@ from __future__ import annotations from unittest.mock import MagicMock -from homeassistant.components.matter.helpers import get_device_id +import pytest + +from homeassistant.components.matter.const import DOMAIN +from homeassistant.components.matter.helpers import ( + get_device_id, + get_node_from_device_entry, +) from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from .common import setup_integration_with_node_fixture +from tests.common import MockConfigEntry + async def test_get_device_id( hass: HomeAssistant, @@ -20,3 +29,42 @@ async def test_get_device_id( device_id = get_device_id(matter_client.server_info, node.node_devices[0]) assert device_id == "00000000000004D2-0000000000000005-MatterNodeDevice" + + +async def test_get_node_from_device_entry( + hass: HomeAssistant, + matter_client: MagicMock, +) -> None: + """Test get_node_from_device_entry.""" + device_registry = dr.async_get(hass) + other_domain = "other_domain" + other_config_entry = MockConfigEntry(domain=other_domain) + other_device_entry = device_registry.async_get_or_create( + config_entry_id=other_config_entry.entry_id, + identifiers={(other_domain, "1234")}, + ) + node = await setup_integration_with_node_fixture( + hass, "device_diagnostics", matter_client + ) + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + device_entry = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + )[0] + assert device_entry + node_from_device_entry = await get_node_from_device_entry(hass, device_entry) + + assert node_from_device_entry is node + + with pytest.raises(ValueError) as value_error: + await get_node_from_device_entry(hass, other_device_entry) + + assert f"Device {other_device_entry.id} is not a Matter device" in str( + value_error.value + ) + + matter_client.server_info = None + + with pytest.raises(RuntimeError) as runtime_error: + node_from_device_entry = await get_node_from_device_entry(hass, device_entry) + + assert "Matter server information is not available" in str(runtime_error.value) diff --git a/tests/components/matter/test_init.py b/tests/components/matter/test_init.py index a3febe799a..ce2e1010f6 100644 --- a/tests/components/matter/test_init.py +++ b/tests/components/matter/test_init.py @@ -2,9 +2,10 @@ from __future__ import annotations import asyncio -from collections.abc import Generator +from collections.abc import Awaitable, Callable, Generator from unittest.mock import AsyncMock, MagicMock, call, patch +from aiohttp import ClientWebSocketResponse from matter_server.client.exceptions import CannotConnect, InvalidServerVersion from matter_server.common.helpers.util import dataclass_from_dict from matter_server.common.models.error import MatterError @@ -16,9 +17,14 @@ from homeassistant.components.matter.const import DOMAIN from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) +from homeassistant.setup import async_setup_component -from .common import load_and_parse_node_fixture +from .common import load_and_parse_node_fixture, setup_integration_with_node_fixture from tests.common import MockConfigEntry @@ -587,3 +593,76 @@ async def test_remove_entry( assert entry.state is ConfigEntryState.NOT_LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 0 assert "Failed to uninstall the Matter Server add-on" in caplog.text + + +async def test_remove_config_entry_device( + hass: HomeAssistant, + matter_client: MagicMock, + hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], +) -> None: + """Test that a device can be removed ok.""" + assert await async_setup_component(hass, "config", {}) + await setup_integration_with_node_fixture(hass, "device_diagnostics", matter_client) + await hass.async_block_till_done() + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + device_registry = dr.async_get(hass) + device_entry = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + )[0] + entity_registry = er.async_get(hass) + entity_id = "light.m5stamp_lighting_app" + + assert device_entry + assert entity_registry.async_get(entity_id) + assert hass.states.get(entity_id) + + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 5, + "type": "config/device_registry/remove_config_entry", + "config_entry_id": config_entry.entry_id, + "device_id": device_entry.id, + } + ) + response = await client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + assert not device_registry.async_get(device_entry.id) + assert not entity_registry.async_get(entity_id) + assert not hass.states.get(entity_id) + + +async def test_remove_config_entry_device_no_node( + hass: HomeAssistant, + matter_client: MagicMock, + integration: MockConfigEntry, + hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], +) -> None: + """Test that a device can be removed ok without an existing node.""" + assert await async_setup_component(hass, "config", {}) + config_entry = integration + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={ + (DOMAIN, "deviceid_00000000000004D2-0000000000000005-MatterNodeDevice") + }, + ) + + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 5, + "type": "config/device_registry/remove_config_entry", + "config_entry_id": config_entry.entry_id, + "device_id": device_entry.id, + } + ) + response = await client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + assert not device_registry.async_get(device_entry.id) From 0d9393ca793f68e59cc5ec3ed5a27012082366dc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Feb 2023 22:59:54 -0600 Subject: [PATCH 0144/2810] Fix indent on slow_range_in_select for MySQL/MariaDB (#87581) We would calculate this value and throw it away because it was only used on first_connection --- homeassistant/components/recorder/util.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index dde32bf05d..ff6f9f1e7e 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -533,13 +533,13 @@ def setup_connection_for_dialect( version or version_string, "MySQL", MIN_VERSION_MYSQL ) - slow_range_in_select = bool( - not version - or version < MARIADB_WITH_FIXED_IN_QUERIES_105 - or MARIA_DB_106 <= version < MARIADB_WITH_FIXED_IN_QUERIES_106 - or MARIA_DB_107 <= version < MARIADB_WITH_FIXED_IN_QUERIES_107 - or MARIA_DB_108 <= version < MARIADB_WITH_FIXED_IN_QUERIES_108 - ) + slow_range_in_select = bool( + not version + or version < MARIADB_WITH_FIXED_IN_QUERIES_105 + or MARIA_DB_106 <= version < MARIADB_WITH_FIXED_IN_QUERIES_106 + or MARIA_DB_107 <= version < MARIADB_WITH_FIXED_IN_QUERIES_107 + or MARIA_DB_108 <= version < MARIADB_WITH_FIXED_IN_QUERIES_108 + ) elif dialect_name == SupportedDialect.POSTGRESQL: # Historically we have marked PostgreSQL as having slow range in select # but this may not be true for all versions. We should investigate From e0a6a6cfa6cd2f5b449f1edf450db3fa23aef3d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Feb 2023 12:38:48 -0600 Subject: [PATCH 0145/2810] Fix LD2410 BLE detection with passive scans (#87584) --- homeassistant/components/ld2410_ble/manifest.json | 5 +++++ homeassistant/generated/bluetooth.py | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index 14af223baf..9d133876a7 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -12,6 +12,11 @@ }, { "local_name": "HLK-LD2410_*" + }, + { + "manufacturer_id": 256, + "manufacturer_data_start": [7, 1], + "service_uuid": "0000af30-0000-1000-8000-00805f9b34fb" } ], "integration_type": "device", diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index ef79445ff3..031791ca10 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -229,6 +229,15 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ "domain": "ld2410_ble", "local_name": "HLK-LD2410_*", }, + { + "domain": "ld2410_ble", + "manufacturer_data_start": [ + 7, + 1, + ], + "manufacturer_id": 256, + "service_uuid": "0000af30-0000-1000-8000-00805f9b34fb", + }, { "domain": "led_ble", "local_name": "LEDnet*", From be837535144b2abee4223f8013f4bee2c62e81ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Feb 2023 22:59:21 -0600 Subject: [PATCH 0146/2810] Bump inkbird-ble to 0.5.6 (#87590) changelog: https://github.com/Bluetooth-Devices/inkbird-ble/compare/v0.5.5...v0.5.6 fixes #85432 --- homeassistant/components/inkbird/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/inkbird/manifest.json b/homeassistant/components/inkbird/manifest.json index e9984284c7..90bb44101a 100644 --- a/homeassistant/components/inkbird/manifest.json +++ b/homeassistant/components/inkbird/manifest.json @@ -10,7 +10,7 @@ { "local_name": "xBBQ*", "connectable": false }, { "local_name": "tps", "connectable": false } ], - "requirements": ["inkbird-ble==0.5.5"], + "requirements": ["inkbird-ble==0.5.6"], "dependencies": ["bluetooth_adapters"], "codeowners": ["@bdraco"], "iot_class": "local_push" diff --git a/requirements_all.txt b/requirements_all.txt index 616ef1fd78..59551eeaf5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -976,7 +976,7 @@ influxdb-client==1.24.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.5.5 +inkbird-ble==0.5.6 # homeassistant.components.insteon insteon-frontend-home-assistant==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 341b5a0d26..4a590176ec 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -735,7 +735,7 @@ influxdb-client==1.24.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.5.5 +inkbird-ble==0.5.6 # homeassistant.components.insteon insteon-frontend-home-assistant==0.2.0 From ab14671dbc97c16cdb49956e1e9dabbdcfca9740 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Feb 2023 22:59:17 -0600 Subject: [PATCH 0147/2810] Bump sensorpro-ble to 0.5.3 (#87591) changelog: https://github.com/Bluetooth-Devices/sensorpro-ble/compare/v0.5.1...v0.5.3 same fix as #85432 but for sensorpro --- homeassistant/components/sensorpro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensorpro/manifest.json b/homeassistant/components/sensorpro/manifest.json index 0749960913..603c29b539 100644 --- a/homeassistant/components/sensorpro/manifest.json +++ b/homeassistant/components/sensorpro/manifest.json @@ -15,7 +15,7 @@ "connectable": false } ], - "requirements": ["sensorpro-ble==0.5.1"], + "requirements": ["sensorpro-ble==0.5.3"], "dependencies": ["bluetooth_adapters"], "codeowners": ["@bdraco"], "iot_class": "local_push" diff --git a/requirements_all.txt b/requirements_all.txt index 59551eeaf5..b4ef378440 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2312,7 +2312,7 @@ sense_energy==0.11.1 sensirion-ble==0.0.1 # homeassistant.components.sensorpro -sensorpro-ble==0.5.1 +sensorpro-ble==0.5.3 # homeassistant.components.sensorpush sensorpush-ble==1.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4a590176ec..1bcd3c41d9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1627,7 +1627,7 @@ sense_energy==0.11.1 sensirion-ble==0.0.1 # homeassistant.components.sensorpro -sensorpro-ble==0.5.1 +sensorpro-ble==0.5.3 # homeassistant.components.sensorpush sensorpush-ble==1.5.2 From c4470fc36dc738f12f2a18ea5bfdc3d54a7a5b11 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Feb 2023 22:59:13 -0600 Subject: [PATCH 0148/2810] Bump thermopro-ble to 0.4.5 (#87592) changelog: https://github.com/Bluetooth-Devices/thermopro-ble/compare/v0.4.3..v0.4.5 same fix as #85432 but for thermopro --- homeassistant/components/thermopro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/thermopro/manifest.json b/homeassistant/components/thermopro/manifest.json index e9135a4432..fc9efdf81b 100644 --- a/homeassistant/components/thermopro/manifest.json +++ b/homeassistant/components/thermopro/manifest.json @@ -8,7 +8,7 @@ { "local_name": "TP39*", "connectable": false } ], "dependencies": ["bluetooth_adapters"], - "requirements": ["thermopro-ble==0.4.3"], + "requirements": ["thermopro-ble==0.4.5"], "codeowners": ["@bdraco"], "iot_class": "local_push" } diff --git a/requirements_all.txt b/requirements_all.txt index b4ef378440..a5e05b06d3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2481,7 +2481,7 @@ tesla-wall-connector==1.0.2 thermobeacon-ble==0.6.0 # homeassistant.components.thermopro -thermopro-ble==0.4.3 +thermopro-ble==0.4.5 # homeassistant.components.thermoworks_smoke thermoworks_smoke==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1bcd3c41d9..dcb47b8ba8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1745,7 +1745,7 @@ tesla-wall-connector==1.0.2 thermobeacon-ble==0.6.0 # homeassistant.components.thermopro -thermopro-ble==0.4.3 +thermopro-ble==0.4.5 # homeassistant.components.tilt_ble tilt-ble==0.2.3 From e67d9988fd5887f3d8f23ffaaa2898c9289d9f31 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Feb 2023 22:59:09 -0600 Subject: [PATCH 0149/2810] Bump bluemaestro-ble to 0.2.3 (#87594) changelog: https://github.com/Bluetooth-Devices/bluemaestro-ble/compare/v0.2.1..v0.2.3 same fix as #85432 but for bluemaestro --- homeassistant/components/bluemaestro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bluemaestro/manifest.json b/homeassistant/components/bluemaestro/manifest.json index 277e02ab48..d4a9890efa 100644 --- a/homeassistant/components/bluemaestro/manifest.json +++ b/homeassistant/components/bluemaestro/manifest.json @@ -9,7 +9,7 @@ "connectable": false } ], - "requirements": ["bluemaestro-ble==0.2.1"], + "requirements": ["bluemaestro-ble==0.2.3"], "dependencies": ["bluetooth_adapters"], "codeowners": ["@bdraco"], "iot_class": "local_push" diff --git a/requirements_all.txt b/requirements_all.txt index a5e05b06d3..c7674ea402 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -449,7 +449,7 @@ blinkstick==1.2.0 blockchain==1.4.4 # homeassistant.components.bluemaestro -bluemaestro-ble==0.2.1 +bluemaestro-ble==0.2.3 # homeassistant.components.decora # homeassistant.components.zengge diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dcb47b8ba8..742a765a8b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -370,7 +370,7 @@ blebox_uniapi==2.1.4 blinkpy==0.19.2 # homeassistant.components.bluemaestro -bluemaestro-ble==0.2.1 +bluemaestro-ble==0.2.3 # homeassistant.components.bluetooth bluetooth-adapters==0.15.2 From a2c9f9242097944bd09bd6551bf97671b5df485f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Feb 2023 22:59:05 -0600 Subject: [PATCH 0150/2810] Bump sensorpush-ble to 1.5.5 (#87595) * Bump sensorpush-ble to 1.5.4 changelog: https://github.com/Bluetooth-Devices/sensorpush-ble/compare/v1.5.2..v1.5.4 same fix as #85432 but for sensorpush * bump again to fix parser with passive scans --- homeassistant/components/sensorpush/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensorpush/manifest.json b/homeassistant/components/sensorpush/manifest.json index 7046837e45..7e68628c91 100644 --- a/homeassistant/components/sensorpush/manifest.json +++ b/homeassistant/components/sensorpush/manifest.json @@ -9,7 +9,7 @@ "connectable": false } ], - "requirements": ["sensorpush-ble==1.5.2"], + "requirements": ["sensorpush-ble==1.5.5"], "dependencies": ["bluetooth_adapters"], "codeowners": ["@bdraco"], "iot_class": "local_push" diff --git a/requirements_all.txt b/requirements_all.txt index c7674ea402..c28ee421fa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2315,7 +2315,7 @@ sensirion-ble==0.0.1 sensorpro-ble==0.5.3 # homeassistant.components.sensorpush -sensorpush-ble==1.5.2 +sensorpush-ble==1.5.5 # homeassistant.components.sentry sentry-sdk==1.13.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 742a765a8b..25409f3a69 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1630,7 +1630,7 @@ sensirion-ble==0.0.1 sensorpro-ble==0.5.3 # homeassistant.components.sensorpush -sensorpush-ble==1.5.2 +sensorpush-ble==1.5.5 # homeassistant.components.sentry sentry-sdk==1.13.0 From 2e541e7ef64eed70a1cd9287fb7013c3f0ffd897 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 6 Feb 2023 19:57:16 -0800 Subject: [PATCH 0151/2810] Improve rainbird device reliability by sending requests serially (#87603) Send rainbird requests serially --- .../components/rainbird/coordinator.py | 32 +++++++------------ 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py index ddb2b70324..14598921a6 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio from dataclasses import dataclass import datetime import logging @@ -84,27 +83,18 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): raise UpdateFailed(f"Error communicating with Device: {err}") from err async def _fetch_data(self) -> RainbirdDeviceState: - """Fetch data from the Rain Bird device.""" - (zones, states, rain, rain_delay) = await asyncio.gather( - self._fetch_zones(), - self._controller.get_zone_states(), - self._controller.get_rain_sensor_state(), - self._controller.get_rain_delay(), - ) + """Fetch data from the Rain Bird device. + + Rainbird devices can only reliably handle a single request at a time, + so the requests are sent serially. + """ + available_stations = await self._controller.get_available_stations() + states = await self._controller.get_zone_states() + rain = await self._controller.get_rain_sensor_state() + rain_delay = await self._controller.get_rain_delay() return RainbirdDeviceState( - zones=set(zones), - active_zones={zone for zone in zones if states.active(zone)}, + zones=available_stations.active_set, + active_zones=states.active_set, rain=rain, rain_delay=rain_delay, ) - - async def _fetch_zones(self) -> set[int]: - """Fetch the zones from the device, caching the results.""" - if self._zones is None: - available_stations = await self._controller.get_available_stations() - self._zones = { - zone - for zone in range(1, available_stations.stations.count + 1) - if available_stations.stations.active(zone) - } - return self._zones From 354d77d26b49f21939b848e802535f4c4c255a4a Mon Sep 17 00:00:00 2001 From: Thomas Hollstegge Date: Tue, 7 Feb 2023 19:39:08 +0100 Subject: [PATCH 0152/2810] Do not return cached values for entity states in emulated_hue (#87642) * Do not return cached values for state and brightness * Move building the uncached state dict behind a lru_cache (_build_entity_state_dict) --------- Co-authored-by: J. Nick Koston --- .../components/emulated_hue/hue_api.py | 146 ++++++++++-------- tests/components/emulated_hue/test_hue_api.py | 13 +- 2 files changed, 92 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index b855d0c738..41c25943a7 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -634,77 +634,87 @@ def get_entity_state_dict(config: Config, entity: State) -> dict[str, Any]: # Remove the now stale cached entry. config.cached_states.pop(entity.entity_id) + if cached_state is None: + return _build_entity_state_dict(entity) + + data: dict[str, Any] = cached_state + # Make sure brightness is valid + if data[STATE_BRIGHTNESS] is None: + data[STATE_BRIGHTNESS] = HUE_API_STATE_BRI_MAX if data[STATE_ON] else 0 + + # Make sure hue/saturation are valid + if (data[STATE_HUE] is None) or (data[STATE_SATURATION] is None): + data[STATE_HUE] = 0 + data[STATE_SATURATION] = 0 + + # If the light is off, set the color to off + if data[STATE_BRIGHTNESS] == 0: + data[STATE_HUE] = 0 + data[STATE_SATURATION] = 0 + + _clamp_values(data) + return data + + +@lru_cache(maxsize=512) +def _build_entity_state_dict(entity: State) -> dict[str, Any]: + """Build a state dict for an entity.""" data: dict[str, Any] = { - STATE_ON: False, + STATE_ON: entity.state != STATE_OFF, STATE_BRIGHTNESS: None, STATE_HUE: None, STATE_SATURATION: None, STATE_COLOR_TEMP: None, } - - if cached_state is None: - data[STATE_ON] = entity.state != STATE_OFF - - if data[STATE_ON]: - data[STATE_BRIGHTNESS] = hass_to_hue_brightness( - entity.attributes.get(ATTR_BRIGHTNESS, 0) - ) - hue_sat = entity.attributes.get(ATTR_HS_COLOR) - if hue_sat is not None: - hue = hue_sat[0] - sat = hue_sat[1] - # Convert hass hs values back to hue hs values - data[STATE_HUE] = int((hue / 360.0) * HUE_API_STATE_HUE_MAX) - data[STATE_SATURATION] = int((sat / 100.0) * HUE_API_STATE_SAT_MAX) - else: - data[STATE_HUE] = HUE_API_STATE_HUE_MIN - data[STATE_SATURATION] = HUE_API_STATE_SAT_MIN - data[STATE_COLOR_TEMP] = entity.attributes.get(ATTR_COLOR_TEMP, 0) - + if data[STATE_ON]: + data[STATE_BRIGHTNESS] = hass_to_hue_brightness( + entity.attributes.get(ATTR_BRIGHTNESS, 0) + ) + hue_sat = entity.attributes.get(ATTR_HS_COLOR) + if hue_sat is not None: + hue = hue_sat[0] + sat = hue_sat[1] + # Convert hass hs values back to hue hs values + data[STATE_HUE] = int((hue / 360.0) * HUE_API_STATE_HUE_MAX) + data[STATE_SATURATION] = int((sat / 100.0) * HUE_API_STATE_SAT_MAX) else: - data[STATE_BRIGHTNESS] = 0 - data[STATE_HUE] = 0 - data[STATE_SATURATION] = 0 - data[STATE_COLOR_TEMP] = 0 + data[STATE_HUE] = HUE_API_STATE_HUE_MIN + data[STATE_SATURATION] = HUE_API_STATE_SAT_MIN + data[STATE_COLOR_TEMP] = entity.attributes.get(ATTR_COLOR_TEMP, 0) - if entity.domain == climate.DOMAIN: - temperature = entity.attributes.get(ATTR_TEMPERATURE, 0) - # Convert 0-100 to 0-254 - data[STATE_BRIGHTNESS] = round(temperature * HUE_API_STATE_BRI_MAX / 100) - elif entity.domain == humidifier.DOMAIN: - humidity = entity.attributes.get(ATTR_HUMIDITY, 0) - # Convert 0-100 to 0-254 - data[STATE_BRIGHTNESS] = round(humidity * HUE_API_STATE_BRI_MAX / 100) - elif entity.domain == media_player.DOMAIN: - level = entity.attributes.get( - ATTR_MEDIA_VOLUME_LEVEL, 1.0 if data[STATE_ON] else 0.0 - ) - # Convert 0.0-1.0 to 0-254 - data[STATE_BRIGHTNESS] = round(min(1.0, level) * HUE_API_STATE_BRI_MAX) - elif entity.domain == fan.DOMAIN: - percentage = entity.attributes.get(ATTR_PERCENTAGE) or 0 - # Convert 0-100 to 0-254 - data[STATE_BRIGHTNESS] = round(percentage * HUE_API_STATE_BRI_MAX / 100) - elif entity.domain == cover.DOMAIN: - level = entity.attributes.get(ATTR_CURRENT_POSITION, 0) - data[STATE_BRIGHTNESS] = round(level / 100 * HUE_API_STATE_BRI_MAX) else: - data = cached_state - # Make sure brightness is valid - if data[STATE_BRIGHTNESS] is None: - data[STATE_BRIGHTNESS] = HUE_API_STATE_BRI_MAX if data[STATE_ON] else 0 + data[STATE_BRIGHTNESS] = 0 + data[STATE_HUE] = 0 + data[STATE_SATURATION] = 0 + data[STATE_COLOR_TEMP] = 0 - # Make sure hue/saturation are valid - if (data[STATE_HUE] is None) or (data[STATE_SATURATION] is None): - data[STATE_HUE] = 0 - data[STATE_SATURATION] = 0 + if entity.domain == climate.DOMAIN: + temperature = entity.attributes.get(ATTR_TEMPERATURE, 0) + # Convert 0-100 to 0-254 + data[STATE_BRIGHTNESS] = round(temperature * HUE_API_STATE_BRI_MAX / 100) + elif entity.domain == humidifier.DOMAIN: + humidity = entity.attributes.get(ATTR_HUMIDITY, 0) + # Convert 0-100 to 0-254 + data[STATE_BRIGHTNESS] = round(humidity * HUE_API_STATE_BRI_MAX / 100) + elif entity.domain == media_player.DOMAIN: + level = entity.attributes.get( + ATTR_MEDIA_VOLUME_LEVEL, 1.0 if data[STATE_ON] else 0.0 + ) + # Convert 0.0-1.0 to 0-254 + data[STATE_BRIGHTNESS] = round(min(1.0, level) * HUE_API_STATE_BRI_MAX) + elif entity.domain == fan.DOMAIN: + percentage = entity.attributes.get(ATTR_PERCENTAGE) or 0 + # Convert 0-100 to 0-254 + data[STATE_BRIGHTNESS] = round(percentage * HUE_API_STATE_BRI_MAX / 100) + elif entity.domain == cover.DOMAIN: + level = entity.attributes.get(ATTR_CURRENT_POSITION, 0) + data[STATE_BRIGHTNESS] = round(level / 100 * HUE_API_STATE_BRI_MAX) + _clamp_values(data) + return data - # If the light is off, set the color to off - if data[STATE_BRIGHTNESS] == 0: - data[STATE_HUE] = 0 - data[STATE_SATURATION] = 0 - # Clamp brightness, hue, saturation, and color temp to valid values +def _clamp_values(data: dict[str, Any]) -> None: + """Clamp brightness, hue, saturation, and color temp to valid values.""" for key, v_min, v_max in ( (STATE_BRIGHTNESS, HUE_API_STATE_BRI_MIN, HUE_API_STATE_BRI_MAX), (STATE_HUE, HUE_API_STATE_HUE_MIN, HUE_API_STATE_HUE_MAX), @@ -714,8 +724,6 @@ def get_entity_state_dict(config: Config, entity: State) -> dict[str, Any]: if data[key] is not None: data[key] = max(v_min, min(data[key], v_max)) - return data - @lru_cache(maxsize=1024) def _entity_unique_id(entity_id: str) -> str: @@ -843,10 +851,18 @@ def create_config_model(config: Config, request: web.Request) -> dict[str, Any]: def create_list_of_entities(config: Config, request: web.Request) -> dict[str, Any]: """Create a list of all entities.""" - json_response: dict[str, Any] = { - config.entity_id_to_number(state.entity_id): state_to_json(config, state) - for state in config.get_exposed_states() - } + hass: core.HomeAssistant = request.app["hass"] + + json_response: dict[str, Any] = {} + for cached_state in config.get_exposed_states(): + entity_id = cached_state.entity_id + state = hass.states.get(entity_id) + assert state is not None + + json_response[config.entity_id_to_number(entity_id)] = state_to_json( + config, state + ) + return json_response diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index cfadccc39c..3d2d91dfa6 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -301,6 +301,7 @@ async def test_discover_lights(hass, hue_client): await hass.async_block_till_done() result_json = await async_get_lights(hue_client) + assert "1" not in result_json.keys() devices = {val["uniqueid"] for val in result_json.values()} assert "00:2f:d2:31:ce:c5:55:cc-ee" not in devices # light.ceiling_lights @@ -308,8 +309,16 @@ async def test_discover_lights(hass, hue_client): hass.states.async_set("light.ceiling_lights", STATE_ON) await hass.async_block_till_done() result_json = await async_get_lights(hue_client) - devices = {val["uniqueid"] for val in result_json.values()} - assert "00:2f:d2:31:ce:c5:55:cc-ee" in devices # light.ceiling_lights + device = result_json["1"] # Test that light ID did not change + assert device["uniqueid"] == "00:2f:d2:31:ce:c5:55:cc-ee" # light.ceiling_lights + assert device["state"][HUE_API_STATE_ON] is True + + # Test that returned value is fresh and not cached + hass.states.async_set("light.ceiling_lights", STATE_OFF) + await hass.async_block_till_done() + result_json = await async_get_lights(hue_client) + device = result_json["1"] + assert device["state"][HUE_API_STATE_ON] is False async def test_light_without_brightness_supported(hass_hue, hue_client): From 08fb16a0c28909672f569f0224abb095e97cb794 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 7 Feb 2023 13:40:53 -0500 Subject: [PATCH 0153/2810] Bumped version to 2023.2.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 168bf80aa1..63d691f6ac 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 = 2 -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 825c2bc356..6253e20411 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.2.2" +version = "2023.2.3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From aacd8e044d9c6e8da4e64e2aabc16262a4ac2cf5 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 29 Jan 2023 06:11:56 -0800 Subject: [PATCH 0154/2810] Bump pyrainbird to 2.0.0 (#86851) --- homeassistant/components/rainbird/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rainbird/manifest.json b/homeassistant/components/rainbird/manifest.json index 50eb11c3fe..dcecaac81a 100644 --- a/homeassistant/components/rainbird/manifest.json +++ b/homeassistant/components/rainbird/manifest.json @@ -3,7 +3,7 @@ "name": "Rain Bird", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/rainbird", - "requirements": ["pyrainbird==1.1.0"], + "requirements": ["pyrainbird==2.0.0"], "codeowners": ["@konikvranik", "@allenporter"], "iot_class": "local_polling", "loggers": ["pyrainbird"] diff --git a/requirements_all.txt b/requirements_all.txt index c28ee421fa..9f2558e5aa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1896,7 +1896,7 @@ pyqwikswitch==0.93 pyrail==0.0.3 # homeassistant.components.rainbird -pyrainbird==1.1.0 +pyrainbird==2.0.0 # homeassistant.components.recswitch pyrecswitch==1.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 25409f3a69..c3b2ad9d2b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1367,7 +1367,7 @@ pyps4-2ndscreen==1.3.1 pyqwikswitch==0.93 # homeassistant.components.rainbird -pyrainbird==1.1.0 +pyrainbird==2.0.0 # homeassistant.components.risco pyrisco==0.5.7 From 8e8a170121d8279354489125f169e6161274d71d Mon Sep 17 00:00:00 2001 From: shbatm Date: Tue, 7 Feb 2023 13:03:27 -0600 Subject: [PATCH 0155/2810] Bump PyISY to 3.1.13, check portal for network buttons (#87650) --- homeassistant/components/isy994/__init__.py | 8 +++++--- homeassistant/components/isy994/const.py | 1 - homeassistant/components/isy994/manifest.json | 2 +- homeassistant/components/isy994/services.py | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 10 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index c9e4f6ed16..3612e87f8e 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -7,6 +7,7 @@ from urllib.parse import urlparse from aiohttp import CookieJar import async_timeout from pyisy import ISY, ISYConnectionError, ISYInvalidAuthError, ISYResponseParseError +from pyisy.constants import CONFIG_NETWORKING, CONFIG_PORTAL import voluptuous as vol from homeassistant import config_entries @@ -43,7 +44,6 @@ from .const import ( ISY_CONF_FIRMWARE, ISY_CONF_MODEL, ISY_CONF_NAME, - ISY_CONF_NETWORKING, MANUFACTURER, PLATFORMS, SCHEME_HTTP, @@ -220,9 +220,11 @@ async def async_setup_entry( numbers = isy_data.variables[Platform.NUMBER] for vtype, _, vid in isy.variables.children: numbers.append(isy.variables[vtype][vid]) - if isy.conf[ISY_CONF_NETWORKING]: + if ( + isy.conf[CONFIG_NETWORKING] or isy.conf[CONFIG_PORTAL] + ) and isy.networking.nobjs: isy_data.devices[CONF_NETWORK] = _create_service_device_info( - isy, name=ISY_CONF_NETWORKING, unique_id=CONF_NETWORK + isy, name=CONFIG_NETWORKING, unique_id=CONF_NETWORK ) for resource in isy.networking.nobjs: isy_data.net_resources.append(resource) diff --git a/homeassistant/components/isy994/const.py b/homeassistant/components/isy994/const.py index 95e68c5fa1..37ae1a82b9 100644 --- a/homeassistant/components/isy994/const.py +++ b/homeassistant/components/isy994/const.py @@ -118,7 +118,6 @@ SUPPORTED_BIN_SENS_CLASSES = ["moisture", "opening", "motion", "climate"] # (they can turn off, and report their state) ISY_GROUP_PLATFORM = Platform.SWITCH -ISY_CONF_NETWORKING = "Networking Module" ISY_CONF_UUID = "uuid" ISY_CONF_NAME = "name" ISY_CONF_MODEL = "model" diff --git a/homeassistant/components/isy994/manifest.json b/homeassistant/components/isy994/manifest.json index 8fa77cd126..b24167e0c7 100644 --- a/homeassistant/components/isy994/manifest.json +++ b/homeassistant/components/isy994/manifest.json @@ -3,7 +3,7 @@ "name": "Universal Devices ISY/IoX", "integration_type": "hub", "documentation": "https://www.home-assistant.io/integrations/isy994", - "requirements": ["pyisy==3.1.11"], + "requirements": ["pyisy==3.1.13"], "codeowners": ["@bdraco", "@shbatm"], "config_flow": true, "ssdp": [ diff --git a/homeassistant/components/isy994/services.py b/homeassistant/components/isy994/services.py index 759ebfbde0..05e0425c3f 100644 --- a/homeassistant/components/isy994/services.py +++ b/homeassistant/components/isy994/services.py @@ -23,7 +23,7 @@ import homeassistant.helpers.entity_registry as er from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.service import entity_service_call -from .const import _LOGGER, CONF_NETWORK, DOMAIN, ISY_CONF_NAME, ISY_CONF_NETWORKING +from .const import _LOGGER, CONF_NETWORK, DOMAIN, ISY_CONF_NAME from .util import _async_cleanup_registry_entries # Common Services for All Platforms: @@ -233,7 +233,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 isy = isy_data.root if isy_name and isy_name != isy.conf[ISY_CONF_NAME]: continue - if isy.networking is None or not isy.conf[ISY_CONF_NETWORKING]: + if isy.networking is None: continue command = None if address: diff --git a/requirements_all.txt b/requirements_all.txt index 9f2558e5aa..2c7b26cd92 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1702,7 +1702,7 @@ pyirishrail==0.0.2 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.1.11 +pyisy==3.1.13 # homeassistant.components.itach pyitachip2ir==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c3b2ad9d2b..91445d7ad5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1221,7 +1221,7 @@ pyiqvia==2022.04.0 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.1.11 +pyisy==3.1.13 # homeassistant.components.kaleidescape pykaleidescape==1.0.1 From 640f5f41cc1f71fff3af39bd2781d94ba9311e11 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 8 Feb 2023 22:38:44 +0100 Subject: [PATCH 0156/2810] Netgear ssdp discovery abort if no serial (#87532) fixes undefined --- .../components/netgear/config_flow.py | 3 +++ homeassistant/components/netgear/strings.json | 4 +++- tests/components/netgear/test_config_flow.py | 22 ++++++++++++++++++- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/netgear/config_flow.py b/homeassistant/components/netgear/config_flow.py index 9024d0510d..024c2d9d6d 100644 --- a/homeassistant/components/netgear/config_flow.py +++ b/homeassistant/components/netgear/config_flow.py @@ -135,6 +135,9 @@ class NetgearFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.debug("Netgear ssdp discovery info: %s", discovery_info) + if ssdp.ATTR_UPNP_SERIAL not in discovery_info.upnp: + return self.async_abort(reason="no_serial") + await self.async_set_unique_id(discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]) self._abort_if_unique_id_configured(updates=updated_data) diff --git a/homeassistant/components/netgear/strings.json b/homeassistant/components/netgear/strings.json index 3585d1e613..d58c4878f6 100644 --- a/homeassistant/components/netgear/strings.json +++ b/homeassistant/components/netgear/strings.json @@ -14,7 +14,9 @@ "config": "Connection or login error: please check your configuration" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "not_ipv4_address": "No IPv4 address in ssdp discovery information", + "no_serial": "No serial number in ssdp discovery information" } }, "options": { diff --git a/tests/components/netgear/test_config_flow.py b/tests/components/netgear/test_config_flow.py index 69dc57b1d2..28ff93d240 100644 --- a/tests/components/netgear/test_config_flow.py +++ b/tests/components/netgear/test_config_flow.py @@ -21,6 +21,7 @@ from homeassistant.const import ( CONF_SSL, CONF_USERNAME, ) +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -235,7 +236,26 @@ async def test_ssdp_already_configured(hass): assert result["reason"] == "already_configured" -async def test_ssdp_ipv6(hass): +async def test_ssdp_no_serial(hass: HomeAssistant) -> None: + """Test ssdp abort when the ssdp info does not include a serial number.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location=SSDP_URL, + upnp={ + ssdp.ATTR_UPNP_MODEL_NUMBER: "RBR20", + ssdp.ATTR_UPNP_PRESENTATION_URL: URL, + }, + ), + ) + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "no_serial" + + +async def test_ssdp_ipv6(hass: HomeAssistant) -> None: """Test ssdp abort when using a ipv6 address.""" MockConfigEntry( domain=DOMAIN, From 0a69f825d27e94206a1fbeb643d50f44be49a757 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 8 Feb 2023 12:23:27 +0100 Subject: [PATCH 0157/2810] Reolink unregistered webhook on unexpected error (#87538) --- homeassistant/components/reolink/__init__.py | 3 ++ homeassistant/components/reolink/host.py | 43 ++++++++++---------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index f15ce90427..aa9e623aa6 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -54,6 +54,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b raise ConfigEntryNotReady( f"Error while trying to setup {host.api.host}:{host.api.port}: {str(err)}" ) from err + except Exception: # pylint: disable=broad-except + await host.stop() + raise config_entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, host.stop) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index e44623e1a1..d552c8daef 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -138,24 +138,27 @@ class ReolinkHost: async def disconnect(self): """Disconnect from the API, so the connection will be released.""" - await self._api.unsubscribe() - try: - await self._api.logout() - except aiohttp.ClientConnectorError as err: + await self._api.unsubscribe() + except ( + aiohttp.ClientConnectorError, + asyncio.TimeoutError, + ReolinkError, + ) as err: _LOGGER.error( - "Reolink connection error while logging out for host %s:%s: %s", + "Reolink error while unsubscribing from host %s:%s: %s", self._api.host, self._api.port, str(err), ) - except asyncio.TimeoutError: - _LOGGER.error( - "Reolink connection timeout while logging out for host %s:%s", - self._api.host, - self._api.port, - ) - except ReolinkError as err: + + try: + await self._api.logout() + except ( + aiohttp.ClientConnectorError, + asyncio.TimeoutError, + ReolinkError, + ) as err: _LOGGER.error( "Reolink error while logging out for host %s:%s: %s", self._api.host, @@ -165,13 +168,13 @@ class ReolinkHost: async def stop(self, event=None): """Disconnect the API.""" - await self.unregister_webhook() + self.unregister_webhook() await self.disconnect() async def subscribe(self) -> None: """Subscribe to motion events and register the webhook as a callback.""" if self.webhook_id is None: - await self.register_webhook() + self.register_webhook() if self._api.subscribed: _LOGGER.debug( @@ -248,7 +251,7 @@ class ReolinkHost: self._api.host, ) - async def register_webhook(self) -> None: + def register_webhook(self) -> None: """Register the webhook for motion events.""" self.webhook_id = f"{DOMAIN}_{self.unique_id.replace(':', '')}_ONVIF" event_id = self.webhook_id @@ -263,8 +266,7 @@ class ReolinkHost: try: base_url = get_url(self._hass, prefer_external=True) except NoURLAvailableError as err: - webhook.async_unregister(self._hass, event_id) - self.webhook_id = None + self.unregister_webhook() raise ReolinkWebhookException( f"Error registering URL for webhook {event_id}: " "HomeAssistant URL is not available" @@ -275,11 +277,10 @@ class ReolinkHost: _LOGGER.debug("Registered webhook: %s", event_id) - async def unregister_webhook(self): + def unregister_webhook(self): """Unregister the webhook for motion events.""" - if self.webhook_id: - _LOGGER.debug("Unregistering webhook %s", self.webhook_id) - webhook.async_unregister(self._hass, self.webhook_id) + _LOGGER.debug("Unregistering webhook %s", self.webhook_id) + webhook.async_unregister(self._hass, self.webhook_id) self.webhook_id = None async def handle_webhook( From f2697b3e9662a6185df59ad8b64c60610ba74d3e Mon Sep 17 00:00:00 2001 From: jan iversen Date: Thu, 9 Feb 2023 21:35:44 +0100 Subject: [PATCH 0158/2810] Correct sensor restore entity in modbus (#87563) * Correct sensor restore entity. * Review comments. --- homeassistant/components/modbus/sensor.py | 16 ++++++++++------ tests/components/modbus/test_sensor.py | 2 +- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index 04b986e41b..ca8246577f 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -5,7 +5,11 @@ from datetime import datetime import logging from typing import Any -from homeassistant.components.sensor import CONF_STATE_CLASS, SensorEntity +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + RestoreSensor, + SensorEntity, +) from homeassistant.const import ( CONF_NAME, CONF_SENSORS, @@ -14,7 +18,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -53,7 +56,7 @@ async def async_setup_platform( async_add_entities(sensors) -class ModbusRegisterSensor(BaseStructPlatform, RestoreEntity, SensorEntity): +class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): """Modbus register sensor.""" def __init__( @@ -90,8 +93,9 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreEntity, SensorEntity): async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await self.async_base_added_to_hass() - if state := await self.async_get_last_state(): - self._attr_native_value = state.state + state = await self.async_get_last_sensor_data() + if state: + self._attr_native_value = state.native_value async def async_update(self, now: datetime | None = None) -> None: """Update the state of the sensor.""" @@ -135,7 +139,7 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreEntity, SensorEntity): class SlaveSensor( CoordinatorEntity[DataUpdateCoordinator[list[int] | None]], - RestoreEntity, + RestoreSensor, SensorEntity, ): """Modbus slave register sensor.""" diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 4a6495d5b4..1369f09005 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -855,7 +855,7 @@ async def test_wrap_sensor(hass, mock_do_cycle, expected): @pytest.mark.parametrize( "mock_test_state", - [(State(ENTITY_ID, "117"), State(f"{ENTITY_ID}_1", "119"))], + [(State(ENTITY_ID, "unknown"), State(f"{ENTITY_ID}_1", "119"))], indirect=True, ) @pytest.mark.parametrize( From 03ba7672ead4d6ba41b020592487cd6c56ea29c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=2E=20Bal=C3=A1zs?= Date: Wed, 8 Feb 2023 22:28:47 +0100 Subject: [PATCH 0159/2810] Upgrading volvooncall to 0.10.2 (#87572) * Upgrading volvooncall to 0.10.2 This release fixes the incorrect remaining time of charging the battery * updating volvooncall test requirements * Updating volvooncall requirements --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/volvooncall/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/volvooncall/manifest.json b/homeassistant/components/volvooncall/manifest.json index b9da3657ae..6f4724fbf0 100644 --- a/homeassistant/components/volvooncall/manifest.json +++ b/homeassistant/components/volvooncall/manifest.json @@ -2,7 +2,7 @@ "domain": "volvooncall", "name": "Volvo On Call", "documentation": "https://www.home-assistant.io/integrations/volvooncall", - "requirements": ["volvooncall==0.10.1"], + "requirements": ["volvooncall==0.10.2"], "codeowners": ["@molobrakos"], "iot_class": "cloud_polling", "loggers": ["geopy", "hbmqtt", "volvooncall"], diff --git a/requirements_all.txt b/requirements_all.txt index 2c7b26cd92..e6450ee8b0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2576,7 +2576,7 @@ vilfo-api-client==0.3.2 volkszaehler==0.4.0 # homeassistant.components.volvooncall -volvooncall==0.10.1 +volvooncall==0.10.2 # homeassistant.components.verisure vsure==1.8.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 91445d7ad5..d82e4e5547 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1819,7 +1819,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.3.2 # homeassistant.components.volvooncall -volvooncall==0.10.1 +volvooncall==0.10.2 # homeassistant.components.verisure vsure==1.8.1 From 151b33a4ab6e781137aee957eb2a6da6ae83f325 Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Thu, 9 Feb 2023 23:42:08 +0100 Subject: [PATCH 0160/2810] Keep sleepy xiaomi-ble devices that don't broadcast regularly available (#87654) Co-authored-by: J. Nick Koston fixes undefined --- .../components/xiaomi_ble/__init__.py | 3 +- .../components/xiaomi_ble/binary_sensor.py | 11 ++ .../components/xiaomi_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../xiaomi_ble/test_binary_sensor.py | 149 ++++++++++++++++-- 6 files changed, 149 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/xiaomi_ble/__init__.py b/homeassistant/components/xiaomi_ble/__init__.py index 372afe4b3c..3930c50c70 100644 --- a/homeassistant/components/xiaomi_ble/__init__.py +++ b/homeassistant/components/xiaomi_ble/__init__.py @@ -3,8 +3,7 @@ from __future__ import annotations import logging -from xiaomi_ble import SensorUpdate, XiaomiBluetoothDeviceData -from xiaomi_ble.parser import EncryptionScheme +from xiaomi_ble import EncryptionScheme, SensorUpdate, XiaomiBluetoothDeviceData from homeassistant import config_entries from homeassistant.components.bluetooth import ( diff --git a/homeassistant/components/xiaomi_ble/binary_sensor.py b/homeassistant/components/xiaomi_ble/binary_sensor.py index 1de3afff53..3d7bdfd0b4 100644 --- a/homeassistant/components/xiaomi_ble/binary_sensor.py +++ b/homeassistant/components/xiaomi_ble/binary_sensor.py @@ -1,6 +1,7 @@ """Support for Xiaomi binary sensors.""" from __future__ import annotations +from xiaomi_ble import SLEEPY_DEVICE_MODELS from xiaomi_ble.parser import ( BinarySensorDeviceClass as XiaomiBinarySensorDeviceClass, ExtendedBinarySensorDeviceClass, @@ -19,6 +20,7 @@ from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothProcessorCoordinator, PassiveBluetoothProcessorEntity, ) +from homeassistant.const import ATTR_MODEL from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info @@ -128,3 +130,12 @@ class XiaomiBluetoothSensorEntity( def is_on(self) -> bool | None: """Return the native value.""" return self.processor.entity_data.get(self.entity_key) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + if self.device_info and self.device_info[ATTR_MODEL] in SLEEPY_DEVICE_MODELS: + # These devices sleep for an indeterminate amount of time + # so there is no way to track their availability. + return True + return super().available diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index f390400341..9fb35db324 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -14,7 +14,7 @@ } ], "dependencies": ["bluetooth_adapters"], - "requirements": ["xiaomi-ble==0.16.1"], + "requirements": ["xiaomi-ble==0.16.3"], "codeowners": ["@Jc2k", "@Ernst79"], "iot_class": "local_push" } diff --git a/requirements_all.txt b/requirements_all.txt index e6450ee8b0..b046166e4f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2637,7 +2637,7 @@ xbox-webapi==2.0.11 xboxapi==2.0.1 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.16.1 +xiaomi-ble==0.16.3 # homeassistant.components.knx xknx==2.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d82e4e5547..b6af545e75 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1862,7 +1862,7 @@ wolf_smartset==0.1.11 xbox-webapi==2.0.11 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.16.1 +xiaomi-ble==0.16.3 # homeassistant.components.knx xknx==2.3.0 diff --git a/tests/components/xiaomi_ble/test_binary_sensor.py b/tests/components/xiaomi_ble/test_binary_sensor.py index 5389a2987f..dae1569ff3 100644 --- a/tests/components/xiaomi_ble/test_binary_sensor.py +++ b/tests/components/xiaomi_ble/test_binary_sensor.py @@ -1,12 +1,28 @@ """Test Xiaomi binary sensors.""" +from datetime import timedelta +import time +from unittest.mock import patch + +from homeassistant.components.bluetooth import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, +) from homeassistant.components.xiaomi_ble.const import DOMAIN -from homeassistant.const import ATTR_FRIENDLY_NAME +from homeassistant.const import ( + ATTR_FRIENDLY_NAME, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) +from homeassistant.util import dt as dt_util from . import make_advertisement -from tests.common import MockConfigEntry -from tests.components.bluetooth import inject_bluetooth_service_info_bleak +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.bluetooth import ( + inject_bluetooth_service_info_bleak, + patch_all_discovered_devices, +) async def test_door_problem_sensors(hass): @@ -34,19 +50,19 @@ async def test_door_problem_sensors(hass): door_sensor = hass.states.get("binary_sensor.door_lock_be98_door") door_sensor_attribtes = door_sensor.attributes - assert door_sensor.state == "off" + assert door_sensor.state == STATE_OFF assert door_sensor_attribtes[ATTR_FRIENDLY_NAME] == "Door Lock BE98 Door" door_left_open = hass.states.get("binary_sensor.door_lock_be98_door_left_open") door_left_open_attribtes = door_left_open.attributes - assert door_left_open.state == "off" + assert door_left_open.state == STATE_OFF assert ( door_left_open_attribtes[ATTR_FRIENDLY_NAME] == "Door Lock BE98 Door left open" ) pry_the_door = hass.states.get("binary_sensor.door_lock_be98_pry_the_door") pry_the_door_attribtes = pry_the_door.attributes - assert pry_the_door.state == "off" + assert pry_the_door.state == STATE_OFF assert pry_the_door_attribtes[ATTR_FRIENDLY_NAME] == "Door Lock BE98 Pry the door" assert await hass.config_entries.async_unload(entry.entry_id) @@ -77,12 +93,12 @@ async def test_light_motion(hass): motion_sensor = hass.states.get("binary_sensor.nightlight_9321_motion") motion_sensor_attribtes = motion_sensor.attributes - assert motion_sensor.state == "on" + assert motion_sensor.state == STATE_ON assert motion_sensor_attribtes[ATTR_FRIENDLY_NAME] == "Nightlight 9321 Motion" light_sensor = hass.states.get("binary_sensor.nightlight_9321_light") light_sensor_attribtes = light_sensor.attributes - assert light_sensor.state == "off" + assert light_sensor.state == STATE_OFF assert light_sensor_attribtes[ATTR_FRIENDLY_NAME] == "Nightlight 9321 Light" assert await hass.config_entries.async_unload(entry.entry_id) @@ -116,7 +132,7 @@ async def test_moisture(hass): sensor = hass.states.get("binary_sensor.smart_flower_pot_3e7a_moisture") sensor_attr = sensor.attributes - assert sensor.state == "on" + assert sensor.state == STATE_ON assert sensor_attr[ATTR_FRIENDLY_NAME] == "Smart Flower Pot 3E7A Moisture" assert await hass.config_entries.async_unload(entry.entry_id) @@ -148,12 +164,12 @@ async def test_opening(hass): opening_sensor = hass.states.get("binary_sensor.door_window_sensor_e567_opening") opening_sensor_attribtes = opening_sensor.attributes - assert opening_sensor.state == "on" + + assert opening_sensor.state == STATE_ON assert ( opening_sensor_attribtes[ATTR_FRIENDLY_NAME] == "Door/Window Sensor E567 Opening" ) - assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() @@ -183,7 +199,7 @@ async def test_opening_problem_sensors(hass): opening_sensor = hass.states.get("binary_sensor.door_window_sensor_e567_opening") opening_sensor_attribtes = opening_sensor.attributes - assert opening_sensor.state == "off" + assert opening_sensor.state == STATE_OFF assert ( opening_sensor_attribtes[ATTR_FRIENDLY_NAME] == "Door/Window Sensor E567 Opening" @@ -193,7 +209,7 @@ async def test_opening_problem_sensors(hass): "binary_sensor.door_window_sensor_e567_door_left_open" ) door_left_open_attribtes = door_left_open.attributes - assert door_left_open.state == "off" + assert door_left_open.state == STATE_OFF assert ( door_left_open_attribtes[ATTR_FRIENDLY_NAME] == "Door/Window Sensor E567 Door left open" @@ -203,7 +219,7 @@ async def test_opening_problem_sensors(hass): "binary_sensor.door_window_sensor_e567_device_forcibly_removed" ) device_forcibly_removed_attribtes = device_forcibly_removed.attributes - assert device_forcibly_removed.state == "off" + assert device_forcibly_removed.state == STATE_OFF assert ( device_forcibly_removed_attribtes[ATTR_FRIENDLY_NAME] == "Door/Window Sensor E567 Device forcibly removed" @@ -238,8 +254,111 @@ async def test_smoke(hass): smoke_sensor = hass.states.get("binary_sensor.thermometer_9cbc_smoke") smoke_sensor_attribtes = smoke_sensor.attributes - assert smoke_sensor.state == "on" + assert smoke_sensor.state == STATE_ON assert smoke_sensor_attribtes[ATTR_FRIENDLY_NAME] == "Thermometer 9CBC Smoke" assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +async def test_unavailable(hass): + """Test normal device goes to unavailable after 60 minutes.""" + start_monotonic = time.monotonic() + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="A4:C1:38:66:E5:67", + data={"bindkey": "0fdcc30fe9289254876b5ef7c11ef1f0"}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + inject_bluetooth_service_info_bleak( + hass, + make_advertisement( + "A4:C1:38:66:E5:67", + b"XY\x89\x18\x9ag\xe5f8\xc1\xa4\x9d\xd9z\xf3&\x00\x00\xc8\xa6\x0b\xd5", + ), + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + + opening_sensor = hass.states.get("binary_sensor.door_window_sensor_e567_opening") + + assert opening_sensor.state == STATE_ON + + # Fastforward time without BLE advertisements + monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 + + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now, + ), patch_all_discovered_devices([]): + async_fire_time_changed( + hass, + dt_util.utcnow() + + timedelta(seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1), + ) + await hass.async_block_till_done() + + opening_sensor = hass.states.get("binary_sensor.door_window_sensor_e567_opening") + + # Normal devices should go to unavailable + assert opening_sensor.state == STATE_UNAVAILABLE + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_sleepy_device(hass): + """Test sleepy device does not go to unavailable after 60 minutes.""" + start_monotonic = time.monotonic() + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="A4:C1:38:66:E5:67", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + inject_bluetooth_service_info_bleak( + hass, + make_advertisement( + "A4:C1:38:66:E5:67", + b"@0\xd6\x03$\x19\x10\x01\x00", + ), + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + + opening_sensor = hass.states.get("binary_sensor.door_window_sensor_e567_opening") + + assert opening_sensor.state == STATE_ON + + # Fastforward time without BLE advertisements + monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 + + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now, + ), patch_all_discovered_devices([]): + async_fire_time_changed( + hass, + dt_util.utcnow() + + timedelta(seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1), + ) + await hass.async_block_till_done() + + opening_sensor = hass.states.get("binary_sensor.door_window_sensor_e567_opening") + + # Sleepy devices should keep their state over time + assert opening_sensor.state == STATE_ON + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() From 1603a0b0372309c00ea9f0f8a81e4e931b87398d Mon Sep 17 00:00:00 2001 From: Luke Date: Tue, 7 Feb 2023 21:01:36 -0500 Subject: [PATCH 0161/2810] Bump oralb-ble to 0.17.5 (#87657) fixes undefined --- homeassistant/components/oralb/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/oralb/manifest.json b/homeassistant/components/oralb/manifest.json index e2f1749eb7..6359458797 100644 --- a/homeassistant/components/oralb/manifest.json +++ b/homeassistant/components/oralb/manifest.json @@ -8,7 +8,7 @@ "manufacturer_id": 220 } ], - "requirements": ["oralb-ble==0.17.4"], + "requirements": ["oralb-ble==0.17.5"], "dependencies": ["bluetooth_adapters"], "codeowners": ["@bdraco", "@Lash-L"], "iot_class": "local_push" diff --git a/requirements_all.txt b/requirements_all.txt index b046166e4f..713883113a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1299,7 +1299,7 @@ openwrt-luci-rpc==1.1.11 openwrt-ubus-rpc==0.0.2 # homeassistant.components.oralb -oralb-ble==0.17.4 +oralb-ble==0.17.5 # homeassistant.components.oru oru==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b6af545e75..b8ef8d603c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -947,7 +947,7 @@ openai==0.26.2 openerz-api==0.2.0 # homeassistant.components.oralb -oralb-ble==0.17.4 +oralb-ble==0.17.5 # homeassistant.components.ovo_energy ovoenergy==1.2.0 From a779e43e2affc77889ab139ab76b0c2c3f07ede4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Feb 2023 19:30:16 -0600 Subject: [PATCH 0162/2810] Bump cryptography to 39.0.1 for CVE-2023-23931 (#87658) Bump cryptography to 39.0.1 CVE-2023-23931 --- 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 8dc60525be..8ad9a144a1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -17,7 +17,7 @@ bluetooth-auto-recovery==1.0.3 bluetooth-data-tools==0.3.1 certifi>=2021.5.30 ciso8601==2.3.0 -cryptography==39.0.0 +cryptography==39.0.1 dbus-fast==1.84.0 fnvhash==0.1.0 hass-nabucasa==0.61.0 diff --git a/pyproject.toml b/pyproject.toml index 6253e20411..216f553946 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ dependencies = [ "lru-dict==1.1.8", "PyJWT==2.5.0", # PyJWT has loose dependency. We want the latest one. - "cryptography==39.0.0", + "cryptography==39.0.1", # pyOpenSSL 23.0.0 is required to work with cryptography 39+ "pyOpenSSL==23.0.0", "orjson==3.8.5", diff --git a/requirements.txt b/requirements.txt index c423388bbb..7d05e1bb2e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,7 +16,7 @@ ifaddr==0.1.7 jinja2==3.1.2 lru-dict==1.1.8 PyJWT==2.5.0 -cryptography==39.0.0 +cryptography==39.0.1 pyOpenSSL==23.0.0 orjson==3.8.5 pip>=21.0,<22.4 From 09970a083bea01f981b08bf630ba7011463d6397 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Feb 2023 02:35:33 -0600 Subject: [PATCH 0163/2810] Bump yalexs_ble to 1.12.12 to fix reconnect when services fail to resolve (#87664) * Bump yalexs_ble to 1.12.11 to fix reconnect when services fail to resolve changelog: https://github.com/bdraco/yalexs-ble/compare/v1.12.8...v1.12.11 * bump to make it work with esphome proxy as well * empty --- 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 cf00616b65..a74f3f2807 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -2,7 +2,7 @@ "domain": "august", "name": "August", "documentation": "https://www.home-assistant.io/integrations/august", - "requirements": ["yalexs==1.2.6", "yalexs_ble==1.12.8"], + "requirements": ["yalexs==1.2.6", "yalexs_ble==1.12.12"], "codeowners": ["@bdraco"], "dhcp": [ { diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 866f5eca6b..df9827afcb 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -3,7 +3,7 @@ "name": "Yale Access Bluetooth", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", - "requirements": ["yalexs-ble==1.12.8"], + "requirements": ["yalexs-ble==1.12.12"], "dependencies": ["bluetooth_adapters"], "codeowners": ["@bdraco"], "bluetooth": [ diff --git a/requirements_all.txt b/requirements_all.txt index 713883113a..2cb981ed6b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2657,13 +2657,13 @@ xs1-api-client==3.0.0 yalesmartalarmclient==0.3.9 # homeassistant.components.yalexs_ble -yalexs-ble==1.12.8 +yalexs-ble==1.12.12 # homeassistant.components.august yalexs==1.2.6 # homeassistant.components.august -yalexs_ble==1.12.8 +yalexs_ble==1.12.12 # homeassistant.components.yeelight yeelight==0.7.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b8ef8d603c..ea54e510aa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1879,13 +1879,13 @@ xmltodict==0.13.0 yalesmartalarmclient==0.3.9 # homeassistant.components.yalexs_ble -yalexs-ble==1.12.8 +yalexs-ble==1.12.12 # homeassistant.components.august yalexs==1.2.6 # homeassistant.components.august -yalexs_ble==1.12.8 +yalexs_ble==1.12.12 # homeassistant.components.yeelight yeelight==0.7.10 From 8780db82e54912c9909492d483ac08f6095aa533 Mon Sep 17 00:00:00 2001 From: John Pettitt Date: Thu, 9 Feb 2023 01:36:05 -0800 Subject: [PATCH 0164/2810] Fix bad battery sense in ambient_station (#87668) --- .../components/ambient_station/binary_sensor.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ambient_station/binary_sensor.py b/homeassistant/components/ambient_station/binary_sensor.py index 67a54906e9..486651e205 100644 --- a/homeassistant/components/ambient_station/binary_sensor.py +++ b/homeassistant/components/ambient_station/binary_sensor.py @@ -168,28 +168,28 @@ BINARY_SENSOR_DESCRIPTIONS = ( name="Leak detector battery 1", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, - on_state=0, + on_state=1, ), AmbientBinarySensorDescription( key=TYPE_BATT_LEAK2, name="Leak detector battery 2", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, - on_state=0, + on_state=1, ), AmbientBinarySensorDescription( key=TYPE_BATT_LEAK3, name="Leak detector battery 3", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, - on_state=0, + on_state=1, ), AmbientBinarySensorDescription( key=TYPE_BATT_LEAK4, name="Leak detector battery 4", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, - on_state=0, + on_state=1, ), AmbientBinarySensorDescription( key=TYPE_BATT_SM1, @@ -273,7 +273,7 @@ BINARY_SENSOR_DESCRIPTIONS = ( name="Lightning detector battery", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, - on_state=0, + on_state=1, ), AmbientBinarySensorDescription( key=TYPE_LEAK1, From f8075f2dfd1c49944b138383fdeafc7353b28d70 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 8 Feb 2023 22:52:23 +0100 Subject: [PATCH 0165/2810] Bump reolink-aio to 0.4.0 (#87733) bump reolink-aio to 0.4.0 --- homeassistant/components/reolink/host.py | 9 ++++----- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index d552c8daef..983ab293f6 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -68,8 +68,6 @@ class ReolinkHost: async def async_init(self) -> None: """Connect to Reolink host.""" - self._api.expire_session() - await self._api.get_host_data() if self._api.mac_address is None: @@ -301,9 +299,10 @@ class ReolinkHost: ) return - channel = await self._api.ONVIF_event_callback(data) + channels = await self._api.ONVIF_event_callback(data) - if channel is None: + if channels is None: async_dispatcher_send(hass, f"{webhook_id}_all", {}) else: - async_dispatcher_send(hass, f"{webhook_id}_{channel}", {}) + for channel in channels: + async_dispatcher_send(hass, f"{webhook_id}_{channel}", {}) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 72ac70ef18..0519960945 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -3,7 +3,7 @@ "name": "Reolink IP NVR/camera", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/reolink", - "requirements": ["reolink-aio==0.3.4"], + "requirements": ["reolink-aio==0.4.0"], "dependencies": ["webhook"], "codeowners": ["@starkillerOG"], "iot_class": "local_polling", diff --git a/requirements_all.txt b/requirements_all.txt index 2cb981ed6b..a740b7ba1b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2227,7 +2227,7 @@ regenmaschine==2022.11.0 renault-api==0.1.11 # homeassistant.components.reolink -reolink-aio==0.3.4 +reolink-aio==0.4.0 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ea54e510aa..63c75495d4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1572,7 +1572,7 @@ regenmaschine==2022.11.0 renault-api==0.1.11 # homeassistant.components.reolink -reolink-aio==0.3.4 +reolink-aio==0.4.0 # homeassistant.components.python_script restrictedpython==6.0 From ec755b34f06aca69c2fe8f39d7a23c8b5aca0c9b Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Fri, 10 Feb 2023 07:38:17 +1100 Subject: [PATCH 0166/2810] Bump aiolifx to 0.8.9 (#87790) Signed-off-by: Avi Miller Co-authored-by: J. Nick Koston --- homeassistant/components/lifx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index f4247f97ec..a12809e3af 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lifx", "requirements": [ - "aiolifx==0.8.7", + "aiolifx==0.8.9", "aiolifx_effects==0.3.1", "aiolifx_themes==0.4.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index a740b7ba1b..6f2b7474b7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -193,7 +193,7 @@ aiokafka==0.7.2 aiokef==0.2.16 # homeassistant.components.lifx -aiolifx==0.8.7 +aiolifx==0.8.9 # homeassistant.components.lifx aiolifx_effects==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 63c75495d4..43e604d5ab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -174,7 +174,7 @@ aioimaplib==1.0.1 aiokafka==0.7.2 # homeassistant.components.lifx -aiolifx==0.8.7 +aiolifx==0.8.9 # homeassistant.components.lifx aiolifx_effects==0.3.1 From 250f032a0d5c928ca944c9faea083dccf42652c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Feb 2023 15:12:40 -0600 Subject: [PATCH 0167/2810] Ensure recorder still shuts down if the final commit fails (#87799) --- homeassistant/components/recorder/core.py | 17 ++++++---- .../components/recorder/run_history.py | 10 ++++-- tests/components/recorder/test_run_history.py | 34 ++++++++++++++++--- 3 files changed, 47 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 497ae364b6..d26d702430 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -988,8 +988,10 @@ class Recorder(threading.Thread): def _handle_sqlite_corruption(self) -> None: """Handle the sqlite3 database being corrupt.""" - self._close_event_session() - self._close_connection() + try: + self._close_event_session() + finally: + self._close_connection() move_away_broken_database(dburl_to_path(self.db_url)) self.run_history.reset() self._setup_recorder() @@ -1212,18 +1214,21 @@ class Recorder(threading.Thread): """End the recorder session.""" if self.event_session is None: return - try: + if self.run_history.active: self.run_history.end(self.event_session) + try: self._commit_event_session_or_retry() - self.event_session.close() except Exception as err: # pylint: disable=broad-except _LOGGER.exception("Error saving the event session during shutdown: %s", err) + self.event_session.close() self.run_history.clear() def _shutdown(self) -> None: """Save end time for current run.""" self.hass.add_job(self._async_stop_listeners) self._stop_executor() - self._end_session() - self._close_connection() + try: + self._end_session() + finally: + self._close_connection() diff --git a/homeassistant/components/recorder/run_history.py b/homeassistant/components/recorder/run_history.py index 02b2df066b..63744db0b5 100644 --- a/homeassistant/components/recorder/run_history.py +++ b/homeassistant/components/recorder/run_history.py @@ -72,6 +72,11 @@ class RunHistory: start=self.recording_start, created=dt_util.utcnow() ) + @property + def active(self) -> bool: + """Return if a run is active.""" + return self._current_run_info is not None + def get(self, start: datetime) -> RecorderRuns | None: """Return the recorder run that started before or at start. @@ -141,6 +146,5 @@ class RunHistory: Must run in the recorder thread. """ - assert self._current_run_info is not None - assert self._current_run_info.end is not None - self._current_run_info = None + if self._current_run_info: + self._current_run_info = None diff --git a/tests/components/recorder/test_run_history.py b/tests/components/recorder/test_run_history.py index 3b5bd7dda6..84dab78d8c 100644 --- a/tests/components/recorder/test_run_history.py +++ b/tests/components/recorder/test_run_history.py @@ -1,12 +1,16 @@ """Test run history.""" from datetime import timedelta +from unittest.mock import patch from homeassistant.components import recorder from homeassistant.components.recorder.db_schema import RecorderRuns from homeassistant.components.recorder.models import process_timestamp +from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util +from tests.common import SetupRecorderInstanceT + async def test_run_history(recorder_mock, hass): """Test the run history gives the correct run.""" @@ -47,12 +51,32 @@ async def test_run_history(recorder_mock, hass): ) -async def test_run_history_during_schema_migration(recorder_mock, hass): - """Test the run history during schema migration.""" - instance = recorder.get_instance(hass) +async def test_run_history_while_recorder_is_not_yet_started( + async_setup_recorder_instance: SetupRecorderInstanceT, + hass: HomeAssistant, + recorder_db_url: str, +) -> None: + """Test the run history while recorder is not yet started. + + This usually happens during schema migration because + we do not start right away. + """ + # Prevent the run history from starting to ensure + # we can test run_history.current.start returns the expected value + with patch( + "homeassistant.components.recorder.run_history.RunHistory.start", + ): + instance = await async_setup_recorder_instance(hass) run_history = instance.run_history assert run_history.current.start == run_history.recording_start - with instance.get_session() as session: - run_history.start(session) + + def _start_run_history(): + with instance.get_session() as session: + run_history.start(session) + + # Ideally we would run run_history.start in the recorder thread + # but since we mocked it out above, we run it directly here + # via the database executor to avoid blocking the event loop. + await instance.async_add_executor_job(_start_run_history) assert run_history.current.start == run_history.recording_start assert run_history.current.created >= run_history.recording_start From da2c1c114218e1c4de23b717e88dfd758688d49f Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Sun, 12 Feb 2023 04:18:34 +0000 Subject: [PATCH 0168/2810] Bump pyipma to 3.0.6 (#87867) bump pyipma --- homeassistant/components/ipma/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ipma/manifest.json b/homeassistant/components/ipma/manifest.json index 36dca71e95..7f250f6392 100644 --- a/homeassistant/components/ipma/manifest.json +++ b/homeassistant/components/ipma/manifest.json @@ -3,7 +3,7 @@ "name": "Instituto Portugu\u00eas do Mar e Atmosfera (IPMA)", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ipma", - "requirements": ["pyipma==3.0.5"], + "requirements": ["pyipma==3.0.6"], "codeowners": ["@dgomes", "@abmantis"], "iot_class": "cloud_polling", "loggers": ["geopy", "pyipma"] diff --git a/requirements_all.txt b/requirements_all.txt index 6f2b7474b7..f76479ad3f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1687,7 +1687,7 @@ pyinsteon==1.2.0 pyintesishome==1.8.0 # homeassistant.components.ipma -pyipma==3.0.5 +pyipma==3.0.6 # homeassistant.components.ipp pyipp==0.12.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 43e604d5ab..d25ed5540b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1209,7 +1209,7 @@ pyicloud==1.0.0 pyinsteon==1.2.0 # homeassistant.components.ipma -pyipma==3.0.5 +pyipma==3.0.6 # homeassistant.components.ipp pyipp==0.12.1 From e8081c61595cd62f0cce74097e95c9ec937eaaf0 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 11 Feb 2023 16:27:22 +0100 Subject: [PATCH 0169/2810] Fix unbound variable in Group sensor (#87878) * group bound last * Add test --- homeassistant/components/group/sensor.py | 15 ++++++++------ tests/components/group/test_sensor.py | 25 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py index 088678b6c1..6c379832ce 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -137,7 +137,7 @@ async def async_setup_entry( def calc_min( sensor_values: list[tuple[str, float, State]] -) -> tuple[dict[str, str | None], float]: +) -> tuple[dict[str, str | None], float | None]: """Calculate min value.""" val: float | None = None entity_id: str | None = None @@ -153,7 +153,7 @@ def calc_min( def calc_max( sensor_values: list[tuple[str, float, State]] -) -> tuple[dict[str, str | None], float]: +) -> tuple[dict[str, str | None], float | None]: """Calculate max value.""" val: float | None = None entity_id: str | None = None @@ -169,7 +169,7 @@ def calc_max( def calc_mean( sensor_values: list[tuple[str, float, State]] -) -> tuple[dict[str, str | None], float]: +) -> tuple[dict[str, str | None], float | None]: """Calculate mean value.""" result = (sensor_value for _, sensor_value, _ in sensor_values) @@ -179,7 +179,7 @@ def calc_mean( def calc_median( sensor_values: list[tuple[str, float, State]] -) -> tuple[dict[str, str | None], float]: +) -> tuple[dict[str, str | None], float | None]: """Calculate median value.""" result = (sensor_value for _, sensor_value, _ in sensor_values) @@ -189,10 +189,11 @@ def calc_median( def calc_last( sensor_values: list[tuple[str, float, State]] -) -> tuple[dict[str, str | None], float]: +) -> tuple[dict[str, str | None], float | None]: """Calculate last value.""" last_updated: datetime | None = None last_entity_id: str | None = None + last: float | None = None for entity_id, state_f, state in sensor_values: if last_updated is None or state.last_updated > last_updated: last_updated = state.last_updated @@ -227,7 +228,9 @@ def calc_sum( CALC_TYPES: dict[ str, - Callable[[list[tuple[str, float, State]]], tuple[dict[str, str | None], float]], + Callable[ + [list[tuple[str, float, State]]], tuple[dict[str, str | None], float | None] + ], ] = { "min": calc_min, "max": calc_max, diff --git a/tests/components/group/test_sensor.py b/tests/components/group/test_sensor.py index 87dbbccab0..e9d5adb046 100644 --- a/tests/components/group/test_sensor.py +++ b/tests/components/group/test_sensor.py @@ -369,3 +369,28 @@ async def test_sensor_calculated_properties(hass: HomeAssistant) -> None: assert state.attributes.get("device_class") is None assert state.attributes.get("state_class") is None assert state.attributes.get("unit_of_measurement") is None + + +async def test_last_sensor(hass: HomeAssistant) -> None: + """Test the last sensor.""" + config = { + SENSOR_DOMAIN: { + "platform": GROUP_DOMAIN, + "name": "test_last", + "type": "last", + "entities": ["sensor.test_1", "sensor.test_2", "sensor.test_3"], + "unique_id": "very_unique_id_last_sensor", + } + } + + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + entity_ids = config["sensor"]["entities"] + + for entity_id, value in dict(zip(entity_ids, VALUES)).items(): + hass.states.async_set(entity_id, value) + await hass.async_block_till_done() + state = hass.states.get("sensor.test_last") + assert str(float(value)) == state.state + assert entity_id == state.attributes.get("last_entity_id") From 65c8895bf703e49204de2933c6bdc4d6a2767700 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Feb 2023 09:59:51 -0600 Subject: [PATCH 0170/2810] Retrigger Bluetooth discovery when calling async_rediscover_address (#87884) * Retrigger Bluetooth discovery when calling async_rediscover_address * Retrigger Bluetooth discovery when calling async_rediscover_address * tweak --- homeassistant/components/bluetooth/manager.py | 25 +++++++++++++------ tests/components/bluetooth/test_init.py | 2 +- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index 1523f41bf1..5d0a8edabd 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -216,13 +216,7 @@ class BluetoothManager: if address in seen: continue seen.add(address) - for domain in self._integration_matcher.match_domains(service_info): - discovery_flow.async_create_flow( - self.hass, - domain, - {"source": config_entries.SOURCE_BLUETOOTH}, - service_info, - ) + self._async_trigger_matching_discovery(service_info) @hass_callback def async_stop(self, event: Event) -> None: @@ -649,10 +643,27 @@ class BluetoothManager: """Return the last service info for an address.""" return self._get_history_by_type(connectable).get(address) + def _async_trigger_matching_discovery( + self, service_info: BluetoothServiceInfoBleak + ) -> None: + """Trigger discovery for matching domains.""" + for domain in self._integration_matcher.match_domains(service_info): + discovery_flow.async_create_flow( + self.hass, + domain, + {"source": config_entries.SOURCE_BLUETOOTH}, + service_info, + ) + @hass_callback def async_rediscover_address(self, address: str) -> None: """Trigger discovery of devices which have already been seen.""" self._integration_matcher.async_clear_address(address) + if service_info := self._connectable_history.get(address): + self._async_trigger_matching_discovery(service_info) + return + if service_info := self._all_history.get(address): + self._async_trigger_matching_discovery(service_info) def _get_scanners_by_type(self, connectable: bool) -> list[BaseHaScanner]: """Return the scanners by type.""" diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index 7a0a7de844..016da6fc13 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -980,7 +980,7 @@ async def test_rediscovery(hass, mock_bleak_scanner_start, enable_bluetooth): inject_advertisement(hass, switchbot_device, switchbot_adv_2) await hass.async_block_till_done() - assert len(mock_config_flow.mock_calls) == 2 + assert len(mock_config_flow.mock_calls) == 3 assert mock_config_flow.mock_calls[1][1][0] == "switchbot" From cd5042e2c2f7b219df04b036f06118675b9dfe52 Mon Sep 17 00:00:00 2001 From: Florent Thoumie Date: Sun, 12 Feb 2023 00:04:59 -0800 Subject: [PATCH 0171/2810] Fix iaqualink exception handling after switch to httpx (#87898) Fix exception handling after switch to httpx --- homeassistant/components/iaqualink/__init__.py | 9 +++------ homeassistant/components/iaqualink/config_flow.py | 3 ++- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/iaqualink/__init__.py b/homeassistant/components/iaqualink/__init__.py index 146e2c2bab..cbdf909001 100644 --- a/homeassistant/components/iaqualink/__init__.py +++ b/homeassistant/components/iaqualink/__init__.py @@ -7,7 +7,7 @@ from functools import wraps import logging from typing import Any, Concatenate, ParamSpec, TypeVar -import aiohttp.client_exceptions +import httpx from iaqualink.client import AqualinkClient from iaqualink.device import ( AqualinkBinarySensor, @@ -77,10 +77,7 @@ async def async_setup_entry( # noqa: C901 _LOGGER.error("Failed to login: %s", login_exception) await aqualink.close() return False - except ( - asyncio.TimeoutError, - aiohttp.client_exceptions.ClientConnectorError, - ) as aio_exception: + except (asyncio.TimeoutError, httpx.HTTPError) as aio_exception: await aqualink.close() raise ConfigEntryNotReady( f"Error while attempting login: {aio_exception}" @@ -149,7 +146,7 @@ async def async_setup_entry( # noqa: C901 try: await system.update() - except AqualinkServiceException as svc_exception: + except (AqualinkServiceException, httpx.HTTPError) as svc_exception: if prev is not None: _LOGGER.warning( "Failed to refresh system %s state: %s", diff --git a/homeassistant/components/iaqualink/config_flow.py b/homeassistant/components/iaqualink/config_flow.py index 3b3a99cac6..0dfc60f2fe 100644 --- a/homeassistant/components/iaqualink/config_flow.py +++ b/homeassistant/components/iaqualink/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any +import httpx from iaqualink.client import AqualinkClient from iaqualink.exception import ( AqualinkServiceException, @@ -42,7 +43,7 @@ class AqualinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): pass except AqualinkServiceUnauthorizedException: errors["base"] = "invalid_auth" - except AqualinkServiceException: + except (AqualinkServiceException, httpx.HTTPError): errors["base"] = "cannot_connect" else: return self.async_create_entry(title=username, data=user_input) From fb456a66c6ff07fdfe4ddeadcbbce41707b167ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Feb 2023 08:07:56 -0600 Subject: [PATCH 0172/2810] Bump aioesphomeapi to 13.3.1 (#87969) * Bump aioesphomeapi to 13.3.0 We probably need to include https://github.com/esphome/aioesphomeapi/pull/382 as well in another bump * bump --- 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 2ae066266e..34bc5befea 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -3,7 +3,7 @@ "name": "ESPHome", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/esphome", - "requirements": ["aioesphomeapi==13.1.0", "esphome-dashboard-api==1.2.3"], + "requirements": ["aioesphomeapi==13.3.1", "esphome-dashboard-api==1.2.3"], "zeroconf": ["_esphomelib._tcp.local."], "dhcp": [{ "registered_devices": true }], "codeowners": ["@OttoWinter", "@jesserockz"], diff --git a/requirements_all.txt b/requirements_all.txt index f76479ad3f..84ece70f75 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.1.0 +aioesphomeapi==13.3.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d25ed5540b..3345692e41 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.1.0 +aioesphomeapi==13.3.1 # homeassistant.components.flo aioflo==2021.11.0 From 3b4efba6069cda55d0f3cef0f7e3ce4423eac98f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 13 Feb 2023 09:24:50 -0500 Subject: [PATCH 0173/2810] Bumped version to 2023.2.4 --- 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 63d691f6ac..acad89bd43 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 = 2 -PATCH_VERSION: Final = "3" +PATCH_VERSION: Final = "4" __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 216f553946..092fdb7407 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.2.3" +version = "2023.2.4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 7a3fdc08a44730b07b826cd6d16dd1e18fd656d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Mon, 13 Feb 2023 23:46:25 +0100 Subject: [PATCH 0174/2810] Update tibber lib 0.26.13 (#88018) --- homeassistant/components/tibber/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index cb3c88532d..770d342f52 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -3,7 +3,7 @@ "domain": "tibber", "name": "Tibber", "documentation": "https://www.home-assistant.io/integrations/tibber", - "requirements": ["pyTibber==0.26.12"], + "requirements": ["pyTibber==0.26.13"], "codeowners": ["@danielhiversen"], "quality_scale": "silver", "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index 84ece70f75..c4a6d809cb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1470,7 +1470,7 @@ pyRFXtrx==0.30.0 pySwitchmate==0.5.1 # homeassistant.components.tibber -pyTibber==0.26.12 +pyTibber==0.26.13 # homeassistant.components.dlink pyW215==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3345692e41..61e82928c2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1073,7 +1073,7 @@ pyMetno==0.9.0 pyRFXtrx==0.30.0 # homeassistant.components.tibber -pyTibber==0.26.12 +pyTibber==0.26.13 # homeassistant.components.dlink pyW215==0.7.0 From 7337cc8e893a8d68ddadab048502d1a00c8417db Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 13 Feb 2023 16:40:49 -0700 Subject: [PATCH 0175/2810] Bump `pyopenuv` to 2023.02.0 (#88039) --- homeassistant/components/openuv/coordinator.py | 12 +----------- homeassistant/components/openuv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/openuv/coordinator.py b/homeassistant/components/openuv/coordinator.py index 7472f213f8..5d0c4bce50 100644 --- a/homeassistant/components/openuv/coordinator.py +++ b/homeassistant/components/openuv/coordinator.py @@ -6,7 +6,7 @@ from typing import Any, cast from pyopenuv.errors import InvalidApiKeyError, OpenUvError -from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.debounce import Debouncer @@ -60,14 +60,4 @@ class OpenUvCoordinator(DataUpdateCoordinator): except OpenUvError as err: raise UpdateFailed(str(err)) from err - # OpenUV uses HTTP 403 to indicate both an invalid API key and an API key that - # has hit its daily/monthly limit; both cases will result in a reauth flow. If - # coordinator update succeeds after a reauth flow has been started, terminate - # it: - if reauth_flow := next( - iter(self._entry.async_get_active_flows(self.hass, {SOURCE_REAUTH})), - None, - ): - self.hass.config_entries.flow.async_abort(reauth_flow["flow_id"]) - return cast(dict[str, Any], data["result"]) diff --git a/homeassistant/components/openuv/manifest.json b/homeassistant/components/openuv/manifest.json index ef367b94da..ae30a36022 100644 --- a/homeassistant/components/openuv/manifest.json +++ b/homeassistant/components/openuv/manifest.json @@ -3,7 +3,7 @@ "name": "OpenUV", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/openuv", - "requirements": ["pyopenuv==2023.01.0"], + "requirements": ["pyopenuv==2023.02.0"], "codeowners": ["@bachya"], "iot_class": "cloud_polling", "loggers": ["pyopenuv"], diff --git a/requirements_all.txt b/requirements_all.txt index c4a6d809cb..d7efccf7fc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1834,7 +1834,7 @@ pyoctoprintapi==0.1.9 pyombi==0.1.10 # homeassistant.components.openuv -pyopenuv==2023.01.0 +pyopenuv==2023.02.0 # homeassistant.components.opnsense pyopnsense==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 61e82928c2..277c819277 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1320,7 +1320,7 @@ pynzbgetapi==0.2.0 pyoctoprintapi==0.1.9 # homeassistant.components.openuv -pyopenuv==2023.01.0 +pyopenuv==2023.02.0 # homeassistant.components.opnsense pyopnsense==0.2.0 From e3273a75da07c413590e6e7cd533b85081fabbbe Mon Sep 17 00:00:00 2001 From: mkmer Date: Wed, 15 Feb 2023 10:01:20 -0500 Subject: [PATCH 0176/2810] Honeywell disable detergent level by default (#88040) * Disable fill by default * Fix tests * use TANK_FILL.get * Remove None from attribute get add reload to sensor test * Typing fix iteration error --- homeassistant/components/whirlpool/sensor.py | 6 ++-- tests/components/whirlpool/conftest.py | 23 +++++++++++++--- tests/components/whirlpool/test_sensor.py | 29 +++++++++++++++----- 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index b026dd0ce3..9c26a0319f 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -119,11 +119,12 @@ SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = ( key="DispenseLevel", name="Detergent Level", translation_key="whirlpool_tank", + entity_registry_enabled_default=False, device_class=SensorDeviceClass.ENUM, options=list(TANK_FILL.values()), - value_fn=lambda WasherDryer: TANK_FILL[ + value_fn=lambda WasherDryer: TANK_FILL.get( WasherDryer.get_attribute("WashCavity_OpStatusBulkDispense1Level") - ], + ), ), ) @@ -265,6 +266,7 @@ class WasherDryerTimeClass(RestoreSensor): async def async_will_remove_from_hass(self) -> None: """Close Whrilpool Appliance sockets before removing.""" + self._wd.unregister_attr_callback(self.update_from_latest_data) await self._wd.disconnect() @property diff --git a/tests/components/whirlpool/conftest.py b/tests/components/whirlpool/conftest.py index dd06c2d768..1671768fdb 100644 --- a/tests/components/whirlpool/conftest.py +++ b/tests/components/whirlpool/conftest.py @@ -49,6 +49,21 @@ def fixture_mock_appliances_manager_api(): yield mock_appliances_manager +@pytest.fixture(name="mock_appliances_manager_laundry_api") +def fixture_mock_appliances_manager_laundry_api(): + """Set up AppliancesManager fixture.""" + with mock.patch( + "homeassistant.components.whirlpool.AppliancesManager" + ) as mock_appliances_manager: + mock_appliances_manager.return_value.fetch_appliances = AsyncMock() + mock_appliances_manager.return_value.aircons = None + mock_appliances_manager.return_value.washer_dryers = [ + {"SAID": MOCK_SAID3, "NAME": "washer"}, + {"SAID": MOCK_SAID4, "NAME": "dryer"}, + ] + yield mock_appliances_manager + + @pytest.fixture(name="mock_backend_selector_api") def fixture_mock_backend_selector_api(): """Set up BackendSelector fixture.""" @@ -115,8 +130,6 @@ def side_effect_function(*args, **kwargs): return "0" if args[0] == "WashCavity_OpStatusBulkDispense1Level": return "3" - if args[0] == "Cavity_TimeStatusEstTimeRemaining": - return "4000" def get_sensor_mock(said): @@ -141,13 +154,13 @@ def get_sensor_mock(said): @pytest.fixture(name="mock_sensor1_api", autouse=False) -def fixture_mock_sensor1_api(mock_auth_api, mock_appliances_manager_api): +def fixture_mock_sensor1_api(mock_auth_api, mock_appliances_manager_laundry_api): """Set up sensor API fixture.""" yield get_sensor_mock(MOCK_SAID3) @pytest.fixture(name="mock_sensor2_api", autouse=False) -def fixture_mock_sensor2_api(mock_auth_api, mock_appliances_manager_api): +def fixture_mock_sensor2_api(mock_auth_api, mock_appliances_manager_laundry_api): """Set up sensor API fixture.""" yield get_sensor_mock(MOCK_SAID4) @@ -161,5 +174,7 @@ def fixture_mock_sensor_api_instances(mock_sensor1_api, mock_sensor2_api): mock_sensor_api.side_effect = [ mock_sensor1_api, mock_sensor2_api, + mock_sensor1_api, + mock_sensor2_api, ] yield mock_sensor_api diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index b8801bd4fd..eef13c08dc 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -17,7 +17,7 @@ async def update_sensor_state( hass: HomeAssistant, entity_id: str, mock_sensor_api_instance: MagicMock, -): +) -> None: """Simulate an update trigger from the API.""" for call in mock_sensor_api_instance.register_attr_callback.call_args_list: @@ -44,7 +44,7 @@ async def test_dryer_sensor_values( hass: HomeAssistant, mock_sensor_api_instances: MagicMock, mock_sensor2_api: MagicMock, -): +) -> None: """Test the sensor value callbacks.""" hass.state = CoreState.not_running thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, timezone.utc) @@ -108,7 +108,7 @@ async def test_washer_sensor_values( hass: HomeAssistant, mock_sensor_api_instances: MagicMock, mock_sensor1_api: MagicMock, -): +) -> None: """Test the sensor value callbacks.""" hass.state = CoreState.not_running thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, timezone.utc) @@ -147,6 +147,21 @@ async def test_washer_sensor_values( assert state.state == thetimestamp.isoformat() state_id = f"{entity_id.split('_')[0]}_detergent_level" + registry = entity_registry.async_get(hass) + entry = registry.async_get(state_id) + assert entry + assert entry.disabled + assert entry.disabled_by is entity_registry.RegistryEntryDisabler.INTEGRATION + + update_entry = registry.async_update_entity(entry.entity_id, disabled_by=None) + await hass.async_block_till_done() + + assert update_entry != entry + assert update_entry.disabled is False + state = hass.states.get(state_id) + assert state is None + + await hass.config_entries.async_reload(entry.config_entry_id) state = hass.states.get(state_id) assert state is not None assert state.state == "50" @@ -253,7 +268,7 @@ async def test_washer_sensor_values( async def test_restore_state( hass: HomeAssistant, mock_sensor_api_instances: MagicMock, -): +) -> None: """Test sensor restore state.""" # Home assistant is not running yet hass.state = CoreState.not_running @@ -288,7 +303,7 @@ async def test_callback( hass: HomeAssistant, mock_sensor_api_instances: MagicMock, mock_sensor1_api: MagicMock, -): +) -> None: """Test callback timestamp callback function.""" hass.state = CoreState.not_running thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, timezone.utc) @@ -314,9 +329,9 @@ async def test_callback( # restore from cache state = hass.states.get("sensor.washer_end_time") assert state.state == thetimestamp.isoformat() - callback = mock_sensor1_api.register_attr_callback.call_args_list[2][0][0] + callback = mock_sensor1_api.register_attr_callback.call_args_list[1][0][0] callback() - # await hass.async_block_till_done() + state = hass.states.get("sensor.washer_end_time") assert state.state == thetimestamp.isoformat() mock_sensor1_api.get_machine_state.return_value = MachineState.RunningMainCycle From 97dab32a4cb0c964b4991892b82c9644ef072f7b Mon Sep 17 00:00:00 2001 From: mkmer Date: Mon, 13 Feb 2023 23:46:46 -0500 Subject: [PATCH 0177/2810] Bump AIOAladdinConnect 0.1.56 (#88041) --- homeassistant/components/aladdin_connect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/aladdin_connect/manifest.json b/homeassistant/components/aladdin_connect/manifest.json index 3cfe7a1416..48f4ef82b8 100644 --- a/homeassistant/components/aladdin_connect/manifest.json +++ b/homeassistant/components/aladdin_connect/manifest.json @@ -2,7 +2,7 @@ "domain": "aladdin_connect", "name": "Aladdin Connect", "documentation": "https://www.home-assistant.io/integrations/aladdin_connect", - "requirements": ["AIOAladdinConnect==0.1.55"], + "requirements": ["AIOAladdinConnect==0.1.56"], "codeowners": ["@mkmer"], "iot_class": "cloud_polling", "loggers": ["aladdin_connect"], diff --git a/requirements_all.txt b/requirements_all.txt index d7efccf7fc..63ae47dce6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -5,7 +5,7 @@ AEMET-OpenData==0.2.1 # homeassistant.components.aladdin_connect -AIOAladdinConnect==0.1.55 +AIOAladdinConnect==0.1.56 # homeassistant.components.adax Adax-local==0.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 277c819277..71a4498e64 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.2.1 # homeassistant.components.aladdin_connect -AIOAladdinConnect==0.1.55 +AIOAladdinConnect==0.1.56 # homeassistant.components.adax Adax-local==0.1.5 From 8024a170255b21f08664e0b5e521dd1698f30beb Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 14 Feb 2023 05:45:27 -0500 Subject: [PATCH 0178/2810] Handle device reg fields not being valid data in openai conversion (#88047) Handle device reg fields not being valid data --- .../components/openai_conversation/const.py | 2 +- tests/components/openai_conversation/test_init.py | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index b5644915d9..294bbbd6e9 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -15,7 +15,7 @@ An overview of the areas and the devices in this smart home: {{ area.name }}: {%- set area_info.printed = true %} {%- endif %} -- {{ device_attr(device, "name") }}{% if device_attr(device, "model") and device_attr(device, "model") not in device_attr(device, "name") %} ({{ device_attr(device, "model") }}){% endif %} +- {{ device_attr(device, "name") }}{% if device_attr(device, "model") and (device_attr(device, "model") | string) not in (device_attr(device, "name") | string) %} ({{ device_attr(device, "model") }}){% endif %} {%- endif %} {%- endfor %} {%- endfor %} diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index b64f332289..14d2015ace 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -67,14 +67,21 @@ async def test_default_prompt(hass, mock_init_component): device_reg.async_update_device( device.id, disabled_by=device_registry.DeviceEntryDisabler.USER ) - device = device_reg.async_get_or_create( + device_reg.async_get_or_create( config_entry_id="1234", connections={("test", "9876-no-name")}, manufacturer="Test Manufacturer NoName", model="Test Model NoName", suggested_area="Test Area 2", ) - + device_reg.async_get_or_create( + config_entry_id="1234", + connections={("test", "9876-integer-values")}, + name=1, + manufacturer=2, + model=3, + suggested_area="Test Area 2", + ) with patch("openai.Completion.create") as mock_create: result = await conversation.async_converse(hass, "hello", None, Context()) @@ -93,6 +100,7 @@ Test Area 2: - Test Device 2 - Test Device 3 (Test Model 3A) - Test Device 4 +- 1 (3) Answer the users questions about the world truthfully. From be5777ba59b2bac9a5e8f6a18b6206931b432274 Mon Sep 17 00:00:00 2001 From: Ryan Fleming Date: Tue, 14 Feb 2023 02:40:03 -0500 Subject: [PATCH 0179/2810] Bump pyoctoprintapi to 0.1.11 (#88052) Bump pyoctoprint to get camera url fixes --- homeassistant/components/octoprint/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/octoprint/manifest.json b/homeassistant/components/octoprint/manifest.json index 9bc2f0011c..047d7d9ce1 100644 --- a/homeassistant/components/octoprint/manifest.json +++ b/homeassistant/components/octoprint/manifest.json @@ -3,7 +3,7 @@ "name": "OctoPrint", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/octoprint", - "requirements": ["pyoctoprintapi==0.1.9"], + "requirements": ["pyoctoprintapi==0.1.11"], "codeowners": ["@rfleming71"], "zeroconf": ["_octoprint._tcp.local."], "ssdp": [ diff --git a/requirements_all.txt b/requirements_all.txt index 63ae47dce6..59c17c20f8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1828,7 +1828,7 @@ pynzbgetapi==0.2.0 pyobihai==1.3.2 # homeassistant.components.octoprint -pyoctoprintapi==0.1.9 +pyoctoprintapi==0.1.11 # homeassistant.components.ombi pyombi==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 71a4498e64..4b236028fb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1317,7 +1317,7 @@ pynx584==0.5 pynzbgetapi==0.2.0 # homeassistant.components.octoprint -pyoctoprintapi==0.1.9 +pyoctoprintapi==0.1.11 # homeassistant.components.openuv pyopenuv==2023.02.0 From 55fed18e3edb3f6b525a616347695e2533374d73 Mon Sep 17 00:00:00 2001 From: Gertjan Date: Wed, 15 Feb 2023 15:53:44 +0100 Subject: [PATCH 0180/2810] Fixed float number validation in sensor component (#88074) --- homeassistant/components/sensor/__init__.py | 2 +- tests/components/sensor/test_init.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 5108a16755..2fbd8ef211 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -587,7 +587,7 @@ class SensorEntity(Entity): numerical_value: int | float | Decimal if not isinstance(value, (int, float, Decimal)): try: - if isinstance(value, str) and "." not in value: + if isinstance(value, str) and "." not in value and "e" not in value: numerical_value = int(value) else: numerical_value = float(value) # type:ignore[arg-type] diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index b43c63f015..f63d10bc76 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -1548,6 +1548,7 @@ async def test_non_numeric_validation_raise( [ (13, "13"), (17.50, "17.5"), + ("1e-05", "1e-05"), (Decimal(18.50), "18.5"), ("19.70", "19.70"), (None, STATE_UNKNOWN), From 634aff0006b4a31e22aaeadfe0131aec8dfbb33e Mon Sep 17 00:00:00 2001 From: Thomas Dietrich Date: Wed, 15 Feb 2023 10:22:09 +0100 Subject: [PATCH 0181/2810] Statistics component fix device_class for incremental source sensors (#88096) * Return None device_class for incremental source sensors * Ignore linting error * Fix ignore linting error * Fix ignore linting error * Fix ignore linting error * Catch potential parsing error with enum --- homeassistant/components/statistics/sensor.py | 59 ++++++--- tests/components/statistics/test_sensor.py | 122 ++++++++++++++++-- 2 files changed, 153 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index 38434c15a1..2025eebd65 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -13,7 +13,8 @@ import voluptuous as vol from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.recorder import get_instance, history -from homeassistant.components.sensor import ( +from homeassistant.components.sensor import ( # pylint: disable=hass-deprecated-import + DEVICE_CLASS_STATE_CLASSES, PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, @@ -47,6 +48,7 @@ from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.start import async_at_start from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from homeassistant.util import dt as dt_util +from homeassistant.util.enum import try_parse_enum from . import DOMAIN, PLATFORMS @@ -144,7 +146,7 @@ STATS_DATETIME = { } # Statistics which retain the unit of the source entity -STAT_NUMERIC_RETAIN_UNIT = { +STATS_NUMERIC_RETAIN_UNIT = { STAT_AVERAGE_LINEAR, STAT_AVERAGE_STEP, STAT_AVERAGE_TIMELESS, @@ -166,7 +168,7 @@ STAT_NUMERIC_RETAIN_UNIT = { } # Statistics which produce percentage ratio from binary_sensor source entity -STAT_BINARY_PERCENTAGE = { +STATS_BINARY_PERCENTAGE = { STAT_AVERAGE_STEP, STAT_AVERAGE_TIMELESS, STAT_MEAN, @@ -296,15 +298,9 @@ class StatisticsSensor(SensorEntity): self.ages: deque[datetime] = deque(maxlen=self._samples_max_buffer_size) self.attributes: dict[str, StateType] = {} - self._state_characteristic_fn: Callable[[], StateType | datetime] - if self.is_binary: - self._state_characteristic_fn = getattr( - self, f"_stat_binary_{self._state_characteristic}" - ) - else: - self._state_characteristic_fn = getattr( - self, f"_stat_{self._state_characteristic}" - ) + self._state_characteristic_fn: Callable[ + [], StateType | datetime + ] = self._callable_characteristic_fn(self._state_characteristic) self._update_listener: CALLBACK_TYPE | None = None @@ -368,11 +364,11 @@ class StatisticsSensor(SensorEntity): def _derive_unit_of_measurement(self, new_state: State) -> str | None: base_unit: str | None = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) unit: str | None - if self.is_binary and self._state_characteristic in STAT_BINARY_PERCENTAGE: + if self.is_binary and self._state_characteristic in STATS_BINARY_PERCENTAGE: unit = PERCENTAGE elif not base_unit: unit = None - elif self._state_characteristic in STAT_NUMERIC_RETAIN_UNIT: + elif self._state_characteristic in STATS_NUMERIC_RETAIN_UNIT: unit = base_unit elif self._state_characteristic in STATS_NOT_A_NUMBER: unit = None @@ -393,11 +389,24 @@ class StatisticsSensor(SensorEntity): @property def device_class(self) -> SensorDeviceClass | None: """Return the class of this device.""" - if self._state_characteristic in STAT_NUMERIC_RETAIN_UNIT: - _state = self.hass.states.get(self._source_entity_id) - return None if _state is None else _state.attributes.get(ATTR_DEVICE_CLASS) if self._state_characteristic in STATS_DATETIME: return SensorDeviceClass.TIMESTAMP + if self._state_characteristic in STATS_NUMERIC_RETAIN_UNIT: + source_state = self.hass.states.get(self._source_entity_id) + if source_state is None: + return None + source_device_class = source_state.attributes.get(ATTR_DEVICE_CLASS) + if source_device_class is None: + return None + sensor_device_class = try_parse_enum(SensorDeviceClass, source_device_class) + if sensor_device_class is None: + return None + sensor_state_classes = DEVICE_CLASS_STATE_CLASSES.get( + sensor_device_class, set() + ) + if SensorStateClass.MEASUREMENT not in sensor_state_classes: + return None + return sensor_device_class return None @property @@ -472,8 +481,8 @@ class StatisticsSensor(SensorEntity): if timestamp := self._next_to_purge_timestamp(): _LOGGER.debug("%s: scheduling update at %s", self.entity_id, timestamp) if self._update_listener: - self._update_listener() - self._update_listener = None + self._update_listener() # pragma: no cover + self._update_listener = None # pragma: no cover @callback def _scheduled_update(now: datetime) -> None: @@ -563,6 +572,18 @@ class StatisticsSensor(SensorEntity): value = int(value) self._value = value + def _callable_characteristic_fn( + self, characteristic: str + ) -> Callable[[], StateType | datetime]: + """Return the function callable of one characteristic function.""" + function: Callable[[], StateType | datetime] = getattr( + self, + f"_stat_binary_{characteristic}" + if self.is_binary + else f"_stat_{characteristic}", + ) + return function + # Statistics for numeric sensor def _stat_average_linear(self) -> StateType: diff --git a/tests/components/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py index 7f68ae6897..148ae87b80 100644 --- a/tests/components/statistics/test_sensor.py +++ b/tests/components/statistics/test_sensor.py @@ -21,6 +21,7 @@ from homeassistant.const import ( SERVICE_RELOAD, STATE_UNAVAILABLE, STATE_UNKNOWN, + UnitOfEnergy, UnitOfTemperature, ) from homeassistant.core import HomeAssistant @@ -250,6 +251,63 @@ async def test_sensor_source_with_force_update(hass: HomeAssistant): assert state_force.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) +async def test_sampling_boundaries_given(hass: HomeAssistant): + """Test if either sampling_size or max_age are given.""" + assert await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "statistics", + "name": "test_boundaries_none", + "entity_id": "sensor.test_monitored", + "state_characteristic": "mean", + }, + { + "platform": "statistics", + "name": "test_boundaries_size", + "entity_id": "sensor.test_monitored", + "state_characteristic": "mean", + "sampling_size": 20, + }, + { + "platform": "statistics", + "name": "test_boundaries_age", + "entity_id": "sensor.test_monitored", + "state_characteristic": "mean", + "max_age": {"minutes": 4}, + }, + { + "platform": "statistics", + "name": "test_boundaries_both", + "entity_id": "sensor.test_monitored", + "state_characteristic": "mean", + "sampling_size": 20, + "max_age": {"minutes": 4}, + }, + ] + }, + ) + await hass.async_block_till_done() + + hass.states.async_set( + "sensor.test_monitored", + str(VALUES_NUMERIC[0]), + {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_boundaries_none") + assert state is None + state = hass.states.get("sensor.test_boundaries_size") + assert state is not None + state = hass.states.get("sensor.test_boundaries_age") + assert state is not None + state = hass.states.get("sensor.test_boundaries_both") + assert state is not None + + async def test_sampling_size_reduced(hass: HomeAssistant): """Test limited buffer size.""" assert await async_setup_component( @@ -514,9 +572,9 @@ async def test_device_class(hass: HomeAssistant): { "sensor": [ { - # Device class is carried over from source sensor for characteristics with same unit + # Device class is carried over from source sensor for characteristics which retain unit "platform": "statistics", - "name": "test_source_class", + "name": "test_retain_unit", "entity_id": "sensor.test_monitored", "state_characteristic": "mean", "sampling_size": 20, @@ -537,6 +595,14 @@ async def test_device_class(hass: HomeAssistant): "state_characteristic": "datetime_oldest", "sampling_size": 20, }, + { + # Device class is set to None for any source sensor with TOTAL state class + "platform": "statistics", + "name": "test_source_class_total", + "entity_id": "sensor.test_monitored_total", + "state_characteristic": "mean", + "sampling_size": 20, + }, ] }, ) @@ -549,11 +615,21 @@ async def test_device_class(hass: HomeAssistant): { ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + }, + ) + hass.states.async_set( + "sensor.test_monitored_total", + str(value), + { + ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.WATT_HOUR, + ATTR_DEVICE_CLASS: SensorDeviceClass.ENERGY, + ATTR_STATE_CLASS: SensorStateClass.TOTAL, }, ) await hass.async_block_till_done() - state = hass.states.get("sensor.test_source_class") + state = hass.states.get("sensor.test_retain_unit") assert state is not None assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE state = hass.states.get("sensor.test_none") @@ -562,6 +638,9 @@ async def test_device_class(hass: HomeAssistant): state = hass.states.get("sensor.test_timestamp") assert state is not None assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP + state = hass.states.get("sensor.test_source_class_total") + assert state is not None + assert state.attributes.get(ATTR_DEVICE_CLASS) is None async def test_state_class(hass: HomeAssistant): @@ -572,6 +651,15 @@ async def test_state_class(hass: HomeAssistant): { "sensor": [ { + # State class is None for datetime characteristics + "platform": "statistics", + "name": "test_nan", + "entity_id": "sensor.test_monitored", + "state_characteristic": "datetime_oldest", + "sampling_size": 20, + }, + { + # State class is MEASUREMENT for all other characteristics "platform": "statistics", "name": "test_normal", "entity_id": "sensor.test_monitored", @@ -579,10 +667,12 @@ async def test_state_class(hass: HomeAssistant): "sampling_size": 20, }, { + # State class is MEASUREMENT, even when the source sensor + # is of state class TOTAL "platform": "statistics", - "name": "test_nan", - "entity_id": "sensor.test_monitored", - "state_characteristic": "datetime_oldest", + "name": "test_total", + "entity_id": "sensor.test_monitored_total", + "state_characteristic": "count", "sampling_size": 20, }, ] @@ -596,14 +686,28 @@ async def test_state_class(hass: HomeAssistant): str(value), {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, ) + hass.states.async_set( + "sensor.test_monitored_total", + str(value), + { + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + ATTR_STATE_CLASS: SensorStateClass.TOTAL, + }, + ) await hass.async_block_till_done() - state = hass.states.get("sensor.test_normal") - assert state is not None - assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT state = hass.states.get("sensor.test_nan") assert state is not None assert state.attributes.get(ATTR_STATE_CLASS) is None + state = hass.states.get("sensor.test_normal") + assert state is not None + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + state = hass.states.get("sensor.test_monitored_total") + assert state is not None + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL + state = hass.states.get("sensor.test_total") + assert state is not None + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT async def test_unitless_source_sensor(hass: HomeAssistant): From 2d293c19bf0d566405d1fc062444c1f42a2dd418 Mon Sep 17 00:00:00 2001 From: Mark Broadbent Date: Tue, 14 Feb 2023 20:26:52 +0000 Subject: [PATCH 0182/2810] Update orjson to resolve segmentation fault during JSON serialisation (#88119) Home Assistant uses orjson 3.8.5 that contains an issue[1] on musl libc platforms that causes a segmentation fault. This particularly affect Home Assistant container installations reported in #87283 and #87522. This updates the version to 3.8.6 that resolves the segmentation fault during json serialisation. [1] https://github.com/ijl/orjson/issues/335 --- 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 8ad9a144a1..ac49c02cc5 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.5 +orjson==3.8.6 paho-mqtt==1.6.1 pillow==9.4.0 pip>=21.0,<22.4 diff --git a/pyproject.toml b/pyproject.toml index 092fdb7407..786f3f90df 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.5", + "orjson==3.8.6", "pip>=21.0,<22.4", "python-slugify==4.0.1", "pyyaml==6.0", diff --git a/requirements.txt b/requirements.txt index 7d05e1bb2e..c2404d880b 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.5 +orjson==3.8.6 pip>=21.0,<22.4 python-slugify==4.0.1 pyyaml==6.0 From 1ca6c263127cf92619ba0604c206d87a9084f194 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Huryn?= Date: Wed, 15 Feb 2023 15:27:46 +0100 Subject: [PATCH 0183/2810] Blebox fix thermobox reporting wrong state (#88169) fix: fixed climate hvac_mode for when device is off --- homeassistant/components/blebox/climate.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/blebox/climate.py b/homeassistant/components/blebox/climate.py index 94e7900985..e4ac8985eb 100644 --- a/homeassistant/components/blebox/climate.py +++ b/homeassistant/components/blebox/climate.py @@ -22,6 +22,7 @@ from .const import DOMAIN, PRODUCT SCAN_INTERVAL = timedelta(seconds=5) BLEBOX_TO_HVACMODE = { + None: None, 0: HVACMode.OFF, 1: HVACMode.HEAT, 2: HVACMode.COOL, @@ -58,13 +59,15 @@ class BleBoxClimateEntity(BleBoxEntity[blebox_uniapi.climate.Climate], ClimateEn @property def hvac_modes(self): """Return list of supported HVAC modes.""" - return [HVACMode.OFF, self.hvac_mode] + return [HVACMode.OFF, BLEBOX_TO_HVACMODE[self._feature.mode]] @property def hvac_mode(self): """Return the desired HVAC mode.""" if self._feature.is_on is None: return None + if not self._feature.is_on: + return HVACMode.OFF if self._feature.mode is not None: return BLEBOX_TO_HVACMODE[self._feature.mode] return HVACMode.HEAT if self._feature.is_on else HVACMode.OFF From bbbc864a8c2f945502ed490fea34af05763b5436 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 15 Feb 2023 15:16:47 +0100 Subject: [PATCH 0184/2810] Bump reolink-aio to 0.4.2 (#88175) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 0519960945..64d5b492e3 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -3,7 +3,7 @@ "name": "Reolink IP NVR/camera", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/reolink", - "requirements": ["reolink-aio==0.4.0"], + "requirements": ["reolink-aio==0.4.2"], "dependencies": ["webhook"], "codeowners": ["@starkillerOG"], "iot_class": "local_polling", diff --git a/requirements_all.txt b/requirements_all.txt index 59c17c20f8..4fadd3ab7c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2227,7 +2227,7 @@ regenmaschine==2022.11.0 renault-api==0.1.11 # homeassistant.components.reolink -reolink-aio==0.4.0 +reolink-aio==0.4.2 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4b236028fb..b99a96cdb2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1572,7 +1572,7 @@ regenmaschine==2022.11.0 renault-api==0.1.11 # homeassistant.components.reolink -reolink-aio==0.4.0 +reolink-aio==0.4.2 # homeassistant.components.python_script restrictedpython==6.0 From 0d140426ccdba2da247ea8d46952fc23419274cb Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 15 Feb 2023 11:14:27 -0500 Subject: [PATCH 0185/2810] Bumped version to 2023.2.5 --- 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 acad89bd43..4d7d066a89 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 = 2 -PATCH_VERSION: Final = "4" +PATCH_VERSION: Final = "5" __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 786f3f90df..1103b78dc7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.2.4" +version = "2023.2.5" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 3e3936e783342eaaf81cfcbc04c35949bafd64b5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 15 Feb 2023 12:17:03 -0500 Subject: [PATCH 0186/2810] backport try_parse_enum --- homeassistant/components/statistics/sensor.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index 2025eebd65..60dc8f54ed 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -5,9 +5,10 @@ from collections import deque from collections.abc import Callable import contextlib from datetime import datetime, timedelta +from enum import Enum import logging import statistics -from typing import Any, Literal, cast +from typing import Any, Literal, TypeVar, cast import voluptuous as vol @@ -48,7 +49,6 @@ from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.start import async_at_start from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from homeassistant.util import dt as dt_util -from homeassistant.util.enum import try_parse_enum from . import DOMAIN, PLATFORMS @@ -769,3 +769,16 @@ class StatisticsSensor(SensorEntity): if len(self.states) > 0: return 100.0 / len(self.states) * self.states.count(True) return None + + +_EnumT = TypeVar("_EnumT", bound=Enum) + + +def try_parse_enum(cls: type[_EnumT], value: Any) -> _EnumT | None: + """Try to parse the value into an Enum. + + Return None if parsing fails. + """ + with contextlib.suppress(ValueError): + return cls(value) + return None From 684845fd8c0a36bc415b1ba0272c825fa84c70a2 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 15 Feb 2023 19:23:03 +0100 Subject: [PATCH 0187/2810] Bump python-matter-server to 2.1.0 (#88192) * Bump python-matter-server to 2.1.0 * Fix tests --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/matter/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/matter/conftest.py | 2 ++ tests/components/matter/fixtures/config_entry_diagnostics.json | 3 ++- .../matter/fixtures/config_entry_diagnostics_redacted.json | 3 ++- 6 files changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 46fe45873b..7b2761b557 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -3,7 +3,7 @@ "name": "Matter (BETA)", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/matter", - "requirements": ["python-matter-server==2.0.2"], + "requirements": ["python-matter-server==2.1.0"], "dependencies": ["websocket_api"], "codeowners": ["@home-assistant/matter"], "iot_class": "local_push" diff --git a/requirements_all.txt b/requirements_all.txt index 4fadd3ab7c..125ff84135 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2072,7 +2072,7 @@ python-kasa==0.5.0 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==2.0.2 +python-matter-server==2.1.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b99a96cdb2..68557b36f5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1468,7 +1468,7 @@ python-juicenet==1.1.0 python-kasa==0.5.0 # homeassistant.components.matter -python-matter-server==2.0.2 +python-matter-server==2.1.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index ad66bb7f6a..c8592c8484 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -5,6 +5,7 @@ import asyncio from collections.abc import AsyncGenerator, Generator from unittest.mock import AsyncMock, MagicMock, patch +from matter_server.common.const import SCHEMA_VERSION from matter_server.common.models.server_information import ServerInfo import pytest @@ -45,6 +46,7 @@ async def matter_client_fixture() -> AsyncGenerator[MagicMock, None]: sdk_version="2022.11.1", wifi_credentials_set=True, thread_credentials_set=True, + min_supported_schema_version=SCHEMA_VERSION, ) yield client diff --git a/tests/components/matter/fixtures/config_entry_diagnostics.json b/tests/components/matter/fixtures/config_entry_diagnostics.json index 13a4e7d26a..8c7b0960fc 100644 --- a/tests/components/matter/fixtures/config_entry_diagnostics.json +++ b/tests/components/matter/fixtures/config_entry_diagnostics.json @@ -5,7 +5,8 @@ "schema_version": 1, "sdk_version": "2022.12.0", "wifi_credentials_set": true, - "thread_credentials_set": false + "thread_credentials_set": false, + "min_supported_schema_version": 1 }, "nodes": [ { diff --git a/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json b/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json index 8f798a5046..bbe7cf1f2c 100644 --- a/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json +++ b/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json @@ -6,7 +6,8 @@ "schema_version": 1, "sdk_version": "2022.12.0", "wifi_credentials_set": true, - "thread_credentials_set": false + "thread_credentials_set": false, + "min_supported_schema_version": 1 }, "nodes": [ { From 1caacab8dd4d2d1ee44fde326afbc3abfed8f22e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 18 Feb 2023 14:38:04 +0100 Subject: [PATCH 0188/2810] Enable Ruff B014 (#88377) --- homeassistant/components/azure_devops/__init__.py | 2 +- pyproject.toml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/azure_devops/__init__.py b/homeassistant/components/azure_devops/__init__.py index 01c0cd123e..7c63b9ffaf 100644 --- a/homeassistant/components/azure_devops/__init__.py +++ b/homeassistant/components/azure_devops/__init__.py @@ -66,7 +66,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_PROJECT], BUILDS_QUERY, ) - except (aiohttp.ClientError, aiohttp.ClientError) as exception: + except aiohttp.ClientError as exception: raise UpdateFailed from exception coordinator = DataUpdateCoordinator( diff --git a/pyproject.toml b/pyproject.toml index 6b3904522a..e0255bbe65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -241,6 +241,7 @@ target-version = "py310" select = [ "B007", # Loop control variable {name} not used within loop body + "B014", # Exception handler with duplicate exception "C", # complexity "D", # docstrings "E", # pycodestyle From 31061b9f351c65b9cf3cdc96c6143e66a6889367 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 18 Feb 2023 14:38:19 +0100 Subject: [PATCH 0189/2810] Fix snapshots testing repr 0 value IntFlags (#88379) --- tests/syrupy.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/syrupy.py b/tests/syrupy.py index ff3b0caa05..346b15259e 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -3,6 +3,7 @@ from __future__ import annotations from contextlib import suppress import dataclasses +from enum import IntFlag from pathlib import Path from typing import Any @@ -110,6 +111,10 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): serializable_data = cls._serializable_config_entry(data) elif dataclasses.is_dataclass(data): serializable_data = dataclasses.asdict(data) + elif isinstance(data, IntFlag) and data == 0: + # The repr of an enum.IntFlag has changed between Python 3.10 and 3.11 + # This only concerns the 0 case, which we normalize here + serializable_data = 0 else: serializable_data = data with suppress(TypeError): From 112804189940b996c80ccb5db6585ca7e78a7a84 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 18 Feb 2023 14:59:26 +0100 Subject: [PATCH 0190/2810] Add type hints to integration tests (zwave_js) (#88311) --- tests/components/zwave_js/test_api.py | 294 ++++++++++++------ .../components/zwave_js/test_binary_sensor.py | 22 +- tests/components/zwave_js/test_button.py | 9 +- tests/components/zwave_js/test_climate.py | 44 +-- tests/components/zwave_js/test_config_flow.py | 157 ++++++---- tests/components/zwave_js/test_cover.py | 33 +- .../components/zwave_js/test_device_action.py | 6 +- .../zwave_js/test_device_condition.py | 31 +- .../zwave_js/test_device_trigger.py | 107 ++++--- tests/components/zwave_js/test_diagnostics.py | 22 +- tests/components/zwave_js/test_discovery.py | 24 +- tests/components/zwave_js/test_events.py | 22 +- tests/components/zwave_js/test_fan.py | 39 ++- tests/components/zwave_js/test_humidifier.py | 17 +- tests/components/zwave_js/test_init.py | 123 +++++--- tests/components/zwave_js/test_light.py | 23 +- tests/components/zwave_js/test_lock.py | 13 +- tests/components/zwave_js/test_logbook.py | 9 +- tests/components/zwave_js/test_migrate.py | 33 +- tests/components/zwave_js/test_number.py | 25 +- tests/components/zwave_js/test_select.py | 8 +- tests/components/zwave_js/test_sensor.py | 46 ++- tests/components/zwave_js/test_services.py | 60 ++-- tests/components/zwave_js/test_siren.py | 5 +- tests/components/zwave_js/test_switch.py | 14 +- tests/components/zwave_js/test_trigger.py | 36 ++- tests/components/zwave_js/test_update.py | 46 +-- 27 files changed, 803 insertions(+), 465 deletions(-) diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index e07bda6a07..1809695643 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -77,8 +77,12 @@ from homeassistant.components.zwave_js.const import ( DOMAIN, ) from homeassistant.components.zwave_js.helpers import get_device_id +from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from tests.common import MockUser +from tests.typing import ClientSessionGenerator, WebSocketGenerator + def get_device(hass, node): """Get device ID for a node.""" @@ -88,8 +92,13 @@ def get_device(hass, node): async def test_no_driver( - hass, client, multisensor_6, controller_state, integration, hass_ws_client -): + hass: HomeAssistant, + client, + multisensor_6, + controller_state, + integration, + hass_ws_client: WebSocketGenerator, +) -> None: """Test driver missing results in error.""" entry = integration ws_client = await hass_ws_client(hass) @@ -108,8 +117,12 @@ async def test_no_driver( async def test_network_status( - hass, multisensor_6, controller_state, integration, hass_ws_client -): + hass: HomeAssistant, + multisensor_6, + controller_state, + integration, + hass_ws_client: WebSocketGenerator, +) -> None: """Test the network status websocket command.""" entry = integration ws_client = await hass_ws_client(hass) @@ -226,12 +239,12 @@ async def test_network_status( async def test_subscribe_node_status( - hass, + hass: HomeAssistant, multisensor_6_state, client, integration, - hass_ws_client, -): + hass_ws_client: WebSocketGenerator, +) -> None: """Test the subscribe node status websocket command.""" entry = integration ws_client = await hass_ws_client(hass) @@ -294,7 +307,9 @@ async def test_subscribe_node_status( assert msg["event"]["ready"] -async def test_node_status(hass, multisensor_6, integration, hass_ws_client): +async def test_node_status( + hass: HomeAssistant, multisensor_6, integration, hass_ws_client: WebSocketGenerator +) -> None: """Test the node status websocket command.""" entry = integration ws_client = await hass_ws_client(hass) @@ -350,7 +365,12 @@ async def test_node_status(hass, multisensor_6, integration, hass_ws_client): assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_node_metadata(hass, wallmote_central_scene, integration, hass_ws_client): +async def test_node_metadata( + hass: HomeAssistant, + wallmote_central_scene, + integration, + hass_ws_client: WebSocketGenerator, +) -> None: """Test the node metadata websocket command.""" entry = integration ws_client = await hass_ws_client(hass) @@ -425,7 +445,12 @@ async def test_node_metadata(hass, wallmote_central_scene, integration, hass_ws_ assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_node_comments(hass, wallmote_central_scene, integration, hass_ws_client): +async def test_node_comments( + hass: HomeAssistant, + wallmote_central_scene, + integration, + hass_ws_client: WebSocketGenerator, +) -> None: """Test the node comments websocket command.""" ws_client = await hass_ws_client(hass) @@ -446,8 +471,12 @@ async def test_node_comments(hass, wallmote_central_scene, integration, hass_ws_ async def test_add_node( - hass, nortek_thermostat_added_event, integration, client, hass_ws_client -): + hass: HomeAssistant, + nortek_thermostat_added_event, + integration, + client, + hass_ws_client: WebSocketGenerator, +) -> None: """Test the add_node websocket command.""" entry = integration ws_client = await hass_ws_client(hass) @@ -857,7 +886,9 @@ async def test_add_node( assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_grant_security_classes(hass, integration, client, hass_ws_client): +async def test_grant_security_classes( + hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator +) -> None: """Test the grant_security_classes websocket command.""" entry = integration ws_client = await hass_ws_client(hass) @@ -902,7 +933,9 @@ async def test_grant_security_classes(hass, integration, client, hass_ws_client) assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_validate_dsk_and_enter_pin(hass, integration, client, hass_ws_client): +async def test_validate_dsk_and_enter_pin( + hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator +) -> None: """Test the validate_dsk_and_enter_pin websocket command.""" entry = integration ws_client = await hass_ws_client(hass) @@ -945,7 +978,9 @@ async def test_validate_dsk_and_enter_pin(hass, integration, client, hass_ws_cli assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_provision_smart_start_node(hass, integration, client, hass_ws_client): +async def test_provision_smart_start_node( + hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator +) -> None: """Test provision_smart_start_node websocket command.""" entry = integration ws_client = await hass_ws_client(hass) @@ -1127,7 +1162,9 @@ async def test_provision_smart_start_node(hass, integration, client, hass_ws_cli assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_unprovision_smart_start_node(hass, integration, client, hass_ws_client): +async def test_unprovision_smart_start_node( + hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator +) -> None: """Test unprovision_smart_start_node websocket command.""" entry = integration ws_client = await hass_ws_client(hass) @@ -1229,7 +1266,9 @@ async def test_unprovision_smart_start_node(hass, integration, client, hass_ws_c assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_get_provisioning_entries(hass, integration, client, hass_ws_client): +async def test_get_provisioning_entries( + hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator +) -> None: """Test get_provisioning_entries websocket command.""" entry = integration ws_client = await hass_ws_client(hass) @@ -1294,7 +1333,9 @@ async def test_get_provisioning_entries(hass, integration, client, hass_ws_clien assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_parse_qr_code_string(hass, integration, client, hass_ws_client): +async def test_parse_qr_code_string( + hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator +) -> None: """Test parse_qr_code_string websocket command.""" entry = integration ws_client = await hass_ws_client(hass) @@ -1390,7 +1431,9 @@ async def test_parse_qr_code_string(hass, integration, client, hass_ws_client): assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_supports_feature(hass, integration, client, hass_ws_client): +async def test_supports_feature( + hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator +) -> None: """Test supports_feature websocket command.""" entry = integration ws_client = await hass_ws_client(hass) @@ -1411,7 +1454,9 @@ async def test_supports_feature(hass, integration, client, hass_ws_client): assert msg["result"] == {"supported": True} -async def test_cancel_inclusion_exclusion(hass, integration, client, hass_ws_client): +async def test_cancel_inclusion_exclusion( + hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator +) -> None: """Test cancelling the inclusion and exclusion process.""" entry = integration ws_client = await hass_ws_client(hass) @@ -1490,13 +1535,13 @@ async def test_cancel_inclusion_exclusion(hass, integration, client, hass_ws_cli async def test_remove_node( - hass, + hass: HomeAssistant, integration, client, - hass_ws_client, + hass_ws_client: WebSocketGenerator, nortek_thermostat, nortek_thermostat_removed_event, -): +) -> None: """Test the remove_node websocket command.""" entry = integration ws_client = await hass_ws_client(hass) @@ -1601,14 +1646,14 @@ async def test_remove_node( async def test_replace_failed_node( - hass, + hass: HomeAssistant, nortek_thermostat, integration, client, - hass_ws_client, + hass_ws_client: WebSocketGenerator, nortek_thermostat_added_event, nortek_thermostat_removed_event, -): +) -> None: """Test the replace_failed_node websocket command.""" entry = integration ws_client = await hass_ws_client(hass) @@ -1950,14 +1995,14 @@ async def test_replace_failed_node( async def test_remove_failed_node( - hass, + hass: HomeAssistant, nortek_thermostat, integration, client, - hass_ws_client, + hass_ws_client: WebSocketGenerator, nortek_thermostat_removed_event, nortek_thermostat_added_event, -): +) -> None: """Test the remove_failed_node websocket command.""" entry = integration ws_client = await hass_ws_client(hass) @@ -2037,11 +2082,11 @@ async def test_remove_failed_node( async def test_begin_healing_network( - hass, + hass: HomeAssistant, integration, client, - hass_ws_client, -): + hass_ws_client: WebSocketGenerator, +) -> None: """Test the begin_healing_network websocket command.""" entry = integration ws_client = await hass_ws_client(hass) @@ -2096,8 +2141,8 @@ async def test_begin_healing_network( async def test_subscribe_heal_network_progress( - hass, integration, client, hass_ws_client -): + hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator +) -> None: """Test the subscribe_heal_network_progress command.""" entry = integration ws_client = await hass_ws_client(hass) @@ -2146,8 +2191,8 @@ async def test_subscribe_heal_network_progress( async def test_subscribe_heal_network_progress_initial_value( - hass, integration, client, hass_ws_client -): + hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator +) -> None: """Test subscribe_heal_network_progress command when heal network in progress.""" entry = integration ws_client = await hass_ws_client(hass) @@ -2179,11 +2224,11 @@ async def test_subscribe_heal_network_progress_initial_value( async def test_stop_healing_network( - hass, + hass: HomeAssistant, integration, client, - hass_ws_client, -): + hass_ws_client: WebSocketGenerator, +) -> None: """Test the stop_healing_network websocket command.""" entry = integration ws_client = await hass_ws_client(hass) @@ -2238,12 +2283,12 @@ async def test_stop_healing_network( async def test_heal_node( - hass, + hass: HomeAssistant, multisensor_6, integration, client, - hass_ws_client, -): + hass_ws_client: WebSocketGenerator, +) -> None: """Test the heal_node websocket command.""" entry = integration ws_client = await hass_ws_client(hass) @@ -2299,8 +2344,12 @@ async def test_heal_node( async def test_refresh_node_info( - hass, client, multisensor_6, integration, hass_ws_client -): + hass: HomeAssistant, + client, + multisensor_6, + integration, + hass_ws_client: WebSocketGenerator, +) -> None: """Test that the refresh_node_info WS API call works.""" entry = integration ws_client = await hass_ws_client(hass) @@ -2423,8 +2472,12 @@ async def test_refresh_node_info( async def test_refresh_node_values( - hass, client, multisensor_6, integration, hass_ws_client -): + hass: HomeAssistant, + client, + multisensor_6, + integration, + hass_ws_client: WebSocketGenerator, +) -> None: """Test that the refresh_node_values WS API call works.""" entry = integration ws_client = await hass_ws_client(hass) @@ -2496,8 +2549,12 @@ async def test_refresh_node_values( async def test_refresh_node_cc_values( - hass, multisensor_6, client, integration, hass_ws_client -): + hass: HomeAssistant, + multisensor_6, + client, + integration, + hass_ws_client: WebSocketGenerator, +) -> None: """Test that the refresh_node_cc_values WS API call works.""" entry = integration ws_client = await hass_ws_client(hass) @@ -2587,8 +2644,12 @@ async def test_refresh_node_cc_values( async def test_set_config_parameter( - hass, multisensor_6, client, hass_ws_client, integration -): + hass: HomeAssistant, + multisensor_6, + client, + hass_ws_client: WebSocketGenerator, + integration, +) -> None: """Test the set_config_parameter service.""" entry = integration ws_client = await hass_ws_client(hass) @@ -2772,7 +2833,9 @@ async def test_set_config_parameter( assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_get_config_parameters(hass, multisensor_6, integration, hass_ws_client): +async def test_get_config_parameters( + hass: HomeAssistant, multisensor_6, integration, hass_ws_client: WebSocketGenerator +) -> None: """Test the get config parameters websocket command.""" entry = integration ws_client = await hass_ws_client(hass) @@ -2831,8 +2894,12 @@ async def test_get_config_parameters(hass, multisensor_6, integration, hass_ws_c async def test_firmware_upload_view( - hass, multisensor_6, integration, hass_client, firmware_file -): + hass: HomeAssistant, + multisensor_6, + integration, + hass_client: ClientSessionGenerator, + firmware_file, +) -> None: """Test the HTTP firmware upload view.""" client = await hass_client() device = get_device(hass, multisensor_6) @@ -2857,8 +2924,12 @@ async def test_firmware_upload_view( async def test_firmware_upload_view_failed_command( - hass, multisensor_6, integration, hass_client, firmware_file -): + hass: HomeAssistant, + multisensor_6, + integration, + hass_client: ClientSessionGenerator, + firmware_file, +) -> None: """Test failed command for the HTTP firmware upload view.""" client = await hass_client() device = get_device(hass, multisensor_6) @@ -2874,8 +2945,8 @@ async def test_firmware_upload_view_failed_command( async def test_firmware_upload_view_invalid_payload( - hass, multisensor_6, integration, hass_client -): + hass: HomeAssistant, multisensor_6, integration, hass_client: ClientSessionGenerator +) -> None: """Test an invalid payload for the HTTP firmware upload view.""" device = get_device(hass, multisensor_6) client = await hass_client() @@ -2891,8 +2962,14 @@ async def test_firmware_upload_view_invalid_payload( [("post", "/api/zwave_js/firmware/upload/{}")], ) async def test_node_view_non_admin_user( - hass, multisensor_6, integration, hass_client, hass_admin_user, method, url -): + hass: HomeAssistant, + multisensor_6, + integration, + hass_client: ClientSessionGenerator, + hass_admin_user: MockUser, + method, + url, +) -> None: """Test node level views for non-admin users.""" client = await hass_client() device = get_device(hass, multisensor_6) @@ -2909,8 +2986,13 @@ async def test_node_view_non_admin_user( ], ) async def test_view_unloaded_config_entry( - hass, multisensor_6, integration, hass_client, method, url -): + hass: HomeAssistant, + multisensor_6, + integration, + hass_client: ClientSessionGenerator, + method, + url, +) -> None: """Test an unloaded config entry raises Bad Request.""" client = await hass_client() device = get_device(hass, multisensor_6) @@ -2923,14 +3005,18 @@ async def test_view_unloaded_config_entry( ("method", "url"), [("post", "/api/zwave_js/firmware/upload/INVALID")], ) -async def test_view_invalid_device_id(integration, hass_client, method, url): +async def test_view_invalid_device_id( + integration, hass_client: ClientSessionGenerator, method, url +) -> None: """Test an invalid device id parameter.""" client = await hass_client() resp = await client.request(method, url.format(integration.entry_id)) assert resp.status == HTTPStatus.NOT_FOUND -async def test_subscribe_log_updates(hass, integration, client, hass_ws_client): +async def test_subscribe_log_updates( + hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator +) -> None: """Test the subscribe_log_updates websocket command.""" entry = integration ws_client = await hass_ws_client(hass) @@ -3033,7 +3119,9 @@ async def test_subscribe_log_updates(hass, integration, client, hass_ws_client): assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_update_log_config(hass, client, integration, hass_ws_client): +async def test_update_log_config( + hass: HomeAssistant, client, integration, hass_ws_client: WebSocketGenerator +) -> None: """Test that the update_log_config WS API call works and that schema validation works.""" entry = integration ws_client = await hass_ws_client(hass) @@ -3189,7 +3277,9 @@ async def test_update_log_config(hass, client, integration, hass_ws_client): assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_get_log_config(hass, client, integration, hass_ws_client): +async def test_get_log_config( + hass: HomeAssistant, client, integration, hass_ws_client: WebSocketGenerator +) -> None: """Test that the get_log_config WS API call works.""" entry = integration ws_client = await hass_ws_client(hass) @@ -3230,7 +3320,9 @@ async def test_get_log_config(hass, client, integration, hass_ws_client): assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_data_collection(hass, client, integration, hass_ws_client): +async def test_data_collection( + hass: HomeAssistant, client, integration, hass_ws_client: WebSocketGenerator +) -> None: """Test that the data collection WS API commands work.""" entry = integration ws_client = await hass_ws_client(hass) @@ -3368,8 +3460,12 @@ async def test_data_collection(hass, client, integration, hass_ws_client): async def test_abort_firmware_update( - hass, client, multisensor_6, integration, hass_ws_client -): + hass: HomeAssistant, + client, + multisensor_6, + integration, + hass_ws_client: WebSocketGenerator, +) -> None: """Test that the abort_firmware_update WS API call works.""" entry = integration ws_client = await hass_ws_client(hass) @@ -3440,8 +3536,12 @@ async def test_abort_firmware_update( async def test_is_node_firmware_update_in_progress( - hass, client, multisensor_6, integration, hass_ws_client -): + hass: HomeAssistant, + client, + multisensor_6, + integration, + hass_ws_client: WebSocketGenerator, +) -> None: """Test that the is_firmware_update_in_progress WS API call works.""" entry = integration ws_client = await hass_ws_client(hass) @@ -3500,8 +3600,12 @@ async def test_is_node_firmware_update_in_progress( async def test_subscribe_firmware_update_status( - hass, multisensor_6, integration, client, hass_ws_client -): + hass: HomeAssistant, + multisensor_6, + integration, + client, + hass_ws_client: WebSocketGenerator, +) -> None: """Test the subscribe_firmware_update_status websocket command.""" ws_client = await hass_ws_client(hass) device = get_device(hass, multisensor_6) @@ -3574,8 +3678,12 @@ async def test_subscribe_firmware_update_status( async def test_subscribe_firmware_update_status_initial_value( - hass, multisensor_6, client, integration, hass_ws_client -): + hass: HomeAssistant, + multisensor_6, + client, + integration, + hass_ws_client: WebSocketGenerator, +) -> None: """Test subscribe_firmware_update_status websocket command with in progress update.""" ws_client = await hass_ws_client(hass) device = get_device(hass, multisensor_6) @@ -3626,8 +3734,12 @@ async def test_subscribe_firmware_update_status_initial_value( async def test_subscribe_firmware_update_status_failures( - hass, multisensor_6, client, integration, hass_ws_client -): + hass: HomeAssistant, + multisensor_6, + client, + integration, + hass_ws_client: WebSocketGenerator, +) -> None: """Test failures for the subscribe_firmware_update_status websocket command.""" entry = integration ws_client = await hass_ws_client(hass) @@ -3663,8 +3775,12 @@ async def test_subscribe_firmware_update_status_failures( async def test_get_node_firmware_update_capabilities( - hass, client, multisensor_6, integration, hass_ws_client -): + hass: HomeAssistant, + client, + multisensor_6, + integration, + hass_ws_client: WebSocketGenerator, +) -> None: """Test that the get_node_firmware_update_capabilities WS API call works.""" entry = integration ws_client = await hass_ws_client(hass) @@ -3748,8 +3864,8 @@ async def test_get_node_firmware_update_capabilities( async def test_is_any_ota_firmware_update_in_progress( - hass, client, integration, hass_ws_client -): + hass: HomeAssistant, client, integration, hass_ws_client: WebSocketGenerator +) -> None: """Test that the is_any_ota_firmware_update_in_progress WS API call works.""" entry = integration ws_client = await hass_ws_client(hass) @@ -3818,7 +3934,9 @@ async def test_is_any_ota_firmware_update_in_progress( assert msg["error"]["code"] == ERR_NOT_FOUND -async def test_check_for_config_updates(hass, client, integration, hass_ws_client): +async def test_check_for_config_updates( + hass: HomeAssistant, client, integration, hass_ws_client: WebSocketGenerator +) -> None: """Test that the check_for_config_updates WS API call works.""" entry = integration ws_client = await hass_ws_client(hass) @@ -3891,7 +4009,9 @@ async def test_check_for_config_updates(hass, client, integration, hass_ws_clien assert msg["error"]["code"] == ERR_NOT_FOUND -async def test_install_config_update(hass, client, integration, hass_ws_client): +async def test_install_config_update( + hass: HomeAssistant, client, integration, hass_ws_client: WebSocketGenerator +) -> None: """Test that the install_config_update WS API call works.""" entry = integration ws_client = await hass_ws_client(hass) @@ -3957,8 +4077,8 @@ async def test_install_config_update(hass, client, integration, hass_ws_client): async def test_subscribe_controller_statistics( - hass, integration, client, hass_ws_client -): + hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator +) -> None: """Test the subscribe_controller_statistics command.""" entry = integration ws_client = await hass_ws_client(hass) @@ -4056,14 +4176,14 @@ async def test_subscribe_controller_statistics( async def test_subscribe_node_statistics( - hass, + hass: HomeAssistant, multisensor_6, wallmote_central_scene, zen_31, integration, client, - hass_ws_client, -): + hass_ws_client: WebSocketGenerator, +) -> None: """Test the subscribe_node_statistics command.""" entry = integration ws_client = await hass_ws_client(hass) diff --git a/tests/components/zwave_js/test_binary_sensor.py b/tests/components/zwave_js/test_binary_sensor.py index 847528d703..ca7d20aa9c 100644 --- a/tests/components/zwave_js/test_binary_sensor.py +++ b/tests/components/zwave_js/test_binary_sensor.py @@ -25,7 +25,9 @@ from .common import ( from tests.common import MockConfigEntry -async def test_low_battery_sensor(hass, multisensor_6, integration): +async def test_low_battery_sensor( + hass: HomeAssistant, multisensor_6, integration +) -> None: """Test boolean binary sensor of type low battery.""" state = hass.states.get(LOW_BATTERY_BINARY_SENSOR) @@ -40,7 +42,9 @@ async def test_low_battery_sensor(hass, multisensor_6, integration): assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC -async def test_enabled_legacy_sensor(hass, ecolink_door_sensor, integration): +async def test_enabled_legacy_sensor( + hass: HomeAssistant, ecolink_door_sensor, integration +) -> None: """Test enabled legacy boolean binary sensor.""" node = ecolink_door_sensor # this node has Notification CC not (fully) implemented @@ -98,7 +102,9 @@ async def test_enabled_legacy_sensor(hass, ecolink_door_sensor, integration): assert state.state == STATE_UNKNOWN -async def test_disabled_legacy_sensor(hass, multisensor_6, integration): +async def test_disabled_legacy_sensor( + hass: HomeAssistant, multisensor_6, integration +) -> None: """Test disabled legacy boolean binary sensor.""" # this node has Notification CC implemented so legacy binary sensor should be disabled @@ -119,7 +125,9 @@ async def test_disabled_legacy_sensor(hass, multisensor_6, integration): assert updated_entry.disabled is False -async def test_notification_sensor(hass, multisensor_6, integration): +async def test_notification_sensor( + hass: HomeAssistant, multisensor_6, integration +) -> None: """Test binary sensor created from Notification CC.""" state = hass.states.get(NOTIFICATION_MOTION_BINARY_SENSOR) @@ -143,7 +151,7 @@ async def test_notification_sensor(hass, multisensor_6, integration): async def test_notification_off_state( hass: HomeAssistant, lock_popp_electric_strike_lock_control: Node, -): +) -> None: """Test the description off_state attribute of certain notification sensors.""" node = lock_popp_electric_strike_lock_control # Remove all other values except the door state value. @@ -172,7 +180,9 @@ async def test_notification_off_state( assert state.entity_id == "binary_sensor.node_62_window_door_is_open" -async def test_property_sensor_door_status(hass, lock_august_pro, integration): +async def test_property_sensor_door_status( + hass: HomeAssistant, lock_august_pro, integration +) -> None: """Test property binary sensor with sensor mapping (doorStatus).""" node = lock_august_pro diff --git a/tests/components/zwave_js/test_button.py b/tests/components/zwave_js/test_button.py index 27bd41b314..82a12981d4 100644 --- a/tests/components/zwave_js/test_button.py +++ b/tests/components/zwave_js/test_button.py @@ -1,18 +1,21 @@ """Test the Z-Wave JS button entities.""" +import pytest + from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.zwave_js.const import DOMAIN, SERVICE_REFRESH_VALUE from homeassistant.components.zwave_js.helpers import get_valueless_base_unique_id from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import async_get async def test_ping_entity( - hass, + hass: HomeAssistant, client, climate_radio_thermostat_ct100_plus_different_endpoints, integration, - caplog, -): + caplog: pytest.LogCaptureFixture, +) -> None: """Test ping entity.""" client.async_send_command.return_value = {"responded": True} diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index 62dfacc754..f0c746f7f2 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -36,6 +36,7 @@ from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, ) +from homeassistant.core import HomeAssistant from .common import ( CLIMATE_DANFOSS_LC13_ENTITY, @@ -48,8 +49,8 @@ from .common import ( async def test_thermostat_v2( - hass, client, climate_radio_thermostat_ct100_plus, integration -): + hass: HomeAssistant, client, climate_radio_thermostat_ct100_plus, integration +) -> None: """Test a thermostat v2 command class entity.""" node = climate_radio_thermostat_ct100_plus state = hass.states.get(CLIMATE_RADIO_THERMOSTAT_ENTITY) @@ -281,8 +282,11 @@ async def test_thermostat_v2( async def test_thermostat_different_endpoints( - hass, client, climate_radio_thermostat_ct100_plus_different_endpoints, integration -): + hass: HomeAssistant, + client, + climate_radio_thermostat_ct100_plus_different_endpoints, + integration, +) -> None: """Test an entity with values on a different endpoint from the primary value.""" state = hass.states.get(CLIMATE_RADIO_THERMOSTAT_ENTITY) @@ -292,7 +296,9 @@ async def test_thermostat_different_endpoints( assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING -async def test_setpoint_thermostat(hass, client, climate_danfoss_lc_13, integration): +async def test_setpoint_thermostat( + hass: HomeAssistant, client, climate_danfoss_lc_13, integration +) -> None: """Test a setpoint thermostat command class entity.""" node = climate_danfoss_lc_13 state = hass.states.get(CLIMATE_DANFOSS_LC13_ENTITY) @@ -387,8 +393,8 @@ async def test_setpoint_thermostat(hass, client, climate_danfoss_lc_13, integrat async def test_thermostat_heatit_z_trm3_no_value( - hass, client, climate_heatit_z_trm3_no_value, integration -): + hass: HomeAssistant, client, climate_heatit_z_trm3_no_value, integration +) -> None: """Test a heatit Z-TRM3 entity that is missing a value.""" # When the config parameter that specifies what sensor to use has no value, we fall # back to the first temperature sensor found on the device @@ -397,8 +403,8 @@ async def test_thermostat_heatit_z_trm3_no_value( async def test_thermostat_heatit_z_trm3( - hass, client, climate_heatit_z_trm3, integration -): + hass: HomeAssistant, client, climate_heatit_z_trm3, integration +) -> None: """Test a heatit Z-TRM3 entity.""" node = climate_heatit_z_trm3 state = hass.states.get(CLIMATE_FLOOR_THERMOSTAT_ENTITY) @@ -467,8 +473,8 @@ async def test_thermostat_heatit_z_trm3( async def test_thermostat_heatit_z_trm2fx( - hass, client, climate_heatit_z_trm2fx, integration -): + hass: HomeAssistant, client, climate_heatit_z_trm2fx, integration +) -> None: """Test a heatit Z-TRM2fx entity.""" node = climate_heatit_z_trm2fx state = hass.states.get(CLIMATE_FLOOR_THERMOSTAT_ENTITY) @@ -513,7 +519,9 @@ async def test_thermostat_heatit_z_trm2fx( assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 0 -async def test_thermostat_srt321_hrt4_zw(hass, client, srt321_hrt4_zw, integration): +async def test_thermostat_srt321_hrt4_zw( + hass: HomeAssistant, client, srt321_hrt4_zw, integration +) -> None: """Test a climate entity from a HRT4-ZW / SRT321 thermostat device. This device currently has no setpoint values. @@ -531,8 +539,8 @@ async def test_thermostat_srt321_hrt4_zw(hass, client, srt321_hrt4_zw, integrati async def test_preset_and_no_setpoint( - hass, client, climate_eurotronic_spirit_z, integration -): + hass: HomeAssistant, client, climate_eurotronic_spirit_z, integration +) -> None: """Test preset without setpoint value.""" node = climate_eurotronic_spirit_z @@ -630,12 +638,12 @@ async def test_preset_and_no_setpoint( async def test_temp_unit_fix( - hass, + hass: HomeAssistant, client, climate_radio_thermostat_ct101_multiple_temp_units, climate_radio_thermostat_ct100_mode_and_setpoint_on_different_endpoints, integration, -): +) -> None: """Test temperaturee unit fix.""" state = hass.states.get("climate.thermostat") assert state @@ -647,8 +655,8 @@ async def test_temp_unit_fix( async def test_thermostat_unknown_values( - hass, client, climate_radio_thermostat_ct100_plus_state, integration -): + hass: HomeAssistant, client, climate_radio_thermostat_ct100_plus_state, integration +) -> None: """Test a thermostat v2 with unknown values.""" node_state = replace_value_of_zwave_value( climate_radio_thermostat_ct100_plus_state, diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 1a19f21f44..2b2c9bb413 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -262,7 +262,9 @@ async def slow_server_version(*args): ), ], ) -async def test_manual_errors(hass, integration, url, error, flow, flow_params): +async def test_manual_errors( + hass: HomeAssistant, integration, url, error, flow, flow_params +) -> None: """Test all errors with a manual set up.""" entry = integration result = await getattr(hass.config_entries, flow).async_init(**flow_params(entry)) @@ -319,8 +321,12 @@ async def test_manual_already_configured(hass: HomeAssistant) -> None: @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) async def test_supervisor_discovery( - hass, supervisor, addon_running, addon_options, get_addon_discovery_info -): + hass: HomeAssistant, + supervisor, + addon_running, + addon_options, + get_addon_discovery_info, +) -> None: """Test flow started from Supervisor discovery.""" addon_options["device"] = "/test" @@ -369,8 +375,8 @@ async def test_supervisor_discovery( [({"config": ADDON_DISCOVERY_INFO}, asyncio.TimeoutError())], ) async def test_supervisor_discovery_cannot_connect( - hass, supervisor, get_addon_discovery_info -): + hass: HomeAssistant, supervisor, get_addon_discovery_info +) -> None: """Test Supervisor discovery and cannot connect.""" result = await hass.config_entries.flow.async_init( @@ -389,8 +395,12 @@ async def test_supervisor_discovery_cannot_connect( @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) async def test_clean_discovery_on_user_create( - hass, supervisor, addon_running, addon_options, get_addon_discovery_info -): + hass: HomeAssistant, + supervisor, + addon_running, + addon_options, + get_addon_discovery_info, +) -> None: """Test discovery flow is cleaned up when a user flow is finished.""" addon_options["device"] = "/test" @@ -457,8 +467,8 @@ async def test_clean_discovery_on_user_create( async def test_abort_discovery_with_existing_entry( - hass, supervisor, addon_running, addon_options -): + hass: HomeAssistant, supervisor, addon_running, addon_options +) -> None: """Test discovery flow is aborted if an entry already exists.""" entry = MockConfigEntry( @@ -486,8 +496,8 @@ async def test_abort_discovery_with_existing_entry( async def test_abort_hassio_discovery_with_existing_flow( - hass, supervisor, addon_installed, addon_options -): + hass: HomeAssistant, supervisor, addon_installed, addon_options +) -> None: """Test hassio discovery flow is aborted when another discovery has happened.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -512,8 +522,8 @@ async def test_abort_hassio_discovery_with_existing_flow( async def test_abort_hassio_discovery_for_other_addon( - hass, supervisor, addon_installed, addon_options -): + hass: HomeAssistant, supervisor, addon_installed, addon_options +) -> None: """Test hassio discovery flow is aborted for a non official add-on discovery.""" result2 = await hass.config_entries.flow.async_init( DOMAIN, @@ -535,7 +545,7 @@ async def test_abort_hassio_discovery_for_other_addon( @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) async def test_usb_discovery( - hass, + hass: HomeAssistant, supervisor, addon_not_installed, install_addon, @@ -543,7 +553,7 @@ async def test_usb_discovery( get_addon_discovery_info, set_addon_options, start_addon, -): +) -> None: """Test usb discovery success path.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -625,14 +635,14 @@ async def test_usb_discovery( @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) async def test_usb_discovery_addon_not_running( - hass, + hass: HomeAssistant, supervisor, addon_installed, addon_options, set_addon_options, start_addon, get_addon_discovery_info, -): +) -> None: """Test usb discovery when add-on is installed but not running.""" addon_options["device"] = "/dev/incorrect_device" @@ -714,8 +724,13 @@ async def test_usb_discovery_addon_not_running( async def test_discovery_addon_not_running( - hass, supervisor, addon_installed, addon_options, set_addon_options, start_addon -): + hass: HomeAssistant, + supervisor, + addon_installed, + addon_options, + set_addon_options, + start_addon, +) -> None: """Test discovery with add-on already installed but not running.""" addon_options["device"] = None @@ -794,14 +809,14 @@ async def test_discovery_addon_not_running( async def test_discovery_addon_not_installed( - hass, + hass: HomeAssistant, supervisor, addon_not_installed, install_addon, addon_options, set_addon_options, start_addon, -): +) -> None: """Test discovery with add-on not installed.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -886,7 +901,9 @@ async def test_discovery_addon_not_installed( assert len(mock_setup_entry.mock_calls) == 1 -async def test_abort_usb_discovery_with_existing_flow(hass, supervisor, addon_options): +async def test_abort_usb_discovery_with_existing_flow( + hass: HomeAssistant, supervisor, addon_options +) -> None: """Test usb discovery flow is aborted when another discovery has happened.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -910,7 +927,9 @@ async def test_abort_usb_discovery_with_existing_flow(hass, supervisor, addon_op assert result2["reason"] == "already_in_progress" -async def test_abort_usb_discovery_already_configured(hass, supervisor, addon_options): +async def test_abort_usb_discovery_already_configured( + hass: HomeAssistant, supervisor, addon_options +) -> None: """Test usb discovery flow is aborted when there is an existing entry.""" entry = MockConfigEntry( domain=DOMAIN, @@ -940,7 +959,9 @@ async def test_usb_discovery_requires_supervisor(hass: HomeAssistant) -> None: assert result["reason"] == "discovery_requires_supervisor" -async def test_usb_discovery_already_running(hass, supervisor, addon_running): +async def test_usb_discovery_already_running( + hass: HomeAssistant, supervisor, addon_running +) -> None: """Test usb discovery flow is aborted when the addon is running.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -956,8 +977,8 @@ async def test_usb_discovery_already_running(hass, supervisor, addon_running): [CP2652_ZIGBEE_DISCOVERY_INFO], ) async def test_abort_usb_discovery_aborts_specific_devices( - hass, supervisor, addon_options, discovery_info -): + hass: HomeAssistant, supervisor, addon_options, discovery_info +) -> None: """Test usb discovery flow is aborted on specific devices.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -968,7 +989,7 @@ async def test_abort_usb_discovery_aborts_specific_devices( assert result["reason"] == "not_zwave_device" -async def test_not_addon(hass, supervisor): +async def test_not_addon(hass: HomeAssistant, supervisor) -> None: """Test opting out of add-on on Supervisor.""" result = await hass.config_entries.flow.async_init( @@ -1017,12 +1038,12 @@ async def test_not_addon(hass, supervisor): @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) async def test_addon_running( - hass, + hass: HomeAssistant, supervisor, addon_running, addon_options, get_addon_discovery_info, -): +) -> None: """Test add-on already running on Supervisor.""" addon_options["device"] = "/test" addon_options["s0_legacy_key"] = "new123" @@ -1104,13 +1125,13 @@ async def test_addon_running( ], ) async def test_addon_running_failures( - hass, + hass: HomeAssistant, supervisor, addon_running, addon_options, get_addon_discovery_info, abort_reason, -): +) -> None: """Test all failures when add-on is running.""" addon_options["device"] = "/test" addon_options["network_key"] = "abc123" @@ -1132,8 +1153,12 @@ async def test_addon_running_failures( @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) async def test_addon_running_already_configured( - hass, supervisor, addon_running, addon_options, get_addon_discovery_info -): + hass: HomeAssistant, + supervisor, + addon_running, + addon_options, + get_addon_discovery_info, +) -> None: """Test that only one unique instance is allowed when add-on is running.""" addon_options["device"] = "/test_new" addon_options["s0_legacy_key"] = "new123" @@ -1180,14 +1205,14 @@ async def test_addon_running_already_configured( @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) async def test_addon_installed( - hass, + hass: HomeAssistant, supervisor, addon_installed, addon_options, set_addon_options, start_addon, get_addon_discovery_info, -): +) -> None: """Test add-on already installed but not running on Supervisor.""" result = await hass.config_entries.flow.async_init( @@ -1265,14 +1290,14 @@ async def test_addon_installed( [({"config": ADDON_DISCOVERY_INFO}, HassioAPIError())], ) async def test_addon_installed_start_failure( - hass, + hass: HomeAssistant, supervisor, addon_installed, addon_options, set_addon_options, start_addon, get_addon_discovery_info, -): +) -> None: """Test add-on start failure when add-on is installed.""" result = await hass.config_entries.flow.async_init( @@ -1340,14 +1365,14 @@ async def test_addon_installed_start_failure( ], ) async def test_addon_installed_failures( - hass, + hass: HomeAssistant, supervisor, addon_installed, addon_options, set_addon_options, start_addon, get_addon_discovery_info, -): +) -> None: """Test all failures when add-on is installed.""" result = await hass.config_entries.flow.async_init( @@ -1406,14 +1431,14 @@ async def test_addon_installed_failures( [(HassioAPIError(), {"config": ADDON_DISCOVERY_INFO})], ) async def test_addon_installed_set_options_failure( - hass, + hass: HomeAssistant, supervisor, addon_installed, addon_options, set_addon_options, start_addon, get_addon_discovery_info, -): +) -> None: """Test all failures when add-on is installed.""" result = await hass.config_entries.flow.async_init( @@ -1463,14 +1488,14 @@ async def test_addon_installed_set_options_failure( @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) async def test_addon_installed_already_configured( - hass, + hass: HomeAssistant, supervisor, addon_installed, addon_options, set_addon_options, start_addon, get_addon_discovery_info, -): +) -> None: """Test that only one unique instance is allowed when add-on is installed.""" entry = MockConfigEntry( domain=DOMAIN, @@ -1547,7 +1572,7 @@ async def test_addon_installed_already_configured( @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) async def test_addon_not_installed( - hass, + hass: HomeAssistant, supervisor, addon_not_installed, install_addon, @@ -1555,7 +1580,7 @@ async def test_addon_not_installed( set_addon_options, start_addon, get_addon_discovery_info, -): +) -> None: """Test add-on not installed.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -1638,8 +1663,8 @@ async def test_addon_not_installed( async def test_install_addon_failure( - hass, supervisor, addon_not_installed, install_addon -): + hass: HomeAssistant, supervisor, addon_not_installed, install_addon +) -> None: """Test add-on install failure.""" install_addon.side_effect = HassioAPIError() @@ -1667,7 +1692,7 @@ async def test_install_addon_failure( assert result["reason"] == "addon_install_failed" -async def test_options_manual(hass, client, integration): +async def test_options_manual(hass: HomeAssistant, client, integration) -> None: """Test manual settings in options flow.""" entry = integration entry.unique_id = "1234" @@ -1693,7 +1718,9 @@ async def test_options_manual(hass, client, integration): assert client.disconnect.call_count == 1 -async def test_options_manual_different_device(hass, integration): +async def test_options_manual_different_device( + hass: HomeAssistant, integration +) -> None: """Test options flow manual step connecting to different device.""" entry = integration entry.unique_id = 5678 @@ -1712,7 +1739,9 @@ async def test_options_manual_different_device(hass, integration): assert result["reason"] == "different_device" -async def test_options_not_addon(hass, client, supervisor, integration): +async def test_options_not_addon( + hass: HomeAssistant, client, supervisor, integration +) -> None: """Test options flow and opting out of add-on on Supervisor.""" entry = integration entry.unique_id = "1234" @@ -1804,7 +1833,7 @@ async def test_options_not_addon(hass, client, supervisor, integration): ], ) async def test_options_addon_running( - hass, + hass: HomeAssistant, client, supervisor, integration, @@ -1818,7 +1847,7 @@ async def test_options_addon_running( old_addon_options, new_addon_options, disconnect_calls, -): +) -> None: """Test options flow and add-on already running on Supervisor.""" addon_options.update(old_addon_options) entry = integration @@ -1915,7 +1944,7 @@ async def test_options_addon_running( ], ) async def test_options_addon_running_no_changes( - hass, + hass: HomeAssistant, client, supervisor, integration, @@ -1928,7 +1957,7 @@ async def test_options_addon_running_no_changes( entry_data, old_addon_options, new_addon_options, -): +) -> None: """Test options flow without changes, and add-on already running on Supervisor.""" addon_options.update(old_addon_options) entry = integration @@ -2057,7 +2086,7 @@ async def different_device_server_version(*args): ], ) async def test_options_different_device( - hass, + hass: HomeAssistant, client, supervisor, integration, @@ -2072,7 +2101,7 @@ async def test_options_different_device( new_addon_options, disconnect_calls, server_version_side_effect, -): +) -> None: """Test options flow and configuring a different device.""" addon_options.update(old_addon_options) entry = integration @@ -2216,7 +2245,7 @@ async def test_options_different_device( ], ) async def test_options_addon_restart_failed( - hass, + hass: HomeAssistant, client, supervisor, integration, @@ -2231,7 +2260,7 @@ async def test_options_addon_restart_failed( new_addon_options, disconnect_calls, restart_addon_side_effect, -): +) -> None: """Test options flow and add-on restart failure.""" addon_options.update(old_addon_options) entry = integration @@ -2344,7 +2373,7 @@ async def test_options_addon_restart_failed( ], ) async def test_options_addon_running_server_info_failure( - hass, + hass: HomeAssistant, client, supervisor, integration, @@ -2359,7 +2388,7 @@ async def test_options_addon_running_server_info_failure( new_addon_options, disconnect_calls, server_version_side_effect, -): +) -> None: """Test options flow and add-on already running with server info failure.""" addon_options.update(old_addon_options) entry = integration @@ -2453,7 +2482,7 @@ async def test_options_addon_running_server_info_failure( ], ) async def test_options_addon_not_installed( - hass, + hass: HomeAssistant, client, supervisor, addon_not_installed, @@ -2468,7 +2497,7 @@ async def test_options_addon_not_installed( old_addon_options, new_addon_options, disconnect_calls, -): +) -> None: """Test options flow and add-on not installed on Supervisor.""" addon_options.update(old_addon_options) entry = integration @@ -2540,7 +2569,7 @@ async def test_options_addon_not_installed( @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) async def test_import_addon_installed( - hass, + hass: HomeAssistant, supervisor, addon_installed, addon_options, @@ -2548,7 +2577,7 @@ async def test_import_addon_installed( start_addon, get_addon_discovery_info, serial_port, -): +) -> None: """Test import step while add-on already installed on Supervisor.""" serial_port.device = "/test/imported" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/zwave_js/test_cover.py b/tests/components/zwave_js/test_cover.py index d2c8fc32a7..f1e9366593 100644 --- a/tests/components/zwave_js/test_cover.py +++ b/tests/components/zwave_js/test_cover.py @@ -24,6 +24,7 @@ from homeassistant.const import ( STATE_OPENING, STATE_UNKNOWN, ) +from homeassistant.core import HomeAssistant from .common import replace_value_of_zwave_value @@ -35,7 +36,9 @@ AEOTEC_SHUTTER_COVER_ENTITY = "cover.nano_shutter_v_3" FIBARO_SHUTTER_COVER_ENTITY = "cover.fgr_222_test_cover" -async def test_window_cover(hass, client, chain_actuator_zws12, integration): +async def test_window_cover( + hass: HomeAssistant, client, chain_actuator_zws12, integration +) -> None: """Test the cover entity.""" node = chain_actuator_zws12 state = hass.states.get(WINDOW_COVER_ENTITY) @@ -216,8 +219,8 @@ async def test_window_cover(hass, client, chain_actuator_zws12, integration): async def test_fibaro_FGR222_shutter_cover( - hass, client, fibaro_fgr222_shutter, integration -): + hass: HomeAssistant, client, fibaro_fgr222_shutter, integration +) -> None: """Test tilt function of the Fibaro Shutter devices.""" state = hass.states.get(FIBARO_SHUTTER_COVER_ENTITY) assert state @@ -294,8 +297,8 @@ async def test_fibaro_FGR222_shutter_cover( async def test_aeotec_nano_shutter_cover( - hass, client, aeotec_nano_shutter, integration -): + hass: HomeAssistant, client, aeotec_nano_shutter, integration +) -> None: """Test movement of an Aeotec Nano Shutter cover entity. Useful to make sure the stop command logic is handled properly.""" node = aeotec_nano_shutter state = hass.states.get(AEOTEC_SHUTTER_COVER_ENTITY) @@ -410,7 +413,9 @@ async def test_aeotec_nano_shutter_cover( assert not open_args["value"] -async def test_blind_cover(hass, client, iblinds_v2, integration): +async def test_blind_cover( + hass: HomeAssistant, client, iblinds_v2, integration +) -> None: """Test a blind cover entity.""" state = hass.states.get(BLIND_COVER_ENTITY) @@ -418,7 +423,9 @@ async def test_blind_cover(hass, client, iblinds_v2, integration): assert state.attributes[ATTR_DEVICE_CLASS] == CoverDeviceClass.BLIND -async def test_shutter_cover(hass, client, qubino_shutter, integration): +async def test_shutter_cover( + hass: HomeAssistant, client, qubino_shutter, integration +) -> None: """Test a shutter cover entity.""" state = hass.states.get(SHUTTER_COVER_ENTITY) @@ -426,7 +433,9 @@ async def test_shutter_cover(hass, client, qubino_shutter, integration): assert state.attributes[ATTR_DEVICE_CLASS] == CoverDeviceClass.SHUTTER -async def test_motor_barrier_cover(hass, client, gdc_zw062, integration): +async def test_motor_barrier_cover( + hass: HomeAssistant, client, gdc_zw062, integration +) -> None: """Test the cover entity.""" node = gdc_zw062 @@ -597,8 +606,8 @@ async def test_motor_barrier_cover(hass, client, gdc_zw062, integration): async def test_motor_barrier_cover_no_primary_value( - hass, client, gdc_zw062_state, integration -): + hass: HomeAssistant, client, gdc_zw062_state, integration +) -> None: """Test the cover entity where primary value value is None.""" node_state = replace_value_of_zwave_value( gdc_zw062_state, @@ -623,8 +632,8 @@ async def test_motor_barrier_cover_no_primary_value( async def test_fibaro_FGR222_shutter_cover_no_tilt( - hass, client, fibaro_fgr222_shutter_state, integration -): + hass: HomeAssistant, client, fibaro_fgr222_shutter_state, integration +) -> None: """Test tilt function of the Fibaro Shutter devices with tilt value is None.""" node_state = replace_value_of_zwave_value( fibaro_fgr222_shutter_state, diff --git a/tests/components/zwave_js/test_device_action.py b/tests/components/zwave_js/test_device_action.py index a60f10761b..de94cb6c5c 100644 --- a/tests/components/zwave_js/test_device_action.py +++ b/tests/components/zwave_js/test_device_action.py @@ -415,7 +415,7 @@ async def test_get_action_capabilities( client: Client, climate_radio_thermostat_ct100_plus: Node, integration: ConfigEntry, -): +) -> None: """Test we get the expected action capabilities.""" dev_reg = device_registry.async_get(hass) device = dev_reg.async_get_device( @@ -582,7 +582,7 @@ async def test_get_action_capabilities_lock_triggers( client: Client, lock_schlage_be469: Node, integration: ConfigEntry, -): +) -> None: """Test we get the expected action capabilities for lock triggers.""" dev_reg = device_registry.async_get(hass) device = device_registry.async_entries_for_config_entry( @@ -662,7 +662,7 @@ async def test_failure_scenarios( client: Client, hank_binary_switch: Node, integration: ConfigEntry, -): +) -> None: """Test failure scenarios.""" dev_reg = device_registry.async_get(hass) device = device_registry.async_entries_for_config_entry( diff --git a/tests/components/zwave_js/test_device_condition.py b/tests/components/zwave_js/test_device_condition.py index 878810ddc5..3902a29861 100644 --- a/tests/components/zwave_js/test_device_condition.py +++ b/tests/components/zwave_js/test_device_condition.py @@ -19,6 +19,7 @@ from homeassistant.components.zwave_js.helpers import ( get_device_id, get_zwave_value_from_config, ) +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, device_registry from homeassistant.setup import async_setup_component @@ -32,7 +33,9 @@ def calls(hass): return async_mock_service(hass, "test", "automation") -async def test_get_conditions(hass, client, lock_schlage_be469, integration) -> None: +async def test_get_conditions( + hass: HomeAssistant, client, lock_schlage_be469, integration +) -> None: """Test we get the expected onditions from a zwave_js.""" dev_reg = device_registry.async_get(hass) device = dev_reg.async_get_device( @@ -88,7 +91,7 @@ async def test_get_conditions(hass, client, lock_schlage_be469, integration) -> async def test_node_status_state( - hass, client, lock_schlage_be469, integration, calls + hass: HomeAssistant, client, lock_schlage_be469, integration, calls ) -> None: """Test for node_status conditions.""" dev_reg = device_registry.async_get(hass) @@ -249,7 +252,7 @@ async def test_node_status_state( async def test_config_parameter_state( - hass, client, lock_schlage_be469, integration, calls + hass: HomeAssistant, client, lock_schlage_be469, integration, calls ) -> None: """Test for config_parameter conditions.""" dev_reg = device_registry.async_get(hass) @@ -365,7 +368,7 @@ async def test_config_parameter_state( async def test_value_state( - hass, client, lock_schlage_be469, integration, calls + hass: HomeAssistant, client, lock_schlage_be469, integration, calls ) -> None: """Test for value conditions.""" dev_reg = device_registry.async_get(hass) @@ -413,8 +416,8 @@ async def test_value_state( async def test_get_condition_capabilities_node_status( - hass, client, lock_schlage_be469, integration -): + hass: HomeAssistant, client, lock_schlage_be469, integration +) -> None: """Test we don't get capabilities from a node_status condition.""" dev_reg = device_registry.async_get(hass) device = dev_reg.async_get_device( @@ -450,8 +453,8 @@ async def test_get_condition_capabilities_node_status( async def test_get_condition_capabilities_value( - hass, client, lock_schlage_be469, integration -): + hass: HomeAssistant, client, lock_schlage_be469, integration +) -> None: """Test we get the expected capabilities from a value condition.""" dev_reg = device_registry.async_get(hass) device = dev_reg.async_get_device( @@ -499,8 +502,8 @@ async def test_get_condition_capabilities_value( async def test_get_condition_capabilities_config_parameter( - hass, client, climate_radio_thermostat_ct100_plus, integration -): + hass: HomeAssistant, client, climate_radio_thermostat_ct100_plus, integration +) -> None: """Test we get the expected capabilities from a config_parameter condition.""" node = climate_radio_thermostat_ct100_plus dev_reg = device_registry.async_get(hass) @@ -581,7 +584,9 @@ async def test_get_condition_capabilities_config_parameter( assert not capabilities -async def test_failure_scenarios(hass, client, hank_binary_switch, integration): +async def test_failure_scenarios( + hass: HomeAssistant, client, hank_binary_switch, integration +) -> None: """Test failure scenarios.""" dev_reg = device_registry.async_get(hass) device = dev_reg.async_get_device( @@ -651,8 +656,8 @@ async def test_failure_scenarios(hass, client, hank_binary_switch, integration): async def test_get_value_from_config_failure( - hass, client, hank_binary_switch, integration -): + hass: HomeAssistant, client, hank_binary_switch, integration +) -> None: """Test get_value_from_config invalid value ID.""" with pytest.raises(vol.Invalid): get_zwave_value_from_config( diff --git a/tests/components/zwave_js/test_device_trigger.py b/tests/components/zwave_js/test_device_trigger.py index 336923d83f..7f32409832 100644 --- a/tests/components/zwave_js/test_device_trigger.py +++ b/tests/components/zwave_js/test_device_trigger.py @@ -17,6 +17,7 @@ from homeassistant.components.zwave_js.helpers import ( async_get_node_status_sensor_entity_id, get_device_id, ) +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import async_get as async_get_dev_reg @@ -36,7 +37,7 @@ def calls(hass): return async_mock_service(hass, "test", "automation") -async def test_no_controller_triggers(hass, client, integration): +async def test_no_controller_triggers(hass: HomeAssistant, client, integration) -> None: """Test that we do not get triggers for the controller.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( @@ -52,8 +53,8 @@ async def test_no_controller_triggers(hass, client, integration): async def test_get_notification_notification_triggers( - hass, client, lock_schlage_be469, integration -): + hass: HomeAssistant, client, lock_schlage_be469, integration +) -> None: """Test we get the expected triggers from a zwave_js device with the Notification CC.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( @@ -75,8 +76,8 @@ async def test_get_notification_notification_triggers( async def test_if_notification_notification_fires( - hass, client, lock_schlage_be469, integration, calls -): + hass: HomeAssistant, client, lock_schlage_be469, integration, calls +) -> None: """Test for event.notification.notification trigger firing.""" node: Node = lock_schlage_be469 dev_reg = async_get_dev_reg(hass) @@ -172,8 +173,8 @@ async def test_if_notification_notification_fires( async def test_get_trigger_capabilities_notification_notification( - hass, client, lock_schlage_be469, integration -): + hass: HomeAssistant, client, lock_schlage_be469, integration +) -> None: """Test we get the expected capabilities from a notification.notification trigger.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( @@ -206,8 +207,8 @@ async def test_get_trigger_capabilities_notification_notification( async def test_if_entry_control_notification_fires( - hass, client, lock_schlage_be469, integration, calls -): + hass: HomeAssistant, client, lock_schlage_be469, integration, calls +) -> None: """Test for notification.entry_control trigger firing.""" node: Node = lock_schlage_be469 dev_reg = async_get_dev_reg(hass) @@ -302,8 +303,8 @@ async def test_if_entry_control_notification_fires( async def test_get_trigger_capabilities_entry_control_notification( - hass, client, lock_schlage_be469, integration -): + hass: HomeAssistant, client, lock_schlage_be469, integration +) -> None: """Test we get the expected capabilities from a notification.entry_control trigger.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( @@ -333,7 +334,9 @@ async def test_get_trigger_capabilities_entry_control_notification( ) -async def test_get_node_status_triggers(hass, client, lock_schlage_be469, integration): +async def test_get_node_status_triggers( + hass: HomeAssistant, client, lock_schlage_be469, integration +) -> None: """Test we get the expected triggers from a device with node status sensor enabled.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( @@ -363,8 +366,8 @@ async def test_get_node_status_triggers(hass, client, lock_schlage_be469, integr async def test_if_node_status_change_fires( - hass, client, lock_schlage_be469, integration, calls -): + hass: HomeAssistant, client, lock_schlage_be469, integration, calls +) -> None: """Test for node_status trigger firing.""" node: Node = lock_schlage_be469 dev_reg = async_get_dev_reg(hass) @@ -442,8 +445,8 @@ async def test_if_node_status_change_fires( async def test_get_trigger_capabilities_node_status( - hass, client, lock_schlage_be469, integration -): + hass: HomeAssistant, client, lock_schlage_be469, integration +) -> None: """Test we get the expected capabilities from a node_status trigger.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( @@ -500,8 +503,8 @@ async def test_get_trigger_capabilities_node_status( async def test_get_basic_value_notification_triggers( - hass, client, ge_in_wall_dimmer_switch, integration -): + hass: HomeAssistant, client, ge_in_wall_dimmer_switch, integration +) -> None: """Test we get the expected triggers from a zwave_js device with the Basic CC.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( @@ -527,8 +530,8 @@ async def test_get_basic_value_notification_triggers( async def test_if_basic_value_notification_fires( - hass, client, ge_in_wall_dimmer_switch, integration, calls -): + hass: HomeAssistant, client, ge_in_wall_dimmer_switch, integration, calls +) -> None: """Test for event.value_notification.basic trigger firing.""" node: Node = ge_in_wall_dimmer_switch dev_reg = async_get_dev_reg(hass) @@ -639,8 +642,8 @@ async def test_if_basic_value_notification_fires( async def test_get_trigger_capabilities_basic_value_notification( - hass, client, ge_in_wall_dimmer_switch, integration -): + hass: HomeAssistant, client, ge_in_wall_dimmer_switch, integration +) -> None: """Test we get the expected capabilities from a value_notification.basic trigger.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( @@ -677,8 +680,8 @@ async def test_get_trigger_capabilities_basic_value_notification( async def test_get_central_scene_value_notification_triggers( - hass, client, wallmote_central_scene, integration -): + hass: HomeAssistant, client, wallmote_central_scene, integration +) -> None: """Test we get the expected triggers from a zwave_js device with the Central Scene CC.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( @@ -704,8 +707,8 @@ async def test_get_central_scene_value_notification_triggers( async def test_if_central_scene_value_notification_fires( - hass, client, wallmote_central_scene, integration, calls -): + hass: HomeAssistant, client, wallmote_central_scene, integration, calls +) -> None: """Test for event.value_notification.central_scene trigger firing.""" node: Node = wallmote_central_scene dev_reg = async_get_dev_reg(hass) @@ -822,8 +825,8 @@ async def test_if_central_scene_value_notification_fires( async def test_get_trigger_capabilities_central_scene_value_notification( - hass, client, wallmote_central_scene, integration -): + hass: HomeAssistant, client, wallmote_central_scene, integration +) -> None: """Test we get the expected capabilities from a value_notification.central_scene trigger.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( @@ -859,8 +862,8 @@ async def test_get_trigger_capabilities_central_scene_value_notification( async def test_get_scene_activation_value_notification_triggers( - hass, client, hank_binary_switch, integration -): + hass: HomeAssistant, client, hank_binary_switch, integration +) -> None: """Test we get the expected triggers from a zwave_js device with the SceneActivation CC.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( @@ -886,8 +889,8 @@ async def test_get_scene_activation_value_notification_triggers( async def test_if_scene_activation_value_notification_fires( - hass, client, hank_binary_switch, integration, calls -): + hass: HomeAssistant, client, hank_binary_switch, integration, calls +) -> None: """Test for event.value_notification.scene_activation trigger firing.""" node: Node = hank_binary_switch dev_reg = async_get_dev_reg(hass) @@ -998,8 +1001,8 @@ async def test_if_scene_activation_value_notification_fires( async def test_get_trigger_capabilities_scene_activation_value_notification( - hass, client, hank_binary_switch, integration -): + hass: HomeAssistant, client, hank_binary_switch, integration +) -> None: """Test we get the expected capabilities from a value_notification.scene_activation trigger.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( @@ -1036,8 +1039,8 @@ async def test_get_trigger_capabilities_scene_activation_value_notification( async def test_get_value_updated_value_triggers( - hass, client, lock_schlage_be469, integration -): + hass: HomeAssistant, client, lock_schlage_be469, integration +) -> None: """Test we get the zwave_js.value_updated.value trigger from a zwave_js device.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( @@ -1058,8 +1061,8 @@ async def test_get_value_updated_value_triggers( async def test_if_value_updated_value_fires( - hass, client, lock_schlage_be469, integration, calls -): + hass: HomeAssistant, client, lock_schlage_be469, integration, calls +) -> None: """Test for zwave_js.value_updated.value trigger firing.""" node: Node = lock_schlage_be469 dev_reg = async_get_dev_reg(hass) @@ -1150,8 +1153,8 @@ async def test_if_value_updated_value_fires( async def test_value_updated_value_no_driver( - hass, client, lock_schlage_be469, integration, calls -): + hass: HomeAssistant, client, lock_schlage_be469, integration, calls +) -> None: """Test zwave_js.value_updated.value trigger with missing driver.""" node: Node = lock_schlage_be469 dev_reg = async_get_dev_reg(hass) @@ -1221,8 +1224,8 @@ async def test_value_updated_value_no_driver( async def test_get_trigger_capabilities_value_updated_value( - hass, client, lock_schlage_be469, integration -): + hass: HomeAssistant, client, lock_schlage_be469, integration +) -> None: """Test we get the expected capabilities from a zwave_js.value_updated.value trigger.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( @@ -1272,8 +1275,8 @@ async def test_get_trigger_capabilities_value_updated_value( async def test_get_value_updated_config_parameter_triggers( - hass, client, lock_schlage_be469, integration -): + hass: HomeAssistant, client, lock_schlage_be469, integration +) -> None: """Test we get the zwave_js.value_updated.config_parameter trigger from a zwave_js device.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( @@ -1299,8 +1302,8 @@ async def test_get_value_updated_config_parameter_triggers( async def test_if_value_updated_config_parameter_fires( - hass, client, lock_schlage_be469, integration, calls -): + hass: HomeAssistant, client, lock_schlage_be469, integration, calls +) -> None: """Test for zwave_js.value_updated.config_parameter trigger firing.""" node: Node = lock_schlage_be469 dev_reg = async_get_dev_reg(hass) @@ -1370,8 +1373,8 @@ async def test_if_value_updated_config_parameter_fires( async def test_get_trigger_capabilities_value_updated_config_parameter_range( - hass, client, lock_schlage_be469, integration -): + hass: HomeAssistant, client, lock_schlage_be469, integration +) -> None: """Test we get the expected capabilities from a range zwave_js.value_updated.config_parameter trigger.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( @@ -1415,8 +1418,8 @@ async def test_get_trigger_capabilities_value_updated_config_parameter_range( async def test_get_trigger_capabilities_value_updated_config_parameter_enumerated( - hass, client, lock_schlage_be469, integration -): + hass: HomeAssistant, client, lock_schlage_be469, integration +) -> None: """Test we get the expected capabilities from an enumerated zwave_js.value_updated.config_parameter trigger.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( @@ -1457,7 +1460,9 @@ async def test_get_trigger_capabilities_value_updated_config_parameter_enumerate ] -async def test_failure_scenarios(hass, client, hank_binary_switch, integration): +async def test_failure_scenarios( + hass: HomeAssistant, client, hank_binary_switch, integration +) -> None: """Test failure scenarios.""" with pytest.raises(HomeAssistantError): await device_trigger.async_attach_trigger( diff --git a/tests/components/zwave_js/test_diagnostics.py b/tests/components/zwave_js/test_diagnostics.py index c649675429..e3c144c4ac 100644 --- a/tests/components/zwave_js/test_diagnostics.py +++ b/tests/components/zwave_js/test_diagnostics.py @@ -13,6 +13,7 @@ from homeassistant.components.zwave_js.helpers import ( get_device_id, get_value_id_from_unique_id, ) +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import async_get as async_get_dev_reg from homeassistant.helpers.entity_registry import async_get as async_get_ent_reg @@ -22,15 +23,16 @@ from tests.components.diagnostics import ( get_diagnostics_for_config_entry, get_diagnostics_for_device, ) +from tests.typing import ClientSessionGenerator async def test_config_entry_diagnostics( - hass, - hass_client, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, integration, config_entry_diagnostics, config_entry_diagnostics_redacted, -): +) -> None: """Test the config entry level diagnostics data dump.""" with patch( "homeassistant.components.zwave_js.diagnostics.dump_msgs", @@ -43,13 +45,13 @@ async def test_config_entry_diagnostics( async def test_device_diagnostics( - hass, + hass: HomeAssistant, client, multisensor_6, integration, - hass_client, + hass_client: ClientSessionGenerator, version_state, -): +) -> None: """Test the device level diagnostics data dump.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device({get_device_id(client.driver, multisensor_6)}) @@ -102,7 +104,7 @@ async def test_device_diagnostics( } -async def test_device_diagnostics_error(hass, integration): +async def test_device_diagnostics_error(hass: HomeAssistant, integration) -> None: """Test the device diagnostics raises exception when an invalid device is used.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_or_create( @@ -119,12 +121,12 @@ async def test_empty_zwave_value_matcher() -> None: async def test_device_diagnostics_missing_primary_value( - hass, + hass: HomeAssistant, client, multisensor_6, integration, - hass_client, -): + hass_client: ClientSessionGenerator, +) -> None: """Test that the device diagnostics handles an entity with a missing primary value.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device({get_device_id(client.driver, multisensor_6)}) diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index c635297b5e..601947f009 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -12,7 +12,7 @@ from homeassistant.components.zwave_js.discovery_data_template import ( from homeassistant.core import HomeAssistant -async def test_iblinds_v2(hass, client, iblinds_v2, integration): +async def test_iblinds_v2(hass: HomeAssistant, client, iblinds_v2, integration) -> None: """Test that an iBlinds v2.0 multilevel switch value is discovered as a cover.""" node = iblinds_v2 assert node.device_class.specific.label == "Unused" @@ -24,7 +24,7 @@ async def test_iblinds_v2(hass, client, iblinds_v2, integration): assert state -async def test_ge_12730(hass, client, ge_12730, integration): +async def test_ge_12730(hass: HomeAssistant, client, ge_12730, integration) -> None: """Test GE 12730 Fan Controller v2.0 multilevel switch is discovered as a fan.""" node = ge_12730 assert node.device_class.specific.label == "Multilevel Power Switch" @@ -36,7 +36,9 @@ async def test_ge_12730(hass, client, ge_12730, integration): assert state -async def test_inovelli_lzw36(hass, client, inovelli_lzw36, integration): +async def test_inovelli_lzw36( + hass: HomeAssistant, client, inovelli_lzw36, integration +) -> None: """Test LZW36 Fan Controller multilevel switch endpoint 2 is discovered as a fan.""" node = inovelli_lzw36 assert node.device_class.specific.label == "Unused" @@ -49,8 +51,8 @@ async def test_inovelli_lzw36(hass, client, inovelli_lzw36, integration): async def test_vision_security_zl7432( - hass, client, vision_security_zl7432, integration -): + hass: HomeAssistant, client, vision_security_zl7432, integration +) -> None: """Test Vision Security ZL7432 is caught by the device specific discovery.""" for entity_id in ( "switch.in_wall_dual_relay_switch", @@ -62,8 +64,8 @@ async def test_vision_security_zl7432( async def test_lock_popp_electric_strike_lock_control( - hass, client, lock_popp_electric_strike_lock_control, integration -): + hass: HomeAssistant, client, lock_popp_electric_strike_lock_control, integration +) -> None: """Test that the Popp Electric Strike Lock Control gets discovered correctly.""" assert hass.states.get("lock.node_62") is not None assert ( @@ -72,7 +74,9 @@ async def test_lock_popp_electric_strike_lock_control( ) -async def test_fortrez_ssa3_siren(hass, client, fortrezz_ssa3_siren, integration): +async def test_fortrez_ssa3_siren( + hass: HomeAssistant, client, fortrezz_ssa3_siren, integration +) -> None: """Test Fortrezz SSA3 siren gets discovered correctly.""" assert hass.states.get("select.siren_and_strobe_alarm") is not None @@ -87,7 +91,9 @@ async def test_firmware_version_range_exception(hass: HomeAssistant) -> None: ) -async def test_dynamic_climate_data_discovery_template_failure(hass, multisensor_6): +async def test_dynamic_climate_data_discovery_template_failure( + hass: HomeAssistant, multisensor_6 +) -> None: """Test that initing a DynamicCurrentTempClimateDataTemplate with no data raises.""" node = multisensor_6 with pytest.raises(ValueError): diff --git a/tests/components/zwave_js/test_events.py b/tests/components/zwave_js/test_events.py index 8201016b9d..e831e1dc7e 100644 --- a/tests/components/zwave_js/test_events.py +++ b/tests/components/zwave_js/test_events.py @@ -5,10 +5,14 @@ import pytest from zwave_js_server.const import CommandClass from zwave_js_server.event import Event +from homeassistant.core import HomeAssistant + from tests.common import async_capture_events -async def test_scenes(hass, hank_binary_switch, integration, client): +async def test_scenes( + hass: HomeAssistant, hank_binary_switch, integration, client +) -> None: """Test scene events.""" # just pick a random node to fake the value notification events node = hank_binary_switch @@ -137,7 +141,9 @@ async def test_scenes(hass, hank_binary_switch, integration, client): assert events[2].data["value_raw"] == 4 -async def test_notifications(hass, hank_binary_switch, integration, client): +async def test_notifications( + hass: HomeAssistant, hank_binary_switch, integration, client +) -> None: """Test notification events.""" # just pick a random node to fake the value notification events node = hank_binary_switch @@ -231,7 +237,9 @@ async def test_notifications(hass, hank_binary_switch, integration, client): assert events[2].data["command_class_name"] == "Multilevel Switch" -async def test_value_updated(hass, vision_security_zl7432, integration, client): +async def test_value_updated( + hass: HomeAssistant, vision_security_zl7432, integration, client +) -> None: """Test value updated events.""" node = vision_security_zl7432 # Add states to the value we are updating to ensure the translation happens @@ -298,7 +306,9 @@ async def test_value_updated(hass, vision_security_zl7432, integration, client): assert len(events) == 1 -async def test_power_level_notification(hass, hank_binary_switch, integration, client): +async def test_power_level_notification( + hass: HomeAssistant, hank_binary_switch, integration, client +) -> None: """Test power level notification events.""" # just pick a random node to fake the notification event node = hank_binary_switch @@ -330,7 +340,9 @@ async def test_power_level_notification(hass, hank_binary_switch, integration, c assert events[0].data["acknowledged_frames"] == 2 -async def test_unknown_notification(hass, hank_binary_switch, integration, client): +async def test_unknown_notification( + hass: HomeAssistant, hank_binary_switch, integration, client +) -> None: """Test behavior of unknown notification type events.""" # just pick a random node to fake the notification event node = hank_binary_switch diff --git a/tests/components/zwave_js/test_fan.py b/tests/components/zwave_js/test_fan.py index 2e200d9e4e..6af7a1ba9f 100644 --- a/tests/components/zwave_js/test_fan.py +++ b/tests/components/zwave_js/test_fan.py @@ -28,11 +28,14 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry -async def test_generic_fan(hass, client, fan_generic, integration): +async def test_generic_fan( + hass: HomeAssistant, client, fan_generic, integration +) -> None: """Test the fan entity for a generic fan that lacks specific speed configuration.""" node = fan_generic entity_id = "fan.generic_fan_controller" @@ -165,7 +168,9 @@ async def test_generic_fan(hass, client, fan_generic, integration): assert state.attributes[ATTR_PERCENTAGE] == 0 -async def test_configurable_speeds_fan(hass, client, hs_fc200, integration): +async def test_configurable_speeds_fan( + hass: HomeAssistant, client, hs_fc200, integration +) -> None: """Test a fan entity with configurable speeds.""" node = hs_fc200 node_id = 39 @@ -233,8 +238,8 @@ async def test_configurable_speeds_fan(hass, client, hs_fc200, integration): async def test_configurable_speeds_fan_with_missing_config_value( - hass, client, hs_fc200_state, integration -): + hass: HomeAssistant, client, hs_fc200_state, integration +) -> None: """Test a fan entity with configurable speeds.""" entity_id = "fan.scene_capable_fan_control_switch" @@ -261,8 +266,8 @@ async def test_configurable_speeds_fan_with_missing_config_value( async def test_configurable_speeds_fan_with_bad_config_value( - hass, client, hs_fc200_state, integration -): + hass: HomeAssistant, client, hs_fc200_state, integration +) -> None: """Test a fan entity with configurable speeds.""" entity_id = "fan.scene_capable_fan_control_switch" @@ -290,7 +295,7 @@ async def test_configurable_speeds_fan_with_bad_config_value( assert state.state == STATE_UNAVAILABLE -async def test_ge_12730_fan(hass, client, ge_12730, integration): +async def test_ge_12730_fan(hass: HomeAssistant, client, ge_12730, integration) -> None: """Test a GE 12730 fan with 3 fixed speeds.""" node = ge_12730 node_id = 24 @@ -357,7 +362,9 @@ async def test_ge_12730_fan(hass, client, ge_12730, integration): assert state.attributes[ATTR_PRESET_MODES] == [] -async def test_inovelli_lzw36(hass, client, inovelli_lzw36, integration): +async def test_inovelli_lzw36( + hass: HomeAssistant, client, inovelli_lzw36, integration +) -> None: """Test an LZW36.""" node = inovelli_lzw36 node_id = 19 @@ -460,7 +467,9 @@ async def test_inovelli_lzw36(hass, client, inovelli_lzw36, integration): assert len(client.async_send_command.call_args_list) == 0 -async def test_leviton_zw4sf_fan(hass, client, leviton_zw4sf, integration): +async def test_leviton_zw4sf_fan( + hass: HomeAssistant, client, leviton_zw4sf, integration +) -> None: """Test a Leviton ZW4SF fan with 4 fixed speeds.""" node = leviton_zw4sf node_id = 88 @@ -528,7 +537,9 @@ async def test_leviton_zw4sf_fan(hass, client, leviton_zw4sf, integration): assert state.attributes[ATTR_PRESET_MODES] == [] -async def test_thermostat_fan(hass, client, climate_adc_t3000, integration): +async def test_thermostat_fan( + hass: HomeAssistant, client, climate_adc_t3000, integration +) -> None: """Test the fan entity for a z-wave fan.""" node = climate_adc_t3000 entity_id = "fan.adc_t3000" @@ -758,8 +769,8 @@ async def test_thermostat_fan(hass, client, climate_adc_t3000, integration): async def test_thermostat_fan_without_off( - hass, client, climate_radio_thermostat_ct100_plus, integration -): + hass: HomeAssistant, client, climate_radio_thermostat_ct100_plus, integration +) -> None: """Test the fan entity for a z-wave fan without "off" property.""" entity_id = "fan.z_wave_thermostat" @@ -816,8 +827,8 @@ async def test_thermostat_fan_without_off( async def test_thermostat_fan_without_preset_modes( - hass, client, climate_adc_t3000_missing_fan_mode_states, integration -): + hass: HomeAssistant, client, climate_adc_t3000_missing_fan_mode_states, integration +) -> None: """Test the fan entity for a z-wave fan without "states" metadata.""" entity_id = "fan.adc_t3000_missing_fan_mode_states" diff --git a/tests/components/zwave_js/test_humidifier.py b/tests/components/zwave_js/test_humidifier.py index 7952f639fe..4dca2754c6 100644 --- a/tests/components/zwave_js/test_humidifier.py +++ b/tests/components/zwave_js/test_humidifier.py @@ -21,11 +21,14 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) +from homeassistant.core import HomeAssistant from .common import DEHUMIDIFIER_ADC_T3000_ENTITY, HUMIDIFIER_ADC_T3000_ENTITY -async def test_humidifier(hass, client, climate_adc_t3000, integration): +async def test_humidifier( + hass: HomeAssistant, client, climate_adc_t3000, integration +) -> None: """Test a humidity control command class entity.""" node = climate_adc_t3000 @@ -424,8 +427,8 @@ async def test_humidifier(hass, client, climate_adc_t3000, integration): async def test_dehumidifier_missing_setpoint( - hass, client, climate_adc_t3000_missing_setpoint, integration -): + hass: HomeAssistant, client, climate_adc_t3000_missing_setpoint, integration +) -> None: """Test a humidity control command class entity.""" entity_id = "humidifier.adc_t3000_missing_setpoint_dehumidifier" @@ -455,8 +458,8 @@ async def test_dehumidifier_missing_setpoint( async def test_humidifier_missing_mode( - hass, client, climate_adc_t3000_missing_mode, integration -): + hass: HomeAssistant, client, climate_adc_t3000_missing_mode, integration +) -> None: """Test a humidity control command class entity.""" node = climate_adc_t3000_missing_mode @@ -513,7 +516,9 @@ async def test_humidifier_missing_mode( client.async_send_command.reset_mock() -async def test_dehumidifier(hass, client, climate_adc_t3000, integration): +async def test_dehumidifier( + hass: HomeAssistant, client, climate_adc_t3000, integration +) -> None: """Test a humidity control command class entity.""" node = climate_adc_t3000 diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 1262ed6da8..62513c3ad8 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -35,7 +35,7 @@ def connect_timeout_fixture(): yield timeout -async def test_entry_setup_unload(hass, client, integration): +async def test_entry_setup_unload(hass: HomeAssistant, client, integration) -> None: """Test the integration set up and unload.""" entry = integration @@ -48,14 +48,16 @@ async def test_entry_setup_unload(hass, client, integration): assert entry.state is ConfigEntryState.NOT_LOADED -async def test_home_assistant_stop(hass, client, integration): +async def test_home_assistant_stop(hass: HomeAssistant, client, integration) -> None: """Test we clean up on home assistant stop.""" await hass.async_stop() assert client.disconnect.call_count == 1 -async def test_initialized_timeout(hass, client, connect_timeout): +async def test_initialized_timeout( + hass: HomeAssistant, client, connect_timeout +) -> None: """Test we handle a timeout during client initialization.""" entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) entry.add_to_hass(hass) @@ -66,7 +68,7 @@ async def test_initialized_timeout(hass, client, connect_timeout): assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_enabled_statistics(hass, client): +async def test_enabled_statistics(hass: HomeAssistant, client) -> None: """Test that we enabled statistics if the entry is opted in.""" entry = MockConfigEntry( domain="zwave_js", @@ -82,7 +84,7 @@ async def test_enabled_statistics(hass, client): assert mock_cmd.called -async def test_disabled_statistics(hass, client): +async def test_disabled_statistics(hass: HomeAssistant, client) -> None: """Test that we diisabled statistics if the entry is opted out.""" entry = MockConfigEntry( domain="zwave_js", @@ -98,7 +100,7 @@ async def test_disabled_statistics(hass, client): assert mock_cmd.called -async def test_noop_statistics(hass, client): +async def test_noop_statistics(hass: HomeAssistant, client) -> None: """Test that we don't make any statistics calls if user hasn't provided preference.""" entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) entry.add_to_hass(hass) @@ -115,7 +117,7 @@ async def test_noop_statistics(hass, client): @pytest.mark.parametrize("error", [BaseZwaveJSServerError("Boom"), Exception("Boom")]) -async def test_listen_failure(hass, client, error): +async def test_listen_failure(hass: HomeAssistant, client, error) -> None: """Test we handle errors during client listen.""" async def listen(driver_ready): @@ -134,7 +136,9 @@ async def test_listen_failure(hass, client, error): assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_new_entity_on_value_added(hass, multisensor_6, client, integration): +async def test_new_entity_on_value_added( + hass: HomeAssistant, multisensor_6, client, integration +) -> None: """Test we create a new entity if a value is added after the fact.""" node: Node = multisensor_6 @@ -167,7 +171,9 @@ async def test_new_entity_on_value_added(hass, multisensor_6, client, integratio assert hass.states.get("sensor.multisensor_6_ultraviolet_10") is not None -async def test_on_node_added_ready(hass, multisensor_6_state, client, integration): +async def test_on_node_added_ready( + hass: HomeAssistant, multisensor_6_state, client, integration +) -> None: """Test we handle a node added event with a ready node.""" dev_reg = dr.async_get(hass) node = Node(client, deepcopy(multisensor_6_state)) @@ -192,8 +198,8 @@ async def test_on_node_added_ready(hass, multisensor_6_state, client, integratio async def test_on_node_added_not_ready( - hass, zp3111_not_ready_state, client, integration -): + hass: HomeAssistant, zp3111_not_ready_state, client, integration +) -> None: """Test we handle a node added event with a non-ready node.""" dev_reg = dr.async_get(hass) device_id = f"{client.driver.controller.home_id}-{zp3111_not_ready_state['nodeId']}" @@ -225,7 +231,9 @@ async def test_on_node_added_not_ready( assert len(device.identifiers) == 1 -async def test_existing_node_ready(hass, client, multisensor_6, integration): +async def test_existing_node_ready( + hass: HomeAssistant, client, multisensor_6, integration +) -> None: """Test we handle a ready node that exists during integration setup.""" dev_reg = dr.async_get(hass) node = multisensor_6 @@ -302,7 +310,9 @@ async def test_existing_node_reinterview( assert device.sw_version == "1.13" -async def test_existing_node_not_ready(hass, zp3111_not_ready, client, integration): +async def test_existing_node_not_ready( + hass: HomeAssistant, zp3111_not_ready, client, integration +) -> None: """Test we handle a non-ready node that exists during integration setup.""" dev_reg = dr.async_get(hass) node = zp3111_not_ready @@ -324,8 +334,13 @@ async def test_existing_node_not_ready(hass, zp3111_not_ready, client, integrati async def test_existing_node_not_replaced_when_not_ready( - hass, zp3111, zp3111_not_ready_state, zp3111_state, client, integration -): + hass: HomeAssistant, + zp3111, + zp3111_not_ready_state, + zp3111_state, + client, + integration, +) -> None: """Test when a node added event with a non-ready node is received. The existing node should not be replaced, and no customization should be lost. @@ -442,15 +457,22 @@ async def test_existing_node_not_replaced_when_not_ready( assert state.name == "Custom Entity Name" -async def test_null_name(hass, client, null_name_check, integration): +async def test_null_name( + hass: HomeAssistant, client, null_name_check, integration +) -> None: """Test that node without a name gets a generic node name.""" node = null_name_check assert hass.states.get(f"switch.node_{node.node_id}") async def test_start_addon( - hass, addon_installed, install_addon, addon_options, set_addon_options, start_addon -): + hass: HomeAssistant, + addon_installed, + install_addon, + addon_options, + set_addon_options, + start_addon, +) -> None: """Test start the Z-Wave JS add-on during entry setup.""" device = "/test" s0_legacy_key = "s0_legacy" @@ -492,13 +514,13 @@ async def test_start_addon( async def test_install_addon( - hass, + hass: HomeAssistant, addon_not_installed, install_addon, addon_options, set_addon_options, start_addon, -): +) -> None: """Test install and start the Z-Wave JS add-on during entry setup.""" device = "/test" s0_legacy_key = "s0_legacy" @@ -542,13 +564,13 @@ async def test_install_addon( @pytest.mark.parametrize("addon_info_side_effect", [HassioAPIError("Boom")]) async def test_addon_info_failure( - hass, + hass: HomeAssistant, addon_installed, install_addon, addon_options, set_addon_options, start_addon, -): +) -> None: """Test failure to get add-on info for Z-Wave JS add-on during entry setup.""" device = "/test" network_key = "abc123" @@ -596,7 +618,7 @@ async def test_addon_info_failure( ], ) async def test_addon_options_changed( - hass, + hass: HomeAssistant, client, addon_installed, addon_running, @@ -613,7 +635,7 @@ async def test_addon_options_changed( new_s2_authenticated_key, old_s2_unauthenticated_key, new_s2_unauthenticated_key, -): +) -> None: """Test update config entry data on entry setup if add-on options changed.""" addon_options["device"] = new_device addon_options["s0_legacy_key"] = new_s0_legacy_key @@ -665,7 +687,7 @@ async def test_addon_options_changed( ], ) async def test_update_addon( - hass, + hass: HomeAssistant, client, addon_info, addon_installed, @@ -680,7 +702,7 @@ async def test_update_addon( update_addon_side_effect, create_backup_side_effect, version_state, -): +) -> None: """Test update the Z-Wave JS add-on during entry setup.""" device = "/test" network_key = "abc123" @@ -713,7 +735,7 @@ async def test_update_addon( assert update_addon.call_count == update_calls -async def test_issue_registry(hass, client, version_state): +async def test_issue_registry(hass: HomeAssistant, client, version_state) -> None: """Test issue registry.""" device = "/test" network_key = "abc123" @@ -762,7 +784,7 @@ async def test_issue_registry(hass, client, version_state): ], ) async def test_stop_addon( - hass, + hass: HomeAssistant, client, addon_installed, addon_running, @@ -770,7 +792,7 @@ async def test_stop_addon( stop_addon, stop_addon_side_effect, entry_state, -): +) -> None: """Test stop the Z-Wave JS add-on on entry unload if entry is disabled.""" stop_addon.side_effect = stop_addon_side_effect device = "/test" @@ -805,8 +827,13 @@ async def test_stop_addon( async def test_remove_entry( - hass, addon_installed, stop_addon, create_backup, uninstall_addon, caplog -): + hass: HomeAssistant, + addon_installed, + stop_addon, + create_backup, + uninstall_addon, + caplog: pytest.LogCaptureFixture, +) -> None: """Test remove the config entry.""" # test successful remove without created add-on entry = MockConfigEntry( @@ -916,8 +943,12 @@ async def test_remove_entry( async def test_removed_device( - hass, client, climate_radio_thermostat_ct100_plus, lock_schlage_be469, integration -): + hass: HomeAssistant, + client, + climate_radio_thermostat_ct100_plus, + lock_schlage_be469, + integration, +) -> None: """Test that the device registry gets updated when a device gets removed.""" driver = client.driver assert driver @@ -948,7 +979,7 @@ async def test_removed_device( assert dev_reg.async_get_device({get_device_id(driver, old_node)}) is None -async def test_suggested_area(hass, client, eaton_rf9640_dimmer): +async def test_suggested_area(hass: HomeAssistant, client, eaton_rf9640_dimmer) -> None: """Test that suggested area works.""" dev_reg = dr.async_get(hass) ent_reg = er.async_get(hass) @@ -962,7 +993,9 @@ async def test_suggested_area(hass, client, eaton_rf9640_dimmer): assert dev_reg.async_get(entity.device_id).area_id is not None -async def test_node_removed(hass, multisensor_6_state, client, integration): +async def test_node_removed( + hass: HomeAssistant, multisensor_6_state, client, integration +) -> None: """Test that device gets removed when node gets removed.""" dev_reg = dr.async_get(hass) node = Node(client, deepcopy(multisensor_6_state)) @@ -988,8 +1021,8 @@ async def test_node_removed(hass, multisensor_6_state, client, integration): async def test_replace_same_node( - hass, multisensor_6, multisensor_6_state, client, integration -): + hass: HomeAssistant, multisensor_6, multisensor_6_state, client, integration +) -> None: """Test when a node is replaced with itself that the device remains.""" dev_reg = dr.async_get(hass) node_id = multisensor_6.node_id @@ -1094,13 +1127,13 @@ async def test_replace_same_node( async def test_replace_different_node( - hass, + hass: HomeAssistant, multisensor_6, multisensor_6_state, hank_binary_switch_state, client, integration, -): +) -> None: """Test when a node is replaced with a different node.""" dev_reg = dr.async_get(hass) node_id = multisensor_6.node_id @@ -1212,7 +1245,9 @@ async def test_replace_different_node( assert hass.states.get("switch.smart_plug_with_two_usb_ports") -async def test_node_model_change(hass, zp3111, client, integration): +async def test_node_model_change( + hass: HomeAssistant, zp3111, client, integration +) -> None: """Test when a node's model is changed due to an updated device config file. The device and entities should not be removed. @@ -1295,8 +1330,8 @@ async def test_node_model_change(hass, zp3111, client, integration): async def test_disabled_node_status_entity_on_node_replaced( - hass, zp3111_state, zp3111, client, integration -): + hass: HomeAssistant, zp3111_state, zp3111, client, integration +) -> None: """Test that when a node replacement event is received the node status sensor is removed.""" node_status_entity = "sensor.4_in_1_sensor_node_status" state = hass.states.get(node_status_entity) @@ -1320,7 +1355,9 @@ async def test_disabled_node_status_entity_on_node_replaced( assert state.state == STATE_UNAVAILABLE -async def test_disabled_entity_on_value_removed(hass, zp3111, client, integration): +async def test_disabled_entity_on_value_removed( + hass: HomeAssistant, zp3111, client, integration +) -> None: """Test that when entity primary values are removed the entity is removed.""" er_reg = er.async_get(hass) diff --git a/tests/components/zwave_js/test_light.py b/tests/components/zwave_js/test_light.py index cd7d9e9145..7546976d26 100644 --- a/tests/components/zwave_js/test_light.py +++ b/tests/components/zwave_js/test_light.py @@ -24,6 +24,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) +from homeassistant.core import HomeAssistant from .common import ( AEON_SMART_SWITCH_LIGHT_ENTITY, @@ -35,7 +36,9 @@ from .common import ( HSM200_V1_ENTITY = "light.hsm200" -async def test_light(hass, client, bulb_6_multi_color, integration): +async def test_light( + hass: HomeAssistant, client, bulb_6_multi_color, integration +) -> None: """Test the light entity.""" node = bulb_6_multi_color state = hass.states.get(BULB_6_MULTI_COLOR_LIGHT_ENTITY) @@ -395,7 +398,9 @@ async def test_light(hass, client, bulb_6_multi_color, integration): assert args["value"] == 0 -async def test_v4_dimmer_light(hass, client, eaton_rf9640_dimmer, integration): +async def test_v4_dimmer_light( + hass: HomeAssistant, client, eaton_rf9640_dimmer, integration +) -> None: """Test a light that supports MultiLevelSwitch CommandClass version 4.""" state = hass.states.get(EATON_RF9640_ENTITY) @@ -405,13 +410,15 @@ async def test_v4_dimmer_light(hass, client, eaton_rf9640_dimmer, integration): assert state.attributes[ATTR_BRIGHTNESS] == 57 -async def test_optional_light(hass, client, aeon_smart_switch_6, integration): +async def test_optional_light( + hass: HomeAssistant, client, aeon_smart_switch_6, integration +) -> None: """Test a device that has an additional light endpoint being identified as light.""" state = hass.states.get(AEON_SMART_SWITCH_LIGHT_ENTITY) assert state.state == STATE_ON -async def test_rgbw_light(hass, client, zen_31, integration): +async def test_rgbw_light(hass: HomeAssistant, client, zen_31, integration) -> None: """Test the light entity.""" state = hass.states.get(ZEN_31_ENTITY) @@ -451,7 +458,9 @@ async def test_rgbw_light(hass, client, zen_31, integration): client.async_send_command.reset_mock() -async def test_light_none_color_value(hass, light_color_null_values, integration): +async def test_light_none_color_value( + hass: HomeAssistant, light_color_null_values, integration +) -> None: """Test the light entity can handle None value in current color Value.""" entity_id = "light.repeater" state = hass.states.get(entity_id) @@ -462,7 +471,9 @@ async def test_light_none_color_value(hass, light_color_null_values, integration assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["hs"] -async def test_black_is_off(hass, client, express_controls_ezmultipli, integration): +async def test_black_is_off( + hass: HomeAssistant, client, express_controls_ezmultipli, integration +) -> None: """Test the black is off light entity.""" node = express_controls_ezmultipli state = hass.states.get(HSM200_V1_ENTITY) diff --git a/tests/components/zwave_js/test_lock.py b/tests/components/zwave_js/test_lock.py index 03ebd3b645..42aee148af 100644 --- a/tests/components/zwave_js/test_lock.py +++ b/tests/components/zwave_js/test_lock.py @@ -26,11 +26,14 @@ from homeassistant.const import ( STATE_UNKNOWN, STATE_UNLOCKED, ) +from homeassistant.core import HomeAssistant from .common import SCHLAGE_BE469_LOCK_ENTITY, replace_value_of_zwave_value -async def test_door_lock(hass, client, lock_schlage_be469, integration): +async def test_door_lock( + hass: HomeAssistant, client, lock_schlage_be469, integration +) -> None: """Test a lock entity with door lock command class.""" node = lock_schlage_be469 state = hass.states.get(SCHLAGE_BE469_LOCK_ENTITY) @@ -164,12 +167,16 @@ async def test_door_lock(hass, client, lock_schlage_be469, integration): assert hass.states.get(SCHLAGE_BE469_LOCK_ENTITY).state == STATE_UNAVAILABLE -async def test_only_one_lock(hass, client, lock_home_connect_620, integration): +async def test_only_one_lock( + hass: HomeAssistant, client, lock_home_connect_620, integration +) -> None: """Test node with both Door Lock and Lock CC values only gets one lock entity.""" assert len(hass.states.async_entity_ids("lock")) == 1 -async def test_door_lock_no_value(hass, client, lock_schlage_be469_state, integration): +async def test_door_lock_no_value( + hass: HomeAssistant, client, lock_schlage_be469_state, integration +) -> None: """Test a lock entity with door lock command class that has no value for mode.""" node_state = replace_value_of_zwave_value( lock_schlage_be469_state, diff --git a/tests/components/zwave_js/test_logbook.py b/tests/components/zwave_js/test_logbook.py index 398db7b032..9806261230 100644 --- a/tests/components/zwave_js/test_logbook.py +++ b/tests/components/zwave_js/test_logbook.py @@ -6,6 +6,7 @@ from homeassistant.components.zwave_js.const import ( ZWAVE_JS_VALUE_NOTIFICATION_EVENT, ) from homeassistant.components.zwave_js.helpers import get_device_id +from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component @@ -13,8 +14,8 @@ from tests.components.logbook.common import MockRow, mock_humanify async def test_humanifying_zwave_js_notification_event( - hass, client, lock_schlage_be469, integration -): + hass: HomeAssistant, client, lock_schlage_be469, integration +) -> None: """Test humanifying Z-Wave JS notification events.""" dev_reg = dr.async_get(hass) device = dev_reg.async_get_device( @@ -96,8 +97,8 @@ async def test_humanifying_zwave_js_notification_event( async def test_humanifying_zwave_js_value_notification_event( - hass, client, lock_schlage_be469, integration -): + hass: HomeAssistant, client, lock_schlage_be469, integration +) -> None: """Test humanifying Z-Wave JS value notification events.""" dev_reg = dr.async_get(hass) device = dev_reg.async_get_device( diff --git a/tests/components/zwave_js/test_migrate.py b/tests/components/zwave_js/test_migrate.py index 849759cc83..1f5a96eb5f 100644 --- a/tests/components/zwave_js/test_migrate.py +++ b/tests/components/zwave_js/test_migrate.py @@ -6,14 +6,15 @@ from zwave_js_server.model.node import Node from homeassistant.components.zwave_js.const import DOMAIN from homeassistant.components.zwave_js.helpers import get_device_id +from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from .common import AIR_TEMPERATURE_SENSOR, NOTIFICATION_MOTION_BINARY_SENSOR async def test_unique_id_migration_dupes( - hass, multisensor_6_state, client, integration -): + hass: HomeAssistant, multisensor_6_state, client, integration +) -> None: """Test we remove an entity when .""" ent_reg = er.async_get(hass) @@ -72,7 +73,9 @@ async def test_unique_id_migration_dupes( "52-49-0-Air temperature-00-00", ], ) -async def test_unique_id_migration(hass, multisensor_6_state, client, integration, id): +async def test_unique_id_migration( + hass: HomeAssistant, multisensor_6_state, client, integration, id +) -> None: """Test unique ID is migrated from old format to new.""" ent_reg = er.async_get(hass) @@ -115,8 +118,8 @@ async def test_unique_id_migration(hass, multisensor_6_state, client, integratio ], ) async def test_unique_id_migration_property_key( - hass, hank_binary_switch_state, client, integration, id -): + hass: HomeAssistant, hank_binary_switch_state, client, integration, id +) -> None: """Test unique ID with property key is migrated from old format to new.""" ent_reg = er.async_get(hass) @@ -151,8 +154,8 @@ async def test_unique_id_migration_property_key( async def test_unique_id_migration_notification_binary_sensor( - hass, multisensor_6_state, client, integration -): + hass: HomeAssistant, multisensor_6_state, client, integration +) -> None: """Test unique ID is migrated from old format to new for a notification binary sensor.""" ent_reg = er.async_get(hass) @@ -192,8 +195,8 @@ async def test_unique_id_migration_notification_binary_sensor( async def test_old_entity_migration( - hass, hank_binary_switch_state, client, integration -): + hass: HomeAssistant, hank_binary_switch_state, client, integration +) -> None: """Test old entity on a different endpoint is migrated to a new one.""" node = Node(client, copy.deepcopy(hank_binary_switch_state)) driver = client.driver @@ -240,8 +243,8 @@ async def test_old_entity_migration( async def test_different_endpoint_migration_status_sensor( - hass, hank_binary_switch_state, client, integration -): + hass: HomeAssistant, hank_binary_switch_state, client, integration +) -> None: """Test that the different endpoint migration logic skips over the status sensor.""" node = Node(client, copy.deepcopy(hank_binary_switch_state)) driver = client.driver @@ -286,8 +289,8 @@ async def test_different_endpoint_migration_status_sensor( async def test_skip_old_entity_migration_for_multiple( - hass, hank_binary_switch_state, client, integration -): + hass: HomeAssistant, hank_binary_switch_state, client, integration +) -> None: """Test that multiple entities of the same value but on a different endpoint get skipped.""" node = Node(client, copy.deepcopy(hank_binary_switch_state)) driver = client.driver @@ -348,8 +351,8 @@ async def test_skip_old_entity_migration_for_multiple( async def test_old_entity_migration_notification_binary_sensor( - hass, multisensor_6_state, client, integration -): + hass: HomeAssistant, multisensor_6_state, client, integration +) -> None: """Test old entity on a different endpoint is migrated to a new one for a notification binary sensor.""" node = Node(client, copy.deepcopy(multisensor_6_state)) driver = client.driver diff --git a/tests/components/zwave_js/test_number.py b/tests/components/zwave_js/test_number.py index ff551ce554..d94af41c9d 100644 --- a/tests/components/zwave_js/test_number.py +++ b/tests/components/zwave_js/test_number.py @@ -5,6 +5,7 @@ import pytest from zwave_js_server.event import Event from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -16,7 +17,9 @@ NUMBER_ENTITY = "number.thermostat_hvac_valve_control" VOLUME_NUMBER_ENTITY = "number.indoor_siren_6_default_volume_2" -async def test_number(hass, client, aeotec_radiator_thermostat, integration): +async def test_number( + hass: HomeAssistant, client, aeotec_radiator_thermostat, integration +) -> None: """Test the number entity.""" node = aeotec_radiator_thermostat state = hass.states.get(NUMBER_ENTITY) @@ -81,8 +84,12 @@ def mock_client_fixture(): async def test_number_no_target_value( - hass, client, no_target_value, aeotec_radiator_thermostat, integration -): + hass: HomeAssistant, + client, + no_target_value, + aeotec_radiator_thermostat, + integration, +) -> None: """Test the number entity with no target value.""" # Test turn on setting value fails with pytest.raises(HomeAssistantError): @@ -94,7 +101,9 @@ async def test_number_no_target_value( ) -async def test_number_writeable(hass, client, aeotec_radiator_thermostat): +async def test_number_writeable( + hass: HomeAssistant, client, aeotec_radiator_thermostat +) -> None: """Test the number entity where current value is writeable.""" aeotec_radiator_thermostat.values["4-38-0-currentValue"].metadata.data[ "writeable" @@ -129,7 +138,9 @@ async def test_number_writeable(hass, client, aeotec_radiator_thermostat): client.async_send_command.reset_mock() -async def test_volume_number(hass, client, aeotec_zw164_siren, integration): +async def test_volume_number( + hass: HomeAssistant, client, aeotec_zw164_siren, integration +) -> None: """Test the volume number entity.""" node = aeotec_zw164_siren state = hass.states.get(VOLUME_NUMBER_ENTITY) @@ -208,7 +219,9 @@ async def test_volume_number(hass, client, aeotec_zw164_siren, integration): assert state.state == STATE_UNKNOWN -async def test_disabled_basic_number(hass, ge_in_wall_dimmer_switch, integration): +async def test_disabled_basic_number( + hass: HomeAssistant, ge_in_wall_dimmer_switch, integration +) -> None: """Test number is created from Basic CC and is disabled.""" ent_reg = er.async_get(hass) entity_entry = ent_reg.async_get(BASIC_NUMBER_ENTITY) diff --git a/tests/components/zwave_js/test_select.py b/tests/components/zwave_js/test_select.py index a6a7a268df..dc367e637f 100644 --- a/tests/components/zwave_js/test_select.py +++ b/tests/components/zwave_js/test_select.py @@ -210,7 +210,9 @@ async def test_protection_select( assert state.state == STATE_UNKNOWN -async def test_multilevel_switch_select(hass, client, fortrezz_ssa1_siren, integration): +async def test_multilevel_switch_select( + hass: HomeAssistant, client, fortrezz_ssa1_siren, integration +) -> None: """Test Multilevel Switch CC based select entity.""" node = fortrezz_ssa1_siren state = hass.states.get(MULTILEVEL_SWITCH_SELECT_ENTITY) @@ -271,8 +273,8 @@ async def test_multilevel_switch_select(hass, client, fortrezz_ssa1_siren, integ async def test_multilevel_switch_select_no_value( - hass, client, fortrezz_ssa1_siren_state, integration -): + hass: HomeAssistant, client, fortrezz_ssa1_siren_state, integration +) -> None: """Test Multilevel Switch CC based select entity with primary value is None.""" node_state = replace_value_of_zwave_value( fortrezz_ssa1_siren_state, diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index 7057922c07..e41158a5d1 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -1,6 +1,7 @@ """Test the Z-Wave JS sensor platform.""" import copy +import pytest from zwave_js_server.const.command_class.meter import MeterType from zwave_js_server.event import Event from zwave_js_server.model.node import Node @@ -33,6 +34,7 @@ from homeassistant.const import ( UnitOfPower, UnitOfTemperature, ) +from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from .common import ( @@ -51,8 +53,8 @@ from .common import ( async def test_numeric_sensor( - hass, multisensor_6, express_controls_ezmultipli, integration -): + hass: HomeAssistant, multisensor_6, express_controls_ezmultipli, integration +) -> None: """Test the numeric sensor.""" state = hass.states.get(AIR_TEMPERATURE_SENSOR) @@ -102,7 +104,9 @@ async def test_numeric_sensor( assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT -async def test_energy_sensors(hass, hank_binary_switch, integration): +async def test_energy_sensors( + hass: HomeAssistant, hank_binary_switch, integration +) -> None: """Test power and energy sensors.""" state = hass.states.get(POWER_SENSOR) @@ -135,7 +139,9 @@ async def test_energy_sensors(hass, hank_binary_switch, integration): assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.CURRENT -async def test_disabled_notification_sensor(hass, multisensor_6, integration): +async def test_disabled_notification_sensor( + hass: HomeAssistant, multisensor_6, integration +) -> None: """Test sensor is created from Notification CC and is disabled.""" ent_reg = er.async_get(hass) entity_entry = ent_reg.async_get(NOTIFICATION_MOTION_SENSOR) @@ -161,8 +167,8 @@ async def test_disabled_notification_sensor(hass, multisensor_6, integration): async def test_disabled_indcator_sensor( - hass, climate_radio_thermostat_ct100_plus, integration -): + hass: HomeAssistant, climate_radio_thermostat_ct100_plus, integration +) -> None: """Test sensor is created from Indicator CC and is disabled.""" ent_reg = er.async_get(hass) entity_entry = ent_reg.async_get(INDICATOR_SENSOR) @@ -172,7 +178,9 @@ async def test_disabled_indcator_sensor( assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION -async def test_config_parameter_sensor(hass, lock_id_lock_as_id150, integration): +async def test_config_parameter_sensor( + hass: HomeAssistant, lock_id_lock_as_id150, integration +) -> None: """Test config parameter sensor is created.""" ent_reg = er.async_get(hass) entity_entry = ent_reg.async_get(ID_LOCK_CONFIG_PARAMETER_SENSOR) @@ -180,7 +188,9 @@ async def test_config_parameter_sensor(hass, lock_id_lock_as_id150, integration) assert entity_entry.disabled -async def test_node_status_sensor(hass, client, lock_id_lock_as_id150, integration): +async def test_node_status_sensor( + hass: HomeAssistant, client, lock_id_lock_as_id150, integration +) -> None: """Test node status sensor is created and gets updated on node state changes.""" NODE_STATUS_ENTITY = "sensor.z_wave_module_for_id_lock_150_and_101_node_status" node = lock_id_lock_as_id150 @@ -241,13 +251,13 @@ async def test_node_status_sensor(hass, client, lock_id_lock_as_id150, integrati async def test_node_status_sensor_not_ready( - hass, + hass: HomeAssistant, client, lock_id_lock_as_id150_not_ready, lock_id_lock_as_id150_state, integration, - caplog, -): + caplog: pytest.LogCaptureFixture, +) -> None: """Test node status sensor is created and available if node is not ready.""" NODE_STATUS_ENTITY = "sensor.z_wave_module_for_id_lock_150_and_101_node_status" node = lock_id_lock_as_id150_not_ready @@ -287,11 +297,11 @@ async def test_node_status_sensor_not_ready( async def test_reset_meter( - hass, + hass: HomeAssistant, client, aeon_smart_switch_6, integration, -): +) -> None: """Test reset_meter service.""" client.async_send_command.return_value = {} client.async_send_command_no_wait.return_value = {} @@ -338,11 +348,11 @@ async def test_reset_meter( async def test_meter_attributes( - hass, + hass: HomeAssistant, client, aeon_smart_switch_6, integration, -): +) -> None: """Test meter entity attributes.""" state = hass.states.get(METER_ENERGY_SENSOR) assert state @@ -352,7 +362,9 @@ async def test_meter_attributes( assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.TOTAL_INCREASING -async def test_special_meters(hass, aeon_smart_switch_6_state, client, integration): +async def test_special_meters( + hass: HomeAssistant, aeon_smart_switch_6_state, client, integration +) -> None: """Test meters that have special handling.""" node_data = copy.deepcopy( aeon_smart_switch_6_state @@ -419,7 +431,7 @@ async def test_special_meters(hass, aeon_smart_switch_6_state, client, integrati assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.MEASUREMENT -async def test_unit_change(hass, zp3111, client, integration): +async def test_unit_change(hass: HomeAssistant, zp3111, client, integration) -> None: """Test unit change via metadata updated event is handled by numeric sensors.""" entity_id = "sensor.4_in_1_sensor_air_temperature" state = hass.states.get(entity_id) diff --git a/tests/components/zwave_js/test_services.py b/tests/components/zwave_js/test_services.py index dc07657643..efa8ec985e 100644 --- a/tests/components/zwave_js/test_services.py +++ b/tests/components/zwave_js/test_services.py @@ -32,6 +32,7 @@ from homeassistant.components.zwave_js.const import ( ) from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.const import ATTR_AREA_ID, ATTR_DEVICE_ID, ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.area_registry import async_get as async_get_area_reg from homeassistant.helpers.device_registry import async_get as async_get_dev_reg @@ -51,7 +52,9 @@ from .common import ( from tests.common import MockConfigEntry -async def test_set_config_parameter(hass, client, multisensor_6, integration): +async def test_set_config_parameter( + hass: HomeAssistant, client, multisensor_6, integration +) -> None: """Test the set_config_parameter service.""" dev_reg = async_get_dev_reg(hass) ent_reg = async_get_ent_reg(hass) @@ -363,12 +366,12 @@ async def test_set_config_parameter(hass, client, multisensor_6, integration): async def test_set_config_parameter_gather( - hass, + hass: HomeAssistant, client, multisensor_6, climate_radio_thermostat_ct100_plus_different_endpoints, integration, -): +) -> None: """Test the set_config_parameter service gather functionality.""" # Test setting config parameter by property and validate that the first node # which triggers an error doesn't prevent the second one to be called. @@ -402,7 +405,9 @@ async def test_set_config_parameter_gather( client.async_send_command.reset_mock() -async def test_bulk_set_config_parameters(hass, client, multisensor_6, integration): +async def test_bulk_set_config_parameters( + hass: HomeAssistant, client, multisensor_6, integration +) -> None: """Test the bulk_set_partial_config_parameters service.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device({get_device_id(client.driver, multisensor_6)}) @@ -612,12 +617,12 @@ async def test_bulk_set_config_parameters(hass, client, multisensor_6, integrati async def test_bulk_set_config_parameters_gather( - hass, + hass: HomeAssistant, client, multisensor_6, climate_radio_thermostat_ct100_plus_different_endpoints, integration, -): +) -> None: """Test the bulk_set_partial_config_parameters service gather functionality.""" # Test bulk setting config parameter by property and validate that the first node # which triggers an error doesn't prevent the second one to be called. @@ -651,8 +656,11 @@ async def test_bulk_set_config_parameters_gather( async def test_refresh_value( - hass, client, climate_radio_thermostat_ct100_plus_different_endpoints, integration -): + hass: HomeAssistant, + client, + climate_radio_thermostat_ct100_plus_different_endpoints, + integration, +) -> None: """Test the refresh_value service.""" # Test polling the primary value client.async_send_command.return_value = {"result": 2} @@ -731,7 +739,9 @@ async def test_refresh_value( ) -async def test_set_value(hass, client, climate_danfoss_lc_13, integration): +async def test_set_value( + hass: HomeAssistant, client, climate_danfoss_lc_13, integration +) -> None: """Test set_value service.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( @@ -897,8 +907,8 @@ async def test_set_value(hass, client, climate_danfoss_lc_13, integration): async def test_set_value_string( - hass, client, climate_danfoss_lc_13, lock_schlage_be469, integration -): + hass: HomeAssistant, client, climate_danfoss_lc_13, lock_schlage_be469, integration +) -> None: """Test set_value service converts number to string when needed.""" client.async_send_command.return_value = {"success": True} @@ -929,7 +939,9 @@ async def test_set_value_string( assert args["value"] == "12345" -async def test_set_value_options(hass, client, aeon_smart_switch_6, integration): +async def test_set_value_options( + hass: HomeAssistant, client, aeon_smart_switch_6, integration +) -> None: """Test set_value service with options.""" await hass.services.async_call( DOMAIN, @@ -960,12 +972,12 @@ async def test_set_value_options(hass, client, aeon_smart_switch_6, integration) async def test_set_value_gather( - hass, + hass: HomeAssistant, client, multisensor_6, climate_radio_thermostat_ct100_plus_different_endpoints, integration, -): +) -> None: """Test the set_value service gather functionality.""" # Test setting value by property and validate that the first node # which triggers an error doesn't prevent the second one to be called. @@ -1003,12 +1015,12 @@ async def test_set_value_gather( async def test_multicast_set_value( - hass, + hass: HomeAssistant, client, climate_danfoss_lc_13, climate_eurotronic_spirit_z, integration, -): +) -> None: """Test multicast_set_value service.""" # Test successful multicast call await hass.services.async_call( @@ -1279,12 +1291,12 @@ async def test_multicast_set_value( async def test_multicast_set_value_options( - hass, + hass: HomeAssistant, client, bulb_6_multi_color, light_color_null_values, integration, -): +) -> None: """Test multicast_set_value service with options.""" await hass.services.async_call( DOMAIN, @@ -1325,12 +1337,12 @@ async def test_multicast_set_value_options( async def test_multicast_set_value_string( - hass, + hass: HomeAssistant, client, lock_id_lock_as_id150, lock_schlage_be469, integration, -): +) -> None: """Test multicast_set_value service converts number to string when needed.""" client.async_send_command.return_value = {"success": True} @@ -1360,12 +1372,12 @@ async def test_multicast_set_value_string( async def test_ping( - hass, + hass: HomeAssistant, client, climate_danfoss_lc_13, climate_radio_thermostat_ct100_plus_different_endpoints, integration, -): +) -> None: """Test ping service.""" dev_reg = async_get_dev_reg(hass) device_radio_thermostat = dev_reg.async_get_device( @@ -1498,12 +1510,12 @@ async def test_ping( async def test_invoke_cc_api( - hass, + hass: HomeAssistant, client, climate_danfoss_lc_13, climate_radio_thermostat_ct100_plus_different_endpoints, integration, -): +) -> None: """Test invoke_cc_api service.""" dev_reg = async_get_dev_reg(hass) device_radio_thermostat = dev_reg.async_get_device( diff --git a/tests/components/zwave_js/test_siren.py b/tests/components/zwave_js/test_siren.py index 75d43c545b..210339e22d 100644 --- a/tests/components/zwave_js/test_siren.py +++ b/tests/components/zwave_js/test_siren.py @@ -7,6 +7,7 @@ from homeassistant.components.siren import ( ATTR_VOLUME_LEVEL, ) from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant.core import HomeAssistant SIREN_ENTITY = "siren.indoor_siren_6_2" @@ -62,7 +63,9 @@ TONE_ID_VALUE_ID = { } -async def test_siren(hass, client, aeotec_zw164_siren, integration): +async def test_siren( + hass: HomeAssistant, client, aeotec_zw164_siren, integration +) -> None: """Test the siren entity.""" node = aeotec_zw164_siren state = hass.states.get(SIREN_ENTITY) diff --git a/tests/components/zwave_js/test_switch.py b/tests/components/zwave_js/test_switch.py index d485c877cc..87ad7838ff 100644 --- a/tests/components/zwave_js/test_switch.py +++ b/tests/components/zwave_js/test_switch.py @@ -1,5 +1,4 @@ """Test the Z-Wave JS switch platform.""" - from zwave_js_server.const import CURRENT_VALUE_PROPERTY, CommandClass from zwave_js_server.event import Event from zwave_js_server.model.node import Node @@ -7,11 +6,14 @@ from zwave_js_server.model.node import Node from homeassistant.components.switch import DOMAIN, SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.components.zwave_js.helpers import ZwaveValueMatcher from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant.core import HomeAssistant from .common import SWITCH_ENTITY, replace_value_of_zwave_value -async def test_switch(hass, hank_binary_switch, integration, client): +async def test_switch( + hass: HomeAssistant, hank_binary_switch, integration, client +) -> None: """Test the switch.""" state = hass.states.get(SWITCH_ENTITY) node = hank_binary_switch @@ -73,7 +75,9 @@ async def test_switch(hass, hank_binary_switch, integration, client): assert args["value"] is False -async def test_barrier_signaling_switch(hass, gdc_zw062, integration, client): +async def test_barrier_signaling_switch( + hass: HomeAssistant, gdc_zw062, integration, client +) -> None: """Test barrier signaling state switch.""" node = gdc_zw062 entity = "switch.aeon_labs_garage_door_controller_gen5_signaling_state_visual" @@ -183,7 +187,9 @@ async def test_barrier_signaling_switch(hass, gdc_zw062, integration, client): assert state.state == STATE_ON -async def test_switch_no_value(hass, hank_binary_switch_state, integration, client): +async def test_switch_no_value( + hass: HomeAssistant, hank_binary_switch_state, integration, client +) -> None: """Test the switch where primary value value is None.""" node_state = replace_value_of_zwave_value( hank_binary_switch_state, diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py index 543f9f82fe..9ba0080667 100644 --- a/tests/components/zwave_js/test_trigger.py +++ b/tests/components/zwave_js/test_trigger.py @@ -24,7 +24,9 @@ from .common import SCHLAGE_BE469_LOCK_ENTITY from tests.common import async_capture_events -async def test_zwave_js_value_updated(hass, client, lock_schlage_be469, integration): +async def test_zwave_js_value_updated( + hass: HomeAssistant, client, lock_schlage_be469, integration +) -> None: """Test for zwave_js.value_updated automation trigger.""" trigger_type = f"{DOMAIN}.value_updated" node: Node = lock_schlage_be469 @@ -272,8 +274,8 @@ async def test_zwave_js_value_updated(hass, client, lock_schlage_be469, integrat async def test_zwave_js_value_updated_bypass_dynamic_validation( - hass, client, lock_schlage_be469, integration -): + hass: HomeAssistant, client, lock_schlage_be469, integration +) -> None: """Test zwave_js.value_updated trigger when bypassing dynamic validation.""" trigger_type = f"{DOMAIN}.value_updated" node: Node = lock_schlage_be469 @@ -330,8 +332,8 @@ async def test_zwave_js_value_updated_bypass_dynamic_validation( async def test_zwave_js_value_updated_bypass_dynamic_validation_no_nodes( - hass, client, lock_schlage_be469, integration -): + hass: HomeAssistant, client, lock_schlage_be469, integration +) -> None: """Test value_updated trigger when bypassing dynamic validation with no nodes.""" trigger_type = f"{DOMAIN}.value_updated" node: Node = lock_schlage_be469 @@ -388,8 +390,8 @@ async def test_zwave_js_value_updated_bypass_dynamic_validation_no_nodes( async def test_zwave_js_value_updated_bypass_dynamic_validation_no_driver( - hass, client, lock_schlage_be469, integration -): + hass: HomeAssistant, client, lock_schlage_be469, integration +) -> None: """Test zwave_js.value_updated trigger without driver.""" trigger_type = f"{DOMAIN}.value_updated" node: Node = lock_schlage_be469 @@ -446,7 +448,9 @@ async def test_zwave_js_value_updated_bypass_dynamic_validation_no_driver( assert len(no_value_filter) == 0 -async def test_zwave_js_event(hass, client, lock_schlage_be469, integration): +async def test_zwave_js_event( + hass: HomeAssistant, client, lock_schlage_be469, integration +) -> None: """Test for zwave_js.event automation trigger.""" trigger_type = f"{DOMAIN}.event" node: Node = lock_schlage_be469 @@ -825,8 +829,8 @@ async def test_zwave_js_event(hass, client, lock_schlage_be469, integration): async def test_zwave_js_event_bypass_dynamic_validation( - hass, client, lock_schlage_be469, integration -): + hass: HomeAssistant, client, lock_schlage_be469, integration +) -> None: """Test zwave_js.event trigger when bypassing dynamic config validation.""" trigger_type = f"{DOMAIN}.event" node: Node = lock_schlage_be469 @@ -875,8 +879,8 @@ async def test_zwave_js_event_bypass_dynamic_validation( async def test_zwave_js_event_bypass_dynamic_validation_no_nodes( - hass, client, lock_schlage_be469, integration -): + hass: HomeAssistant, client, lock_schlage_be469, integration +) -> None: """Test event trigger when bypassing dynamic validation with no nodes.""" trigger_type = f"{DOMAIN}.event" node: Node = lock_schlage_be469 @@ -926,8 +930,8 @@ async def test_zwave_js_event_bypass_dynamic_validation_no_nodes( async def test_zwave_js_event_invalid_config_entry_id( - hass, client, integration, caplog -): + hass: HomeAssistant, client, integration, caplog: pytest.LogCaptureFixture +) -> None: """Test zwave_js.event automation trigger fails when config entry ID is invalid.""" trigger_type = f"{DOMAIN}.event" @@ -993,8 +997,8 @@ async def test_invalid_trigger_configs(hass: HomeAssistant) -> None: async def test_zwave_js_trigger_config_entry_unloaded( - hass, client, lock_schlage_be469, integration -): + hass: HomeAssistant, client, lock_schlage_be469, integration +) -> None: """Test zwave_js triggers bypass dynamic validation when needed.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index 4152d9aa32..30371904a4 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -21,11 +21,13 @@ from homeassistant.components.update import ( from homeassistant.components.zwave_js.const import DOMAIN, SERVICE_REFRESH_VALUE from homeassistant.components.zwave_js.helpers import get_valueless_base_unique_id from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_registry import async_get from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed +from tests.typing import WebSocketGenerator UPDATE_ENTITY = "update.z_wave_thermostat_firmware" FIRMWARE_UPDATES = { @@ -69,13 +71,13 @@ FIRMWARE_UPDATE_MULTIPLE_FILES = { async def test_update_entity_states( - hass, + hass: HomeAssistant, client, climate_radio_thermostat_ct100_plus_different_endpoints, integration, - caplog, - hass_ws_client, -): + caplog: pytest.LogCaptureFixture, + hass_ws_client: WebSocketGenerator, +) -> None: """Test update entity states.""" ws_client = await hass_ws_client(hass) @@ -154,11 +156,11 @@ async def test_update_entity_states( async def test_update_entity_install_raises( - hass, + hass: HomeAssistant, client, climate_radio_thermostat_ct100_plus_different_endpoints, integration, -): +) -> None: """Test update entity install raises exception.""" client.async_send_command.return_value = FIRMWARE_UPDATES @@ -180,11 +182,11 @@ async def test_update_entity_install_raises( async def test_update_entity_sleep( - hass, + hass: HomeAssistant, client, zen_31, integration, -): +) -> None: """Test update occurs when device is asleep after it wakes up.""" event = Event( "sleep", @@ -217,11 +219,11 @@ async def test_update_entity_sleep( async def test_update_entity_dead( - hass, + hass: HomeAssistant, client, zen_31, integration, -): +) -> None: """Test update occurs when device is dead after it becomes alive.""" event = Event( "dead", @@ -254,11 +256,11 @@ async def test_update_entity_dead( async def test_update_entity_ha_not_running( - hass, + hass: HomeAssistant, client, zen_31, - hass_ws_client, -): + hass_ws_client: WebSocketGenerator, +) -> None: """Test update occurs after HA starts.""" await hass.async_stop() @@ -278,11 +280,11 @@ async def test_update_entity_ha_not_running( async def test_update_entity_update_failure( - hass, + hass: HomeAssistant, client, climate_radio_thermostat_ct100_plus_different_endpoints, integration, -): +) -> None: """Test update entity update failed.""" assert len(client.async_send_command.call_args_list) == 0 client.async_send_command.side_effect = FailedZWaveCommand("test", 260, "test") @@ -303,11 +305,11 @@ async def test_update_entity_update_failure( async def test_update_entity_progress( - hass, + hass: HomeAssistant, client, climate_radio_thermostat_ct100_plus_different_endpoints, integration, -): +) -> None: """Test update entity progress.""" node = climate_radio_thermostat_ct100_plus_different_endpoints client.async_send_command.return_value = FIRMWARE_UPDATES @@ -398,12 +400,12 @@ async def test_update_entity_progress( async def test_update_entity_install_failed( - hass, + hass: HomeAssistant, client, climate_radio_thermostat_ct100_plus_different_endpoints, integration, - caplog, -): + caplog: pytest.LogCaptureFixture, +) -> None: """Test update entity install returns error status.""" node = climate_radio_thermostat_ct100_plus_different_endpoints client.async_send_command.return_value = FIRMWARE_UPDATES @@ -491,11 +493,11 @@ async def test_update_entity_install_failed( async def test_update_entity_reload( - hass, + hass: HomeAssistant, client, climate_radio_thermostat_ct100_plus_different_endpoints, integration, -): +) -> None: """Test update entity maintains state after reload.""" assert hass.states.get(UPDATE_ENTITY).state == STATE_OFF From d84fde8c54cd072c5bcad35abd29f20e14faaa74 Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Sat, 18 Feb 2023 09:52:49 -0500 Subject: [PATCH 0191/2810] Insteon scenes (#87803) * Add Insteon scene support * Bump to pyinsteon 1.3.1 * Add tests * Bump Insteon Panel to 0.3.1 * Change docstring --- .../components/insteon/api/__init__.py | 11 + .../components/insteon/api/scenes.py | 122 ++++ .../components/insteon/manifest.json | 2 +- homeassistant/components/insteon/schemas.py | 12 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../insteon/fixtures/scene_data.json | 547 ++++++++++++++++++ tests/components/insteon/test_api_scenes.py | 184 ++++++ 8 files changed, 879 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/insteon/api/scenes.py create mode 100644 tests/components/insteon/fixtures/scene_data.json create mode 100644 tests/components/insteon/test_api_scenes.py diff --git a/homeassistant/components/insteon/api/__init__.py b/homeassistant/components/insteon/api/__init__.py index b12ae993d9..fa006c6a6d 100644 --- a/homeassistant/components/insteon/api/__init__.py +++ b/homeassistant/components/insteon/api/__init__.py @@ -28,6 +28,12 @@ from .properties import ( websocket_reset_properties, websocket_write_properties, ) +from .scenes import ( + websocket_delete_scene, + websocket_get_scene, + websocket_get_scenes, + websocket_save_scene, +) URL_BASE = "/insteon_static" @@ -39,6 +45,11 @@ def async_load_api(hass): websocket_api.async_register_command(hass, websocket_add_device) websocket_api.async_register_command(hass, websocket_cancel_add_device) + websocket_api.async_register_command(hass, websocket_get_scenes) + websocket_api.async_register_command(hass, websocket_get_scene) + websocket_api.async_register_command(hass, websocket_save_scene) + websocket_api.async_register_command(hass, websocket_delete_scene) + websocket_api.async_register_command(hass, websocket_get_aldb) websocket_api.async_register_command(hass, websocket_change_aldb_record) websocket_api.async_register_command(hass, websocket_create_aldb_record) diff --git a/homeassistant/components/insteon/api/scenes.py b/homeassistant/components/insteon/api/scenes.py new file mode 100644 index 0000000000..894ae2da6a --- /dev/null +++ b/homeassistant/components/insteon/api/scenes.py @@ -0,0 +1,122 @@ +"""Web socket API for Insteon scenes.""" + +from pyinsteon import devices +from pyinsteon.constants import ResponseStatus +from pyinsteon.managers.scene_manager import ( + DeviceLinkSchema, + async_add_or_update_scene, + async_delete_scene, + async_get_scene, + async_get_scenes, +) +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant + +from ..const import ID, TYPE + + +def _scene_to_dict(scene): + """Return a dictionary mapping of a scene.""" + device_dict = {} + for addr, links in scene["devices"].items(): + str_addr = str(addr) + device_dict[str_addr] = [] + for data in links: + device_dict[str_addr].append( + { + "data1": data.data1, + "data2": data.data2, + "data3": data.data3, + "has_controller": data.has_controller, + "has_responder": data.has_responder, + } + ) + return {"name": scene["name"], "group": scene["group"], "devices": device_dict} + + +@websocket_api.websocket_command({vol.Required(TYPE): "insteon/scenes/get"}) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_get_scenes( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Get all Insteon scenes.""" + scenes = await async_get_scenes(work_dir=hass.config.config_dir) + scenes_dict = { + scene_num: _scene_to_dict(scene) for scene_num, scene in scenes.items() + } + connection.send_result(msg[ID], scenes_dict) + + +@websocket_api.websocket_command( + {vol.Required(TYPE): "insteon/scene/get", vol.Required("scene_id"): int} +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_get_scene( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Get an Insteon scene.""" + scene_id = msg["scene_id"] + scene = await async_get_scene(scene_num=scene_id, work_dir=hass.config.config_dir) + connection.send_result(msg[ID], _scene_to_dict(scene)) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/scene/save", + vol.Required("name"): str, + vol.Required("scene_id"): int, + vol.Required("links"): DeviceLinkSchema, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_save_scene( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Save an Insteon scene.""" + scene_id = msg["scene_id"] + name = msg["name"] + links = msg["links"] + + scene_id, result = await async_add_or_update_scene( + scene_num=scene_id, links=links, name=name, work_dir=hass.config.config_dir + ) + await devices.async_save(workdir=hass.config.config_dir) + connection.send_result( + msg[ID], {"scene_id": scene_id, "result": result == ResponseStatus.SUCCESS} + ) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/scene/delete", + vol.Required("scene_id"): int, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_delete_scene( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Delete an Insteon scene.""" + scene_id = msg["scene_id"] + + result = await async_delete_scene( + scene_num=scene_id, work_dir=hass.config.config_dir + ) + await devices.async_save(workdir=hass.config.config_dir) + connection.send_result( + msg[ID], {"scene_id": scene_id, "result": result == ResponseStatus.SUCCESS} + ) diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index 60933962cc..57a26b4821 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -18,7 +18,7 @@ "loggers": ["pyinsteon", "pypubsub"], "requirements": [ "pyinsteon==1.3.1", - "insteon-frontend-home-assistant==0.2.0" + "insteon-frontend-home-assistant==0.3.1" ], "usb": [ { diff --git a/homeassistant/components/insteon/schemas.py b/homeassistant/components/insteon/schemas.py index 6bcde545e3..785aa90dd4 100644 --- a/homeassistant/components/insteon/schemas.py +++ b/homeassistant/components/insteon/schemas.py @@ -170,6 +170,18 @@ TRIGGER_SCENE_SCHEMA = vol.Schema( ADD_DEFAULT_LINKS_SCHEMA = vol.Schema({vol.Required(CONF_ENTITY_ID): cv.entity_id}) +SCENE_ENTITY_SCHEMA = vol.Schema( + [ + { + vol.Required(CONF_ADDRESS): str, + vol.Required("data1"): int, + vol.Required("data2"): int, + vol.Required("data3"): int, + } + ] +) + + def normalize_byte_entry_to_int(entry: int | bytes | str): """Format a hex entry value.""" if isinstance(entry, int): diff --git a/requirements_all.txt b/requirements_all.txt index 1c997881d0..e00ab993c6 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.2.0 +insteon-frontend-home-assistant==0.3.1 # homeassistant.components.intellifire intellifire4py==2.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 38cff29ba5..2503ca343f 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.2.0 +insteon-frontend-home-assistant==0.3.1 # homeassistant.components.intellifire intellifire4py==2.2.2 diff --git a/tests/components/insteon/fixtures/scene_data.json b/tests/components/insteon/fixtures/scene_data.json new file mode 100644 index 0000000000..fc6a58e84f --- /dev/null +++ b/tests/components/insteon/fixtures/scene_data.json @@ -0,0 +1,547 @@ +[ + { + "address": "aaaaaa", + "aldb": { + "8191": { + "memory": 8191, + "in_use": true, + "controller": true, + "high_water_mark": false, + "bit5": true, + "bit4": false, + "group": 0, + "target": "111111", + "data1": 0, + "data2": 26, + "data3": 0 + }, + "8183": { + "memory": 8183, + "in_use": true, + "controller": false, + "high_water_mark": false, + "bit5": true, + "bit4": false, + "group": 1, + "target": "111111", + "data1": 0, + "data2": 0, + "data3": 0 + }, + "8175": { + "memory": 8175, + "in_use": true, + "controller": true, + "high_water_mark": false, + "bit5": true, + "bit4": false, + "group": 0, + "target": "333333", + "data1": 1, + "data2": 66, + "data3": 69 + }, + "8167": { + "memory": 8167, + "in_use": true, + "controller": false, + "high_water_mark": false, + "bit5": false, + "bit4": false, + "group": 1, + "target": "333333", + "data1": 0, + "data2": 0, + "data3": 0 + }, + "8159": { + "memory": 8159, + "in_use": true, + "controller": false, + "high_water_mark": false, + "bit5": false, + "bit4": false, + "group": 3, + "target": "333333", + "data1": 0, + "data2": 0, + "data3": 0 + }, + "8151": { + "memory": 8151, + "in_use": true, + "controller": false, + "high_water_mark": false, + "bit5": false, + "bit4": false, + "group": 4, + "target": "333333", + "data1": 0, + "data2": 0, + "data3": 0 + }, + "8143": { + "memory": 8143, + "in_use": true, + "controller": false, + "high_water_mark": false, + "bit5": false, + "bit4": false, + "group": 5, + "target": "333333", + "data1": 0, + "data2": 0, + "data3": 0 + }, + "8135": { + "memory": 8135, + "in_use": true, + "controller": false, + "high_water_mark": false, + "bit5": false, + "bit4": false, + "group": 6, + "target": "333333", + "data1": 0, + "data2": 0, + "data3": 0 + }, + "8127": { + "memory": 8127, + "in_use": true, + "controller": true, + "high_water_mark": false, + "bit5": true, + "bit4": false, + "group": 0, + "target": "444444", + "data1": 1, + "data2": 31, + "data3": 65 + }, + "8119": { + "memory": 8119, + "in_use": true, + "controller": false, + "high_water_mark": false, + "bit5": true, + "bit4": false, + "group": 1, + "target": "444444", + "data1": 0, + "data2": 0, + "data3": 0 + }, + "8111": { + "memory": 8111, + "in_use": true, + "controller": true, + "high_water_mark": false, + "bit5": false, + "bit4": false, + "group": 20, + "target": "111111", + "data1": 0, + "data2": 0, + "data3": 0 + }, + "8103": { + "memory": 8103, + "in_use": true, + "controller": true, + "high_water_mark": false, + "bit5": false, + "bit4": false, + "group": 20, + "target": "333333", + "data1": 0, + "data2": 0, + "data3": 0 + }, + "8095": { + "memory": 8095, + "in_use": true, + "controller": true, + "high_water_mark": false, + "bit5": false, + "bit4": false, + "group": 20, + "target": "444444", + "data1": 0, + "data2": 0, + "data3": 0 + }, + "8087": { + "memory": 8087, + "in_use": false, + "controller": false, + "high_water_mark": true, + "bit5": false, + "bit4": false, + "group": 0, + "target": "000000", + "data1": 0, + "data2": 0, + "data3": 0 + } + }, + "operating_flags": {}, + "properties": {}, + "read_write_mode": 2, + "first_mem_addr": 8191 + }, + { + "address": "111111", + "aldb": { + "4095": { + "memory": 4095, + "in_use": true, + "controller": false, + "high_water_mark": false, + "bit5": false, + "bit4": false, + "group": 0, + "target": "aaaaaa", + "data1": 0, + "data2": 0, + "data3": 0 + }, + "4087": { + "memory": 4087, + "in_use": true, + "controller": true, + "high_water_mark": false, + "bit5": true, + "bit4": false, + "group": 1, + "target": "aaaaaa", + "data1": 3, + "data2": 51, + "data3": 165 + }, + "4079": { + "memory": 4079, + "in_use": true, + "controller": false, + "high_water_mark": false, + "bit5": true, + "bit4": false, + "group": 20, + "target": "aaaaaa", + "data1": 255, + "data2": 28, + "data3": 1 + }, + "4071": { + "memory": 4071, + "in_use": true, + "controller": false, + "high_water_mark": false, + "bit5": true, + "bit4": false, + "group": 1, + "target": "333333", + "data1": 255, + "data2": 28, + "data3": 1 + }, + "4063": { + "memory": 4063, + "in_use": true, + "controller": true, + "high_water_mark": false, + "bit5": true, + "bit4": false, + "group": 1, + "target": "333333", + "data1": 0, + "data2": 0, + "data3": 0 + }, + "4055": { + "memory": 4055, + "in_use": false, + "controller": false, + "high_water_mark": true, + "bit5": false, + "bit4": false, + "group": 0, + "target": "000000", + "data1": 0, + "data2": 0, + "data3": 0 + } + }, + "operating_flags": { + "program_lock_on": false, + "blink_on_tx_on": false, + "resume_dim_on": false, + "key_beep_on": true, + "blink_on_error_on": false + }, + "properties": { + "x10_house": 32, + "x10_unit": 0, + "ramp_rate": 28, + "on_level": 255 + }, + "read_write_mode": 3, + "first_mem_addr": 4095 + }, + { + "address": "333333", + "aldb": { + "4095": { + "memory": 4095, + "in_use": true, + "controller": false, + "high_water_mark": false, + "bit5": true, + "bit4": false, + "group": 0, + "target": "aaaaaa", + "data1": 0, + "data2": 0, + "data3": 0 + }, + "4087": { + "memory": 4087, + "in_use": true, + "controller": true, + "high_water_mark": false, + "bit5": true, + "bit4": false, + "group": 1, + "target": "aaaaaa", + "data1": 3, + "data2": 51, + "data3": 165 + }, + "4079": { + "memory": 4079, + "in_use": true, + "controller": true, + "high_water_mark": false, + "bit5": true, + "bit4": false, + "group": 3, + "target": "aaaaaa", + "data1": 3, + "data2": 51, + "data3": 165 + }, + "4071": { + "memory": 4071, + "in_use": true, + "controller": true, + "high_water_mark": false, + "bit5": true, + "bit4": false, + "group": 4, + "target": "aaaaaa", + "data1": 3, + "data2": 51, + "data3": 165 + }, + "4063": { + "memory": 4063, + "in_use": true, + "controller": true, + "high_water_mark": false, + "bit5": true, + "bit4": false, + "group": 5, + "target": "aaaaaa", + "data1": 3, + "data2": 51, + "data3": 165 + }, + "4055": { + "memory": 4055, + "in_use": true, + "controller": true, + "high_water_mark": false, + "bit5": true, + "bit4": false, + "group": 6, + "target": "aaaaaa", + "data1": 3, + "data2": 51, + "data3": 165 + }, + "4047": { + "memory": 4047, + "in_use": true, + "controller": false, + "high_water_mark": false, + "bit5": true, + "bit4": false, + "group": 20, + "target": "aaaaaa", + "data1": 255, + "data2": 0, + "data3": 3 + }, + "4039": { + "memory": 4039, + "in_use": true, + "controller": false, + "high_water_mark": false, + "bit5": true, + "bit4": false, + "group": 1, + "target": "111111", + "data1": 255, + "data2": 28, + "data3": 1 + }, + "4031": { + "memory": 4031, + "in_use": true, + "controller": true, + "high_water_mark": false, + "bit5": true, + "bit4": false, + "group": 1, + "target": "111111", + "data1": 0, + "data2": 0, + "data3": 0 + }, + "4023": { + "memory": 4023, + "in_use": false, + "controller": false, + "high_water_mark": true, + "bit5": false, + "bit4": false, + "group": 0, + "target": "000000", + "data1": 0, + "data2": 0, + "data3": 0 + } + }, + "operating_flags": { + "program_lock_on": false, + "blink_on_tx_on": false, + "resume_dim_on": false, + "led_off": false, + "key_beep_on": false, + "rf_disable_on": false, + "powerline_disable_on": false, + "blink_on_error_off": true + }, + "properties": { + "led_dimming": 51, + "non_toggle_mask": 0, + "non_toggle_on_off_mask": 0, + "trigger_group_mask": 0, + "on_mask": 0, + "off_mask": 0, + "x10_house": 32, + "x10_unit": 32, + "ramp_rate": 28, + "on_level": 255, + "on_mask_3": 0, + "off_mask_3": 0, + "x10_house_3": 32, + "x10_unit_3": 32, + "ramp_rate_3": 0, + "on_level_3": 0, + "on_mask_4": 0, + "off_mask_4": 0, + "x10_house_4": 32, + "x10_unit_4": 32, + "ramp_rate_4": 0, + "on_level_4": 0, + "on_mask_5": 0, + "off_mask_5": 0, + "x10_house_5": 32, + "x10_unit_5": 32, + "ramp_rate_5": 0, + "on_level_5": 0, + "on_mask_6": 0, + "off_mask_6": 0, + "x10_house_6": 32, + "x10_unit_6": 32, + "ramp_rate_6": 0, + "on_level_6": 0 + }, + "read_write_mode": 1, + "first_mem_addr": 4095 + }, + { + "address": "444444", + "aldb": { + "4095": { + "memory": 4095, + "in_use": false, + "controller": false, + "high_water_mark": false, + "bit5": true, + "bit4": false, + "group": 0, + "target": "aaaaaa", + "data1": 0, + "data2": 0, + "data3": 0 + }, + "4087": { + "memory": 4087, + "in_use": true, + "controller": true, + "high_water_mark": false, + "bit5": true, + "bit4": false, + "group": 1, + "target": "aaaaaa", + "data1": 255, + "data2": 28, + "data3": 0 + }, + "4079": { + "memory": 4079, + "in_use": true, + "controller": false, + "high_water_mark": false, + "bit5": true, + "bit4": false, + "group": 20, + "target": "aaaaaa", + "data1": 255, + "data2": 28, + "data3": 1 + }, + "4071": { + "memory": 4071, + "in_use": false, + "controller": false, + "high_water_mark": true, + "bit5": false, + "bit4": false, + "group": 0, + "target": "000000", + "data1": 0, + "data2": 0, + "data3": 0 + } + }, + "operating_flags": { + "program_lock_on": false, + "blink_on_tx_on": false, + "resume_dim_on": false, + "key_beep_on": false, + "blink_on_error_on": false + }, + "properties": { + "led_dimming": 0, + "x10_house": 32, + "x10_unit": 32, + "ramp_rate": 28, + "on_level": 255 + }, + "read_write_mode": 1, + "first_mem_addr": 4095 + } +] diff --git a/tests/components/insteon/test_api_scenes.py b/tests/components/insteon/test_api_scenes.py new file mode 100644 index 0000000000..a777964052 --- /dev/null +++ b/tests/components/insteon/test_api_scenes.py @@ -0,0 +1,184 @@ +"""Test the Insteon Scenes APIs.""" + +import json +import os +from unittest.mock import AsyncMock, patch + +from pyinsteon.constants import ResponseStatus +import pyinsteon.managers.scene_manager +import pytest + +from homeassistant.components.insteon.api import async_load_api, scenes +from homeassistant.components.insteon.const import ID, TYPE + +from .mock_devices import MockDevices + +from tests.common import load_fixture + + +@pytest.fixture(name="scene_data", scope="session") +def aldb_data_fixture(): + """Load the controller state fixture data.""" + return json.loads(load_fixture("insteon/scene_data.json")) + + +@pytest.fixture(name="remove_json") +def remove_insteon_devices_json(hass): + """Fixture to remove insteon_devices.json at the end of the test.""" + yield + file = os.path.join(hass.config.config_dir, "insteon_devices.json") + if os.path.exists(file): + os.remove(file) + + +def _scene_to_array(scene): + """Convert a scene object to a dictionary.""" + scene_list = [] + for device, links in scene["devices"].items(): + for link in links: + link_dict = {} + link_dict["address"] = device.id + link_dict["data1"] = link.data1 + link_dict["data2"] = link.data2 + link_dict["data3"] = link.data3 + scene_list.append(link_dict) + return scene_list + + +async def _setup(hass, hass_ws_client, scene_data): + """Set up tests.""" + ws_client = await hass_ws_client(hass) + devices = MockDevices() + await devices.async_load() + async_load_api(hass) + for device in scene_data: + addr = device["address"] + aldb = device["aldb"] + devices.fill_aldb(addr, aldb) + return ws_client, devices + + +async def test_get_scenes(hass, hass_ws_client, scene_data): + """Test getting all Insteon scenes.""" + ws_client, devices = await _setup(hass, hass_ws_client, scene_data) + + with patch.object(pyinsteon.managers.scene_manager, "devices", devices): + await ws_client.send_json({ID: 1, TYPE: "insteon/scenes/get"}) + msg = await ws_client.receive_json() + result = msg["result"] + assert len(result) == 1 + assert len(result["20"]) == 3 + + +async def test_get_scene(hass, hass_ws_client, scene_data): + """Test getting an Insteon scene.""" + ws_client, devices = await _setup(hass, hass_ws_client, scene_data) + + with patch.object(pyinsteon.managers.scene_manager, "devices", devices): + await ws_client.send_json({ID: 1, TYPE: "insteon/scene/get", "scene_id": 20}) + msg = await ws_client.receive_json() + result = msg["result"] + assert len(result["devices"]) == 3 + + +async def test_save_scene(hass, hass_ws_client, scene_data, remove_json): + """Test saving an Insteon scene.""" + ws_client, devices = await _setup(hass, hass_ws_client, scene_data) + + mock_add_or_update_scene = AsyncMock(return_value=(20, ResponseStatus.SUCCESS)) + + with patch.object( + pyinsteon.managers.scene_manager, "devices", devices + ), patch.object(scenes, "async_add_or_update_scene", mock_add_or_update_scene): + scene = await pyinsteon.managers.scene_manager.async_get_scene(20) + scene["devices"]["1a1a1a"] = [] + links = _scene_to_array(scene) + await ws_client.send_json( + { + ID: 1, + TYPE: "insteon/scene/save", + "scene_id": 20, + "name": "Some scene name", + "links": links, + } + ) + msg = await ws_client.receive_json() + result = msg["result"] + assert result["result"] + assert result["scene_id"] == 20 + + +async def test_save_new_scene(hass, hass_ws_client, scene_data, remove_json): + """Test saving a new Insteon scene.""" + ws_client, devices = await _setup(hass, hass_ws_client, scene_data) + + mock_add_or_update_scene = AsyncMock(return_value=(21, ResponseStatus.SUCCESS)) + + with patch.object( + pyinsteon.managers.scene_manager, "devices", devices + ), patch.object(scenes, "async_add_or_update_scene", mock_add_or_update_scene): + scene = await pyinsteon.managers.scene_manager.async_get_scene(20) + scene["devices"]["1a1a1a"] = [] + links = _scene_to_array(scene) + await ws_client.send_json( + { + ID: 1, + TYPE: "insteon/scene/save", + "scene_id": -1, + "name": "Some scene name", + "links": links, + } + ) + msg = await ws_client.receive_json() + result = msg["result"] + assert result["result"] + assert result["scene_id"] == 21 + + +async def test_save_scene_error(hass, hass_ws_client, scene_data, remove_json): + """Test saving an Insteon scene with error.""" + ws_client, devices = await _setup(hass, hass_ws_client, scene_data) + + mock_add_or_update_scene = AsyncMock(return_value=(20, ResponseStatus.FAILURE)) + + with patch.object( + pyinsteon.managers.scene_manager, "devices", devices + ), patch.object(scenes, "async_add_or_update_scene", mock_add_or_update_scene): + scene = await pyinsteon.managers.scene_manager.async_get_scene(20) + scene["devices"]["1a1a1a"] = [] + links = _scene_to_array(scene) + await ws_client.send_json( + { + ID: 1, + TYPE: "insteon/scene/save", + "scene_id": 20, + "name": "Some scene name", + "links": links, + } + ) + msg = await ws_client.receive_json() + result = msg["result"] + assert not result["result"] + assert result["scene_id"] == 20 + + +async def test_delete_scene(hass, hass_ws_client, scene_data, remove_json): + """Test delete an Insteon scene.""" + ws_client, devices = await _setup(hass, hass_ws_client, scene_data) + + mock_delete_scene = AsyncMock(return_value=ResponseStatus.SUCCESS) + + with patch.object( + pyinsteon.managers.scene_manager, "devices", devices + ), patch.object(scenes, "async_delete_scene", mock_delete_scene): + await ws_client.send_json( + { + ID: 1, + TYPE: "insteon/scene/delete", + "scene_id": 20, + } + ) + msg = await ws_client.receive_json() + result = msg["result"] + assert result["result"] + assert result["scene_id"] == 20 From 1eb20affa162c7fd45f134055c9a97f53561dff0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 18 Feb 2023 16:18:01 +0100 Subject: [PATCH 0192/2810] Update use of sqlalchemy with_variant (#88395) --- homeassistant/components/recorder/db_schema.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 1cd130bbf9..88ddf93861 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -127,12 +127,12 @@ JSONB_VARIANT_CAST = Text().with_variant( ) DATETIME_TYPE = ( DateTime(timezone=True) - .with_variant(mysql.DATETIME(timezone=True, fsp=6), "mysql") # type: ignore[no-untyped-call] + .with_variant(mysql.DATETIME(timezone=True, fsp=6), "mysql", "mariadb") # type: ignore[no-untyped-call] .with_variant(FAST_PYSQLITE_DATETIME(), "sqlite") # type: ignore[no-untyped-call] ) DOUBLE_TYPE = ( Float() - .with_variant(mysql.DOUBLE(asdecimal=False), "mysql") # type: ignore[no-untyped-call] + .with_variant(mysql.DOUBLE(asdecimal=False), "mysql", "mariadb") # type: ignore[no-untyped-call] .with_variant(oracle.DOUBLE_PRECISION(), "oracle") .with_variant(postgresql.DOUBLE_PRECISION(), "postgresql") ) @@ -170,7 +170,7 @@ class Events(Base): event_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) event_type: Mapped[str | None] = mapped_column(String(MAX_LENGTH_EVENT_EVENT_TYPE)) event_data: Mapped[str | None] = mapped_column( - Text().with_variant(mysql.LONGTEXT, "mysql") + Text().with_variant(mysql.LONGTEXT, "mysql", "mariadb") ) origin: Mapped[str | None] = mapped_column( String(MAX_LENGTH_EVENT_ORIGIN) @@ -263,7 +263,7 @@ class EventData(Base): hash: Mapped[int | None] = mapped_column(BigInteger, index=True) # Note that this is not named attributes to avoid confusion with the states table shared_data: Mapped[str | None] = mapped_column( - Text().with_variant(mysql.LONGTEXT, "mysql") + Text().with_variant(mysql.LONGTEXT, "mysql", "mariadb") ) def __repr__(self) -> str: @@ -314,7 +314,7 @@ class States(Base): entity_id: Mapped[str | None] = mapped_column(String(MAX_LENGTH_STATE_ENTITY_ID)) state: Mapped[str | None] = mapped_column(String(MAX_LENGTH_STATE_STATE)) attributes: Mapped[str | None] = mapped_column( - Text().with_variant(mysql.LONGTEXT, "mysql") + Text().with_variant(mysql.LONGTEXT, "mysql", "mariadb") ) # no longer used for new rows event_id: Mapped[int | None] = mapped_column( # no longer used for new rows Integer, ForeignKey("events.event_id", ondelete="CASCADE"), index=True @@ -446,7 +446,7 @@ class StateAttributes(Base): hash: Mapped[int | None] = mapped_column(BigInteger, index=True) # Note that this is not named attributes to avoid confusion with the states table shared_attrs: Mapped[str | None] = mapped_column( - Text().with_variant(mysql.LONGTEXT, "mysql") + Text().with_variant(mysql.LONGTEXT, "mysql", "mariadb") ) def __repr__(self) -> str: From 06213ee595e1be2d637f8cf1ffec97fa4463e14f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 18 Feb 2023 16:18:23 +0100 Subject: [PATCH 0193/2810] Drop sqlachemy_utils workaround (#88393) --- tests/conftest.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 05dfcaa19f..5a46971730 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1075,24 +1075,6 @@ def recorder_db_url( assert not hass_fixture_setup db_url = cast(str, pytestconfig.getoption("dburl")) - if db_url.startswith(("postgresql://", "mysql://")): - # pylint: disable-next=import-outside-toplevel - import sqlalchemy_utils - - def _ha_orm_quote(mixed, ident): - """Conditionally quote an identifier. - - Modified to include https://github.com/kvesteri/sqlalchemy-utils/pull/677 - """ - if isinstance(mixed, sqlalchemy_utils.functions.orm.Dialect): - dialect = mixed - elif hasattr(mixed, "dialect"): - dialect = mixed.dialect - else: - dialect = sqlalchemy_utils.functions.orm.get_bind(mixed).dialect - return dialect.preparer(dialect).quote(ident) - - sqlalchemy_utils.functions.database.quote = _ha_orm_quote if db_url.startswith("mysql://"): # pylint: disable-next=import-outside-toplevel import sqlalchemy_utils From 496ab49d80a66723a853583ec03b80a65eb4576b Mon Sep 17 00:00:00 2001 From: jjlawren Date: Sat, 18 Feb 2023 14:02:03 -0600 Subject: [PATCH 0194/2810] Bump `SoCo` to 0.29.1 (#88410) Bump soco to 0.29.1 --- homeassistant/components/sonos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 54a36b093e..e1b3c6c113 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/sonos", "iot_class": "local_push", "loggers": ["soco"], - "requirements": ["soco==0.29.0"], + "requirements": ["soco==0.29.1"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:ZonePlayer:1" diff --git a/requirements_all.txt b/requirements_all.txt index e00ab993c6..e81b91d64d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2367,7 +2367,7 @@ smhi-pkg==1.0.16 snapcast==2.3.0 # homeassistant.components.sonos -soco==0.29.0 +soco==0.29.1 # homeassistant.components.solaredge_local solaredge-local==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2503ca343f..0765d13371 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1667,7 +1667,7 @@ smart-meter-texas==0.4.7 smhi-pkg==1.0.16 # homeassistant.components.sonos -soco==0.29.0 +soco==0.29.1 # homeassistant.components.solaredge solaredge==0.0.2 From 3ca9f3c0d51333a2606af17b5d0d4c665df6f11b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Feb 2023 19:21:14 -0600 Subject: [PATCH 0195/2810] Only build the recorder SupportedDialect enum once (#88402) Every call to dialect_name was creating a new enum object in the recorder --- homeassistant/components/recorder/core.py | 8 ++++---- tests/components/recorder/test_init.py | 6 +++++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 7070df6f94..665028b074 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -39,6 +39,7 @@ from homeassistant.helpers.event import ( from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import UNDEFINED, UndefinedType import homeassistant.util.dt as dt_util +from homeassistant.util.enum import try_parse_enum from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS from . import migration, statistics @@ -217,6 +218,7 @@ class Recorder(threading.Thread): self._commit_listener: CALLBACK_TYPE | None = None self._periodic_listener: CALLBACK_TYPE | None = None self._nightly_listener: CALLBACK_TYPE | None = None + self._dialect_name: SupportedDialect | None = None self.enabled = True @property @@ -227,9 +229,7 @@ class Recorder(threading.Thread): @property def dialect_name(self) -> SupportedDialect | None: """Return the dialect the recorder uses.""" - with contextlib.suppress(ValueError): - return SupportedDialect(self.engine.dialect.name) if self.engine else None - return None + return self._dialect_name @property def _using_file_sqlite(self) -> bool: @@ -1176,7 +1176,7 @@ class Recorder(threading.Thread): validate_or_move_away_sqlite_database(self.db_url) self.engine = create_engine(self.db_url, **kwargs, future=True) - + self._dialect_name = try_parse_enum(SupportedDialect, self.engine.dialect.name) sqlalchemy_event.listen(self.engine, "connect", setup_recorder_connection) Base.metadata.create_all(self.engine) diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 3900585d54..5c54240537 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -1720,7 +1720,7 @@ async def test_database_lock_without_instance( hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) instance = get_instance(hass) - with patch.object(instance, "engine", None): + with patch.object(instance, "engine"): try: assert await instance.lock_database() finally: @@ -1990,6 +1990,10 @@ async def test_connect_args_priority(hass: HomeAssistant, config_url) -> None: def create_connect_args(self, url): return ([], {"charset": "invalid"}) + @property + def name(self) -> str: + return "mysql" + @classmethod def dbapi(cls): ... From d4376b20413c09dfcadc9a97ff7784df20265430 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Feb 2023 19:23:47 -0600 Subject: [PATCH 0196/2810] Use a single session to compile statistics (#88405) We had two session_scope contexts in compile_statistics which causes a commit to happen twice during the compile. --- .../components/recorder/statistics.py | 51 +++++++++---------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 59a12a6441..1c8d62eb82 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -737,36 +737,35 @@ def compile_statistics(instance: Recorder, start: datetime, fire_events: bool) - end = start + timedelta(minutes=5) # Return if we already have 5-minute statistics for the requested period - with session_scope(session=instance.get_session()) as session: - if session.query(StatisticsRuns).filter_by(start=start).first(): - _LOGGER.debug("Statistics already compiled for %s-%s", start, end) - return True - - _LOGGER.debug("Compiling statistics for %s-%s", start, end) - platform_stats: list[StatisticResult] = [] - current_metadata: dict[str, tuple[int, StatisticMetaData]] = {} - # Collect statistics from all platforms implementing support - for domain, platform in instance.hass.data[DOMAIN].recorder_platforms.items(): - if not hasattr(platform, "compile_statistics"): - continue - compiled: PlatformCompiledStatistics = platform.compile_statistics( - instance.hass, start, end - ) - _LOGGER.debug( - "Statistics for %s during %s-%s: %s", - domain, - start, - end, - compiled.platform_stats, - ) - platform_stats.extend(compiled.platform_stats) - current_metadata.update(compiled.current_metadata) - - # Insert collected statistics in the database with session_scope( session=instance.get_session(), exception_filter=_filter_unique_constraint_integrity_error(instance), ) as session: + if session.query(StatisticsRuns).filter_by(start=start).first(): + _LOGGER.debug("Statistics already compiled for %s-%s", start, end) + return True + + _LOGGER.debug("Compiling statistics for %s-%s", start, end) + platform_stats: list[StatisticResult] = [] + current_metadata: dict[str, tuple[int, StatisticMetaData]] = {} + # Collect statistics from all platforms implementing support + for domain, platform in instance.hass.data[DOMAIN].recorder_platforms.items(): + if not hasattr(platform, "compile_statistics"): + continue + compiled: PlatformCompiledStatistics = platform.compile_statistics( + instance.hass, start, end + ) + _LOGGER.debug( + "Statistics for %s during %s-%s: %s", + domain, + start, + end, + compiled.platform_stats, + ) + platform_stats.extend(compiled.platform_stats) + current_metadata.update(compiled.current_metadata) + + # Insert collected statistics in the database for stats in platform_stats: metadata_id = _update_or_add_metadata( session, stats["meta"], current_metadata From 6cbad61572b25f1c1589c2c7f9433a02ba9a4d83 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 19 Feb 2023 05:05:44 +0100 Subject: [PATCH 0197/2810] Enable Ruff RUF006; Hard reference to asyncio.create_task return value (#88216) * Enable Ruff RUF006 * Fix test --------- Co-authored-by: Paulus Schoutsen --- pyproject.toml | 1 + tests/test_runner.py | 11 +++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e0255bbe65..d913ad0daa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -258,6 +258,7 @@ select = [ "SIM401", # Use get from dict with default instead of an if block "T20", # flake8-print "TRY004", # Prefer TypeError exception for invalid type + "RUF006", # Store a reference to the return value of asyncio.create_task "UP", # pyupgrade "W", # pycodestyle ] diff --git a/tests/test_runner.py b/tests/test_runner.py index 11a5503149..c5222cd026 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -122,18 +122,21 @@ def test_run_does_not_block_forever_with_shielded_task(hass, tmpdir, caplog): async def test_unhandled_exception_traceback(hass, caplog): """Test an unhandled exception gets a traceback in debug mode.""" + raised = asyncio.Event() + async def _unhandled_exception(): + raised.set() raise Exception("This is unhandled") try: hass.loop.set_debug(True) - asyncio.create_task(_unhandled_exception()) + task = asyncio.create_task(_unhandled_exception()) + await raised.wait() + # Delete it without checking result to trigger unhandled exception + del task finally: hass.loop.set_debug(False) - await asyncio.sleep(0) - await asyncio.sleep(0) - assert "Task exception was never retrieved" in caplog.text assert "This is unhandled" in caplog.text assert "_unhandled_exception" in caplog.text From 1d928f05fa85aa95865134cc1e1f62cb06a3c05f Mon Sep 17 00:00:00 2001 From: Teemu R Date: Sun, 19 Feb 2023 05:39:08 +0100 Subject: [PATCH 0198/2810] Update python-kasa to 0.5.1 (#88416) --- homeassistant/components/tplink/light.py | 9 ++++----- homeassistant/components/tplink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tplink/test_light.py | 15 ++------------- 5 files changed, 9 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index 4d7d7a6acc..6739ea421b 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -232,7 +232,7 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): if brightness is not None: await self.device.set_brightness(brightness, transition=transition) return - await self.device.turn_on(transition=transition) # type: ignore[arg-type] + await self.device.turn_on(transition=transition) @async_refresh_after async def async_turn_on(self, **kwargs: Any) -> None: @@ -338,10 +338,9 @@ class TPLinkSmartLightStrip(TPLinkSmartBulb): """Turn the light on.""" brightness, transition = self._async_extract_brightness_transition(**kwargs) if ATTR_EFFECT in kwargs: - await self.device.set_effect(kwargs[ATTR_EFFECT]) - # We need to set the brightness separately until upstream allows defining it for set_effect. - if brightness is not None: - await self._async_turn_on_with_brightness(brightness, transition) + await self.device.set_effect( + kwargs[ATTR_EFFECT], brightness=brightness, transition=transition + ) elif ATTR_COLOR_TEMP in kwargs: if self.effect: # If there is an effect in progress diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index 0a2a2331cb..6683e7e458 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -137,5 +137,5 @@ "iot_class": "local_polling", "loggers": ["kasa"], "quality_scale": "platinum", - "requirements": ["python-kasa==0.5.0"] + "requirements": ["python-kasa==0.5.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index e81b91d64d..89f86d9bd1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2072,7 +2072,7 @@ python-join-api==0.0.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa==0.5.0 +python-kasa==0.5.1 # homeassistant.components.lirc # python-lirc==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0765d13371..5f62818d27 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1471,7 +1471,7 @@ python-izone==1.2.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa==0.5.0 +python-kasa==0.5.1 # homeassistant.components.matter python-matter-server==2.1.1 diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py index 5a5aff55dc..b5dc38b230 100644 --- a/tests/components/tplink/test_light.py +++ b/tests/components/tplink/test_light.py @@ -431,22 +431,11 @@ async def test_smart_strip_effects(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "Effect2"}, blocking=True, ) - strip.set_effect.assert_called_once_with("Effect2") - strip.set_effect.reset_mock() - - # Setting an effect with brightness calls set_brightness implicitly - await hass.services.async_call( - LIGHT_DOMAIN, - "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "Effect2", ATTR_BRIGHTNESS: 255}, - blocking=True, + strip.set_effect.assert_called_once_with( + "Effect2", brightness=None, transition=None ) - strip.set_effect.assert_called_once_with("Effect2") strip.set_effect.reset_mock() - strip.set_brightness.assert_called_with(100, transition=None) - strip.set_brightness.reset_mock() - strip.effect = {"name": "Effect1", "enable": 0, "custom": 0} async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) await hass.async_block_till_done() From 04fabf4838f051abc04102893b2e5f9ee8cd9b9f Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Sat, 18 Feb 2023 22:40:02 -0600 Subject: [PATCH 0199/2810] Update rokuecp to 0.17.1 (#88425) --- homeassistant/components/roku/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json index f5308eddf9..944304aa64 100644 --- a/homeassistant/components/roku/manifest.json +++ b/homeassistant/components/roku/manifest.json @@ -11,7 +11,7 @@ "iot_class": "local_polling", "loggers": ["rokuecp"], "quality_scale": "silver", - "requirements": ["rokuecp==0.17.0"], + "requirements": ["rokuecp==0.17.1"], "ssdp": [ { "st": "roku:ecp", diff --git a/requirements_all.txt b/requirements_all.txt index 89f86d9bd1..4025a77b64 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2258,7 +2258,7 @@ rjpl==0.3.6 rocketchat-API==0.6.1 # homeassistant.components.roku -rokuecp==0.17.0 +rokuecp==0.17.1 # homeassistant.components.roomba roombapy==1.6.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5f62818d27..c0b3fa2466 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1591,7 +1591,7 @@ rflink==0.0.63 ring_doorbell==0.7.2 # homeassistant.components.roku -rokuecp==0.17.0 +rokuecp==0.17.1 # homeassistant.components.roomba roombapy==1.6.5 From e799f248533e5d2d907edd06557ba8c5fdc902d2 Mon Sep 17 00:00:00 2001 From: spycle <48740594+spycle@users.noreply.github.com> Date: Sun, 19 Feb 2023 04:43:28 +0000 Subject: [PATCH 0200/2810] Update keymitt dependency (#88430) --- homeassistant/components/keymitt_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/keymitt_ble/manifest.json b/homeassistant/components/keymitt_ble/manifest.json index 995a592beb..760cc67cbd 100644 --- a/homeassistant/components/keymitt_ble/manifest.json +++ b/homeassistant/components/keymitt_ble/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/keymitt_ble", "iot_class": "assumed_state", "loggers": ["keymitt_ble"], - "requirements": ["PyMicroBot==0.0.8"] + "requirements": ["PyMicroBot==0.0.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4025a77b64..245894f85d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -23,7 +23,7 @@ PyFlick==0.0.2 PyMVGLive==1.1.4 # homeassistant.components.keymitt_ble -PyMicroBot==0.0.8 +PyMicroBot==0.0.9 # homeassistant.components.mobile_app # homeassistant.components.owntracks diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c0b3fa2466..bb7693e711 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -19,7 +19,7 @@ HAP-python==4.6.0 PyFlick==0.0.2 # homeassistant.components.keymitt_ble -PyMicroBot==0.0.8 +PyMicroBot==0.0.9 # homeassistant.components.mobile_app # homeassistant.components.owntracks From 9df00bd285a656814c8073ad92e88d1778efdf25 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Feb 2023 23:02:36 -0600 Subject: [PATCH 0201/2810] Adjust recorder LRU cache to align with the number of entities (#88350) * Adjust size of recorder LRU based on number of entities If there are a large number of entities the cache would get thrashed as there were more state attributes being recorded than the size of the cache. This meant we had to go back to the database to do lookups frequently when an instance has more than 2048 entities that change frequently * add a test * do not actually record 4096 states * patch target * patch target --- homeassistant/components/recorder/core.py | 23 +++++++++++++++++++++-- tests/components/recorder/test_init.py | 15 +++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 665028b074..7cd0ad56f7 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -411,6 +411,7 @@ class Recorder(threading.Thread): @callback def _async_hass_started(self, hass: HomeAssistant) -> None: """Notify that hass has started.""" + self.async_adjust_lru() self._hass_started.set_result(None) @callback @@ -475,7 +476,25 @@ class Recorder(threading.Thread): self.queue_task(PerodicCleanupTask()) @callback - def async_periodic_statistics(self, now: datetime) -> None: + def _async_five_minute_tasks(self, now: datetime) -> None: + """Run tasks every five minutes.""" + self.async_adjust_lru() + self.async_periodic_statistics() + + @callback + def async_adjust_lru(self) -> None: + """Trigger the LRU adjustment. + + If the number of entities has increased, increase the size of the LRU + cache to avoid thrashing. + """ + current_size = self._state_attributes_ids.get_size() + new_size = self.hass.states.async_entity_ids_count() * 2 + if new_size > current_size: + self._state_attributes_ids.set_size(new_size) + + @callback + def async_periodic_statistics(self) -> None: """Trigger the statistics run. Short term statistics run every 5 minutes @@ -570,7 +589,7 @@ class Recorder(threading.Thread): # Compile short term statistics every 5 minutes self._periodic_listener = async_track_utc_time_change( - self.hass, self.async_periodic_statistics, minute=range(0, 60, 5), second=10 + self.hass, self._async_five_minute_tasks, minute=range(0, 60, 5), second=10 ) async def _async_wait_for_started(self) -> object | None: diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 5c54240537..11233af146 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -2075,3 +2075,18 @@ async def test_excluding_attributes_by_integration( expected = _state_with_context(hass, entity_id) expected.attributes = {"test_attr": 5} assert state.as_dict() == expected.as_dict() + + +async def test_lru_increases_with_many_entities( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test that the recorder's internal LRU cache increases with many entities.""" + # We do not actually want to record 4096 entities so we mock the entity count + mock_entity_count = 4096 + with patch.object( + hass.states, "async_entity_ids_count", return_value=mock_entity_count + ): + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) + await hass.async_block_till_done() + + assert recorder_mock._state_attributes_ids.get_size() == mock_entity_count * 2 From 17e87556de46158920378b3c8291c155a80d5666 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 19 Feb 2023 17:39:17 +0100 Subject: [PATCH 0202/2810] Add uchardet-dev to Wheels build deps (#88449) --- .github/workflows/wheels.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 59604ae0e4..144d6cbae1 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -175,7 +175,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "libexecinfo-dev;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev" + apk: "libexecinfo-dev;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" skip-binary: aiohttp;grpcio legacy: true constraints: "homeassistant/package_constraints.txt" @@ -190,7 +190,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "libexecinfo-dev;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev" + apk: "libexecinfo-dev;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" skip-binary: aiohttp;grpcio legacy: true constraints: "homeassistant/package_constraints.txt" From 564f71438637b7ad7d49cef154e9e6ee33f08234 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 19 Feb 2023 18:09:26 +0100 Subject: [PATCH 0203/2810] Update pipdeptree to 2.5.0 (#88390) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 23c0cc1694..49b6450883 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -17,7 +17,7 @@ pre-commit==3.0.0 pydantic==1.10.5 pylint==2.16.0 pylint-per-file-ignores==1.1.0 -pipdeptree==2.3.1 +pipdeptree==2.5.0 pytest-asyncio==0.20.3 pytest-aiohttp==1.0.4 pytest-cov==3.0.0 From 1cdd914411f1ae474312eb4ba92ff1fda9a9060f Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 19 Feb 2023 19:15:28 +0100 Subject: [PATCH 0204/2810] Bump motionblinds to 0.6.17 (#88450) --- homeassistant/components/motion_blinds/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index ec86d34778..0ca982ada8 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/motion_blinds", "iot_class": "local_push", "loggers": ["motionblinds"], - "requirements": ["motionblinds==0.6.16"] + "requirements": ["motionblinds==0.6.17"] } diff --git a/requirements_all.txt b/requirements_all.txt index 245894f85d..36da5f10ad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1147,7 +1147,7 @@ moehlenhoff-alpha2==1.3.0 mopeka_iot_ble==0.4.0 # homeassistant.components.motion_blinds -motionblinds==0.6.16 +motionblinds==0.6.17 # homeassistant.components.motioneye motioneye-client==0.3.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bb7693e711..825dbc1d25 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -852,7 +852,7 @@ moehlenhoff-alpha2==1.3.0 mopeka_iot_ble==0.4.0 # homeassistant.components.motion_blinds -motionblinds==0.6.16 +motionblinds==0.6.17 # homeassistant.components.motioneye motioneye-client==0.3.14 From 08cdb0e3eee769e393d6017671dc129331301961 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Feb 2023 12:20:01 -0600 Subject: [PATCH 0205/2810] Fix recorder LRU being destroyed if event session is reopened (#88448) --- homeassistant/components/recorder/core.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 7cd0ad56f7..a6ec512a9f 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -1027,11 +1027,11 @@ class Recorder(threading.Thread): def _close_event_session(self) -> None: """Close the event session.""" - self._old_states = {} - self._state_attributes_ids = {} - self._event_data_ids = {} - self._pending_state_attributes = {} - self._pending_event_data = {} + self._old_states.clear() + self._state_attributes_ids.clear() + self._event_data_ids.clear() + self._pending_state_attributes.clear() + self._pending_event_data.clear() if not self.event_session: return From 0d832c0a5a2cdf8b098de7bc77baf9777348586b Mon Sep 17 00:00:00 2001 From: Ruslan Sayfutdinov Date: Sun, 19 Feb 2023 18:29:06 +0000 Subject: [PATCH 0206/2810] Cleanup mypy ignore list feature (#88451) --- script/hassfest/mypy_config.py | 46 ++-------------------------------- 1 file changed, 2 insertions(+), 44 deletions(-) diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index c9b599bdcf..ad4a0f64fe 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -12,12 +12,6 @@ from homeassistant.const import REQUIRED_PYTHON_VER from .model import Config, Integration -# Modules which have type hints which known to be broken. -# If you are an author of component listed here, please fix these errors and -# remove your component from this list to enable type checks. -# Do your best to not add anything new here. -IGNORED_MODULES: Final[list[str]] = [] - # Component modules which should set no_implicit_reexport = true. NO_IMPLICIT_REEXPORT_MODULES: set[str] = { "homeassistant.components", @@ -98,19 +92,6 @@ PLUGIN_CONFIG: Final[dict[str, dict[str, str]]] = { } -def _strict_module_in_ignore_list( - module: str, ignored_modules_set: set[str] -) -> str | None: - if module in ignored_modules_set: - return module - if module.endswith("*"): - module = module[:-1] - for ignored_module in ignored_modules_set: - if ignored_module.startswith(module): - return ignored_module - return None - - def _sort_within_sections(line_iter: Iterable[str]) -> Iterable[str]: """Sort lines within sections. @@ -145,7 +126,7 @@ def _generate_and_validate_strict_typing(config: Config) -> str: return "\n".join(_sort_within_sections(lines)) + "\n" -def _generate_and_validate_mypy_config(config: Config) -> str: # noqa: C901 +def _generate_and_validate_mypy_config(config: Config) -> str: """Validate and generate mypy config.""" # Filter empty and commented lines. @@ -163,27 +144,9 @@ def _generate_and_validate_mypy_config(config: Config) -> str: # noqa: C901 else: strict_core_modules.append(module) - ignored_modules_set: set[str] = set(IGNORED_MODULES) - for module in strict_modules: - if ( - not module.startswith("homeassistant.components.") - and module != "homeassistant.components" - ): - config.add_error( - "mypy_config", f"Only components should be added: {module}" - ) - if ignored_module := _strict_module_in_ignore_list(module, ignored_modules_set): - config.add_error( - "mypy_config", - f"Module '{ignored_module}' is in ignored list in mypy_config.py", - ) - # Validate that all modules exist. all_modules = ( - strict_modules - + strict_core_modules - + IGNORED_MODULES - + list(NO_IMPLICIT_REEXPORT_MODULES) + strict_modules + strict_core_modules + list(NO_IMPLICIT_REEXPORT_MODULES) ) for module in all_modules: if module.endswith(".*"): @@ -259,11 +222,6 @@ def _generate_and_validate_mypy_config(config: Config) -> str: # noqa: C901 for key in STRICT_SETTINGS: mypy_config.set(tests_section, key, "false") - for ignored_module in IGNORED_MODULES: - ignored_section = f"mypy-{ignored_module}" - mypy_config.add_section(ignored_section) - mypy_config.set(ignored_section, "ignore_errors", "true") - with io.StringIO() as fp: mypy_config.write(fp) fp.seek(0) From a9731a7b26806b04ab7acbd097a6e9d513a9561a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Feb 2023 12:30:08 -0600 Subject: [PATCH 0207/2810] Make the recorder LRU adjustment threadsafe (#88443) --- homeassistant/components/recorder/core.py | 14 ++++++++------ homeassistant/components/recorder/tasks.py | 11 +++++++++++ tests/components/recorder/test_init.py | 2 +- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index a6ec512a9f..8cae7c5ead 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -78,6 +78,7 @@ from .pool import POOL_SIZE, MutexPool, RecorderPool from .queries import find_shared_attributes_id, find_shared_data_id from .run_history import RunHistory from .tasks import ( + AdjustLRUSizeTask, AdjustStatisticsTask, ChangeStatisticsUnitTask, ClearStatisticsTask, @@ -131,6 +132,7 @@ SHUTDOWN_TASK = object() COMMIT_TASK = CommitTask() KEEP_ALIVE_TASK = KeepAliveTask() WAIT_TASK = WaitTask() +ADJUST_LRU_SIZE_TASK = AdjustLRUSizeTask() DB_LOCK_TIMEOUT = 30 DB_LOCK_QUEUE_CHECK_TIMEOUT = 1 @@ -411,7 +413,6 @@ class Recorder(threading.Thread): @callback def _async_hass_started(self, hass: HomeAssistant) -> None: """Notify that hass has started.""" - self.async_adjust_lru() self._hass_started.set_result(None) @callback @@ -478,20 +479,20 @@ class Recorder(threading.Thread): @callback def _async_five_minute_tasks(self, now: datetime) -> None: """Run tasks every five minutes.""" - self.async_adjust_lru() + self.queue_task(ADJUST_LRU_SIZE_TASK) self.async_periodic_statistics() - @callback - def async_adjust_lru(self) -> None: + def _adjust_lru_size(self) -> None: """Trigger the LRU adjustment. If the number of entities has increased, increase the size of the LRU cache to avoid thrashing. """ - current_size = self._state_attributes_ids.get_size() + state_attributes_lru = self._state_attributes_ids + current_size = state_attributes_lru.get_size() new_size = self.hass.states.async_entity_ids_count() * 2 if new_size > current_size: - self._state_attributes_ids.set_size(new_size) + state_attributes_lru.set_size(new_size) @callback def async_periodic_statistics(self) -> None: @@ -677,6 +678,7 @@ class Recorder(threading.Thread): self._schedule_compile_missing_statistics(session) _LOGGER.debug("Recorder processing the queue") + self._adjust_lru_size() self.hass.add_job(self._async_set_recorder_ready_migration_done) self._run_event_loop() diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index 4d12f6b343..c8ad1aeb89 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -328,3 +328,14 @@ class StatisticsTimestampMigrationCleanupTask(RecorderTask): if not statistics.cleanup_statistics_timestamp_migration(instance): # Schedule a new statistics migration task if this one didn't finish instance.queue_task(StatisticsTimestampMigrationCleanupTask()) + + +@dataclass +class AdjustLRUSizeTask(RecorderTask): + """An object to insert into the recorder queue to adjust the LRU size.""" + + commit_before = False + + def run(self, instance: Recorder) -> None: + """Handle the task to adjust the size.""" + instance._adjust_lru_size() # pylint: disable=[protected-access] diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 11233af146..a810d74556 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -2087,6 +2087,6 @@ async def test_lru_increases_with_many_entities( hass.states, "async_entity_ids_count", return_value=mock_entity_count ): async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) - await hass.async_block_till_done() + await async_wait_recording_done(hass) assert recorder_mock._state_attributes_ids.get_size() == mock_entity_count * 2 From 68e1aaa0be9bad826ebb0d8afda267c311e16f09 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 19 Feb 2023 20:14:18 +0100 Subject: [PATCH 0208/2810] Refactor Elgato tests (#88376) --- homeassistant/components/elgato/button.py | 2 +- tests/components/elgato/conftest.py | 17 +- .../elgato/snapshots/test_button.ambr | 149 ++++++++ .../elgato/snapshots/test_light.ambr | 321 ++++++++++++++++++ .../elgato/snapshots/test_switch.ambr | 149 ++++++++ tests/components/elgato/test_button.py | 96 ++---- tests/components/elgato/test_config_flow.py | 24 +- tests/components/elgato/test_light.py | 106 ++---- tests/components/elgato/test_sensor.py | 12 +- tests/components/elgato/test_switch.py | 118 ++----- 10 files changed, 729 insertions(+), 265 deletions(-) create mode 100644 tests/components/elgato/snapshots/test_button.ambr create mode 100644 tests/components/elgato/snapshots/test_light.ambr create mode 100644 tests/components/elgato/snapshots/test_switch.ambr diff --git a/homeassistant/components/elgato/button.py b/homeassistant/components/elgato/button.py index 9f68074bb2..0dd602f1ec 100644 --- a/homeassistant/components/elgato/button.py +++ b/homeassistant/components/elgato/button.py @@ -94,5 +94,5 @@ class ElgatoButtonEntity(ElgatoEntity, ButtonEntity): await self.entity_description.press_fn(self.coordinator.client) except ElgatoError as error: raise HomeAssistantError( - "An error occurred while identifying the Elgato Light" + "An error occurred while communicating with the Elgato Light" ) from error diff --git a/tests/components/elgato/conftest.py b/tests/components/elgato/conftest.py index a32b3a7036..bd1b4242ed 100644 --- a/tests/components/elgato/conftest.py +++ b/tests/components/elgato/conftest.py @@ -59,19 +59,6 @@ def mock_onboarding() -> Generator[None, MagicMock, None]: yield mock_onboarding -@pytest.fixture -def mock_elgato_config_flow(device_fixtures: str) -> Generator[None, MagicMock, None]: - """Return a mocked Elgato client.""" - with patch( - "homeassistant.components.elgato.config_flow.Elgato", autospec=True - ) as elgato_mock: - elgato = elgato_mock.return_value - elgato.info.return_value = Info.parse_raw( - load_fixture(f"{device_fixtures}/info.json", DOMAIN) - ) - yield elgato - - @pytest.fixture def mock_elgato( device_fixtures: str, state_variant: str @@ -79,7 +66,9 @@ def mock_elgato( """Return a mocked Elgato client.""" with patch( "homeassistant.components.elgato.coordinator.Elgato", autospec=True - ) as elgato_mock: + ) as elgato_mock, patch( + "homeassistant.components.elgato.config_flow.Elgato", new=elgato_mock + ): elgato = elgato_mock.return_value elgato.info.return_value = Info.parse_raw( load_fixture(f"{device_fixtures}/info.json", DOMAIN) diff --git a/tests/components/elgato/snapshots/test_button.ambr b/tests/components/elgato/snapshots/test_button.ambr new file mode 100644 index 0000000000..900a3f316f --- /dev/null +++ b/tests/components/elgato/snapshots/test_button.ambr @@ -0,0 +1,149 @@ +# serializer version: 1 +# name: test_buttons[button.frenck_identify-identify-key-light-mini] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Frenck Identify', + 'icon': 'mdi:help', + }), + 'context': , + 'entity_id': 'button.frenck_identify', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.frenck_identify-identify-key-light-mini].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.frenck_identify', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:help', + 'original_name': 'Identify', + 'platform': 'elgato', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'GW24L1A02987_identify', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.frenck_identify-identify-key-light-mini].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '202', + 'id': , + 'identifiers': set({ + tuple( + 'elgato', + 'GW24L1A02987', + ), + }), + 'is_new': False, + 'manufacturer': 'Elgato', + 'model': 'Elgato Key Light Mini', + 'name': 'Frenck', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.4 (229)', + 'via_device_id': None, + }) +# --- +# name: test_buttons[button.frenck_restart-restart-key-light-mini] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'Frenck Restart', + }), + 'context': , + 'entity_id': 'button.frenck_restart', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.frenck_restart-restart-key-light-mini].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.frenck_restart', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart', + 'platform': 'elgato', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'GW24L1A02987_restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.frenck_restart-restart-key-light-mini].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '202', + 'id': , + 'identifiers': set({ + tuple( + 'elgato', + 'GW24L1A02987', + ), + }), + 'is_new': False, + 'manufacturer': 'Elgato', + 'model': 'Elgato Key Light Mini', + 'name': 'Frenck', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.4 (229)', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/elgato/snapshots/test_light.ambr b/tests/components/elgato/snapshots/test_light.ambr new file mode 100644 index 0000000000..5395851673 --- /dev/null +++ b/tests/components/elgato/snapshots/test_light.ambr @@ -0,0 +1,321 @@ +# serializer version: 1 +# name: test_light_state_temperature[key-light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 54, + 'color_mode': , + 'color_temp': 297, + 'color_temp_kelvin': 3367, + 'friendly_name': 'Frenck', + 'hs_color': tuple( + 27.316, + 47.743, + ), + 'max_color_temp_kelvin': 6993, + 'max_mireds': 344, + 'min_color_temp_kelvin': 2906, + 'min_mireds': 143, + 'rgb_color': tuple( + 255, + 188, + 133, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': 0, + 'xy_color': tuple( + 0.465, + 0.376, + ), + }), + 'context': , + 'entity_id': 'light.frenck', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_light_state_temperature[key-light-state].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6993, + 'max_mireds': 344, + 'min_color_temp_kelvin': 2906, + 'min_mireds': 143, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.frenck', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'elgato', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'CN11A1A00001', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_state_temperature[key-light-state].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '53', + 'id': , + 'identifiers': set({ + tuple( + 'elgato', + 'CN11A1A00001', + ), + }), + 'is_new': False, + 'manufacturer': 'Elgato', + 'model': 'Elgato Key Light', + 'name': 'Frenck', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.3 (192)', + 'via_device_id': None, + }) +# --- +# name: test_light_state_temperature[light-strip-state-color-temperature] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 54, + 'color_mode': , + 'color_temp': 297, + 'color_temp_kelvin': 3367, + 'friendly_name': 'Frenck', + 'hs_color': tuple( + 27.316, + 47.743, + ), + 'max_color_temp_kelvin': 6535, + 'max_mireds': 285, + 'min_color_temp_kelvin': 3508, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 188, + 133, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': 0, + 'xy_color': tuple( + 0.465, + 0.376, + ), + }), + 'context': , + 'entity_id': 'light.frenck', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_light_state_temperature[light-strip-state-color-temperature].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 285, + 'min_color_temp_kelvin': 3508, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.frenck', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'elgato', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'CN11A1A00001', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_state_temperature[light-strip-state-color-temperature].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '53', + 'id': , + 'identifiers': set({ + tuple( + 'elgato', + 'CN11A1A00001', + ), + }), + 'is_new': False, + 'manufacturer': 'Elgato', + 'model': 'Elgato Key Light', + 'name': 'Frenck', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.3 (192)', + 'via_device_id': None, + }) +# --- +# name: test_light_state_temperature[light-strip-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 128, + 'color_mode': , + 'friendly_name': 'Frenck', + 'hs_color': tuple( + 358.0, + 6.0, + ), + 'max_color_temp_kelvin': 6535, + 'max_mireds': 285, + 'min_color_temp_kelvin': 3508, + 'min_mireds': 153, + 'rgb_color': tuple( + 255, + 239, + 240, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': 0, + 'xy_color': tuple( + 0.34, + 0.327, + ), + }), + 'context': , + 'entity_id': 'light.frenck', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_light_state_temperature[light-strip-state].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6535, + 'max_mireds': 285, + 'min_color_temp_kelvin': 3508, + 'min_mireds': 153, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.frenck', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'elgato', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'CN11A1A00001', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_state_temperature[light-strip-state].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '53', + 'id': , + 'identifiers': set({ + tuple( + 'elgato', + 'CN11A1A00001', + ), + }), + 'is_new': False, + 'manufacturer': 'Elgato', + 'model': 'Elgato Key Light', + 'name': 'Frenck', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.3 (192)', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/elgato/snapshots/test_switch.ambr b/tests/components/elgato/snapshots/test_switch.ambr new file mode 100644 index 0000000000..02f32d22f9 --- /dev/null +++ b/tests/components/elgato/snapshots/test_switch.ambr @@ -0,0 +1,149 @@ +# serializer version: 1 +# name: test_switches[switch.frenck_energy_saving-energy_saving-key-light-mini] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Frenck Energy saving', + 'icon': 'mdi:leaf', + }), + 'context': , + 'entity_id': 'switch.frenck_energy_saving', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.frenck_energy_saving-energy_saving-key-light-mini].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.frenck_energy_saving', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:leaf', + 'original_name': 'Energy saving', + 'platform': 'elgato', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'GW24L1A02987_energy_saving', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.frenck_energy_saving-energy_saving-key-light-mini].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '202', + 'id': , + 'identifiers': set({ + tuple( + 'elgato', + 'GW24L1A02987', + ), + }), + 'is_new': False, + 'manufacturer': 'Elgato', + 'model': 'Elgato Key Light Mini', + 'name': 'Frenck', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.4 (229)', + 'via_device_id': None, + }) +# --- +# name: test_switches[switch.frenck_studio_mode-battery_bypass-key-light-mini] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Frenck Studio mode', + 'icon': 'mdi:battery-off-outline', + }), + 'context': , + 'entity_id': 'switch.frenck_studio_mode', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.frenck_studio_mode-battery_bypass-key-light-mini].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.frenck_studio_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:battery-off-outline', + 'original_name': 'Studio mode', + 'platform': 'elgato', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'GW24L1A02987_bypass', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.frenck_studio_mode-battery_bypass-key-light-mini].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '202', + 'id': , + 'identifiers': set({ + tuple( + 'elgato', + 'GW24L1A02987', + ), + }), + 'is_new': False, + 'manufacturer': 'Elgato', + 'model': 'Elgato Key Light Mini', + 'name': 'Frenck', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.4 (229)', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/elgato/test_button.py b/tests/components/elgato/test_button.py index 6c85fbf31e..bd6c9a1bfe 100644 --- a/tests/components/elgato/test_button.py +++ b/tests/components/elgato/test_button.py @@ -3,10 +3,10 @@ from unittest.mock import MagicMock from elgato import ElgatoError import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS -from homeassistant.components.elgato.const import DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, ATTR_ICON, STATE_UNKNOWN, EntityCategory +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -18,97 +18,59 @@ pytestmark = [ ] -async def test_button_identify( +@pytest.mark.parametrize( + ("entity_id", "method"), + [ + ("button.frenck_identify", "identify"), + ("button.frenck_restart", "restart"), + ], +) +async def test_buttons( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, mock_elgato: MagicMock, + snapshot: SnapshotAssertion, + entity_id: str, + method: str, ) -> None: """Test the Elgato identify button.""" - state = hass.states.get("button.frenck_identify") - assert state - assert state.attributes.get(ATTR_ICON) == "mdi:help" - assert state.state == STATE_UNKNOWN + assert (state := hass.states.get(entity_id)) + assert state == snapshot - entry = entity_registry.async_get("button.frenck_identify") - assert entry - assert entry.unique_id == "GW24L1A02987_identify" - assert entry.entity_category == EntityCategory.CONFIG + assert (entry := entity_registry.async_get(entity_id)) + assert entry == snapshot assert entry.device_id - device_entry = device_registry.async_get(entry.device_id) - assert device_entry - assert device_entry.configuration_url is None - assert device_entry.connections == { - (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff") - } - assert device_entry.entry_type is None - assert device_entry.identifiers == {(DOMAIN, "GW24L1A02987")} - assert device_entry.manufacturer == "Elgato" - assert device_entry.model == "Elgato Key Light Mini" - assert device_entry.name == "Frenck" - assert device_entry.sw_version == "1.0.4 (229)" - assert device_entry.hw_version == "202" + assert (device_entry := device_registry.async_get(entry.device_id)) + assert device_entry == snapshot await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, - {ATTR_ENTITY_ID: "button.frenck_identify"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert len(mock_elgato.identify.mock_calls) == 1 - mock_elgato.identify.assert_called_with() + mocked_method = getattr(mock_elgato, method) + assert len(mocked_method.mock_calls) == 1 + mocked_method.assert_called_with() - state = hass.states.get("button.frenck_identify") + state = hass.states.get(entity_id) assert state assert state.state == "2021-11-13T11:48:00+00:00" - -async def test_button_restart( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, - mock_elgato: MagicMock, -) -> None: - """Test the Elgato restart button.""" - state = hass.states.get("button.frenck_restart") - assert state - assert state.state == STATE_UNKNOWN - assert not state.attributes.get(ATTR_ICON) - - entry = entity_registry.async_get("button.frenck_restart") - assert entry - assert entry.unique_id == "GW24L1A02987_restart" - assert entry.entity_category == EntityCategory.CONFIG - - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: "button.frenck_restart"}, - blocking=True, - ) - - assert len(mock_elgato.restart.mock_calls) == 1 - mock_elgato.restart.assert_called_with() - - state = hass.states.get("button.frenck_restart") - assert state - assert state.state == "2021-11-13T11:48:00+00:00" - - -async def test_button_error(hass: HomeAssistant, mock_elgato: MagicMock) -> None: - """Test an error occurs with the Elgato buttons.""" - mock_elgato.identify.side_effect = ElgatoError + mocked_method.side_effect = ElgatoError with pytest.raises( - HomeAssistantError, match="An error occurred while identifying the Elgato Light" + HomeAssistantError, + match="An error occurred while communicating with the Elgato Light", ): await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, - {ATTR_ENTITY_ID: "button.frenck_identify"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert len(mock_elgato.identify.mock_calls) == 1 + assert len(mocked_method.mock_calls) == 2 diff --git a/tests/components/elgato/test_config_flow.py b/tests/components/elgato/test_config_flow.py index 96da6ec4a4..3447038b77 100644 --- a/tests/components/elgato/test_config_flow.py +++ b/tests/components/elgato/test_config_flow.py @@ -17,7 +17,7 @@ from tests.common import MockConfigEntry async def test_full_user_flow_implementation( hass: HomeAssistant, - mock_elgato_config_flow: MagicMock, + mock_elgato: MagicMock, mock_setup_entry: AsyncMock, snapshot: SnapshotAssertion, ) -> None: @@ -38,12 +38,12 @@ async def test_full_user_flow_implementation( assert result2 == snapshot assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_elgato_config_flow.info.mock_calls) == 1 + assert len(mock_elgato.info.mock_calls) == 1 async def test_full_zeroconf_flow_implementation( hass: HomeAssistant, - mock_elgato_config_flow: MagicMock, + mock_elgato: MagicMock, mock_setup_entry: AsyncMock, snapshot: SnapshotAssertion, ) -> None: @@ -80,15 +80,15 @@ async def test_full_zeroconf_flow_implementation( assert result2 == snapshot assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_elgato_config_flow.info.mock_calls) == 1 + assert len(mock_elgato.info.mock_calls) == 1 async def test_connection_error( hass: HomeAssistant, - mock_elgato_config_flow: MagicMock, + mock_elgato: MagicMock, ) -> None: """Test we show user form on Elgato Key Light connection error.""" - mock_elgato_config_flow.info.side_effect = ElgatoConnectionError + mock_elgato.info.side_effect = ElgatoConnectionError result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -102,10 +102,10 @@ async def test_connection_error( async def test_zeroconf_connection_error( hass: HomeAssistant, - mock_elgato_config_flow: MagicMock, + mock_elgato: MagicMock, ) -> None: """Test we abort zeroconf flow on Elgato Key Light connection error.""" - mock_elgato_config_flow.info.side_effect = ElgatoConnectionError + mock_elgato.info.side_effect = ElgatoConnectionError result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, @@ -124,7 +124,7 @@ async def test_zeroconf_connection_error( assert result.get("type") == FlowResultType.ABORT -@pytest.mark.usefixtures("mock_elgato_config_flow") +@pytest.mark.usefixtures("mock_elgato") async def test_user_device_exists_abort( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: @@ -140,7 +140,7 @@ async def test_user_device_exists_abort( assert result.get("reason") == "already_configured" -@pytest.mark.usefixtures("mock_elgato_config_flow") +@pytest.mark.usefixtures("mock_elgato") async def test_zeroconf_device_exists_abort( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: @@ -190,7 +190,7 @@ async def test_zeroconf_device_exists_abort( async def test_zeroconf_during_onboarding( hass: HomeAssistant, - mock_elgato_config_flow: MagicMock, + mock_elgato: MagicMock, mock_setup_entry: AsyncMock, mock_onboarding: MagicMock, snapshot: SnapshotAssertion, @@ -214,5 +214,5 @@ async def test_zeroconf_during_onboarding( assert result == snapshot assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_elgato_config_flow.info.mock_calls) == 1 + assert len(mock_elgato.info.mock_calls) == 1 assert len(mock_onboarding.mock_calls) == 1 diff --git a/tests/components/elgato/test_light.py b/tests/components/elgato/test_light.py index 1f1dd5217b..066e980653 100644 --- a/tests/components/elgato/test_light.py +++ b/tests/components/elgato/test_light.py @@ -3,18 +3,14 @@ from unittest.mock import MagicMock from elgato import ElgatoError import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.elgato.const import DOMAIN, SERVICE_IDENTIFY from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_MODE, ATTR_COLOR_TEMP, ATTR_HS_COLOR, - ATTR_MAX_MIREDS, - ATTR_MIN_MIREDS, - ATTR_SUPPORTED_COLOR_MODES, DOMAIN as LIGHT_DOMAIN, - ColorMode, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -27,69 +23,36 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er +pytestmark = pytest.mark.usefixtures("init_integration") -@pytest.mark.usefixtures("init_integration", "mock_elgato") -async def test_light_state_temperature(hass: HomeAssistant) -> None: + +@pytest.mark.usefixtures("mock_elgato") +@pytest.mark.parametrize( + ("device_fixtures", "state_variant"), + [ + ("key-light", "state"), + ("light-strip", "state"), + ("light-strip", "state-color-temperature"), + ], +) +async def test_light_state_temperature( + hass: HomeAssistant, + device_registry: dr.DeviceEntry, + entity_registry: er.RegistryEntry, + snapshot: SnapshotAssertion, +) -> None: """Test the creation and values of the Elgato Lights in temperature mode.""" - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) # First segment of the strip - state = hass.states.get("light.frenck") - assert state - assert state.attributes.get(ATTR_BRIGHTNESS) == 54 - assert state.attributes.get(ATTR_COLOR_TEMP) == 297 - assert state.attributes.get(ATTR_HS_COLOR) == (27.316, 47.743) - assert state.attributes.get(ATTR_COLOR_MODE) == ColorMode.COLOR_TEMP - assert state.attributes.get(ATTR_MIN_MIREDS) == 143 - assert state.attributes.get(ATTR_MAX_MIREDS) == 344 - assert state.attributes.get(ATTR_SUPPORTED_COLOR_MODES) == [ColorMode.COLOR_TEMP] - assert state.state == STATE_ON + assert (state := hass.states.get("light.frenck")) + assert state == snapshot - entry = entity_registry.async_get("light.frenck") - assert entry - assert entry.unique_id == "CN11A1A00001" + assert (entry := entity_registry.async_get("light.frenck")) + assert entry == snapshot assert entry.device_id - device_entry = device_registry.async_get(entry.device_id) - assert device_entry - assert device_entry.configuration_url is None - assert device_entry.connections == { - (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff") - } - assert device_entry.entry_type is None - assert device_entry.identifiers == {(DOMAIN, "CN11A1A00001")} - assert device_entry.manufacturer == "Elgato" - assert device_entry.model == "Elgato Key Light" - assert device_entry.name == "Frenck" - assert device_entry.sw_version == "1.0.3 (192)" - assert device_entry.hw_version == "53" - - -@pytest.mark.parametrize("device_fixtures", ["light-strip"]) -@pytest.mark.usefixtures("device_fixtures", "init_integration", "mock_elgato") -async def test_light_state_color(hass: HomeAssistant) -> None: - """Test the creation and values of the Elgato Lights in temperature mode.""" - entity_registry = er.async_get(hass) - - # First segment of the strip - state = hass.states.get("light.frenck") - assert state - assert state.attributes.get(ATTR_BRIGHTNESS) == 128 - assert state.attributes.get(ATTR_COLOR_TEMP) is None - assert state.attributes.get(ATTR_HS_COLOR) == (358.0, 6.0) - assert state.attributes.get(ATTR_MIN_MIREDS) == 153 - assert state.attributes.get(ATTR_MAX_MIREDS) == 285 - assert state.attributes.get(ATTR_COLOR_MODE) == ColorMode.HS - assert state.attributes.get(ATTR_SUPPORTED_COLOR_MODES) == [ - ColorMode.COLOR_TEMP, - ColorMode.HS, - ] - assert state.state == STATE_ON - - entry = entity_registry.async_get("light.frenck") - assert entry - assert entry.unique_id == "CN11A1A00001" + assert (device_entry := device_registry.async_get(entry.device_id)) + assert device_entry == snapshot @pytest.mark.parametrize( @@ -101,8 +64,7 @@ async def test_light_change_state_temperature( mock_elgato: MagicMock, ) -> None: """Test the change of state of a Elgato Key Light device.""" - state = hass.states.get("light.frenck") - assert state + assert (state := hass.states.get("light.frenck")) assert state.state == STATE_ON await hass.services.async_call( @@ -115,7 +77,6 @@ async def test_light_change_state_temperature( }, blocking=True, ) - await hass.async_block_till_done() assert len(mock_elgato.light.mock_calls) == 1 mock_elgato.light.assert_called_with( on=True, brightness=100, temperature=100, hue=None, saturation=None @@ -130,7 +91,6 @@ async def test_light_change_state_temperature( }, blocking=True, ) - await hass.async_block_till_done() assert len(mock_elgato.light.mock_calls) == 2 mock_elgato.light.assert_called_with( on=True, brightness=100, temperature=297, hue=None, saturation=None @@ -142,7 +102,6 @@ async def test_light_change_state_temperature( {ATTR_ENTITY_ID: "light.frenck"}, blocking=True, ) - await hass.async_block_till_done() assert len(mock_elgato.light.mock_calls) == 3 mock_elgato.light.assert_called_with(on=False) @@ -156,7 +115,6 @@ async def test_light_change_state_temperature( }, blocking=True, ) - await hass.async_block_till_done() assert len(mock_elgato.light.mock_calls) == 4 mock_elgato.light.assert_called_with( on=True, brightness=100, temperature=None, hue=10.1, saturation=20.2 @@ -164,7 +122,6 @@ async def test_light_change_state_temperature( @pytest.mark.parametrize("service", [SERVICE_TURN_ON, SERVICE_TURN_OFF]) -@pytest.mark.usefixtures("init_integration") async def test_light_unavailable( hass: HomeAssistant, mock_elgato: MagicMock, service: str ) -> None: @@ -180,8 +137,7 @@ async def test_light_unavailable( blocking=True, ) - state = hass.states.get("light.frenck") - assert state + assert (state := hass.states.get("light.frenck")) assert state.state == STATE_UNAVAILABLE @@ -196,17 +152,11 @@ async def test_light_identify(hass: HomeAssistant, mock_elgato: MagicMock) -> No }, blocking=True, ) - await hass.async_block_till_done() assert len(mock_elgato.identify.mock_calls) == 1 mock_elgato.identify.assert_called_with() - -@pytest.mark.usefixtures("init_integration") -async def test_light_identify_error( - hass: HomeAssistant, mock_elgato: MagicMock -) -> None: - """Test error occurred during identifying an Elgato Light.""" mock_elgato.identify.side_effect = ElgatoError + with pytest.raises( HomeAssistantError, match="An error occurred while identifying the Elgato Light" ): @@ -219,4 +169,4 @@ async def test_light_identify_error( blocking=True, ) - assert len(mock_elgato.identify.mock_calls) == 1 + assert len(mock_elgato.identify.mock_calls) == 2 diff --git a/tests/components/elgato/test_sensor.py b/tests/components/elgato/test_sensor.py index 6e45cfeb48..f53e2e4315 100644 --- a/tests/components/elgato/test_sensor.py +++ b/tests/components/elgato/test_sensor.py @@ -31,14 +31,14 @@ async def test_sensors( ) -> None: """Test the Elgato sensors.""" - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state == snapshot - entry = entity_registry.async_get(entity_id) + assert (entry := entity_registry.async_get(entity_id)) assert entry == snapshot assert entry.device_id - device_entry = device_registry.async_get(entry.device_id) + assert (device_entry := device_registry.async_get(entry.device_id)) assert device_entry == snapshot @@ -55,10 +55,8 @@ async def test_disabled_by_default_sensors( hass: HomeAssistant, entity_registry: er.EntityRegistry, entity_id: str ) -> None: """Test the disabled by default Elgato sensors.""" - state = hass.states.get(entity_id) - assert state is None + assert not hass.states.get(entity_id) - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/elgato/test_switch.py b/tests/components/elgato/test_switch.py index dab891065c..336c2a9376 100644 --- a/tests/components/elgato/test_switch.py +++ b/tests/components/elgato/test_switch.py @@ -3,21 +3,14 @@ from unittest.mock import MagicMock from elgato import ElgatoError import pytest +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.elgato.const import DOMAIN from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, - ATTR_ICON, - STATE_OFF, - EntityCategory, -) +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -28,61 +21,55 @@ pytestmark = [ ] -async def test_battery_bypass( +@pytest.mark.parametrize( + ("entity_id", "method"), + [ + ("switch.frenck_studio_mode", "battery_bypass"), + ("switch.frenck_energy_saving", "energy_saving"), + ], +) +async def test_switches( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, mock_elgato: MagicMock, + snapshot: SnapshotAssertion, + entity_id: str, + method: str, ) -> None: - """Test the Elgato battery bypass switch.""" - state = hass.states.get("switch.frenck_studio_mode") - assert state - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frenck Studio mode" - assert state.attributes.get(ATTR_ICON) == "mdi:battery-off-outline" - assert not state.attributes.get(ATTR_DEVICE_CLASS) + """Test the Elgato switches.""" + assert (state := hass.states.get(entity_id)) + assert state == snapshot - entry = entity_registry.async_get("switch.frenck_studio_mode") - assert entry - assert entry.unique_id == "GW24L1A02987_bypass" - assert entry.entity_category == EntityCategory.CONFIG + assert (entry := entity_registry.async_get(entity_id)) + assert entry == snapshot assert entry.device_id - device_entry = device_registry.async_get(entry.device_id) - assert device_entry - assert device_entry.configuration_url is None - assert device_entry.connections == { - (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff") - } - assert device_entry.entry_type is None - assert device_entry.identifiers == {(DOMAIN, "GW24L1A02987")} - assert device_entry.manufacturer == "Elgato" - assert device_entry.model == "Elgato Key Light Mini" - assert device_entry.name == "Frenck" - assert device_entry.sw_version == "1.0.4 (229)" - assert device_entry.hw_version == "202" + assert (device_entry := device_registry.async_get(entry.device_id)) + assert device_entry == snapshot await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.frenck_studio_mode"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert len(mock_elgato.battery_bypass.mock_calls) == 1 - mock_elgato.battery_bypass.assert_called_once_with(on=True) + mocked_method = getattr(mock_elgato, method) + assert len(mocked_method.mock_calls) == 1 + mocked_method.assert_called_once_with(on=True) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.frenck_studio_mode"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert len(mock_elgato.battery_bypass.mock_calls) == 2 - mock_elgato.battery_bypass.assert_called_with(on=False) + assert len(mocked_method.mock_calls) == 2 + mocked_method.assert_called_with(on=False) - mock_elgato.battery_bypass.side_effect = ElgatoError + mocked_method.side_effect = ElgatoError with pytest.raises( HomeAssistantError, match="An error occurred while updating the Elgato Light" @@ -90,12 +77,11 @@ async def test_battery_bypass( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.frenck_studio_mode"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - await hass.async_block_till_done() - assert len(mock_elgato.battery_bypass.mock_calls) == 3 + assert len(mocked_method.mock_calls) == 3 with pytest.raises( HomeAssistantError, match="An error occurred while updating the Elgato Light" @@ -103,48 +89,8 @@ async def test_battery_bypass( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.frenck_studio_mode"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - await hass.async_block_till_done() - assert len(mock_elgato.battery_bypass.mock_calls) == 4 - - -async def test_battery_energy_saving( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - mock_elgato: MagicMock, -) -> None: - """Test the Elgato energy saving switch.""" - state = hass.states.get("switch.frenck_energy_saving") - assert state - assert state.state == STATE_OFF - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frenck Energy saving" - assert state.attributes.get(ATTR_ICON) == "mdi:leaf" - assert not state.attributes.get(ATTR_DEVICE_CLASS) - - entry = entity_registry.async_get("switch.frenck_energy_saving") - assert entry - assert entry.unique_id == "GW24L1A02987_energy_saving" - assert entry.entity_category == EntityCategory.CONFIG - - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.frenck_energy_saving"}, - blocking=True, - ) - - assert len(mock_elgato.energy_saving.mock_calls) == 1 - mock_elgato.energy_saving.assert_called_once_with(on=True) - - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.frenck_energy_saving"}, - blocking=True, - ) - - assert len(mock_elgato.energy_saving.mock_calls) == 2 - mock_elgato.energy_saving.assert_called_with(on=False) + assert len(mocked_method.mock_calls) == 4 From 9a6bcc2b63bb1a80d538064d4d8c228ec7e47ede Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Feb 2023 20:00:49 -0600 Subject: [PATCH 0209/2810] Fix incorrect created and created_ts columns in statistics database schema (#88462) * Remove default from created statistics schema We were still inserting created times because even though None was passed when creating the object explictly, the default would still be used * adjust column * preserve original pre sql alc 2.0 behavior --- homeassistant/components/recorder/db_schema.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 88ddf93861..0c3de80c5f 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -513,10 +513,8 @@ class StatisticsBase: """Statistics base class.""" id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) - created: Mapped[datetime] = mapped_column( - DATETIME_TYPE, default=dt_util.utcnow - ) # No longer used - created_ts: Mapped[float] = mapped_column(TIMESTAMP_TYPE, default=time.time) + created: Mapped[datetime | None] = mapped_column(DATETIME_TYPE) # No longer used + created_ts: Mapped[float | None] = mapped_column(TIMESTAMP_TYPE, default=time.time) metadata_id: Mapped[int | None] = mapped_column( Integer, ForeignKey(f"{TABLE_STATISTICS_META}.id", ondelete="CASCADE"), From eac9ad8437f33ee958e40d394d2e4fe605970d5e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Feb 2023 20:05:45 -0600 Subject: [PATCH 0210/2810] Fix most recent states query not using the index for group by (#88461) * Fix most recent states query not using the index for group by fixes #87851 * Apply suggestions from code review * reduce --- homeassistant/components/recorder/history.py | 145 ++++++++++--------- 1 file changed, 78 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/recorder/history.py b/homeassistant/components/recorder/history.py index 387cd40575..e466cd07e0 100644 --- a/homeassistant/components/recorder/history.py +++ b/homeassistant/components/recorder/history.py @@ -583,7 +583,49 @@ def get_last_state_changes( ) -def _get_states_for_entites_stmt( +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, utc_point_in_time: datetime, @@ -594,41 +636,32 @@ def _get_states_for_entites_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.where( - States.state_id - == ( - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - select(func.max(States.state_id).label("max_state_id")) - .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() - ).c.max_state_id + stmt += lambda q: q.join( + most_recent_states_for_entities_by_date, + and_( + States.entity_id + == most_recent_states_for_entities_by_date.c.max_entity_id, + States.last_updated_ts + == most_recent_states_for_entities_by_date.c.max_last_updated, + ), ) else: - stmt += lambda q: q.where( - States.state_id - == ( - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - select(func.max(States.state_id).label("max_state_id")) - .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() - ).c.max_state_id + stmt += lambda q: q.join( + most_recent_states_for_entities_by_date, + and_( + States.entity_id + == most_recent_states_for_entities_by_date.c.max_entity_id, + States.last_updated + == most_recent_states_for_entities_by_date.c.max_last_updated, + ), ) if join_attributes: stmt += lambda q: q.outerjoin( @@ -642,7 +675,7 @@ def _generate_most_recent_states_by_date( run_start: datetime, utc_point_in_time: datetime, ) -> Subquery: - """Generate the sub query for the most recent states by data.""" + """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) @@ -695,42 +728,20 @@ def _get_states_for_all_stmt( schema_version, run_start, utc_point_in_time ) if schema_version >= 31: - stmt += lambda q: q.where( - States.state_id - == ( - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - select(func.max(States.state_id).label("max_state_id")) - .join( - most_recent_states_by_date, - 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, - ), - ) - .group_by(States.entity_id) - .subquery() - ).c.max_state_id, + stmt += lambda q: q.join( + most_recent_states_by_date, + 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, + ), ) else: - stmt += lambda q: q.where( - States.state_id - == ( - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - select(func.max(States.state_id).label("max_state_id")) - .join( - most_recent_states_by_date, - 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, - ), - ) - .group_by(States.entity_id) - .subquery() - ).c.max_state_id, + stmt += lambda q: q.join( + most_recent_states_by_date, + 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, + ), ) stmt += _ignore_domains_filter if filters and filters.has_config: @@ -772,7 +783,7 @@ def _get_rows_with_session( # We have more than one entity to look at so we need to do a query on states # since the last recorder run started. if entity_ids: - stmt = _get_states_for_entites_stmt( + stmt = _get_states_for_entities_stmt( schema_version, run.start, utc_point_in_time, entity_ids, no_attributes ) else: From 0b311c8c7f5ac8ac407cb92b068a53e6a799b833 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Feb 2023 20:07:31 -0600 Subject: [PATCH 0211/2810] Fix statistics_at_time query not using index (#88460) * Fix statistics_at_time query not using index fixes #82411 * fix refactoring error * fix query so sqlalc does not get confused * split it * write as subquery * reduce * cleanup * reduce * Revert "reduce" This reverts commit 43b4b55778207d87e3f24543a876ae73e4a7107a. --- .../components/recorder/statistics.py | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 1c8d62eb82..e0d02e8a0e 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -16,7 +16,7 @@ import re from statistics import mean from typing import TYPE_CHECKING, Any, Literal, cast -from sqlalchemy import bindparam, func, lambda_stmt, select, text +from sqlalchemy import 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 @@ -1976,6 +1976,24 @@ 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"), + ) + .filter(table.start_ts < start_time_ts) + .filter(table.metadata_id.in_(metadata_ids)) + .group_by(table.metadata_id) + .subquery() + ) + + def _statistics_at_time( session: Session, metadata_ids: set[int], @@ -1999,22 +2017,17 @@ def _statistics_at_time( columns = columns.add_columns(table.sum) start_time_ts = start_time.timestamp() - stmt = lambda_stmt(lambda: columns) - - most_recent_statistic_ids = ( - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - lambda_stmt(lambda: select(func.max(table.id).label("max_id"))) - .filter(table.start_ts < start_time_ts) - .filter(table.metadata_id.in_(metadata_ids)) - .group_by(table.metadata_id) - .subquery() + most_recent_statistic_ids = _get_most_recent_statistics_subquery( + metadata_ids, table, start_time_ts ) - - stmt += lambda q: q.join( + stmt = lambda_stmt(lambda: columns).join( most_recent_statistic_ids, - table.id == most_recent_statistic_ids.c.max_id, + 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)) From 3533e77ec97d89aea2613e39c82b5c63d7a4b50f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Feb 2023 20:08:43 -0600 Subject: [PATCH 0212/2810] Fix using MariaDB slow range select workaround with Postgresql (#88459) * Mark PostgreSQL range select as fast Currently we were using the slow range select workaround for PostgreSQL that was original developed for MariaDB but its actually slower on PostgreSQ fixes #83253 * Mark PostgreSQL range select as fast Currently we were using the slow range select workaround for PostgreSQL that was original developed for MariaDB but its actually slower on PostgreSQ fixes #83253 --- homeassistant/components/recorder/models.py | 3 --- homeassistant/components/recorder/util.py | 8 +------- tests/components/recorder/test_util.py | 2 +- 3 files changed, 2 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 1109b1f43c..acdf61743f 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -480,7 +480,4 @@ class DatabaseOptimizer: # # https://jira.mariadb.org/browse/MDEV-25020 # - # Historically, we have applied this logic to PostgreSQL as well, but - # it may not be necessary. We should revisit this in the future - # when we have more data. slow_range_in_select: bool diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index f8a4097230..4db9203009 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -454,9 +454,8 @@ def setup_connection_for_dialect( ) -> DatabaseEngine | None: """Execute statements needed for dialect connection.""" version: AwesomeVersion | None = None - slow_range_in_select = True + slow_range_in_select = False if dialect_name == SupportedDialect.SQLITE: - slow_range_in_select = False if first_connection: old_isolation = dbapi_connection.isolation_level dbapi_connection.isolation_level = None @@ -533,11 +532,6 @@ def setup_connection_for_dialect( # Ensure all times are using UTC to avoid issues with daylight savings execute_on_connection(dbapi_connection, "SET time_zone = '+00:00'") elif dialect_name == SupportedDialect.POSTGRESQL: - # Historically we have marked PostgreSQL as having slow range in select - # but this may not be true for all versions. We should investigate - # this further when we have more data and remove this if possible - # in the future so we can use the simpler purge SQL query for - # _select_unused_attributes_ids and _select_unused_events_ids if first_connection: # server_version_num was added in 2006 result = query_on_connection(dbapi_connection, "SHOW server_version") diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 83fed98aa2..609af6c362 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -460,7 +460,7 @@ def test_supported_pgsql(caplog: pytest.LogCaptureFixture, pgsql_version) -> Non assert "minimum supported version" not in caplog.text assert database_engine is not None - assert database_engine.optimizer.slow_range_in_select is True + assert database_engine.optimizer.slow_range_in_select is False @pytest.mark.parametrize( From 037b3ebd26c5c211306650fe7a6fb2de3cc54b02 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 20 Feb 2023 03:11:05 +0100 Subject: [PATCH 0213/2810] Refactor EnergyZero tests (#88458) --- .../snapshots/test_config_flow.ambr | 35 + .../snapshots/test_diagnostics.ambr | 43 + .../energyzero/snapshots/test_sensor.ambr | 784 ++++++++++++++++++ .../components/energyzero/test_config_flow.py | 6 +- .../components/energyzero/test_diagnostics.py | 57 +- tests/components/energyzero/test_init.py | 4 +- tests/components/energyzero/test_sensor.py | 200 ++--- 7 files changed, 938 insertions(+), 191 deletions(-) create mode 100644 tests/components/energyzero/snapshots/test_config_flow.ambr create mode 100644 tests/components/energyzero/snapshots/test_diagnostics.ambr create mode 100644 tests/components/energyzero/snapshots/test_sensor.ambr diff --git a/tests/components/energyzero/snapshots/test_config_flow.ambr b/tests/components/energyzero/snapshots/test_config_flow.ambr new file mode 100644 index 0000000000..68c46a705d --- /dev/null +++ b/tests/components/energyzero/snapshots/test_config_flow.ambr @@ -0,0 +1,35 @@ +# serializer version: 1 +# name: test_full_user_flow + FlowResultSnapshot({ + 'context': dict({ + 'source': 'user', + 'unique_id': 'energyzero', + }), + 'data': dict({ + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'energyzero', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + }), + 'disabled_by': None, + 'domain': 'energyzero', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'EnergyZero', + 'unique_id': 'energyzero', + 'version': 1, + }), + 'title': 'EnergyZero', + 'type': , + 'version': 1, + }) +# --- diff --git a/tests/components/energyzero/snapshots/test_diagnostics.ambr b/tests/components/energyzero/snapshots/test_diagnostics.ambr new file mode 100644 index 0000000000..488e01e8d1 --- /dev/null +++ b/tests/components/energyzero/snapshots/test_diagnostics.ambr @@ -0,0 +1,43 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'energy': dict({ + 'average_price': 0.37, + 'current_hour_price': 0.49, + 'highest_price_time': '2022-12-07T16:00:00+00:00', + 'lowest_price_time': '2022-12-07T02:00:00+00:00', + 'max_price': 0.55, + 'min_price': 0.26, + 'next_hour_price': 0.55, + 'percentage_of_max': 89.09, + }), + 'entry': dict({ + 'title': 'energy', + }), + 'gas': dict({ + 'current_hour_price': 1.47, + 'next_hour_price': 1.47, + }), + }) +# --- +# name: test_diagnostics_no_gas_today + dict({ + 'energy': dict({ + 'average_price': 0.37, + 'current_hour_price': 0.49, + 'highest_price_time': '2022-12-07T16:00:00+00:00', + 'lowest_price_time': '2022-12-07T02:00:00+00:00', + 'max_price': 0.55, + 'min_price': 0.26, + 'next_hour_price': 0.55, + 'percentage_of_max': 89.09, + }), + 'entry': dict({ + 'title': 'energy', + }), + 'gas': dict({ + 'current_hour_price': None, + 'next_hour_price': None, + }), + }) +# --- diff --git a/tests/components/energyzero/snapshots/test_sensor.ambr b/tests/components/energyzero/snapshots/test_sensor.ambr new file mode 100644 index 0000000000..f758e8f53c --- /dev/null +++ b/tests/components/energyzero/snapshots/test_sensor.ambr @@ -0,0 +1,784 @@ +# serializer version: 1 +# name: test_energy_today + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'friendly_name': 'Energy market price Current hour', + 'state_class': , + 'unit_of_measurement': '€/kWh', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_current_hour_price', + 'last_changed': , + 'last_updated': , + 'state': '0.49', + }) +# --- +# name: test_energy_today.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_energy_current_hour_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current hour', + 'platform': 'energyzero', + 'supported_features': 0, + 'translation_key': None, + 'unit_of_measurement': '€/kWh', + }) +# --- +# name: test_energy_today.2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'is_new': False, + 'manufacturer': 'EnergyZero', + 'model': None, + 'name': 'Energy market price', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_energy_today[sensor.energyzero_today_energy_average_price-today_energy_average_price-] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'friendly_name': 'Energy market price Average - today', + 'unit_of_measurement': '€/kWh', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_average_price', + 'last_changed': , + 'last_updated': , + 'state': '0.37', + }) +# --- +# name: test_energy_today[sensor.energyzero_today_energy_average_price-today_energy_average_price-].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_energy_average_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Average - today', + 'platform': 'energyzero', + 'supported_features': 0, + 'translation_key': None, + 'unit_of_measurement': '€/kWh', + }) +# --- +# name: test_energy_today[sensor.energyzero_today_energy_average_price-today_energy_average_price-].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'is_new': False, + 'manufacturer': 'EnergyZero', + 'model': None, + 'name': 'Energy market price', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_energy_today[sensor.energyzero_today_energy_average_price-today_energy_average_price-today_energy] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'friendly_name': 'Energy market price Average - today', + 'unit_of_measurement': '€/kWh', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_average_price', + 'last_changed': , + 'last_updated': , + 'state': '0.37', + }) +# --- +# name: test_energy_today[sensor.energyzero_today_energy_average_price-today_energy_average_price-today_energy].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_energy_average_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Average - today', + 'platform': 'energyzero', + 'supported_features': 0, + 'translation_key': None, + 'unit_of_measurement': '€/kWh', + }) +# --- +# name: test_energy_today[sensor.energyzero_today_energy_average_price-today_energy_average_price-today_energy].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'is_new': False, + 'manufacturer': 'EnergyZero', + 'model': None, + 'name': 'Energy market price', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_energy_today[sensor.energyzero_today_energy_current_hour_price-today_energy_current_hour_price-] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'friendly_name': 'Energy market price Current hour', + 'state_class': , + 'unit_of_measurement': '€/kWh', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_current_hour_price', + 'last_changed': , + 'last_updated': , + 'state': '0.49', + }) +# --- +# name: test_energy_today[sensor.energyzero_today_energy_current_hour_price-today_energy_current_hour_price-].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_energy_current_hour_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current hour', + 'platform': 'energyzero', + 'supported_features': 0, + 'translation_key': None, + 'unit_of_measurement': '€/kWh', + }) +# --- +# name: test_energy_today[sensor.energyzero_today_energy_current_hour_price-today_energy_current_hour_price-].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'is_new': False, + 'manufacturer': 'EnergyZero', + 'model': None, + 'name': 'Energy market price', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_energy_today[sensor.energyzero_today_energy_current_hour_price-today_energy_current_hour_price-today_energy] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'friendly_name': 'Energy market price Current hour', + 'state_class': , + 'unit_of_measurement': '€/kWh', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_current_hour_price', + 'last_changed': , + 'last_updated': , + 'state': '0.49', + }) +# --- +# name: test_energy_today[sensor.energyzero_today_energy_current_hour_price-today_energy_current_hour_price-today_energy].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_energy_current_hour_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current hour', + 'platform': 'energyzero', + 'supported_features': 0, + 'translation_key': None, + 'unit_of_measurement': '€/kWh', + }) +# --- +# name: test_energy_today[sensor.energyzero_today_energy_current_hour_price-today_energy_current_hour_price-today_energy].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'is_new': False, + 'manufacturer': 'EnergyZero', + 'model': None, + 'name': 'Energy market price', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_energy_today[sensor.energyzero_today_energy_highest_price_time-today_energy_highest_price_time-today_energy] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'device_class': 'timestamp', + 'friendly_name': 'Energy market price Time of highest price - today', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_highest_price_time', + 'last_changed': , + 'last_updated': , + 'state': '2022-12-07T16:00:00+00:00', + }) +# --- +# name: test_energy_today[sensor.energyzero_today_energy_highest_price_time-today_energy_highest_price_time-today_energy].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_energy_highest_price_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time of highest price - today', + 'platform': 'energyzero', + 'supported_features': 0, + 'translation_key': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_energy_today[sensor.energyzero_today_energy_highest_price_time-today_energy_highest_price_time-today_energy].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'is_new': False, + 'manufacturer': 'EnergyZero', + 'model': None, + 'name': 'Energy market price', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_energy_today[sensor.energyzero_today_energy_max_price-today_energy_max_price-today_energy] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'friendly_name': 'Energy market price Highest price - today', + 'unit_of_measurement': '€/kWh', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_max_price', + 'last_changed': , + 'last_updated': , + 'state': '0.55', + }) +# --- +# name: test_energy_today[sensor.energyzero_today_energy_max_price-today_energy_max_price-today_energy].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_energy_max_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Highest price - today', + 'platform': 'energyzero', + 'supported_features': 0, + 'translation_key': None, + 'unit_of_measurement': '€/kWh', + }) +# --- +# name: test_energy_today[sensor.energyzero_today_energy_max_price-today_energy_max_price-today_energy].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'is_new': False, + 'manufacturer': 'EnergyZero', + 'model': None, + 'name': 'Energy market price', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_average_price-today_energy_average_price-today_energy] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'friendly_name': 'Energy market price Average - today', + 'unit_of_measurement': '€/kWh', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_average_price', + 'last_changed': , + 'last_updated': , + 'state': '0.37', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_average_price-today_energy_average_price-today_energy].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_energy_average_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Average - today', + 'platform': 'energyzero', + 'supported_features': 0, + 'translation_key': None, + 'unit_of_measurement': '€/kWh', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_average_price-today_energy_average_price-today_energy].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'is_new': False, + 'manufacturer': 'EnergyZero', + 'model': None, + 'name': 'Energy market price', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_current_hour_price-today_energy_current_hour_price-today_energy] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'friendly_name': 'Energy market price Current hour', + 'state_class': , + 'unit_of_measurement': '€/kWh', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_current_hour_price', + 'last_changed': , + 'last_updated': , + 'state': '0.49', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_current_hour_price-today_energy_current_hour_price-today_energy].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_energy_current_hour_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current hour', + 'platform': 'energyzero', + 'supported_features': 0, + 'translation_key': None, + 'unit_of_measurement': '€/kWh', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_current_hour_price-today_energy_current_hour_price-today_energy].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'is_new': False, + 'manufacturer': 'EnergyZero', + 'model': None, + 'name': 'Energy market price', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_highest_price_time-today_energy_highest_price_time-today_energy] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'device_class': 'timestamp', + 'friendly_name': 'Energy market price Time of highest price - today', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_highest_price_time', + 'last_changed': , + 'last_updated': , + 'state': '2022-12-07T16:00:00+00:00', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_highest_price_time-today_energy_highest_price_time-today_energy].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_energy_highest_price_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time of highest price - today', + 'platform': 'energyzero', + 'supported_features': 0, + 'translation_key': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_highest_price_time-today_energy_highest_price_time-today_energy].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'is_new': False, + 'manufacturer': 'EnergyZero', + 'model': None, + 'name': 'Energy market price', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_max_price-today_energy_max_price-today_energy] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'friendly_name': 'Energy market price Highest price - today', + 'unit_of_measurement': '€/kWh', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_max_price', + 'last_changed': , + 'last_updated': , + 'state': '0.55', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_max_price-today_energy_max_price-today_energy].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_energy_max_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Highest price - today', + 'platform': 'energyzero', + 'supported_features': 0, + 'translation_key': None, + 'unit_of_measurement': '€/kWh', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_max_price-today_energy_max_price-today_energy].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'is_new': False, + 'manufacturer': 'EnergyZero', + 'model': None, + 'name': 'Energy market price', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_sensor[sensor.energyzero_today_gas_current_hour_price-today_gas_current_hour_price-today_gas] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'friendly_name': 'Gas market price Current hour', + 'state_class': , + 'unit_of_measurement': '€/m³', + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_gas_current_hour_price', + 'last_changed': , + 'last_updated': , + 'state': '1.47', + }) +# --- +# name: test_sensor[sensor.energyzero_today_gas_current_hour_price-today_gas_current_hour_price-today_gas].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energyzero_today_gas_current_hour_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current hour', + 'platform': 'energyzero', + 'supported_features': 0, + 'translation_key': None, + 'unit_of_measurement': '€/m³', + }) +# --- +# name: test_sensor[sensor.energyzero_today_gas_current_hour_price-today_gas_current_hour_price-today_gas].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'is_new': False, + 'manufacturer': 'EnergyZero', + 'model': None, + 'name': 'Gas market price', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/energyzero/test_config_flow.py b/tests/components/energyzero/test_config_flow.py index b75b0c00da..c24eed9b25 100644 --- a/tests/components/energyzero/test_config_flow.py +++ b/tests/components/energyzero/test_config_flow.py @@ -1,6 +1,8 @@ """Test the EnergyZero config flow.""" from unittest.mock import MagicMock +from syrupy.assertion import SnapshotAssertion + from homeassistant.components.energyzero.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant @@ -10,6 +12,7 @@ from homeassistant.data_entry_flow import FlowResultType async def test_full_user_flow( hass: HomeAssistant, mock_setup_entry: MagicMock, + snapshot: SnapshotAssertion, ) -> None: """Test the full user configuration flow.""" result = await hass.config_entries.flow.async_init( @@ -26,7 +29,6 @@ async def test_full_user_flow( ) assert result2.get("type") == FlowResultType.CREATE_ENTRY - assert result2.get("title") == "EnergyZero" - assert result2.get("data") == {} + assert result2 == snapshot assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/energyzero/test_diagnostics.py b/tests/components/energyzero/test_diagnostics.py index 2a02447830..db0821cc95 100644 --- a/tests/components/energyzero/test_diagnostics.py +++ b/tests/components/energyzero/test_diagnostics.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock from energyzero import EnergyZeroNoDataError import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY from homeassistant.const import ATTR_ENTITY_ID @@ -13,43 +14,28 @@ from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator +pytestmark = pytest.mark.freeze_time("2022-12-07 15:00:00") + -@pytest.mark.freeze_time("2022-12-07 15:00:00") async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - assert await get_diagnostics_for_config_entry( - hass, hass_client, init_integration - ) == { - "entry": { - "title": "energy", - }, - "energy": { - "current_hour_price": 0.49, - "next_hour_price": 0.55, - "average_price": 0.37, - "max_price": 0.55, - "min_price": 0.26, - "highest_price_time": "2022-12-07T16:00:00+00:00", - "lowest_price_time": "2022-12-07T02:00:00+00:00", - "percentage_of_max": 89.09, - }, - "gas": { - "current_hour_price": 1.47, - "next_hour_price": 1.47, - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) -@pytest.mark.freeze_time("2022-12-07 15:00:00") async def test_diagnostics_no_gas_today( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_energyzero: MagicMock, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics, no gas sensors available.""" await async_setup_component(hass, "homeassistant", {}) @@ -63,24 +49,7 @@ async def test_diagnostics_no_gas_today( ) await hass.async_block_till_done() - assert await get_diagnostics_for_config_entry( - hass, hass_client, init_integration - ) == { - "entry": { - "title": "energy", - }, - "energy": { - "current_hour_price": 0.49, - "next_hour_price": 0.55, - "average_price": 0.37, - "max_price": 0.55, - "min_price": 0.26, - "highest_price_time": "2022-12-07T16:00:00+00:00", - "lowest_price_time": "2022-12-07T02:00:00+00:00", - "percentage_of_max": 89.09, - }, - "gas": { - "current_hour_price": None, - "next_hour_price": None, - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) diff --git a/tests/components/energyzero/test_init.py b/tests/components/energyzero/test_init.py index 489e4346e2..b7072108b3 100644 --- a/tests/components/energyzero/test_init.py +++ b/tests/components/energyzero/test_init.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, patch from energyzero import EnergyZeroConnectionError +import pytest from homeassistant.components.energyzero.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -10,8 +11,9 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +@pytest.mark.usefixtures("mock_energyzero") async def test_load_unload_config_entry( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_energyzero: MagicMock + hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test the EnergyZero configuration entry loading/unloading.""" mock_config_entry.add_to_hass(hass) diff --git a/tests/components/energyzero/test_sensor.py b/tests/components/energyzero/test_sensor.py index e1773e5349..4e961d1a68 100644 --- a/tests/components/energyzero/test_sensor.py +++ b/tests/components/energyzero/test_sensor.py @@ -4,164 +4,77 @@ from unittest.mock import MagicMock from energyzero import EnergyZeroNoDataError import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props from homeassistant.components.energyzero.const import DOMAIN from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - SensorDeviceClass, - SensorStateClass, -) -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, - ATTR_ICON, - ATTR_UNIT_OF_MEASUREMENT, - CURRENCY_EURO, - STATE_UNKNOWN, - UnitOfEnergy, - UnitOfVolume, -) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +pytestmark = [pytest.mark.freeze_time("2022-12-07 15:00:00")] -@pytest.mark.freeze_time("2022-12-07 15:00:00") -async def test_energy_today( - hass: HomeAssistant, init_integration: MockConfigEntry + +@pytest.mark.parametrize( + ("entity_id", "entity_unique_id", "device_identifier"), + [ + ( + "sensor.energyzero_today_energy_current_hour_price", + "today_energy_current_hour_price", + "today_energy", + ), + ( + "sensor.energyzero_today_energy_average_price", + "today_energy_average_price", + "today_energy", + ), + ( + "sensor.energyzero_today_energy_max_price", + "today_energy_max_price", + "today_energy", + ), + ( + "sensor.energyzero_today_energy_highest_price_time", + "today_energy_highest_price_time", + "today_energy", + ), + ( + "sensor.energyzero_today_gas_current_hour_price", + "today_gas_current_hour_price", + "today_gas", + ), + ], +) +async def test_sensor( + hass: HomeAssistant, + init_integration: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + entity_id: str, + entity_unique_id: str, + device_identifier: str, ) -> None: """Test the EnergyZero - Energy sensors.""" entry_id = init_integration.entry_id - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) + assert (state := hass.states.get(entity_id)) + assert state == snapshot + assert (entity_entry := entity_registry.async_get(entity_id)) + assert entity_entry == snapshot(exclude=props("unique_id")) + assert entity_entry.unique_id == f"{entry_id}_{entity_unique_id}" - # Current energy price sensor - state = hass.states.get("sensor.energyzero_today_energy_current_hour_price") - entry = entity_registry.async_get( - "sensor.energyzero_today_energy_current_hour_price" - ) - assert entry - assert state - assert entry.unique_id == f"{entry_id}_today_energy_current_hour_price" - assert state.state == "0.49" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy market price Current hour" - ) - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ATTR_DEVICE_CLASS not in state.attributes - assert ATTR_ICON not in state.attributes - - # Average price sensor - state = hass.states.get("sensor.energyzero_today_energy_average_price") - entry = entity_registry.async_get("sensor.energyzero_today_energy_average_price") - assert entry - assert state - assert entry.unique_id == f"{entry_id}_today_energy_average_price" - assert state.state == "0.37" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Energy market price Average - today" - ) - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}" - ) - assert ATTR_DEVICE_CLASS not in state.attributes - assert ATTR_ICON not in state.attributes - - # Highest price sensor - state = hass.states.get("sensor.energyzero_today_energy_max_price") - entry = entity_registry.async_get("sensor.energyzero_today_energy_max_price") - assert entry - assert state - assert entry.unique_id == f"{entry_id}_today_energy_max_price" - assert state.state == "0.55" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Energy market price Highest price - today" - ) - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}" - ) - assert ATTR_DEVICE_CLASS not in state.attributes - assert ATTR_ICON not in state.attributes - - # Highest price time sensor - state = hass.states.get("sensor.energyzero_today_energy_highest_price_time") - entry = entity_registry.async_get( - "sensor.energyzero_today_energy_highest_price_time" - ) - assert entry - assert state - assert entry.unique_id == f"{entry_id}_today_energy_highest_price_time" - assert state.state == "2022-12-07T16:00:00+00:00" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Energy market price Time of highest price - today" - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP - assert ATTR_ICON not in state.attributes - - assert entry.device_id - device_entry = device_registry.async_get(entry.device_id) - assert device_entry - assert device_entry.identifiers == {(DOMAIN, f"{entry_id}_today_energy")} - assert device_entry.manufacturer == "EnergyZero" - assert device_entry.name == "Energy market price" - assert device_entry.entry_type is dr.DeviceEntryType.SERVICE - assert not device_entry.model - assert not device_entry.sw_version + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry == snapshot(exclude=props("identifiers")) + assert device_entry.identifiers == {(DOMAIN, f"{entry_id}_{device_identifier}")} -@pytest.mark.freeze_time("2022-12-07 15:00:00") -async def test_gas_today( - hass: HomeAssistant, init_integration: MockConfigEntry -) -> None: - """Test the EnergyZero - Gas sensors.""" - entry_id = init_integration.entry_id - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - - # Current gas price sensor - state = hass.states.get("sensor.energyzero_today_gas_current_hour_price") - entry = entity_registry.async_get("sensor.energyzero_today_gas_current_hour_price") - assert entry - assert state - assert entry.unique_id == f"{entry_id}_today_gas_current_hour_price" - assert state.state == "1.47" - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Gas market price Current hour" - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == f"{CURRENCY_EURO}/{UnitOfVolume.CUBIC_METERS}" - ) - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ATTR_DEVICE_CLASS not in state.attributes - assert ATTR_ICON not in state.attributes - - assert entry.device_id - device_entry = device_registry.async_get(entry.device_id) - assert device_entry - assert device_entry.identifiers == {(DOMAIN, f"{entry_id}_today_gas")} - assert device_entry.manufacturer == "EnergyZero" - assert device_entry.name == "Gas market price" - assert device_entry.entry_type is dr.DeviceEntryType.SERVICE - assert not device_entry.model - assert not device_entry.sw_version - - -@pytest.mark.freeze_time("2022-12-07 15:00:00") -async def test_no_gas_today( - hass: HomeAssistant, mock_energyzero: MagicMock, init_integration: MockConfigEntry -) -> None: +@pytest.mark.usefixtures("init_integration") +async def test_no_gas_today(hass: HomeAssistant, mock_energyzero: MagicMock) -> None: """Test the EnergyZero - No gas sensors available.""" await async_setup_component(hass, "homeassistant", {}) @@ -175,6 +88,5 @@ async def test_no_gas_today( ) await hass.async_block_till_done() - state = hass.states.get("sensor.energyzero_today_gas_current_hour_price") - assert state + assert (state := hass.states.get("sensor.energyzero_today_gas_current_hour_price")) assert state.state == STATE_UNKNOWN From 172d505314b38b58a7c721f44f7d08442d1ff30d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 20 Feb 2023 03:14:10 +0100 Subject: [PATCH 0214/2810] Refactor Whois tests (#88456) --- tests/components/whois/conftest.py | 12 +- .../whois/snapshots/test_config_flow.ambr | 181 +++++ .../whois/snapshots/test_diagnostics.ambr | 13 + .../whois/snapshots/test_sensor.ambr | 679 ++++++++++++++++++ tests/components/whois/test_config_flow.py | 23 +- tests/components/whois/test_diagnostics.py | 17 +- tests/components/whois/test_init.py | 1 - tests/components/whois/test_sensor.py | 210 ++---- 8 files changed, 942 insertions(+), 194 deletions(-) create mode 100644 tests/components/whois/snapshots/test_config_flow.ambr create mode 100644 tests/components/whois/snapshots/test_diagnostics.ambr create mode 100644 tests/components/whois/snapshots/test_sensor.ambr diff --git a/tests/components/whois/conftest.py b/tests/components/whois/conftest.py index 056f6058bd..1ea31621e8 100644 --- a/tests/components/whois/conftest.py +++ b/tests/components/whois/conftest.py @@ -28,13 +28,6 @@ def mock_config_entry() -> MockConfigEntry: ) -@pytest.fixture -def mock_whois_config_flow() -> Generator[MagicMock, None, None]: - """Return a mocked whois.""" - with patch("homeassistant.components.whois.config_flow.whois.query") as whois_mock: - yield whois_mock - - @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: """Mock setting up a config entry.""" @@ -47,10 +40,11 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture def mock_whois() -> Generator[MagicMock, None, None]: """Return a mocked query.""" - with patch( "homeassistant.components.whois.whois_query", - ) as whois_mock: + ) as whois_mock, patch( + "homeassistant.components.whois.config_flow.whois.query", new=whois_mock + ): domain = whois_mock.return_value domain.abuse_contact = "abuse@example.com" domain.admin = "admin@example.com" diff --git a/tests/components/whois/snapshots/test_config_flow.ambr b/tests/components/whois/snapshots/test_config_flow.ambr new file mode 100644 index 0000000000..6eec94d42a --- /dev/null +++ b/tests/components/whois/snapshots/test_config_flow.ambr @@ -0,0 +1,181 @@ +# serializer version: 1 +# name: test_full_flow_with_error[FailedParsingWhoisOutput-unexpected_response] + FlowResultSnapshot({ + 'context': dict({ + 'source': 'user', + 'unique_id': 'example.com', + }), + 'data': dict({ + 'domain': 'example.com', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'whois', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'domain': 'example.com', + }), + 'disabled_by': None, + 'domain': 'whois', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Example.com', + 'unique_id': 'example.com', + 'version': 1, + }), + 'title': 'Example.com', + 'type': , + 'version': 1, + }) +# --- +# name: test_full_flow_with_error[UnknownDateFormat-unknown_date_format] + FlowResultSnapshot({ + 'context': dict({ + 'source': 'user', + 'unique_id': 'example.com', + }), + 'data': dict({ + 'domain': 'example.com', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'whois', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'domain': 'example.com', + }), + 'disabled_by': None, + 'domain': 'whois', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Example.com', + 'unique_id': 'example.com', + 'version': 1, + }), + 'title': 'Example.com', + 'type': , + 'version': 1, + }) +# --- +# name: test_full_flow_with_error[UnknownTld-unknown_tld] + FlowResultSnapshot({ + 'context': dict({ + 'source': 'user', + 'unique_id': 'example.com', + }), + 'data': dict({ + 'domain': 'example.com', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'whois', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'domain': 'example.com', + }), + 'disabled_by': None, + 'domain': 'whois', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Example.com', + 'unique_id': 'example.com', + 'version': 1, + }), + 'title': 'Example.com', + 'type': , + 'version': 1, + }) +# --- +# name: test_full_flow_with_error[WhoisCommandFailed-whois_command_failed] + FlowResultSnapshot({ + 'context': dict({ + 'source': 'user', + 'unique_id': 'example.com', + }), + 'data': dict({ + 'domain': 'example.com', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'whois', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'domain': 'example.com', + }), + 'disabled_by': None, + 'domain': 'whois', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Example.com', + 'unique_id': 'example.com', + 'version': 1, + }), + 'title': 'Example.com', + 'type': , + 'version': 1, + }) +# --- +# name: test_full_user_flow + FlowResultSnapshot({ + 'context': dict({ + 'source': 'user', + 'unique_id': 'example.com', + }), + 'data': dict({ + 'domain': 'example.com', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'whois', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'domain': 'example.com', + }), + 'disabled_by': None, + 'domain': 'whois', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Example.com', + 'unique_id': 'example.com', + 'version': 1, + }), + 'title': 'Example.com', + 'type': , + 'version': 1, + }) +# --- diff --git a/tests/components/whois/snapshots/test_diagnostics.ambr b/tests/components/whois/snapshots/test_diagnostics.ambr new file mode 100644 index 0000000000..f373a20700 --- /dev/null +++ b/tests/components/whois/snapshots/test_diagnostics.ambr @@ -0,0 +1,13 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'creation_date': '2019-01-01T00:00:00', + 'dnssec': True, + 'expiration_date': '2023-01-01T00:00:00', + 'last_updated': '2022-01-01T00:00:00+01:00', + 'status': 'OK', + 'statuses': list([ + 'OK', + ]), + }) +# --- diff --git a/tests/components/whois/snapshots/test_sensor.ambr b/tests/components/whois/snapshots/test_sensor.ambr new file mode 100644 index 0000000000..d0bcff20b0 --- /dev/null +++ b/tests/components/whois/snapshots/test_sensor.ambr @@ -0,0 +1,679 @@ +# serializer version: 1 +# name: test_whois_sensors[sensor.home_assistant_io_admin] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'home-assistant.io Admin', + 'icon': 'mdi:account-star', + }), + 'context': , + 'entity_id': 'sensor.home_assistant_io_admin', + 'last_changed': , + 'last_updated': , + 'state': 'admin@example.com', + }) +# --- +# name: test_whois_sensors[sensor.home_assistant_io_admin].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.home_assistant_io_admin', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:account-star', + 'original_name': 'Admin', + 'platform': 'whois', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'home-assistant.io_admin', + 'unit_of_measurement': None, + }) +# --- +# name: test_whois_sensors[sensor.home_assistant_io_admin].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'whois', + 'home-assistant.io', + ), + }), + 'is_new': False, + 'manufacturer': None, + 'model': None, + 'name': 'home-assistant.io', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_whois_sensors[sensor.home_assistant_io_created] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'home-assistant.io Created', + }), + 'context': , + 'entity_id': 'sensor.home_assistant_io_created', + 'last_changed': , + 'last_updated': , + 'state': '2019-01-01T00:00:00+00:00', + }) +# --- +# name: test_whois_sensors[sensor.home_assistant_io_created].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.home_assistant_io_created', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Created', + 'platform': 'whois', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'home-assistant.io_creation_date', + 'unit_of_measurement': None, + }) +# --- +# name: test_whois_sensors[sensor.home_assistant_io_created].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'whois', + 'home-assistant.io', + ), + }), + 'is_new': False, + 'manufacturer': None, + 'model': None, + 'name': 'home-assistant.io', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_whois_sensors[sensor.home_assistant_io_days_until_expiration] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'expires': '2023-01-01T00:00:00', + 'friendly_name': 'home-assistant.io Days until expiration', + 'icon': 'mdi:calendar-clock', + 'name_servers': 'ns1.example.com ns2.example.com', + 'registrar': 'My Registrar', + 'unit_of_measurement': , + 'updated': '2022-01-01T00:00:00+01:00', + }), + 'context': , + 'entity_id': 'sensor.home_assistant_io_days_until_expiration', + 'last_changed': , + 'last_updated': , + 'state': '364', + }) +# --- +# name: test_whois_sensors[sensor.home_assistant_io_days_until_expiration].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_assistant_io_days_until_expiration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:calendar-clock', + 'original_name': 'Days until expiration', + 'platform': 'whois', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'home-assistant.io_days_until_expiration', + 'unit_of_measurement': , + }) +# --- +# name: test_whois_sensors[sensor.home_assistant_io_days_until_expiration].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'whois', + 'home-assistant.io', + ), + }), + 'is_new': False, + 'manufacturer': None, + 'model': None, + 'name': 'home-assistant.io', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_whois_sensors[sensor.home_assistant_io_expires] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'home-assistant.io Expires', + }), + 'context': , + 'entity_id': 'sensor.home_assistant_io_expires', + 'last_changed': , + 'last_updated': , + 'state': '2023-01-01T00:00:00+00:00', + }) +# --- +# name: test_whois_sensors[sensor.home_assistant_io_expires].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.home_assistant_io_expires', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Expires', + 'platform': 'whois', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'home-assistant.io_expiration_date', + 'unit_of_measurement': None, + }) +# --- +# name: test_whois_sensors[sensor.home_assistant_io_expires].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'whois', + 'home-assistant.io', + ), + }), + 'is_new': False, + 'manufacturer': None, + 'model': None, + 'name': 'home-assistant.io', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_whois_sensors[sensor.home_assistant_io_last_updated] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'home-assistant.io Last updated', + }), + 'context': , + 'entity_id': 'sensor.home_assistant_io_last_updated', + 'last_changed': , + 'last_updated': , + 'state': '2021-12-31T23:00:00+00:00', + }) +# --- +# name: test_whois_sensors[sensor.home_assistant_io_last_updated].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.home_assistant_io_last_updated', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last updated', + 'platform': 'whois', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'home-assistant.io_last_updated', + 'unit_of_measurement': None, + }) +# --- +# name: test_whois_sensors[sensor.home_assistant_io_last_updated].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'whois', + 'home-assistant.io', + ), + }), + 'is_new': False, + 'manufacturer': None, + 'model': None, + 'name': 'home-assistant.io', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_whois_sensors[sensor.home_assistant_io_owner] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'home-assistant.io Owner', + 'icon': 'mdi:account', + }), + 'context': , + 'entity_id': 'sensor.home_assistant_io_owner', + 'last_changed': , + 'last_updated': , + 'state': 'owner@example.com', + }) +# --- +# name: test_whois_sensors[sensor.home_assistant_io_owner].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.home_assistant_io_owner', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:account', + 'original_name': 'Owner', + 'platform': 'whois', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'home-assistant.io_owner', + 'unit_of_measurement': None, + }) +# --- +# name: test_whois_sensors[sensor.home_assistant_io_owner].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'whois', + 'home-assistant.io', + ), + }), + 'is_new': False, + 'manufacturer': None, + 'model': None, + 'name': 'home-assistant.io', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_whois_sensors[sensor.home_assistant_io_registrant] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'home-assistant.io Registrant', + 'icon': 'mdi:account-edit', + }), + 'context': , + 'entity_id': 'sensor.home_assistant_io_registrant', + 'last_changed': , + 'last_updated': , + 'state': 'registrant@example.com', + }) +# --- +# name: test_whois_sensors[sensor.home_assistant_io_registrant].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.home_assistant_io_registrant', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:account-edit', + 'original_name': 'Registrant', + 'platform': 'whois', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'home-assistant.io_registrant', + 'unit_of_measurement': None, + }) +# --- +# name: test_whois_sensors[sensor.home_assistant_io_registrant].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'whois', + 'home-assistant.io', + ), + }), + 'is_new': False, + 'manufacturer': None, + 'model': None, + 'name': 'home-assistant.io', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_whois_sensors[sensor.home_assistant_io_registrar] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'home-assistant.io Registrar', + 'icon': 'mdi:store', + }), + 'context': , + 'entity_id': 'sensor.home_assistant_io_registrar', + 'last_changed': , + 'last_updated': , + 'state': 'My Registrar', + }) +# --- +# name: test_whois_sensors[sensor.home_assistant_io_registrar].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.home_assistant_io_registrar', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:store', + 'original_name': 'Registrar', + 'platform': 'whois', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'home-assistant.io_registrar', + 'unit_of_measurement': None, + }) +# --- +# name: test_whois_sensors[sensor.home_assistant_io_registrar].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'whois', + 'home-assistant.io', + ), + }), + 'is_new': False, + 'manufacturer': None, + 'model': None, + 'name': 'home-assistant.io', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_whois_sensors[sensor.home_assistant_io_reseller] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'home-assistant.io Reseller', + 'icon': 'mdi:store', + }), + 'context': , + 'entity_id': 'sensor.home_assistant_io_reseller', + 'last_changed': , + 'last_updated': , + 'state': 'Top Domains, Low Prices', + }) +# --- +# name: test_whois_sensors[sensor.home_assistant_io_reseller].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.home_assistant_io_reseller', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:store', + 'original_name': 'Reseller', + 'platform': 'whois', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'home-assistant.io_reseller', + 'unit_of_measurement': None, + }) +# --- +# name: test_whois_sensors[sensor.home_assistant_io_reseller].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'whois', + 'home-assistant.io', + ), + }), + 'is_new': False, + 'manufacturer': None, + 'model': None, + 'name': 'home-assistant.io', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_whois_sensors_missing_some_attrs + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'home-assistant.io Last updated', + }), + 'context': , + 'entity_id': 'sensor.home_assistant_io_last_updated', + 'last_changed': , + 'last_updated': , + 'state': '2021-12-31T23:00:00+00:00', + }) +# --- +# name: test_whois_sensors_missing_some_attrs.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.home_assistant_io_last_updated', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last updated', + 'platform': 'whois', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'home-assistant.io_last_updated', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/whois/test_config_flow.py b/tests/components/whois/test_config_flow.py index 1c160aacf7..52bb87817f 100644 --- a/tests/components/whois/test_config_flow.py +++ b/tests/components/whois/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from syrupy.assertion import SnapshotAssertion from whois.exceptions import ( FailedParsingWhoisOutput, UnknownDateFormat, @@ -18,10 +19,11 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry +@pytest.mark.usefixtures("mock_whois") async def test_full_user_flow( hass: HomeAssistant, mock_setup_entry: AsyncMock, - mock_whois_config_flow: MagicMock, + snapshot: SnapshotAssertion, ) -> None: """Test the full user configuration flow.""" result = await hass.config_entries.flow.async_init( @@ -37,8 +39,7 @@ async def test_full_user_flow( ) assert result2.get("type") == FlowResultType.CREATE_ENTRY - assert result2.get("title") == "Example.com" - assert result2.get("data") == {CONF_DOMAIN: "example.com"} + assert result2 == snapshot assert len(mock_setup_entry.mock_calls) == 1 @@ -55,7 +56,8 @@ async def test_full_user_flow( async def test_full_flow_with_error( hass: HomeAssistant, mock_setup_entry: AsyncMock, - mock_whois_config_flow: MagicMock, + mock_whois: MagicMock, + snapshot: SnapshotAssertion, throw: Exception, reason: str, ) -> None: @@ -71,7 +73,7 @@ async def test_full_flow_with_error( assert result.get("type") == FlowResultType.FORM assert result.get("step_id") == SOURCE_USER - mock_whois_config_flow.side_effect = throw + mock_whois.side_effect = throw result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_DOMAIN: "Example.com"}, @@ -82,27 +84,26 @@ async def test_full_flow_with_error( assert result2.get("errors") == {"base": reason} assert len(mock_setup_entry.mock_calls) == 0 - assert len(mock_whois_config_flow.mock_calls) == 1 + assert len(mock_whois.mock_calls) == 1 - mock_whois_config_flow.side_effect = None + mock_whois.side_effect = None result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], user_input={CONF_DOMAIN: "Example.com"}, ) assert result3.get("type") == FlowResultType.CREATE_ENTRY - assert result3.get("title") == "Example.com" - assert result3.get("data") == {CONF_DOMAIN: "example.com"} + assert result3 == snapshot assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_whois_config_flow.mock_calls) == 2 + assert len(mock_whois.mock_calls) == 2 +@pytest.mark.usefixtures("mock_whois") async def test_already_configured( hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry, - mock_whois_config_flow: MagicMock, ) -> None: """Test we abort if already configured.""" mock_config_entry.add_to_hass(hass) diff --git a/tests/components/whois/test_diagnostics.py b/tests/components/whois/test_diagnostics.py index a818ba81ef..a134c2136a 100644 --- a/tests/components/whois/test_diagnostics.py +++ b/tests/components/whois/test_diagnostics.py @@ -1,4 +1,6 @@ """Tests for the diagnostics data provided by the Whois integration.""" +from syrupy.assertion import SnapshotAssertion + from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -10,15 +12,10 @@ async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - assert await get_diagnostics_for_config_entry( - hass, hass_client, init_integration - ) == { - "creation_date": "2019-01-01T00:00:00", - "expiration_date": "2023-01-01T00:00:00", - "last_updated": "2022-01-01T00:00:00+01:00", - "status": "OK", - "statuses": ["OK"], - "dnssec": True, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) diff --git a/tests/components/whois/test_init.py b/tests/components/whois/test_init.py index efb68a8e80..1cf2fb7b5e 100644 --- a/tests/components/whois/test_init.py +++ b/tests/components/whois/test_init.py @@ -44,7 +44,6 @@ async def test_error_handling( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_whois: MagicMock, - caplog: pytest.LogCaptureFixture, side_effect: Exception, ) -> None: """Test the Whois threw an error.""" diff --git a/tests/components/whois/test_sensor.py b/tests/components/whois/test_sensor.py index 718eb028b2..a7c1ad8229 100644 --- a/tests/components/whois/test_sensor.py +++ b/tests/components/whois/test_sensor.py @@ -1,172 +1,67 @@ """Tests for the sensors provided by the Whois integration.""" -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import MagicMock import pytest +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.components.whois.const import DOMAIN, SCAN_INTERVAL -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_FRIENDLY_NAME, - ATTR_ICON, - STATE_UNKNOWN, - EntityCategory, -) +from homeassistant.components.whois.const import SCAN_INTERVAL +from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er import homeassistant.util.dt as dt_util -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import async_fire_time_changed + +pytestmark = [ + pytest.mark.usefixtures("init_integration"), + pytest.mark.freeze_time("2022-01-01 12:00:00", tz_offset=0), +] -@pytest.mark.freeze_time("2022-01-01 12:00:00", tz_offset=0) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + "entity_id", + ( + "sensor.home_assistant_io_admin", + "sensor.home_assistant_io_created", + "sensor.home_assistant_io_days_until_expiration", + "sensor.home_assistant_io_expires", + "sensor.home_assistant_io_last_updated", + "sensor.home_assistant_io_owner", + "sensor.home_assistant_io_registrant", + "sensor.home_assistant_io_registrar", + "sensor.home_assistant_io_reseller", + ), +) async def test_whois_sensors( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, - init_integration: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + entity_id: str, ) -> None: """Test the Whois sensors.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - state = hass.states.get("sensor.home_assistant_io_admin") - entry = entity_registry.async_get("sensor.home_assistant_io_admin") - assert entry - assert state - assert entry.unique_id == "home-assistant.io_admin" - assert entry.entity_category == EntityCategory.DIAGNOSTIC - assert state.state == "admin@example.com" - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "home-assistant.io Admin" - assert state.attributes.get(ATTR_ICON) == "mdi:account-star" - assert ATTR_DEVICE_CLASS not in state.attributes + assert (state := hass.states.get(entity_id)) + assert state == snapshot - state = hass.states.get("sensor.home_assistant_io_created") - entry = entity_registry.async_get("sensor.home_assistant_io_created") - assert entry - assert state - assert entry.unique_id == "home-assistant.io_creation_date" - assert entry.entity_category == EntityCategory.DIAGNOSTIC - assert state.state == "2019-01-01T00:00:00+00:00" - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "home-assistant.io Created" - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP - assert ATTR_ICON not in state.attributes + assert (entity_entry := entity_registry.async_get(entity_id)) + assert entity_entry == snapshot - state = hass.states.get("sensor.home_assistant_io_days_until_expiration") - entry = entity_registry.async_get("sensor.home_assistant_io_days_until_expiration") - assert entry - assert state - assert entry.unique_id == "home-assistant.io_days_until_expiration" - assert entry.entity_category is None - assert state.state == "364" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "home-assistant.io Days until expiration" - ) - assert state.attributes.get(ATTR_ICON) == "mdi:calendar-clock" - assert ATTR_DEVICE_CLASS not in state.attributes - - state = hass.states.get("sensor.home_assistant_io_expires") - entry = entity_registry.async_get("sensor.home_assistant_io_expires") - assert entry - assert state - assert entry.unique_id == "home-assistant.io_expiration_date" - assert entry.entity_category == EntityCategory.DIAGNOSTIC - assert state.state == "2023-01-01T00:00:00+00:00" - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "home-assistant.io Expires" - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP - assert ATTR_ICON not in state.attributes - - state = hass.states.get("sensor.home_assistant_io_last_updated") - entry = entity_registry.async_get("sensor.home_assistant_io_last_updated") - assert entry - assert state - assert entry.unique_id == "home-assistant.io_last_updated" - assert entry.entity_category == EntityCategory.DIAGNOSTIC - assert state.state == "2021-12-31T23:00:00+00:00" - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "home-assistant.io Last updated" - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP - assert ATTR_ICON not in state.attributes - - state = hass.states.get("sensor.home_assistant_io_owner") - entry = entity_registry.async_get("sensor.home_assistant_io_owner") - assert entry - assert state - assert entry.unique_id == "home-assistant.io_owner" - assert entry.entity_category == EntityCategory.DIAGNOSTIC - assert state.state == "owner@example.com" - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "home-assistant.io Owner" - assert state.attributes.get(ATTR_ICON) == "mdi:account" - assert ATTR_DEVICE_CLASS not in state.attributes - - state = hass.states.get("sensor.home_assistant_io_registrant") - entry = entity_registry.async_get("sensor.home_assistant_io_registrant") - assert entry - assert state - assert entry.unique_id == "home-assistant.io_registrant" - assert entry.entity_category == EntityCategory.DIAGNOSTIC - assert state.state == "registrant@example.com" - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "home-assistant.io Registrant" - assert state.attributes.get(ATTR_ICON) == "mdi:account-edit" - assert ATTR_DEVICE_CLASS not in state.attributes - - state = hass.states.get("sensor.home_assistant_io_registrar") - entry = entity_registry.async_get("sensor.home_assistant_io_registrar") - assert entry - assert state - assert entry.unique_id == "home-assistant.io_registrar" - assert entry.entity_category == EntityCategory.DIAGNOSTIC - assert state.state == "My Registrar" - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "home-assistant.io Registrar" - assert state.attributes.get(ATTR_ICON) == "mdi:store" - assert ATTR_DEVICE_CLASS not in state.attributes - - state = hass.states.get("sensor.home_assistant_io_reseller") - entry = entity_registry.async_get("sensor.home_assistant_io_reseller") - assert entry - assert state - assert entry.unique_id == "home-assistant.io_reseller" - assert entry.entity_category == EntityCategory.DIAGNOSTIC - assert state.state == "Top Domains, Low Prices" - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "home-assistant.io Reseller" - assert state.attributes.get(ATTR_ICON) == "mdi:store" - assert ATTR_DEVICE_CLASS not in state.attributes - - assert entry.device_id - device_entry = device_registry.async_get(entry.device_id) - assert device_entry - assert device_entry.configuration_url is None - assert device_entry.entry_type == dr.DeviceEntryType.SERVICE - assert device_entry.identifiers == {(DOMAIN, "home-assistant.io")} - assert device_entry.name == "home-assistant.io" - assert device_entry.manufacturer is None - assert device_entry.model is None - assert device_entry.sw_version is None + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry == snapshot -@pytest.mark.freeze_time("2022-01-01 12:00:00", tz_offset=0) async def test_whois_sensors_missing_some_attrs( - hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, - init_integration_missing_some_attrs: MockConfigEntry, + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: """Test the Whois sensors with owner and reseller missing.""" - entity_registry = er.async_get(hass) + assert (state := hass.states.get("sensor.home_assistant_io_last_updated")) + assert state == snapshot - state = hass.states.get("sensor.home_assistant_io_last_updated") - entry = entity_registry.async_get("sensor.home_assistant_io_last_updated") - assert entry - assert state - assert entry.unique_id == "home-assistant.io_last_updated" - assert entry.entity_category == EntityCategory.DIAGNOSTIC - assert state.state == "2021-12-31T23:00:00+00:00" - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "home-assistant.io Last updated" - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP - assert ATTR_ICON not in state.attributes - - assert hass.states.get("sensor.home_assistant_io_owner").state == STATE_UNKNOWN - assert hass.states.get("sensor.home_assistant_io_reseller").state == STATE_UNKNOWN - assert hass.states.get("sensor.home_assistant_io_registrant").state == STATE_UNKNOWN - assert hass.states.get("sensor.home_assistant_io_admin").state == STATE_UNKNOWN + assert (entry := entity_registry.async_get("sensor.home_assistant_io_last_updated")) + assert entry == snapshot @pytest.mark.parametrize( @@ -180,22 +75,16 @@ async def test_whois_sensors_missing_some_attrs( ), ) async def test_disabled_by_default_sensors( - hass: HomeAssistant, - init_integration: MockConfigEntry, - entity_id: str, + hass: HomeAssistant, entity_id: str, entity_registry: er.EntityRegistry ) -> None: """Test the disabled by default Whois sensors.""" - registry = er.async_get(hass) - - state = hass.states.get(entity_id) - assert state is None - - entry = registry.async_get(entity_id) - assert entry + assert hass.states.get(entity_id) is None + assert (entry := entity_registry.async_get(entity_id)) assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( "entity_id", ( @@ -211,11 +100,7 @@ async def test_disabled_by_default_sensors( ), ) async def test_no_data( - hass: HomeAssistant, - mock_whois: MagicMock, - entity_registry_enabled_by_default: AsyncMock, - init_integration: MockConfigEntry, - entity_id: str, + hass: HomeAssistant, mock_whois: MagicMock, entity_id: str ) -> None: """Test whois sensors become unknown when there is no data provided.""" mock_whois.return_value = None @@ -223,6 +108,5 @@ async def test_no_data( async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == STATE_UNKNOWN From c4f92f5ad4b49fd4dc0c06ab22fa3515ad31222e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 20 Feb 2023 03:15:07 +0100 Subject: [PATCH 0215/2810] Cleanup use of test fixtures in LaMetric tests (#88453) Cleanup use of test fixture in LaMetric tests --- tests/components/lametric/conftest.py | 22 +-- tests/components/lametric/test_button.py | 42 ++--- tests/components/lametric/test_config_flow.py | 148 +++++++++--------- tests/components/lametric/test_notify.py | 7 +- tests/components/lametric/test_number.py | 16 +- tests/components/lametric/test_select.py | 10 +- tests/components/lametric/test_sensor.py | 14 +- tests/components/lametric/test_services.py | 4 +- tests/components/lametric/test_switch.py | 12 +- 9 files changed, 115 insertions(+), 160 deletions(-) diff --git a/tests/components/lametric/conftest.py b/tests/components/lametric/conftest.py index a645dc6605..177204b6f2 100644 --- a/tests/components/lametric/conftest.py +++ b/tests/components/lametric/conftest.py @@ -54,22 +54,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_lametric_config_flow() -> Generator[MagicMock, None, None]: - """Return a mocked LaMetric client.""" - with patch( - "homeassistant.components.lametric.config_flow.LaMetricDevice", autospec=True - ) as lametric_mock: - lametric = lametric_mock.return_value - lametric.api_key = "mock-api-key" - lametric.host = "127.0.0.1" - lametric.device.return_value = Device.parse_raw( - load_fixture("device.json", DOMAIN) - ) - yield lametric - - -@pytest.fixture -def mock_lametric_cloud_config_flow() -> Generator[MagicMock, None, None]: +def mock_lametric_cloud() -> Generator[MagicMock, None, None]: """Return a mocked LaMetric Cloud client.""" with patch( "homeassistant.components.lametric.config_flow.LaMetricCloud", autospec=True @@ -86,7 +71,10 @@ def mock_lametric() -> Generator[MagicMock, None, None]: """Return a mocked LaMetric client.""" with patch( "homeassistant.components.lametric.coordinator.LaMetricDevice", autospec=True - ) as lametric_mock: + ) as lametric_mock, patch( + "homeassistant.components.lametric.config_flow.LaMetricDevice", + new=lametric_mock, + ): lametric = lametric_mock.return_value lametric.api_key = "mock-api-key" lametric.host = "127.0.0.1" diff --git a/tests/components/lametric/test_button.py b/tests/components/lametric/test_button.py index 329f50feb5..ce8639db71 100644 --- a/tests/components/lametric/test_button.py +++ b/tests/components/lametric/test_button.py @@ -17,19 +17,19 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import MockConfigEntry +pytestmark = [ + pytest.mark.usefixtures("init_integration"), + pytest.mark.freeze_time("2022-09-19 12:07:30"), +] -@pytest.mark.freeze_time("2022-09-19 12:07:30") async def test_button_app_next( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_lametric: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test the LaMetric next app button.""" - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - state = hass.states.get("button.frenck_s_lametric_next_app") assert state assert state.attributes.get(ATTR_ICON) == "mdi:arrow-right-bold" @@ -70,16 +70,13 @@ async def test_button_app_next( assert state.state == "2022-09-19T12:07:30+00:00" -@pytest.mark.freeze_time("2022-09-19 12:07:30") async def test_button_app_previous( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_lametric: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test the LaMetric previous app button.""" - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - state = hass.states.get("button.frenck_s_lametric_previous_app") assert state assert state.attributes.get(ATTR_ICON) == "mdi:arrow-left-bold" @@ -120,16 +117,13 @@ async def test_button_app_previous( assert state.state == "2022-09-19T12:07:30+00:00" -@pytest.mark.freeze_time("2022-10-19 12:44:00") async def test_button_dismiss_current_notification( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_lametric: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test the LaMetric dismiss current notification button.""" - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - state = hass.states.get("button.frenck_s_lametric_dismiss_current_notification") assert state assert state.attributes.get(ATTR_ICON) == "mdi:bell-cancel" @@ -169,19 +163,16 @@ async def test_button_dismiss_current_notification( state = hass.states.get("button.frenck_s_lametric_dismiss_current_notification") assert state - assert state.state == "2022-10-19T12:44:00+00:00" + assert state.state == "2022-09-19T12:07:30+00:00" -@pytest.mark.freeze_time("2022-10-19 12:44:00") async def test_button_dismiss_all_notifications( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_lametric: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test the LaMetric dismiss all notifications button.""" - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - state = hass.states.get("button.frenck_s_lametric_dismiss_all_notifications") assert state assert state.attributes.get(ATTR_ICON) == "mdi:bell-cancel" @@ -221,13 +212,11 @@ async def test_button_dismiss_all_notifications( state = hass.states.get("button.frenck_s_lametric_dismiss_all_notifications") assert state - assert state.state == "2022-10-19T12:44:00+00:00" + assert state.state == "2022-09-19T12:07:30+00:00" -@pytest.mark.freeze_time("2022-10-11 22:00:00") async def test_button_error( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_lametric: MagicMock, ) -> None: """Test error handling of the LaMetric buttons.""" @@ -246,12 +235,11 @@ async def test_button_error( state = hass.states.get("button.frenck_s_lametric_next_app") assert state - assert state.state == "2022-10-11T22:00:00+00:00" + assert state.state == "2022-09-19T12:07:30+00:00" async def test_button_connection_error( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_lametric: MagicMock, ) -> None: """Test connection error handling of the LaMetric buttons.""" diff --git a/tests/components/lametric/test_config_flow.py b/tests/components/lametric/test_config_flow.py index 5a14d2bcce..8fd0ef061a 100644 --- a/tests/components/lametric/test_config_flow.py +++ b/tests/components/lametric/test_config_flow.py @@ -42,14 +42,14 @@ SSDP_DISCOVERY_INFO = SsdpServiceInfo( ) +@pytest.mark.usefixtures("current_request_with_host") async def test_full_cloud_import_flow_multiple_devices( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, mock_setup_entry: MagicMock, - mock_lametric_cloud_config_flow: MagicMock, - mock_lametric_config_flow: MagicMock, + mock_lametric_cloud: MagicMock, + mock_lametric: MagicMock, ) -> None: """Check a full flow importing from cloud, with multiple devices.""" result = await hass.config_entries.flow.async_init( @@ -116,20 +116,20 @@ async def test_full_cloud_import_flow_multiple_devices( assert "result" in result4 assert result4["result"].unique_id == "SA110405124500W00BS9" - assert len(mock_lametric_cloud_config_flow.devices.mock_calls) == 1 - assert len(mock_lametric_config_flow.device.mock_calls) == 1 - assert len(mock_lametric_config_flow.notify.mock_calls) == 1 + assert len(mock_lametric_cloud.devices.mock_calls) == 1 + assert len(mock_lametric.device.mock_calls) == 1 + assert len(mock_lametric.notify.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("current_request_with_host") async def test_full_cloud_import_flow_single_device( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, mock_setup_entry: MagicMock, - mock_lametric_cloud_config_flow: MagicMock, - mock_lametric_config_flow: MagicMock, + mock_lametric_cloud: MagicMock, + mock_lametric: MagicMock, ) -> None: """Check a full flow importing from cloud, with a single device.""" result = await hass.config_entries.flow.async_init( @@ -179,8 +179,8 @@ async def test_full_cloud_import_flow_single_device( # Stage a single device # Should skip step that ask for device selection - mock_lametric_cloud_config_flow.devices.return_value = [ - mock_lametric_cloud_config_flow.devices.return_value[0] + mock_lametric_cloud.devices.return_value = [ + mock_lametric_cloud.devices.return_value[0] ] result3 = await hass.config_entries.flow.async_configure(flow_id) @@ -194,16 +194,16 @@ async def test_full_cloud_import_flow_single_device( assert "result" in result3 assert result3["result"].unique_id == "SA110405124500W00BS9" - assert len(mock_lametric_cloud_config_flow.devices.mock_calls) == 1 - assert len(mock_lametric_config_flow.device.mock_calls) == 1 - assert len(mock_lametric_config_flow.notify.mock_calls) == 1 + assert len(mock_lametric_cloud.devices.mock_calls) == 1 + assert len(mock_lametric.device.mock_calls) == 1 + assert len(mock_lametric.notify.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 async def test_full_manual( hass: HomeAssistant, mock_setup_entry: MagicMock, - mock_lametric_config_flow: MagicMock, + mock_lametric: MagicMock, ) -> None: """Check a full flow manual entry.""" result = await hass.config_entries.flow.async_init( @@ -236,19 +236,19 @@ async def test_full_manual( assert "result" in result3 assert result3["result"].unique_id == "SA110405124500W00BS9" - assert len(mock_lametric_config_flow.device.mock_calls) == 1 - assert len(mock_lametric_config_flow.notify.mock_calls) == 1 + assert len(mock_lametric.device.mock_calls) == 1 + assert len(mock_lametric.notify.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("current_request_with_host") async def test_full_ssdp_with_cloud_import( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, mock_setup_entry: MagicMock, - mock_lametric_cloud_config_flow: MagicMock, - mock_lametric_config_flow: MagicMock, + mock_lametric_cloud: MagicMock, + mock_lametric: MagicMock, ) -> None: """Check a full flow triggered by SSDP, importing from cloud.""" result = await hass.config_entries.flow.async_init( @@ -308,16 +308,16 @@ async def test_full_ssdp_with_cloud_import( assert "result" in result3 assert result3["result"].unique_id == "SA110405124500W00BS9" - assert len(mock_lametric_cloud_config_flow.devices.mock_calls) == 1 - assert len(mock_lametric_config_flow.device.mock_calls) == 1 - assert len(mock_lametric_config_flow.notify.mock_calls) == 1 + assert len(mock_lametric_cloud.devices.mock_calls) == 1 + assert len(mock_lametric.device.mock_calls) == 1 + assert len(mock_lametric.notify.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 async def test_full_ssdp_manual_entry( hass: HomeAssistant, mock_setup_entry: MagicMock, - mock_lametric_config_flow: MagicMock, + mock_lametric: MagicMock, ) -> None: """Check a full flow triggered by SSDP, with manual API key entry.""" result = await hass.config_entries.flow.async_init( @@ -350,8 +350,8 @@ async def test_full_ssdp_manual_entry( assert "result" in result3 assert result3["result"].unique_id == "SA110405124500W00BS9" - assert len(mock_lametric_config_flow.device.mock_calls) == 1 - assert len(mock_lametric_config_flow.notify.mock_calls) == 1 + assert len(mock_lametric.device.mock_calls) == 1 + assert len(mock_lametric.notify.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -386,13 +386,13 @@ async def test_ssdp_abort_invalid_discovery( assert result.get("reason") == reason +@pytest.mark.usefixtures("current_request_with_host") async def test_cloud_import_updates_existing_entry( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, - mock_lametric_cloud_config_flow: MagicMock, - mock_lametric_config_flow: MagicMock, + mock_lametric_cloud: MagicMock, + mock_lametric: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test cloud importing existing device updates existing entry.""" @@ -439,14 +439,14 @@ async def test_cloud_import_updates_existing_entry( CONF_MAC: "AA:BB:CC:DD:EE:FF", } - assert len(mock_lametric_cloud_config_flow.devices.mock_calls) == 1 - assert len(mock_lametric_config_flow.device.mock_calls) == 1 - assert len(mock_lametric_config_flow.notify.mock_calls) == 0 + assert len(mock_lametric_cloud.devices.mock_calls) == 1 + assert len(mock_lametric.device.mock_calls) == 1 + assert len(mock_lametric.notify.mock_calls) == 0 async def test_manual_updates_existing_entry( hass: HomeAssistant, - mock_lametric_config_flow: MagicMock, + mock_lametric: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test adding existing device updates existing entry.""" @@ -473,8 +473,8 @@ async def test_manual_updates_existing_entry( CONF_MAC: "AA:BB:CC:DD:EE:FF", } - assert len(mock_lametric_config_flow.device.mock_calls) == 1 - assert len(mock_lametric_config_flow.notify.mock_calls) == 0 + assert len(mock_lametric.device.mock_calls) == 1 + assert len(mock_lametric.notify.mock_calls) == 0 async def test_discovery_updates_existing_entry( @@ -496,12 +496,12 @@ async def test_discovery_updates_existing_entry( } +@pytest.mark.usefixtures("current_request_with_host") async def test_cloud_abort_no_devices( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, - mock_lametric_cloud_config_flow: MagicMock, + mock_lametric_cloud: MagicMock, ) -> None: """Test cloud importing aborts when account has no devices.""" result = await hass.config_entries.flow.async_init( @@ -533,13 +533,13 @@ async def test_cloud_abort_no_devices( ) # Stage there are no devices - mock_lametric_cloud_config_flow.devices.return_value = [] + mock_lametric_cloud.devices.return_value = [] result2 = await hass.config_entries.flow.async_configure(flow_id) assert result2.get("type") == FlowResultType.ABORT assert result2.get("reason") == "no_devices" - assert len(mock_lametric_cloud_config_flow.devices.mock_calls) == 1 + assert len(mock_lametric_cloud.devices.mock_calls) == 1 @pytest.mark.parametrize( @@ -553,7 +553,7 @@ async def test_cloud_abort_no_devices( ) async def test_manual_errors( hass: HomeAssistant, - mock_lametric_config_flow: MagicMock, + mock_lametric: MagicMock, mock_setup_entry: MagicMock, side_effect: Exception, reason: str, @@ -568,7 +568,7 @@ async def test_manual_errors( flow_id, user_input={"next_step_id": "manual_entry"} ) - mock_lametric_config_flow.device.side_effect = side_effect + mock_lametric.device.side_effect = side_effect result2 = await hass.config_entries.flow.async_configure( flow_id, user_input={CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key"} ) @@ -577,11 +577,11 @@ async def test_manual_errors( assert result2.get("step_id") == "manual_entry" assert result2.get("errors") == {"base": reason} - assert len(mock_lametric_config_flow.device.mock_calls) == 1 - assert len(mock_lametric_config_flow.notify.mock_calls) == 0 + assert len(mock_lametric.device.mock_calls) == 1 + assert len(mock_lametric.notify.mock_calls) == 0 assert len(mock_setup_entry.mock_calls) == 0 - mock_lametric_config_flow.device.side_effect = None + mock_lametric.device.side_effect = None result3 = await hass.config_entries.flow.async_configure( flow_id, user_input={CONF_HOST: "127.0.0.1", CONF_API_KEY: "mock-api-key"} ) @@ -596,11 +596,12 @@ async def test_manual_errors( assert "result" in result3 assert result3["result"].unique_id == "SA110405124500W00BS9" - assert len(mock_lametric_config_flow.device.mock_calls) == 2 - assert len(mock_lametric_config_flow.notify.mock_calls) == 1 + assert len(mock_lametric.device.mock_calls) == 2 + assert len(mock_lametric.notify.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("current_request_with_host") @pytest.mark.parametrize( ("side_effect", "reason"), [ @@ -614,10 +615,9 @@ async def test_cloud_errors( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, mock_setup_entry: MagicMock, - mock_lametric_cloud_config_flow: MagicMock, - mock_lametric_config_flow: MagicMock, + mock_lametric_cloud: MagicMock, + mock_lametric: MagicMock, side_effect: Exception, reason: str, ) -> None: @@ -651,7 +651,7 @@ async def test_cloud_errors( ) await hass.config_entries.flow.async_configure(flow_id) - mock_lametric_config_flow.device.side_effect = side_effect + mock_lametric.device.side_effect = side_effect result2 = await hass.config_entries.flow.async_configure( flow_id, user_input={CONF_DEVICE: "SA110405124500W00BS9"} ) @@ -660,12 +660,12 @@ async def test_cloud_errors( assert result2.get("step_id") == "cloud_select_device" assert result2.get("errors") == {"base": reason} - assert len(mock_lametric_cloud_config_flow.devices.mock_calls) == 1 - assert len(mock_lametric_config_flow.device.mock_calls) == 1 - assert len(mock_lametric_config_flow.notify.mock_calls) == 0 + assert len(mock_lametric_cloud.devices.mock_calls) == 1 + assert len(mock_lametric.device.mock_calls) == 1 + assert len(mock_lametric.notify.mock_calls) == 0 assert len(mock_setup_entry.mock_calls) == 0 - mock_lametric_config_flow.device.side_effect = None + mock_lametric.device.side_effect = None result3 = await hass.config_entries.flow.async_configure( flow_id, user_input={CONF_DEVICE: "SA110405124500W00BS9"} ) @@ -680,9 +680,9 @@ async def test_cloud_errors( assert "result" in result3 assert result3["result"].unique_id == "SA110405124500W00BS9" - assert len(mock_lametric_cloud_config_flow.devices.mock_calls) == 1 - assert len(mock_lametric_config_flow.device.mock_calls) == 2 - assert len(mock_lametric_config_flow.notify.mock_calls) == 1 + assert len(mock_lametric_cloud.devices.mock_calls) == 1 + assert len(mock_lametric.device.mock_calls) == 2 + assert len(mock_lametric.notify.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -733,14 +733,13 @@ async def test_dhcp_unknown_device( assert result.get("reason") == "unknown" +@pytest.mark.usefixtures("current_request_with_host", "mock_setup_entry") async def test_reauth_cloud_import( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, - mock_setup_entry: MagicMock, - mock_lametric_cloud_config_flow: MagicMock, - mock_lametric_config_flow: MagicMock, + mock_lametric_cloud: MagicMock, + mock_lametric: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test reauth flow importing api keys from the cloud.""" @@ -792,19 +791,18 @@ async def test_reauth_cloud_import( CONF_MAC: "AA:BB:CC:DD:EE:FF", } - assert len(mock_lametric_cloud_config_flow.devices.mock_calls) == 1 - assert len(mock_lametric_config_flow.device.mock_calls) == 1 - assert len(mock_lametric_config_flow.notify.mock_calls) == 1 + assert len(mock_lametric_cloud.devices.mock_calls) == 1 + assert len(mock_lametric.device.mock_calls) == 1 + assert len(mock_lametric.notify.mock_calls) == 1 +@pytest.mark.usefixtures("current_request_with_host", "mock_setup_entry") async def test_reauth_cloud_abort_device_not_found( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, - mock_setup_entry: MagicMock, - mock_lametric_cloud_config_flow: MagicMock, - mock_lametric_config_flow: MagicMock, + mock_lametric_cloud: MagicMock, + mock_lametric: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test reauth flow importing api keys from the cloud.""" @@ -852,15 +850,15 @@ async def test_reauth_cloud_abort_device_not_found( assert result2.get("type") == FlowResultType.ABORT assert result2.get("reason") == "reauth_device_not_found" - assert len(mock_lametric_cloud_config_flow.devices.mock_calls) == 1 - assert len(mock_lametric_config_flow.device.mock_calls) == 0 - assert len(mock_lametric_config_flow.notify.mock_calls) == 0 + assert len(mock_lametric_cloud.devices.mock_calls) == 1 + assert len(mock_lametric.device.mock_calls) == 0 + assert len(mock_lametric.notify.mock_calls) == 0 +@pytest.mark.usefixtures("mock_setup_entry") async def test_reauth_manual( hass: HomeAssistant, - mock_setup_entry: MagicMock, - mock_lametric_config_flow: MagicMock, + mock_lametric: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test reauth flow with manual entry.""" @@ -894,5 +892,5 @@ async def test_reauth_manual( CONF_MAC: "AA:BB:CC:DD:EE:FF", } - assert len(mock_lametric_config_flow.device.mock_calls) == 1 - assert len(mock_lametric_config_flow.notify.mock_calls) == 1 + assert len(mock_lametric.device.mock_calls) == 1 + assert len(mock_lametric.notify.mock_calls) == 1 diff --git a/tests/components/lametric/test_notify.py b/tests/components/lametric/test_notify.py index 7d43e7ba9b..a94b8f2ce5 100644 --- a/tests/components/lametric/test_notify.py +++ b/tests/components/lametric/test_notify.py @@ -20,14 +20,13 @@ from homeassistant.components.notify import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from tests.common import MockConfigEntry - NOTIFY_SERVICE = "frenck_s_lametric" +pytestmark = pytest.mark.usefixtures("init_integration") + async def test_notification_defaults( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_lametric: MagicMock, ) -> None: """Test the LaMetric notification defaults.""" @@ -64,7 +63,6 @@ async def test_notification_defaults( async def test_notification_options( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_lametric: MagicMock, ) -> None: """Test the LaMetric notification options.""" @@ -107,7 +105,6 @@ async def test_notification_options( async def test_notification_error( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_lametric: MagicMock, ) -> None: """Test the LaMetric notification error.""" diff --git a/tests/components/lametric/test_number.py b/tests/components/lametric/test_number.py index 654ef54851..7e033722c3 100644 --- a/tests/components/lametric/test_number.py +++ b/tests/components/lametric/test_number.py @@ -27,18 +27,16 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("init_integration") async def test_brightness( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_lametric: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test the LaMetric display brightness controls.""" - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - state = hass.states.get("number.frenck_s_lametric_brightness") assert state assert state.attributes.get(ATTR_DEVICE_CLASS) is None @@ -84,13 +82,11 @@ async def test_brightness( async def test_volume( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_lametric: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test the LaMetric volume controls.""" - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - state = hass.states.get("number.frenck_s_lametric_volume") assert state assert state.attributes.get(ATTR_DEVICE_CLASS) is None @@ -135,7 +131,6 @@ async def test_volume( async def test_number_error( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_lametric: MagicMock, ) -> None: """Test error handling of the LaMetric numbers.""" @@ -166,7 +161,6 @@ async def test_number_error( async def test_number_connection_error( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_lametric: MagicMock, ) -> None: """Test connection error handling of the LaMetric numbers.""" diff --git a/tests/components/lametric/test_select.py b/tests/components/lametric/test_select.py index 8a507f556c..4215ffe2de 100644 --- a/tests/components/lametric/test_select.py +++ b/tests/components/lametric/test_select.py @@ -22,18 +22,16 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("init_integration") async def test_brightness_mode( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_lametric: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test the LaMetric brightness mode controls.""" - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - state = hass.states.get("select.frenck_s_lametric_brightness_mode") assert state assert ( @@ -76,7 +74,6 @@ async def test_brightness_mode( async def test_select_error( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_lametric: MagicMock, ) -> None: """Test error handling of the LaMetric selects.""" @@ -107,7 +104,6 @@ async def test_select_error( async def test_select_connection_error( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_lametric: MagicMock, ) -> None: """Test connection error handling of the LaMetric selects.""" diff --git a/tests/components/lametric/test_sensor.py b/tests/components/lametric/test_sensor.py index 3809f405ab..743ed0e8bd 100644 --- a/tests/components/lametric/test_sensor.py +++ b/tests/components/lametric/test_sensor.py @@ -1,5 +1,5 @@ """Tests for the LaMetric sensor platform.""" -from unittest.mock import AsyncMock, MagicMock +import pytest from homeassistant.components.lametric.const import DOMAIN from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass @@ -14,19 +14,17 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures( + "entity_registry_enabled_by_default", "init_integration" +) async def test_wifi_signal( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, - init_integration: MockConfigEntry, - mock_lametric: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test the LaMetric Wi-Fi sensor.""" - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - state = hass.states.get("sensor.frenck_s_lametric_wi_fi_signal") assert state assert state.attributes.get(ATTR_DEVICE_CLASS) is None diff --git a/tests/components/lametric/test_services.py b/tests/components/lametric/test_services.py index 4792db266f..6a6ff4256a 100644 --- a/tests/components/lametric/test_services.py +++ b/tests/components/lametric/test_services.py @@ -29,12 +29,11 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("init_integration") async def test_service_chart( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_lametric: MagicMock, ) -> None: """Test the LaMetric chart service.""" @@ -122,7 +121,6 @@ async def test_service_chart( async def test_service_message( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_lametric: MagicMock, ) -> None: """Test the LaMetric message service.""" diff --git a/tests/components/lametric/test_switch.py b/tests/components/lametric/test_switch.py index 26c38f6aa2..bd4b7856a2 100644 --- a/tests/components/lametric/test_switch.py +++ b/tests/components/lametric/test_switch.py @@ -24,18 +24,18 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er import homeassistant.util.dt as dt_util -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import async_fire_time_changed + +pytestmark = pytest.mark.usefixtures("init_integration") async def test_bluetooth( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_lametric: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test the LaMetric Bluetooth control.""" - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - state = hass.states.get("switch.frenck_s_lametric_bluetooth") assert state assert state.attributes.get(ATTR_DEVICE_CLASS) is None @@ -95,7 +95,6 @@ async def test_bluetooth( async def test_switch_error( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_lametric: MagicMock, ) -> None: """Test error handling of the LaMetric switches.""" @@ -125,7 +124,6 @@ async def test_switch_error( async def test_switch_connection_error( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_lametric: MagicMock, ) -> None: """Test connection error handling of the LaMetric switches.""" From c2b770bcb944ed4c52f9139a76844bede95a3409 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Feb 2023 20:26:38 -0600 Subject: [PATCH 0216/2810] Load pending state attributes and event data ids at startup (#88444) * Load pending state attributes and event data ids at startup Since we queue all events to be processed after startup we can have a thundering herd of queries to prime the LRUs of event data and state attributes ids. Since we know we are about to process a chunk of events we can fetch all the ids in two queries * lru * fix hang * Fix recorder LRU being destroyed if event session is reopened We would clear the LRU in _close_event_session but it would never get replaced with an LRU again so it would leak memory if the event session is reopened * Fix recorder LRU being destroyed if event session is reopened We would clear the LRU in _close_event_session but it would never get replaced with an LRU again so it would leak memory if the event session is reopened * cleanup --- homeassistant/components/recorder/const.py | 2 +- homeassistant/components/recorder/core.py | 129 +++++++++++++++--- .../components/recorder/db_schema.py | 3 + homeassistant/components/recorder/purge.py | 47 ++----- homeassistant/components/recorder/queries.py | 30 +++- .../components/recorder/statistics.py | 16 ++- homeassistant/components/recorder/util.py | 20 ++- tests/components/recorder/test_migrate.py | 2 + tests/components/recorder/test_purge.py | 15 +- 9 files changed, 190 insertions(+), 74 deletions(-) diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index 379185bec7..effecf15a8 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -27,7 +27,7 @@ MAX_QUEUE_BACKLOG = 65000 # in https://github.com/sqlite/sqlite/commit/efdba1a8b3c6c967e7fae9c1989c40d420ce64cc # We can increase this back to 1000 once most # have upgraded their sqlite version -MAX_ROWS_TO_PURGE = 998 +SQLITE_MAX_BIND_VARS = 998 DB_WORKER_PREFIX = "DbWorker" diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 8cae7c5ead..4e099a3b17 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -52,6 +52,7 @@ from .const import ( MAX_QUEUE_BACKLOG, MYSQLDB_PYMYSQL_URL_PREFIX, MYSQLDB_URL_PREFIX, + SQLITE_MAX_BIND_VARS, SQLITE_URL_PREFIX, SupportedDialect, ) @@ -75,7 +76,12 @@ from .models import ( process_timestamp, ) from .pool import POOL_SIZE, MutexPool, RecorderPool -from .queries import find_shared_attributes_id, find_shared_data_id +from .queries import ( + find_shared_attributes_id, + find_shared_data_id, + get_shared_attributes, + get_shared_event_datas, +) from .run_history import RunHistory from .tasks import ( AdjustLRUSizeTask, @@ -98,6 +104,7 @@ from .tasks import ( ) from .util import ( build_mysqldb_conv, + chunked, dburl_to_path, end_incomplete_runs, is_second_sunday, @@ -681,22 +688,93 @@ class Recorder(threading.Thread): self._adjust_lru_size() self.hass.add_job(self._async_set_recorder_ready_migration_done) self._run_event_loop() + self._shutdown() def _run_event_loop(self) -> None: """Run the event loop for the recorder.""" # Use a session for the event read loop # with a commit every time the event time # has changed. This reduces the disk io. + queue_ = self._queue + startup_tasks: list[RecorderTask] = [] + while not queue_.empty() and (task := queue_.get_nowait()): + startup_tasks.append(task) + self._pre_process_startup_tasks(startup_tasks) + for task in startup_tasks: + self._guarded_process_one_task_or_recover(task) + self.stop_requested = False while not self.stop_requested: - task = self._queue.get() - _LOGGER.debug("Processing task: %s", task) - try: - self._process_one_task_or_recover(task) - except Exception as err: # pylint: disable=broad-except - _LOGGER.exception("Error while processing event %s: %s", task, err) + self._guarded_process_one_task_or_recover(queue_.get()) - self._shutdown() + def _pre_process_startup_tasks(self, startup_tasks: list[RecorderTask]) -> None: + """Pre process startup tasks.""" + # Prime all the state_attributes and event_data caches + # before we start processing events + state_change_events: list[Event] = [] + non_state_change_events: list[Event] = [] + + for task in startup_tasks: + if isinstance(task, EventTask): + event_ = task.event + if event_.event_type == EVENT_STATE_CHANGED: + state_change_events.append(event_) + else: + non_state_change_events.append(event_) + + self._pre_process_state_change_events(state_change_events) + self._pre_process_non_state_change_events(non_state_change_events) + + def _pre_process_state_change_events(self, events: list[Event]) -> None: + """Load startup state attributes from the database. + + Since the _state_attributes_ids cache is empty at startup + we restore it from the database to avoid having to look up + the attributes in the database for every state change + until its primed. + """ + assert self.event_session is not None + if hashes := [ + StateAttributes.hash_shared_attrs_bytes(shared_attrs_bytes) + for event in events + if ( + shared_attrs_bytes := self._serialize_state_attributes_from_event(event) + ) + ]: + with self.event_session.no_autoflush: + for hash_chunk in chunked(hashes, SQLITE_MAX_BIND_VARS): + for id_, shared_attrs in self.event_session.execute( + get_shared_attributes(hash_chunk) + ).fetchall(): + self._state_attributes_ids[shared_attrs] = id_ + + def _pre_process_non_state_change_events(self, events: list[Event]) -> None: + """Load startup event attributes from the database. + + Since the _event_data_ids cache is empty at startup + we restore it from the database to avoid having to look up + the data in the database for every event until its primed. + """ + assert self.event_session is not None + if hashes := [ + EventData.hash_shared_data_bytes(shared_event_bytes) + for event in events + if (shared_event_bytes := self._serialize_event_data_from_event(event)) + ]: + with self.event_session.no_autoflush: + for hash_chunk in chunked(hashes, SQLITE_MAX_BIND_VARS): + for id_, shared_data in self.event_session.execute( + get_shared_event_datas(hash_chunk) + ).fetchall(): + self._event_data_ids[shared_data] = id_ + + def _guarded_process_one_task_or_recover(self, task: RecorderTask) -> None: + """Process a task, guarding against exceptions to ensure the loop does not collapse.""" + _LOGGER.debug("Processing task: %s", task) + try: + self._process_one_task_or_recover(task) + except Exception as err: # pylint: disable=broad-except + _LOGGER.exception("Error while processing event %s: %s", task, err) def _process_one_task_or_recover(self, task: RecorderTask) -> None: """Process an event, reconnect, or recover a malformed database.""" @@ -854,6 +932,14 @@ class Recorder(threading.Thread): return cast(int, data_id[0]) return None + def _serialize_event_data_from_event(self, event: Event) -> bytes | None: + """Serialize event data.""" + try: + return EventData.shared_data_bytes_from_event(event, self.dialect_name) + except JSON_ENCODE_EXCEPTIONS as ex: + _LOGGER.warning("Event is not JSON serializable: %s: %s", event, ex) + return None + def _process_non_state_changed_event_into_session(self, event: Event) -> None: """Process any event into the session except state changed.""" assert self.event_session is not None @@ -861,15 +947,8 @@ class Recorder(threading.Thread): if not event.data: self.event_session.add(dbevent) return - - try: - shared_data_bytes = EventData.shared_data_bytes_from_event( - event, self.dialect_name - ) - except JSON_ENCODE_EXCEPTIONS as ex: - _LOGGER.warning("Event is not JSON serializable: %s: %s", event, ex) + if not (shared_data_bytes := self._serialize_event_data_from_event(event)): return - shared_data = shared_data_bytes.decode("utf-8") # Matching attributes found in the pending commit if pending_event_data := self._pending_event_data.get(shared_data): @@ -892,12 +971,10 @@ class Recorder(threading.Thread): self.event_session.add(dbevent) - def _process_state_changed_event_into_session(self, event: Event) -> None: - """Process a state_changed event into the session.""" - assert self.event_session is not None + def _serialize_state_attributes_from_event(self, event: Event) -> bytes | None: + """Serialize state changed event data.""" try: - dbstate = States.from_event(event) - shared_attrs_bytes = StateAttributes.shared_attrs_bytes_from_event( + return StateAttributes.shared_attrs_bytes_from_event( event, self._entity_sources, self._exclude_attributes_by_domain, @@ -909,6 +986,15 @@ class Recorder(threading.Thread): event.data.get("new_state"), ex, ) + return None + + def _process_state_changed_event_into_session(self, event: Event) -> None: + """Process a state_changed event into the session.""" + assert self.event_session is not None + dbstate = States.from_event(event) + if not ( + shared_attrs_bytes := self._serialize_state_attributes_from_event(event) + ): return shared_attrs = shared_attrs_bytes.decode("utf-8") @@ -1256,6 +1342,7 @@ class Recorder(threading.Thread): def _shutdown(self) -> None: """Save end time for current run.""" + _LOGGER.debug("Shutting down recorder") self.hass.add_job(self._async_stop_listeners) self._stop_executor() try: diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 0c3de80c5f..3bdbb0d3c2 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable from datetime import datetime, timedelta +from functools import lru_cache import logging import time from typing import Any, cast @@ -284,6 +285,7 @@ class EventData(Base): return json_bytes(event.data) @staticmethod + @lru_cache def hash_shared_data_bytes(shared_data_bytes: bytes) -> int: """Return the hash of json encoded shared data.""" return cast(int, fnv1a_32(shared_data_bytes)) @@ -492,6 +494,7 @@ class StateAttributes(Base): return bytes_result @staticmethod + @lru_cache(maxsize=2048) def hash_shared_attrs_bytes(shared_attrs_bytes: bytes) -> int: """Return the hash of json encoded shared attributes.""" return cast(int, fnv1a_32(shared_attrs_bytes)) diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index eb6413fa78..7ae63ef026 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -3,10 +3,9 @@ from __future__ import annotations from collections.abc import Callable, Iterable from datetime import datetime -from functools import partial -from itertools import islice, zip_longest +from itertools import zip_longest import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from sqlalchemy.engine.row import Row from sqlalchemy.orm.session import Session @@ -15,7 +14,7 @@ from sqlalchemy.sql.expression import distinct from homeassistant.const import EVENT_STATE_CHANGED import homeassistant.util.dt as dt_util -from .const import MAX_ROWS_TO_PURGE +from .const import SQLITE_MAX_BIND_VARS from .db_schema import Events, StateAttributes, States from .models import DatabaseEngine from .queries import ( @@ -40,7 +39,7 @@ from .queries import ( find_statistics_runs_to_purge, ) from .repack import repack_database -from .util import retryable_database_job, session_scope +from .util import chunked, retryable_database_job, session_scope if TYPE_CHECKING: from . import Recorder @@ -52,22 +51,6 @@ DEFAULT_STATES_BATCHES_PER_PURGE = 20 # We expect ~95% de-dupe rate DEFAULT_EVENTS_BATCHES_PER_PURGE = 15 # We expect ~92% de-dupe rate -def take(take_num: int, iterable: Iterable) -> list[Any]: - """Return first n items of the iterable as a list. - - From itertools recipes - """ - return list(islice(iterable, take_num)) - - -def chunked(iterable: Iterable, chunked_num: int) -> Iterable[Any]: - """Break *iterable* into lists of length *n*. - - From more-itertools - """ - return iter(partial(take, chunked_num, iter(iterable)), []) - - @retryable_database_job("purge") def purge_old_data( instance: Recorder, @@ -86,7 +69,7 @@ def purge_old_data( purge_before.isoformat(sep=" ", timespec="seconds"), ) with session_scope(session=instance.get_session()) as session: - # Purge a max of MAX_ROWS_TO_PURGE, based on the oldest states or events record + # Purge a max of SQLITE_MAX_BIND_VARS, based on the oldest states or events record has_more_to_purge = False if _purging_legacy_format(session): _LOGGER.debug( @@ -174,7 +157,7 @@ def _purge_states_and_attributes_ids( # There are more states relative to attributes_ids so # we purge enough state_ids to try to generate a full # size batch of attributes_ids that will be around the size - # MAX_ROWS_TO_PURGE + # SQLITE_MAX_BIND_VARS attributes_ids_batch: set[int] = set() for _ in range(states_batch_size): state_ids, attributes_ids = _select_state_attributes_ids_to_purge( @@ -208,7 +191,7 @@ def _purge_events_and_data_ids( # There are more events relative to data_ids so # we purge enough event_ids to try to generate a full # size batch of data_ids that will be around the size - # MAX_ROWS_TO_PURGE + # SQLITE_MAX_BIND_VARS data_ids_batch: set[int] = set() for _ in range(events_batch_size): event_ids, data_ids = _select_event_data_ids_to_purge(session, purge_before) @@ -310,7 +293,7 @@ def _select_unused_attributes_ids( # # We used to generate a query based on how many attribute_ids to find but # that meant sqlalchemy Transparent SQL Compilation Caching was working against - # us by cached up to MAX_ROWS_TO_PURGE different statements which could be + # us by cached up to SQLITE_MAX_BIND_VARS different statements which could be # up to 500MB for large database due to the complexity of the ORM objects. # # We now break the query into groups of 100 and use a lambda_stmt to ensure @@ -525,8 +508,8 @@ def _evict_purged_attributes_from_attributes_cache( def _purge_batch_attributes_ids( instance: Recorder, session: Session, attributes_ids: set[int] ) -> None: - """Delete old attributes ids in batches of MAX_ROWS_TO_PURGE.""" - for attributes_ids_chunk in chunked(attributes_ids, MAX_ROWS_TO_PURGE): + """Delete old attributes ids in batches of SQLITE_MAX_BIND_VARS.""" + for attributes_ids_chunk in chunked(attributes_ids, SQLITE_MAX_BIND_VARS): deleted_rows = session.execute( delete_states_attributes_rows(attributes_ids_chunk) ) @@ -539,8 +522,8 @@ def _purge_batch_attributes_ids( def _purge_batch_data_ids( instance: Recorder, session: Session, data_ids: set[int] ) -> None: - """Delete old event data ids in batches of MAX_ROWS_TO_PURGE.""" - for data_ids_chunk in chunked(data_ids, MAX_ROWS_TO_PURGE): + """Delete old event data ids in batches of SQLITE_MAX_BIND_VARS.""" + for data_ids_chunk in chunked(data_ids, SQLITE_MAX_BIND_VARS): deleted_rows = session.execute(delete_event_data_rows(data_ids_chunk)) _LOGGER.debug("Deleted %s data events", deleted_rows) @@ -624,7 +607,7 @@ def _purge_filtered_states( *( session.query(States.state_id, States.attributes_id, States.event_id) .filter(States.entity_id.in_(excluded_entity_ids)) - .limit(MAX_ROWS_TO_PURGE) + .limit(SQLITE_MAX_BIND_VARS) .all() ) ) @@ -650,7 +633,7 @@ def _purge_filtered_events( *( session.query(Events.event_id, Events.data_id) .filter(Events.event_type.in_(excluded_event_types)) - .limit(MAX_ROWS_TO_PURGE) + .limit(SQLITE_MAX_BIND_VARS) .all() ) ) @@ -685,7 +668,7 @@ def purge_entity_data(instance: Recorder, entity_filter: Callable[[str], bool]) ] _LOGGER.debug("Purging entity data for %s", selected_entity_ids) if len(selected_entity_ids) > 0: - # Purge a max of MAX_ROWS_TO_PURGE, based on the oldest states + # Purge a max of SQLITE_MAX_BIND_VARS, based on the oldest states # or events record. _purge_filtered_states( instance, session, selected_entity_ids, database_engine diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py index d12b6409b7..d93a6b0d62 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -8,7 +8,7 @@ from sqlalchemy import delete, distinct, func, lambda_stmt, select, union_all, u from sqlalchemy.sql.lambdas import StatementLambdaElement from sqlalchemy.sql.selectable import Select -from .const import MAX_ROWS_TO_PURGE +from .const import SQLITE_MAX_BIND_VARS from .db_schema import ( EventData, Events, @@ -20,6 +20,24 @@ from .db_schema import ( ) +def get_shared_attributes(hashes: list[int]) -> StatementLambdaElement: + """Load shared attributes from the database.""" + return lambda_stmt( + lambda: select( + StateAttributes.attributes_id, StateAttributes.shared_attrs + ).where(StateAttributes.hash.in_(hashes)) + ) + + +def get_shared_event_datas(hashes: list[int]) -> StatementLambdaElement: + """Load shared event data from the database.""" + return lambda_stmt( + lambda: select(EventData.data_id, EventData.shared_data).where( + EventData.hash.in_(hashes) + ) + ) + + def find_shared_attributes_id( data_hash: int, shared_attrs: str ) -> StatementLambdaElement: @@ -587,7 +605,7 @@ def find_events_to_purge(purge_before: float) -> StatementLambdaElement: return lambda_stmt( lambda: select(Events.event_id, Events.data_id) .filter(Events.time_fired_ts < purge_before) - .limit(MAX_ROWS_TO_PURGE) + .limit(SQLITE_MAX_BIND_VARS) ) @@ -596,7 +614,7 @@ def find_states_to_purge(purge_before: float) -> StatementLambdaElement: return lambda_stmt( lambda: select(States.state_id, States.attributes_id) .filter(States.last_updated_ts < purge_before) - .limit(MAX_ROWS_TO_PURGE) + .limit(SQLITE_MAX_BIND_VARS) ) @@ -608,7 +626,7 @@ def find_short_term_statistics_to_purge( return lambda_stmt( lambda: select(StatisticsShortTerm.id) .filter(StatisticsShortTerm.start_ts < purge_before_ts) - .limit(MAX_ROWS_TO_PURGE) + .limit(SQLITE_MAX_BIND_VARS) ) @@ -619,7 +637,7 @@ def find_statistics_runs_to_purge( return lambda_stmt( lambda: select(StatisticsRuns.run_id) .filter(StatisticsRuns.start < purge_before) - .limit(MAX_ROWS_TO_PURGE) + .limit(SQLITE_MAX_BIND_VARS) ) @@ -640,7 +658,7 @@ def find_legacy_event_state_and_attributes_and_data_ids_to_purge( ) .outerjoin(States, Events.event_id == States.event_id) .filter(Events.time_fired_ts < purge_before) - .limit(MAX_ROWS_TO_PURGE) + .limit(SQLITE_MAX_BIND_VARS) ) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index e0d02e8a0e..fece82b4ff 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -56,7 +56,7 @@ from .const import ( DOMAIN, EVENT_RECORDER_5MIN_STATISTICS_GENERATED, EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, - MAX_ROWS_TO_PURGE, + SQLITE_MAX_BIND_VARS, SupportedDialect, ) from .db_schema import ( @@ -441,7 +441,7 @@ def _find_duplicates( ) .filter(subquery.c.is_duplicate == 1) .order_by(table.metadata_id, table.start, table.id.desc()) - .limit(1000 * MAX_ROWS_TO_PURGE) + .limit(1000 * SQLITE_MAX_BIND_VARS) ) duplicates = execute(query) original_as_dict = {} @@ -505,10 +505,10 @@ def _delete_duplicates_from_table( if not duplicate_ids: break all_non_identical_duplicates.extend(non_identical_duplicates) - for i in range(0, len(duplicate_ids), MAX_ROWS_TO_PURGE): + for i in range(0, len(duplicate_ids), SQLITE_MAX_BIND_VARS): deleted_rows = ( session.query(table) - .filter(table.id.in_(duplicate_ids[i : i + MAX_ROWS_TO_PURGE])) + .filter(table.id.in_(duplicate_ids[i : i + SQLITE_MAX_BIND_VARS])) .delete(synchronize_session=False) ) total_deleted_rows += deleted_rows @@ -584,7 +584,7 @@ def _find_statistics_meta_duplicates(session: Session) -> list[int]: ) .filter(subquery.c.is_duplicate == 1) .order_by(StatisticsMeta.statistic_id, StatisticsMeta.id.desc()) - .limit(1000 * MAX_ROWS_TO_PURGE) + .limit(1000 * SQLITE_MAX_BIND_VARS) ) duplicates = execute(query) statistic_id = None @@ -609,10 +609,12 @@ def _delete_statistics_meta_duplicates(session: Session) -> int: duplicate_ids = _find_statistics_meta_duplicates(session) if not duplicate_ids: break - for i in range(0, len(duplicate_ids), MAX_ROWS_TO_PURGE): + for i in range(0, len(duplicate_ids), SQLITE_MAX_BIND_VARS): deleted_rows = ( session.query(StatisticsMeta) - .filter(StatisticsMeta.id.in_(duplicate_ids[i : i + MAX_ROWS_TO_PURGE])) + .filter( + StatisticsMeta.id.in_(duplicate_ids[i : i + SQLITE_MAX_BIND_VARS]) + ) .delete(synchronize_session=False) ) total_deleted_rows += deleted_rows diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 4db9203009..5cda3d283d 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -1,10 +1,12 @@ """SQLAlchemy util functions.""" from __future__ import annotations -from collections.abc import Callable, Generator, Sequence +from collections.abc import Callable, Generator, Iterable, Sequence from contextlib import contextmanager from datetime import date, datetime, timedelta import functools +from functools import partial +from itertools import islice import logging import os import time @@ -761,3 +763,19 @@ def resolve_period( end_time += offset return (start_time, end_time) + + +def take(take_num: int, iterable: Iterable) -> list[Any]: + """Return first n items of the iterable as a list. + + From itertools recipes + """ + return list(islice(iterable, take_num)) + + +def chunked(iterable: Iterable, chunked_num: int) -> Iterable[Any]: + """Break *iterable* into lists of length *n*. + + From more-itertools + """ + return iter(partial(take, chunked_num, iter(iterable)), []) diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index fce8bc3851..44c3ffac99 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -374,6 +374,8 @@ async def test_schema_migrate( "homeassistant.components.recorder.Recorder._process_state_changed_event_into_session", ), patch( "homeassistant.components.recorder.Recorder._process_non_state_changed_event_into_session", + ), patch( + "homeassistant.components.recorder.Recorder._pre_process_startup_tasks", ): recorder_helper.async_initialize_recorder(hass) hass.async_create_task( diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index a56bcb265a..c5ce8d272c 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -9,7 +9,10 @@ from sqlalchemy.exc import DatabaseError, OperationalError from sqlalchemy.orm.session import Session from homeassistant.components import recorder -from homeassistant.components.recorder.const import MAX_ROWS_TO_PURGE, SupportedDialect +from homeassistant.components.recorder.const import ( + SQLITE_MAX_BIND_VARS, + SupportedDialect, +) from homeassistant.components.recorder.db_schema import ( EventData, Events, @@ -591,7 +594,7 @@ async def test_purge_cutoff_date( service_data = {"keep_days": 2} # Force multiple purge batches to be run - rows = MAX_ROWS_TO_PURGE + 1 + rows = SQLITE_MAX_BIND_VARS + 1 cutoff = dt_util.utcnow() - timedelta(days=service_data["keep_days"]) await _add_db_entries(hass, cutoff, rows) @@ -1548,11 +1551,11 @@ async def test_purge_many_old_events( """Test deleting old events.""" instance = await async_setup_recorder_instance(hass) - await _add_test_events(hass, MAX_ROWS_TO_PURGE) + await _add_test_events(hass, SQLITE_MAX_BIND_VARS) with session_scope(hass=hass) as session: events = session.query(Events).filter(Events.event_type.like("EVENT_TEST%")) - assert events.count() == MAX_ROWS_TO_PURGE * 6 + assert events.count() == SQLITE_MAX_BIND_VARS * 6 purge_before = dt_util.utcnow() - timedelta(days=4) @@ -1565,7 +1568,7 @@ async def test_purge_many_old_events( events_batch_size=3, ) assert not finished - assert events.count() == MAX_ROWS_TO_PURGE * 3 + assert events.count() == SQLITE_MAX_BIND_VARS * 3 # we should only have 2 groups of events left finished = purge_old_data( @@ -1576,7 +1579,7 @@ async def test_purge_many_old_events( events_batch_size=3, ) assert finished - assert events.count() == MAX_ROWS_TO_PURGE * 2 + assert events.count() == SQLITE_MAX_BIND_VARS * 2 # we should now purge everything finished = purge_old_data( From f3c6558db4d834dd3c26b159b2382641810defe9 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 20 Feb 2023 03:27:30 +0100 Subject: [PATCH 0217/2810] Improve conversation typing (#88440) --- homeassistant/components/conversation/default_agent.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index b418275f85..13dec808be 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio from collections import defaultdict -from collections.abc import Iterable, Mapping +from collections.abc import Iterable from dataclasses import dataclass import logging from pathlib import Path @@ -83,7 +83,7 @@ class DefaultAgent(AbstractConversationAgent): # intent -> [sentences] self._config_intents: dict[str, Any] = {} - self._slot_lists: dict[str, TextSlotList] | None = None + self._slot_lists: dict[str, SlotList] | None = None async def async_initialize(self, config_intents): """Initialize the default agent.""" @@ -133,7 +133,7 @@ class DefaultAgent(AbstractConversationAgent): conversation_id, ) - slot_lists: Mapping[str, SlotList] = self._make_slot_lists() + slot_lists = self._make_slot_lists() result = await self.hass.async_add_executor_job( self._recognize, @@ -437,7 +437,7 @@ class DefaultAgent(AbstractConversationAgent): return self._slot_lists = None - def _make_slot_lists(self) -> Mapping[str, SlotList]: + def _make_slot_lists(self) -> dict[str, SlotList]: """Create slot lists with areas and entity names/aliases.""" if self._slot_lists is not None: return self._slot_lists From 79c6dcb3caba7561145d488be1997d5553467bd2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 19 Feb 2023 22:06:01 -0500 Subject: [PATCH 0218/2810] Disable vulcan (#88466) Disable Vulcan integration --- homeassistant/components/vulcan/manifest.json | 1 + requirements_all.txt | 3 --- requirements_test_all.txt | 3 --- 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/homeassistant/components/vulcan/manifest.json b/homeassistant/components/vulcan/manifest.json index fea87480cf..b4771ed04d 100644 --- a/homeassistant/components/vulcan/manifest.json +++ b/homeassistant/components/vulcan/manifest.json @@ -3,6 +3,7 @@ "name": "Uonet+ Vulcan", "codeowners": ["@Antoni-Czaplicki"], "config_flow": true, + "disabled": "Temporarily unblock nightly build", "documentation": "https://www.home-assistant.io/integrations/vulcan", "iot_class": "cloud_polling", "quality_scale": "silver", diff --git a/requirements_all.txt b/requirements_all.txt index 36da5f10ad..bbbb5e30ed 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2594,9 +2594,6 @@ vsure==1.8.1 # homeassistant.components.vasttrafik vtjp==0.1.14 -# homeassistant.components.vulcan -vulcan-api==2.3.0 - # homeassistant.components.vultr vultr==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 825dbc1d25..f5bea952a0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1834,9 +1834,6 @@ volvooncall==0.10.2 # homeassistant.components.verisure vsure==1.8.1 -# homeassistant.components.vulcan -vulcan-api==2.3.0 - # homeassistant.components.vultr vultr==0.1.2 From 69e42d0e4d5f6a157d731e46f9a2155f12c55914 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 19 Feb 2023 23:49:42 -0500 Subject: [PATCH 0219/2810] Fix Vulcan break CI (#88469) * Fix Vulcan break CI * Remove unrelated change * Flake8 ignore --- homeassistant/components/vulcan/__init__.py | 7 ++- homeassistant/components/vulcan/calendar.py | 2 +- .../components/vulcan/config_flow.py | 2 +- homeassistant/components/vulcan/register.py | 2 +- tests/components/vulcan/test_config_flow.py | 44 +++++++++++-------- 5 files changed, 35 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/vulcan/__init__.py b/homeassistant/components/vulcan/__init__.py index 0bfd09d590..829073b557 100644 --- a/homeassistant/components/vulcan/__init__.py +++ b/homeassistant/components/vulcan/__init__.py @@ -1,7 +1,12 @@ """The Vulcan component.""" from aiohttp import ClientConnectorError -from vulcan import Account, Keystore, UnauthorizedCertificateException, Vulcan +from vulcan import ( # pylint: disable=import-error + Account, + Keystore, + UnauthorizedCertificateException, + Vulcan, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform diff --git a/homeassistant/components/vulcan/calendar.py b/homeassistant/components/vulcan/calendar.py index d9182bb990..fa47286fb7 100644 --- a/homeassistant/components/vulcan/calendar.py +++ b/homeassistant/components/vulcan/calendar.py @@ -5,7 +5,7 @@ from datetime import date, datetime, timedelta import logging from aiohttp import ClientConnectorError -from vulcan import UnauthorizedCertificateException +from vulcan import UnauthorizedCertificateException # pylint: disable=import-error from homeassistant.components.calendar import ( ENTITY_ID_FORMAT, diff --git a/homeassistant/components/vulcan/config_flow.py b/homeassistant/components/vulcan/config_flow.py index 4547e6dd31..b92c20ae60 100644 --- a/homeassistant/components/vulcan/config_flow.py +++ b/homeassistant/components/vulcan/config_flow.py @@ -5,7 +5,7 @@ from typing import Any from aiohttp import ClientConnectionError import voluptuous as vol -from vulcan import ( +from vulcan import ( # pylint: disable=import-error Account, ExpiredTokenException, InvalidPINException, diff --git a/homeassistant/components/vulcan/register.py b/homeassistant/components/vulcan/register.py index 67cceb8d7b..c37d4ce9fc 100644 --- a/homeassistant/components/vulcan/register.py +++ b/homeassistant/components/vulcan/register.py @@ -1,6 +1,6 @@ """Support for register Vulcan account.""" -from vulcan import Account, Keystore +from vulcan import Account, Keystore # pylint: disable=import-error async def register(hass, token, symbol, pin): diff --git a/tests/components/vulcan/test_config_flow.py b/tests/components/vulcan/test_config_flow.py index d575fe7360..c68c013a34 100644 --- a/tests/components/vulcan/test_config_flow.py +++ b/tests/components/vulcan/test_config_flow.py @@ -1,33 +1,41 @@ """Test the Uonet+ Vulcan config flow.""" +# flake8: noqa import json from unittest import mock from unittest.mock import patch -from vulcan import ( - Account, - ExpiredTokenException, - InvalidPINException, - InvalidSymbolException, - InvalidTokenException, - UnauthorizedCertificateException, -) -from vulcan.model import Student +import pytest from homeassistant import config_entries, data_entry_flow -from homeassistant.components.vulcan import config_flow, const, register -from homeassistant.components.vulcan.config_flow import ClientConnectionError, Keystore + +# from homeassistant.components.vulcan import config_flow, const, register +# from homeassistant.components.vulcan.config_flow import ClientConnectionError, Keystore from homeassistant.const import CONF_PIN, CONF_REGION, CONF_TOKEN from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture -fake_keystore = Keystore("", "", "", "", "") -fake_account = Account( - login_id=1, - user_login="example@example.com", - user_name="example@example.com", - rest_url="rest_url", -) +# from vulcan import ( +# Account, +# ExpiredTokenException, +# InvalidPINException, +# InvalidSymbolException, +# InvalidTokenException, +# UnauthorizedCertificateException, +# ) +# from vulcan.model import Student + + +pytestmark = pytest.mark.skip + + +# fake_keystore = Keystore("", "", "", "", "") +# fake_account = Account( +# login_id=1, +# user_login="example@example.com", +# user_name="example@example.com", +# rest_url="rest_url", +# ) async def test_show_form(hass: HomeAssistant) -> None: From 488d78571e022e930be0be8b0e73041587d1994f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 20 Feb 2023 08:30:51 +0100 Subject: [PATCH 0220/2810] Add missing device class triggers (#88316) * Add constant tests for sensor device classes * Add missing strings * Adjust tests * Add missing conditions * Add missing trigger * Cleanup --- .../components/sensor/device_condition.py | 9 ++++ .../components/sensor/device_trigger.py | 9 ++++ homeassistant/components/sensor/strings.json | 17 +++++++- .../sensor/test_device_condition.py | 43 ++++++++++++++++++- .../components/sensor/test_device_trigger.py | 41 +++++++++++++++++- 5 files changed, 113 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index ded2eed6dd..5746b8b8e8 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -32,6 +32,7 @@ from . import ATTR_STATE_CLASS, DOMAIN, SensorDeviceClass DEVICE_CLASS_NONE = "none" CONF_IS_APPARENT_POWER = "is_apparent_power" +CONF_IS_AQI = "is_aqi" CONF_IS_ATMOSPHERIC_PRESSURE = "is_atmospheric_pressure" CONF_IS_BATTERY_LEVEL = "is_battery_level" CONF_IS_CO = "is_carbon_monoxide" @@ -40,6 +41,7 @@ CONF_IS_CURRENT = "is_current" CONF_IS_DATA_RATE = "is_data_rate" CONF_IS_DATA_SIZE = "is_data_size" CONF_IS_DISTANCE = "is_distance" +CONF_IS_DURATION = "is_duration" CONF_IS_ENERGY = "is_energy" CONF_IS_FREQUENCY = "is_frequency" CONF_IS_HUMIDITY = "is_humidity" @@ -47,6 +49,7 @@ CONF_IS_GAS = "is_gas" CONF_IS_ILLUMINANCE = "is_illuminance" CONF_IS_IRRADIANCE = "is_irradiance" CONF_IS_MOISTURE = "is_moisture" +CONF_IS_MONETARY = "is_monetary" CONF_IS_NITROGEN_DIOXIDE = "is_nitrogen_dioxide" CONF_IS_NITROGEN_MONOXIDE = "is_nitrogen_monoxide" CONF_IS_NITROUS_OXIDE = "is_nitrous_oxide" @@ -75,6 +78,7 @@ CONF_IS_WIND_SPEED = "is_wind_speed" ENTITY_CONDITIONS = { SensorDeviceClass.APPARENT_POWER: [{CONF_TYPE: CONF_IS_APPARENT_POWER}], + SensorDeviceClass.AQI: [{CONF_TYPE: CONF_IS_AQI}], SensorDeviceClass.ATMOSPHERIC_PRESSURE: [{CONF_TYPE: CONF_IS_ATMOSPHERIC_PRESSURE}], SensorDeviceClass.BATTERY: [{CONF_TYPE: CONF_IS_BATTERY_LEVEL}], SensorDeviceClass.CO: [{CONF_TYPE: CONF_IS_CO}], @@ -83,6 +87,7 @@ ENTITY_CONDITIONS = { SensorDeviceClass.DATA_RATE: [{CONF_TYPE: CONF_IS_DATA_RATE}], SensorDeviceClass.DATA_SIZE: [{CONF_TYPE: CONF_IS_DATA_SIZE}], SensorDeviceClass.DISTANCE: [{CONF_TYPE: CONF_IS_DISTANCE}], + SensorDeviceClass.DURATION: [{CONF_TYPE: CONF_IS_DURATION}], SensorDeviceClass.ENERGY: [{CONF_TYPE: CONF_IS_ENERGY}], SensorDeviceClass.FREQUENCY: [{CONF_TYPE: CONF_IS_FREQUENCY}], SensorDeviceClass.GAS: [{CONF_TYPE: CONF_IS_GAS}], @@ -90,6 +95,7 @@ ENTITY_CONDITIONS = { SensorDeviceClass.ILLUMINANCE: [{CONF_TYPE: CONF_IS_ILLUMINANCE}], SensorDeviceClass.IRRADIANCE: [{CONF_TYPE: CONF_IS_IRRADIANCE}], SensorDeviceClass.MOISTURE: [{CONF_TYPE: CONF_IS_MOISTURE}], + SensorDeviceClass.MONETARY: [{CONF_TYPE: CONF_IS_MONETARY}], SensorDeviceClass.NITROGEN_DIOXIDE: [{CONF_TYPE: CONF_IS_NITROGEN_DIOXIDE}], SensorDeviceClass.NITROGEN_MONOXIDE: [{CONF_TYPE: CONF_IS_NITROGEN_MONOXIDE}], SensorDeviceClass.NITROUS_OXIDE: [{CONF_TYPE: CONF_IS_NITROUS_OXIDE}], @@ -128,6 +134,7 @@ CONDITION_SCHEMA = vol.All( vol.Required(CONF_TYPE): vol.In( [ CONF_IS_APPARENT_POWER, + CONF_IS_AQI, CONF_IS_ATMOSPHERIC_PRESSURE, CONF_IS_BATTERY_LEVEL, CONF_IS_CO, @@ -136,6 +143,7 @@ CONDITION_SCHEMA = vol.All( CONF_IS_DATA_RATE, CONF_IS_DATA_SIZE, CONF_IS_DISTANCE, + CONF_IS_DURATION, CONF_IS_ENERGY, CONF_IS_FREQUENCY, CONF_IS_GAS, @@ -143,6 +151,7 @@ CONDITION_SCHEMA = vol.All( CONF_IS_ILLUMINANCE, CONF_IS_IRRADIANCE, CONF_IS_MOISTURE, + CONF_IS_MONETARY, CONF_IS_NITROGEN_DIOXIDE, CONF_IS_NITROGEN_MONOXIDE, CONF_IS_NITROUS_OXIDE, diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index 721df69cc5..dfd0a576d2 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -31,6 +31,7 @@ from . import ATTR_STATE_CLASS, DOMAIN, SensorDeviceClass DEVICE_CLASS_NONE = "none" CONF_APPARENT_POWER = "apparent_power" +CONF_AQI = "aqi" CONF_ATMOSPHERIC_PRESSURE = "atmospheric_pressure" CONF_BATTERY_LEVEL = "battery_level" CONF_CO = "carbon_monoxide" @@ -39,6 +40,7 @@ CONF_CURRENT = "current" CONF_DATA_RATE = "data_rate" CONF_DATA_SIZE = "data_size" CONF_DISTANCE = "distance" +CONF_DURATION = "duration" CONF_ENERGY = "energy" CONF_FREQUENCY = "frequency" CONF_GAS = "gas" @@ -46,6 +48,7 @@ CONF_HUMIDITY = "humidity" CONF_ILLUMINANCE = "illuminance" CONF_IRRADIANCE = "irradiance" CONF_MOISTURE = "moisture" +CONF_MONETARY = "monetary" CONF_NITROGEN_DIOXIDE = "nitrogen_dioxide" CONF_NITROGEN_MONOXIDE = "nitrogen_monoxide" CONF_NITROUS_OXIDE = "nitrous_oxide" @@ -74,6 +77,7 @@ CONF_WIND_SPEED = "wind_speed" ENTITY_TRIGGERS = { SensorDeviceClass.APPARENT_POWER: [{CONF_TYPE: CONF_APPARENT_POWER}], + SensorDeviceClass.AQI: [{CONF_TYPE: CONF_AQI}], SensorDeviceClass.ATMOSPHERIC_PRESSURE: [{CONF_TYPE: CONF_ATMOSPHERIC_PRESSURE}], SensorDeviceClass.BATTERY: [{CONF_TYPE: CONF_BATTERY_LEVEL}], SensorDeviceClass.CO: [{CONF_TYPE: CONF_CO}], @@ -82,6 +86,7 @@ ENTITY_TRIGGERS = { SensorDeviceClass.DATA_RATE: [{CONF_TYPE: CONF_DATA_RATE}], SensorDeviceClass.DATA_SIZE: [{CONF_TYPE: CONF_DATA_SIZE}], SensorDeviceClass.DISTANCE: [{CONF_TYPE: CONF_DISTANCE}], + SensorDeviceClass.DURATION: [{CONF_TYPE: CONF_DURATION}], SensorDeviceClass.ENERGY: [{CONF_TYPE: CONF_ENERGY}], SensorDeviceClass.FREQUENCY: [{CONF_TYPE: CONF_FREQUENCY}], SensorDeviceClass.GAS: [{CONF_TYPE: CONF_GAS}], @@ -89,6 +94,7 @@ ENTITY_TRIGGERS = { SensorDeviceClass.ILLUMINANCE: [{CONF_TYPE: CONF_ILLUMINANCE}], SensorDeviceClass.IRRADIANCE: [{CONF_TYPE: CONF_IRRADIANCE}], SensorDeviceClass.MOISTURE: [{CONF_TYPE: CONF_MOISTURE}], + SensorDeviceClass.MONETARY: [{CONF_TYPE: CONF_MONETARY}], SensorDeviceClass.NITROGEN_DIOXIDE: [{CONF_TYPE: CONF_NITROGEN_DIOXIDE}], SensorDeviceClass.NITROGEN_MONOXIDE: [{CONF_TYPE: CONF_NITROGEN_MONOXIDE}], SensorDeviceClass.NITROUS_OXIDE: [{CONF_TYPE: CONF_NITROUS_OXIDE}], @@ -128,6 +134,7 @@ TRIGGER_SCHEMA = vol.All( vol.Required(CONF_TYPE): vol.In( [ CONF_APPARENT_POWER, + CONF_AQI, CONF_ATMOSPHERIC_PRESSURE, CONF_BATTERY_LEVEL, CONF_CO, @@ -136,6 +143,7 @@ TRIGGER_SCHEMA = vol.All( CONF_DATA_RATE, CONF_DATA_SIZE, CONF_DISTANCE, + CONF_DURATION, CONF_ENERGY, CONF_FREQUENCY, CONF_GAS, @@ -143,6 +151,7 @@ TRIGGER_SCHEMA = vol.All( CONF_ILLUMINANCE, CONF_IRRADIANCE, CONF_MOISTURE, + CONF_MONETARY, CONF_NITROGEN_DIOXIDE, CONF_NITROGEN_MONOXIDE, CONF_NITROUS_OXIDE, diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 2d53612698..2396bbf295 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -3,6 +3,7 @@ "device_automation": { "condition_type": { "is_apparent_power": "Current {entity_name} apparent power", + "is_aqi": "Current {entity_name} air quality index", "is_atmospheric_pressure": "Current {entity_name} atmospheric pressure", "is_battery_level": "Current {entity_name} battery level", "is_carbon_monoxide": "Current {entity_name} carbon monoxide concentration level", @@ -11,6 +12,7 @@ "is_data_rate": "Current {entity_name} data rate", "is_data_size": "Current {entity_name} data size", "is_distance": "Current {entity_name} distance", + "is_duration": "Current {entity_name} duration", "is_energy": "Current {entity_name} energy", "is_frequency": "Current {entity_name} frequency", "is_gas": "Current {entity_name} gas", @@ -18,6 +20,7 @@ "is_illuminance": "Current {entity_name} illuminance", "is_irradiance": "Current {entity_name} irradiance", "is_moisture": "Current {entity_name} moisture", + "is_monetary": "Current {entity_name} money", "is_nitrogen_dioxide": "Current {entity_name} nitrogen dioxide concentration level", "is_nitrogen_monoxide": "Current {entity_name} nitrogen monoxide concentration level", "is_nitrous_oxide": "Current {entity_name} nitrous oxide concentration level", @@ -27,6 +30,8 @@ "is_pm25": "Current {entity_name} PM2.5 concentration level", "is_power": "Current {entity_name} power", "is_power_factor": "Current {entity_name} power factor", + "is_precipitation": "Current {entity_name} precipitation", + "is_precipitation_intensity": "Current {entity_name} precipitation intensity", "is_pressure": "Current {entity_name} pressure", "is_reactive_power": "Current {entity_name} reactive power", "is_signal_strength": "Current {entity_name} signal strength", @@ -39,17 +44,21 @@ "is_voltage": "Current {entity_name} voltage", "is_volume": "Current {entity_name} volume", "is_water": "Current {entity_name} water", - "is_weight": "Current {entity_name} weight" + "is_weight": "Current {entity_name} weight", + "is_wind_speed": "Current {entity_name} wind speed" }, "trigger_type": { "apparent_power": "{entity_name} apparent power changes", + "aqi": "{entity_name} air quality index changes", "atmospheric_pressure": "{entity_name} atmospheric pressure changes", "battery_level": "{entity_name} battery level changes", "carbon_monoxide": "{entity_name} carbon monoxide concentration changes", "carbon_dioxide": "{entity_name} carbon dioxide concentration changes", "current": "{entity_name} current changes", "data_rate": "{entity_name} data rate changes", + "data_size": "{entity_name} data size changes", "distance": "{entity_name} distance changes", + "duration": "{entity_name} duration changes", "energy": "{entity_name} energy changes", "frequency": "{entity_name} frequency changes", "gas": "{entity_name} gas changes", @@ -57,6 +66,7 @@ "illuminance": "{entity_name} illuminance changes", "irradiance": "{entity_name} irradiance changes", "moisture": "{entity_name} moisture changes", + "monetary": "{entity_name} money changes", "nitrogen_dioxide": "{entity_name} nitrogen dioxide concentration changes", "nitrogen_monoxide": "{entity_name} nitrogen monoxide concentration changes", "nitrous_oxide": "{entity_name} nitrous oxide concentration changes", @@ -66,6 +76,8 @@ "pm25": "{entity_name} PM2.5 concentration changes", "power": "{entity_name} power changes", "power_factor": "{entity_name} power factor changes", + "precipitation": "{entity_name} precipitation changes", + "precipitation_intensity": "{entity_name} precipitation intensity changes", "pressure": "{entity_name} pressure changes", "reactive_power": "{entity_name} reactive power changes", "signal_strength": "{entity_name} signal strength changes", @@ -78,7 +90,8 @@ "voltage": "{entity_name} voltage changes", "volume": "{entity_name} volume changes", "water": "{entity_name} water changes", - "weight": "{entity_name} weight changes" + "weight": "{entity_name} weight changes", + "wind_speed": "{entity_name} wind speed changes" } }, "state": { diff --git a/tests/components/sensor/test_device_condition.py b/tests/components/sensor/test_device_condition.py index c008acf7d0..02b369b6a6 100644 --- a/tests/components/sensor/test_device_condition.py +++ b/tests/components/sensor/test_device_condition.py @@ -8,13 +8,16 @@ from homeassistant.components.sensor import ( DOMAIN, SensorDeviceClass, SensorStateClass, + device_condition, ) +from homeassistant.components.sensor.const import NON_NUMERIC_DEVICE_CLASSES from homeassistant.components.sensor.device_condition import ENTITY_CONDITIONS from homeassistant.const import CONF_PLATFORM, PERCENTAGE, STATE_UNKNOWN, EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component +from homeassistant.util.json import load_json from tests.common import ( MockConfigEntry, @@ -28,11 +31,47 @@ from tests.testing_config.custom_components.test.sensor import UNITS_OF_MEASUREM @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") +@pytest.mark.parametrize( + "device_class", + [ + device_class + for device_class in SensorDeviceClass + if device_class not in NON_NUMERIC_DEVICE_CLASSES + ], +) +def test_matches_device_classes(device_class: SensorDeviceClass) -> None: + """Ensure device class constants are declared in device_condition module.""" + # Ensure it has corresponding CONF_IS_*** constant + constant_name = { + SensorDeviceClass.BATTERY: "CONF_IS_BATTERY_LEVEL", + SensorDeviceClass.CO: "CONF_IS_CO", + SensorDeviceClass.CO2: "CONF_IS_CO2", + }.get(device_class, f"CONF_IS_{device_class.value.upper()}") + assert hasattr(device_condition, constant_name), f"Missing constant {constant_name}" + + # Ensure it has correct value + constant_value = { + SensorDeviceClass.BATTERY: "is_battery_level", + }.get(device_class, f"is_{device_class.value}") + assert getattr(device_condition, constant_name) == constant_value + + # Ensure it is present in ENTITY_CONDITIONS + assert device_class in ENTITY_CONDITIONS + # Ensure it is present in CONDITION_SCHEMA + schema_types = ( + device_condition.CONDITION_SCHEMA.validators[0].schema["type"].container + ) + assert constant_value in schema_types + # Ensure it is present in string.json + strings = load_json("homeassistant/components/sensor/strings.json") + assert constant_value in strings["device_automation"]["condition_type"] + + async def test_get_conditions( hass: HomeAssistant, device_registry: dr.DeviceRegistry, diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index 56133353b4..298aab2953 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -10,14 +10,17 @@ from homeassistant.components.sensor import ( DOMAIN, SensorDeviceClass, SensorStateClass, + device_trigger, ) +from homeassistant.components.sensor.const import NON_NUMERIC_DEVICE_CLASSES from homeassistant.components.sensor.device_trigger import ENTITY_TRIGGERS from homeassistant.const import CONF_PLATFORM, PERCENTAGE, STATE_UNKNOWN, EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util +from homeassistant.util.json import load_json from tests.common import ( MockConfigEntry, @@ -32,11 +35,45 @@ from tests.testing_config.custom_components.test.sensor import UNITS_OF_MEASUREM @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") +@pytest.mark.parametrize( + "device_class", + [ + device_class + for device_class in SensorDeviceClass + if device_class not in NON_NUMERIC_DEVICE_CLASSES + ], +) +def test_matches_device_classes(device_class: SensorDeviceClass) -> None: + """Ensure device class constants are declared in device_trigger module.""" + # Ensure it has corresponding CONF_*** constant + constant_name = { + SensorDeviceClass.BATTERY: "CONF_BATTERY_LEVEL", + SensorDeviceClass.CO: "CONF_CO", + SensorDeviceClass.CO2: "CONF_CO2", + }.get(device_class, f"CONF_{device_class.value.upper()}") + assert hasattr(device_trigger, constant_name), f"Missing constant {constant_name}" + + # Ensure it has correct value + constant_value = { + SensorDeviceClass.BATTERY: "battery_level", + }.get(device_class, device_class.value) + assert getattr(device_trigger, constant_name) == constant_value + + # Ensure it is present in ENTITY_TRIGGERS + assert device_class in ENTITY_TRIGGERS + # Ensure it is present in TRIGGER_SCHEMA + schema_types = device_trigger.TRIGGER_SCHEMA.validators[0].schema["type"].container + assert constant_value in schema_types + # Ensure it is present in string.json + strings = load_json("homeassistant/components/sensor/strings.json") + assert constant_value in strings["device_automation"]["trigger_type"] + + async def test_get_triggers( hass: HomeAssistant, device_registry: dr.DeviceRegistry, From b11539fb0415c1636ddd3a453e56a44ca6b0bc27 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 20 Feb 2023 08:32:47 +0100 Subject: [PATCH 0221/2810] Move connect method into a task (#88452) Move connect() into a task. --- homeassistant/components/modbus/modbus.py | 15 ++++++++++----- tests/components/modbus/test_init.py | 2 +- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index fb30d24585..627950fe00 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -310,6 +310,13 @@ class ModbusHub: _LOGGER.error(log_text) self._in_error = error_state + async def async_pymodbus_connect(self) -> None: + """Connect to device, async.""" + async with self._lock: + if not await self.hass.async_add_executor_job(self._pymodbus_connect): + err = f"{self.name} connect failed, retry in pymodbus" + self._log_error(err, error_state=False) + async def async_setup(self) -> bool: """Set up pymodbus client.""" try: @@ -322,11 +329,9 @@ class ModbusHub: func = getattr(self._client, entry.func_name) self._pb_call[entry.call_type] = RunEntry(entry.attr, func) - async with self._lock: - if not await self.hass.async_add_executor_job(self._pymodbus_connect): - err = f"{self.name} connect failed, retry in pymodbus" - self._log_error(err, error_state=False) - return False + self.hass.async_create_background_task( + self.async_pymodbus_connect(), "modbus-connect" + ) # Start counting down to allow modbus requests. if self._config_delay: diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index d9d2e0055f..75f2f9d3e6 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -704,7 +704,7 @@ async def test_pymodbus_connect_fail( caplog.set_level(logging.WARNING) ExceptionMessage = "test connect exception" mock_pymodbus.connect.side_effect = ModbusException(ExceptionMessage) - assert await async_setup_component(hass, DOMAIN, config) is False + assert await async_setup_component(hass, DOMAIN, config) is True assert ExceptionMessage in caplog.text From b78318a6176ae11221a626d3e555375828330237 Mon Sep 17 00:00:00 2001 From: Alan Edwardes Date: Mon, 20 Feb 2023 08:20:46 +0000 Subject: [PATCH 0222/2810] Remove Audio Content Restrictions From vlc_telnet (#86821) --- homeassistant/components/vlc_telnet/media_player.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/homeassistant/components/vlc_telnet/media_player.py b/homeassistant/components/vlc_telnet/media_player.py index a8e12cd771..80b9d75303 100644 --- a/homeassistant/components/vlc_telnet/media_player.py +++ b/homeassistant/components/vlc_telnet/media_player.py @@ -21,7 +21,6 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import SOURCE_HASSIO, ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -293,14 +292,8 @@ class VlcDevice(MediaPlayerEntity): sourced_media = await media_source.async_resolve_media( self.hass, media_id, self.entity_id ) - media_type = sourced_media.mime_type media_id = sourced_media.url - if media_type != MediaType.MUSIC and not media_type.startswith("audio/"): - raise HomeAssistantError( - f"Invalid media type {media_type}. Only {MediaType.MUSIC} is supported" - ) - # If media ID is a relative URL, we serve it from HA. media_id = async_process_play_media_url( self.hass, media_id, for_supervisor_network=self._using_addon @@ -336,8 +329,4 @@ class VlcDevice(MediaPlayerEntity): media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" - return await media_source.async_browse_media( - self.hass, - media_content_id, - content_filter=lambda item: item.media_content_type.startswith("audio/"), - ) + return await media_source.async_browse_media(self.hass, media_content_id) From ba2e80f741100ee1b3e74589d46793e75cefa665 Mon Sep 17 00:00:00 2001 From: Yuxin Wang Date: Mon, 20 Feb 2023 03:51:01 -0500 Subject: [PATCH 0223/2810] Add more tests for APC UPS Daemon integration (#85967) * Add tests for init. * Add more test init. * Fix test init side_effect. * Add test sensor. * Fix sensor test file name. * Fix sensor test. * Add binary sensor test. * Fix comments and styling. * Remove apcupsd from omissions in coveragerc. * Add a test case for binary sensor when STATFLAG is not available. * Complete type annotations for test files. * Revert "Remove apcupsd from omissions in coveragerc." This reverts commit 66b05fcb8829619a771a650a3d70174089e15d91. --- tests/components/apcupsd/__init__.py | 33 ++++++ .../components/apcupsd/test_binary_sensor.py | 28 +++++ tests/components/apcupsd/test_init.py | 101 ++++++++++++++++++ tests/components/apcupsd/test_sensor.py | 98 +++++++++++++++++ 4 files changed, 260 insertions(+) create mode 100644 tests/components/apcupsd/test_binary_sensor.py create mode 100644 tests/components/apcupsd/test_init.py create mode 100644 tests/components/apcupsd/test_sensor.py diff --git a/tests/components/apcupsd/__init__.py b/tests/components/apcupsd/__init__.py index f6df277b37..64547822b4 100644 --- a/tests/components/apcupsd/__init__.py +++ b/tests/components/apcupsd/__init__.py @@ -1,8 +1,14 @@ """Tests for the APCUPSd component.""" from collections import OrderedDict from typing import Final +from unittest.mock import patch +from homeassistant.components.apcupsd import DOMAIN +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry CONF_DATA: Final = {CONF_HOST: "test", CONF_PORT: 1234} @@ -64,3 +70,30 @@ MOCK_MINIMAL_STATUS: Final = OrderedDict( ("END APC", "1970-01-01 00:00:00 0000"), ] ) + + +async def init_integration( + hass: HomeAssistant, host: str = "test", status=None +) -> MockConfigEntry: + """Set up the APC UPS Daemon integration in HomeAssistant.""" + if status is None: + status = MOCK_STATUS + + entry = MockConfigEntry( + version=1, + domain=DOMAIN, + title="APCUPSd", + data=CONF_DATA | {CONF_HOST: host}, + unique_id=status.get("SERIALNO", None), + source=SOURCE_USER, + ) + + entry.add_to_hass(hass) + + with patch("apcaccess.status.parse", return_value=status), patch( + "apcaccess.status.get", return_value=b"" + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/apcupsd/test_binary_sensor.py b/tests/components/apcupsd/test_binary_sensor.py new file mode 100644 index 0000000000..c00707b7ff --- /dev/null +++ b/tests/components/apcupsd/test_binary_sensor.py @@ -0,0 +1,28 @@ +"""Test binary sensors of APCUPSd integration.""" +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import MOCK_STATUS, init_integration + + +async def test_binary_sensor(hass: HomeAssistant) -> None: + """Test states of binary sensor.""" + await init_integration(hass, status=MOCK_STATUS) + registry = er.async_get(hass) + + state = hass.states.get("binary_sensor.ups_online_status") + assert state + assert state.state == "on" + entry = registry.async_get("binary_sensor.ups_online_status") + assert entry + assert entry.unique_id == "XXXXXXXXXXXX_statflag" + + +async def test_no_binary_sensor(hass: HomeAssistant) -> None: + """Test binary sensor when STATFLAG is not available.""" + status = MOCK_STATUS.copy() + status.pop("STATFLAG") + await init_integration(hass, status=status) + + state = hass.states.get("binary_sensor.ups_online_status") + assert state is None diff --git a/tests/components/apcupsd/test_init.py b/tests/components/apcupsd/test_init.py new file mode 100644 index 0000000000..8f2e7901c5 --- /dev/null +++ b/tests/components/apcupsd/test_init.py @@ -0,0 +1,101 @@ +"""Test init of APCUPSd integration.""" +from collections import OrderedDict +from unittest.mock import patch + +import pytest + +from homeassistant.components.apcupsd import DOMAIN, APCUPSdData +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from . import CONF_DATA, MOCK_MINIMAL_STATUS, MOCK_STATUS, init_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize("status", (MOCK_STATUS, MOCK_MINIMAL_STATUS)) +async def test_async_setup_entry(hass: HomeAssistant, status: OrderedDict) -> None: + """Test a successful setup entry.""" + # Minimal status does not contain "SERIALNO" field, which is used to determine the + # unique ID of this integration. But, the integration should work fine without it. + await init_integration(hass, status=status) + + # Verify successful setup by querying the status sensor. + state = hass.states.get("binary_sensor.ups_online_status") + assert state is not None + assert state.state != STATE_UNAVAILABLE + assert state.state == "on" + + +async def test_multiple_integrations(hass: HomeAssistant) -> None: + """Test successful setup for multiple entries.""" + # Load two integrations from two mock hosts. + entries = ( + await init_integration(hass, host="test1", status=MOCK_STATUS), + await init_integration(hass, host="test2", status=MOCK_MINIMAL_STATUS), + ) + + # Data dict should contain different API objects. + assert len(hass.data[DOMAIN]) == len(entries) + for entry in entries: + assert entry.entry_id in hass.data[DOMAIN] + assert isinstance(hass.data[DOMAIN][entry.entry_id], APCUPSdData) + + assert ( + hass.data[DOMAIN][entries[0].entry_id] != hass.data[DOMAIN][entries[1].entry_id] + ) + + +async def test_connection_error(hass: HomeAssistant) -> None: + """Test connection error during integration setup.""" + entry = MockConfigEntry( + version=1, + domain=DOMAIN, + title="APCUPSd", + data=CONF_DATA, + source=SOURCE_USER, + ) + + entry.add_to_hass(hass) + + with patch("apcaccess.status.parse", side_effect=OSError()), patch( + "apcaccess.status.get" + ): + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_unload_remove(hass): + """Test successful unload of entry.""" + # Load two integrations from two mock hosts. + entries = ( + await init_integration(hass, host="test1", status=MOCK_STATUS), + await init_integration(hass, host="test2", status=MOCK_MINIMAL_STATUS), + ) + + # Assert they are loaded. + assert len(hass.config_entries.async_entries(DOMAIN)) == 2 + assert all(entry.state is ConfigEntryState.LOADED for entry in entries) + + # Unload the first entry. + assert await hass.config_entries.async_unload(entries[0].entry_id) + await hass.async_block_till_done() + assert entries[0].state is ConfigEntryState.NOT_LOADED + assert entries[1].state is ConfigEntryState.LOADED + assert len(hass.data[DOMAIN]) == 1 + + # Unload the second entry. + assert await hass.config_entries.async_unload(entries[1].entry_id) + await hass.async_block_till_done() + assert all(entry.state is ConfigEntryState.NOT_LOADED for entry in entries) + + # We should never leave any garbage in the data dict. + assert len(hass.data[DOMAIN]) == 0 + + # Remove both entries. + for entry in entries: + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + state = hass.states.get(entry.entry_id) + assert state is None diff --git a/tests/components/apcupsd/test_sensor.py b/tests/components/apcupsd/test_sensor.py new file mode 100644 index 0000000000..bf797db7db --- /dev/null +++ b/tests/components/apcupsd/test_sensor.py @@ -0,0 +1,98 @@ +"""Test sensors of APCUPSd integration.""" +import pytest + +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, + UnitOfElectricPotential, + UnitOfPower, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import MOCK_STATUS, init_integration + + +async def test_sensor(hass: HomeAssistant) -> None: + """Test states of sensor.""" + await init_integration(hass, status=MOCK_STATUS) + registry = er.async_get(hass) + + # Test a representative string sensor. + state = hass.states.get("sensor.ups_mode") + assert state + assert state.state == "Stand Alone" + entry = registry.async_get("sensor.ups_mode") + assert entry + assert entry.unique_id == "XXXXXXXXXXXX_upsmode" + + # Test two representative voltage sensors. + state = hass.states.get("sensor.ups_input_voltage") + assert state + assert pytest.approx(float(state.state)) == 124.0 + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfElectricPotential.VOLT + ) + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.VOLTAGE + entry = registry.async_get("sensor.ups_input_voltage") + assert entry + assert entry.unique_id == "XXXXXXXXXXXX_linev" + + state = hass.states.get("sensor.ups_battery_voltage") + assert state + assert pytest.approx(float(state.state)) == 13.7 + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfElectricPotential.VOLT + ) + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.VOLTAGE + entry = registry.async_get("sensor.ups_battery_voltage") + assert entry + assert entry.unique_id == "XXXXXXXXXXXX_battv" + + # Test a representative percentage sensor. + state = hass.states.get("sensor.ups_load") + assert state + assert pytest.approx(float(state.state)) == 14.0 + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + entry = registry.async_get("sensor.ups_load") + assert entry + assert entry.unique_id == "XXXXXXXXXXXX_loadpct" + + # Test a representative wattage sensor. + state = hass.states.get("sensor.ups_nominal_output_power") + assert state + assert pytest.approx(float(state.state)) == 330.0 + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER + entry = registry.async_get("sensor.ups_nominal_output_power") + assert entry + assert entry.unique_id == "XXXXXXXXXXXX_nompower" + + +async def test_sensor_disabled(hass: HomeAssistant) -> None: + """Test sensor disabled by default.""" + await init_integration(hass) + registry = er.async_get(hass) + + # Test a representative integration-disabled sensor. + entry = registry.async_get("sensor.ups_model") + assert entry.disabled + assert entry.unique_id == "XXXXXXXXXXXX_model" + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + # Test enabling entity. + updated_entry = registry.async_update_entity( + entry.entity_id, **{"disabled_by": None} + ) + + assert updated_entry != entry + assert updated_entry.disabled is False From 6da524b3785b0df47980d70abd6f9f7e43f3c172 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 20 Feb 2023 21:54:40 +1300 Subject: [PATCH 0224/2810] ESPHome: Add additional field for current temperature precision (#88464) --- homeassistant/components/esphome/climate.py | 8 ++++++-- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index 058e557b1a..991ad9d3b1 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -142,8 +142,12 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti def precision(self) -> float: """Return the precision of the climate device.""" precicions = [PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS] + if self._static_info.visual_current_temperature_step != 0: + step = self._static_info.visual_current_temperature_step + else: + step = self._static_info.visual_target_temperature_step for prec in precicions: - if self._static_info.visual_temperature_step >= prec: + if step >= prec: return prec # Fall back to highest precision, tenths return PRECISION_TENTHS @@ -184,7 +188,7 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti def target_temperature_step(self) -> float: """Return the supported step of target temperature.""" # Round to one digit because of floating point math - return round(self._static_info.visual_temperature_step, 1) + return round(self._static_info.visual_target_temperature_step, 1) @property def min_temp(self) -> float: diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index dbb089acba..94b111ab42 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.3.1", "esphome-dashboard-api==1.2.3"], + "requirements": ["aioesphomeapi==13.4.0", "esphome-dashboard-api==1.2.3"], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index bbbb5e30ed..88615181e3 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.3.1 +aioesphomeapi==13.4.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f5bea952a0..57ad98c58b 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.3.1 +aioesphomeapi==13.4.0 # homeassistant.components.flo aioflo==2021.11.0 From 26755a684129bd7c1c0b238b2e3adbf670f4ec9e Mon Sep 17 00:00:00 2001 From: majuss Date: Mon, 20 Feb 2023 10:22:52 +0100 Subject: [PATCH 0225/2810] Bump lupupy to 0.3.0 (#88473) * Bumped lupupy to support XT2 and up * requirements script * Bump lupupy to 0.3.0 --- homeassistant/components/lupusec/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lupusec/manifest.json b/homeassistant/components/lupusec/manifest.json index ee5760a233..6fa6c55de2 100644 --- a/homeassistant/components/lupusec/manifest.json +++ b/homeassistant/components/lupusec/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/lupusec", "iot_class": "local_polling", "loggers": ["lupupy"], - "requirements": ["lupupy==0.2.8"] + "requirements": ["lupupy==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 88615181e3..1135cfa516 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1081,7 +1081,7 @@ london-tube-status==0.5 luftdaten==0.7.4 # homeassistant.components.lupusec -lupupy==0.2.8 +lupupy==0.3.0 # homeassistant.components.lw12wifi lw12==0.9.2 From 5f25b71df77fd488eed57bf143c3698721c9fc96 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 20 Feb 2023 11:42:56 +0100 Subject: [PATCH 0226/2810] Add type hints to core tests (#88478) --- tests/auth/test_auth_store.py | 17 +- tests/auth/test_init.py | 50 +++-- tests/hassfest/test_dependencies.py | 14 +- tests/hassfest/test_requirements.py | 12 +- tests/hassfest/test_version.py | 4 +- tests/helpers/test_aiohttp_client.py | 25 ++- tests/helpers/test_area_registry.py | 60 +++-- tests/helpers/test_collection.py | 5 +- tests/helpers/test_condition.py | 49 ++++- tests/helpers/test_config_entry_flow.py | 49 +++-- .../helpers/test_config_entry_oauth2_flow.py | 93 ++++---- tests/helpers/test_config_validation.py | 34 ++- tests/helpers/test_deprecation.py | 12 +- tests/helpers/test_device_registry.py | 113 +++++++--- tests/helpers/test_discovery.py | 4 +- tests/helpers/test_discovery_flow.py | 17 +- tests/helpers/test_dispatcher.py | 4 +- tests/helpers/test_entity.py | 33 ++- tests/helpers/test_entity_component.py | 14 +- tests/helpers/test_entity_platform.py | 54 +++-- tests/helpers/test_entity_registry.py | 125 +++++++---- tests/helpers/test_event.py | 36 +-- tests/helpers/test_frame.py | 12 +- tests/helpers/test_httpx_client.py | 9 +- tests/helpers/test_instance_id.py | 8 +- tests/helpers/test_intent.py | 1 - tests/helpers/test_issue_registry.py | 8 +- tests/helpers/test_network.py | 18 +- tests/helpers/test_reload.py | 4 +- tests/helpers/test_restore_state.py | 5 +- .../helpers/test_schema_config_entry_flow.py | 4 +- tests/helpers/test_script.py | 206 +++++++++++++----- tests/helpers/test_selector.py | 68 +++--- tests/helpers/test_service.py | 78 ++++--- tests/helpers/test_significant_change.py | 6 +- tests/helpers/test_singleton.py | 4 +- tests/helpers/test_start.py | 26 ++- tests/helpers/test_storage.py | 91 +++++--- tests/helpers/test_storage_remove.py | 2 +- tests/helpers/test_template.py | 30 +-- tests/helpers/test_translation.py | 28 ++- tests/helpers/test_trigger.py | 2 +- tests/helpers/test_update_coordinator.py | 36 +-- tests/scripts/test_auth.py | 20 +- tests/scripts/test_check_config.py | 12 +- tests/scripts/test_init.py | 2 +- tests/util/test_async.py | 16 +- tests/util/test_distance.py | 16 +- tests/util/test_dt.py | 4 +- tests/util/test_executor.py | 14 +- tests/util/test_file.py | 14 +- tests/util/test_init.py | 2 +- tests/util/test_location.py | 9 +- tests/util/test_logging.py | 4 +- tests/util/test_package.py | 18 +- tests/util/test_speed.py | 2 +- tests/util/test_timeout.py | 8 +- tests/util/test_variance.py | 2 +- tests/util/test_volume.py | 2 +- 59 files changed, 1068 insertions(+), 547 deletions(-) diff --git a/tests/auth/test_auth_store.py b/tests/auth/test_auth_store.py index 8250d94f6d..860abe7657 100644 --- a/tests/auth/test_auth_store.py +++ b/tests/auth/test_auth_store.py @@ -1,12 +1,15 @@ """Tests for the auth store.""" import asyncio +from typing import Any from unittest.mock import patch from homeassistant.auth import auth_store from homeassistant.core import HomeAssistant -async def test_loading_no_group_data_format(hass, hass_storage): +async def test_loading_no_group_data_format( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: """Test we correctly load old data without any groups.""" hass_storage[auth_store.STORAGE_KEY] = { "version": 1, @@ -99,7 +102,9 @@ async def test_loading_no_group_data_format(hass, hass_storage): assert system_token.version is None -async def test_loading_all_access_group_data_format(hass, hass_storage): +async def test_loading_all_access_group_data_format( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: """Test we correctly load old data with single group.""" hass_storage[auth_store.STORAGE_KEY] = { "version": 1, @@ -195,7 +200,9 @@ async def test_loading_all_access_group_data_format(hass, hass_storage): assert system_token.version is None -async def test_loading_empty_data(hass, hass_storage): +async def test_loading_empty_data( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: """Test we correctly load with no existing data.""" store = auth_store.AuthStore(hass) groups = await store.async_get_groups() @@ -217,7 +224,9 @@ async def test_loading_empty_data(hass, hass_storage): assert len(users) == 0 -async def test_system_groups_store_id_and_name(hass, hass_storage): +async def test_system_groups_store_id_and_name( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: """Test that for system groups we store the ID and name. Name is stored so that we remain backwards compat with < 0.82. diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index 0f602a0e54..3d3674e137 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -1,5 +1,6 @@ """Tests for the Home Assistant auth module.""" from datetime import timedelta +from typing import Any from unittest.mock import Mock, patch import jwt @@ -15,6 +16,7 @@ from homeassistant.auth import ( models as auth_models, ) from homeassistant.auth.const import GROUP_ID_ADMIN, MFA_SESSION_EXPIRATION +from homeassistant.auth.models import Credentials from homeassistant.core import HomeAssistant, callback from homeassistant.util import dt as dt_util @@ -35,7 +37,7 @@ def mock_hass(event_loop): return hass -async def test_auth_manager_from_config_validates_config(mock_hass): +async def test_auth_manager_from_config_validates_config(mock_hass) -> None: """Test get auth providers.""" with pytest.raises(vol.Invalid): manager = await auth.auth_manager_from_config( @@ -76,7 +78,7 @@ async def test_auth_manager_from_config_validates_config(mock_hass): ] -async def test_auth_manager_from_config_auth_modules(mock_hass): +async def test_auth_manager_from_config_auth_modules(mock_hass) -> None: """Test get auth modules.""" with pytest.raises(vol.Invalid): manager = await auth.auth_manager_from_config( @@ -187,7 +189,7 @@ async def test_create_new_user(hass: HomeAssistant) -> None: assert events[0].data["user_id"] == user.id -async def test_login_as_existing_user(mock_hass): +async def test_login_as_existing_user(mock_hass) -> None: """Test login as existing user.""" manager = await auth.auth_manager_from_config( mock_hass, @@ -253,7 +255,9 @@ async def test_login_as_existing_user(mock_hass): assert user.name == "Paulus" -async def test_linking_user_to_two_auth_providers(hass, hass_storage): +async def test_linking_user_to_two_auth_providers( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: """Test linking user to two auth providers.""" manager = await auth.auth_manager_from_config( hass, @@ -300,7 +304,9 @@ async def test_linking_user_to_two_auth_providers(hass, hass_storage): assert len(user_2.credentials) == 0 -async def test_saving_loading(hass, hass_storage): +async def test_saving_loading( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: """Test storing and saving data. Creates one of each type that we store to test we restore correctly. @@ -506,7 +512,7 @@ async def test_refresh_token_type_long_lived_access_token(hass: HomeAssistant) - assert token.token_type == auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN -async def test_refresh_token_provider_validation(mock_hass): +async def test_refresh_token_provider_validation(mock_hass) -> None: """Test that creating access token from refresh token checks with provider.""" manager = await auth.auth_manager_from_config( mock_hass, @@ -545,7 +551,7 @@ async def test_refresh_token_provider_validation(mock_hass): call.assert_called_with(refresh_token, ip) -async def test_cannot_deactive_owner(mock_hass): +async def test_cannot_deactive_owner(mock_hass) -> None: """Test that we cannot deactivate the owner.""" manager = await auth.auth_manager_from_config(mock_hass, [], []) owner = MockUser(is_owner=True).add_to_auth_manager(manager) @@ -554,7 +560,7 @@ async def test_cannot_deactive_owner(mock_hass): await manager.async_deactivate_user(owner) -async def test_remove_refresh_token(mock_hass): +async def test_remove_refresh_token(mock_hass) -> None: """Test that we can remove a refresh token.""" manager = await auth.auth_manager_from_config(mock_hass, [], []) user = MockUser().add_to_auth_manager(manager) @@ -567,7 +573,7 @@ async def test_remove_refresh_token(mock_hass): assert await manager.async_validate_access_token(access_token) is None -async def test_register_revoke_token_callback(mock_hass): +async def test_register_revoke_token_callback(mock_hass) -> None: """Test that a registered revoke token callback is called.""" manager = await auth.auth_manager_from_config(mock_hass, [], []) user = MockUser().add_to_auth_manager(manager) @@ -584,7 +590,7 @@ async def test_register_revoke_token_callback(mock_hass): assert called -async def test_unregister_revoke_token_callback(mock_hass): +async def test_unregister_revoke_token_callback(mock_hass) -> None: """Test that a revoke token callback can be unregistered.""" manager = await auth.auth_manager_from_config(mock_hass, [], []) user = MockUser().add_to_auth_manager(manager) @@ -603,7 +609,7 @@ async def test_unregister_revoke_token_callback(mock_hass): assert not called -async def test_create_access_token(mock_hass): +async def test_create_access_token(mock_hass) -> None: """Test normal refresh_token's jwt_key keep same after used.""" manager = await auth.auth_manager_from_config(mock_hass, [], []) user = MockUser().add_to_auth_manager(manager) @@ -620,7 +626,7 @@ async def test_create_access_token(mock_hass): ) -async def test_create_long_lived_access_token(mock_hass): +async def test_create_long_lived_access_token(mock_hass) -> None: """Test refresh_token's jwt_key changed for long-lived access token.""" manager = await auth.auth_manager_from_config(mock_hass, [], []) user = MockUser().add_to_auth_manager(manager) @@ -639,7 +645,7 @@ async def test_create_long_lived_access_token(mock_hass): ) -async def test_one_long_lived_access_token_per_refresh_token(mock_hass): +async def test_one_long_lived_access_token_per_refresh_token(mock_hass) -> None: """Test one refresh_token can only have one long-lived access token.""" manager = await auth.auth_manager_from_config(mock_hass, [], []) user = MockUser().add_to_auth_manager(manager) @@ -691,7 +697,7 @@ async def test_one_long_lived_access_token_per_refresh_token(mock_hass): ) -async def test_login_with_auth_module(mock_hass): +async def test_login_with_auth_module(mock_hass) -> None: """Test login as existing user with auth module.""" manager = await auth.auth_manager_from_config( mock_hass, @@ -761,7 +767,7 @@ async def test_login_with_auth_module(mock_hass): assert step["result"].id == "mock-id" -async def test_login_with_multi_auth_module(mock_hass): +async def test_login_with_multi_auth_module(mock_hass) -> None: """Test login as existing user with multiple auth modules.""" manager = await auth.auth_manager_from_config( mock_hass, @@ -834,7 +840,7 @@ async def test_login_with_multi_auth_module(mock_hass): assert step["result"].id == "mock-id" -async def test_auth_module_expired_session(mock_hass): +async def test_auth_module_expired_session(mock_hass) -> None: """Test login as existing user.""" manager = await auth.auth_manager_from_config( mock_hass, @@ -896,7 +902,9 @@ async def test_auth_module_expired_session(mock_hass): assert step["reason"] == "login_expired" -async def test_enable_mfa_for_user(hass, hass_storage): +async def test_enable_mfa_for_user( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: """Test enable mfa module for user.""" manager = await auth.auth_manager_from_config( hass, @@ -1015,8 +1023,8 @@ async def test_async_remove_user(hass: HomeAssistant) -> None: async def test_async_remove_user_fail_if_remove_credential_fails( - hass, hass_admin_user, hass_admin_credential -): + hass: HomeAssistant, hass_admin_user: MockUser, hass_admin_credential: Credentials +) -> None: """Test removing a user.""" await hass.auth.async_link_user(hass_admin_user, hass_admin_credential) @@ -1026,7 +1034,7 @@ async def test_async_remove_user_fail_if_remove_credential_fails( await hass.auth.async_remove_user(hass_admin_user) -async def test_new_users(mock_hass): +async def test_new_users(mock_hass) -> None: """Test newly created users.""" manager = await auth.auth_manager_from_config( mock_hass, @@ -1086,7 +1094,7 @@ async def test_new_users(mock_hass): assert user_cred.is_admin -async def test_rename_does_not_change_refresh_token(mock_hass): +async def test_rename_does_not_change_refresh_token(mock_hass) -> None: """Test that we can rename without changing refresh token.""" manager = await auth.auth_manager_from_config(mock_hass, [], []) user = MockUser().add_to_auth_manager(manager) diff --git a/tests/hassfest/test_dependencies.py b/tests/hassfest/test_dependencies.py index dcef6d3484..98ccbe4eae 100644 --- a/tests/hassfest/test_dependencies.py +++ b/tests/hassfest/test_dependencies.py @@ -15,7 +15,7 @@ def mock_collector(): return collector -def test_child_import(mock_collector): +def test_child_import(mock_collector) -> None: """Test detecting a child_import reference.""" mock_collector.visit( ast.parse( @@ -27,7 +27,7 @@ from homeassistant.components import child_import assert mock_collector.unfiltered_referenced == {"child_import"} -def test_subimport(mock_collector): +def test_subimport(mock_collector) -> None: """Test detecting a subimport reference.""" mock_collector.visit( ast.parse( @@ -39,7 +39,7 @@ from homeassistant.components.subimport.smart_home import EVENT_ALEXA_SMART_HOME assert mock_collector.unfiltered_referenced == {"subimport"} -def test_child_import_field(mock_collector): +def test_child_import_field(mock_collector) -> None: """Test detecting a child_import_field reference.""" mock_collector.visit( ast.parse( @@ -51,7 +51,7 @@ from homeassistant.components.child_import_field import bla assert mock_collector.unfiltered_referenced == {"child_import_field"} -def test_renamed_absolute(mock_collector): +def test_renamed_absolute(mock_collector) -> None: """Test detecting a renamed_absolute reference.""" mock_collector.visit( ast.parse( @@ -63,7 +63,7 @@ import homeassistant.components.renamed_absolute as hue assert mock_collector.unfiltered_referenced == {"renamed_absolute"} -def test_hass_components_var(mock_collector): +def test_hass_components_var(mock_collector) -> None: """Test detecting a hass_components_var reference.""" mock_collector.visit( ast.parse( @@ -76,7 +76,7 @@ def bla(hass): assert mock_collector.unfiltered_referenced == {"hass_components_var"} -def test_hass_components_class(mock_collector): +def test_hass_components_class(mock_collector) -> None: """Test detecting a hass_components_class reference.""" mock_collector.visit( ast.parse( @@ -90,7 +90,7 @@ class Hello: assert mock_collector.unfiltered_referenced == {"hass_components_class"} -def test_all_imports(mock_collector): +def test_all_imports(mock_collector) -> None: """Test all imports together.""" mock_collector.visit( ast.parse( diff --git a/tests/hassfest/test_requirements.py b/tests/hassfest/test_requirements.py index 174bf84178..e6b247047e 100644 --- a/tests/hassfest/test_requirements.py +++ b/tests/hassfest/test_requirements.py @@ -23,7 +23,7 @@ def integration(): return integration -def test_validate_requirements_format_with_space(integration: Integration): +def test_validate_requirements_format_with_space(integration: Integration) -> None: """Test validate requirement with space around separator.""" integration.manifest["requirements"] = ["test_package == 1"] assert not validate_requirements_format(integration) @@ -33,7 +33,7 @@ def test_validate_requirements_format_with_space(integration: Integration): ] -def test_validate_requirements_format_wrongly_pinned(integration: Integration): +def test_validate_requirements_format_wrongly_pinned(integration: Integration) -> None: """Test requirement with loose pin.""" integration.manifest["requirements"] = ["test_package>=1"] assert not validate_requirements_format(integration) @@ -43,7 +43,9 @@ def test_validate_requirements_format_wrongly_pinned(integration: Integration): ] -def test_validate_requirements_format_ignore_pin_for_custom(integration: Integration): +def test_validate_requirements_format_ignore_pin_for_custom( + integration: Integration, +) -> None: """Test requirement ignore pinning for custom.""" integration.manifest["requirements"] = [ "test_package>=1", @@ -59,7 +61,7 @@ def test_validate_requirements_format_ignore_pin_for_custom(integration: Integra assert len(integration.errors) == 0 -def test_validate_requirements_format_invalid_version(integration: Integration): +def test_validate_requirements_format_invalid_version(integration: Integration) -> None: """Test requirement with invalid version.""" integration.manifest["requirements"] = ["test_package==invalid"] assert not validate_requirements_format(integration) @@ -69,7 +71,7 @@ def test_validate_requirements_format_invalid_version(integration: Integration): ] -def test_validate_requirements_format_successful(integration: Integration): +def test_validate_requirements_format_successful(integration: Integration) -> None: """Test requirement with successful result.""" integration.manifest["requirements"] = [ "test_package==1.2.3", diff --git a/tests/hassfest/test_version.py b/tests/hassfest/test_version.py index eee184404b..f473e98802 100644 --- a/tests/hassfest/test_version.py +++ b/tests/hassfest/test_version.py @@ -22,7 +22,7 @@ def integration(): return integration -def test_validate_version_no_key(integration: Integration): +def test_validate_version_no_key(integration: Integration) -> None: """Test validate version with no key.""" validate_version(integration) assert "No 'version' key in the manifest file." in [ @@ -30,7 +30,7 @@ def test_validate_version_no_key(integration: Integration): ] -def test_validate_custom_integration_manifest(integration: Integration): +def test_validate_custom_integration_manifest(integration: Integration) -> None: """Test validate custom integration manifest.""" with pytest.raises(vol.Invalid): diff --git a/tests/helpers/test_aiohttp_client.py b/tests/helpers/test_aiohttp_client.py index 23ef8878c2..fe7ffca9a4 100644 --- a/tests/helpers/test_aiohttp_client.py +++ b/tests/helpers/test_aiohttp_client.py @@ -22,6 +22,7 @@ import homeassistant.helpers.aiohttp_client as client from homeassistant.util.color import RGBColor from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker @pytest.fixture(name="camera_client") @@ -127,7 +128,9 @@ async def test_get_clientsession_patched_close(hass: HomeAssistant) -> None: @patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()) -async def test_warning_close_session_integration(hass, caplog): +async def test_warning_close_session_integration( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test log warning message when closing the session from integration context.""" with patch( "homeassistant.helpers.frame.extract_stack", @@ -159,7 +162,9 @@ async def test_warning_close_session_integration(hass, caplog): @patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()) -async def test_warning_close_session_custom(hass, caplog): +async def test_warning_close_session_custom( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test log warning message when closing the session from custom context.""" with patch( "homeassistant.helpers.frame.extract_stack", @@ -190,7 +195,9 @@ async def test_warning_close_session_custom(hass, caplog): ) -async def test_async_aiohttp_proxy_stream(aioclient_mock, camera_client): +async def test_async_aiohttp_proxy_stream( + aioclient_mock: AiohttpClientMocker, camera_client +) -> None: """Test that it fetches the given url.""" aioclient_mock.get("http://example.com/mjpeg_stream", content=b"Frame1Frame2Frame3") @@ -202,7 +209,9 @@ async def test_async_aiohttp_proxy_stream(aioclient_mock, camera_client): assert body == "Frame1Frame2Frame3" -async def test_async_aiohttp_proxy_stream_timeout(aioclient_mock, camera_client): +async def test_async_aiohttp_proxy_stream_timeout( + aioclient_mock: AiohttpClientMocker, camera_client +) -> None: """Test that it fetches the given url.""" aioclient_mock.get("http://example.com/mjpeg_stream", exc=asyncio.TimeoutError()) @@ -210,7 +219,9 @@ async def test_async_aiohttp_proxy_stream_timeout(aioclient_mock, camera_client) assert resp.status == 504 -async def test_async_aiohttp_proxy_stream_client_err(aioclient_mock, camera_client): +async def test_async_aiohttp_proxy_stream_client_err( + aioclient_mock: AiohttpClientMocker, camera_client +) -> None: """Test that it fetches the given url.""" aioclient_mock.get("http://example.com/mjpeg_stream", exc=aiohttp.ClientError()) @@ -218,7 +229,9 @@ async def test_async_aiohttp_proxy_stream_client_err(aioclient_mock, camera_clie assert resp.status == 502 -async def test_sending_named_tuple(hass, aioclient_mock): +async def test_sending_named_tuple( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: """Test sending a named tuple in json.""" resp = aioclient_mock.post("http://127.0.0.1/rgb", json={"rgb": RGBColor(4, 3, 2)}) session = client.async_create_clientsession(hass) diff --git a/tests/helpers/test_area_registry.py b/tests/helpers/test_area_registry.py index 3128e5b80b..fd74da547d 100644 --- a/tests/helpers/test_area_registry.py +++ b/tests/helpers/test_area_registry.py @@ -1,7 +1,9 @@ """Tests for the Area Registry.""" +from typing import Any + import pytest -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import area_registry as ar from tests.common import ANY, flush_store @@ -21,7 +23,7 @@ def update_events(hass): return events -async def test_list_areas(area_registry): +async def test_list_areas(area_registry: ar.AreaRegistry) -> None: """Make sure that we can read areas.""" area_registry.async_create("mock") @@ -30,7 +32,9 @@ async def test_list_areas(area_registry): assert len(areas) == len(area_registry.areas) -async def test_create_area(hass, area_registry, update_events): +async def test_create_area( + hass: HomeAssistant, area_registry: ar.AreaRegistry, update_events +) -> None: """Make sure that we can create an area.""" # Create area with only mandatory parameters area = area_registry.async_create("mock") @@ -67,7 +71,9 @@ async def test_create_area(hass, area_registry, update_events): assert update_events[-1]["area_id"] == area.id -async def test_create_area_with_name_already_in_use(hass, area_registry, update_events): +async def test_create_area_with_name_already_in_use( + hass: HomeAssistant, area_registry: ar.AreaRegistry, update_events +) -> None: """Make sure that we can't create an area with a name already in use.""" area1 = area_registry.async_create("mock") @@ -82,7 +88,9 @@ async def test_create_area_with_name_already_in_use(hass, area_registry, update_ assert len(update_events) == 1 -async def test_create_area_with_id_already_in_use(area_registry): +async def test_create_area_with_id_already_in_use( + area_registry: ar.AreaRegistry, +) -> None: """Make sure that we can't create an area with a name already in use.""" area1 = area_registry.async_create("mock") @@ -93,7 +101,9 @@ async def test_create_area_with_id_already_in_use(area_registry): assert area2.id == "mock_2" -async def test_delete_area(hass, area_registry, update_events): +async def test_delete_area( + hass: HomeAssistant, area_registry: ar.AreaRegistry, update_events +) -> None: """Make sure that we can delete an area.""" area = area_registry.async_create("mock") @@ -110,7 +120,7 @@ async def test_delete_area(hass, area_registry, update_events): assert update_events[1]["area_id"] == area.id -async def test_delete_non_existing_area(area_registry): +async def test_delete_non_existing_area(area_registry: ar.AreaRegistry) -> None: """Make sure that we can't delete an area that doesn't exist.""" area_registry.async_create("mock") @@ -120,7 +130,9 @@ async def test_delete_non_existing_area(area_registry): assert len(area_registry.areas) == 1 -async def test_update_area(hass, area_registry, update_events): +async def test_update_area( + hass: HomeAssistant, area_registry: ar.AreaRegistry, update_events +) -> None: """Make sure that we can read areas.""" area = area_registry.async_create("mock") @@ -150,7 +162,7 @@ async def test_update_area(hass, area_registry, update_events): assert update_events[1]["area_id"] == area.id -async def test_update_area_with_same_name(area_registry): +async def test_update_area_with_same_name(area_registry: ar.AreaRegistry) -> None: """Make sure that we can reapply the same name to the area.""" area = area_registry.async_create("mock") @@ -160,7 +172,9 @@ async def test_update_area_with_same_name(area_registry): assert len(area_registry.areas) == 1 -async def test_update_area_with_same_name_change_case(area_registry): +async def test_update_area_with_same_name_change_case( + area_registry: ar.AreaRegistry, +) -> None: """Make sure that we can reapply the same name with a different case to the area.""" area = area_registry.async_create("mock") @@ -172,7 +186,9 @@ async def test_update_area_with_same_name_change_case(area_registry): assert len(area_registry.areas) == 1 -async def test_update_area_with_name_already_in_use(area_registry): +async def test_update_area_with_name_already_in_use( + area_registry: ar.AreaRegistry, +) -> None: """Make sure that we can't update an area with a name already in use.""" area1 = area_registry.async_create("mock1") area2 = area_registry.async_create("mock2") @@ -186,7 +202,9 @@ async def test_update_area_with_name_already_in_use(area_registry): assert len(area_registry.areas) == 2 -async def test_update_area_with_normalized_name_already_in_use(area_registry): +async def test_update_area_with_normalized_name_already_in_use( + area_registry: ar.AreaRegistry, +) -> None: """Make sure that we can't update an area with a normalized name already in use.""" area1 = area_registry.async_create("mock1") area2 = area_registry.async_create("Moc k2") @@ -200,7 +218,7 @@ async def test_update_area_with_normalized_name_already_in_use(area_registry): assert len(area_registry.areas) == 2 -async def test_load_area(hass, area_registry): +async def test_load_area(hass: HomeAssistant, area_registry: ar.AreaRegistry) -> None: """Make sure that we can load/save data correctly.""" area1 = area_registry.async_create("mock1") area2 = area_registry.async_create("mock2") @@ -220,7 +238,9 @@ async def test_load_area(hass, area_registry): @pytest.mark.parametrize("load_registries", [False]) -async def test_loading_area_from_storage(hass, hass_storage): +async def test_loading_area_from_storage( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: """Test loading stored areas on start.""" hass_storage[ar.STORAGE_KEY] = { "version": ar.STORAGE_VERSION_MAJOR, @@ -244,7 +264,9 @@ async def test_loading_area_from_storage(hass, hass_storage): @pytest.mark.parametrize("load_registries", [False]) -async def test_migration_from_1_1(hass, hass_storage): +async def test_migration_from_1_1( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: """Test migration from version 1.1.""" hass_storage[ar.STORAGE_KEY] = { "version": 1, @@ -270,7 +292,7 @@ async def test_migration_from_1_1(hass, hass_storage): } -async def test_async_get_or_create(area_registry): +async def test_async_get_or_create(area_registry: ar.AreaRegistry) -> None: """Make sure we can get the area by name.""" area = area_registry.async_get_or_create("Mock1") area2 = area_registry.async_get_or_create("mock1") @@ -281,7 +303,7 @@ async def test_async_get_or_create(area_registry): assert area2 == area3 -async def test_async_get_area_by_name(area_registry): +async def test_async_get_area_by_name(area_registry: ar.AreaRegistry) -> None: """Make sure we can get the area by name.""" area_registry.async_create("Mock1") @@ -290,7 +312,7 @@ async def test_async_get_area_by_name(area_registry): assert area_registry.async_get_area_by_name("M o c k 1").normalized_name == "mock1" -async def test_async_get_area_by_name_not_found(area_registry): +async def test_async_get_area_by_name_not_found(area_registry: ar.AreaRegistry) -> None: """Make sure we return None for non-existent areas.""" area_registry.async_create("Mock1") @@ -299,7 +321,7 @@ async def test_async_get_area_by_name_not_found(area_registry): assert area_registry.async_get_area_by_name("non_exist") is None -async def test_async_get_area(area_registry): +async def test_async_get_area(area_registry: ar.AreaRegistry) -> None: """Make sure we can get the area by id.""" area = area_registry.async_create("Mock1") diff --git a/tests/helpers/test_collection.py b/tests/helpers/test_collection.py index 9e5526ff32..fe403f8db1 100644 --- a/tests/helpers/test_collection.py +++ b/tests/helpers/test_collection.py @@ -16,6 +16,7 @@ from homeassistant.helpers import ( from homeassistant.helpers.typing import ConfigType from tests.common import flush_store +from tests.typing import WebSocketGenerator _LOGGER = logging.getLogger(__name__) @@ -425,7 +426,9 @@ async def test_entity_component_collection_entity_removed(hass: HomeAssistant) - assert len(async_remove_calls) == 1 -async def test_storage_collection_websocket(hass, hass_ws_client): +async def test_storage_collection_websocket( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: """Test exposing a storage collection via websockets.""" store = storage.Store(hass, 1, "test-data") coll = MockStorageCollection(store, _LOGGER) diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 80b40baae0..364e4c9fb9 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -29,6 +29,7 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from tests.common import async_mock_service +from tests.typing import WebSocketGenerator @pytest.fixture @@ -2140,7 +2141,9 @@ async def assert_automation_condition_trace(hass_ws_client, automation_id, expec assert condition_trace == expected -async def test_if_action_before_sunrise_no_offset(hass, hass_ws_client, calls): +async def test_if_action_before_sunrise_no_offset( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls +) -> None: """Test if action was before sunrise. Before sunrise is true from midnight until sunset, local time. @@ -2209,7 +2212,9 @@ async def test_if_action_before_sunrise_no_offset(hass, hass_ws_client, calls): ) -async def test_if_action_after_sunrise_no_offset(hass, hass_ws_client, calls): +async def test_if_action_after_sunrise_no_offset( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls +) -> None: """Test if action was after sunrise. After sunrise is true from sunrise until midnight, local time. @@ -2278,7 +2283,9 @@ async def test_if_action_after_sunrise_no_offset(hass, hass_ws_client, calls): ) -async def test_if_action_before_sunrise_with_offset(hass, hass_ws_client, calls): +async def test_if_action_before_sunrise_with_offset( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls +) -> None: """Test if action was before sunrise with offset. Before sunrise is true from midnight until sunset, local time. @@ -2399,7 +2406,9 @@ async def test_if_action_before_sunrise_with_offset(hass, hass_ws_client, calls) ) -async def test_if_action_before_sunset_with_offset(hass, hass_ws_client, calls): +async def test_if_action_before_sunset_with_offset( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls +) -> None: """Test if action was before sunset with offset. Before sunset is true from midnight until sunset, local time. @@ -2520,7 +2529,9 @@ async def test_if_action_before_sunset_with_offset(hass, hass_ws_client, calls): ) -async def test_if_action_after_sunrise_with_offset(hass, hass_ws_client, calls): +async def test_if_action_after_sunrise_with_offset( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls +) -> None: """Test if action was after sunrise with offset. After sunrise is true from sunrise until midnight, local time. @@ -2665,7 +2676,9 @@ async def test_if_action_after_sunrise_with_offset(hass, hass_ws_client, calls): ) -async def test_if_action_after_sunset_with_offset(hass, hass_ws_client, calls): +async def test_if_action_after_sunset_with_offset( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls +) -> None: """Test if action was after sunset with offset. After sunset is true from sunset until midnight, local time. @@ -2738,7 +2751,9 @@ async def test_if_action_after_sunset_with_offset(hass, hass_ws_client, calls): ) -async def test_if_action_after_and_before_during(hass, hass_ws_client, calls): +async def test_if_action_after_and_before_during( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls +) -> None: """Test if action was after sunrise and before sunset. This is true from sunrise until sunset. @@ -2839,7 +2854,9 @@ async def test_if_action_after_and_before_during(hass, hass_ws_client, calls): ) -async def test_if_action_before_or_after_during(hass, hass_ws_client, calls): +async def test_if_action_before_or_after_during( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls +) -> None: """Test if action was before sunrise or after sunset. This is true from midnight until sunrise and from sunset until midnight @@ -2960,7 +2977,9 @@ async def test_if_action_before_or_after_during(hass, hass_ws_client, calls): ) -async def test_if_action_before_sunrise_no_offset_kotzebue(hass, hass_ws_client, calls): +async def test_if_action_before_sunrise_no_offset_kotzebue( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls +) -> None: """Test if action was before sunrise. Local timezone: Alaska time @@ -3035,7 +3054,9 @@ async def test_if_action_before_sunrise_no_offset_kotzebue(hass, hass_ws_client, ) -async def test_if_action_after_sunrise_no_offset_kotzebue(hass, hass_ws_client, calls): +async def test_if_action_after_sunrise_no_offset_kotzebue( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls +) -> None: """Test if action was after sunrise. Local timezone: Alaska time @@ -3110,7 +3131,9 @@ async def test_if_action_after_sunrise_no_offset_kotzebue(hass, hass_ws_client, ) -async def test_if_action_before_sunset_no_offset_kotzebue(hass, hass_ws_client, calls): +async def test_if_action_before_sunset_no_offset_kotzebue( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls +) -> None: """Test if action was before sunrise. Local timezone: Alaska time @@ -3185,7 +3208,9 @@ async def test_if_action_before_sunset_no_offset_kotzebue(hass, hass_ws_client, ) -async def test_if_action_after_sunset_no_offset_kotzebue(hass, hass_ws_client, calls): +async def test_if_action_after_sunset_no_offset_kotzebue( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls +) -> None: """Test if action was after sunrise. Local timezone: Alaska time diff --git a/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py index bc545a647c..8909f18e54 100644 --- a/tests/helpers/test_config_entry_flow.py +++ b/tests/helpers/test_config_entry_flow.py @@ -5,6 +5,7 @@ import pytest from homeassistant import config_entries, data_entry_flow, setup from homeassistant.config import async_process_ha_core_config +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_flow from tests.common import ( @@ -42,7 +43,7 @@ def webhook_flow_conf(hass): yield {} -async def test_single_entry_allowed(hass, discovery_flow_conf): +async def test_single_entry_allowed(hass: HomeAssistant, discovery_flow_conf) -> None: """Test only a single entry is allowed.""" flow = config_entries.HANDLERS["test"]() flow.hass = hass @@ -55,7 +56,7 @@ async def test_single_entry_allowed(hass, discovery_flow_conf): assert result["reason"] == "single_instance_allowed" -async def test_user_no_devices_found(hass, discovery_flow_conf): +async def test_user_no_devices_found(hass: HomeAssistant, discovery_flow_conf) -> None: """Test if no devices found.""" flow = config_entries.HANDLERS["test"]() flow.hass = hass @@ -66,7 +67,7 @@ async def test_user_no_devices_found(hass, discovery_flow_conf): assert result["reason"] == "no_devices_found" -async def test_user_has_confirmation(hass, discovery_flow_conf): +async def test_user_has_confirmation(hass: HomeAssistant, discovery_flow_conf) -> None: """Test user requires confirmation to setup.""" discovery_flow_conf["discovered"] = True mock_entity_platform(hass, "config_flow.test", None) @@ -102,7 +103,9 @@ async def test_user_has_confirmation(hass, discovery_flow_conf): config_entries.SOURCE_DHCP, ], ) -async def test_discovery_single_instance(hass, discovery_flow_conf, source): +async def test_discovery_single_instance( + hass: HomeAssistant, discovery_flow_conf, source +) -> None: """Test we not allow duplicates.""" flow = config_entries.HANDLERS["test"]() flow.hass = hass @@ -126,7 +129,9 @@ async def test_discovery_single_instance(hass, discovery_flow_conf, source): config_entries.SOURCE_DHCP, ], ) -async def test_discovery_confirmation(hass, discovery_flow_conf, source): +async def test_discovery_confirmation( + hass: HomeAssistant, discovery_flow_conf, source +) -> None: """Test we ask for confirmation via discovery.""" flow = config_entries.HANDLERS["test"]() flow.hass = hass @@ -152,7 +157,9 @@ async def test_discovery_confirmation(hass, discovery_flow_conf, source): config_entries.SOURCE_DHCP, ], ) -async def test_discovery_during_onboarding(hass, discovery_flow_conf, source): +async def test_discovery_during_onboarding( + hass: HomeAssistant, discovery_flow_conf, source +) -> None: """Test we create config entry via discovery during onboarding.""" flow = config_entries.HANDLERS["test"]() flow.hass = hass @@ -166,7 +173,7 @@ async def test_discovery_during_onboarding(hass, discovery_flow_conf, source): assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY -async def test_multiple_discoveries(hass, discovery_flow_conf): +async def test_multiple_discoveries(hass: HomeAssistant, discovery_flow_conf) -> None: """Test we only create one instance for multiple discoveries.""" mock_entity_platform(hass, "config_flow.test", None) @@ -182,7 +189,7 @@ async def test_multiple_discoveries(hass, discovery_flow_conf): assert result["type"] == data_entry_flow.FlowResultType.ABORT -async def test_only_one_in_progress(hass, discovery_flow_conf): +async def test_only_one_in_progress(hass: HomeAssistant, discovery_flow_conf) -> None: """Test a user initialized one will finish and cancel discovered one.""" mock_entity_platform(hass, "config_flow.test", None) @@ -208,7 +215,7 @@ async def test_only_one_in_progress(hass, discovery_flow_conf): assert len(hass.config_entries.flow.async_progress()) == 0 -async def test_import_abort_discovery(hass, discovery_flow_conf): +async def test_import_abort_discovery(hass: HomeAssistant, discovery_flow_conf) -> None: """Test import will finish and cancel discovered one.""" mock_entity_platform(hass, "config_flow.test", None) @@ -229,7 +236,7 @@ async def test_import_abort_discovery(hass, discovery_flow_conf): assert len(hass.config_entries.flow.async_progress()) == 0 -async def test_import_no_confirmation(hass, discovery_flow_conf): +async def test_import_no_confirmation(hass: HomeAssistant, discovery_flow_conf) -> None: """Test import requires no confirmation to set up.""" flow = config_entries.HANDLERS["test"]() flow.hass = hass @@ -240,7 +247,7 @@ async def test_import_no_confirmation(hass, discovery_flow_conf): assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY -async def test_import_single_instance(hass, discovery_flow_conf): +async def test_import_single_instance(hass: HomeAssistant, discovery_flow_conf) -> None: """Test import doesn't create second instance.""" flow = config_entries.HANDLERS["test"]() flow.hass = hass @@ -252,7 +259,7 @@ async def test_import_single_instance(hass, discovery_flow_conf): assert result["type"] == data_entry_flow.FlowResultType.ABORT -async def test_ignored_discoveries(hass, discovery_flow_conf): +async def test_ignored_discoveries(hass: HomeAssistant, discovery_flow_conf) -> None: """Test we can ignore discovered entries.""" mock_entity_platform(hass, "config_flow.test", None) @@ -284,7 +291,9 @@ async def test_ignored_discoveries(hass, discovery_flow_conf): assert result["type"] == data_entry_flow.FlowResultType.ABORT -async def test_webhook_single_entry_allowed(hass, webhook_flow_conf): +async def test_webhook_single_entry_allowed( + hass: HomeAssistant, webhook_flow_conf +) -> None: """Test only a single entry is allowed.""" flow = config_entries.HANDLERS["test_single"]() flow.hass = hass @@ -296,7 +305,9 @@ async def test_webhook_single_entry_allowed(hass, webhook_flow_conf): assert result["reason"] == "single_instance_allowed" -async def test_webhook_multiple_entries_allowed(hass, webhook_flow_conf): +async def test_webhook_multiple_entries_allowed( + hass: HomeAssistant, webhook_flow_conf +) -> None: """Test multiple entries are allowed when specified.""" flow = config_entries.HANDLERS["test_multiple"]() flow.hass = hass @@ -308,7 +319,9 @@ async def test_webhook_multiple_entries_allowed(hass, webhook_flow_conf): assert result["type"] == data_entry_flow.FlowResultType.FORM -async def test_webhook_config_flow_registers_webhook(hass, webhook_flow_conf): +async def test_webhook_config_flow_registers_webhook( + hass: HomeAssistant, webhook_flow_conf +) -> None: """Test setting up an entry creates a webhook.""" flow = config_entries.HANDLERS["test_single"]() flow.hass = hass @@ -323,7 +336,7 @@ async def test_webhook_config_flow_registers_webhook(hass, webhook_flow_conf): assert result["data"]["webhook_id"] is not None -async def test_webhook_create_cloudhook(hass, webhook_flow_conf): +async def test_webhook_create_cloudhook(hass: HomeAssistant, webhook_flow_conf) -> None: """Test cloudhook will be created if subscribed.""" assert await setup.async_setup_component(hass, "cloud", {}) @@ -376,7 +389,9 @@ async def test_webhook_create_cloudhook(hass, webhook_flow_conf): assert result["require_restart"] is False -async def test_webhook_create_cloudhook_aborts_not_connected(hass, webhook_flow_conf): +async def test_webhook_create_cloudhook_aborts_not_connected( + hass: HomeAssistant, webhook_flow_conf +) -> None: """Test cloudhook aborts if subscribed but not connected.""" assert await setup.async_setup_component(hass, "cloud", {}) diff --git a/tests/helpers/test_config_entry_oauth2_flow.py b/tests/helpers/test_config_entry_oauth2_flow.py index 6dc353723d..3baaf7e733 100644 --- a/tests/helpers/test_config_entry_oauth2_flow.py +++ b/tests/helpers/test_config_entry_oauth2_flow.py @@ -8,10 +8,13 @@ import aiohttp import pytest from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers.network import NoURLAvailableError from tests.common import MockConfigEntry, mock_platform +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator TEST_DOMAIN = "oauth2_test" CLIENT_SECRET = "5678" @@ -105,7 +108,7 @@ def test_inherit_enforces_domain_set() -> None: TestFlowHandler() -async def test_abort_if_no_implementation(hass, flow_handler): +async def test_abort_if_no_implementation(hass: HomeAssistant, flow_handler) -> None: """Check flow abort when no implementations.""" flow = flow_handler() flow.hass = hass @@ -114,7 +117,9 @@ async def test_abort_if_no_implementation(hass, flow_handler): assert result["reason"] == "missing_configuration" -async def test_missing_credentials_for_domain(hass, flow_handler): +async def test_missing_credentials_for_domain( + hass: HomeAssistant, flow_handler +) -> None: """Check flow abort for integration supporting application credentials.""" flow = flow_handler() flow.hass = hass @@ -126,8 +131,8 @@ async def test_missing_credentials_for_domain(hass, flow_handler): async def test_abort_if_authorization_timeout( - hass, flow_handler, local_impl, current_request_with_host -): + hass: HomeAssistant, flow_handler, local_impl, current_request_with_host: None +) -> None: """Check timeout generating authorization url.""" flow_handler.async_register_implementation(hass, local_impl) @@ -145,8 +150,8 @@ async def test_abort_if_authorization_timeout( async def test_abort_if_no_url_available( - hass, flow_handler, local_impl, current_request_with_host -): + hass: HomeAssistant, flow_handler, local_impl, current_request_with_host: None +) -> None: """Check no_url_available generating authorization url.""" flow_handler.async_register_implementation(hass, local_impl) @@ -163,13 +168,13 @@ async def test_abort_if_no_url_available( async def test_abort_if_oauth_error( - hass, + hass: HomeAssistant, flow_handler, local_impl, - hass_client_no_auth, - aioclient_mock, - current_request_with_host, -): + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, +) -> None: """Check bad oauth token.""" flow_handler.async_register_implementation(hass, local_impl) config_entry_oauth2_flow.async_register_implementation( @@ -225,13 +230,13 @@ async def test_abort_if_oauth_error( async def test_abort_if_oauth_rejected( - hass, + hass: HomeAssistant, flow_handler, local_impl, - hass_client_no_auth, - aioclient_mock, - current_request_with_host, -): + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, +) -> None: """Check bad oauth token.""" flow_handler.async_register_implementation(hass, local_impl) config_entry_oauth2_flow.async_register_implementation( @@ -280,13 +285,13 @@ async def test_abort_if_oauth_rejected( async def test_abort_on_oauth_timeout_error( - hass, + hass: HomeAssistant, flow_handler, local_impl, - hass_client_no_auth, - aioclient_mock, - current_request_with_host, -): + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, +) -> None: """Check timeout during oauth token exchange.""" flow_handler.async_register_implementation(hass, local_impl) config_entry_oauth2_flow.async_register_implementation( @@ -335,7 +340,7 @@ async def test_abort_on_oauth_timeout_error( assert result["reason"] == "oauth2_timeout" -async def test_step_discovery(hass, flow_handler, local_impl): +async def test_step_discovery(hass: HomeAssistant, flow_handler, local_impl) -> None: """Check flow triggers from discovery.""" flow_handler.async_register_implementation(hass, local_impl) config_entry_oauth2_flow.async_register_implementation( @@ -352,7 +357,9 @@ async def test_step_discovery(hass, flow_handler, local_impl): assert result["step_id"] == "pick_implementation" -async def test_abort_discovered_multiple(hass, flow_handler, local_impl): +async def test_abort_discovered_multiple( + hass: HomeAssistant, flow_handler, local_impl +) -> None: """Test if aborts when discovered multiple times.""" flow_handler.async_register_implementation(hass, local_impl) config_entry_oauth2_flow.async_register_implementation( @@ -378,7 +385,9 @@ async def test_abort_discovered_multiple(hass, flow_handler, local_impl): assert result["reason"] == "already_in_progress" -async def test_abort_discovered_existing_entries(hass, flow_handler, local_impl): +async def test_abort_discovered_existing_entries( + hass: HomeAssistant, flow_handler, local_impl +) -> None: """Test if abort discovery when entries exists.""" flow_handler.async_register_implementation(hass, local_impl) config_entry_oauth2_flow.async_register_implementation( @@ -402,13 +411,13 @@ async def test_abort_discovered_existing_entries(hass, flow_handler, local_impl) async def test_full_flow( - hass, + hass: HomeAssistant, flow_handler, local_impl, - hass_client_no_auth, - aioclient_mock, - current_request_with_host, -): + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, +) -> None: """Check full flow.""" flow_handler.async_register_implementation(hass, local_impl) config_entry_oauth2_flow.async_register_implementation( @@ -479,7 +488,9 @@ async def test_full_flow( ) -async def test_local_refresh_token(hass, local_impl, aioclient_mock): +async def test_local_refresh_token( + hass: HomeAssistant, local_impl, aioclient_mock: AiohttpClientMocker +) -> None: """Test we can refresh token.""" aioclient_mock.post( TOKEN_URL, json={"access_token": ACCESS_TOKEN_2, "expires_in": 100} @@ -511,7 +522,9 @@ async def test_local_refresh_token(hass, local_impl, aioclient_mock): } -async def test_oauth_session(hass, flow_handler, local_impl, aioclient_mock): +async def test_oauth_session( + hass: HomeAssistant, flow_handler, local_impl, aioclient_mock: AiohttpClientMocker +) -> None: """Test the OAuth2 session helper.""" flow_handler.async_register_implementation(hass, local_impl) @@ -556,8 +569,8 @@ async def test_oauth_session(hass, flow_handler, local_impl, aioclient_mock): async def test_oauth_session_with_clock_slightly_out_of_sync( - hass, flow_handler, local_impl, aioclient_mock -): + hass: HomeAssistant, flow_handler, local_impl, aioclient_mock: AiohttpClientMocker +) -> None: """Test the OAuth2 session helper when the remote clock is slightly out of sync.""" flow_handler.async_register_implementation(hass, local_impl) @@ -602,8 +615,8 @@ async def test_oauth_session_with_clock_slightly_out_of_sync( async def test_oauth_session_no_token_refresh_needed( - hass, flow_handler, local_impl, aioclient_mock -): + hass: HomeAssistant, flow_handler, local_impl, aioclient_mock: AiohttpClientMocker +) -> None: """Test the OAuth2 session helper when no refresh is needed.""" flow_handler.async_register_implementation(hass, local_impl) @@ -643,7 +656,7 @@ async def test_oauth_session_no_token_refresh_needed( assert round(config_entry.data["token"]["expires_at"] - now) == 500 -async def test_implementation_provider(hass, local_impl): +async def test_implementation_provider(hass: HomeAssistant, local_impl) -> None: """Test providing an implementation provider.""" assert ( await config_entry_oauth2_flow.async_get_implementations(hass, TEST_DOMAIN) @@ -700,8 +713,8 @@ async def test_implementation_provider(hass, local_impl): async def test_oauth_session_refresh_failure( - hass, flow_handler, local_impl, aioclient_mock -): + hass: HomeAssistant, flow_handler, local_impl, aioclient_mock: AiohttpClientMocker +) -> None: """Test the OAuth2 session helper when no refresh is needed.""" flow_handler.async_register_implementation(hass, local_impl) @@ -728,7 +741,9 @@ async def test_oauth_session_refresh_failure( await session.async_request("post", "https://example.com") -async def test_oauth2_without_secret_init(local_impl, hass_client_no_auth): +async def test_oauth2_without_secret_init( + local_impl, hass_client_no_auth: ClientSessionGenerator +) -> None: """Check authorize callback without secret initalizated.""" client = await hass_client_no_auth() resp = await client.get("/auth/external/callback?code=abcd&state=qwer") diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 5d97ff9b5d..9e1c84151c 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -174,7 +174,7 @@ def test_entity_id() -> None: @pytest.mark.parametrize("validator", [cv.entity_ids, cv.entity_ids_or_uuids]) -def test_entity_ids(validator): +def test_entity_ids(validator) -> None: """Test entity ID validation.""" schema = vol.Schema(validator) @@ -751,7 +751,7 @@ def version(monkeypatch): monkeypatch.setattr(homeassistant.const, "__version__", "0.5.0") -def test_deprecated_with_no_optionals(caplog, schema): +def test_deprecated_with_no_optionals(caplog: pytest.LogCaptureFixture, schema) -> None: """Test deprecation behaves correctly when optional params are None. Expected behavior: @@ -782,7 +782,9 @@ def test_deprecated_with_no_optionals(caplog, schema): assert test_data == output -def test_deprecated_or_removed_param_and_raise(caplog, schema): +def test_deprecated_or_removed_param_and_raise( + caplog: pytest.LogCaptureFixture, schema +) -> None: """Test removed or deprecation options and fail the config validation by raising an exception. Expected behavior: @@ -821,7 +823,9 @@ def test_deprecated_or_removed_param_and_raise(caplog, schema): assert test_data == output -def test_deprecated_with_replacement_key(caplog, schema): +def test_deprecated_with_replacement_key( + caplog: pytest.LogCaptureFixture, schema +) -> None: """Test deprecation behaves correctly when only a replacement key is provided. Expected behavior: @@ -858,7 +862,7 @@ def test_deprecated_with_replacement_key(caplog, schema): assert test_data == output -def test_deprecated_with_default(caplog, schema): +def test_deprecated_with_default(caplog: pytest.LogCaptureFixture, schema) -> None: """Test deprecation behaves correctly with a default value. This is likely a scenario that would never occur. @@ -886,7 +890,9 @@ def test_deprecated_with_default(caplog, schema): assert test_data == output -def test_deprecated_with_replacement_key_and_default(caplog, schema): +def test_deprecated_with_replacement_key_and_default( + caplog: pytest.LogCaptureFixture, schema +) -> None: """Test deprecation with a replacement key and default. Expected behavior: @@ -960,7 +966,9 @@ def test_deprecated_cant_find_module() -> None: ) -def test_deprecated_or_removed_logger_with_config_attributes(caplog): +def test_deprecated_or_removed_logger_with_config_attributes( + caplog: pytest.LogCaptureFixture, +) -> None: """Test if the logger outputs the correct message if the line and file attribute is available in config.""" file: str = "configuration.yaml" line: int = 54 @@ -997,7 +1005,9 @@ def test_deprecated_or_removed_logger_with_config_attributes(caplog): assert len(caplog.records) == 0 -def test_deprecated_logger_with_one_config_attribute(caplog): +def test_deprecated_logger_with_one_config_attribute( + caplog: pytest.LogCaptureFixture, +) -> None: """Test if the logger outputs the correct message if only one of line and file attribute is available in config.""" file: str = "configuration.yaml" line: int = 54 @@ -1031,7 +1041,9 @@ def test_deprecated_logger_with_one_config_attribute(caplog): assert len(caplog.records) == 0 -def test_deprecated_logger_without_config_attributes(caplog): +def test_deprecated_logger_without_config_attributes( + caplog: pytest.LogCaptureFixture, +) -> None: """Test if the logger outputs the correct message if the line and file attribute is not available in config.""" file: str = "configuration.yaml" line: int = 54 @@ -1166,7 +1178,7 @@ def test_comp_entity_ids() -> None: schema(invalid) -def test_uuid4_hex(caplog): +def test_uuid4_hex(caplog: pytest.LogCaptureFixture) -> None: """Test uuid validation.""" schema = vol.Schema(cv.uuid4_hex) @@ -1262,7 +1274,7 @@ def test_key_value_schemas_with_default() -> None: schema({"mode": "{{ 1 + 1}}"}) -def test_script(caplog): +def test_script(caplog: pytest.LogCaptureFixture) -> None: """Test script validation is user friendly.""" for data, msg in ( ({"delay": "{{ invalid"}, "should be format 'HH:MM'"), diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py index ebabe12c1a..380801123b 100644 --- a/tests/helpers/test_deprecation.py +++ b/tests/helpers/test_deprecation.py @@ -1,6 +1,8 @@ """Test deprecation helpers.""" from unittest.mock import MagicMock, patch +import pytest + from homeassistant.helpers.deprecation import ( deprecated_function, deprecated_substitute, @@ -37,7 +39,7 @@ class MockUpdatedClass(MockBaseClass): @patch("logging.getLogger") -def test_deprecated_substitute_old_class(mock_get_logger): +def test_deprecated_substitute_old_class(mock_get_logger) -> None: """Test deprecated class object.""" mock_logger = MagicMock() mock_get_logger.return_value = mock_logger @@ -50,7 +52,7 @@ def test_deprecated_substitute_old_class(mock_get_logger): @patch("logging.getLogger") -def test_deprecated_substitute_new_class(mock_get_logger): +def test_deprecated_substitute_new_class(mock_get_logger) -> None: """Test deprecated class object.""" mock_logger = MagicMock() mock_get_logger.return_value = mock_logger @@ -62,7 +64,7 @@ def test_deprecated_substitute_new_class(mock_get_logger): @patch("logging.getLogger") -def test_config_get_deprecated_old(mock_get_logger): +def test_config_get_deprecated_old(mock_get_logger) -> None: """Test deprecated class object.""" mock_logger = MagicMock() mock_get_logger.return_value = mock_logger @@ -74,7 +76,7 @@ def test_config_get_deprecated_old(mock_get_logger): @patch("logging.getLogger") -def test_config_get_deprecated_new(mock_get_logger): +def test_config_get_deprecated_new(mock_get_logger) -> None: """Test deprecated class object.""" mock_logger = MagicMock() mock_get_logger.return_value = mock_logger @@ -84,7 +86,7 @@ def test_config_get_deprecated_new(mock_get_logger): assert not mock_logger.warning.called -def test_deprecated_function(caplog): +def test_deprecated_function(caplog: pytest.LogCaptureFixture) -> None: """Test deprecated_function decorator.""" @deprecated_function("new_function") diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index b0116a158e..e183bd4c38 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -1,5 +1,6 @@ """Tests for the Device Registry.""" import time +from typing import Any from unittest.mock import patch import pytest @@ -8,7 +9,11 @@ from homeassistant import config_entries from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import CoreState, HomeAssistant, callback from homeassistant.exceptions import RequiredParameterMissing -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, +) from tests.common import MockConfigEntry, flush_store @@ -28,8 +33,11 @@ def update_events(hass): async def test_get_or_create_returns_same_entry( - hass, device_registry, area_registry, update_events -): + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + area_registry: ar.AreaRegistry, + update_events, +) -> None: """Make sure we do not duplicate entries.""" entry = device_registry.async_get_or_create( config_entry_id="1234", @@ -87,7 +95,9 @@ async def test_get_or_create_returns_same_entry( } -async def test_requirement_for_identifier_or_connection(device_registry): +async def test_requirement_for_identifier_or_connection( + device_registry: dr.DeviceRegistry, +) -> None: """Make sure we do require some descriptor of device.""" entry = device_registry.async_get_or_create( config_entry_id="1234", @@ -120,7 +130,7 @@ async def test_requirement_for_identifier_or_connection(device_registry): assert exc_info.value.parameter_names == ["identifiers", "connections"] -async def test_multiple_config_entries(device_registry): +async def test_multiple_config_entries(device_registry: dr.DeviceRegistry) -> None: """Make sure we do not get duplicate entries.""" entry = device_registry.async_get_or_create( config_entry_id="123", @@ -151,7 +161,9 @@ async def test_multiple_config_entries(device_registry): @pytest.mark.parametrize("load_registries", [False]) -async def test_loading_from_storage(hass, hass_storage): +async def test_loading_from_storage( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: """Test loading stored devices on start.""" hass_storage[dr.STORAGE_KEY] = { "version": dr.STORAGE_VERSION_MAJOR, @@ -244,7 +256,9 @@ async def test_loading_from_storage(hass, hass_storage): @pytest.mark.parametrize("load_registries", [False]) -async def test_migration_1_1_to_1_3(hass, hass_storage): +async def test_migration_1_1_to_1_3( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: """Test migration from version 1.1 to 1.3.""" hass_storage[dr.STORAGE_KEY] = { "version": 1, @@ -368,7 +382,9 @@ async def test_migration_1_1_to_1_3(hass, hass_storage): @pytest.mark.parametrize("load_registries", [False]) -async def test_migration_1_2_to_1_3(hass, hass_storage): +async def test_migration_1_2_to_1_3( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: """Test migration from version 1.2 to 1.3.""" hass_storage[dr.STORAGE_KEY] = { "version": 1, @@ -482,7 +498,9 @@ async def test_migration_1_2_to_1_3(hass, hass_storage): } -async def test_removing_config_entries(hass, device_registry, update_events): +async def test_removing_config_entries( + hass: HomeAssistant, device_registry: dr.DeviceRegistry, update_events +) -> None: """Make sure we do not get duplicate entries.""" entry = device_registry.async_get_or_create( config_entry_id="123", @@ -539,8 +557,8 @@ async def test_removing_config_entries(hass, device_registry, update_events): async def test_deleted_device_removing_config_entries( - hass, device_registry, update_events -): + hass: HomeAssistant, device_registry: dr.DeviceRegistry, update_events +) -> None: """Make sure we do not get duplicate entries.""" entry = device_registry.async_get_or_create( config_entry_id="123", @@ -633,7 +651,7 @@ async def test_deleted_device_removing_config_entries( assert entry3.id != entry4.id -async def test_removing_area_id(device_registry): +async def test_removing_area_id(device_registry: dr.DeviceRegistry) -> None: """Make sure we can clear area id.""" entry = device_registry.async_get_or_create( config_entry_id="123", @@ -652,7 +670,7 @@ async def test_removing_area_id(device_registry): assert entry_w_area != entry_wo_area -async def test_specifying_via_device_create(device_registry): +async def test_specifying_via_device_create(device_registry: dr.DeviceRegistry) -> None: """Test specifying a via_device and removal of the hub device.""" via = device_registry.async_get_or_create( config_entry_id="123", @@ -678,7 +696,7 @@ async def test_specifying_via_device_create(device_registry): assert light.via_device_id is None -async def test_specifying_via_device_update(device_registry): +async def test_specifying_via_device_update(device_registry: dr.DeviceRegistry) -> None: """Test specifying a via_device and updating.""" light = device_registry.async_get_or_create( config_entry_id="456", @@ -711,7 +729,9 @@ async def test_specifying_via_device_update(device_registry): assert light.via_device_id == via.id -async def test_loading_saving_data(hass, device_registry): +async def test_loading_saving_data( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test that we load/save data correctly.""" orig_via = device_registry.async_get_or_create( config_entry_id="123", @@ -829,7 +849,7 @@ async def test_loading_saving_data(hass, device_registry): assert orig_kitchen_light_witout_suggested_area == new_kitchen_light -async def test_no_unnecessary_changes(device_registry): +async def test_no_unnecessary_changes(device_registry: dr.DeviceRegistry) -> None: """Make sure we do not consider devices changes.""" entry = device_registry.async_get_or_create( config_entry_id="1234", @@ -847,7 +867,7 @@ async def test_no_unnecessary_changes(device_registry): assert len(mock_save.mock_calls) == 0 -async def test_format_mac(device_registry): +async def test_format_mac(device_registry: dr.DeviceRegistry) -> None: """Make sure we normalize mac addresses.""" entry = device_registry.async_get_or_create( config_entry_id="1234", @@ -879,7 +899,9 @@ async def test_format_mac(device_registry): assert list(invalid_mac_entry.connections)[0][1] == invalid -async def test_update(hass, device_registry, update_events): +async def test_update( + hass: HomeAssistant, device_registry: dr.DeviceRegistry, update_events +) -> None: """Verify that we can update some attributes of a device.""" entry = device_registry.async_get_or_create( config_entry_id="1234", @@ -969,7 +991,9 @@ async def test_update(hass, device_registry, update_events): } -async def test_update_remove_config_entries(hass, device_registry, update_events): +async def test_update_remove_config_entries( + hass: HomeAssistant, device_registry: dr.DeviceRegistry, update_events +) -> None: """Make sure we do not get duplicate entries.""" entry = device_registry.async_get_or_create( config_entry_id="123", @@ -1033,8 +1057,11 @@ async def test_update_remove_config_entries(hass, device_registry, update_events async def test_update_suggested_area( - hass, device_registry, area_registry, update_events -): + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + area_registry: ar.AreaRegistry, + update_events, +) -> None: """Verify that we can update the suggested area version of a device.""" entry = device_registry.async_get_or_create( config_entry_id="1234", @@ -1083,7 +1110,9 @@ async def test_update_suggested_area( assert updated_entry.suggested_area == "Other" -async def test_cleanup_device_registry(hass, device_registry): +async def test_cleanup_device_registry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test cleanup works.""" config_entry = MockConfigEntry(domain="hue") config_entry.add_to_hass(hass) @@ -1115,8 +1144,8 @@ async def test_cleanup_device_registry(hass, device_registry): async def test_cleanup_device_registry_removes_expired_orphaned_devices( - hass, device_registry -): + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test cleanup removes expired orphaned devices.""" config_entry = MockConfigEntry(domain="hue") config_entry.add_to_hass(hass) @@ -1197,7 +1226,9 @@ async def test_cleanup_entity_registry_change(hass: HomeAssistant) -> None: assert len(mock_call.mock_calls) == 2 -async def test_restore_device(hass, device_registry, update_events): +async def test_restore_device( + hass: HomeAssistant, device_registry: dr.DeviceRegistry, update_events +) -> None: """Make sure device id is stable.""" entry = device_registry.async_get_or_create( config_entry_id="123", @@ -1256,7 +1287,9 @@ async def test_restore_device(hass, device_registry, update_events): assert "changes" not in update_events[3] -async def test_restore_simple_device(hass, device_registry, update_events): +async def test_restore_simple_device( + hass: HomeAssistant, device_registry: dr.DeviceRegistry, update_events +) -> None: """Make sure device id is stable.""" entry = device_registry.async_get_or_create( config_entry_id="123", @@ -1305,7 +1338,9 @@ async def test_restore_simple_device(hass, device_registry, update_events): assert "changes" not in update_events[3] -async def test_restore_shared_device(hass, device_registry, update_events): +async def test_restore_shared_device( + hass: HomeAssistant, device_registry: dr.DeviceRegistry, update_events +) -> None: """Make sure device id is stable for shared devices.""" entry = device_registry.async_get_or_create( config_entry_id="123", @@ -1416,7 +1451,9 @@ async def test_restore_shared_device(hass, device_registry, update_events): } -async def test_get_or_create_empty_then_set_default_values(device_registry): +async def test_get_or_create_empty_then_set_default_values( + device_registry: dr.DeviceRegistry, +) -> None: """Test creating an entry, then setting default name, model, manufacturer.""" entry = device_registry.async_get_or_create( identifiers={("bridgeid", "0123")}, config_entry_id="1234" @@ -1448,7 +1485,9 @@ async def test_get_or_create_empty_then_set_default_values(device_registry): assert entry.manufacturer == "default manufacturer 1" -async def test_get_or_create_empty_then_update(device_registry): +async def test_get_or_create_empty_then_update( + device_registry: dr.DeviceRegistry, +) -> None: """Test creating an entry, then setting name, model, manufacturer.""" entry = device_registry.async_get_or_create( identifiers={("bridgeid", "0123")}, config_entry_id="1234" @@ -1480,7 +1519,9 @@ async def test_get_or_create_empty_then_update(device_registry): assert entry.manufacturer == "manufacturer 1" -async def test_get_or_create_sets_default_values(device_registry): +async def test_get_or_create_sets_default_values( + device_registry: dr.DeviceRegistry, +) -> None: """Test creating an entry, then setting default name, model, manufacturer.""" entry = device_registry.async_get_or_create( config_entry_id="1234", @@ -1506,8 +1547,8 @@ async def test_get_or_create_sets_default_values(device_registry): async def test_verify_suggested_area_does_not_overwrite_area_id( - device_registry, area_registry -): + device_registry: dr.DeviceRegistry, area_registry: ar.AreaRegistry +) -> None: """Make sure suggested area does not override a set area id.""" game_room_area = area_registry.async_create("Game Room") @@ -1539,7 +1580,9 @@ async def test_verify_suggested_area_does_not_overwrite_area_id( assert entry2.area_id == game_room_area.id -async def test_disable_config_entry_disables_devices(hass, device_registry): +async def test_disable_config_entry_disables_devices( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test that we disable entities tied to a config entry.""" config_entry = MockConfigEntry(domain="light") config_entry.add_to_hass(hass) @@ -1580,8 +1623,8 @@ async def test_disable_config_entry_disables_devices(hass, device_registry): async def test_only_disable_device_if_all_config_entries_are_disabled( - hass, device_registry -): + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test that we only disable device if all related config entries are disabled.""" config_entry1 = MockConfigEntry(domain="light") config_entry1.add_to_hass(hass) diff --git a/tests/helpers/test_discovery.py b/tests/helpers/test_discovery.py index a3913fd959..2900cb2c09 100644 --- a/tests/helpers/test_discovery.py +++ b/tests/helpers/test_discovery.py @@ -24,7 +24,7 @@ def mock_setup_component(): yield mock -async def test_listen(hass, mock_setup_component): +async def test_listen(hass: HomeAssistant, mock_setup_component) -> None: """Test discovery listen/discover combo.""" calls_single = [] @@ -50,7 +50,7 @@ async def test_listen(hass, mock_setup_component): assert calls_single[0] == ("test service", "discovery info") -async def test_platform(hass, mock_setup_component): +async def test_platform(hass: HomeAssistant, mock_setup_component) -> None: """Test discover platform method.""" calls = [] diff --git a/tests/helpers/test_discovery_flow.py b/tests/helpers/test_discovery_flow.py index 4019be8031..3b20782f5b 100644 --- a/tests/helpers/test_discovery_flow.py +++ b/tests/helpers/test_discovery_flow.py @@ -1,11 +1,10 @@ """Test the discovery flow helper.""" - from unittest.mock import AsyncMock, call, patch import pytest from homeassistant import config_entries -from homeassistant.core import EVENT_HOMEASSISTANT_STARTED, CoreState +from homeassistant.core import EVENT_HOMEASSISTANT_STARTED, CoreState, HomeAssistant from homeassistant.helpers import discovery_flow @@ -18,7 +17,7 @@ def mock_flow_init(hass): yield mock_init -async def test_async_create_flow(hass, mock_flow_init): +async def test_async_create_flow(hass: HomeAssistant, mock_flow_init) -> None: """Test we can create a flow.""" discovery_flow.async_create_flow( hass, @@ -35,7 +34,9 @@ async def test_async_create_flow(hass, mock_flow_init): ] -async def test_async_create_flow_deferred_until_started(hass, mock_flow_init): +async def test_async_create_flow_deferred_until_started( + hass: HomeAssistant, mock_flow_init +) -> None: """Test flows are deferred until started.""" hass.state = CoreState.stopped discovery_flow.async_create_flow( @@ -57,8 +58,8 @@ async def test_async_create_flow_deferred_until_started(hass, mock_flow_init): async def test_async_create_flow_checks_existing_flows_after_startup( - hass, mock_flow_init -): + hass: HomeAssistant, mock_flow_init +) -> None: """Test existing flows prevent an identical ones from being after startup.""" hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) with patch( @@ -75,8 +76,8 @@ async def test_async_create_flow_checks_existing_flows_after_startup( async def test_async_create_flow_checks_existing_flows_before_startup( - hass, mock_flow_init -): + hass: HomeAssistant, mock_flow_init +) -> None: """Test existing flows prevent an identical ones from being created before startup.""" hass.state = CoreState.stopped for _ in range(2): diff --git a/tests/helpers/test_dispatcher.py b/tests/helpers/test_dispatcher.py index 639157607b..e30aaa6e0d 100644 --- a/tests/helpers/test_dispatcher.py +++ b/tests/helpers/test_dispatcher.py @@ -131,7 +131,9 @@ async def test_simple_function_multiargs(hass: HomeAssistant) -> None: @pytest.mark.no_fail_on_log_exception -async def test_callback_exception_gets_logged(hass, caplog): +async def test_callback_exception_gets_logged( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test exception raised by signal handler.""" @callback diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 546565d744..4b505c894b 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -1,5 +1,4 @@ """Test the entity helper.""" - import asyncio import dataclasses from datetime import timedelta @@ -123,7 +122,9 @@ class TestHelpersEntity: assert state.attributes.get(ATTR_DEVICE_CLASS) == "test_class" -async def test_warn_slow_update(hass, caplog): +async def test_warn_slow_update( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Warn we log when entity update takes a long time.""" update_call = False @@ -147,7 +148,9 @@ async def test_warn_slow_update(hass, caplog): assert update_call -async def test_warn_slow_update_with_exception(hass, caplog): +async def test_warn_slow_update_with_exception( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Warn we log when entity update takes a long time and trow exception.""" update_call = False @@ -172,7 +175,9 @@ async def test_warn_slow_update_with_exception(hass, caplog): assert update_call -async def test_warn_slow_device_update_disabled(hass, caplog): +async def test_warn_slow_device_update_disabled( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Disable slow update warning with async_device_update.""" update_call = False @@ -595,7 +600,9 @@ async def test_set_context_expired(hass: HomeAssistant) -> None: assert ent._context_set is None -async def test_warn_disabled(hass, caplog): +async def test_warn_disabled( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test we warn once if we write to a disabled entity.""" entry = er.RegistryEntry( entity_id="hello.world", @@ -678,7 +685,9 @@ async def test_capability_attrs(hass: HomeAssistant) -> None: assert state.attributes["always"] == "there" -async def test_warn_slow_write_state(hass, caplog): +async def test_warn_slow_write_state( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Check that we log a warning if reading properties takes too long.""" mock_entity = entity.Entity() mock_entity.hass = hass @@ -697,7 +706,9 @@ async def test_warn_slow_write_state(hass, caplog): ) in caplog.text -async def test_warn_slow_write_state_custom_component(hass, caplog): +async def test_warn_slow_write_state_custom_component( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Check that we log a warning if reading properties takes too long.""" class CustomComponentEntity(entity.Entity): @@ -861,7 +872,7 @@ async def test_entity_category_property(hass: HomeAssistant) -> None: ("diagnostic", entity.EntityCategory.DIAGNOSTIC), ), ) -def test_entity_category_schema(value, expected): +def test_entity_category_schema(value, expected) -> None: """Test entity category schema.""" schema = vol.Schema(entity.ENTITY_CATEGORIES_SCHEMA) result = schema(value) @@ -870,7 +881,7 @@ def test_entity_category_schema(value, expected): @pytest.mark.parametrize("value", (None, "non_existing")) -def test_entity_category_schema_error(value): +def test_entity_category_schema_error(value) -> None: """Test entity category schema.""" schema = vol.Schema(entity.ENTITY_CATEGORIES_SCHEMA) with pytest.raises( @@ -903,8 +914,8 @@ async def test_entity_description_fallback() -> None: ), ) async def test_friendly_name( - hass, has_entity_name, entity_name, expected_friendly_name -): + hass: HomeAssistant, has_entity_name, entity_name, expected_friendly_name +) -> None: """Test entity_id is influenced by entity name.""" async def async_setup_entry(hass, config_entry, async_add_entities): diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 3104da02df..ac5108cb28 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -89,7 +89,9 @@ async def test_setup_recovers_when_setup_raises(hass: HomeAssistant) -> None: "homeassistant.helpers.entity_component.EntityComponent.async_setup_platform", ) @patch("homeassistant.setup.async_setup_component", return_value=True) -async def test_setup_does_discovery(mock_setup_component, mock_setup, hass): +async def test_setup_does_discovery( + mock_setup_component, mock_setup, hass: HomeAssistant +) -> None: """Test setup for discovery.""" component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -106,7 +108,7 @@ async def test_setup_does_discovery(mock_setup_component, mock_setup, hass): @patch("homeassistant.helpers.entity_platform.async_track_time_interval") -async def test_set_scan_interval_via_config(mock_track, hass): +async def test_set_scan_interval_via_config(mock_track, hass: HomeAssistant) -> None: """Test the setting of the scan interval via configuration.""" def platform_setup(hass, config, add_entities, discovery_info=None): @@ -414,7 +416,9 @@ async def test_set_service_race(hass: HomeAssistant) -> None: assert not exception -async def test_extract_all_omit_entity_id(hass, caplog): +async def test_extract_all_omit_entity_id( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test extract all with None and *.""" component = EntityComponent(_LOGGER, DOMAIN, hass) await component.async_add_entities( @@ -428,7 +432,9 @@ async def test_extract_all_omit_entity_id(hass, caplog): ) -async def test_extract_all_use_match_all(hass, caplog): +async def test_extract_all_use_match_all( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test extract all with None and *.""" component = EntityComponent(_LOGGER, DOMAIN, hass) await component.async_add_entities( diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 26e948fc5d..441f47ff12 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -161,7 +161,7 @@ async def test_update_state_adds_entities_with_update_before_add_false( @patch("homeassistant.helpers.entity_platform.async_track_time_interval") -async def test_set_scan_interval_via_platform(mock_track, hass): +async def test_set_scan_interval_via_platform(mock_track, hass: HomeAssistant) -> None: """Test the setting of the scan interval via platform.""" def platform_setup(hass, config, add_entities, discovery_info=None): @@ -224,7 +224,9 @@ async def test_platform_warn_slow_setup(hass: HomeAssistant) -> None: assert mock_call().cancel.called -async def test_platform_error_slow_setup(hass, caplog): +async def test_platform_error_slow_setup( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Don't block startup more than SLOW_SETUP_MAX_WAIT.""" with patch.object(entity_platform, "SLOW_SETUP_MAX_WAIT", 0): called = [] @@ -449,7 +451,9 @@ async def test_async_remove_with_platform_update_finishes(hass: HomeAssistant) - assert len(hass.states.async_entity_ids()) == 0 -async def test_not_adding_duplicate_entities_with_unique_id(hass, caplog): +async def test_not_adding_duplicate_entities_with_unique_id( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test for not adding duplicate entities. Also test that the entity registry is not updated for duplicates. @@ -605,7 +609,9 @@ async def test_registry_respect_entity_disabled(hass: HomeAssistant) -> None: assert hass.states.async_entity_ids() == [] -async def test_unique_id_conflict_has_priority_over_disabled_entity(hass, caplog): +async def test_unique_id_conflict_has_priority_over_disabled_entity( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test that an entity that is not unique has priority over a disabled entity.""" component = EntityComponent(_LOGGER, DOMAIN, hass) entity1 = MockEntity( @@ -684,7 +690,9 @@ async def test_setup_entry( ) -async def test_setup_entry_platform_not_ready(hass, caplog): +async def test_setup_entry_platform_not_ready( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test when an entry is not ready yet.""" async_setup_entry = Mock(side_effect=PlatformNotReady) platform = MockPlatform(async_setup_entry=async_setup_entry) @@ -703,7 +711,9 @@ async def test_setup_entry_platform_not_ready(hass, caplog): assert len(mock_call_later.mock_calls) == 1 -async def test_setup_entry_platform_not_ready_with_message(hass, caplog): +async def test_setup_entry_platform_not_ready_with_message( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test when an entry is not ready yet that includes a message.""" async_setup_entry = Mock(side_effect=PlatformNotReady("lp0 on fire")) platform = MockPlatform(async_setup_entry=async_setup_entry) @@ -724,7 +734,9 @@ async def test_setup_entry_platform_not_ready_with_message(hass, caplog): assert len(mock_call_later.mock_calls) == 1 -async def test_setup_entry_platform_not_ready_from_exception(hass, caplog): +async def test_setup_entry_platform_not_ready_from_exception( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test when an entry is not ready yet that includes the causing exception string.""" original_exception = HomeAssistantError("The device dropped the connection") platform_exception = PlatformNotReady() @@ -1022,7 +1034,9 @@ async def test_device_info_not_overrides(hass: HomeAssistant) -> None: assert device2.model == "test-model" -async def test_device_info_invalid_url(hass, caplog): +async def test_device_info_invalid_url( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test device info is forwarded correctly.""" registry = dr.async_get(hass) registry.async_get_or_create( @@ -1071,7 +1085,9 @@ async def test_device_info_invalid_url(hass, caplog): ) -async def test_device_info_homeassistant_url(hass, caplog): +async def test_device_info_homeassistant_url( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test device info with homeassistant URL.""" registry = dr.async_get(hass) registry.async_get_or_create( @@ -1115,7 +1131,9 @@ async def test_device_info_homeassistant_url(hass, caplog): assert device.configuration_url == "homeassistant://config/mqtt" -async def test_device_info_change_to_no_url(hass, caplog): +async def test_device_info_change_to_no_url( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test device info changes to no URL.""" registry = dr.async_get(hass) registry.async_get_or_create( @@ -1184,7 +1202,7 @@ async def test_entity_disabled_by_integration(hass: HomeAssistant) -> None: assert entry_disabled.disabled_by is er.RegistryEntryDisabler.INTEGRATION -async def test_entity_disabled_by_device(hass: HomeAssistant): +async def test_entity_disabled_by_device(hass: HomeAssistant) -> None: """Test entity disabled by device.""" connections = {(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")} @@ -1303,7 +1321,9 @@ async def test_override_restored_entities( assert state.state == "on" -async def test_platform_with_no_setup(hass, caplog): +async def test_platform_with_no_setup( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test setting up a platform that does not support setup.""" entity_platform = MockEntityPlatform( hass, domain="mock-integration", platform_name="mock-platform", platform=None @@ -1379,8 +1399,10 @@ class MockBlockingEntity(MockEntity): async def test_setup_entry_with_entities_that_block_forever( - hass, caplog, entity_registry -): + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + entity_registry: er.EntityRegistry, +) -> None: """Test we cancel adding entities when we reach the timeout.""" async def async_setup_entry(hass, config_entry, async_add_entities): @@ -1465,8 +1487,8 @@ class SlowEntity(MockEntity): ), ) async def test_entity_name_influences_entity_id( - hass, has_entity_name, entity_name, expected_entity_id -): + hass: HomeAssistant, has_entity_name, entity_name, expected_entity_id +) -> None: """Test entity_id is influenced by entity name.""" registry = er.async_get(hass) diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 3a4f983a47..79d6de3290 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -1,4 +1,5 @@ """Tests for the Entity Registry.""" +from typing import Any from unittest.mock import patch import pytest @@ -33,7 +34,9 @@ def update_events(hass): return events -async def test_get_or_create_returns_same_entry(hass, entity_registry, update_events): +async def test_get_or_create_returns_same_entry( + hass: HomeAssistant, entity_registry: er.EntityRegistry, update_events +) -> None: """Make sure we do not duplicate entries.""" entry = entity_registry.async_get_or_create("light", "hue", "1234") entry2 = entity_registry.async_get_or_create("light", "hue", "1234") @@ -48,7 +51,7 @@ async def test_get_or_create_returns_same_entry(hass, entity_registry, update_ev assert update_events[0]["entity_id"] == entry.entity_id -def test_get_or_create_suggested_object_id(entity_registry): +def test_get_or_create_suggested_object_id(entity_registry: er.EntityRegistry) -> None: """Test that suggested_object_id works.""" entry = entity_registry.async_get_or_create( "light", "hue", "1234", suggested_object_id="beer" @@ -57,7 +60,7 @@ def test_get_or_create_suggested_object_id(entity_registry): assert entry.entity_id == "light.beer" -def test_get_or_create_updates_data(entity_registry): +def test_get_or_create_updates_data(entity_registry: er.EntityRegistry) -> None: """Test that we update data in get_or_create.""" orig_config_entry = MockConfigEntry(domain="light") @@ -194,7 +197,9 @@ def test_get_or_create_updates_data(entity_registry): ) -def test_get_or_create_suggested_object_id_conflict_register(entity_registry): +def test_get_or_create_suggested_object_id_conflict_register( + entity_registry: er.EntityRegistry, +) -> None: """Test that we don't generate an entity id that is already registered.""" entry = entity_registry.async_get_or_create( "light", "hue", "1234", suggested_object_id="beer" @@ -207,14 +212,16 @@ def test_get_or_create_suggested_object_id_conflict_register(entity_registry): assert entry2.entity_id == "light.beer_2" -def test_get_or_create_suggested_object_id_conflict_existing(hass, entity_registry): +def test_get_or_create_suggested_object_id_conflict_existing( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test that we don't generate an entity id that currently exists.""" hass.states.async_set("light.hue_1234", "on") entry = entity_registry.async_get_or_create("light", "hue", "1234") assert entry.entity_id == "light.hue_1234_2" -def test_create_triggers_save(entity_registry): +def test_create_triggers_save(entity_registry: er.EntityRegistry) -> None: """Test that registering entry triggers a save.""" with patch.object(entity_registry, "async_schedule_save") as mock_schedule_save: entity_registry.async_get_or_create("light", "hue", "1234") @@ -222,7 +229,9 @@ def test_create_triggers_save(entity_registry): assert len(mock_schedule_save.mock_calls) == 1 -async def test_loading_saving_data(hass, entity_registry): +async def test_loading_saving_data( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test that we load/save data correctly.""" mock_config = MockConfigEntry(domain="light") @@ -293,7 +302,9 @@ async def test_loading_saving_data(hass, entity_registry): assert new_entry2.unit_of_measurement == "initial-unit_of_measurement" -def test_generate_entity_considers_registered_entities(entity_registry): +def test_generate_entity_considers_registered_entities( + entity_registry: er.EntityRegistry, +) -> None: """Test that we don't create entity id that are already registered.""" entry = entity_registry.async_get_or_create("light", "hue", "1234") assert entry.entity_id == "light.hue_1234" @@ -303,7 +314,9 @@ def test_generate_entity_considers_registered_entities(entity_registry): ) -def test_generate_entity_considers_existing_entities(hass, entity_registry): +def test_generate_entity_considers_existing_entities( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test that we don't create entity id that currently exists.""" hass.states.async_set("light.kitchen", "on") assert ( @@ -312,7 +325,7 @@ def test_generate_entity_considers_existing_entities(hass, entity_registry): ) -def test_is_registered(entity_registry): +def test_is_registered(entity_registry: er.EntityRegistry) -> None: """Test that is_registered works.""" entry = entity_registry.async_get_or_create("light", "hue", "1234") assert entity_registry.async_is_registered(entry.entity_id) @@ -320,7 +333,9 @@ def test_is_registered(entity_registry): @pytest.mark.parametrize("load_registries", [False]) -async def test_filter_on_load(hass, hass_storage): +async def test_filter_on_load( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: """Test we transform some data when loading from storage.""" hass_storage[er.STORAGE_KEY] = { "version": er.STORAGE_VERSION_MAJOR, @@ -401,7 +416,7 @@ async def test_filter_on_load(hass, hass_storage): assert entry_system_category.entity_category is None -def test_async_get_entity_id(entity_registry): +def test_async_get_entity_id(entity_registry: er.EntityRegistry) -> None: """Test that entity_id is returned.""" entry = entity_registry.async_get_or_create("light", "hue", "1234") assert entry.entity_id == "light.hue_1234" @@ -411,7 +426,9 @@ def test_async_get_entity_id(entity_registry): assert entity_registry.async_get_entity_id("light", "hue", "123") is None -async def test_updating_config_entry_id(hass, entity_registry, update_events): +async def test_updating_config_entry_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, update_events +) -> None: """Test that we update config entry id in registry.""" mock_config_1 = MockConfigEntry(domain="light", entry_id="mock-id-1") entry = entity_registry.async_get_or_create( @@ -435,7 +452,9 @@ async def test_updating_config_entry_id(hass, entity_registry, update_events): assert update_events[1]["changes"] == {"config_entry_id": "mock-id-1"} -async def test_removing_config_entry_id(hass, entity_registry, update_events): +async def test_removing_config_entry_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, update_events +) -> None: """Test that we update config entry id in registry.""" mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") @@ -456,7 +475,7 @@ async def test_removing_config_entry_id(hass, entity_registry, update_events): assert update_events[1]["entity_id"] == entry.entity_id -async def test_removing_area_id(entity_registry): +async def test_removing_area_id(entity_registry: er.EntityRegistry) -> None: """Make sure we can clear area id.""" entry = entity_registry.async_get_or_create("light", "hue", "5678") @@ -472,7 +491,7 @@ async def test_removing_area_id(entity_registry): @pytest.mark.parametrize("load_registries", [False]) -async def test_migration_1_1(hass, hass_storage): +async def test_migration_1_1(hass: HomeAssistant, hass_storage: dict[str, Any]) -> None: """Test migration from version 1.1.""" hass_storage[er.STORAGE_KEY] = { "version": 1, @@ -499,7 +518,7 @@ async def test_migration_1_1(hass, hass_storage): @pytest.mark.parametrize("load_registries", [False]) -async def test_migration_1_7(hass, hass_storage): +async def test_migration_1_7(hass: HomeAssistant, hass_storage: dict[str, Any]) -> None: """Test migration from version 1.7. This tests cleanup after frontend bug which incorrectly updated device_class @@ -570,7 +589,7 @@ async def test_migration_1_7(hass, hass_storage): assert entry.original_device_class == "class_by_integration" -async def test_update_entity_unique_id(entity_registry): +async def test_update_entity_unique_id(entity_registry: er.EntityRegistry) -> None: """Test entity's unique_id is updated.""" mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") @@ -596,7 +615,9 @@ async def test_update_entity_unique_id(entity_registry): ) -async def test_update_entity_unique_id_conflict(entity_registry): +async def test_update_entity_unique_id_conflict( + entity_registry: er.EntityRegistry, +) -> None: """Test migration raises when unique_id already in use.""" mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") entry = entity_registry.async_get_or_create( @@ -620,7 +641,7 @@ async def test_update_entity_unique_id_conflict(entity_registry): ) -async def test_update_entity_entity_id(entity_registry): +async def test_update_entity_entity_id(entity_registry: er.EntityRegistry) -> None: """Test entity's entity_id is updated.""" entry = entity_registry.async_get_or_create("light", "hue", "5678") assert ( @@ -641,7 +662,9 @@ async def test_update_entity_entity_id(entity_registry): assert entity_registry.async_get(new_entity_id) is not None -async def test_update_entity_entity_id_entity_id(hass: HomeAssistant, entity_registry): +async def test_update_entity_entity_id_entity_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test update raises when entity_id already in use.""" entry = entity_registry.async_get_or_create("light", "hue", "5678") entry2 = entity_registry.async_get_or_create("light", "hue", "1234") @@ -682,7 +705,7 @@ async def test_update_entity_entity_id_entity_id(hass: HomeAssistant, entity_reg assert entity_registry.async_get(state_entity_id) is None -async def test_update_entity(entity_registry): +async def test_update_entity(entity_registry: er.EntityRegistry) -> None: """Test updating entity.""" mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") entry = entity_registry.async_get_or_create( @@ -709,7 +732,7 @@ async def test_update_entity(entity_registry): entry = updated_entry -async def test_update_entity_options(entity_registry): +async def test_update_entity_options(entity_registry: er.EntityRegistry) -> None: """Test updating entity.""" mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") entry = entity_registry.async_get_or_create( @@ -734,7 +757,7 @@ async def test_update_entity_options(entity_registry): assert new_entry_2.options == {"light": {"minimum_brightness": 30}} -async def test_disabled_by(entity_registry): +async def test_disabled_by(entity_registry: er.EntityRegistry) -> None: """Test that we can disable an entry when we create it.""" entry = entity_registry.async_get_or_create( "light", "hue", "5678", disabled_by=er.RegistryEntryDisabler.HASS @@ -750,7 +773,9 @@ async def test_disabled_by(entity_registry): assert entry2.disabled_by is None -async def test_disabled_by_config_entry_pref(entity_registry): +async def test_disabled_by_config_entry_pref( + entity_registry: er.EntityRegistry, +) -> None: """Test config entry preference setting disabled_by.""" mock_config = MockConfigEntry( domain="light", @@ -917,7 +942,11 @@ async def test_async_get_device_class_lookup(hass: HomeAssistant) -> None: } -async def test_remove_device_removes_entities(hass, entity_registry, device_registry): +async def test_remove_device_removes_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: """Test that we remove entities tied to a device.""" config_entry = MockConfigEntry(domain="light") @@ -943,8 +972,10 @@ async def test_remove_device_removes_entities(hass, entity_registry, device_regi async def test_remove_config_entry_from_device_removes_entities( - hass, device_registry, entity_registry -): + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test that we remove entities tied to a device when config entry is removed.""" config_entry_1 = MockConfigEntry(domain="hue") config_entry_2 = MockConfigEntry(domain="device_tracker") @@ -1007,8 +1038,10 @@ async def test_remove_config_entry_from_device_removes_entities( async def test_remove_config_entry_from_device_removes_entities_2( - hass, device_registry, entity_registry -): + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test that we don't remove entities with no config entry when device is modified.""" config_entry_1 = MockConfigEntry(domain="hue") config_entry_2 = MockConfigEntry(domain="device_tracker") @@ -1047,7 +1080,11 @@ async def test_remove_config_entry_from_device_removes_entities_2( assert entity_registry.async_is_registered(entry_1.entity_id) -async def test_update_device_race(hass, device_registry, entity_registry): +async def test_update_device_race( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test race when a device is created, updated and removed.""" config_entry = MockConfigEntry(domain="light") @@ -1079,7 +1116,11 @@ async def test_update_device_race(hass, device_registry, entity_registry): assert not entity_registry.async_is_registered(entry.entity_id) -async def test_disable_device_disables_entities(hass, device_registry, entity_registry): +async def test_disable_device_disables_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test that we disable entities tied to a device.""" config_entry = MockConfigEntry(domain="light") config_entry.add_to_hass(hass) @@ -1146,8 +1187,10 @@ async def test_disable_device_disables_entities(hass, device_registry, entity_re async def test_disable_config_entry_disables_entities( - hass, device_registry, entity_registry -): + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test that we disable entities tied to a config entry.""" config_entry = MockConfigEntry(domain="light") config_entry.add_to_hass(hass) @@ -1214,8 +1257,10 @@ async def test_disable_config_entry_disables_entities( async def test_disabled_entities_excluded_from_entity_list( - hass, device_registry, entity_registry -): + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test that disabled entities are excluded from async_entries_for_device.""" config_entry = MockConfigEntry(domain="light") @@ -1250,7 +1295,7 @@ async def test_disabled_entities_excluded_from_entity_list( assert entries == [entry1, entry2] -async def test_entity_max_length_exceeded(entity_registry): +async def test_entity_max_length_exceeded(entity_registry: er.EntityRegistry) -> None: """Test that an exception is raised when the max character length is exceeded.""" long_domain_name = ( @@ -1292,7 +1337,7 @@ async def test_entity_max_length_exceeded(entity_registry): assert new_id == "sensor." + long_entity_id_name[: 255 - 7 - 2] + "_3" -async def test_resolve_entity_ids(entity_registry): +async def test_resolve_entity_ids(entity_registry: er.EntityRegistry) -> None: """Test resolving entity IDs.""" entry1 = entity_registry.async_get_or_create( @@ -1401,7 +1446,9 @@ async def test_hidden_by_str_not_allowed(hass: HomeAssistant) -> None: reg.async_update_entity(entity_id, hidden_by=er.RegistryEntryHider.USER.value) -def test_migrate_entity_to_new_platform(hass, entity_registry): +def test_migrate_entity_to_new_platform( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test migrate_entity_to_new_platform.""" orig_config_entry = MockConfigEntry(domain="light") orig_unique_id = "5678" diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 4a90c0638b..fb0925c15d 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -857,7 +857,9 @@ async def test_track_template(hass: HomeAssistant) -> None: assert iterate_calls[0][2].state == "on" -async def test_track_template_error(hass, caplog): +async def test_track_template_error( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test tracking template with error.""" template_error = Template("{{ (states.switch | lunch) > 0 }}", hass) error_calls = [] @@ -888,7 +890,9 @@ async def test_track_template_error(hass, caplog): assert "TemplateAssertionError" not in caplog.text -async def test_track_template_error_can_recover(hass, caplog): +async def test_track_template_error_can_recover( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test tracking template with error.""" hass.states.async_set("switch.data_system", "cow", {"opmode": 0}) template_error = Template( @@ -915,7 +919,9 @@ async def test_track_template_error_can_recover(hass, caplog): assert "UndefinedError" not in caplog.text -async def test_track_template_time_change(hass, caplog): +async def test_track_template_time_change( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test tracking template with time change.""" template_error = Template("{{ utcnow().minute % 2 == 0 }}", hass) calls = [] @@ -1399,7 +1405,9 @@ async def test_track_template_result_super_template_initially_false( "{% if states('sensor.test2') != 'unavailable' -%} {{'a' + 5}} {%- else -%} false {%- endif %}", ], ) -async def test_track_template_result_super_template_2(hass, availability_template): +async def test_track_template_result_super_template_2( + hass: HomeAssistant, availability_template +) -> None: """Test tracking template with super template listening to different entities.""" specific_runs = [] specific_runs_availability = [] @@ -1537,8 +1545,8 @@ async def test_track_template_result_super_template_2(hass, availability_templat ], ) async def test_track_template_result_super_template_2_initially_false( - hass, availability_template -): + hass: HomeAssistant, availability_template +) -> None: """Test tracking template with super template listening to different entities.""" specific_runs = [] specific_runs_availability = [] @@ -2136,7 +2144,9 @@ async def test_track_template_result_iterator(hass: HomeAssistant) -> None: assert filter_runs == ["", "sensor.new"] -async def test_track_template_result_errors(hass, caplog): +async def test_track_template_result_errors( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test tracking template with errors in the template.""" template_syntax_error = Template("{{states.switch", hass) @@ -3838,7 +3848,7 @@ async def test_periodic_task_duplicate_time(hass: HomeAssistant) -> None: # DST starts early morning March 28th 2021 @pytest.mark.freeze_time("2021-03-28 01:28:00+01:00") -async def test_periodic_task_entering_dst(hass, freezer): +async def test_periodic_task_entering_dst(hass: HomeAssistant, freezer) -> None: """Test periodic task behavior when entering dst.""" hass.config.set_time_zone("Europe/Vienna") specific_runs = [] @@ -3884,7 +3894,7 @@ async def test_periodic_task_entering_dst(hass, freezer): # DST starts early morning March 28th 2021 @pytest.mark.freeze_time("2021-03-28 01:59:59+01:00") -async def test_periodic_task_entering_dst_2(hass, freezer): +async def test_periodic_task_entering_dst_2(hass: HomeAssistant, freezer) -> None: """Test periodic task behavior when entering dst. This tests a task firing every second in the range 0..58 (not *:*:59) @@ -3935,7 +3945,7 @@ async def test_periodic_task_entering_dst_2(hass, freezer): # DST ends early morning October 31st 2021 @pytest.mark.freeze_time("2021-10-31 02:28:00+02:00") -async def test_periodic_task_leaving_dst(hass, freezer): +async def test_periodic_task_leaving_dst(hass: HomeAssistant, freezer) -> None: """Test periodic task behavior when leaving dst.""" hass.config.set_time_zone("Europe/Vienna") specific_runs = [] @@ -4009,7 +4019,7 @@ async def test_periodic_task_leaving_dst(hass, freezer): # DST ends early morning October 31st 2021 @pytest.mark.freeze_time("2021-10-31 02:28:00+02:00") -async def test_periodic_task_leaving_dst_2(hass, freezer): +async def test_periodic_task_leaving_dst_2(hass: HomeAssistant, freezer) -> None: """Test periodic task behavior when leaving dst.""" hass.config.set_time_zone("Europe/Vienna") specific_runs = [] @@ -4391,8 +4401,8 @@ async def test_async_track_entity_registry_updated_event(hass: HomeAssistant) -> async def test_async_track_entity_registry_updated_event_with_a_callback_that_throws( - hass, -): + hass: HomeAssistant, +) -> None: """Test tracking entity registry updates for an entity_id when one callback throws.""" entity_id = "switch.puppy_feeder" diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index 320b2c51c6..3086bebe09 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -18,7 +18,9 @@ async def test_extract_frame_integration( assert found_frame == mock_integration_frame -async def test_extract_frame_integration_with_excluded_integration(caplog): +async def test_extract_frame_integration_with_excluded_integration( + caplog: pytest.LogCaptureFixture, +) -> None: """Test extracting the current frame from integration context.""" correct_frame = Mock( filename="/home/dev/homeassistant/components/mdns/light.py", @@ -55,7 +57,7 @@ async def test_extract_frame_integration_with_excluded_integration(caplog): assert found_frame == correct_frame -async def test_extract_frame_no_integration(caplog): +async def test_extract_frame_no_integration(caplog: pytest.LogCaptureFixture) -> None: """Test extracting the current frame without integration context.""" with patch( "homeassistant.helpers.frame.extract_stack", @@ -77,7 +79,7 @@ async def test_extract_frame_no_integration(caplog): @pytest.mark.usefixtures("mock_integration_frame") @patch.object(frame, "_REPORTED_INTEGRATIONS", set()) -async def test_prevent_flooding(caplog): +async def test_prevent_flooding(caplog: pytest.LogCaptureFixture) -> None: """Test to ensure a report is only written once to the log.""" what = "accessed hi instead of hello" @@ -96,7 +98,9 @@ async def test_prevent_flooding(caplog): assert len(frame._REPORTED_INTEGRATIONS) == 1 -async def test_report_missing_integration_frame(caplog): +async def test_report_missing_integration_frame( + caplog: pytest.LogCaptureFixture, +) -> None: """Test reporting when no integration is detected.""" what = "teststring" diff --git a/tests/helpers/test_httpx_client.py b/tests/helpers/test_httpx_client.py index eb6b971f13..f9473ffaf8 100644 --- a/tests/helpers/test_httpx_client.py +++ b/tests/helpers/test_httpx_client.py @@ -1,5 +1,4 @@ """Test the httpx client helper.""" - from unittest.mock import Mock, patch import httpx @@ -98,7 +97,9 @@ async def test_get_async_client_context_manager(hass: HomeAssistant) -> None: @patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()) -async def test_warning_close_session_integration(hass, caplog): +async def test_warning_close_session_integration( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test log warning message when closing the session from integration context.""" with patch( "homeassistant.helpers.frame.extract_stack", @@ -131,7 +132,9 @@ async def test_warning_close_session_integration(hass, caplog): @patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()) -async def test_warning_close_session_custom(hass, caplog): +async def test_warning_close_session_custom( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test log warning message when closing the session from custom context.""" with patch( "homeassistant.helpers.frame.extract_stack", diff --git a/tests/helpers/test_instance_id.py b/tests/helpers/test_instance_id.py index a8c040f48b..453d18b1d2 100644 --- a/tests/helpers/test_instance_id.py +++ b/tests/helpers/test_instance_id.py @@ -1,10 +1,12 @@ """Tests for instance ID helper.""" +from typing import Any from unittest.mock import patch +from homeassistant.core import HomeAssistant from homeassistant.helpers import instance_id -async def test_get_id_empty(hass, hass_storage): +async def test_get_id_empty(hass: HomeAssistant, hass_storage: dict[str, Any]) -> None: """Get unique ID.""" uuid = await instance_id.async_get(hass) assert uuid is not None @@ -12,7 +14,9 @@ async def test_get_id_empty(hass, hass_storage): assert hass_storage["core.uuid"]["data"]["uuid"] == uuid -async def test_get_id_migrate(hass, hass_storage): +async def test_get_id_migrate( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: """Migrate existing file.""" with patch( "homeassistant.util.json.load_json", return_value={"uuid": "1234"} diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py index b706a8c755..17c513fabb 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -1,5 +1,4 @@ """Tests for the intent helpers.""" - import pytest import voluptuous as vol diff --git a/tests/helpers/test_issue_registry.py b/tests/helpers/test_issue_registry.py index 01c2d83fb4..21600b776c 100644 --- a/tests/helpers/test_issue_registry.py +++ b/tests/helpers/test_issue_registry.py @@ -1,4 +1,6 @@ """Test the repairs websocket API.""" +from typing import Any + import pytest from homeassistant.core import HomeAssistant @@ -172,7 +174,9 @@ async def test_load_issues(hass: HomeAssistant) -> None: @pytest.mark.parametrize("load_registries", [False]) -async def test_loading_issues_from_storage(hass: HomeAssistant, hass_storage) -> None: +async def test_loading_issues_from_storage( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: """Test loading stored issues on start.""" hass_storage[issue_registry.STORAGE_KEY] = { "version": issue_registry.STORAGE_VERSION_MAJOR, @@ -219,7 +223,7 @@ async def test_loading_issues_from_storage(hass: HomeAssistant, hass_storage) -> @pytest.mark.parametrize("load_registries", [False]) -async def test_migration_1_1(hass: HomeAssistant, hass_storage) -> None: +async def test_migration_1_1(hass: HomeAssistant, hass_storage: dict[str, Any]) -> None: """Test migration from version 1.1.""" hass_storage[issue_registry.STORAGE_KEY] = { "version": 1, diff --git a/tests/helpers/test_network.py b/tests/helpers/test_network.py index 1606842b75..b8fbfc9346 100644 --- a/tests/helpers/test_network.py +++ b/tests/helpers/test_network.py @@ -32,7 +32,7 @@ def mock_current_request_mock(): yield mock_current_request -async def test_get_url_internal(hass: HomeAssistant): +async def test_get_url_internal(hass: HomeAssistant) -> None: """Test getting an instance URL when the user has set an internal URL.""" assert hass.config.internal_url is None @@ -174,7 +174,7 @@ async def test_get_url_internal(hass: HomeAssistant): _get_internal_url(hass, require_current_request=True, require_ssl=True) -async def test_get_url_internal_fallback(hass: HomeAssistant): +async def test_get_url_internal_fallback(hass: HomeAssistant) -> None: """Test getting an instance URL when the user has not set an internal URL.""" assert hass.config.internal_url is None @@ -230,7 +230,7 @@ async def test_get_url_internal_fallback(hass: HomeAssistant): _get_internal_url(hass, require_ssl=True) -async def test_get_url_external(hass: HomeAssistant): +async def test_get_url_external(hass: HomeAssistant) -> None: """Test getting an instance URL when the user has set an external URL.""" assert hass.config.external_url is None @@ -356,7 +356,7 @@ async def test_get_url_external(hass: HomeAssistant): _get_external_url(hass, require_current_request=True, require_ssl=True) -async def test_get_cloud_url(hass: HomeAssistant): +async def test_get_cloud_url(hass: HomeAssistant) -> None: """Test getting an instance URL when the user has set an external URL.""" assert hass.config.external_url is None hass.config.components.add("cloud") @@ -394,7 +394,7 @@ async def test_get_cloud_url(hass: HomeAssistant): _get_cloud_url(hass) -async def test_get_external_url_cloud_fallback(hass: HomeAssistant): +async def test_get_external_url_cloud_fallback(hass: HomeAssistant) -> None: """Test getting an external instance URL with cloud fallback.""" assert hass.config.external_url is None @@ -454,7 +454,7 @@ async def test_get_external_url_cloud_fallback(hass: HomeAssistant): ) -async def test_get_url(hass: HomeAssistant): +async def test_get_url(hass: HomeAssistant) -> None: """Test getting an instance URL.""" assert hass.config.external_url is None assert hass.config.internal_url is None @@ -552,7 +552,7 @@ async def test_get_url(hass: HomeAssistant): assert get_url(hass, allow_internal=False) -async def test_get_request_host(hass: HomeAssistant): +async def test_get_request_host(hass: HomeAssistant) -> None: """Test getting the host of the current web request from the request context.""" with pytest.raises(NoURLAvailableError): _get_request_host() @@ -567,7 +567,7 @@ async def test_get_request_host(hass: HomeAssistant): async def test_get_current_request_url_with_known_host( hass: HomeAssistant, current_request -): +) -> None: """Test getting current request URL with known hosts addresses.""" hass.config.api = Mock(use_ssl=False, port=8123, local_ip="127.0.0.1") assert hass.config.internal_url is None @@ -623,7 +623,7 @@ async def test_get_current_request_url_with_known_host( get_url(hass, require_current_request=True) -async def test_is_internal_request(hass: HomeAssistant, mock_current_request): +async def test_is_internal_request(hass: HomeAssistant, mock_current_request) -> None: """Test if accessing an instance on its internal URL.""" # Test with internal URL: http://example.local:8123 await async_process_ha_core_config( diff --git a/tests/helpers/test_reload.py b/tests/helpers/test_reload.py index fdc8ef31e1..ad3b7ccb24 100644 --- a/tests/helpers/test_reload.py +++ b/tests/helpers/test_reload.py @@ -153,8 +153,8 @@ async def test_setup_reload_service_when_async_process_component_config_fails( async def test_setup_reload_service_with_platform_that_provides_async_reset_platform( - hass, -): + hass: HomeAssistant, +) -> None: """Test setting up a reload service using a platform that has its own async_reset_platform.""" component_setup = AsyncMock(return_value=True) diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index fdd3e4b8e1..1e8fa8b7fb 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -1,5 +1,6 @@ """The tests for the Restore component.""" from datetime import datetime, timedelta +from typing import Any from unittest.mock import patch from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP @@ -348,7 +349,9 @@ async def test_state_saved_on_remove(hass: HomeAssistant) -> None: assert set(state.attributes["complicated"]["value"]) == {1, 2, now.isoformat()} -async def test_restoring_invalid_entity_id(hass, hass_storage): +async def test_restoring_invalid_entity_id( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: """Test restoring invalid entity IDs.""" entity = RestoreEntity() entity.hass = hass diff --git a/tests/helpers/test_schema_config_entry_flow.py b/tests/helpers/test_schema_config_entry_flow.py index 05e8b8c467..9919a53839 100644 --- a/tests/helpers/test_schema_config_entry_flow.py +++ b/tests/helpers/test_schema_config_entry_flow.py @@ -107,7 +107,7 @@ async def test_name(hass: HomeAssistant) -> None: @pytest.mark.parametrize("marker", (vol.Required, vol.Optional)) async def test_config_flow_advanced_option( hass: HomeAssistant, manager: data_entry_flow.FlowManager, marker -): +) -> None: """Test handling of advanced options in config flow.""" manager.hass = hass @@ -202,7 +202,7 @@ async def test_config_flow_advanced_option( @pytest.mark.parametrize("marker", (vol.Required, vol.Optional)) async def test_options_flow_advanced_option( hass: HomeAssistant, manager: data_entry_flow.FlowManager, marker -): +) -> None: """Test handling of advanced options in options flow.""" manager.hass = hass diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 350672acce..5328a1d38e 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -1,5 +1,4 @@ """The tests for the Script component.""" - import asyncio from contextlib import contextmanager from datetime import timedelta @@ -139,7 +138,9 @@ def async_watch_for_action(script_obj, message): return flag -async def test_firing_event_basic(hass, caplog): +async def test_firing_event_basic( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test the firing of events.""" event = "test_event" context = Context() @@ -237,7 +238,9 @@ async def test_firing_event_template(hass: HomeAssistant) -> None: ) -async def test_calling_service_basic(hass, caplog): +async def test_calling_service_basic( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test the calling of a service.""" context = Context() calls = async_mock_service(hass, "test", "script") @@ -441,7 +444,9 @@ async def test_multiple_runs_no_wait(hass: HomeAssistant) -> None: assert len(calls) == 4 -async def test_activating_scene(hass, caplog): +async def test_activating_scene( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test the activation of a scene.""" context = Context() calls = async_mock_service(hass, scene.DOMAIN, SERVICE_TURN_ON) @@ -466,7 +471,7 @@ async def test_activating_scene(hass, caplog): @pytest.mark.parametrize("count", [1, 3]) -async def test_stop_no_wait(hass, count): +async def test_stop_no_wait(hass: HomeAssistant, count) -> None: """Test stopping script.""" service_started_sem = asyncio.Semaphore(0) finish_service_event = asyncio.Event() @@ -616,7 +621,9 @@ async def test_delay_template_ok(hass: HomeAssistant) -> None: ) -async def test_delay_template_invalid(hass, caplog): +async def test_delay_template_invalid( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test the delay as a template that fails.""" event = "test_event" events = async_capture_events(hass, event) @@ -677,7 +684,9 @@ async def test_delay_template_complex_ok(hass: HomeAssistant) -> None: ) -async def test_delay_template_complex_invalid(hass, caplog): +async def test_delay_template_complex_invalid( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test the delay with a complex template that fails.""" event = "test_event" events = async_capture_events(hass, event) @@ -751,7 +760,7 @@ async def test_cancel_delay(hass: HomeAssistant) -> None: @pytest.mark.parametrize("action_type", ["template", "trigger"]) -async def test_wait_basic(hass, action_type): +async def test_wait_basic(hass: HomeAssistant, action_type) -> None: """Test wait actions.""" wait_alias = "wait step" action = {"alias": wait_alias} @@ -847,7 +856,7 @@ async def test_wait_for_trigger_variables(hass: HomeAssistant) -> None: @pytest.mark.parametrize("action_type", ["template", "trigger"]) -async def test_wait_basic_times_out(hass, action_type): +async def test_wait_basic_times_out(hass: HomeAssistant, action_type) -> None: """Test wait actions times out when the action does not happen.""" wait_alias = "wait step" action = {"alias": wait_alias} @@ -896,7 +905,7 @@ async def test_wait_basic_times_out(hass, action_type): @pytest.mark.parametrize("action_type", ["template", "trigger"]) -async def test_multiple_runs_wait(hass, action_type): +async def test_multiple_runs_wait(hass: HomeAssistant, action_type) -> None: """Test multiple runs with wait in script.""" event = "test_event" events = async_capture_events(hass, event) @@ -951,7 +960,7 @@ async def test_multiple_runs_wait(hass, action_type): @pytest.mark.parametrize("action_type", ["template", "trigger"]) -async def test_cancel_wait(hass, action_type): +async def test_cancel_wait(hass: HomeAssistant, action_type) -> None: """Test the cancelling while wait is present.""" event = "test_event" events = async_capture_events(hass, event) @@ -1047,7 +1056,9 @@ async def test_wait_template_not_schedule(hass: HomeAssistant) -> None: "timeout_param", [5, "{{ 5 }}", {"seconds": 5}, {"seconds": "{{ 5 }}"}] ) @pytest.mark.parametrize("action_type", ["template", "trigger"]) -async def test_wait_timeout(hass, caplog, timeout_param, action_type): +async def test_wait_timeout( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, timeout_param, action_type +) -> None: """Test the wait timeout option.""" event = "test_event" events = async_capture_events(hass, event) @@ -1113,8 +1124,8 @@ async def test_wait_timeout(hass, caplog, timeout_param, action_type): ) @pytest.mark.parametrize("action_type", ["template", "trigger"]) async def test_wait_continue_on_timeout( - hass, continue_on_timeout, n_events, action_type -): + hass: HomeAssistant, continue_on_timeout, n_events, action_type +) -> None: """Test the wait continue_on_timeout option.""" event = "test_event" events = async_capture_events(hass, event) @@ -1274,7 +1285,7 @@ async def test_wait_template_with_utcnow_no_match(hass: HomeAssistant) -> None: @pytest.mark.parametrize("mode", ["no_timeout", "timeout_finish", "timeout_not_finish"]) @pytest.mark.parametrize("action_type", ["template", "trigger"]) -async def test_wait_variables_out(hass, mode, action_type): +async def test_wait_variables_out(hass: HomeAssistant, mode, action_type) -> None: """Test the wait output variable.""" event = "test_event" events = async_capture_events(hass, event) @@ -1342,7 +1353,9 @@ async def test_wait_variables_out(hass, mode, action_type): assert float(remaining) == 0.0 -async def test_wait_for_trigger_bad(hass, caplog): +async def test_wait_for_trigger_bad( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test bad wait_for_trigger.""" sequence = cv.SCRIPT_SCHEMA( {"wait_for_trigger": {"platform": "state", "entity_id": "sensor.abc"}} @@ -1374,7 +1387,9 @@ async def test_wait_for_trigger_bad(hass, caplog): ) -async def test_wait_for_trigger_generated_exception(hass, caplog): +async def test_wait_for_trigger_generated_exception( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test bad wait_for_trigger.""" sequence = cv.SCRIPT_SCHEMA( {"wait_for_trigger": {"platform": "state", "entity_id": "sensor.abc"}} @@ -1408,7 +1423,9 @@ async def test_wait_for_trigger_generated_exception(hass, caplog): ) -async def test_condition_warning(hass, caplog): +async def test_condition_warning( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test warning on condition.""" event = "test_event" events = async_capture_events(hass, event) @@ -1447,7 +1464,9 @@ async def test_condition_warning(hass, caplog): ) -async def test_condition_basic(hass, caplog): +async def test_condition_basic( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test if we can use conditions in a script.""" event = "test_event" events = async_capture_events(hass, event) @@ -1502,7 +1521,9 @@ async def test_condition_basic(hass, caplog): ) -async def test_condition_subscript(hass, caplog): +async def test_condition_subscript( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test failing conditions in a subscript don't stop the parent script.""" event = "test_event" events = async_capture_events(hass, event) @@ -1551,7 +1572,9 @@ async def test_condition_subscript(hass, caplog): ) -async def test_and_default_condition(hass, caplog): +async def test_and_default_condition( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test that a list of conditions evaluates as AND.""" alias = "condition step" sequence = cv.SCRIPT_SCHEMA( @@ -1587,7 +1610,9 @@ async def test_and_default_condition(hass, caplog): assert f"Test condition {alias}: False" in caplog.text -async def test_shorthand_template_condition(hass, caplog): +async def test_shorthand_template_condition( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test if we can use shorthand template conditions in a script.""" event = "test_event" events = async_capture_events(hass, event) @@ -1641,7 +1666,9 @@ async def test_shorthand_template_condition(hass, caplog): ) -async def test_condition_validation(hass, caplog): +async def test_condition_validation( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test if we can use conditions which validate late in a script.""" registry = er.async_get(hass) entry = registry.async_get_or_create( @@ -1716,7 +1743,7 @@ async def test_condition_validation(hass, caplog): @patch("homeassistant.helpers.script.condition.async_from_config") -async def test_condition_created_once(async_from_config, hass): +async def test_condition_created_once(async_from_config, hass: HomeAssistant) -> None: """Test that the conditions do not get created multiple times.""" sequence = cv.SCRIPT_SCHEMA( { @@ -1764,7 +1791,9 @@ async def test_condition_all_cached(hass: HomeAssistant) -> None: @pytest.mark.parametrize("count", [3, script.ACTION_TRACE_NODE_MAX_LEN * 2]) -async def test_repeat_count(hass, caplog, count): +async def test_repeat_count( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, count +) -> None: """Test repeat action w/ count option.""" event = "test_event" events = async_capture_events(hass, event) @@ -1827,7 +1856,9 @@ async def test_repeat_count(hass, caplog, count): ) -async def test_repeat_count_0(hass, caplog): +async def test_repeat_count_0( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test repeat action w/ count option.""" event = "test_event" events = async_capture_events(hass, event) @@ -2058,7 +2089,9 @@ async def test_repeat_for_each_non_list_template(hass: HomeAssistant) -> None: ) -async def test_repeat_for_each_invalid_template(hass: HomeAssistant, caplog) -> None: +async def test_repeat_for_each_invalid_template( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test repeat action using for each with an invalid template.""" events = async_capture_events(hass, "test_event") sequence = cv.SCRIPT_SCHEMA( @@ -2092,7 +2125,9 @@ async def test_repeat_for_each_invalid_template(hass: HomeAssistant, caplog) -> @pytest.mark.parametrize("condition", ["while", "until"]) -async def test_repeat_condition_warning(hass, caplog, condition): +async def test_repeat_condition_warning( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition +) -> None: """Test warning on repeat conditions.""" event = "test_event" events = async_capture_events(hass, event) @@ -2154,7 +2189,9 @@ async def test_repeat_condition_warning(hass, caplog, condition): @pytest.mark.parametrize("condition", ["while", "until"]) @pytest.mark.parametrize("direct_template", [False, True]) -async def test_repeat_conditional(hass, condition, direct_template): +async def test_repeat_conditional( + hass: HomeAssistant, condition, direct_template +) -> None: """Test repeat action w/ while option.""" event = "test_event" events = async_capture_events(hass, event) @@ -2226,7 +2263,9 @@ async def test_repeat_conditional(hass, condition, direct_template): assert event.data.get("index") == index + 1 -async def test_repeat_until_condition_validation(hass, caplog): +async def test_repeat_until_condition_validation( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test if we can use conditions in repeat until conditions which validate late.""" registry = er.async_get(hass) entry = registry.async_get_or_create( @@ -2286,7 +2325,9 @@ async def test_repeat_until_condition_validation(hass, caplog): ) -async def test_repeat_while_condition_validation(hass, caplog): +async def test_repeat_while_condition_validation( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test if we can use conditions in repeat while conditions which validate late.""" registry = er.async_get(hass) entry = registry.async_get_or_create( @@ -2347,7 +2388,7 @@ async def test_repeat_while_condition_validation(hass, caplog): @pytest.mark.parametrize("condition", ["while", "until"]) -async def test_repeat_var_in_condition(hass, condition): +async def test_repeat_var_in_condition(hass: HomeAssistant, condition) -> None: """Test repeat action w/ while option.""" event = "test_event" events = async_capture_events(hass, event) @@ -2439,7 +2480,9 @@ async def test_repeat_var_in_condition(hass, condition): (MappingProxyType({"x": 1}), {"repeat": None, "x": 1}, 1), ], ) -async def test_repeat_nested(hass, variables, first_last, inside_x): +async def test_repeat_nested( + hass: HomeAssistant, variables, first_last, inside_x +) -> None: """Test nested repeats.""" event = "test_event" events = async_capture_events(hass, event) @@ -2576,7 +2619,9 @@ async def test_repeat_nested(hass, variables, first_last, inside_x): assert_action_trace(expected_trace) -async def test_choose_warning(hass, caplog): +async def test_choose_warning( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test warning on choose.""" event = "test_event" events = async_capture_events(hass, event) @@ -2628,7 +2673,9 @@ async def test_choose_warning(hass, caplog): @pytest.mark.parametrize( ("var", "result"), [(1, "first"), (2, "second"), (3, "default")] ) -async def test_choose(hass, caplog, var, result): +async def test_choose( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, var, result +) -> None: """Test choose action.""" event = "test_event" events = async_capture_events(hass, event) @@ -2711,7 +2758,9 @@ async def test_choose(hass, caplog, var, result): assert_action_trace(expected_trace) -async def test_choose_condition_validation(hass, caplog): +async def test_choose_condition_validation( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test if we can use conditions in choose actions which validate late.""" registry = er.async_get(hass) entry = registry.async_get_or_create( @@ -2801,7 +2850,9 @@ async def test_choose_condition_validation(hass, caplog): {"choose": [], "default": {"event": "abc"}}, ], ) -async def test_multiple_runs_repeat_choose(hass, caplog, action): +async def test_multiple_runs_repeat_choose( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, action +) -> None: """Test parallel runs with repeat & choose actions & max_runs > default.""" max_runs = script.DEFAULT_MAX + 1 script_obj = script.Script( @@ -3786,7 +3837,9 @@ def does_not_raise(): yield -async def test_script_mode_single(hass, caplog): +async def test_script_mode_single( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test overlapping runs with max_runs = 1.""" event = "test_event" events = async_capture_events(hass, event) @@ -3831,7 +3884,13 @@ async def test_script_mode_single(hass, caplog): @pytest.mark.parametrize( ("script_mode", "max_runs"), [("single", 1), ("parallel", 2), ("queued", 2)] ) -async def test_max_exceeded(hass, caplog, max_exceeded, script_mode, max_runs): +async def test_max_exceeded( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + max_exceeded, + script_mode, + max_runs, +) -> None: """Test max_exceeded option.""" sequence = cv.SCRIPT_SCHEMA( {"wait_template": "{{ states.switch.test.state == 'off' }}"} @@ -3885,7 +3944,13 @@ async def test_max_exceeded(hass, caplog, max_exceeded, script_mode, max_runs): ("script_mode", "messages", "last_events"), [("restart", ["Restarting"], [2]), ("parallel", [], [2, 2])], ) -async def test_script_mode_2(hass, caplog, script_mode, messages, last_events): +async def test_script_mode_2( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + script_mode, + messages, + last_events, +) -> None: """Test overlapping runs with max_runs > 1.""" event = "test_event" events = async_capture_events(hass, event) @@ -4119,7 +4184,9 @@ async def test_script_mode_queued_stop(hass: HomeAssistant) -> None: assert script_obj.runs == 0 -async def test_script_logging(hass, caplog): +async def test_script_logging( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test script logging.""" script_obj = script.Script(hass, [], "Script with % Name", "test_domain") script_obj._log("Test message with name %s", 1) @@ -4127,7 +4194,9 @@ async def test_script_logging(hass, caplog): assert "Script with % Name: Test message with name 1" in caplog.text -async def test_shutdown_at(hass, caplog): +async def test_shutdown_at( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test stopping scripts at shutdown.""" delay_alias = "delay step" sequence = cv.SCRIPT_SCHEMA({"delay": {"seconds": 120}, "alias": delay_alias}) @@ -4156,7 +4225,9 @@ async def test_shutdown_at(hass, caplog): assert_action_trace(expected_trace) -async def test_shutdown_after(hass, caplog): +async def test_shutdown_after( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test stopping scripts at shutdown.""" delay_alias = "delay step" sequence = cv.SCRIPT_SCHEMA({"delay": {"seconds": 120}, "alias": delay_alias}) @@ -4192,7 +4263,9 @@ async def test_shutdown_after(hass, caplog): assert_action_trace(expected_trace) -async def test_start_script_after_shutdown(hass, caplog): +async def test_start_script_after_shutdown( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test starting scripts after shutdown is blocked.""" delay_alias = "delay step" sequence = cv.SCRIPT_SCHEMA({"delay": {"seconds": 120}, "alias": delay_alias}) @@ -4212,7 +4285,9 @@ async def test_start_script_after_shutdown(hass, caplog): assert "Home Assistant is shutting down, starting script blocked" in caplog.text -async def test_update_logger(hass, caplog): +async def test_update_logger( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test updating logger.""" sequence = cv.SCRIPT_SCHEMA({"event": "test_event"}) script_obj = script.Script(hass, sequence, "Test Name", "test_domain") @@ -4231,7 +4306,9 @@ async def test_update_logger(hass, caplog): assert log_name in caplog.text -async def test_started_action(hass, caplog): +async def test_started_action( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test the callback of started_action.""" event = "test_event" log_message = "The script started!" @@ -4250,7 +4327,9 @@ async def test_started_action(hass, caplog): assert log_message in caplog.text -async def test_set_variable(hass, caplog): +async def test_set_variable( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test setting variables in scripts.""" alias = "variables step" sequence = cv.SCRIPT_SCHEMA( @@ -4290,7 +4369,9 @@ async def test_set_variable(hass, caplog): assert_action_trace(expected_trace) -async def test_set_redefines_variable(hass, caplog): +async def test_set_redefines_variable( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test setting variables based on their current value.""" sequence = cv.SCRIPT_SCHEMA( [ @@ -4685,7 +4766,9 @@ async def test_platform_async_validate_action_config(hass: HomeAssistant) -> Non device_automation_validate_action_mock.assert_awaited() -async def test_stop_action(hass, caplog): +async def test_stop_action( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test if automation stops on calling the stop action.""" event = "test_event" events = async_capture_events(hass, event) @@ -4726,8 +4809,13 @@ async def test_stop_action(hass, caplog): ), ) async def test_stop_action_subscript( - hass, caplog, error, error_type, logmsg, script_execution -): + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + error, + error_type, + logmsg, + script_execution, +) -> None: """Test if automation stops on calling the stop action from a sub-script.""" event = "test_event" events = async_capture_events(hass, event) @@ -4777,7 +4865,9 @@ async def test_stop_action_subscript( ) -async def test_stop_action_with_error(hass, caplog): +async def test_stop_action_with_error( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test if automation fails on calling the error action.""" event = "test_event" events = async_capture_events(hass, event) @@ -5038,7 +5128,9 @@ async def test_disabled_actions( ) -async def test_condition_and_shorthand(hass, caplog): +async def test_condition_and_shorthand( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test if we can use the shorthand and conditions in a script.""" events = async_capture_events(hass, "test_event") sequence = cv.SCRIPT_SCHEMA( @@ -5077,7 +5169,9 @@ async def test_condition_and_shorthand(hass, caplog): ) -async def test_condition_or_shorthand(hass, caplog): +async def test_condition_or_shorthand( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test if we can use the shorthand or conditions in a script.""" events = async_capture_events(hass, "test_event") sequence = cv.SCRIPT_SCHEMA( @@ -5116,7 +5210,9 @@ async def test_condition_or_shorthand(hass, caplog): ) -async def test_condition_not_shorthand(hass, caplog): +async def test_condition_not_shorthand( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test if we can use the shorthand not conditions in a script.""" events = async_capture_events(hass, "test_event") sequence = cv.SCRIPT_SCHEMA( diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 78d21bcc0a..75e8a8dc54 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -17,7 +17,7 @@ FAKE_UUID = "a266a680b608c32770e6c45bfe6b8411" {"entity": None}, ), ) -def test_valid_base_schema(schema): +def test_valid_base_schema(schema) -> None: """Test base schema validation.""" selector.validate_selector(schema) @@ -33,7 +33,7 @@ def test_valid_base_schema(schema): {"device": {}, "entity": {}}, ), ) -def test_invalid_base_schema(schema): +def test_invalid_base_schema(schema) -> None: """Test base schema validation.""" with pytest.raises(vol.Invalid): selector.validate_selector(schema) @@ -109,7 +109,7 @@ def _test_selector( ), ), ) -def test_device_selector_schema(schema, valid_selections, invalid_selections): +def test_device_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test device selector.""" _test_selector("device", schema, valid_selections, invalid_selections) @@ -169,7 +169,7 @@ def test_device_selector_schema(schema, valid_selections, invalid_selections): ), ), ) -def test_entity_selector_schema(schema, valid_selections, invalid_selections): +def test_entity_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test entity selector.""" _test_selector("entity", schema, valid_selections, invalid_selections) @@ -216,7 +216,7 @@ def test_entity_selector_schema(schema, valid_selections, invalid_selections): ), ), ) -def test_area_selector_schema(schema, valid_selections, invalid_selections): +def test_area_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test area selector.""" _test_selector("area", schema, valid_selections, invalid_selections) @@ -244,7 +244,7 @@ def test_area_selector_schema(schema, valid_selections, invalid_selections): ({"mode": "box", "step": "any"}, (), ()), ), ) -def test_number_selector_schema(schema, valid_selections, invalid_selections): +def test_number_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test number selector.""" _test_selector("number", schema, valid_selections, invalid_selections) @@ -262,7 +262,7 @@ def test_number_selector_schema(schema, valid_selections, invalid_selections): }, ), ) -def test_number_selector_schema_error(schema): +def test_number_selector_schema_error(schema) -> None: """Test number selector.""" with pytest.raises(vol.Invalid): selector.validate_selector({"number": schema}) @@ -272,7 +272,7 @@ def test_number_selector_schema_error(schema): ("schema", "valid_selections", "invalid_selections"), (({}, ("abc123",), (None,)),), ) -def test_addon_selector_schema(schema, valid_selections, invalid_selections): +def test_addon_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test add-on selector.""" _test_selector("addon", schema, valid_selections, invalid_selections) @@ -281,7 +281,7 @@ def test_addon_selector_schema(schema, valid_selections, invalid_selections): ("schema", "valid_selections", "invalid_selections"), (({}, (1, "one", None), ()),), # Everything can be coarced to bool ) -def test_boolean_selector_schema(schema, valid_selections, invalid_selections): +def test_boolean_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test boolean selector.""" _test_selector( "boolean", @@ -307,7 +307,9 @@ def test_boolean_selector_schema(schema, valid_selections, invalid_selections): ), ), ) -def test_config_entry_selector_schema(schema, valid_selections, invalid_selections): +def test_config_entry_selector_schema( + schema, valid_selections, invalid_selections +) -> None: """Test boolean selector.""" _test_selector("config_entry", schema, valid_selections, invalid_selections) @@ -316,7 +318,7 @@ def test_config_entry_selector_schema(schema, valid_selections, invalid_selectio ("schema", "valid_selections", "invalid_selections"), (({}, ("00:00:00",), ("blah", None)),), ) -def test_time_selector_schema(schema, valid_selections, invalid_selections): +def test_time_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test time selector.""" _test_selector("time", schema, valid_selections, invalid_selections) @@ -331,7 +333,7 @@ def test_time_selector_schema(schema, valid_selections, invalid_selections): ), ), ) -def test_state_selector_schema(schema, valid_selections, invalid_selections): +def test_state_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test state selector.""" _test_selector("state", schema, valid_selections, invalid_selections) @@ -365,7 +367,7 @@ def test_state_selector_schema(schema, valid_selections, invalid_selections): ), ), ) -def test_target_selector_schema(schema, valid_selections, invalid_selections): +def test_target_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test target selector.""" _test_selector("target", schema, valid_selections, invalid_selections) @@ -374,7 +376,7 @@ def test_target_selector_schema(schema, valid_selections, invalid_selections): ("schema", "valid_selections", "invalid_selections"), (({}, ("abc123",), ()),), ) -def test_action_selector_schema(schema, valid_selections, invalid_selections): +def test_action_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test action sequence selector.""" _test_selector("action", schema, valid_selections, invalid_selections) @@ -383,7 +385,7 @@ def test_action_selector_schema(schema, valid_selections, invalid_selections): ("schema", "valid_selections", "invalid_selections"), (({}, ("abc123",), ()),), ) -def test_object_selector_schema(schema, valid_selections, invalid_selections): +def test_object_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test object selector.""" _test_selector("object", schema, valid_selections, invalid_selections) @@ -396,7 +398,7 @@ def test_object_selector_schema(schema, valid_selections, invalid_selections): ({"multiline": False, "type": "email"}, (), ()), ), ) -def test_text_selector_schema(schema, valid_selections, invalid_selections): +def test_text_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test text selector.""" _test_selector("text", schema, valid_selections, invalid_selections) @@ -469,7 +471,7 @@ def test_text_selector_schema(schema, valid_selections, invalid_selections): ), ), ) -def test_select_selector_schema(schema, valid_selections, invalid_selections): +def test_select_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test select selector.""" _test_selector("select", schema, valid_selections, invalid_selections) @@ -485,7 +487,7 @@ def test_select_selector_schema(schema, valid_selections, invalid_selections): {"options": ["red", {"value": "green", "label": "Emerald Green"}]}, ), ) -def test_select_selector_schema_error(schema): +def test_select_selector_schema_error(schema) -> None: """Test select selector.""" with pytest.raises(vol.Invalid): selector.validate_selector({"select": schema}) @@ -506,7 +508,9 @@ def test_select_selector_schema_error(schema): ), ), ) -def test_attribute_selector_schema(schema, valid_selections, invalid_selections): +def test_attribute_selector_schema( + schema, valid_selections, invalid_selections +) -> None: """Test attribute selector.""" _test_selector("attribute", schema, valid_selections, invalid_selections) @@ -529,7 +533,7 @@ def test_attribute_selector_schema(schema, valid_selections, invalid_selections) ), ), ) -def test_duration_selector_schema(schema, valid_selections, invalid_selections): +def test_duration_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test duration selector.""" _test_selector("duration", schema, valid_selections, invalid_selections) @@ -544,7 +548,7 @@ def test_duration_selector_schema(schema, valid_selections, invalid_selections): ), ), ) -def test_icon_selector_schema(schema, valid_selections, invalid_selections): +def test_icon_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test icon selector.""" _test_selector("icon", schema, valid_selections, invalid_selections) @@ -559,7 +563,7 @@ def test_icon_selector_schema(schema, valid_selections, invalid_selections): ), ), ) -def test_theme_selector_schema(schema, valid_selections, invalid_selections): +def test_theme_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test theme selector.""" _test_selector("theme", schema, valid_selections, invalid_selections) @@ -586,7 +590,7 @@ def test_theme_selector_schema(schema, valid_selections, invalid_selections): ), ), ) -def test_media_selector_schema(schema, valid_selections, invalid_selections): +def test_media_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test media selector.""" def drop_metadata(data): @@ -630,7 +634,7 @@ def test_media_selector_schema(schema, valid_selections, invalid_selections): ), ), ) -def test_location_selector_schema(schema, valid_selections, invalid_selections): +def test_location_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test location selector.""" _test_selector("location", schema, valid_selections, invalid_selections) @@ -646,7 +650,9 @@ def test_location_selector_schema(schema, valid_selections, invalid_selections): ), ), ) -def test_rgb_color_selector_schema(schema, valid_selections, invalid_selections): +def test_rgb_color_selector_schema( + schema, valid_selections, invalid_selections +) -> None: """Test color_rgb selector.""" _test_selector("color_rgb", schema, valid_selections, invalid_selections) @@ -667,7 +673,9 @@ def test_rgb_color_selector_schema(schema, valid_selections, invalid_selections) ), ), ) -def test_color_tempselector_schema(schema, valid_selections, invalid_selections): +def test_color_tempselector_schema( + schema, valid_selections, invalid_selections +) -> None: """Test color_temp selector.""" _test_selector("color_temp", schema, valid_selections, invalid_selections) @@ -683,7 +691,7 @@ def test_color_tempselector_schema(schema, valid_selections, invalid_selections) ), ), ) -def test_date_selector_schema(schema, valid_selections, invalid_selections): +def test_date_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test date selector.""" _test_selector("date", schema, valid_selections, invalid_selections) @@ -699,7 +707,7 @@ def test_date_selector_schema(schema, valid_selections, invalid_selections): ), ), ) -def test_datetime_selector_schema(schema, valid_selections, invalid_selections): +def test_datetime_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test datetime selector.""" _test_selector("datetime", schema, valid_selections, invalid_selections) @@ -709,7 +717,7 @@ def test_datetime_selector_schema(schema, valid_selections, invalid_selections): ("schema", "valid_selections", "invalid_selections"), (({}, ("abc123", "{{ now() }}"), (None, "{{ incomplete }", "{% if True %}Hi!")),), ) -def test_template_selector_schema(schema, valid_selections, invalid_selections): +def test_template_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test template selector.""" _test_selector("template", schema, valid_selections, invalid_selections) @@ -724,7 +732,7 @@ def test_template_selector_schema(schema, valid_selections, invalid_selections): ), ), ) -def test_file_selector_schema(schema, valid_selections, invalid_selections): +def test_file_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test file selector.""" _test_selector("file", schema, valid_selections, invalid_selections) diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 0cabacb63e..9f47bb7219 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -31,6 +31,7 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockEntity, + MockUser, async_mock_service, get_test_home_assistant, mock_device_registry, @@ -424,7 +425,7 @@ async def test_service_call_entry_id(hass: HomeAssistant) -> None: @pytest.mark.parametrize("target", ("all", "none")) -async def test_service_call_all_none(hass, target): +async def test_service_call_all_none(hass: HomeAssistant, target) -> None: """Test service call targeting all.""" calls = async_mock_service(hass, "test_domain", "test_service") @@ -474,7 +475,7 @@ async def test_extract_entity_ids(hass: HomeAssistant) -> None: ) -async def test_extract_entity_ids_from_area(hass, area_mock): +async def test_extract_entity_ids_from_area(hass: HomeAssistant, area_mock) -> None: """Test extract_entity_ids method with areas.""" call = ServiceCall("light", "turn_on", {"area_id": "own-area"}) @@ -505,7 +506,7 @@ async def test_extract_entity_ids_from_area(hass, area_mock): ) -async def test_extract_entity_ids_from_devices(hass, area_mock): +async def test_extract_entity_ids_from_devices(hass: HomeAssistant, area_mock) -> None: """Test extract_entity_ids method with devices.""" assert await service.async_extract_entity_ids( hass, ServiceCall("light", "turn_on", {"device_id": "device-no-area-id"}) @@ -551,7 +552,7 @@ async def test_async_get_all_descriptions(hass: HomeAssistant) -> None: assert "fields" in descriptions[logger.DOMAIN]["set_level"] -async def test_call_with_required_features(hass, mock_entities): +async def test_call_with_required_features(hass: HomeAssistant, mock_entities) -> None: """Test service calls invoked only if entity has required features.""" test_service_mock = AsyncMock(return_value=None) await service.entity_service_call( @@ -585,7 +586,9 @@ async def test_call_with_required_features(hass, mock_entities): assert test_service_mock.call_count == 0 -async def test_call_with_both_required_features(hass, mock_entities): +async def test_call_with_both_required_features( + hass: HomeAssistant, mock_entities +) -> None: """Test service calls invoked only if entity has both features.""" test_service_mock = AsyncMock(return_value=None) await service.entity_service_call( @@ -602,7 +605,9 @@ async def test_call_with_both_required_features(hass, mock_entities): ] -async def test_call_with_one_of_required_features(hass, mock_entities): +async def test_call_with_one_of_required_features( + hass: HomeAssistant, mock_entities +) -> None: """Test service calls invoked with one entity having the required features.""" test_service_mock = AsyncMock(return_value=None) await service.entity_service_call( @@ -623,7 +628,7 @@ async def test_call_with_one_of_required_features(hass, mock_entities): assert all(entity in actual for entity in expected) -async def test_call_with_sync_func(hass, mock_entities): +async def test_call_with_sync_func(hass: HomeAssistant, mock_entities) -> None: """Test invoking sync service calls.""" test_service_mock = Mock(return_value=None) await service.entity_service_call( @@ -635,7 +640,7 @@ async def test_call_with_sync_func(hass, mock_entities): assert test_service_mock.call_count == 1 -async def test_call_with_sync_attr(hass, mock_entities): +async def test_call_with_sync_attr(hass: HomeAssistant, mock_entities) -> None: """Test invoking sync service calls.""" mock_method = mock_entities["light.kitchen"].sync_method = Mock(return_value=None) await service.entity_service_call( @@ -670,7 +675,9 @@ async def test_call_context_user_not_exist(hass: HomeAssistant) -> None: assert err.value.context.user_id == "non-existing" -async def test_call_context_target_all(hass, mock_handle_entity_call, mock_entities): +async def test_call_context_target_all( + hass: HomeAssistant, mock_handle_entity_call, mock_entities +) -> None: """Check we only target allowed entities if targeting all.""" with patch( "homeassistant.auth.AuthManager.async_get_user", @@ -697,8 +704,8 @@ async def test_call_context_target_all(hass, mock_handle_entity_call, mock_entit async def test_call_context_target_specific( - hass, mock_handle_entity_call, mock_entities -): + hass: HomeAssistant, mock_handle_entity_call, mock_entities +) -> None: """Check targeting specific entities.""" with patch( "homeassistant.auth.AuthManager.async_get_user", @@ -725,8 +732,8 @@ async def test_call_context_target_specific( async def test_call_context_target_specific_no_auth( - hass, mock_handle_entity_call, mock_entities -): + hass: HomeAssistant, mock_handle_entity_call, mock_entities +) -> None: """Check targeting specific entities without auth.""" with pytest.raises(exceptions.Unauthorized) as err, patch( "homeassistant.auth.AuthManager.async_get_user", @@ -748,7 +755,9 @@ async def test_call_context_target_specific_no_auth( assert err.value.entity_id == "light.kitchen" -async def test_call_no_context_target_all(hass, mock_handle_entity_call, mock_entities): +async def test_call_no_context_target_all( + hass: HomeAssistant, mock_handle_entity_call, mock_entities +) -> None: """Check we target all if no user context given.""" await service.entity_service_call( hass, @@ -766,8 +775,8 @@ async def test_call_no_context_target_all(hass, mock_handle_entity_call, mock_en async def test_call_no_context_target_specific( - hass, mock_handle_entity_call, mock_entities -): + hass: HomeAssistant, mock_handle_entity_call, mock_entities +) -> None: """Check we can target specified entities.""" await service.entity_service_call( hass, @@ -785,8 +794,11 @@ async def test_call_no_context_target_specific( async def test_call_with_match_all( - hass, mock_handle_entity_call, mock_entities, caplog -): + hass: HomeAssistant, + mock_handle_entity_call, + mock_entities, + caplog: pytest.LogCaptureFixture, +) -> None: """Check we only target allowed entities if targeting all.""" await service.entity_service_call( hass, @@ -801,7 +813,9 @@ async def test_call_with_match_all( ) -async def test_call_with_omit_entity_id(hass, mock_handle_entity_call, mock_entities): +async def test_call_with_omit_entity_id( + hass: HomeAssistant, mock_handle_entity_call, mock_entities +) -> None: """Check service call if we do not pass an entity ID.""" await service.entity_service_call( hass, @@ -813,7 +827,9 @@ async def test_call_with_omit_entity_id(hass, mock_handle_entity_call, mock_enti assert len(mock_handle_entity_call.mock_calls) == 0 -async def test_register_admin_service(hass, hass_read_only_user, hass_admin_user): +async def test_register_admin_service( + hass: HomeAssistant, hass_read_only_user: MockUser, hass_admin_user: MockUser +) -> None: """Test the register admin service.""" calls = [] @@ -880,7 +896,7 @@ async def test_register_admin_service(hass, hass_read_only_user, hass_admin_user assert calls[0].context.user_id == hass_admin_user.id -async def test_domain_control_not_async(hass, mock_entities): +async def test_domain_control_not_async(hass: HomeAssistant, mock_entities) -> None: """Test domain verification in a service call with an unknown user.""" calls = [] @@ -892,7 +908,7 @@ async def test_domain_control_not_async(hass, mock_entities): service.verify_domain_control(hass, "test_domain")(mock_service_log) -async def test_domain_control_unknown(hass, mock_entities): +async def test_domain_control_unknown(hass: HomeAssistant, mock_entities) -> None: """Test domain verification in a service call with an unknown user.""" calls = [] @@ -923,7 +939,9 @@ async def test_domain_control_unknown(hass, mock_entities): assert len(calls) == 0 -async def test_domain_control_unauthorized(hass, hass_read_only_user): +async def test_domain_control_unauthorized( + hass: HomeAssistant, hass_read_only_user: MockUser +) -> None: """Test domain verification in a service call with an unauthorized user.""" mock_registry( hass, @@ -962,7 +980,9 @@ async def test_domain_control_unauthorized(hass, hass_read_only_user): assert len(calls) == 0 -async def test_domain_control_admin(hass, hass_admin_user): +async def test_domain_control_admin( + hass: HomeAssistant, hass_admin_user: MockUser +) -> None: """Test domain verification in a service call with an admin user.""" mock_registry( hass, @@ -1114,7 +1134,7 @@ async def test_extract_from_service_filter_out_non_existing_entities( ] -async def test_extract_from_service_area_id(hass, area_mock): +async def test_extract_from_service_area_id(hass: HomeAssistant, area_mock) -> None: """Test the extraction using area ID as reference.""" entities = [ MockEntity(name="in_area", entity_id="light.in_area"), @@ -1149,7 +1169,9 @@ async def test_extract_from_service_area_id(hass, area_mock): ] -async def test_entity_service_call_warn_referenced(hass, caplog): +async def test_entity_service_call_warn_referenced( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test we only warn for referenced entities in entity_service_call.""" call = ServiceCall( "light", @@ -1167,7 +1189,9 @@ async def test_entity_service_call_warn_referenced(hass, caplog): ) -async def test_async_extract_entities_warn_referenced(hass, caplog): +async def test_async_extract_entities_warn_referenced( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test we only warn for referenced entities in async_extract_entities.""" call = ServiceCall( "light", diff --git a/tests/helpers/test_significant_change.py b/tests/helpers/test_significant_change.py index b707e36993..c0d9f1b3a4 100644 --- a/tests/helpers/test_significant_change.py +++ b/tests/helpers/test_significant_change.py @@ -3,7 +3,7 @@ import pytest from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE, STATE_UNKNOWN -from homeassistant.core import State +from homeassistant.core import HomeAssistant, State from homeassistant.helpers import significant_change @@ -23,7 +23,7 @@ async def checker_fixture(hass): return checker -async def test_signicant_change(hass, checker): +async def test_signicant_change(hass: HomeAssistant, checker) -> None: """Test initialize helper works.""" ent_id = "test_domain.test_entity" attrs = {ATTR_DEVICE_CLASS: SensorDeviceClass.BATTERY} @@ -47,7 +47,7 @@ async def test_signicant_change(hass, checker): assert checker.async_is_significant_change(State(ent_id, STATE_UNAVAILABLE, attrs)) -async def test_significant_change_extra(hass, checker): +async def test_significant_change_extra(hass: HomeAssistant, checker) -> None: """Test extra significant checker works.""" ent_id = "test_domain.test_entity" attrs = {ATTR_DEVICE_CLASS: SensorDeviceClass.BATTERY} diff --git a/tests/helpers/test_singleton.py b/tests/helpers/test_singleton.py index 1d4f496a79..8157920279 100644 --- a/tests/helpers/test_singleton.py +++ b/tests/helpers/test_singleton.py @@ -13,7 +13,7 @@ def mock_hass(): @pytest.mark.parametrize("result", (object(), {}, [])) -async def test_singleton_async(mock_hass, result): +async def test_singleton_async(mock_hass, result) -> None: """Test singleton with async function.""" @singleton.singleton("test_key") @@ -29,7 +29,7 @@ async def test_singleton_async(mock_hass, result): @pytest.mark.parametrize("result", (object(), {}, [])) -def test_singleton(mock_hass, result): +def test_singleton(mock_hass, result) -> None: """Test singleton with function.""" @singleton.singleton("test_key") diff --git a/tests/helpers/test_start.py b/tests/helpers/test_start.py index 574c28a658..ec7ffbc9af 100644 --- a/tests/helpers/test_start.py +++ b/tests/helpers/test_start.py @@ -1,4 +1,6 @@ """Test starting HA helpers.""" +import pytest + from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STARTED from homeassistant.core import CoreState, HomeAssistant, callback from homeassistant.helpers import start @@ -27,7 +29,9 @@ async def test_at_start_when_running_awaitable(hass: HomeAssistant) -> None: assert len(calls) == 2 -async def test_at_start_when_running_callback(hass, caplog): +async def test_at_start_when_running_callback( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test at start when already running.""" assert hass.state == CoreState.running assert hass.is_running @@ -73,7 +77,9 @@ async def test_at_start_when_starting_awaitable(hass: HomeAssistant) -> None: assert len(calls) == 1 -async def test_at_start_when_starting_callback(hass, caplog): +async def test_at_start_when_starting_callback( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test at start when yet to start.""" hass.state = CoreState.not_running assert not hass.is_running @@ -100,7 +106,9 @@ async def test_at_start_when_starting_callback(hass, caplog): assert record.levelname in ("DEBUG", "INFO") -async def test_cancelling_at_start_when_running(hass, caplog): +async def test_cancelling_at_start_when_running( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test cancelling at start when already running.""" assert hass.state == CoreState.running assert hass.is_running @@ -163,7 +171,9 @@ async def test_at_started_when_running_awaitable(hass: HomeAssistant) -> None: assert len(calls) == 1 -async def test_at_started_when_running_callback(hass, caplog): +async def test_at_started_when_running_callback( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test at started when already running.""" assert hass.state == CoreState.running @@ -211,7 +221,9 @@ async def test_at_started_when_starting_awaitable(hass: HomeAssistant) -> None: assert len(calls) == 1 -async def test_at_started_when_starting_callback(hass, caplog): +async def test_at_started_when_starting_callback( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test at started when yet to start.""" hass.state = CoreState.not_running @@ -241,7 +253,9 @@ async def test_at_started_when_starting_callback(hass, caplog): assert record.levelname in ("DEBUG", "INFO") -async def test_cancelling_at_started_when_running(hass, caplog): +async def test_cancelling_at_started_when_running( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test cancelling at start when already running.""" assert hass.state == CoreState.running assert hass.is_running diff --git a/tests/helpers/test_storage.py b/tests/helpers/test_storage.py index 4613a9162c..77e5fa4aef 100644 --- a/tests/helpers/test_storage.py +++ b/tests/helpers/test_storage.py @@ -2,7 +2,7 @@ import asyncio from datetime import timedelta import json -from typing import NamedTuple +from typing import Any, NamedTuple from unittest.mock import Mock, patch import pytest @@ -57,7 +57,7 @@ def store_v_2_1(hass): ) -async def test_loading(hass, store): +async def test_loading(hass: HomeAssistant, store) -> None: """Test we can save and load data.""" await store.async_save(MOCK_DATA) data = await store.async_load() @@ -82,14 +82,19 @@ async def test_custom_encoder(hass: HomeAssistant) -> None: assert data == "9" -async def test_loading_non_existing(hass, store): +async def test_loading_non_existing(hass: HomeAssistant, store) -> None: """Test we can save and load data.""" with patch("homeassistant.util.json.open", side_effect=FileNotFoundError): data = await store.async_load() assert data is None -async def test_loading_parallel(hass, store, hass_storage, caplog): +async def test_loading_parallel( + hass: HomeAssistant, + store, + hass_storage: dict[str, Any], + caplog: pytest.LogCaptureFixture, +) -> None: """Test we can save and load data.""" hass_storage[store.key] = {"version": MOCK_VERSION, "data": MOCK_DATA} @@ -100,7 +105,9 @@ async def test_loading_parallel(hass, store, hass_storage, caplog): assert caplog.text.count(f"Loading data for {store.key}") -async def test_saving_with_delay(hass, store, hass_storage): +async def test_saving_with_delay( + hass: HomeAssistant, store, hass_storage: dict[str, Any] +) -> None: """Test saving data after a delay.""" store.async_delay_save(lambda: MOCK_DATA, 1) assert store.key not in hass_storage @@ -115,7 +122,9 @@ async def test_saving_with_delay(hass, store, hass_storage): } -async def test_saving_on_final_write(hass, hass_storage): +async def test_saving_on_final_write( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: """Test delayed saves trigger when we quit Home Assistant.""" store = storage.Store(hass, MOCK_VERSION, MOCK_KEY) store.async_delay_save(lambda: MOCK_DATA, 5) @@ -139,7 +148,9 @@ async def test_saving_on_final_write(hass, hass_storage): } -async def test_not_delayed_saving_while_stopping(hass, hass_storage): +async def test_not_delayed_saving_while_stopping( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: """Test delayed saves don't write after the stop event has fired.""" store = storage.Store(hass, MOCK_VERSION, MOCK_KEY) hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) @@ -152,7 +163,9 @@ async def test_not_delayed_saving_while_stopping(hass, hass_storage): assert store.key not in hass_storage -async def test_not_delayed_saving_after_stopping(hass, hass_storage): +async def test_not_delayed_saving_after_stopping( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: """Test delayed saves don't write after stop if issued before stopping Home Assistant.""" store = storage.Store(hass, MOCK_VERSION, MOCK_KEY) store.async_delay_save(lambda: MOCK_DATA, 10) @@ -168,7 +181,9 @@ async def test_not_delayed_saving_after_stopping(hass, hass_storage): assert store.key not in hass_storage -async def test_not_saving_while_stopping(hass, hass_storage): +async def test_not_saving_while_stopping( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: """Test saves don't write when stopping Home Assistant.""" store = storage.Store(hass, MOCK_VERSION, MOCK_KEY) hass.state = CoreState.stopping @@ -176,7 +191,9 @@ async def test_not_saving_while_stopping(hass, hass_storage): assert store.key not in hass_storage -async def test_loading_while_delay(hass, store, hass_storage): +async def test_loading_while_delay( + hass: HomeAssistant, store, hass_storage: dict[str, Any] +) -> None: """Test we load new data even if not written yet.""" await store.async_save({"delay": "no"}) assert hass_storage[store.key] == { @@ -198,7 +215,9 @@ async def test_loading_while_delay(hass, store, hass_storage): assert data == {"delay": "yes"} -async def test_writing_while_writing_delay(hass, store, hass_storage): +async def test_writing_while_writing_delay( + hass: HomeAssistant, store, hass_storage: dict[str, Any] +) -> None: """Test a write while a write with delay is active.""" store.async_delay_save(lambda: {"delay": "yes"}, 1) assert store.key not in hass_storage @@ -223,7 +242,9 @@ async def test_writing_while_writing_delay(hass, store, hass_storage): assert data == {"delay": "no"} -async def test_multiple_delay_save_calls(hass, store, hass_storage): +async def test_multiple_delay_save_calls( + hass: HomeAssistant, store, hass_storage: dict[str, Any] +) -> None: """Test a write while a write with changing delays.""" store.async_delay_save(lambda: {"delay": "yes"}, 1) store.async_delay_save(lambda: {"delay": "yes"}, 2) @@ -251,7 +272,9 @@ async def test_multiple_delay_save_calls(hass, store, hass_storage): assert data == {"delay": "no"} -async def test_multiple_save_calls(hass, store, hass_storage): +async def test_multiple_save_calls( + hass: HomeAssistant, store, hass_storage: dict[str, Any] +) -> None: """Test multiple write tasks.""" assert store.key not in hass_storage @@ -269,7 +292,9 @@ async def test_multiple_save_calls(hass, store, hass_storage): assert data == {"savecount": 5} -async def test_migrator_no_existing_config(hass, store, hass_storage): +async def test_migrator_no_existing_config( + hass: HomeAssistant, store, hass_storage: dict[str, Any] +) -> None: """Test migrator with no existing config.""" with patch("os.path.isfile", return_value=False), patch.object( store, "async_load", return_value={"cur": "config"} @@ -280,7 +305,9 @@ async def test_migrator_no_existing_config(hass, store, hass_storage): assert store.key not in hass_storage -async def test_migrator_existing_config(hass, store, hass_storage): +async def test_migrator_existing_config( + hass: HomeAssistant, store, hass_storage: dict[str, Any] +) -> None: """Test migrating existing config.""" with patch("os.path.isfile", return_value=True), patch("os.remove") as mock_remove: data = await storage.async_migrator( @@ -297,7 +324,9 @@ async def test_migrator_existing_config(hass, store, hass_storage): } -async def test_migrator_transforming_config(hass, store, hass_storage): +async def test_migrator_transforming_config( + hass: HomeAssistant, store, hass_storage: dict[str, Any] +) -> None: """Test migrating config to new format.""" async def old_conf_migrate_func(old_config): @@ -323,21 +352,27 @@ async def test_migrator_transforming_config(hass, store, hass_storage): } -async def test_minor_version_default(hass, store, hass_storage): +async def test_minor_version_default( + hass: HomeAssistant, store, hass_storage: dict[str, Any] +) -> None: """Test minor version default.""" await store.async_save(MOCK_DATA) assert hass_storage[store.key]["minor_version"] == 1 -async def test_minor_version(hass, store_v_1_2, hass_storage): +async def test_minor_version( + hass: HomeAssistant, store_v_1_2, hass_storage: dict[str, Any] +) -> None: """Test minor version.""" await store_v_1_2.async_save(MOCK_DATA) assert hass_storage[store_v_1_2.key]["minor_version"] == MOCK_MINOR_VERSION_2 -async def test_migrate_major_not_implemented_raises(hass, store, store_v_2_1): +async def test_migrate_major_not_implemented_raises( + hass: HomeAssistant, store, store_v_2_1 +) -> None: """Test migrating between major versions fails if not implemented.""" await store_v_2_1.async_save(MOCK_DATA) @@ -346,8 +381,8 @@ async def test_migrate_major_not_implemented_raises(hass, store, store_v_2_1): async def test_migrate_minor_not_implemented( - hass, hass_storage, store_v_1_1, store_v_1_2 -): + hass: HomeAssistant, hass_storage: dict[str, Any], store_v_1_1, store_v_1_2 +) -> None: """Test migrating between minor versions does not fail if not implemented.""" assert store_v_1_1.key == store_v_1_2.key @@ -371,7 +406,9 @@ async def test_migrate_minor_not_implemented( } -async def test_migration(hass, hass_storage, store_v_1_2): +async def test_migration( + hass: HomeAssistant, hass_storage: dict[str, Any], store_v_1_2 +) -> None: """Test migration.""" calls = 0 @@ -408,7 +445,9 @@ async def test_migration(hass, hass_storage, store_v_1_2): } -async def test_legacy_migration(hass, hass_storage, store_v_1_2): +async def test_legacy_migration( + hass: HomeAssistant, hass_storage: dict[str, Any], store_v_1_2 +) -> None: """Test legacy migration method signature.""" calls = 0 @@ -442,7 +481,9 @@ async def test_legacy_migration(hass, hass_storage, store_v_1_2): } -async def test_changing_delayed_written_data(hass, store, hass_storage): +async def test_changing_delayed_written_data( + hass: HomeAssistant, store, hass_storage: dict[str, Any] +) -> None: """Test changing data that is written with delay.""" data_to_store = {"hello": "world"} store.async_delay_save(lambda: data_to_store, 1) @@ -464,7 +505,7 @@ async def test_changing_delayed_written_data(hass, store, hass_storage): } -async def test_saving_load_round_trip(tmpdir): +async def test_saving_load_round_trip(tmpdir) -> None: """Test saving and loading round trip.""" loop = asyncio.get_running_loop() hass = await async_test_home_assistant(loop) diff --git a/tests/helpers/test_storage_remove.py b/tests/helpers/test_storage_remove.py index aa118aded5..b90d0df0d0 100644 --- a/tests/helpers/test_storage_remove.py +++ b/tests/helpers/test_storage_remove.py @@ -10,7 +10,7 @@ from homeassistant.util import dt from tests.common import async_fire_time_changed, async_test_home_assistant -async def test_removing_while_delay_in_progress(tmpdir): +async def test_removing_while_delay_in_progress(tmpdir) -> None: """Test removing while delay in progress.""" loop = asyncio.get_event_loop() diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 740cbdd1fc..ec6714bafe 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -371,7 +371,7 @@ def test_bool_filter(hass: HomeAssistant) -> None: ("inf", False), ], ) -def test_isnumber(hass, value, expected): +def test_isnumber(hass: HomeAssistant, value, expected) -> None: """Test is_number.""" assert ( template.Template("{{ is_number(value) }}", hass).async_render({"value": value}) @@ -895,7 +895,7 @@ def test_timestamp_local(hass: HomeAssistant) -> None: "invalid", ), ) -def test_as_datetime(hass, input): +def test_as_datetime(hass: HomeAssistant, input) -> None: """Test converting a timestamp string to a date object.""" expected = dt_util.parse_datetime(input) if expected is not None: @@ -1061,7 +1061,7 @@ def test_max(hass: HomeAssistant) -> None: "c", ), ) -def test_min_max_attribute(hass, attribute): +def test_min_max_attribute(hass: HomeAssistant, attribute) -> None: """Test the min and max filters with attribute.""" hass.states.async_set( "test.object", @@ -1234,7 +1234,7 @@ def test_as_timestamp(hass: HomeAssistant) -> None: @patch.object(random, "choice") -def test_random_every_time(test_choice, hass): +def test_random_every_time(test_choice, hass: HomeAssistant) -> None: """Ensure the random filter runs every time, not just once.""" tpl = template.Template("{{ [1,2] | random }}", hass) test_choice.return_value = "foo" @@ -1510,7 +1510,7 @@ def test_states_function(hass: HomeAssistant) -> None: "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", return_value=True, ) -def test_now(mock_is_safe, hass): +def test_now(mock_is_safe, hass: HomeAssistant) -> None: """Test now method.""" now = dt_util.now() with patch("homeassistant.util.dt.now", return_value=now): @@ -1524,7 +1524,7 @@ def test_now(mock_is_safe, hass): "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", return_value=True, ) -def test_utcnow(mock_is_safe, hass): +def test_utcnow(mock_is_safe, hass: HomeAssistant) -> None: """Test now method.""" utcnow = dt_util.utcnow() with patch("homeassistant.util.dt.utcnow", return_value=utcnow): @@ -1559,7 +1559,9 @@ def test_utcnow(mock_is_safe, hass): "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", return_value=True, ) -def test_today_at(mock_is_safe, hass, now, expected, expected_midnight, timezone_str): +def test_today_at( + mock_is_safe, hass: HomeAssistant, now, expected, expected_midnight, timezone_str +) -> None: """Test today_at method.""" freezer = freeze_time(now) freezer.start() @@ -1600,7 +1602,7 @@ def test_today_at(mock_is_safe, hass, now, expected, expected_midnight, timezone "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", return_value=True, ) -def test_relative_time(mock_is_safe, hass): +def test_relative_time(mock_is_safe, hass: HomeAssistant) -> None: """Test relative_time method.""" hass.config.set_time_zone("UTC") now = datetime.strptime("2000-01-01 10:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") @@ -1670,7 +1672,7 @@ def test_relative_time(mock_is_safe, hass): "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", return_value=True, ) -def test_timedelta(mock_is_safe, hass): +def test_timedelta(mock_is_safe, hass: HomeAssistant) -> None: """Test relative_time method.""" now = datetime.strptime("2000-01-01 10:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") with patch("homeassistant.util.dt.now", return_value=now): @@ -1954,7 +1956,7 @@ def test_bitwise_or(hass: HomeAssistant) -> None: assert tpl.async_render() == 8 | 2 -def test_pack(hass, caplog): +def test_pack(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: """Test struct pack method.""" # render as filter @@ -2019,7 +2021,7 @@ def test_pack(hass, caplog): ) -def test_unpack(hass, caplog): +def test_unpack(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: """Test struct unpack method.""" # render as filter @@ -4243,7 +4245,9 @@ async def test_parse_result(hass: HomeAssistant) -> None: assert template.Template(tpl, hass).async_render() == result -async def test_undefined_variable(hass, caplog): +async def test_undefined_variable( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test a warning is logged on undefined variables.""" tpl = template.Template("{{ no_such_variable }}", hass) assert tpl.async_render() == "" @@ -4285,7 +4289,7 @@ async def test_template_states_can_serialize(hass: HomeAssistant) -> None: ([], None, False), ], ) -def test_contains(hass, seq, value, expected): +def test_contains(hass: HomeAssistant, seq, value, expected) -> None: """Test contains.""" assert ( template.Template("{{ seq | contains(value) }}", hass).async_render( diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index 0cafe48f00..25f1a74266 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -34,7 +34,9 @@ def test_recursive_flatten() -> None: } -async def test_component_translation_path(hass, enable_custom_integrations): +async def test_component_translation_path( + hass: HomeAssistant, enable_custom_integrations: None +) -> None: """Test the component translation file function.""" assert await async_setup_component( hass, @@ -96,7 +98,9 @@ def test_load_translations_files(hass: HomeAssistant) -> None: } -async def test_get_translations(hass, mock_config_flows, enable_custom_integrations): +async def test_get_translations( + hass: HomeAssistant, mock_config_flows, enable_custom_integrations: None +) -> None: """Test the get translations helper.""" translations = await translation.async_get_translations(hass, "en", "state") assert translations == {} @@ -127,7 +131,9 @@ async def test_get_translations(hass, mock_config_flows, enable_custom_integrati assert translations["component.switch.state.string2"] == "Value 2" -async def test_get_translations_loads_config_flows(hass, mock_config_flows): +async def test_get_translations_loads_config_flows( + hass: HomeAssistant, mock_config_flows +) -> None: """Test the get translations helper loads config flow translations.""" mock_config_flows["integration"].append("component1") integration = Mock(file_path=pathlib.Path(__file__)) @@ -245,7 +251,9 @@ async def test_get_translation_categories(hass: HomeAssistant) -> None: assert "component.light.device_automation.action_type.turn_on" in translations -async def test_translation_merging(hass, caplog): +async def test_translation_merging( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test we merge translations of two integrations.""" hass.config.components.add("sensor.moon") hass.config.components.add("sensor") @@ -291,7 +299,9 @@ async def test_translation_merging(hass, caplog): ) in caplog.text -async def test_translation_merging_loaded_apart(hass, caplog): +async def test_translation_merging_loaded_apart( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test we merge translations of two integrations when they are not loaded at the same time.""" orig_load_translations = translation.load_translations_files @@ -334,7 +344,9 @@ async def test_translation_merging_loaded_apart(hass, caplog): assert "component.sensor.state.moon__phase.first_quarter" in translations -async def test_translation_merging_loaded_together(hass, caplog): +async def test_translation_merging_loaded_together( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test we merge translations of two integrations when they are loaded at the same time.""" hass.config.components.add("hue") hass.config.components.add("homekit") @@ -417,7 +429,9 @@ async def test_caching(hass: HomeAssistant) -> None: assert len(mock_build.mock_calls) > 1 -async def test_custom_component_translations(hass, enable_custom_integrations): +async def test_custom_component_translations( + hass: HomeAssistant, enable_custom_integrations: None +) -> None: """Test getting translation from custom components.""" hass.config.components.add("test_embedded") hass.config.components.add("test_package") diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index 1481df3b97..0a102c4ce4 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -43,7 +43,7 @@ async def test_trigger_variables(hass: HomeAssistant) -> None: """Test trigger variables.""" -async def test_if_fires_on_event(hass, calls): +async def test_if_fires_on_event(hass: HomeAssistant, calls) -> None: """Test the firing of events.""" assert await async_setup_component( hass, diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 5af4f02cc8..6bba27af65 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -85,7 +85,7 @@ def crd_without_update_interval(hass): return get_crd(hass, None) -async def test_async_refresh(crd): +async def test_async_refresh(crd) -> None: """Test async_refresh for update coordinator.""" assert crd.data is None await crd.async_refresh() @@ -134,7 +134,7 @@ async def test_update_context(crd: update_coordinator.DataUpdateCoordinator[int] assert not set(crd.async_contexts()) -async def test_request_refresh(crd): +async def test_request_refresh(crd) -> None: """Test request refresh for update coordinator.""" assert crd.data is None await crd.async_request_refresh() @@ -147,7 +147,7 @@ async def test_request_refresh(crd): assert crd.last_update_success is True -async def test_request_refresh_no_auto_update(crd_without_update_interval): +async def test_request_refresh_no_auto_update(crd_without_update_interval) -> None: """Test request refresh for update coordinator without automatic update.""" crd = crd_without_update_interval assert crd.data is None @@ -165,7 +165,9 @@ async def test_request_refresh_no_auto_update(crd_without_update_interval): "err_msg", KNOWN_ERRORS, ) -async def test_refresh_known_errors(err_msg, crd, caplog): +async def test_refresh_known_errors( + err_msg, crd, caplog: pytest.LogCaptureFixture +) -> None: """Test raising known errors.""" crd.update_method = AsyncMock(side_effect=err_msg[0]) @@ -177,7 +179,7 @@ async def test_refresh_known_errors(err_msg, crd, caplog): assert err_msg[2] in caplog.text -async def test_refresh_fail_unknown(crd, caplog): +async def test_refresh_fail_unknown(crd, caplog: pytest.LogCaptureFixture) -> None: """Test raising unknown error.""" await crd.async_refresh() @@ -190,7 +192,7 @@ async def test_refresh_fail_unknown(crd, caplog): assert "Unexpected error fetching test data" in caplog.text -async def test_refresh_no_update_method(crd): +async def test_refresh_no_update_method(crd) -> None: """Test raising error is no update method is provided.""" await crd.async_refresh() @@ -200,7 +202,7 @@ async def test_refresh_no_update_method(crd): await crd.async_refresh() -async def test_update_interval(hass, crd): +async def test_update_interval(hass: HomeAssistant, crd) -> None: """Test update interval works.""" # Test we don't update without subscriber async_fire_time_changed(hass, utcnow() + crd.update_interval) @@ -230,7 +232,9 @@ async def test_update_interval(hass, crd): assert crd.data == 2 -async def test_update_interval_not_present(hass, crd_without_update_interval): +async def test_update_interval_not_present( + hass: HomeAssistant, crd_without_update_interval +) -> None: """Test update never happens with no update interval.""" crd = crd_without_update_interval # Test we don't update without subscriber with no update interval @@ -261,7 +265,7 @@ async def test_update_interval_not_present(hass, crd_without_update_interval): assert crd.data is None -async def test_refresh_recover(crd, caplog): +async def test_refresh_recover(crd, caplog: pytest.LogCaptureFixture) -> None: """Test recovery of freshing data.""" crd.last_update_success = False @@ -300,7 +304,7 @@ async def test_coordinator_entity(crd: update_coordinator.DataUpdateCoordinator[ assert list(crd.async_contexts()) == [context] -async def test_async_set_updated_data(crd): +async def test_async_set_updated_data(crd) -> None: """Test async_set_updated_data for update coordinator.""" assert crd.data is None @@ -334,7 +338,7 @@ async def test_async_set_updated_data(crd): assert crd._unsub_refresh is not old_refresh -async def test_stop_refresh_on_ha_stop(hass, crd): +async def test_stop_refresh_on_ha_stop(hass: HomeAssistant, crd) -> None: """Test no update interval refresh when Home Assistant is stopping.""" # Add subscriber update_callback = Mock() @@ -371,7 +375,9 @@ async def test_stop_refresh_on_ha_stop(hass, crd): "err_msg", KNOWN_ERRORS, ) -async def test_async_config_entry_first_refresh_failure(err_msg, crd, caplog): +async def test_async_config_entry_first_refresh_failure( + err_msg, crd, caplog: pytest.LogCaptureFixture +) -> None: """Test async_config_entry_first_refresh raises ConfigEntryNotReady on failure. Verify we do not log the exception since raising ConfigEntryNotReady @@ -388,7 +394,9 @@ async def test_async_config_entry_first_refresh_failure(err_msg, crd, caplog): assert err_msg[2] not in caplog.text -async def test_async_config_entry_first_refresh_success(crd, caplog): +async def test_async_config_entry_first_refresh_success( + crd, caplog: pytest.LogCaptureFixture +) -> None: """Test first refresh successfully.""" await crd.async_config_entry_first_refresh() @@ -406,7 +414,7 @@ async def test_not_schedule_refresh_if_system_option_disable_polling( assert crd._unsub_refresh is None -async def test_async_set_update_error(crd, caplog): +async def test_async_set_update_error(crd, caplog: pytest.LogCaptureFixture) -> None: """Test manually setting an update failure.""" update_callback = Mock() crd.async_add_listener(update_callback) diff --git a/tests/scripts/test_auth.py b/tests/scripts/test_auth.py index a5478d149e..82a8a408c5 100644 --- a/tests/scripts/test_auth.py +++ b/tests/scripts/test_auth.py @@ -1,9 +1,11 @@ """Test the auth script to manage local users.""" +from typing import Any from unittest.mock import Mock, patch import pytest from homeassistant.auth.providers import homeassistant as hass_auth +from homeassistant.core import HomeAssistant from homeassistant.scripts import auth as script_auth from tests.common import register_auth_provider @@ -19,7 +21,7 @@ def provider(hass): return provider -async def test_list_user(hass, provider, capsys): +async def test_list_user(hass: HomeAssistant, provider, capsys) -> None: """Test we can list users.""" data = provider.data data.add_auth("test-user", "test-pass") @@ -34,7 +36,9 @@ async def test_list_user(hass, provider, capsys): ) -async def test_add_user(hass, provider, capsys, hass_storage): +async def test_add_user( + hass: HomeAssistant, provider, capsys, hass_storage: dict[str, Any] +) -> None: """Test we can add a user.""" data = provider.data await script_auth.add_user( @@ -50,7 +54,7 @@ async def test_add_user(hass, provider, capsys, hass_storage): data.validate_login("paulus", "test-pass") -async def test_validate_login(hass, provider, capsys): +async def test_validate_login(hass: HomeAssistant, provider, capsys) -> None: """Test we can validate a user login.""" data = provider.data data.add_auth("test-user", "test-pass") @@ -74,7 +78,9 @@ async def test_validate_login(hass, provider, capsys): assert captured.out == "Auth invalid\n" -async def test_change_password(hass, provider, capsys, hass_storage): +async def test_change_password( + hass: HomeAssistant, provider, capsys, hass_storage: dict[str, Any] +) -> None: """Test we can change a password.""" data = provider.data data.add_auth("test-user", "test-pass") @@ -91,7 +97,9 @@ async def test_change_password(hass, provider, capsys, hass_storage): data.validate_login("test-user", "test-pass") -async def test_change_password_invalid_user(hass, provider, capsys, hass_storage): +async def test_change_password_invalid_user( + hass: HomeAssistant, provider, capsys, hass_storage: dict[str, Any] +) -> None: """Test changing password of non-existing user.""" data = provider.data data.add_auth("test-user", "test-pass") @@ -108,7 +116,7 @@ async def test_change_password_invalid_user(hass, provider, capsys, hass_storage data.validate_login("invalid-user", "new-pass") -def test_parsing_args(event_loop): +def test_parsing_args(event_loop) -> None: """Test we parse args correctly.""" called = False diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 8720ae1a1f..1e6f1c184b 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -43,7 +43,7 @@ def normalize_yaml_files(check_dict): return [key.replace(root, "...") for key in sorted(check_dict["yaml_files"].keys())] -def test_bad_core_config(mock_is_file, event_loop): +def test_bad_core_config(mock_is_file, event_loop) -> None: """Test a bad core config setup.""" files = {YAML_CONFIG_FILE: BAD_CORE_CONFIG} with patch_yaml_files(files): @@ -52,7 +52,7 @@ def test_bad_core_config(mock_is_file, event_loop): assert res["except"]["homeassistant"][1] == {"unit_system": "bad"} -def test_config_platform_valid(mock_is_file, event_loop): +def test_config_platform_valid(mock_is_file, event_loop) -> None: """Test a valid platform setup.""" files = {YAML_CONFIG_FILE: BASE_CONFIG + "light:\n platform: demo"} with patch_yaml_files(files): @@ -65,7 +65,7 @@ def test_config_platform_valid(mock_is_file, event_loop): assert len(res["yaml_files"]) == 1 -def test_component_platform_not_found(mock_is_file, event_loop): +def test_component_platform_not_found(mock_is_file, event_loop) -> None: """Test errors if component or platform not found.""" # Make sure they don't exist files = {YAML_CONFIG_FILE: BASE_CONFIG + "beer:"} @@ -96,7 +96,7 @@ def test_component_platform_not_found(mock_is_file, event_loop): assert len(res["yaml_files"]) == 1 -def test_secrets(mock_is_file, event_loop): +def test_secrets(mock_is_file, event_loop) -> None: """Test secrets config checking method.""" secrets_path = get_test_config_dir("secrets.yaml") @@ -126,7 +126,7 @@ def test_secrets(mock_is_file, event_loop): ] -def test_package_invalid(mock_is_file, event_loop): +def test_package_invalid(mock_is_file, event_loop) -> None: """Test an invalid package.""" files = {YAML_CONFIG_FILE: BASE_CONFIG + ' packages:\n p1:\n group: ["a"]'} with patch_yaml_files(files): @@ -142,7 +142,7 @@ def test_package_invalid(mock_is_file, event_loop): assert len(res["yaml_files"]) == 1 -def test_bootstrap_error(event_loop): +def test_bootstrap_error(event_loop) -> None: """Test a valid platform setup.""" files = {YAML_CONFIG_FILE: BASE_CONFIG + "automation: !include no.yaml"} with patch_yaml_files(files): diff --git a/tests/scripts/test_init.py b/tests/scripts/test_init.py index 8feef2d338..44090019a2 100644 --- a/tests/scripts/test_init.py +++ b/tests/scripts/test_init.py @@ -5,7 +5,7 @@ import homeassistant.scripts as scripts @patch("homeassistant.scripts.get_default_config_dir", return_value="/default") -def test_config_per_platform(mock_def): +def test_config_per_platform(mock_def) -> None: """Test config per platform method.""" assert scripts.get_default_config_dir() == "/default" assert scripts.extract_config_dir() == "/default" diff --git a/tests/util/test_async.py b/tests/util/test_async.py index 37abd87c2a..48d9ee02f8 100644 --- a/tests/util/test_async.py +++ b/tests/util/test_async.py @@ -15,7 +15,7 @@ from homeassistant.util import async_ as hasync @patch("threading.get_ident") def test_fire_coroutine_threadsafe_from_inside_event_loop( mock_ident, _, mock_iscoroutine -): +) -> None: """Testing calling fire_coroutine_threadsafe from inside an event loop.""" coro = MagicMock() loop = MagicMock() @@ -49,7 +49,7 @@ def test_fire_coroutine_threadsafe_from_inside_event_loop( @patch("concurrent.futures.Future") @patch("threading.get_ident") -def test_run_callback_threadsafe_from_inside_event_loop(mock_ident, _): +def test_run_callback_threadsafe_from_inside_event_loop(mock_ident, _) -> None: """Testing calling run_callback_threadsafe from inside an event loop.""" callback = MagicMock() @@ -82,7 +82,7 @@ async def test_check_loop_async() -> None: hasync.check_loop(banned_function) -async def test_check_loop_async_integration(caplog): +async def test_check_loop_async_integration(caplog: pytest.LogCaptureFixture) -> None: """Test check_loop detects and raises when called from event loop from integration context.""" with pytest.raises(RuntimeError), patch( "homeassistant.util.async_.extract_stack", @@ -113,7 +113,9 @@ async def test_check_loop_async_integration(caplog): ) -async def test_check_loop_async_integration_non_strict(caplog): +async def test_check_loop_async_integration_non_strict( + caplog: pytest.LogCaptureFixture, +) -> None: """Test check_loop detects when called from event loop from integration context.""" with patch( "homeassistant.util.async_.extract_stack", @@ -144,7 +146,7 @@ async def test_check_loop_async_integration_non_strict(caplog): ) -async def test_check_loop_async_custom(caplog): +async def test_check_loop_async_custom(caplog: pytest.LogCaptureFixture) -> None: """Test check_loop detects when called from event loop with custom component context.""" with pytest.raises(RuntimeError), patch( "homeassistant.util.async_.extract_stack", @@ -175,7 +177,7 @@ async def test_check_loop_async_custom(caplog): ) in caplog.text -def test_check_loop_sync(caplog): +def test_check_loop_sync(caplog: pytest.LogCaptureFixture) -> None: """Test check_loop does nothing when called from thread.""" hasync.check_loop(banned_function) assert "Detected blocking call inside the event loop" not in caplog.text @@ -190,7 +192,7 @@ def test_protect_loop_sync() -> None: func.assert_called_once_with(1, test=2) -async def test_protect_loop_debugger_sleep(caplog): +async def test_protect_loop_debugger_sleep(caplog: pytest.LogCaptureFixture) -> None: """Test time.sleep injected by the debugger is not reported.""" block_async_io.enable() diff --git a/tests/util/test_distance.py b/tests/util/test_distance.py index 2de39208ac..d0b4bbc1eb 100644 --- a/tests/util/test_distance.py +++ b/tests/util/test_distance.py @@ -64,7 +64,7 @@ def test_convert_nonnumeric_value() -> None: (LENGTH_INCHES, 316800.171072), ], ) -def test_convert_from_miles(unit, expected): +def test_convert_from_miles(unit, expected) -> None: """Test conversion from miles to other units.""" miles = 5 assert distance_util.convert(miles, LENGTH_MILES, unit) == pytest.approx(expected) @@ -82,7 +82,7 @@ def test_convert_from_miles(unit, expected): (LENGTH_INCHES, 180.0000972), ], ) -def test_convert_from_yards(unit, expected): +def test_convert_from_yards(unit, expected) -> None: """Test conversion from yards to other units.""" yards = 5 assert distance_util.convert(yards, LENGTH_YARD, unit) == pytest.approx(expected) @@ -100,7 +100,7 @@ def test_convert_from_yards(unit, expected): (LENGTH_INCHES, 60000.032400000004), ], ) -def test_convert_from_feet(unit, expected): +def test_convert_from_feet(unit, expected) -> None: """Test conversion from feet to other units.""" feet = 5000 assert distance_util.convert(feet, LENGTH_FEET, unit) == pytest.approx(expected) @@ -118,7 +118,7 @@ def test_convert_from_feet(unit, expected): (LENGTH_FEET, 416.66668), ], ) -def test_convert_from_inches(unit, expected): +def test_convert_from_inches(unit, expected) -> None: """Test conversion from inches to other units.""" inches = 5000 assert distance_util.convert(inches, LENGTH_INCHES, unit) == pytest.approx(expected) @@ -136,7 +136,7 @@ def test_convert_from_inches(unit, expected): (LENGTH_INCHES, 196850.5), ], ) -def test_convert_from_kilometers(unit, expected): +def test_convert_from_kilometers(unit, expected) -> None: """Test conversion from kilometers to other units.""" km = 5 assert distance_util.convert(km, LENGTH_KILOMETERS, unit) == pytest.approx(expected) @@ -154,7 +154,7 @@ def test_convert_from_kilometers(unit, expected): (LENGTH_INCHES, 196850.5), ], ) -def test_convert_from_meters(unit, expected): +def test_convert_from_meters(unit, expected) -> None: """Test conversion from meters to other units.""" m = 5000 assert distance_util.convert(m, LENGTH_METERS, unit) == pytest.approx(expected) @@ -172,7 +172,7 @@ def test_convert_from_meters(unit, expected): (LENGTH_INCHES, 196850.5), ], ) -def test_convert_from_centimeters(unit, expected): +def test_convert_from_centimeters(unit, expected) -> None: """Test conversion from centimeters to other units.""" cm = 500000 assert distance_util.convert(cm, LENGTH_CENTIMETERS, unit) == pytest.approx( @@ -192,7 +192,7 @@ def test_convert_from_centimeters(unit, expected): (LENGTH_INCHES, 196850.5), ], ) -def test_convert_from_millimeters(unit, expected): +def test_convert_from_millimeters(unit, expected) -> None: """Test conversion from millimeters to other units.""" mm = 5000000 assert distance_util.convert(mm, LENGTH_MILLIMETERS, unit) == pytest.approx( diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index 1f4758a75d..e9cde21a26 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -325,7 +325,7 @@ def test_find_next_time_expression_time_dst() -> None: ), ], ) -def test_find_next_time_expression_entering_dst(now_dt, expected_dt): +def test_find_next_time_expression_entering_dst(now_dt, expected_dt) -> None: """Test entering daylight saving time for find_next_time_expression_time.""" tz = dt_util.get_time_zone("Europe/Vienna") dt_util.set_default_time_zone(tz) @@ -409,7 +409,7 @@ def test_find_next_time_expression_entering_dst(now_dt, expected_dt): ), ], ) -def test_find_next_time_expression_exiting_dst(now_dt, expected_dt): +def test_find_next_time_expression_exiting_dst(now_dt, expected_dt) -> None: """Test exiting daylight saving time for find_next_time_expression_time.""" tz = dt_util.get_time_zone("Europe/Vienna") dt_util.set_default_time_zone(tz) diff --git a/tests/util/test_executor.py b/tests/util/test_executor.py index 70c1f5f3a7..763efa494e 100644 --- a/tests/util/test_executor.py +++ b/tests/util/test_executor.py @@ -10,7 +10,9 @@ from homeassistant.util import executor from homeassistant.util.executor import InterruptibleThreadPoolExecutor -async def test_executor_shutdown_can_interrupt_threads(caplog): +async def test_executor_shutdown_can_interrupt_threads( + caplog: pytest.LogCaptureFixture, +) -> None: """Test that the executor shutdown can interrupt threads.""" iexecutor = InterruptibleThreadPoolExecutor() @@ -34,7 +36,9 @@ async def test_executor_shutdown_can_interrupt_threads(caplog): assert "time.sleep(0.1)" in caplog.text -async def test_executor_shutdown_only_logs_max_attempts(caplog): +async def test_executor_shutdown_only_logs_max_attempts( + caplog: pytest.LogCaptureFixture, +) -> None: """Test that the executor shutdown will only log max attempts.""" iexecutor = InterruptibleThreadPoolExecutor() @@ -52,7 +56,9 @@ async def test_executor_shutdown_only_logs_max_attempts(caplog): iexecutor.shutdown() -async def test_executor_shutdown_does_not_log_shutdown_on_first_attempt(caplog): +async def test_executor_shutdown_does_not_log_shutdown_on_first_attempt( + caplog: pytest.LogCaptureFixture, +) -> None: """Test that the executor shutdown does not log on first attempt.""" iexecutor = InterruptibleThreadPoolExecutor() @@ -68,7 +74,7 @@ async def test_executor_shutdown_does_not_log_shutdown_on_first_attempt(caplog): assert "is still running at shutdown" not in caplog.text -async def test_overall_timeout_reached(caplog): +async def test_overall_timeout_reached(caplog: pytest.LogCaptureFixture) -> None: """Test that shutdown moves on when the overall timeout is reached.""" iexecutor = InterruptibleThreadPoolExecutor() diff --git a/tests/util/test_file.py b/tests/util/test_file.py index 41d104cdd8..5934dc689f 100644 --- a/tests/util/test_file.py +++ b/tests/util/test_file.py @@ -9,7 +9,7 @@ from homeassistant.util.file import WriteError, write_utf8_file, write_utf8_file @pytest.mark.parametrize("func", [write_utf8_file, write_utf8_file_atomic]) -def test_write_utf8_file_atomic_private(tmpdir, func): +def test_write_utf8_file_atomic_private(tmpdir, func) -> None: """Test files can be written as 0o600 or 0o644.""" test_dir = tmpdir.mkdir("files") test_file = Path(test_dir / "test.json") @@ -25,7 +25,7 @@ def test_write_utf8_file_atomic_private(tmpdir, func): assert os.stat(test_file).st_mode & 0o777 == 0o600 -def test_write_utf8_file_fails_at_creation(tmpdir): +def test_write_utf8_file_fails_at_creation(tmpdir) -> None: """Test that failed creation of the temp file does not create an empty file.""" test_dir = tmpdir.mkdir("files") test_file = Path(test_dir / "test.json") @@ -38,7 +38,9 @@ def test_write_utf8_file_fails_at_creation(tmpdir): assert not os.path.exists(test_file) -def test_write_utf8_file_fails_at_rename(tmpdir, caplog): +def test_write_utf8_file_fails_at_rename( + tmpdir, caplog: pytest.LogCaptureFixture +) -> None: """Test that if rename fails not not remove, we do not log the failed cleanup.""" test_dir = tmpdir.mkdir("files") test_file = Path(test_dir / "test.json") @@ -53,7 +55,9 @@ def test_write_utf8_file_fails_at_rename(tmpdir, caplog): assert "File replacement cleanup failed" not in caplog.text -def test_write_utf8_file_fails_at_rename_and_remove(tmpdir, caplog): +def test_write_utf8_file_fails_at_rename_and_remove( + tmpdir, caplog: pytest.LogCaptureFixture +) -> None: """Test that if rename and remove both fail, we log the failed cleanup.""" test_dir = tmpdir.mkdir("files") test_file = Path(test_dir / "test.json") @@ -66,7 +70,7 @@ def test_write_utf8_file_fails_at_rename_and_remove(tmpdir, caplog): assert "File replacement cleanup failed" in caplog.text -def test_write_utf8_file_atomic_fails(tmpdir): +def test_write_utf8_file_atomic_fails(tmpdir) -> None: """Test OSError from write_utf8_file_atomic is rethrown as WriteError.""" test_dir = tmpdir.mkdir("files") test_file = Path(test_dir / "test.json") diff --git a/tests/util/test_init.py b/tests/util/test_init.py index 4e9de0bdd8..3ead4cf41c 100644 --- a/tests/util/test_init.py +++ b/tests/util/test_init.py @@ -200,7 +200,7 @@ def test_throttle_on_two_method() -> None: @patch.object(util, "random") -def test_get_random_string(mock_random): +def test_get_random_string(mock_random) -> None: """Test get random string.""" results = ["A", "B", "C"] diff --git a/tests/util/test_location.py b/tests/util/test_location.py index 801b145373..e998c10e56 100644 --- a/tests/util/test_location.py +++ b/tests/util/test_location.py @@ -8,6 +8,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.util.location as location_util from tests.common import load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker # Paris COORDINATES_PARIS = (48.864716, 2.349014) @@ -73,7 +74,9 @@ def test_get_miles() -> None: assert round(miles, 2) == DISTANCE_MILES -async def test_detect_location_info_whoami(aioclient_mock, session): +async def test_detect_location_info_whoami( + aioclient_mock: AiohttpClientMocker, session +) -> None: """Test detect location info using services.home-assistant.io/whoami.""" aioclient_mock.get(location_util.WHOAMI_URL, text=load_fixture("whoami.json")) @@ -95,7 +98,7 @@ async def test_detect_location_info_whoami(aioclient_mock, session): assert info.use_metric -async def test_dev_url(aioclient_mock, session): +async def test_dev_url(aioclient_mock: AiohttpClientMocker, session) -> None: """Test usage of dev URL.""" aioclient_mock.get(location_util.WHOAMI_URL_DEV, text=load_fixture("whoami.json")) with patch("homeassistant.util.location.HA_VERSION", "1.0.dev0"): @@ -106,7 +109,7 @@ async def test_dev_url(aioclient_mock, session): assert info.currency == "XXX" -async def test_whoami_query_raises(raising_session): +async def test_whoami_query_raises(raising_session) -> None: """Test whoami query when the request to API fails.""" info = await location_util._get_whoami(raising_session) assert info is None diff --git a/tests/util/test_logging.py b/tests/util/test_logging.py index 2a2e0107a8..39bf38f56b 100644 --- a/tests/util/test_logging.py +++ b/tests/util/test_logging.py @@ -78,7 +78,9 @@ async def test_migrate_log_handler(hass: HomeAssistant) -> None: @pytest.mark.no_fail_on_log_exception -async def test_async_create_catching_coro(hass, caplog): +async def test_async_create_catching_coro( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test exception logging of wrapped coroutine.""" async def job(): diff --git a/tests/util/test_package.py b/tests/util/test_package.py index 7c9ecdd75f..2a190d2aea 100644 --- a/tests/util/test_package.py +++ b/tests/util/test_package.py @@ -84,7 +84,7 @@ def mock_async_subprocess(): return async_popen -def test_install(mock_sys, mock_popen, mock_env_copy, mock_venv): +def test_install(mock_sys, mock_popen, mock_env_copy, mock_venv) -> None: """Test an install attempt on a package that doesn't exist.""" env = mock_env_copy() assert package.install_package(TEST_NEW_REQ, False) @@ -100,7 +100,7 @@ def test_install(mock_sys, mock_popen, mock_env_copy, mock_venv): assert mock_popen.return_value.communicate.call_count == 1 -def test_install_upgrade(mock_sys, mock_popen, mock_env_copy, mock_venv): +def test_install_upgrade(mock_sys, mock_popen, mock_env_copy, mock_venv) -> None: """Test an upgrade attempt on a package.""" env = mock_env_copy() assert package.install_package(TEST_NEW_REQ) @@ -124,7 +124,7 @@ def test_install_upgrade(mock_sys, mock_popen, mock_env_copy, mock_venv): assert mock_popen.return_value.communicate.call_count == 1 -def test_install_target(mock_sys, mock_popen, mock_env_copy, mock_venv): +def test_install_target(mock_sys, mock_popen, mock_env_copy, mock_venv) -> None: """Test an install with a target.""" target = "target_folder" env = mock_env_copy() @@ -149,14 +149,16 @@ def test_install_target(mock_sys, mock_popen, mock_env_copy, mock_venv): assert mock_popen.return_value.communicate.call_count == 1 -def test_install_target_venv(mock_sys, mock_popen, mock_env_copy, mock_venv): +def test_install_target_venv(mock_sys, mock_popen, mock_env_copy, mock_venv) -> None: """Test an install with a target in a virtual environment.""" target = "target_folder" with pytest.raises(AssertionError): package.install_package(TEST_NEW_REQ, False, target=target) -def test_install_error(caplog, mock_sys, mock_popen, mock_venv): +def test_install_error( + caplog: pytest.LogCaptureFixture, mock_sys, mock_popen, mock_venv +) -> None: """Test an install that errors out.""" caplog.set_level(logging.WARNING) mock_popen.return_value.returncode = 1 @@ -166,7 +168,7 @@ def test_install_error(caplog, mock_sys, mock_popen, mock_venv): assert record.levelname == "ERROR" -def test_install_constraint(mock_sys, mock_popen, mock_env_copy, mock_venv): +def test_install_constraint(mock_sys, mock_popen, mock_env_copy, mock_venv) -> None: """Test install with constraint file on not installed package.""" env = mock_env_copy() constraints = "constraints_file.txt" @@ -192,7 +194,7 @@ def test_install_constraint(mock_sys, mock_popen, mock_env_copy, mock_venv): assert mock_popen.return_value.communicate.call_count == 1 -def test_install_find_links(mock_sys, mock_popen, mock_env_copy, mock_venv): +def test_install_find_links(mock_sys, mock_popen, mock_env_copy, mock_venv) -> None: """Test install with find-links on not installed package.""" env = mock_env_copy() link = "https://wheels-repository" @@ -219,7 +221,7 @@ def test_install_find_links(mock_sys, mock_popen, mock_env_copy, mock_venv): assert mock_popen.return_value.communicate.call_count == 1 -async def test_async_get_user_site(mock_env_copy): +async def test_async_get_user_site(mock_env_copy) -> None: """Test async get user site directory.""" deps_dir = "/deps_dir" env = mock_env_copy() diff --git a/tests/util/test_speed.py b/tests/util/test_speed.py index a425ff2274..ae47b4d39c 100644 --- a/tests/util/test_speed.py +++ b/tests/util/test_speed.py @@ -85,7 +85,7 @@ def test_convert_nonnumeric_value() -> None: (5, UnitOfSpeed.FEET_PER_SECOND, 1.524, UnitOfSpeed.METERS_PER_SECOND), ], ) -def test_convert_different_units(from_value, from_unit, expected, to_unit): +def test_convert_different_units(from_value, from_unit, expected, to_unit) -> None: """Test conversion between units.""" assert speed_util.convert(from_value, from_unit, to_unit) == pytest.approx( expected, rel=1e-4 diff --git a/tests/util/test_timeout.py b/tests/util/test_timeout.py index 74a20c0c10..60c87fa743 100644 --- a/tests/util/test_timeout.py +++ b/tests/util/test_timeout.py @@ -97,8 +97,8 @@ async def test_mix_global_timeout_freeze_and_zone_freeze_different_order( async def test_mix_global_timeout_freeze_and_zone_freeze_other_zone_inside_executor_job( - hass, -): + hass: HomeAssistant, +) -> None: """Test a simple global timeout freeze other zone inside an executor job.""" timeout = TimeoutManager() @@ -114,8 +114,8 @@ async def test_mix_global_timeout_freeze_and_zone_freeze_other_zone_inside_execu async def test_mix_global_timeout_freeze_and_zone_freeze_inside_executor_job_second_job_outside_zone_context( - hass, -): + hass: HomeAssistant, +) -> None: """Test a simple global timeout freeze inside an executor job with second job outside of zone context.""" timeout = TimeoutManager() diff --git a/tests/util/test_variance.py b/tests/util/test_variance.py index f4db65dbfd..2072e3f341 100644 --- a/tests/util/test_variance.py +++ b/tests/util/test_variance.py @@ -33,7 +33,7 @@ from homeassistant.util.variance import ignore_variance ), ], ) -def test_ignore_variance(value_1, value_2, variance, expected): +def test_ignore_variance(value_1, value_2, variance, expected) -> None: """Test ignore_variance.""" with_ignore = ignore_variance(lambda x: x, variance) assert with_ignore(value_1) == value_1 diff --git a/tests/util/test_volume.py b/tests/util/test_volume.py index 1a49200b83..f8a73929b7 100644 --- a/tests/util/test_volume.py +++ b/tests/util/test_volume.py @@ -130,7 +130,7 @@ def test_convert_from_cubic_feet() -> None: (VOLUME_MILLILITERS, VOLUME_LITERS, 0.5), ], ) -def test_convert(source_unit, target_unit, expected): +def test_convert(source_unit, target_unit, expected) -> None: """Test conversion between units.""" value = 500 assert volume_util.convert(value, source_unit, target_unit) == pytest.approx( From ec5c3d63301b2fb80329997374df72cb1870541b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 20 Feb 2023 11:43:51 +0100 Subject: [PATCH 0227/2810] Add type hints to integration tests (#88476) --- tests/components/elgato/test_light.py | 4 +-- tests/components/history_stats/test_sensor.py | 8 +++--- tests/components/insteon/test_api_scenes.py | 27 ++++++++++++++----- tests/components/litejet/test_light.py | 2 +- tests/components/litejet/test_scene.py | 2 +- tests/components/litejet/test_switch.py | 2 +- 6 files changed, 30 insertions(+), 15 deletions(-) diff --git a/tests/components/elgato/test_light.py b/tests/components/elgato/test_light.py index 066e980653..8a3d3382a5 100644 --- a/tests/components/elgato/test_light.py +++ b/tests/components/elgato/test_light.py @@ -37,8 +37,8 @@ pytestmark = pytest.mark.usefixtures("init_integration") ) async def test_light_state_temperature( hass: HomeAssistant, - device_registry: dr.DeviceEntry, - entity_registry: er.RegistryEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test the creation and values of the Elgato Lights in temperature mode.""" diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index 20c530dce3..74194bed60 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -22,7 +22,7 @@ import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed, get_fixture_path -async def test_setup(recorder_mock, hass): +async def test_setup(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test the history statistics sensor setup.""" config = { @@ -44,7 +44,9 @@ async def test_setup(recorder_mock, hass): assert state.state == "0.0" -async def test_setup_multiple_states(recorder_mock, hass): +async def test_setup_multiple_states( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: """Test the history statistics sensor setup for multiple states.""" config = { @@ -95,7 +97,7 @@ async def test_setup_multiple_states(recorder_mock, hass): }, ], ) -def test_setup_invalid_config(config): +def test_setup_invalid_config(config) -> None: """Test the history statistics sensor setup with invalid config.""" with pytest.raises(vol.Invalid): diff --git a/tests/components/insteon/test_api_scenes.py b/tests/components/insteon/test_api_scenes.py index a777964052..9730f2427f 100644 --- a/tests/components/insteon/test_api_scenes.py +++ b/tests/components/insteon/test_api_scenes.py @@ -1,5 +1,4 @@ """Test the Insteon Scenes APIs.""" - import json import os from unittest.mock import AsyncMock, patch @@ -10,10 +9,12 @@ import pytest from homeassistant.components.insteon.api import async_load_api, scenes from homeassistant.components.insteon.const import ID, TYPE +from homeassistant.core import HomeAssistant from .mock_devices import MockDevices from tests.common import load_fixture +from tests.typing import WebSocketGenerator @pytest.fixture(name="scene_data", scope="session") @@ -58,7 +59,9 @@ async def _setup(hass, hass_ws_client, scene_data): return ws_client, devices -async def test_get_scenes(hass, hass_ws_client, scene_data): +async def test_get_scenes( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, scene_data +) -> None: """Test getting all Insteon scenes.""" ws_client, devices = await _setup(hass, hass_ws_client, scene_data) @@ -70,7 +73,9 @@ async def test_get_scenes(hass, hass_ws_client, scene_data): assert len(result["20"]) == 3 -async def test_get_scene(hass, hass_ws_client, scene_data): +async def test_get_scene( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, scene_data +) -> None: """Test getting an Insteon scene.""" ws_client, devices = await _setup(hass, hass_ws_client, scene_data) @@ -81,7 +86,9 @@ async def test_get_scene(hass, hass_ws_client, scene_data): assert len(result["devices"]) == 3 -async def test_save_scene(hass, hass_ws_client, scene_data, remove_json): +async def test_save_scene( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, scene_data, remove_json +) -> None: """Test saving an Insteon scene.""" ws_client, devices = await _setup(hass, hass_ws_client, scene_data) @@ -108,7 +115,9 @@ async def test_save_scene(hass, hass_ws_client, scene_data, remove_json): assert result["scene_id"] == 20 -async def test_save_new_scene(hass, hass_ws_client, scene_data, remove_json): +async def test_save_new_scene( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, scene_data, remove_json +) -> None: """Test saving a new Insteon scene.""" ws_client, devices = await _setup(hass, hass_ws_client, scene_data) @@ -135,7 +144,9 @@ async def test_save_new_scene(hass, hass_ws_client, scene_data, remove_json): assert result["scene_id"] == 21 -async def test_save_scene_error(hass, hass_ws_client, scene_data, remove_json): +async def test_save_scene_error( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, scene_data, remove_json +) -> None: """Test saving an Insteon scene with error.""" ws_client, devices = await _setup(hass, hass_ws_client, scene_data) @@ -162,7 +173,9 @@ async def test_save_scene_error(hass, hass_ws_client, scene_data, remove_json): assert result["scene_id"] == 20 -async def test_delete_scene(hass, hass_ws_client, scene_data, remove_json): +async def test_delete_scene( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, scene_data, remove_json +) -> None: """Test delete an Insteon scene.""" ws_client, devices = await _setup(hass, hass_ws_client, scene_data) diff --git a/tests/components/litejet/test_light.py b/tests/components/litejet/test_light.py index dd3a905d50..32f121a88a 100644 --- a/tests/components/litejet/test_light.py +++ b/tests/components/litejet/test_light.py @@ -178,7 +178,7 @@ async def test_deactivated_event(hass: HomeAssistant, mock_litejet) -> None: assert hass.states.get(ENTITY_OTHER_LIGHT).state == "off" -async def test_connected_event(hass, mock_litejet): +async def test_connected_event(hass: HomeAssistant, mock_litejet) -> None: """Test handling an event from LiteJet.""" await async_init_integration(hass) diff --git a/tests/components/litejet/test_scene.py b/tests/components/litejet/test_scene.py index af7a5f10e5..a430fc6387 100644 --- a/tests/components/litejet/test_scene.py +++ b/tests/components/litejet/test_scene.py @@ -47,7 +47,7 @@ async def test_activate(hass: HomeAssistant, mock_litejet) -> None: mock_litejet.activate_scene.assert_called_once_with(ENTITY_SCENE_NUMBER) -async def test_connected_event(hass, mock_litejet): +async def test_connected_event(hass: HomeAssistant, mock_litejet) -> None: """Test handling an event from LiteJet.""" await async_init_integration(hass, use_scene=True) diff --git a/tests/components/litejet/test_switch.py b/tests/components/litejet/test_switch.py index 21ac42e7e9..472ccc9491 100644 --- a/tests/components/litejet/test_switch.py +++ b/tests/components/litejet/test_switch.py @@ -84,7 +84,7 @@ async def test_released_event(hass: HomeAssistant, mock_litejet) -> None: assert hass.states.get(ENTITY_OTHER_SWITCH).state == STATE_OFF -async def test_connected_event(hass, mock_litejet): +async def test_connected_event(hass: HomeAssistant, mock_litejet) -> None: """Test handling an event from LiteJet.""" await async_init_integration(hass, use_switch=True) From 92815c99489ccf0f90e7924981f95fcf93278cfa Mon Sep 17 00:00:00 2001 From: Koen van Zuijlen <8818390+kvanzuijlen@users.noreply.github.com> Date: Mon, 20 Feb 2023 12:50:43 +0100 Subject: [PATCH 0228/2810] Bump zeversolar to 0.3.1 (#88386) Updated zeversolar to 0.3.1 --- homeassistant/components/zeversolar/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zeversolar/manifest.json b/homeassistant/components/zeversolar/manifest.json index eceba0ddeb..af197b3aa7 100644 --- a/homeassistant/components/zeversolar/manifest.json +++ b/homeassistant/components/zeversolar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/zeversolar", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["zeversolar==0.3.0"] + "requirements": ["zeversolar==0.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1135cfa516..1da11ae9ea 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2697,7 +2697,7 @@ zengge==0.2 zeroconf==0.47.3 # homeassistant.components.zeversolar -zeversolar==0.3.0 +zeversolar==0.3.1 # homeassistant.components.zha zha-quirks==0.0.92 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 57ad98c58b..b5edd509cd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1910,7 +1910,7 @@ zamg==0.2.2 zeroconf==0.47.3 # homeassistant.components.zeversolar -zeversolar==0.3.0 +zeversolar==0.3.1 # homeassistant.components.zha zha-quirks==0.0.92 From 36e59fc3223bc8e623ed4927627724e8af10d9c8 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 20 Feb 2023 13:00:02 +0100 Subject: [PATCH 0229/2810] Cleanups in WLED tests (#88480) --- tests/components/wled/conftest.py | 35 ++--- tests/components/wled/test_binary_sensor.py | 44 ++---- tests/components/wled/test_button.py | 22 +-- tests/components/wled/test_config_flow.py | 38 +++-- tests/components/wled/test_coordinator.py | 10 +- tests/components/wled/test_init.py | 2 +- tests/components/wled/test_light.py | 98 ++++-------- tests/components/wled/test_number.py | 92 ++++-------- tests/components/wled/test_select.py | 158 +++++++------------- tests/components/wled/test_sensor.py | 80 ++++------ tests/components/wled/test_switch.py | 54 ++----- tests/components/wled/test_update.py | 67 +++------ 12 files changed, 226 insertions(+), 474 deletions(-) diff --git a/tests/components/wled/conftest.py b/tests/components/wled/conftest.py index d0b5b24a8f..824801fe44 100644 --- a/tests/components/wled/conftest.py +++ b/tests/components/wled/conftest.py @@ -1,6 +1,5 @@ """Fixtures for WLED integration tests.""" from collections.abc import Generator -import json from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -10,7 +9,7 @@ from homeassistant.components.wled.const import DOMAIN from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_object_fixture from tests.components.light.conftest import mock_light_profiles # noqa: F401 @@ -25,7 +24,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[None, AsyncMock, None]: +def mock_setup_entry() -> Generator[AsyncMock, None, None]: """Mock setting up a config entry.""" with patch( "homeassistant.components.wled.async_setup_entry", return_value=True @@ -34,7 +33,7 @@ def mock_setup_entry() -> Generator[None, AsyncMock, None]: @pytest.fixture -def mock_onboarding() -> Generator[None, MagicMock, None]: +def mock_onboarding() -> Generator[MagicMock, None, None]: """Mock that Home Assistant is currently onboarding.""" with patch( "homeassistant.components.onboarding.async_is_onboarded", @@ -44,31 +43,23 @@ def mock_onboarding() -> Generator[None, MagicMock, None]: @pytest.fixture -def mock_wled_config_flow( - request: pytest.FixtureRequest, -) -> Generator[None, MagicMock, None]: - """Return a mocked WLED client.""" - with patch( - "homeassistant.components.wled.config_flow.WLED", autospec=True - ) as wled_mock: - wled = wled_mock.return_value - wled.update.return_value = WLEDDevice(json.loads(load_fixture("wled/rgb.json"))) - yield wled +def device_fixture() -> str: + """Return the device fixture for a specific device.""" + return "rgb" @pytest.fixture -def mock_wled(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]: +def mock_wled(device_fixture: str) -> Generator[MagicMock, None, None]: """Return a mocked WLED client.""" - fixture: str = "wled/rgb.json" - if hasattr(request, "param") and request.param: - fixture = request.param - - device = WLEDDevice(json.loads(load_fixture(fixture))) with patch( "homeassistant.components.wled.coordinator.WLED", autospec=True - ) as wled_mock: + ) as wled_mock, patch( + "homeassistant.components.wled.config_flow.WLED", new=wled_mock + ): wled = wled_mock.return_value - wled.update.return_value = device + wled.update.return_value = WLEDDevice( + load_json_object_fixture(f"{device_fixture}.json", DOMAIN) + ) wled.connected = False wled.host = "127.0.0.1" yield wled diff --git a/tests/components/wled/test_binary_sensor.py b/tests/components/wled/test_binary_sensor.py index 61c5509271..aa1bec313d 100644 --- a/tests/components/wled/test_binary_sensor.py +++ b/tests/components/wled/test_binary_sensor.py @@ -1,6 +1,4 @@ """Tests for the WLED binary sensor platform.""" -from unittest.mock import AsyncMock, MagicMock - import pytest from homeassistant.components.binary_sensor import BinarySensorDeviceClass @@ -14,62 +12,46 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("init_integration") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_update_available( - hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, - init_integration: MockConfigEntry, - mock_wled: MagicMock, + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test the firmware update binary sensor.""" - entity_registry = er.async_get(hass) - - state = hass.states.get("binary_sensor.wled_rgb_light_firmware") - assert state + assert (state := hass.states.get("binary_sensor.wled_rgb_light_firmware")) assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.UPDATE assert state.state == STATE_ON assert ATTR_ICON not in state.attributes - entry = entity_registry.async_get("binary_sensor.wled_rgb_light_firmware") - assert entry + assert (entry := entity_registry.async_get("binary_sensor.wled_rgb_light_firmware")) assert entry.unique_id == "aabbccddeeff_update" assert entry.entity_category is EntityCategory.DIAGNOSTIC -@pytest.mark.parametrize("mock_wled", ["wled/rgb_websocket.json"], indirect=True) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("device_fixture", ["rgb_websocket"]) async def test_no_update_available( - hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, - init_integration: MockConfigEntry, - mock_wled: MagicMock, + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test the update binary sensor. There is no update available.""" - entity_registry = er.async_get(hass) - - state = hass.states.get("binary_sensor.wled_websocket_firmware") - assert state + assert (state := hass.states.get("binary_sensor.wled_websocket_firmware")) assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.UPDATE assert state.state == STATE_OFF assert ATTR_ICON not in state.attributes - entry = entity_registry.async_get("binary_sensor.wled_websocket_firmware") - assert entry + assert (entry := entity_registry.async_get("binary_sensor.wled_websocket_firmware")) assert entry.unique_id == "aabbccddeeff_update" assert entry.entity_category is EntityCategory.DIAGNOSTIC async def test_disabled_by_default( - hass: HomeAssistant, init_integration: MockConfigEntry + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test that the binary update sensor is disabled by default.""" - registry = er.async_get(hass) + assert hass.states.get("binary_sensor.wled_rgb_light_firmware") is None - state = hass.states.get("binary_sensor.wled_rgb_light_firmware") - assert state is None - - entry = registry.async_get("binary_sensor.wled_rgb_light_firmware") - assert entry + assert (entry := entity_registry.async_get("binary_sensor.wled_rgb_light_firmware")) assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/wled/test_button.py b/tests/components/wled/test_button.py index bdca4d89d7..daa6839557 100644 --- a/tests/components/wled/test_button.py +++ b/tests/components/wled/test_button.py @@ -21,22 +21,18 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("init_integration") async def test_button_restart( - hass: HomeAssistant, init_integration: MockConfigEntry, mock_wled: MagicMock + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_wled: MagicMock ) -> None: """Test the creation and values of the WLED button.""" - entity_registry = er.async_get(hass) - - state = hass.states.get("button.wled_rgb_light_restart") - assert state + assert (state := hass.states.get("button.wled_rgb_light_restart")) assert state.state == STATE_UNKNOWN assert state.attributes[ATTR_DEVICE_CLASS] == ButtonDeviceClass.RESTART - entry = entity_registry.async_get("button.wled_rgb_light_restart") - assert entry + assert (entry := entity_registry.async_get("button.wled_rgb_light_restart")) assert entry.unique_id == "aabbccddeeff_restart" assert entry.entity_category is EntityCategory.CONFIG @@ -46,7 +42,6 @@ async def test_button_restart( {ATTR_ENTITY_ID: "button.wled_rgb_light_restart"}, blocking=True, ) - await hass.async_block_till_done() assert mock_wled.reset.call_count == 1 mock_wled.reset.assert_called_with() @@ -54,7 +49,6 @@ async def test_button_restart( @freeze_time("2021-11-04 17:37:00", tz_offset=-1) async def test_button_error( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test error handling of the WLED buttons.""" @@ -69,14 +63,12 @@ async def test_button_error( ) await hass.async_block_till_done() - state = hass.states.get("button.wled_rgb_light_restart") - assert state + assert (state := hass.states.get("button.wled_rgb_light_restart")) assert state.state == "2021-11-04T16:37:00+00:00" async def test_button_connection_error( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test error handling of the WLED buttons.""" @@ -89,8 +81,6 @@ async def test_button_connection_error( {ATTR_ENTITY_ID: "button.wled_rgb_light_restart"}, blocking=True, ) - await hass.async_block_till_done() - state = hass.states.get("button.wled_rgb_light_restart") - assert state + assert (state := hass.states.get("button.wled_rgb_light_restart")) assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/wled/test_config_flow.py b/tests/components/wled/test_config_flow.py index 0ba8e65942..9f99bd5861 100644 --- a/tests/components/wled/test_config_flow.py +++ b/tests/components/wled/test_config_flow.py @@ -1,6 +1,7 @@ """Tests for the WLED config flow.""" from unittest.mock import AsyncMock, MagicMock +import pytest from wled import WLEDConnectionError from homeassistant.components import zeroconf @@ -13,9 +14,8 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry -async def test_full_user_flow_implementation( - hass: HomeAssistant, mock_wled_config_flow: MagicMock, mock_setup_entry: AsyncMock -) -> None: +@pytest.mark.usefixtures("mock_setup_entry", "mock_wled") +async def test_full_user_flow_implementation(hass: HomeAssistant) -> None: """Test the full manual user flow from start to finish.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -37,9 +37,8 @@ async def test_full_user_flow_implementation( assert result["result"].unique_id == "aabbccddeeff" -async def test_full_zeroconf_flow_implementation( - hass: HomeAssistant, mock_wled_config_flow: MagicMock, mock_setup_entry: AsyncMock -) -> None: +@pytest.mark.usefixtures("mock_setup_entry", "mock_wled") +async def test_full_zeroconf_flow_implementation(hass: HomeAssistant) -> None: """Test the full manual user flow from start to finish.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -78,9 +77,9 @@ async def test_full_zeroconf_flow_implementation( assert result2["result"].unique_id == "aabbccddeeff" +@pytest.mark.usefixtures("mock_wled") async def test_zeroconf_during_onboarding( hass: HomeAssistant, - mock_wled_config_flow: MagicMock, mock_setup_entry: AsyncMock, mock_onboarding: MagicMock, ) -> None: @@ -110,11 +109,9 @@ async def test_zeroconf_during_onboarding( assert len(mock_onboarding.mock_calls) == 1 -async def test_connection_error( - hass: HomeAssistant, mock_wled_config_flow: MagicMock -) -> None: +async def test_connection_error(hass: HomeAssistant, mock_wled: MagicMock) -> None: """Test we show user form on WLED connection error.""" - mock_wled_config_flow.update.side_effect = WLEDConnectionError + mock_wled.update.side_effect = WLEDConnectionError result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -127,10 +124,10 @@ async def test_connection_error( async def test_zeroconf_connection_error( - hass: HomeAssistant, mock_wled_config_flow: MagicMock + hass: HomeAssistant, mock_wled: MagicMock ) -> None: """Test we abort zeroconf flow on WLED connection error.""" - mock_wled_config_flow.update.side_effect = WLEDConnectionError + mock_wled.update.side_effect = WLEDConnectionError result = await hass.config_entries.flow.async_init( DOMAIN, @@ -150,10 +147,11 @@ async def test_zeroconf_connection_error( assert result.get("reason") == "cannot_connect" +@pytest.mark.usefixtures("mock_wled") async def test_user_device_exists_abort( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_wled_config_flow: MagicMock, + mock_wled: MagicMock, ) -> None: """Test we abort zeroconf flow if WLED device already configured.""" mock_config_entry.add_to_hass(hass) @@ -169,10 +167,10 @@ async def test_user_device_exists_abort( async def test_user_with_cct_channel_abort( hass: HomeAssistant, - mock_wled_config_flow: MagicMock, + mock_wled: MagicMock, ) -> None: """Test we abort user flow if WLED device uses a CCT channel.""" - mock_wled_config_flow.update.return_value.info.leds.cct = True + mock_wled.update.return_value.info.leds.cct = True result = await hass.config_entries.flow.async_init( DOMAIN, @@ -184,10 +182,10 @@ async def test_user_with_cct_channel_abort( assert result.get("reason") == "cct_unsupported" +@pytest.mark.usefixtures("mock_wled") async def test_zeroconf_without_mac_device_exists_abort( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_wled_config_flow: MagicMock, ) -> None: """Test we abort zeroconf flow if WLED device already configured.""" mock_config_entry.add_to_hass(hass) @@ -212,7 +210,7 @@ async def test_zeroconf_without_mac_device_exists_abort( async def test_zeroconf_with_mac_device_exists_abort( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_wled_config_flow: MagicMock, + mock_wled: MagicMock, ) -> None: """Test we abort zeroconf flow if WLED device already configured.""" mock_config_entry.add_to_hass(hass) @@ -236,10 +234,10 @@ async def test_zeroconf_with_mac_device_exists_abort( async def test_zeroconf_with_cct_channel_abort( hass: HomeAssistant, - mock_wled_config_flow: MagicMock, + mock_wled: MagicMock, ) -> None: """Test we abort zeroconf flow if WLED device uses a CCT channel.""" - mock_wled_config_flow.update.return_value.info.leds.cct = True + mock_wled.update.return_value.info.leds.cct = True result = await hass.config_entries.flow.async_init( DOMAIN, diff --git a/tests/components/wled/test_coordinator.py b/tests/components/wled/test_coordinator.py index a7d7929c84..04d1c8f435 100644 --- a/tests/components/wled/test_coordinator.py +++ b/tests/components/wled/test_coordinator.py @@ -32,7 +32,7 @@ async def test_not_supporting_websocket( assert mock_wled.connect.call_count == 0 -@pytest.mark.parametrize("mock_wled", ["wled/rgb_websocket.json"], indirect=True) +@pytest.mark.parametrize("device_fixture", ["rgb_websocket"]) async def test_websocket_already_connected( hass: HomeAssistant, init_integration: MockConfigEntry, mock_wled: MagicMock ) -> None: @@ -46,7 +46,7 @@ async def test_websocket_already_connected( assert mock_wled.connect.call_count == 1 -@pytest.mark.parametrize("mock_wled", ["wled/rgb_websocket.json"], indirect=True) +@pytest.mark.parametrize("device_fixture", ["rgb_websocket"]) async def test_websocket_connect_error_no_listen( hass: HomeAssistant, init_integration: MockConfigEntry, @@ -64,7 +64,7 @@ async def test_websocket_connect_error_no_listen( assert mock_wled.listen.call_count == 1 -@pytest.mark.parametrize("mock_wled", ["wled/rgb_websocket.json"], indirect=True) +@pytest.mark.parametrize("device_fixture", ["rgb_websocket"]) async def test_websocket( hass: HomeAssistant, init_integration: MockConfigEntry, @@ -137,7 +137,7 @@ async def test_websocket( assert state.state == STATE_OFF -@pytest.mark.parametrize("mock_wled", ["wled/rgb_websocket.json"], indirect=True) +@pytest.mark.parametrize("device_fixture", ["rgb_websocket"]) async def test_websocket_error( hass: HomeAssistant, init_integration: MockConfigEntry, @@ -169,7 +169,7 @@ async def test_websocket_error( assert state.state == STATE_UNAVAILABLE -@pytest.mark.parametrize("mock_wled", ["wled/rgb_websocket.json"], indirect=True) +@pytest.mark.parametrize("device_fixture", ["rgb_websocket"]) async def test_websocket_disconnect_on_home_assistant_stop( hass: HomeAssistant, init_integration: MockConfigEntry, diff --git a/tests/components/wled/test_init.py b/tests/components/wled/test_init.py index 72a065a3b8..3879389824 100644 --- a/tests/components/wled/test_init.py +++ b/tests/components/wled/test_init.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -@pytest.mark.parametrize("mock_wled", ["wled/rgb_websocket.json"], indirect=True) +@pytest.mark.parametrize("device_fixture", ["rgb_websocket"]) async def test_load_unload_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_wled: AsyncMock ) -> None: diff --git a/tests/components/wled/test_light.py b/tests/components/wled/test_light.py index b52cf91a11..16aba21392 100644 --- a/tests/components/wled/test_light.py +++ b/tests/components/wled/test_light.py @@ -31,53 +31,46 @@ import homeassistant.util.dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture +pytestmark = pytest.mark.usefixtures("init_integration") + async def test_rgb_light_state( - hass: HomeAssistant, init_integration: MockConfigEntry + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test the creation and values of the WLED lights.""" - entity_registry = er.async_get(hass) - # First segment of the strip - state = hass.states.get("light.wled_rgb_light") - assert state + assert (state := hass.states.get("light.wled_rgb_light")) assert state.attributes.get(ATTR_BRIGHTNESS) == 127 assert state.attributes.get(ATTR_EFFECT) == "Solid" assert state.attributes.get(ATTR_HS_COLOR) == (37.412, 100.0) assert state.attributes.get(ATTR_ICON) == "mdi:led-strip-variant" assert state.state == STATE_ON - entry = entity_registry.async_get("light.wled_rgb_light") - assert entry + assert (entry := entity_registry.async_get("light.wled_rgb_light")) assert entry.unique_id == "aabbccddeeff_0" # Second segment of the strip - state = hass.states.get("light.wled_rgb_light_segment_1") - assert state + assert (state := hass.states.get("light.wled_rgb_light_segment_1")) assert state.attributes.get(ATTR_BRIGHTNESS) == 127 assert state.attributes.get(ATTR_EFFECT) == "Blink" assert state.attributes.get(ATTR_HS_COLOR) == (148.941, 100.0) assert state.attributes.get(ATTR_ICON) == "mdi:led-strip-variant" assert state.state == STATE_ON - entry = entity_registry.async_get("light.wled_rgb_light_segment_1") - assert entry + assert (entry := entity_registry.async_get("light.wled_rgb_light_segment_1")) assert entry.unique_id == "aabbccddeeff_1" # Test master control of the lightstrip - state = hass.states.get("light.wled_rgb_light_master") - assert state + assert (state := hass.states.get("light.wled_rgb_light_master")) assert state.attributes.get(ATTR_BRIGHTNESS) == 127 assert state.state == STATE_ON - entry = entity_registry.async_get("light.wled_rgb_light_master") - assert entry + assert (entry := entity_registry.async_get("light.wled_rgb_light_master")) assert entry.unique_id == "aabbccddeeff" async def test_segment_change_state( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test the change of state of the WLED segments.""" @@ -87,7 +80,6 @@ async def test_segment_change_state( {ATTR_ENTITY_ID: "light.wled_rgb_light", ATTR_TRANSITION: 5}, blocking=True, ) - await hass.async_block_till_done() assert mock_wled.segment.call_count == 1 mock_wled.segment.assert_called_with( on=False, @@ -107,7 +99,6 @@ async def test_segment_change_state( }, blocking=True, ) - await hass.async_block_till_done() assert mock_wled.segment.call_count == 2 mock_wled.segment.assert_called_with( brightness=42, @@ -121,7 +112,6 @@ async def test_segment_change_state( async def test_master_change_state( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test the change of state of the WLED master light control.""" @@ -131,7 +121,6 @@ async def test_master_change_state( {ATTR_ENTITY_ID: "light.wled_rgb_light_master", ATTR_TRANSITION: 5}, blocking=True, ) - await hass.async_block_till_done() assert mock_wled.master.call_count == 1 mock_wled.master.assert_called_with( on=False, @@ -148,7 +137,6 @@ async def test_master_change_state( }, blocking=True, ) - await hass.async_block_till_done() assert mock_wled.master.call_count == 2 mock_wled.master.assert_called_with( brightness=42, @@ -162,7 +150,6 @@ async def test_master_change_state( {ATTR_ENTITY_ID: "light.wled_rgb_light_master", ATTR_TRANSITION: 5}, blocking=True, ) - await hass.async_block_till_done() assert mock_wled.master.call_count == 3 mock_wled.master.assert_called_with( on=False, @@ -179,7 +166,6 @@ async def test_master_change_state( }, blocking=True, ) - await hass.async_block_till_done() assert mock_wled.master.call_count == 4 mock_wled.master.assert_called_with( brightness=42, @@ -188,20 +174,16 @@ async def test_master_change_state( ) -@pytest.mark.parametrize("mock_wled", ["wled/rgb_single_segment.json"], indirect=True) +@pytest.mark.parametrize("device_fixture", ["rgb_single_segment"]) async def test_dynamically_handle_segments( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test if a new/deleted segment is dynamically added/removed.""" - master = hass.states.get("light.wled_rgb_light_master") - segment0 = hass.states.get("light.wled_rgb_light") - segment1 = hass.states.get("light.wled_rgb_light_segment_1") - assert segment0 + assert (segment0 := hass.states.get("light.wled_rgb_light")) assert segment0.state == STATE_ON - assert not master - assert not segment1 + assert not hass.states.get("light.wled_rgb_light_master") + assert not hass.states.get("light.wled_rgb_light_segment_1") return_value = mock_wled.update.return_value mock_wled.update.return_value = WLEDDevice( @@ -211,14 +193,11 @@ async def test_dynamically_handle_segments( async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() - master = hass.states.get("light.wled_rgb_light_master") - segment0 = hass.states.get("light.wled_rgb_light") - segment1 = hass.states.get("light.wled_rgb_light_segment_1") - assert master + assert (master := hass.states.get("light.wled_rgb_light_master")) assert master.state == STATE_ON - assert segment0 + assert (segment0 := hass.states.get("light.wled_rgb_light")) assert segment0.state == STATE_ON - assert segment1 + assert (segment1 := hass.states.get("light.wled_rgb_light_segment_1")) assert segment1.state == STATE_ON # Test adding if segment shows up again, including the master entity @@ -226,29 +205,24 @@ async def test_dynamically_handle_segments( async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() - master = hass.states.get("light.wled_rgb_light_master") - segment0 = hass.states.get("light.wled_rgb_light") - segment1 = hass.states.get("light.wled_rgb_light_segment_1") - assert master + assert (master := hass.states.get("light.wled_rgb_light_master")) assert master.state == STATE_UNAVAILABLE - assert segment0 + assert (segment0 := hass.states.get("light.wled_rgb_light")) assert segment0.state == STATE_ON - assert segment1 + assert (segment1 := hass.states.get("light.wled_rgb_light_segment_1")) assert segment1.state == STATE_UNAVAILABLE -@pytest.mark.parametrize("mock_wled", ["wled/rgb_single_segment.json"], indirect=True) +@pytest.mark.parametrize("device_fixture", ["rgb_single_segment"]) async def test_single_segment_behavior( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test the behavior of the integration with a single segment.""" device = mock_wled.update.return_value assert not hass.states.get("light.wled_rgb_light_master") - state = hass.states.get("light.wled_rgb_light") - assert state + assert (state := hass.states.get("light.wled_rgb_light")) assert state.state == STATE_ON # Test segment brightness takes master into account @@ -257,8 +231,7 @@ async def test_single_segment_behavior( async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() - state = hass.states.get("light.wled_rgb_light") - assert state + assert (state := hass.states.get("light.wled_rgb_light")) assert state.attributes.get(ATTR_BRIGHTNESS) == 100 # Test segment is off when master is off @@ -276,7 +249,6 @@ async def test_single_segment_behavior( {ATTR_ENTITY_ID: "light.wled_rgb_light", ATTR_TRANSITION: 5}, blocking=True, ) - await hass.async_block_till_done() assert mock_wled.master.call_count == 1 mock_wled.master.assert_called_with( on=False, @@ -295,7 +267,6 @@ async def test_single_segment_behavior( }, blocking=True, ) - await hass.async_block_till_done() assert mock_wled.segment.call_count == 1 assert mock_wled.master.call_count == 2 mock_wled.segment.assert_called_with(on=True, segment_id=0, brightness=255) @@ -304,7 +275,6 @@ async def test_single_segment_behavior( async def test_light_error( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test error handling of the WLED lights.""" @@ -317,10 +287,8 @@ async def test_light_error( {ATTR_ENTITY_ID: "light.wled_rgb_light"}, blocking=True, ) - await hass.async_block_till_done() - state = hass.states.get("light.wled_rgb_light") - assert state + assert (state := hass.states.get("light.wled_rgb_light")) assert state.state == STATE_ON assert mock_wled.segment.call_count == 1 mock_wled.segment.assert_called_with(on=False, segment_id=0, transition=None) @@ -328,7 +296,6 @@ async def test_light_error( async def test_light_connection_error( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test error handling of the WLED switches.""" @@ -341,22 +308,17 @@ async def test_light_connection_error( {ATTR_ENTITY_ID: "light.wled_rgb_light"}, blocking=True, ) - await hass.async_block_till_done() - state = hass.states.get("light.wled_rgb_light") - assert state + assert (state := hass.states.get("light.wled_rgb_light")) assert state.state == STATE_UNAVAILABLE assert mock_wled.segment.call_count == 1 mock_wled.segment.assert_called_with(on=False, segment_id=0, transition=None) -@pytest.mark.parametrize("mock_wled", ["wled/rgbw.json"], indirect=True) -async def test_rgbw_light( - hass: HomeAssistant, init_integration: MockConfigEntry, mock_wled: MagicMock -) -> None: +@pytest.mark.parametrize("device_fixture", ["rgbw"]) +async def test_rgbw_light(hass: HomeAssistant, mock_wled: MagicMock) -> None: """Test RGBW support for WLED.""" - state = hass.states.get("light.wled_rgbw_light") - assert state + assert (state := hass.states.get("light.wled_rgbw_light")) assert state.state == STATE_ON assert state.attributes.get(ATTR_RGBW_COLOR) == (255, 0, 0, 139) @@ -369,7 +331,6 @@ async def test_rgbw_light( }, blocking=True, ) - await hass.async_block_till_done() assert mock_wled.segment.call_count == 1 mock_wled.segment.assert_called_with( color_primary=(255, 255, 255, 255), @@ -378,7 +339,7 @@ async def test_rgbw_light( ) -@pytest.mark.parametrize("mock_wled", ["wled/rgb_single_segment.json"], indirect=True) +@pytest.mark.parametrize("device_fixture", ["rgb_single_segment"]) async def test_single_segment_with_keep_master_light( hass: HomeAssistant, init_integration: MockConfigEntry, @@ -392,6 +353,5 @@ async def test_single_segment_with_keep_master_light( ) await hass.async_block_till_done() - state = hass.states.get("light.wled_rgb_light_master") - assert state + assert (state := hass.states.get("light.wled_rgb_light_master")) assert state.state == STATE_ON diff --git a/tests/components/wled/test_number.py b/tests/components/wled/test_number.py index aff7f4f8e8..150db49515 100644 --- a/tests/components/wled/test_number.py +++ b/tests/components/wled/test_number.py @@ -20,32 +20,29 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util -from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture +from tests.common import async_fire_time_changed, load_fixture + +pytestmark = pytest.mark.usefixtures("init_integration") async def test_speed_state( - hass: HomeAssistant, init_integration: MockConfigEntry + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test the creation and values of the WLED numbers.""" - entity_registry = er.async_get(hass) - # First segment of the strip - state = hass.states.get("number.wled_rgb_light_segment_1_speed") - assert state + assert (state := hass.states.get("number.wled_rgb_light_segment_1_speed")) assert state.attributes.get(ATTR_ICON) == "mdi:speedometer" assert state.attributes.get(ATTR_MAX) == 255 assert state.attributes.get(ATTR_MIN) == 0 assert state.attributes.get(ATTR_STEP) == 1 assert state.state == "16" - entry = entity_registry.async_get("number.wled_rgb_light_segment_1_speed") - assert entry + assert (entry := entity_registry.async_get("number.wled_rgb_light_segment_1_speed")) assert entry.unique_id == "aabbccddeeff_speed_1" async def test_speed_segment_change_state( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test the value change of the WLED segments.""" @@ -58,7 +55,6 @@ async def test_speed_segment_change_state( }, blocking=True, ) - await hass.async_block_till_done() assert mock_wled.segment.call_count == 1 mock_wled.segment.assert_called_with( segment_id=1, @@ -66,18 +62,15 @@ async def test_speed_segment_change_state( ) -@pytest.mark.parametrize("mock_wled", ["wled/rgb_single_segment.json"], indirect=True) +@pytest.mark.parametrize("device_fixture", ["rgb_single_segment"]) async def test_speed_dynamically_handle_segments( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test if a new/deleted segment is dynamically added/removed.""" - segment0 = hass.states.get("number.wled_rgb_light_speed") - segment1 = hass.states.get("number.wled_rgb_light_segment_1_speed") - assert segment0 + assert (segment0 := hass.states.get("number.wled_rgb_light_speed")) assert segment0.state == "32" - assert not segment1 + assert not hass.states.get("number.wled_rgb_light_segment_1_speed") # Test adding a segment dynamically... return_value = mock_wled.update.return_value @@ -88,11 +81,9 @@ async def test_speed_dynamically_handle_segments( async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() - segment0 = hass.states.get("number.wled_rgb_light_speed") - segment1 = hass.states.get("number.wled_rgb_light_segment_1_speed") - assert segment0 + assert (segment0 := hass.states.get("number.wled_rgb_light_speed")) assert segment0.state == "32" - assert segment1 + assert (segment1 := hass.states.get("number.wled_rgb_light_segment_1_speed")) assert segment1.state == "16" # Test remove segment again... @@ -100,17 +91,14 @@ async def test_speed_dynamically_handle_segments( async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() - segment0 = hass.states.get("number.wled_rgb_light_speed") - segment1 = hass.states.get("number.wled_rgb_light_segment_1_speed") - assert segment0 + assert (segment0 := hass.states.get("number.wled_rgb_light_speed")) assert segment0.state == "32" - assert segment1 + assert (segment1 := hass.states.get("number.wled_rgb_light_segment_1_speed")) assert segment1.state == STATE_UNAVAILABLE async def test_speed_error( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test error handling of the WLED numbers.""" @@ -126,10 +114,8 @@ async def test_speed_error( }, blocking=True, ) - await hass.async_block_till_done() - state = hass.states.get("number.wled_rgb_light_segment_1_speed") - assert state + assert (state := hass.states.get("number.wled_rgb_light_segment_1_speed")) assert state.state == "16" assert mock_wled.segment.call_count == 1 mock_wled.segment.assert_called_with(segment_id=1, speed=42) @@ -137,7 +123,6 @@ async def test_speed_error( async def test_speed_connection_error( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test error handling of the WLED numbers.""" @@ -153,38 +138,33 @@ async def test_speed_connection_error( }, blocking=True, ) - await hass.async_block_till_done() - state = hass.states.get("number.wled_rgb_light_segment_1_speed") - assert state + assert (state := hass.states.get("number.wled_rgb_light_segment_1_speed")) assert state.state == STATE_UNAVAILABLE assert mock_wled.segment.call_count == 1 mock_wled.segment.assert_called_with(segment_id=1, speed=42) async def test_intensity_state( - hass: HomeAssistant, init_integration: MockConfigEntry + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test the creation and values of the WLED numbers.""" - entity_registry = er.async_get(hass) - # First segment of the strip - state = hass.states.get("number.wled_rgb_light_segment_1_intensity") - assert state + assert (state := hass.states.get("number.wled_rgb_light_segment_1_intensity")) assert state.attributes.get(ATTR_ICON) is None assert state.attributes.get(ATTR_MAX) == 255 assert state.attributes.get(ATTR_MIN) == 0 assert state.attributes.get(ATTR_STEP) == 1 assert state.state == "64" - entry = entity_registry.async_get("number.wled_rgb_light_segment_1_intensity") - assert entry + assert ( + entry := entity_registry.async_get("number.wled_rgb_light_segment_1_intensity") + ) assert entry.unique_id == "aabbccddeeff_intensity_1" async def test_intensity_segment_change_state( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test the value change of the WLED segments.""" @@ -197,7 +177,6 @@ async def test_intensity_segment_change_state( }, blocking=True, ) - await hass.async_block_till_done() assert mock_wled.segment.call_count == 1 mock_wled.segment.assert_called_with( segment_id=1, @@ -205,18 +184,15 @@ async def test_intensity_segment_change_state( ) -@pytest.mark.parametrize("mock_wled", ["wled/rgb_single_segment.json"], indirect=True) +@pytest.mark.parametrize("device_fixture", ["rgb_single_segment"]) async def test_intensity_dynamically_handle_segments( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test if a new/deleted segment is dynamically added/removed.""" - segment0 = hass.states.get("number.wled_rgb_light_intensity") - segment1 = hass.states.get("number.wled_rgb_light_segment_1_intensity") - assert segment0 + assert (segment0 := hass.states.get("number.wled_rgb_light_intensity")) assert segment0.state == "128" - assert not segment1 + assert not hass.states.get("number.wled_rgb_light_segment_1_intensity") # Test adding a segment dynamically... return_value = mock_wled.update.return_value @@ -227,11 +203,9 @@ async def test_intensity_dynamically_handle_segments( async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() - segment0 = hass.states.get("number.wled_rgb_light_intensity") - segment1 = hass.states.get("number.wled_rgb_light_segment_1_intensity") - assert segment0 + assert (segment0 := hass.states.get("number.wled_rgb_light_intensity")) assert segment0.state == "128" - assert segment1 + assert (segment1 := hass.states.get("number.wled_rgb_light_segment_1_intensity")) assert segment1.state == "64" # Test remove segment again... @@ -239,17 +213,14 @@ async def test_intensity_dynamically_handle_segments( async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() - segment0 = hass.states.get("number.wled_rgb_light_intensity") - segment1 = hass.states.get("number.wled_rgb_light_segment_1_intensity") - assert segment0 + assert (segment0 := hass.states.get("number.wled_rgb_light_intensity")) assert segment0.state == "128" - assert segment1 + assert (segment1 := hass.states.get("number.wled_rgb_light_segment_1_intensity")) assert segment1.state == STATE_UNAVAILABLE async def test_intensity_error( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test error handling of the WLED numbers.""" @@ -265,10 +236,8 @@ async def test_intensity_error( }, blocking=True, ) - await hass.async_block_till_done() - state = hass.states.get("number.wled_rgb_light_segment_1_intensity") - assert state + assert (state := hass.states.get("number.wled_rgb_light_segment_1_intensity")) assert state.state == "64" assert mock_wled.segment.call_count == 1 mock_wled.segment.assert_called_with(segment_id=1, intensity=21) @@ -276,7 +245,6 @@ async def test_intensity_error( async def test_intensity_connection_error( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test error handling of the WLED numbers.""" @@ -292,10 +260,8 @@ async def test_intensity_connection_error( }, blocking=True, ) - await hass.async_block_till_done() - state = hass.states.get("number.wled_rgb_light_segment_1_intensity") - assert state + assert (state := hass.states.get("number.wled_rgb_light_segment_1_intensity")) assert state.state == STATE_UNAVAILABLE assert mock_wled.segment.call_count == 1 mock_wled.segment.assert_called_with(segment_id=1, intensity=128) diff --git a/tests/components/wled/test_select.py b/tests/components/wled/test_select.py index f272f3ed73..4ef8fa5941 100644 --- a/tests/components/wled/test_select.py +++ b/tests/components/wled/test_select.py @@ -24,18 +24,17 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util -from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture +from tests.common import async_fire_time_changed, load_fixture + +pytestmark = pytest.mark.usefixtures("init_integration") async def test_color_palette_state( - hass: HomeAssistant, init_integration: MockConfigEntry + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test the creation and values of the WLED selects.""" - entity_registry = er.async_get(hass) - # First segment of the strip - state = hass.states.get("select.wled_rgb_light_segment_1_color_palette") - assert state + assert (state := hass.states.get("select.wled_rgb_light_segment_1_color_palette")) assert state.attributes.get(ATTR_ICON) == "mdi:palette-outline" assert state.attributes.get(ATTR_OPTIONS) == [ "Analogous", @@ -91,15 +90,17 @@ async def test_color_palette_state( ] assert state.state == "Random Cycle" - entry = entity_registry.async_get("select.wled_rgb_light_segment_1_color_palette") - assert entry + assert ( + entry := entity_registry.async_get( + "select.wled_rgb_light_segment_1_color_palette" + ) + ) assert entry.unique_id == "aabbccddeeff_palette_1" assert entry.entity_category is EntityCategory.CONFIG async def test_color_palette_segment_change_state( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test the option change of state of the WLED segments.""" @@ -112,7 +113,6 @@ async def test_color_palette_segment_change_state( }, blocking=True, ) - await hass.async_block_till_done() assert mock_wled.segment.call_count == 1 mock_wled.segment.assert_called_with( segment_id=1, @@ -120,18 +120,15 @@ async def test_color_palette_segment_change_state( ) -@pytest.mark.parametrize("mock_wled", ["wled/rgb_single_segment.json"], indirect=True) +@pytest.mark.parametrize("device_fixture", ["rgb_single_segment"]) async def test_color_palette_dynamically_handle_segments( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test if a new/deleted segment is dynamically added/removed.""" - segment0 = hass.states.get("select.wled_rgb_light_color_palette") - segment1 = hass.states.get("select.wled_rgb_light_segment_1_color_palette") - assert segment0 + assert (segment0 := hass.states.get("select.wled_rgb_light_color_palette")) assert segment0.state == "Default" - assert not segment1 + assert not hass.states.get("select.wled_rgb_light_segment_1_color_palette") return_value = mock_wled.update.return_value mock_wled.update.return_value = WLEDDevice( @@ -141,11 +138,11 @@ async def test_color_palette_dynamically_handle_segments( async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() - segment0 = hass.states.get("select.wled_rgb_light_color_palette") - segment1 = hass.states.get("select.wled_rgb_light_segment_1_color_palette") - assert segment0 + assert (segment0 := hass.states.get("select.wled_rgb_light_color_palette")) assert segment0.state == "Default" - assert segment1 + assert ( + segment1 := hass.states.get("select.wled_rgb_light_segment_1_color_palette") + ) assert segment1.state == "Random Cycle" # Test adding if segment shows up again, including the master entity @@ -153,17 +150,16 @@ async def test_color_palette_dynamically_handle_segments( async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() - segment0 = hass.states.get("select.wled_rgb_light_color_palette") - segment1 = hass.states.get("select.wled_rgb_light_segment_1_color_palette") - assert segment0 + assert (segment0 := hass.states.get("select.wled_rgb_light_color_palette")) assert segment0.state == "Default" - assert segment1 + assert ( + segment1 := hass.states.get("select.wled_rgb_light_segment_1_color_palette") + ) assert segment1.state == STATE_UNAVAILABLE async def test_color_palette_select_error( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test error handling of the WLED selects.""" @@ -179,10 +175,8 @@ async def test_color_palette_select_error( }, blocking=True, ) - await hass.async_block_till_done() - state = hass.states.get("select.wled_rgb_light_segment_1_color_palette") - assert state + assert (state := hass.states.get("select.wled_rgb_light_segment_1_color_palette")) assert state.state == "Random Cycle" assert mock_wled.segment.call_count == 1 mock_wled.segment.assert_called_with(segment_id=1, palette="Icefire") @@ -190,7 +184,6 @@ async def test_color_palette_select_error( async def test_color_palette_select_connection_error( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test error handling of the WLED selects.""" @@ -206,42 +199,32 @@ async def test_color_palette_select_connection_error( }, blocking=True, ) - await hass.async_block_till_done() - state = hass.states.get("select.wled_rgb_light_segment_1_color_palette") - assert state + assert (state := hass.states.get("select.wled_rgb_light_segment_1_color_palette")) assert state.state == STATE_UNAVAILABLE assert mock_wled.segment.call_count == 1 mock_wled.segment.assert_called_with(segment_id=1, palette="Icefire") -async def test_preset_unavailable_without_presets( - hass: HomeAssistant, - init_integration: MockConfigEntry, -) -> None: +async def test_preset_unavailable_without_presets(hass: HomeAssistant) -> None: """Test WLED preset entity is unavailable when presets are not available.""" - state = hass.states.get("select.wled_rgb_light_preset") - assert state + assert (state := hass.states.get("select.wled_rgb_light_preset")) assert state.state == STATE_UNAVAILABLE -@pytest.mark.parametrize("mock_wled", ["wled/rgbw.json"], indirect=True) +@pytest.mark.parametrize("device_fixture", ["rgbw"]) async def test_preset_state( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, + entity_registry: er.EntityRegistry, ) -> None: """Test the creation and values of the WLED selects.""" - entity_registry = er.async_get(hass) - - state = hass.states.get("select.wled_rgbw_light_preset") - assert state + assert (state := hass.states.get("select.wled_rgbw_light_preset")) assert state.attributes.get(ATTR_ICON) == "mdi:playlist-play" assert state.attributes.get(ATTR_OPTIONS) == ["Preset 1", "Preset 2"] assert state.state == "Preset 1" - entry = entity_registry.async_get("select.wled_rgbw_light_preset") - assert entry + assert (entry := entity_registry.async_get("select.wled_rgbw_light_preset")) assert entry.unique_id == "aabbccddee11_preset" await hass.services.async_call( @@ -253,17 +236,14 @@ async def test_preset_state( }, blocking=True, ) - await hass.async_block_till_done() assert mock_wled.preset.call_count == 1 mock_wled.preset.assert_called_with(preset="Preset 2") -@pytest.mark.parametrize("mock_wled", ["wled/rgbw.json"], indirect=True) +@pytest.mark.parametrize("device_fixture", ["rgbw"]) async def test_old_style_preset_active( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, - caplog: pytest.LogCaptureFixture, ) -> None: """Test unknown preset returned (when old style/unknown) preset is active.""" # Set device preset state to a random number @@ -272,15 +252,13 @@ async def test_old_style_preset_active( async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() - state = hass.states.get("select.wled_rgbw_light_preset") - assert state + assert (state := hass.states.get("select.wled_rgbw_light_preset")) assert state.state == STATE_UNKNOWN -@pytest.mark.parametrize("mock_wled", ["wled/rgbw.json"], indirect=True) +@pytest.mark.parametrize("device_fixture", ["rgbw"]) async def test_preset_select_error( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test error handling of the WLED selects.""" @@ -298,17 +276,15 @@ async def test_preset_select_error( ) await hass.async_block_till_done() - state = hass.states.get("select.wled_rgbw_light_preset") - assert state + assert (state := hass.states.get("select.wled_rgbw_light_preset")) assert state.state == "Preset 1" assert mock_wled.preset.call_count == 1 mock_wled.preset.assert_called_with(preset="Preset 2") -@pytest.mark.parametrize("mock_wled", ["wled/rgbw.json"], indirect=True) +@pytest.mark.parametrize("device_fixture", ["rgbw"]) async def test_preset_select_connection_error( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test error handling of the WLED selects.""" @@ -324,42 +300,33 @@ async def test_preset_select_connection_error( }, blocking=True, ) - await hass.async_block_till_done() - state = hass.states.get("select.wled_rgbw_light_preset") - assert state + assert (state := hass.states.get("select.wled_rgbw_light_preset")) assert state.state == STATE_UNAVAILABLE assert mock_wled.preset.call_count == 1 mock_wled.preset.assert_called_with(preset="Preset 2") -async def test_playlist_unavailable_without_playlists( - hass: HomeAssistant, - init_integration: MockConfigEntry, -) -> None: +async def test_playlist_unavailable_without_playlists(hass: HomeAssistant) -> None: """Test WLED playlist entity is unavailable when playlists are not available.""" - state = hass.states.get("select.wled_rgb_light_playlist") - assert state + assert (state := hass.states.get("select.wled_rgb_light_playlist")) assert state.state == STATE_UNAVAILABLE -@pytest.mark.parametrize("mock_wled", ["wled/rgbw.json"], indirect=True) +@pytest.mark.parametrize("device_fixture", ["rgbw"]) async def test_playlist_state( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, + entity_registry: er.EntityRegistry, ) -> None: """Test the creation and values of the WLED selects.""" - entity_registry = er.async_get(hass) - state = hass.states.get("select.wled_rgbw_light_playlist") - assert state + assert (state := hass.states.get("select.wled_rgbw_light_playlist")) assert state.attributes.get(ATTR_ICON) == "mdi:play-speed" assert state.attributes.get(ATTR_OPTIONS) == ["Playlist 1", "Playlist 2"] assert state.state == "Playlist 1" - entry = entity_registry.async_get("select.wled_rgbw_light_playlist") - assert entry + assert (entry := entity_registry.async_get("select.wled_rgbw_light_playlist")) assert entry.unique_id == "aabbccddee11_playlist" await hass.services.async_call( @@ -371,17 +338,14 @@ async def test_playlist_state( }, blocking=True, ) - await hass.async_block_till_done() assert mock_wled.playlist.call_count == 1 mock_wled.playlist.assert_called_with(playlist="Playlist 2") -@pytest.mark.parametrize("mock_wled", ["wled/rgbw.json"], indirect=True) +@pytest.mark.parametrize("device_fixture", ["rgbw"]) async def test_old_style_playlist_active( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, - caplog: pytest.LogCaptureFixture, ) -> None: """Test when old style playlist cycle is active.""" # Set device playlist to 0, which meant "cycle" previously. @@ -390,15 +354,13 @@ async def test_old_style_playlist_active( async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() - state = hass.states.get("select.wled_rgbw_light_playlist") - assert state + assert (state := hass.states.get("select.wled_rgbw_light_playlist")) assert state.state == STATE_UNKNOWN -@pytest.mark.parametrize("mock_wled", ["wled/rgbw.json"], indirect=True) +@pytest.mark.parametrize("device_fixture", ["rgbw"]) async def test_playlist_select_error( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test error handling of the WLED selects.""" @@ -414,19 +376,16 @@ async def test_playlist_select_error( }, blocking=True, ) - await hass.async_block_till_done() - state = hass.states.get("select.wled_rgbw_light_playlist") - assert state + assert (state := hass.states.get("select.wled_rgbw_light_playlist")) assert state.state == "Playlist 1" assert mock_wled.playlist.call_count == 1 mock_wled.playlist.assert_called_with(playlist="Playlist 2") -@pytest.mark.parametrize("mock_wled", ["wled/rgbw.json"], indirect=True) +@pytest.mark.parametrize("device_fixture", ["rgbw"]) async def test_playlist_select_connection_error( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test error handling of the WLED selects.""" @@ -442,10 +401,8 @@ async def test_playlist_select_connection_error( }, blocking=True, ) - await hass.async_block_till_done() - state = hass.states.get("select.wled_rgbw_light_playlist") - assert state + assert (state := hass.states.get("select.wled_rgbw_light_playlist")) assert state.state == STATE_UNAVAILABLE assert mock_wled.playlist.call_count == 1 mock_wled.playlist.assert_called_with(playlist="Playlist 2") @@ -453,20 +410,16 @@ async def test_playlist_select_connection_error( async def test_live_override( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, + entity_registry: er.EntityRegistry, ) -> None: """Test the creation and values of the WLED selects.""" - entity_registry = er.async_get(hass) - - state = hass.states.get("select.wled_rgb_light_live_override") - assert state + assert (state := hass.states.get("select.wled_rgb_light_live_override")) assert state.attributes.get(ATTR_ICON) == "mdi:theater" assert state.attributes.get(ATTR_OPTIONS) == ["0", "1", "2"] assert state.state == "0" - entry = entity_registry.async_get("select.wled_rgb_light_live_override") - assert entry + assert (entry := entity_registry.async_get("select.wled_rgb_light_live_override")) assert entry.unique_id == "aabbccddeeff_live_override" await hass.services.async_call( @@ -478,14 +431,12 @@ async def test_live_override( }, blocking=True, ) - await hass.async_block_till_done() assert mock_wled.live.call_count == 1 mock_wled.live.assert_called_with(live=2) async def test_live_select_error( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test error handling of the WLED selects.""" @@ -501,10 +452,8 @@ async def test_live_select_error( }, blocking=True, ) - await hass.async_block_till_done() - state = hass.states.get("select.wled_rgb_light_live_override") - assert state + assert (state := hass.states.get("select.wled_rgb_light_live_override")) assert state.state == "0" assert mock_wled.live.call_count == 1 mock_wled.live.assert_called_with(live=1) @@ -512,7 +461,6 @@ async def test_live_select_error( async def test_live_select_connection_error( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test error handling of the WLED selects.""" @@ -528,10 +476,8 @@ async def test_live_select_connection_error( }, blocking=True, ) - await hass.async_block_till_done() - state = hass.states.get("select.wled_rgb_light_live_override") - assert state + assert (state := hass.states.get("select.wled_rgb_light_live_override")) assert state.state == STATE_UNAVAILABLE assert mock_wled.live.call_count == 1 mock_wled.live.assert_called_with(live=2) diff --git a/tests/components/wled/test_sensor.py b/tests/components/wled/test_sensor.py index f475b8fab7..f4016ce37a 100644 --- a/tests/components/wled/test_sensor.py +++ b/tests/components/wled/test_sensor.py @@ -1,11 +1,10 @@ """Tests for the WLED sensor platform.""" from datetime import datetime -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch import pytest -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass -from homeassistant.components.wled.const import DOMAIN +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ICON, @@ -24,14 +23,13 @@ from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_wled") async def test_sensors( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_wled: MagicMock, - entity_registry_enabled_by_default: AsyncMock, + entity_registry: er.EntityRegistry, ) -> None: """Test the creation and values of the WLED sensors.""" - registry = er.async_get(hass) mock_config_entry.add_to_hass(hass) test_time = datetime(2019, 11, 11, 9, 10, 32, tzinfo=dt_util.UTC) @@ -39,8 +37,7 @@ async def test_sensors( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("sensor.wled_rgb_light_estimated_current") - assert state + assert (state := hass.states.get("sensor.wled_rgb_light_estimated_current")) assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfElectricCurrent.MILLIAMPERE @@ -48,48 +45,42 @@ async def test_sensors( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.CURRENT assert state.state == "470" - entry = registry.async_get("sensor.wled_rgb_light_estimated_current") - assert entry + assert ( + entry := entity_registry.async_get("sensor.wled_rgb_light_estimated_current") + ) assert entry.unique_id == "aabbccddeeff_estimated_current" assert entry.entity_category is EntityCategory.DIAGNOSTIC - state = hass.states.get("sensor.wled_rgb_light_uptime") - assert state + assert (state := hass.states.get("sensor.wled_rgb_light_uptime")) assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None assert state.state == "2019-11-11T09:10:00+00:00" - entry = registry.async_get("sensor.wled_rgb_light_uptime") - assert entry + assert (entry := entity_registry.async_get("sensor.wled_rgb_light_uptime")) assert entry.unique_id == "aabbccddeeff_uptime" assert entry.entity_category is EntityCategory.DIAGNOSTIC - state = hass.states.get("sensor.wled_rgb_light_free_memory") - assert state + assert (state := hass.states.get("sensor.wled_rgb_light_free_memory")) assert state.attributes.get(ATTR_ICON) == "mdi:memory" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfInformation.BYTES assert state.state == "14600" assert entry.entity_category is EntityCategory.DIAGNOSTIC - entry = registry.async_get("sensor.wled_rgb_light_free_memory") - assert entry + assert (entry := entity_registry.async_get("sensor.wled_rgb_light_free_memory")) assert entry.unique_id == "aabbccddeeff_free_heap" assert entry.entity_category is EntityCategory.DIAGNOSTIC - state = hass.states.get("sensor.wled_rgb_light_wi_fi_signal") - assert state + assert (state := hass.states.get("sensor.wled_rgb_light_wi_fi_signal")) assert state.attributes.get(ATTR_ICON) == "mdi:wifi" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "76" assert entry.entity_category is EntityCategory.DIAGNOSTIC - entry = registry.async_get("sensor.wled_rgb_light_wi_fi_signal") - assert entry + assert (entry := entity_registry.async_get("sensor.wled_rgb_light_wi_fi_signal")) assert entry.unique_id == "aabbccddeeff_wifi_signal" assert entry.entity_category is EntityCategory.DIAGNOSTIC - state = hass.states.get("sensor.wled_rgb_light_wi_fi_rssi") - assert state + assert (state := hass.states.get("sensor.wled_rgb_light_wi_fi_rssi")) assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.SIGNAL_STRENGTH assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -97,30 +88,25 @@ async def test_sensors( ) assert state.state == "-62" - entry = registry.async_get("sensor.wled_rgb_light_wi_fi_rssi") - assert entry + assert (entry := entity_registry.async_get("sensor.wled_rgb_light_wi_fi_rssi")) assert entry.unique_id == "aabbccddeeff_wifi_rssi" assert entry.entity_category is EntityCategory.DIAGNOSTIC - state = hass.states.get("sensor.wled_rgb_light_wi_fi_channel") - assert state + assert (state := hass.states.get("sensor.wled_rgb_light_wi_fi_channel")) assert state.attributes.get(ATTR_ICON) == "mdi:wifi" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None assert state.state == "11" - entry = registry.async_get("sensor.wled_rgb_light_wi_fi_channel") - assert entry + assert (entry := entity_registry.async_get("sensor.wled_rgb_light_wi_fi_channel")) assert entry.unique_id == "aabbccddeeff_wifi_channel" assert entry.entity_category is EntityCategory.DIAGNOSTIC - state = hass.states.get("sensor.wled_rgb_light_wi_fi_bssid") - assert state + assert (state := hass.states.get("sensor.wled_rgb_light_wi_fi_bssid")) assert state.attributes.get(ATTR_ICON) == "mdi:wifi" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None assert state.state == "AA:AA:AA:AA:AA:BB" - entry = registry.async_get("sensor.wled_rgb_light_wi_fi_bssid") - assert entry + assert (entry := entity_registry.async_get("sensor.wled_rgb_light_wi_fi_bssid")) assert entry.unique_id == "aabbccddeeff_wifi_bssid" assert entry.entity_category is EntityCategory.DIAGNOSTIC @@ -136,17 +122,14 @@ async def test_sensors( "sensor.wled_rgb_light_wi_fi_bssid", ), ) +@pytest.mark.usefixtures("init_integration") async def test_disabled_by_default_sensors( - hass: HomeAssistant, init_integration: MockConfigEntry, entity_id: str + hass: HomeAssistant, entity_registry: er.EntityRegistry, entity_id: str ) -> None: """Test the disabled by default WLED sensors.""" - registry = er.async_get(hass) + assert hass.states.get(entity_id) is None - state = hass.states.get(entity_id) - assert state is None - - entry = registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION @@ -160,6 +143,7 @@ async def test_disabled_by_default_sensors( "signal", ], ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_no_wifi_support( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -167,17 +151,6 @@ async def test_no_wifi_support( key: str, ) -> None: """Test missing Wi-Fi information from WLED device.""" - registry = er.async_get(hass) - - # Pre-create registry entries for disabled by default sensors - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - f"aabbccddeeff_wifi_{key}", - suggested_object_id=f"wled_rgb_light_wifi_{key}", - disabled_by=None, - ) - # Remove Wi-Fi info device = mock_wled.update.return_value device.info.wifi = None @@ -187,8 +160,7 @@ async def test_no_wifi_support( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(f"sensor.wled_rgb_light_wifi_{key}") - assert state + assert (state := hass.states.get(f"sensor.wled_rgb_light_wi_fi_{key}")) assert state.state == STATE_UNKNOWN diff --git a/tests/components/wled/test_switch.py b/tests/components/wled/test_switch.py index 9a409bbdb7..ef2d3f3ac5 100644 --- a/tests/components/wled/test_switch.py +++ b/tests/components/wled/test_switch.py @@ -28,64 +28,54 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util -from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture +from tests.common import async_fire_time_changed, load_fixture + +pytestmark = pytest.mark.usefixtures("init_integration") async def test_switch_state( - hass: HomeAssistant, init_integration: MockConfigEntry + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test the creation and values of the WLED switches.""" - entity_registry = er.async_get(hass) - - state = hass.states.get("switch.wled_rgb_light_nightlight") - assert state + assert (state := hass.states.get("switch.wled_rgb_light_nightlight")) assert state.attributes.get(ATTR_DURATION) == 60 assert state.attributes.get(ATTR_ICON) == "mdi:weather-night" assert state.attributes.get(ATTR_TARGET_BRIGHTNESS) == 0 assert state.attributes.get(ATTR_FADE) assert state.state == STATE_OFF - entry = entity_registry.async_get("switch.wled_rgb_light_nightlight") - assert entry + assert (entry := entity_registry.async_get("switch.wled_rgb_light_nightlight")) assert entry.unique_id == "aabbccddeeff_nightlight" assert entry.entity_category is EntityCategory.CONFIG - state = hass.states.get("switch.wled_rgb_light_sync_send") - assert state + assert (state := hass.states.get("switch.wled_rgb_light_sync_send")) assert state.attributes.get(ATTR_ICON) == "mdi:upload-network-outline" assert state.attributes.get(ATTR_UDP_PORT) == 21324 assert state.state == STATE_OFF - entry = entity_registry.async_get("switch.wled_rgb_light_sync_send") - assert entry + assert (entry := entity_registry.async_get("switch.wled_rgb_light_sync_send")) assert entry.unique_id == "aabbccddeeff_sync_send" assert entry.entity_category is EntityCategory.CONFIG - state = hass.states.get("switch.wled_rgb_light_sync_receive") - assert state + assert (state := hass.states.get("switch.wled_rgb_light_sync_receive")) assert state.attributes.get(ATTR_ICON) == "mdi:download-network-outline" assert state.attributes.get(ATTR_UDP_PORT) == 21324 assert state.state == STATE_ON - entry = entity_registry.async_get("switch.wled_rgb_light_sync_receive") - assert entry + assert (entry := entity_registry.async_get("switch.wled_rgb_light_sync_receive")) assert entry.unique_id == "aabbccddeeff_sync_receive" assert entry.entity_category is EntityCategory.CONFIG - state = hass.states.get("switch.wled_rgb_light_reverse") - assert state + assert (state := hass.states.get("switch.wled_rgb_light_reverse")) assert state.attributes.get(ATTR_ICON) == "mdi:swap-horizontal-bold" assert state.state == STATE_OFF - entry = entity_registry.async_get("switch.wled_rgb_light_reverse") - assert entry + assert (entry := entity_registry.async_get("switch.wled_rgb_light_reverse")) assert entry.unique_id == "aabbccddeeff_reverse_0" assert entry.entity_category is EntityCategory.CONFIG -async def test_switch_change_state( - hass: HomeAssistant, init_integration: MockConfigEntry, mock_wled: MagicMock -) -> None: +async def test_switch_change_state(hass: HomeAssistant, mock_wled: MagicMock) -> None: """Test the change of state of the WLED switches.""" # Nightlight @@ -95,7 +85,6 @@ async def test_switch_change_state( {ATTR_ENTITY_ID: "switch.wled_rgb_light_nightlight"}, blocking=True, ) - await hass.async_block_till_done() assert mock_wled.nightlight.call_count == 1 mock_wled.nightlight.assert_called_with(on=True) @@ -105,7 +94,6 @@ async def test_switch_change_state( {ATTR_ENTITY_ID: "switch.wled_rgb_light_nightlight"}, blocking=True, ) - await hass.async_block_till_done() assert mock_wled.nightlight.call_count == 2 mock_wled.nightlight.assert_called_with(on=False) @@ -116,7 +104,6 @@ async def test_switch_change_state( {ATTR_ENTITY_ID: "switch.wled_rgb_light_sync_send"}, blocking=True, ) - await hass.async_block_till_done() assert mock_wled.sync.call_count == 1 mock_wled.sync.assert_called_with(send=True) @@ -126,7 +113,6 @@ async def test_switch_change_state( {ATTR_ENTITY_ID: "switch.wled_rgb_light_sync_send"}, blocking=True, ) - await hass.async_block_till_done() assert mock_wled.sync.call_count == 2 mock_wled.sync.assert_called_with(send=False) @@ -137,7 +123,6 @@ async def test_switch_change_state( {ATTR_ENTITY_ID: "switch.wled_rgb_light_sync_receive"}, blocking=True, ) - await hass.async_block_till_done() assert mock_wled.sync.call_count == 3 mock_wled.sync.assert_called_with(receive=False) @@ -147,7 +132,6 @@ async def test_switch_change_state( {ATTR_ENTITY_ID: "switch.wled_rgb_light_sync_receive"}, blocking=True, ) - await hass.async_block_till_done() assert mock_wled.sync.call_count == 4 mock_wled.sync.assert_called_with(receive=True) @@ -157,7 +141,6 @@ async def test_switch_change_state( {ATTR_ENTITY_ID: "switch.wled_rgb_light_reverse"}, blocking=True, ) - await hass.async_block_till_done() assert mock_wled.segment.call_count == 1 mock_wled.segment.assert_called_with(segment_id=0, reverse=True) @@ -167,14 +150,12 @@ async def test_switch_change_state( {ATTR_ENTITY_ID: "switch.wled_rgb_light_reverse"}, blocking=True, ) - await hass.async_block_till_done() assert mock_wled.segment.call_count == 2 mock_wled.segment.assert_called_with(segment_id=0, reverse=False) async def test_switch_error( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test error handling of the WLED switches.""" @@ -187,7 +168,6 @@ async def test_switch_error( {ATTR_ENTITY_ID: "switch.wled_rgb_light_nightlight"}, blocking=True, ) - await hass.async_block_till_done() state = hass.states.get("switch.wled_rgb_light_nightlight") assert state @@ -196,7 +176,6 @@ async def test_switch_error( async def test_switch_connection_error( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test error handling of the WLED switches.""" @@ -209,17 +188,14 @@ async def test_switch_connection_error( {ATTR_ENTITY_ID: "switch.wled_rgb_light_nightlight"}, blocking=True, ) - await hass.async_block_till_done() - state = hass.states.get("switch.wled_rgb_light_nightlight") - assert state + assert (state := hass.states.get("switch.wled_rgb_light_nightlight")) assert state.state == STATE_UNAVAILABLE -@pytest.mark.parametrize("mock_wled", ["wled/rgb_single_segment.json"], indirect=True) +@pytest.mark.parametrize("device_fixture", ["rgb_single_segment"]) async def test_switch_dynamically_handle_segments( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test if a new/deleted segment is dynamically added/removed.""" diff --git a/tests/components/wled/test_update.py b/tests/components/wled/test_update.py index a5575818c5..d35f071240 100644 --- a/tests/components/wled/test_update.py +++ b/tests/components/wled/test_update.py @@ -1,5 +1,5 @@ """Tests for the WLED update platform.""" -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import MagicMock import pytest from wled import WLEDError @@ -30,19 +30,14 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("init_integration") async def test_update_available( - hass: HomeAssistant, - init_integration: MockConfigEntry, - mock_wled: MagicMock, + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test the firmware update available.""" - entity_registry = er.async_get(hass) - - state = hass.states.get("update.wled_rgb_light_firmware") - assert state + assert (state := hass.states.get("update.wled_rgb_light_firmware")) assert state.attributes.get(ATTR_DEVICE_CLASS) == UpdateDeviceClass.FIRMWARE assert state.state == STATE_ON assert ( @@ -63,23 +58,17 @@ async def test_update_available( assert state.attributes[ATTR_TITLE] == "WLED" assert ATTR_ICON not in state.attributes - entry = entity_registry.async_get("update.wled_rgb_light_firmware") - assert entry + assert (entry := entity_registry.async_get("update.wled_rgb_light_firmware")) assert entry.unique_id == "aabbccddeeff" assert entry.entity_category is EntityCategory.CONFIG -@pytest.mark.parametrize("mock_wled", ["wled/rgb_no_update.json"], indirect=True) +@pytest.mark.parametrize("device_fixture", ["rgb_no_update"]) async def test_update_information_available( - hass: HomeAssistant, - init_integration: MockConfigEntry, - mock_wled: MagicMock, + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test having no update information available at all.""" - entity_registry = er.async_get(hass) - - state = hass.states.get("update.wled_rgb_light_firmware") - assert state + assert (state := hass.states.get("update.wled_rgb_light_firmware")) assert state.attributes.get(ATTR_DEVICE_CLASS) == UpdateDeviceClass.FIRMWARE assert state.state == STATE_UNKNOWN assert state.attributes[ATTR_INSTALLED_VERSION] is None @@ -99,18 +88,13 @@ async def test_update_information_available( assert entry.entity_category is EntityCategory.CONFIG -@pytest.mark.parametrize("mock_wled", ["wled/rgb_websocket.json"], indirect=True) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("device_fixture", ["rgb_websocket"]) async def test_no_update_available( - hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, - init_integration: MockConfigEntry, - mock_wled: MagicMock, + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test there is no update available.""" - entity_registry = er.async_get(hass) - - state = hass.states.get("update.wled_websocket_firmware") - assert state + assert (state := hass.states.get("update.wled_websocket_firmware")) assert state.state == STATE_OFF assert state.attributes.get(ATTR_DEVICE_CLASS) == UpdateDeviceClass.FIRMWARE assert state.attributes[ATTR_INSTALLED_VERSION] == "0.12.0-b2" @@ -129,15 +113,13 @@ async def test_no_update_available( assert ATTR_ICON not in state.attributes - entry = entity_registry.async_get("update.wled_websocket_firmware") - assert entry + assert (entry := entity_registry.async_get("update.wled_websocket_firmware")) assert entry.unique_id == "aabbccddeeff" assert entry.entity_category is EntityCategory.CONFIG async def test_update_error( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, caplog: pytest.LogCaptureFixture, ) -> None: @@ -150,17 +132,14 @@ async def test_update_error( {ATTR_ENTITY_ID: "update.wled_rgb_light_firmware"}, blocking=True, ) - await hass.async_block_till_done() - state = hass.states.get("update.wled_rgb_light_firmware") - assert state + assert (state := hass.states.get("update.wled_rgb_light_firmware")) assert state.state == STATE_UNAVAILABLE assert "Invalid response from API" in caplog.text async def test_update_stay_stable( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test the update entity staying on stable. @@ -169,8 +148,7 @@ async def test_update_stay_stable( is currently running a stable version. Therefore, the update entity should update to the next stable (even though beta is newer). """ - state = hass.states.get("update.wled_rgb_light_firmware") - assert state + assert (state := hass.states.get("update.wled_rgb_light_firmware")) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "0.8.5" assert state.attributes[ATTR_LATEST_VERSION] == "0.12.0" @@ -181,15 +159,13 @@ async def test_update_stay_stable( {ATTR_ENTITY_ID: "update.wled_rgb_light_firmware"}, blocking=True, ) - await hass.async_block_till_done() assert mock_wled.upgrade.call_count == 1 mock_wled.upgrade.assert_called_with(version="0.12.0") -@pytest.mark.parametrize("mock_wled", ["wled/rgbw.json"], indirect=True) +@pytest.mark.parametrize("device_fixture", ["rgbw"]) async def test_update_beta_to_stable( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test the update entity. @@ -198,8 +174,7 @@ async def test_update_beta_to_stable( is currently a beta, however, a newer stable is available. Therefore, the update entity should update to the next stable. """ - state = hass.states.get("update.wled_rgbw_light_firmware") - assert state + assert (state := hass.states.get("update.wled_rgbw_light_firmware")) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "0.8.6b4" assert state.attributes[ATTR_LATEST_VERSION] == "0.8.6" @@ -210,15 +185,13 @@ async def test_update_beta_to_stable( {ATTR_ENTITY_ID: "update.wled_rgbw_light_firmware"}, blocking=True, ) - await hass.async_block_till_done() assert mock_wled.upgrade.call_count == 1 mock_wled.upgrade.assert_called_with(version="0.8.6") -@pytest.mark.parametrize("mock_wled", ["wled/rgb_single_segment.json"], indirect=True) +@pytest.mark.parametrize("device_fixture", ["rgb_single_segment"]) async def test_update_stay_beta( hass: HomeAssistant, - init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test the update entity. @@ -226,8 +199,7 @@ async def test_update_stay_beta( There is an update for beta and the device is currently a beta. Therefore, the update entity should update to the next beta. """ - state = hass.states.get("update.wled_rgb_light_firmware") - assert state + assert (state := hass.states.get("update.wled_rgb_light_firmware")) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "0.8.6b1" assert state.attributes[ATTR_LATEST_VERSION] == "0.8.6b2" @@ -238,6 +210,5 @@ async def test_update_stay_beta( {ATTR_ENTITY_ID: "update.wled_rgb_light_firmware"}, blocking=True, ) - await hass.async_block_till_done() assert mock_wled.upgrade.call_count == 1 mock_wled.upgrade.assert_called_with(version="0.8.6b2") From 306322704cdd15d481b44cc5e14ecc02b18841d1 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 20 Feb 2023 13:10:33 +0100 Subject: [PATCH 0230/2810] Update CI runner to ubuntu-22.04 (#88420) --- .github/workflows/ci.yaml | 42 ++++++++++++++++++--------------------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d4def28106..6c8c6b88b0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -76,7 +76,7 @@ jobs: test_groups: ${{ steps.info.outputs.test_groups }} tests_glob: ${{ steps.info.outputs.tests_glob }} tests: ${{ steps.info.outputs.tests }} - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - name: Check out code from GitHub uses: actions/checkout@v3.3.0 @@ -195,7 +195,7 @@ jobs: pre-commit: name: Prepare pre-commit base - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 if: | github.event.inputs.pylint-only != 'true' && github.event.inputs.mypy-only != 'true' @@ -241,7 +241,7 @@ jobs: lint-black: name: Check black - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: - info - pre-commit @@ -287,7 +287,7 @@ jobs: lint-flake8: name: Check flake8 - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: - info - pre-commit @@ -336,7 +336,7 @@ jobs: lint-ruff: name: Check ruff - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: - info - pre-commit @@ -385,7 +385,7 @@ jobs: lint-isort: name: Check isort - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: - info - pre-commit @@ -423,7 +423,7 @@ jobs: lint-other: name: Check other linters - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: - info - pre-commit @@ -540,7 +540,7 @@ jobs: base: name: Prepare dependencies - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: info timeout-minutes: 60 strategy: @@ -606,7 +606,7 @@ jobs: hassfest: name: Check hassfest - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 if: | github.event.inputs.pylint-only != 'true' && github.event.inputs.mypy-only != 'true' @@ -638,7 +638,7 @@ jobs: gen-requirements-all: name: Check all requirements - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 if: | github.event.inputs.pylint-only != 'true' && github.event.inputs.mypy-only != 'true' @@ -670,7 +670,7 @@ jobs: pylint: name: Check pylint - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 timeout-minutes: 20 if: | github.event.inputs.mypy-only != 'true' @@ -715,7 +715,7 @@ jobs: mypy: name: Check mypy - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 if: | github.event.inputs.pylint-only != 'true' || github.event.inputs.mypy-only == 'true' @@ -776,7 +776,7 @@ jobs: mypy homeassistant/components/${{ needs.info.outputs.integrations_glob }} pip-check: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 if: | github.event.inputs.pylint-only != 'true' && github.event.inputs.mypy-only != 'true' @@ -812,7 +812,7 @@ jobs: ./script/pip_check $PIP_CACHE pytest: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 if: | (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core') && github.event.inputs.lint-only != 'true' @@ -929,7 +929,7 @@ jobs: ./script/check_dirty pytest-mariadb: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 services: mariadb: image: ${{ matrix.mariadb-group }} @@ -1033,7 +1033,7 @@ jobs: ./script/check_dirty pytest-postgres: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 services: postgres: image: ${{ matrix.postgresql-group }} @@ -1071,7 +1071,7 @@ jobs: sudo apt-get -y install \ bluez \ ffmpeg \ - postgresql-server-dev-12 + postgresql-server-dev-14 - name: Check out code from GitHub uses: actions/checkout@v3.1.0 - name: Set up Python ${{ matrix.python-version }} @@ -1085,13 +1085,9 @@ jobs: uses: actions/cache@v3.2.5 with: path: venv + fail-on-cache-miss: true key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - - name: Fail job if Python cache restore failed - if: steps.cache-venv.outputs.cache-hit != 'true' - run: | - echo "Failed to restore Python virtual environment from cache" - exit 1 - name: Register Python problem matcher run: | echo "::add-matcher::.github/workflows/matchers/python.json" @@ -1143,7 +1139,7 @@ jobs: coverage: name: Upload test coverage to Codecov - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: - info - pytest From 9876dd804e4f0b0034eac17c9099c4e76f68b38f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Feb 2023 07:09:56 -0600 Subject: [PATCH 0231/2810] Exclude event_id and event_score from being recorded in unifiprotect (#88387) --- .../components/unifiprotect/recorder.py | 12 +++ .../components/unifiprotect/test_recorder.py | 91 +++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 homeassistant/components/unifiprotect/recorder.py create mode 100644 tests/components/unifiprotect/test_recorder.py diff --git a/homeassistant/components/unifiprotect/recorder.py b/homeassistant/components/unifiprotect/recorder.py new file mode 100644 index 0000000000..6603a0543f --- /dev/null +++ b/homeassistant/components/unifiprotect/recorder.py @@ -0,0 +1,12 @@ +"""Integration platform for recorder.""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant, callback + +from .const import ATTR_EVENT_ID, ATTR_EVENT_SCORE + + +@callback +def exclude_attributes(hass: HomeAssistant) -> set[str]: + """Exclude event_id and event_score from being recorded in the database.""" + return {ATTR_EVENT_ID, ATTR_EVENT_SCORE} diff --git a/tests/components/unifiprotect/test_recorder.py b/tests/components/unifiprotect/test_recorder.py new file mode 100644 index 0000000000..628f5022af --- /dev/null +++ b/tests/components/unifiprotect/test_recorder.py @@ -0,0 +1,91 @@ +"""The tests for unifiprotect recorder.""" +from __future__ import annotations + +from datetime import datetime, timedelta +from unittest.mock import Mock + +from pyunifiprotect.data import Camera, Event, EventType + +from homeassistant.components.recorder import Recorder +from homeassistant.components.recorder.db_schema import StateAttributes, States +from homeassistant.components.recorder.util import session_scope +from homeassistant.components.unifiprotect.binary_sensor import EVENT_SENSORS +from homeassistant.components.unifiprotect.const import ( + ATTR_EVENT_ID, + ATTR_EVENT_SCORE, + DEFAULT_ATTRIBUTION, +) +from homeassistant.const import ATTR_ATTRIBUTION, ATTR_FRIENDLY_NAME, STATE_ON, Platform +from homeassistant.core import HomeAssistant, State + +from .utils import MockUFPFixture, ids_from_device_description, init_entry + +from tests.components.recorder.common import async_wait_recording_done + + +async def test_exclude_attributes( + recorder_mock: Recorder, + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, + fixed_now: datetime, +) -> None: + """Test binary_sensor has event_id and event_score excluded from recording.""" + + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + + _, entity_id = ids_from_device_description( + Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[1] + ) + + event = Event( + id="test_event_id", + type=EventType.MOTION, + start=fixed_now - timedelta(seconds=1), + end=None, + score=100, + smart_detect_types=[], + smart_detect_event_ids=[], + camera_id=doorbell.id, + ) + + new_camera = doorbell.copy() + new_camera.is_motion_detected = True + new_camera.last_motion_event_id = event.id + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = new_camera + + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.api.bootstrap.events = {event.id: event} + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + assert state.attributes[ATTR_EVENT_SCORE] == 100 + await async_wait_recording_done(hass) + + def _fetch_states() -> list[State]: + with session_scope(hass=hass) as session: + native_states = [] + for db_state, db_state_attributes in session.query( + States, StateAttributes + ).outerjoin( + StateAttributes, States.attributes_id == StateAttributes.attributes_id + ): + state = db_state.to_native() + state.attributes = db_state_attributes.to_native() + native_states.append(state) + return native_states + + states: list[State] = await hass.async_add_executor_job(_fetch_states) + assert len(states) > 1 + for state in states: + assert ATTR_EVENT_SCORE not in state.attributes + assert ATTR_EVENT_ID not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes From 6a0ea09f296247e5538b118526c0a8afa49d4d9b Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Mon, 20 Feb 2023 14:48:56 +0100 Subject: [PATCH 0232/2810] Upload KNX Keyfile from Config/Options Flow directly (#88097) * Manage KNX Keyfile from UI * migrate config entry to use new keyfile * Revert "migrate config entry to use new keyfile" use same config style as before instead of entry version migration * clean up uploaded file when integration is removed * change default filename * revert to previous step name * remove empty directory on unload --- homeassistant/components/knx/__init__.py | 53 +++- homeassistant/components/knx/config_flow.py | 259 +++++++++++------ homeassistant/components/knx/const.py | 2 + homeassistant/components/knx/manifest.json | 1 + homeassistant/components/knx/strings.json | 13 +- tests/components/knx/test_config_flow.py | 307 ++++++++++++++------ tests/components/knx/test_init.py | 35 +++ 7 files changed, 480 insertions(+), 190 deletions(-) diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 5a5daa5008..f58df9dc11 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -2,7 +2,9 @@ from __future__ import annotations import asyncio +import contextlib import logging +from pathlib import Path from typing import Final import voluptuous as vol @@ -335,6 +337,21 @@ async def async_update_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: await hass.config_entries.async_reload(entry.entry_id) +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove a config entry.""" + + def remove_keyring_files(file_path: Path) -> None: + """Remove keyring files.""" + with contextlib.suppress(FileNotFoundError): + file_path.unlink() + with contextlib.suppress(FileNotFoundError, OSError): + file_path.parent.rmdir() + + if (_knxkeys_file := entry.data.get(CONF_KNX_KNXKEY_FILENAME)) is not None: + file_path = Path(hass.config.path(STORAGE_DIR)) / _knxkeys_file + await hass.async_add_executor_job(remove_keyring_files, file_path) + + class KNXModule: """Representation of KNX Object.""" @@ -384,6 +401,14 @@ class KNXModule: def connection_config(self) -> ConnectionConfig: """Return the connection_config.""" _conn_type: str = self.entry.data[CONF_KNX_CONNECTION_TYPE] + _knxkeys_file: str | None = ( + self.hass.config.path( + STORAGE_DIR, + self.entry.data[CONF_KNX_KNXKEY_FILENAME], + ) + if self.entry.data.get(CONF_KNX_KNXKEY_FILENAME) is not None + else None + ) if _conn_type == CONF_KNX_ROUTING: return ConnectionConfig( connection_type=ConnectionType.ROUTING, @@ -392,6 +417,10 @@ class KNXModule: multicast_port=self.entry.data[CONF_KNX_MCAST_PORT], local_ip=self.entry.data.get(CONF_KNX_LOCAL_IP), auto_reconnect=True, + secure_config=SecureConfig( + knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), + knxkeys_file_path=_knxkeys_file, + ), threaded=True, ) if _conn_type == CONF_KNX_TUNNELING: @@ -402,6 +431,10 @@ class KNXModule: local_ip=self.entry.data.get(CONF_KNX_LOCAL_IP), route_back=self.entry.data.get(CONF_KNX_ROUTE_BACK, False), auto_reconnect=True, + secure_config=SecureConfig( + knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), + knxkeys_file_path=_knxkeys_file, + ), threaded=True, ) if _conn_type == CONF_KNX_TUNNELING_TCP: @@ -410,16 +443,12 @@ class KNXModule: gateway_ip=self.entry.data[CONF_HOST], gateway_port=self.entry.data[CONF_PORT], auto_reconnect=True, + secure_config=SecureConfig( + knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), + knxkeys_file_path=_knxkeys_file, + ), threaded=True, ) - knxkeys_file: str | None = ( - self.hass.config.path( - STORAGE_DIR, - self.entry.data[CONF_KNX_KNXKEY_FILENAME], - ) - if self.entry.data.get(CONF_KNX_KNXKEY_FILENAME) is not None - else None - ) if _conn_type == CONF_KNX_TUNNELING_TCP_SECURE: return ConnectionConfig( connection_type=ConnectionType.TUNNELING_TCP_SECURE, @@ -432,7 +461,7 @@ class KNXModule: CONF_KNX_SECURE_DEVICE_AUTHENTICATION ), knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), - knxkeys_file_path=knxkeys_file, + knxkeys_file_path=_knxkeys_file, ), auto_reconnect=True, threaded=True, @@ -450,13 +479,17 @@ class KNXModule: CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE ), knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), - knxkeys_file_path=knxkeys_file, + knxkeys_file_path=_knxkeys_file, ), auto_reconnect=True, threaded=True, ) return ConnectionConfig( auto_reconnect=True, + secure_config=SecureConfig( + knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), + knxkeys_file_path=_knxkeys_file, + ), threaded=True, ) diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index 7465c394dd..ec79dbc5f9 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from abc import ABC, abstractmethod from collections.abc import AsyncGenerator +from pathlib import Path from typing import Any, Final import voluptuous as vol @@ -11,8 +12,9 @@ from xknx.exceptions.exception import CommunicationError, InvalidSecureConfigura from xknx.io import DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT from xknx.io.gateway_scanner import GatewayDescriptor, GatewayScanner from xknx.io.self_description import request_description -from xknx.secure.keyring import XMLInterface, load_keyring +from xknx.secure.keyring import Keyring, XMLInterface, sync_load_keyring +from homeassistant.components.file_upload import process_uploaded_file from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import callback @@ -27,7 +29,6 @@ from .const import ( CONF_KNX_DEFAULT_RATE_LIMIT, CONF_KNX_DEFAULT_STATE_UPDATER, CONF_KNX_INDIVIDUAL_ADDRESS, - CONF_KNX_KNXKEY_FILENAME, CONF_KNX_KNXKEY_PASSWORD, CONF_KNX_LOCAL_IP, CONF_KNX_MCAST_GRP, @@ -42,10 +43,10 @@ from .const import ( CONF_KNX_SECURE_USER_ID, CONF_KNX_SECURE_USER_PASSWORD, CONF_KNX_STATE_UPDATER, + CONF_KNX_TUNNEL_ENDPOINT_IA, CONF_KNX_TUNNELING, CONF_KNX_TUNNELING_TCP, CONF_KNX_TUNNELING_TCP_SECURE, - CONST_KNX_STORAGE_KEY, DEFAULT_ROUTING_IA, DOMAIN, KNXConfigEntryData, @@ -65,6 +66,9 @@ DEFAULT_ENTRY_DATA = KNXConfigEntryData( state_updater=CONF_KNX_DEFAULT_STATE_UPDATER, ) +CONF_KEYRING_FILE: Final = "knxkeys_file" +DEFAULT_KNX_KEYRING_FILENAME: Final = "keyring.knxkeys" + CONF_KNX_TUNNELING_TYPE: Final = "tunneling_type" CONF_KNX_TUNNELING_TYPE_LABELS: Final = { CONF_KNX_TUNNELING: "UDP (Tunnelling v1)", @@ -93,6 +97,9 @@ class KNXCommonFlow(ABC, FlowHandler): """Initialize KNXCommonFlow.""" self.initial_data = initial_data self.new_entry_data = KNXConfigEntryData() + self.new_title: str | None = None + + self._keyring: Keyring | None = None self._found_gateways: list[GatewayDescriptor] = [] self._found_tunnels: list[GatewayDescriptor] = [] self._selected_tunnel: GatewayDescriptor | None = None @@ -102,9 +109,25 @@ class KNXCommonFlow(ABC, FlowHandler): self._async_scan_gen: AsyncGenerator[GatewayDescriptor, None] | None = None @abstractmethod - def finish_flow(self, title: str) -> FlowResult: + def finish_flow(self) -> FlowResult: """Finish the flow.""" + @property + def connection_type(self) -> str: + """Return the configured connection type.""" + _new_type = self.new_entry_data.get(CONF_KNX_CONNECTION_TYPE) + if _new_type is None: + return self.initial_data[CONF_KNX_CONNECTION_TYPE] + return _new_type + + @property + def tunnel_endpoint_ia(self) -> str | None: + """Return the configured tunnel endpoint individual address.""" + return self.new_entry_data.get( + CONF_KNX_TUNNEL_ENDPOINT_IA, + self.initial_data.get(CONF_KNX_TUNNEL_ENDPOINT_IA), + ) + async def async_step_connection_type( self, user_input: dict | None = None ) -> FlowResult: @@ -135,8 +158,12 @@ class KNXCommonFlow(ABC, FlowHandler): return await self.async_step_tunnel() # Automatic connection type - self.new_entry_data = KNXConfigEntryData(connection_type=CONF_KNX_AUTOMATIC) - return self.finish_flow(title=CONF_KNX_AUTOMATIC.capitalize()) + self.new_entry_data = KNXConfigEntryData( + connection_type=CONF_KNX_AUTOMATIC, + tunnel_endpoint_ia=None, + ) + self.new_title = CONF_KNX_AUTOMATIC.capitalize() + return self.finish_flow() supported_connection_types = { CONF_KNX_TUNNELING: CONF_KNX_TUNNELING.capitalize(), @@ -194,13 +221,18 @@ class KNXCommonFlow(ABC, FlowHandler): port=self._selected_tunnel.port, route_back=False, connection_type=connection_type, + device_authentication=None, + user_id=None, + user_password=None, + tunnel_endpoint_ia=None, ) if connection_type == CONF_KNX_TUNNELING_TCP_SECURE: return self.async_show_menu( step_id="secure_key_source", menu_options=["secure_knxkeys", "secure_tunnel_manual"], ) - return self.finish_flow(title=f"Tunneling @ {self._selected_tunnel}") + self.new_title = f"Tunneling @ {self._selected_tunnel}" + return self.finish_flow() if not self._found_tunnels: return await self.async_step_manual_tunnel() @@ -264,6 +296,10 @@ class KNXCommonFlow(ABC, FlowHandler): port=user_input[CONF_PORT], route_back=user_input[CONF_KNX_ROUTE_BACK], local_ip=_local_ip, + device_authentication=None, + user_id=None, + user_password=None, + tunnel_endpoint_ia=None, ) if selected_tunnelling_type == CONF_KNX_TUNNELING_TCP_SECURE: @@ -271,7 +307,12 @@ class KNXCommonFlow(ABC, FlowHandler): step_id="secure_key_source", menu_options=["secure_knxkeys", "secure_tunnel_manual"], ) - return self.finish_flow(title=f"Tunneling @ {_host}") + self.new_title = ( + "Tunneling " + f"{'UDP' if selected_tunnelling_type == CONF_KNX_TUNNELING else 'TCP'} " + f"@ {_host}" + ) + return self.finish_flow() _reconfiguring_existing_tunnel = ( self.initial_data.get(CONF_KNX_CONNECTION_TYPE) @@ -342,10 +383,10 @@ class KNXCommonFlow(ABC, FlowHandler): device_authentication=user_input[CONF_KNX_SECURE_DEVICE_AUTHENTICATION], user_id=user_input[CONF_KNX_SECURE_USER_ID], user_password=user_input[CONF_KNX_SECURE_USER_PASSWORD], + tunnel_endpoint_ia=None, ) - return self.finish_flow( - title=f"Secure Tunneling @ {self.new_entry_data[CONF_HOST]}" - ) + self.new_title = f"Secure Tunneling @ {self.new_entry_data[CONF_HOST]}" + return self.finish_flow() fields = { vol.Required( @@ -399,12 +440,8 @@ class KNXCommonFlow(ABC, FlowHandler): CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE ], ) - return self.finish_flow( - title=( - "Secure Routing as" - f" {self.new_entry_data[CONF_KNX_INDIVIDUAL_ADDRESS]}" - ) - ) + self.new_title = f"Secure Routing as {self.new_entry_data[CONF_KNX_INDIVIDUAL_ADDRESS]}" + return self.finish_flow() fields = { vol.Required( @@ -437,92 +474,101 @@ class KNXCommonFlow(ABC, FlowHandler): ) async def async_step_secure_knxkeys( - self, user_input: dict | None = None + self, user_input: dict[str, Any] | None = None ) -> FlowResult: - """Configure secure knxkeys used to authenticate.""" - errors = {} - description_placeholders = {} + """Manage upload of new KNX Keyring file.""" + errors: dict[str, str] = {} if user_input is not None: - connection_type = self.new_entry_data[CONF_KNX_CONNECTION_TYPE] - storage_key = CONST_KNX_STORAGE_KEY + user_input[CONF_KNX_KNXKEY_FILENAME] - try: - keyring = await load_keyring( - path=self.hass.config.path(STORAGE_DIR, storage_key), - password=user_input[CONF_KNX_KNXKEY_PASSWORD], - ) - except FileNotFoundError: - errors[CONF_KNX_KNXKEY_FILENAME] = "keyfile_not_found" - except InvalidSecureConfiguration: - errors[CONF_KNX_KNXKEY_PASSWORD] = "keyfile_invalid_signature" - else: - if ( - connection_type == CONF_KNX_TUNNELING_TCP_SECURE - and self._selected_tunnel is not None - ): - if host_ia := self._selected_tunnel.individual_address: - self._tunnel_endpoints = keyring.get_tunnel_interfaces_by_host( - host=host_ia - ) - if not self._tunnel_endpoints: - errors["base"] = "keyfile_no_tunnel_for_host" - description_placeholders = {CONF_HOST: str(host_ia)} - - if connection_type == CONF_KNX_ROUTING_SECURE: - if not (keyring.backbone is not None and keyring.backbone.key): - errors["base"] = "keyfile_no_backbone_key" - - if not errors: + password = user_input[CONF_KNX_KNXKEY_PASSWORD] + errors = await self._save_uploaded_knxkeys_file( + uploaded_file_id=user_input[CONF_KEYRING_FILE], + password=password, + ) + if not errors and self._keyring: self.new_entry_data |= KNXConfigEntryData( - knxkeys_filename=storage_key, - knxkeys_password=user_input[CONF_KNX_KNXKEY_PASSWORD], + knxkeys_filename=f"{DOMAIN}/{DEFAULT_KNX_KEYRING_FILENAME}", + knxkeys_password=password, backbone_key=None, sync_latency_tolerance=None, - device_authentication=None, - user_id=None, - user_password=None, ) - if connection_type == CONF_KNX_ROUTING_SECURE: - return self.finish_flow( - title=( - "Secure Routing as" - f" {self.new_entry_data[CONF_KNX_INDIVIDUAL_ADDRESS]}" - ) - ) - return await self.async_step_knxkeys_tunnel_select() + # Routing + if self.connection_type in (CONF_KNX_ROUTING, CONF_KNX_ROUTING_SECURE): + return self.finish_flow() + + # Tunneling / Automatic + # skip selection step if we have a keyfile update that includes a configured tunnel + if self.tunnel_endpoint_ia is not None and self.tunnel_endpoint_ia in [ + str(_if.individual_address) for _if in self._keyring.interfaces + ]: + return self.finish_flow() + if not errors: + return await self.async_step_knxkeys_tunnel_select() - if _default_filename := self.initial_data.get(CONF_KNX_KNXKEY_FILENAME): - _default_filename = _default_filename.lstrip(CONST_KNX_STORAGE_KEY) fields = { - vol.Required( - CONF_KNX_KNXKEY_FILENAME, default=_default_filename - ): selector.TextSelector(), + vol.Required(CONF_KEYRING_FILE): selector.FileSelector( + config=selector.FileSelectorConfig(accept=".knxkeys") + ), vol.Required( CONF_KNX_KNXKEY_PASSWORD, default=self.initial_data.get(CONF_KNX_KNXKEY_PASSWORD), ): selector.TextSelector(), } - return self.async_show_form( step_id="secure_knxkeys", data_schema=vol.Schema(fields), errors=errors, - description_placeholders=description_placeholders, ) async def async_step_knxkeys_tunnel_select( self, user_input: dict | None = None ) -> FlowResult: """Select if a specific tunnel should be used from knxkeys file.""" + errors = {} + description_placeholders = {} if user_input is not None: - if user_input[CONF_KNX_SECURE_USER_ID] == CONF_KNX_AUTOMATIC: - selected_user_id = None + selected_tunnel_ia: str | None = None + _if_user_id: int | None = None + if user_input[CONF_KNX_TUNNEL_ENDPOINT_IA] == CONF_KNX_AUTOMATIC: + self.new_entry_data |= KNXConfigEntryData( + tunnel_endpoint_ia=None, + ) else: - selected_user_id = int(user_input[CONF_KNX_SECURE_USER_ID]) - self.new_entry_data |= KNXConfigEntryData(user_id=selected_user_id) - return self.finish_flow( - title=f"Secure Tunneling @ {self.new_entry_data[CONF_HOST]}" + selected_tunnel_ia = user_input[CONF_KNX_TUNNEL_ENDPOINT_IA] + self.new_entry_data |= KNXConfigEntryData( + tunnel_endpoint_ia=selected_tunnel_ia, + user_id=None, + user_password=None, + device_authentication=None, + ) + _if_user_id = next( + ( + _if.user_id + for _if in self._tunnel_endpoints + if str(_if.individual_address) == selected_tunnel_ia + ), + None, + ) + self.new_title = ( + f"{'Secure ' if _if_user_id else ''}" + f"Tunneling @ {selected_tunnel_ia or self.new_entry_data[CONF_HOST]}" ) + return self.finish_flow() + + # this step is only called from async_step_secure_knxkeys so self._keyring is always set + assert self._keyring + + # Filter for selected tunnel + if self._selected_tunnel is not None: + if host_ia := self._selected_tunnel.individual_address: + self._tunnel_endpoints = self._keyring.get_tunnel_interfaces_by_host( + host=host_ia + ) + if not self._tunnel_endpoints: + errors["base"] = "keyfile_no_tunnel_for_host" + description_placeholders = {CONF_HOST: str(host_ia)} + else: + self._tunnel_endpoints = self._keyring.interfaces tunnel_endpoint_options = [ selector.SelectOptionDict( @@ -532,8 +578,12 @@ class KNXCommonFlow(ABC, FlowHandler): for endpoint in self._tunnel_endpoints: tunnel_endpoint_options.append( selector.SelectOptionDict( - value=str(endpoint.user_id), - label=f"{endpoint.individual_address} (User ID: {endpoint.user_id})", + value=str(endpoint.individual_address), + label=( + f"{endpoint.individual_address} " + f"{'🔐 ' if endpoint.user_id else ''}" + f"(Data Secure GAs: {len(endpoint.group_addresses)})" + ), ) ) return self.async_show_form( @@ -541,7 +591,7 @@ class KNXCommonFlow(ABC, FlowHandler): data_schema=vol.Schema( { vol.Required( - CONF_KNX_SECURE_USER_ID, default=CONF_KNX_AUTOMATIC + CONF_KNX_TUNNEL_ENDPOINT_IA, default=CONF_KNX_AUTOMATIC ): selector.SelectSelector( selector.SelectSelectorConfig( options=tunnel_endpoint_options, @@ -550,6 +600,8 @@ class KNXCommonFlow(ABC, FlowHandler): ), } ), + errors=errors, + description_placeholders=description_placeholders, ) async def async_step_routing(self, user_input: dict | None = None) -> FlowResult: @@ -598,13 +650,19 @@ class KNXCommonFlow(ABC, FlowHandler): multicast_group=_multicast_group, multicast_port=_multicast_port, local_ip=_local_ip, + device_authentication=None, + user_id=None, + user_password=None, + tunnel_endpoint_ia=None, ) if connection_type == CONF_KNX_ROUTING_SECURE: + self.new_title = f"Secure Routing as {_individual_address}" return self.async_show_menu( step_id="secure_key_source", menu_options=["secure_knxkeys", "secure_routing_manual"], ) - return self.finish_flow(title=f"Routing as {_individual_address}") + self.new_title = f"Routing as {_individual_address}" + return self.finish_flow() routers = [router for router in self._found_gateways if router.supports_routing] if not routers: @@ -631,6 +689,32 @@ class KNXCommonFlow(ABC, FlowHandler): step_id="routing", data_schema=vol.Schema(fields), errors=errors ) + async def _save_uploaded_knxkeys_file( + self, uploaded_file_id: str, password: str + ) -> dict[str, str]: + """Validate the uploaded file and move it to the storage directory. Return errors.""" + + def _process_upload() -> tuple[Keyring | None, dict[str, str]]: + keyring: Keyring | None = None + errors = {} + with process_uploaded_file(self.hass, uploaded_file_id) as file_path: + try: + keyring = sync_load_keyring( + path=file_path, + password=password, + ) + except InvalidSecureConfiguration: + errors[CONF_KNX_KNXKEY_PASSWORD] = "keyfile_invalid_signature" + 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) + return keyring, errors + + keyring, errors = await self.hass.async_add_executor_job(_process_upload) + self._keyring = keyring + return errors + class KNXConfigFlow(KNXCommonFlow, ConfigFlow, domain=DOMAIN): """Handle a KNX config flow.""" @@ -648,8 +732,9 @@ class KNXConfigFlow(KNXCommonFlow, ConfigFlow, domain=DOMAIN): return KNXOptionsFlow(config_entry) @callback - def finish_flow(self, title: str) -> FlowResult: + def finish_flow(self) -> FlowResult: """Create the ConfigEntry.""" + title = self.new_title or f"KNX {self.new_entry_data[CONF_KNX_CONNECTION_TYPE]}" return self.async_create_entry( title=title, data=DEFAULT_ENTRY_DATA | self.new_entry_data, @@ -673,13 +758,13 @@ class KNXOptionsFlow(KNXCommonFlow, OptionsFlow): super().__init__(initial_data=config_entry.data) # type: ignore[arg-type] @callback - def finish_flow(self, title: str | None) -> FlowResult: + def finish_flow(self) -> FlowResult: """Update the ConfigEntry and finish the flow.""" new_data = DEFAULT_ENTRY_DATA | self.initial_data | self.new_entry_data self.hass.config_entries.async_update_entry( self.config_entry, data=new_data, - title=title or UNDEFINED, + title=self.new_title or UNDEFINED, ) return self.async_create_entry(title="", data={}) @@ -689,7 +774,11 @@ class KNXOptionsFlow(KNXCommonFlow, OptionsFlow): """Manage KNX options.""" return self.async_show_menu( step_id="options_init", - menu_options=["connection_type", "communication_settings"], + menu_options=[ + "connection_type", + "communication_settings", + "secure_knxkeys", + ], ) async def async_step_communication_settings( @@ -701,7 +790,7 @@ class KNXOptionsFlow(KNXCommonFlow, OptionsFlow): state_updater=user_input[CONF_KNX_STATE_UPDATER], rate_limit=user_input[CONF_KNX_RATE_LIMIT], ) - return self.finish_flow(title=None) + return self.finish_flow() data_schema = { vol.Required( diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 058223bfaa..d006637abd 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -39,6 +39,7 @@ CONF_KNX_TUNNELING_TCP_SECURE: Final = "tunneling_tcp_secure" CONF_KNX_LOCAL_IP: Final = "local_ip" CONF_KNX_MCAST_GRP: Final = "multicast_group" CONF_KNX_MCAST_PORT: Final = "multicast_port" +CONF_KNX_TUNNEL_ENDPOINT_IA: Final = "tunnel_endpoint_ia" CONF_KNX_RATE_LIMIT: Final = "rate_limit" CONF_KNX_ROUTE_BACK: Final = "route_back" @@ -89,6 +90,7 @@ class KNXConfigEntryData(TypedDict, total=False): rate_limit: int host: str port: int + tunnel_endpoint_ia: str | None user_id: int | None user_password: str | None diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index c8c5c37a48..9bf2731b3d 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -3,6 +3,7 @@ "name": "KNX", "codeowners": ["@Julius2342", "@farmio", "@marvin-w"], "config_flow": true, + "dependencies": ["file_upload"], "documentation": "https://www.home-assistant.io/integrations/knx", "integration_type": "hub", "iot_class": "local_push", diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index d1ac3793c0..a781f9d73c 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -42,14 +42,13 @@ } }, "secure_knxkeys": { - "title": "Keyfile", - "description": "Please enter the information for your `.knxkeys` file.", + "title": "Import KNX Keyring", + "description": "Please select a `.knxkeys` file to import.", "data": { - "knxkeys_filename": "The filename of your `.knxkeys` file (including extension)", + "knxkeys_file": "Keyring file", "knxkeys_password": "The password to decrypt the `.knxkeys` file" }, "data_description": { - "knxkeys_filename": "The file is expected to be found in your config directory in `.storage/knx/`.\nIn Home Assistant OS this would be `/config/.storage/knx/`\nExample: `my_project.knxkeys`", "knxkeys_password": "This was set when exporting the file from ETS." } }, @@ -126,7 +125,8 @@ "title": "KNX Settings", "menu_options": { "connection_type": "Configure KNX interface", - "communication_settings": "Communication settings" + "communication_settings": "Communication settings", + "secure_knxkeys": "Import a `.knxkeys` file" } }, "communication_settings": { @@ -184,11 +184,10 @@ "title": "[%key:component::knx::config::step::secure_knxkeys::title%]", "description": "[%key:component::knx::config::step::secure_knxkeys::description%]", "data": { - "knxkeys_filename": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_filename%]", + "knxkeys_file": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_file%]", "knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_password%]" }, "data_description": { - "knxkeys_filename": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_filename%]", "knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_password%]" } }, diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py index 4466db2b8c..4ac6a36611 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -1,4 +1,5 @@ """Test the KNX config flow.""" +from contextlib import contextmanager from unittest.mock import Mock, patch import pytest @@ -10,6 +11,7 @@ from xknx.telegram import IndividualAddress from homeassistant import config_entries from homeassistant.components.knx.config_flow import ( + CONF_KEYRING_FILE, CONF_KNX_GATEWAY, CONF_KNX_TUNNELING_TYPE, DEFAULT_ENTRY_DATA, @@ -35,6 +37,7 @@ from homeassistant.components.knx.const import ( CONF_KNX_SECURE_USER_ID, CONF_KNX_SECURE_USER_PASSWORD, CONF_KNX_STATE_UPDATER, + CONF_KNX_TUNNEL_ENDPOINT_IA, CONF_KNX_TUNNELING, CONF_KNX_TUNNELING_TCP, CONF_KNX_TUNNELING_TCP_SECURE, @@ -47,6 +50,10 @@ from homeassistant.data_entry_flow import FlowResult, FlowResultType from tests.common import MockConfigEntry, get_fixture_path FIXTURE_KNXKEYS_PASSWORD = "test" +FIXTURE_KEYRING = sync_load_keyring( + get_fixture_path("fixture.knxkeys", DOMAIN), FIXTURE_KNXKEYS_PASSWORD +) +FIXTURE_UPLOAD_UUID = "0123456789abcdef0123456789abcdef" GATEWAY_INDIVIDUAL_ADDRESS = IndividualAddress("1.0.0") @@ -59,6 +66,29 @@ def fixture_knx_setup(): yield mock_async_setup_entry +@contextmanager +def patch_file_upload(return_value=FIXTURE_KEYRING, side_effect=None): + """Patch file upload. Yields the Keyring instance (return_value).""" + with patch( + "homeassistant.components.knx.config_flow.process_uploaded_file" + ) as file_upload_mock, patch( + "homeassistant.components.knx.config_flow.sync_load_keyring", + return_value=return_value, + 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 + yield return_value + if side_effect: + mkdir_mock.assert_not_called() + file_path_mock.rename.assert_not_called() + else: + mkdir_mock.assert_called_once() + file_path_mock.rename.assert_called_once() + + def _gateway_descriptor( ip: str, port: int, @@ -153,6 +183,10 @@ async def test_routing_setup( CONF_KNX_MCAST_PORT: 3675, CONF_KNX_LOCAL_IP: None, CONF_KNX_INDIVIDUAL_ADDRESS: "1.1.110", + CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None, + CONF_KNX_SECURE_USER_ID: None, + CONF_KNX_SECURE_USER_PASSWORD: None, + CONF_KNX_TUNNEL_ENDPOINT_IA: None, } knx_setup.assert_called_once() @@ -224,6 +258,10 @@ async def test_routing_setup_advanced( CONF_KNX_MCAST_PORT: 3675, CONF_KNX_LOCAL_IP: "192.168.1.112", CONF_KNX_INDIVIDUAL_ADDRESS: "1.1.110", + CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None, + CONF_KNX_SECURE_USER_ID: None, + CONF_KNX_SECURE_USER_PASSWORD: None, + CONF_KNX_TUNNEL_ENDPOINT_IA: None, } knx_setup.assert_called_once() @@ -310,6 +348,10 @@ async def test_routing_secure_manual_setup( CONF_KNX_ROUTING_BACKBONE_KEY: "bbaacc44bbaacc44bbaacc44bbaacc44", CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: 2000, CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.123", + CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None, + CONF_KNX_SECURE_USER_ID: None, + CONF_KNX_SECURE_USER_PASSWORD: None, + CONF_KNX_TUNNEL_ENDPOINT_IA: None, } knx_setup.assert_called_once() @@ -358,29 +400,11 @@ async def test_routing_secure_keyfile( assert result4["step_id"] == "secure_knxkeys" assert not result4["errors"] - # test file without backbone key - with patch( - "homeassistant.components.knx.config_flow.load_keyring" - ) as mock_load_keyring: - mock_keyring = Mock() - mock_keyring.backbone.key = None - mock_load_keyring.return_value = mock_keyring - secure_knxkeys = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_KNX_KNXKEY_FILENAME: "testcase.knxkeys", - CONF_KNX_KNXKEY_PASSWORD: "password", - }, - ) - assert secure_knxkeys["type"] == FlowResultType.FORM - assert secure_knxkeys["errors"] == {"base": "keyfile_no_backbone_key"} - - # test valid file - with patch("homeassistant.components.knx.config_flow.load_keyring"): + with patch_file_upload(): routing_secure_knxkeys = await hass.config_entries.flow.async_configure( result4["flow_id"], { - CONF_KNX_KNXKEY_FILENAME: "testcase.knxkeys", + CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID, CONF_KNX_KNXKEY_PASSWORD: "password", }, ) @@ -390,20 +414,21 @@ async def test_routing_secure_keyfile( assert routing_secure_knxkeys["data"] == { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING_SECURE, - CONF_KNX_KNXKEY_FILENAME: "knx/testcase.knxkeys", + CONF_KNX_KNXKEY_FILENAME: "knx/keyring.knxkeys", CONF_KNX_KNXKEY_PASSWORD: "password", CONF_KNX_ROUTING_BACKBONE_KEY: None, CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: None, CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None, CONF_KNX_SECURE_USER_ID: None, CONF_KNX_SECURE_USER_PASSWORD: None, + CONF_KNX_TUNNEL_ENDPOINT_IA: None, CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.123", } knx_setup.assert_called_once() @pytest.mark.parametrize( - ("user_input", "config_entry_data"), + ("user_input", "title", "config_entry_data"), [ ( { @@ -412,6 +437,7 @@ async def test_routing_secure_keyfile( CONF_PORT: 3675, CONF_KNX_ROUTE_BACK: False, }, + "Tunneling UDP @ 192.168.0.1", { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, @@ -420,6 +446,10 @@ async def test_routing_secure_keyfile( CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240", CONF_KNX_ROUTE_BACK: False, CONF_KNX_LOCAL_IP: None, + CONF_KNX_TUNNEL_ENDPOINT_IA: None, + CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None, + CONF_KNX_SECURE_USER_ID: None, + CONF_KNX_SECURE_USER_PASSWORD: None, }, ), ( @@ -429,6 +459,7 @@ async def test_routing_secure_keyfile( CONF_PORT: 3675, CONF_KNX_ROUTE_BACK: False, }, + "Tunneling TCP @ 192.168.0.1", { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP, @@ -437,6 +468,10 @@ async def test_routing_secure_keyfile( CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240", CONF_KNX_ROUTE_BACK: False, CONF_KNX_LOCAL_IP: None, + CONF_KNX_TUNNEL_ENDPOINT_IA: None, + CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None, + CONF_KNX_SECURE_USER_ID: None, + CONF_KNX_SECURE_USER_PASSWORD: None, }, ), ( @@ -446,6 +481,7 @@ async def test_routing_secure_keyfile( CONF_PORT: 3675, CONF_KNX_ROUTE_BACK: True, }, + "Tunneling UDP @ 192.168.0.1", { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, @@ -454,6 +490,10 @@ async def test_routing_secure_keyfile( CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240", CONF_KNX_ROUTE_BACK: True, CONF_KNX_LOCAL_IP: None, + CONF_KNX_TUNNEL_ENDPOINT_IA: None, + CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None, + CONF_KNX_SECURE_USER_ID: None, + CONF_KNX_SECURE_USER_PASSWORD: None, }, ), ], @@ -467,6 +507,7 @@ async def test_tunneling_setup_manual( hass: HomeAssistant, knx_setup, user_input, + title, config_entry_data, ) -> None: """Test tunneling if no gateway was found found (or `manual` option was chosen).""" @@ -502,7 +543,7 @@ async def test_tunneling_setup_manual( ) await hass.async_block_till_done() assert result3["type"] == FlowResultType.CREATE_ENTRY - assert result3["title"] == "Tunneling @ 192.168.0.1" + assert result3["title"] == title assert result3["data"] == config_entry_data knx_setup.assert_called_once() @@ -631,12 +672,16 @@ async def test_tunneling_setup_manual_request_description_error( ) await hass.async_block_till_done() assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "Tunneling @ 192.168.0.1" + assert result["title"] == "Tunneling TCP @ 192.168.0.1" assert result["data"] == { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP, CONF_HOST: "192.168.0.1", CONF_PORT: 3671, + CONF_KNX_TUNNEL_ENDPOINT_IA: None, + CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None, + CONF_KNX_SECURE_USER_ID: None, + CONF_KNX_SECURE_USER_PASSWORD: None, } knx_setup.assert_called_once() @@ -718,7 +763,7 @@ async def test_tunneling_setup_for_local_ip( ) await hass.async_block_till_done() assert result3["type"] == FlowResultType.CREATE_ENTRY - assert result3["title"] == "Tunneling @ 192.168.0.2" + assert result3["title"] == "Tunneling UDP @ 192.168.0.2" assert result3["data"] == { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, @@ -727,6 +772,10 @@ async def test_tunneling_setup_for_local_ip( CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240", CONF_KNX_ROUTE_BACK: False, CONF_KNX_LOCAL_IP: "192.168.1.112", + CONF_KNX_TUNNEL_ENDPOINT_IA: None, + CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None, + CONF_KNX_SECURE_USER_ID: None, + CONF_KNX_SECURE_USER_PASSWORD: None, } knx_setup.assert_called_once() @@ -771,6 +820,10 @@ async def test_tunneling_setup_for_multiple_found_gateways( CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240", CONF_KNX_ROUTE_BACK: False, CONF_KNX_LOCAL_IP: None, + CONF_KNX_TUNNEL_ENDPOINT_IA: None, + CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None, + CONF_KNX_SECURE_USER_ID: None, + CONF_KNX_SECURE_USER_PASSWORD: None, } knx_setup.assert_called_once() @@ -848,11 +901,12 @@ async def test_form_with_automatic_connection_handling( assert result2["data"] == { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, + CONF_KNX_TUNNEL_ENDPOINT_IA: None, } knx_setup.assert_called_once() -async def _get_menu_step(hass: HomeAssistant) -> FlowResult: +async def _get_menu_step_secure_tunnel(hass: HomeAssistant) -> FlowResult: """Return flow in secure_tunnelling menu step.""" gateway = _gateway_descriptor( "192.168.0.1", @@ -950,7 +1004,7 @@ async def test_get_secure_menu_step_manual_tunnelling( async def test_configure_secure_tunnel_manual(hass: HomeAssistant, knx_setup) -> None: """Test configure tunnelling secure keys manually.""" - menu_step = await _get_menu_step(hass) + menu_step = await _get_menu_step_secure_tunnel(hass) result = await hass.config_entries.flow.async_configure( menu_step["flow_id"], @@ -981,13 +1035,14 @@ async def test_configure_secure_tunnel_manual(hass: HomeAssistant, knx_setup) -> CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240", CONF_KNX_ROUTE_BACK: False, CONF_KNX_LOCAL_IP: None, + CONF_KNX_TUNNEL_ENDPOINT_IA: None, } knx_setup.assert_called_once() async def test_configure_secure_knxkeys(hass: HomeAssistant, knx_setup) -> None: """Test configure secure knxkeys.""" - menu_step = await _get_menu_step(hass) + menu_step = await _get_menu_step_secure_tunnel(hass) result = await hass.config_entries.flow.async_configure( menu_step["flow_id"], @@ -997,17 +1052,11 @@ async def test_configure_secure_knxkeys(hass: HomeAssistant, knx_setup) -> None: assert result["step_id"] == "secure_knxkeys" assert not result["errors"] - with patch( - "xknx.secure.keyring.sync_load_keyring", - return_value=sync_load_keyring( - str(get_fixture_path("fixture.knxkeys", DOMAIN).absolute()), - FIXTURE_KNXKEYS_PASSWORD, - ), - ): + with patch_file_upload(): secure_knxkeys = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_KNX_KNXKEY_FILENAME: "testcase.knxkeys", + CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID, CONF_KNX_KNXKEY_PASSWORD: "test", }, ) @@ -1016,7 +1065,7 @@ async def test_configure_secure_knxkeys(hass: HomeAssistant, knx_setup) -> None: assert not result["errors"] secure_knxkeys = await hass.config_entries.flow.async_configure( secure_knxkeys["flow_id"], - {CONF_KNX_SECURE_USER_ID: CONF_KNX_AUTOMATIC}, + {CONF_KNX_TUNNEL_ENDPOINT_IA: CONF_KNX_AUTOMATIC}, ) await hass.async_block_till_done() @@ -1024,13 +1073,14 @@ async def test_configure_secure_knxkeys(hass: HomeAssistant, knx_setup) -> None: assert secure_knxkeys["data"] == { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP_SECURE, - CONF_KNX_KNXKEY_FILENAME: "knx/testcase.knxkeys", + CONF_KNX_KNXKEY_FILENAME: "knx/keyring.knxkeys", CONF_KNX_KNXKEY_PASSWORD: "test", CONF_KNX_ROUTING_BACKBONE_KEY: None, CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: None, CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None, CONF_KNX_SECURE_USER_ID: None, CONF_KNX_SECURE_USER_PASSWORD: None, + CONF_KNX_TUNNEL_ENDPOINT_IA: None, CONF_HOST: "192.168.0.1", CONF_PORT: 3675, CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240", @@ -1040,37 +1090,9 @@ async def test_configure_secure_knxkeys(hass: HomeAssistant, knx_setup) -> None: knx_setup.assert_called_once() -async def test_configure_secure_knxkeys_file_not_found(hass: HomeAssistant) -> None: - """Test configure secure knxkeys but file was not found.""" - menu_step = await _get_menu_step(hass) - - result = await hass.config_entries.flow.async_configure( - menu_step["flow_id"], - {"next_step_id": "secure_knxkeys"}, - ) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "secure_knxkeys" - assert not result["errors"] - - with patch( - "homeassistant.components.knx.config_flow.load_keyring", - side_effect=FileNotFoundError(), - ): - secure_knxkeys = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_KNX_KNXKEY_FILENAME: "testcase.knxkeys", - CONF_KNX_KNXKEY_PASSWORD: "password", - }, - ) - assert secure_knxkeys["type"] == FlowResultType.FORM - assert secure_knxkeys["errors"] - assert secure_knxkeys["errors"][CONF_KNX_KNXKEY_FILENAME] == "keyfile_not_found" - - async def test_configure_secure_knxkeys_invalid_signature(hass: HomeAssistant) -> None: """Test configure secure knxkeys but file was not found.""" - menu_step = await _get_menu_step(hass) + menu_step = await _get_menu_step_secure_tunnel(hass) result = await hass.config_entries.flow.async_configure( menu_step["flow_id"], @@ -1080,14 +1102,13 @@ async def test_configure_secure_knxkeys_invalid_signature(hass: HomeAssistant) - assert result["step_id"] == "secure_knxkeys" assert not result["errors"] - with patch( - "homeassistant.components.knx.config_flow.load_keyring", + with patch_file_upload( side_effect=InvalidSecureConfiguration(), ): secure_knxkeys = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_KNX_KNXKEY_FILENAME: "testcase.knxkeys", + CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID, CONF_KNX_KNXKEY_PASSWORD: "password", }, ) @@ -1101,7 +1122,7 @@ async def test_configure_secure_knxkeys_invalid_signature(hass: HomeAssistant) - async def test_configure_secure_knxkeys_no_tunnel_for_host(hass: HomeAssistant) -> None: """Test configure secure knxkeys but file was not found.""" - menu_step = await _get_menu_step(hass) + menu_step = await _get_menu_step_secure_tunnel(hass) result = await hass.config_entries.flow.async_configure( menu_step["flow_id"], @@ -1111,16 +1132,12 @@ async def test_configure_secure_knxkeys_no_tunnel_for_host(hass: HomeAssistant) assert result["step_id"] == "secure_knxkeys" assert not result["errors"] - with patch( - "homeassistant.components.knx.config_flow.load_keyring" - ) as mock_load_keyring: - mock_keyring = Mock() + with patch_file_upload(return_value=Mock()) as mock_keyring: mock_keyring.get_tunnel_interfaces_by_host.return_value = [] - mock_load_keyring.return_value = mock_keyring secure_knxkeys = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_KNX_KNXKEY_FILENAME: "testcase.knxkeys", + CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID, CONF_KNX_KNXKEY_PASSWORD: "password", }, ) @@ -1180,6 +1197,10 @@ async def test_options_flow_connection_type( CONF_KNX_RATE_LIMIT: 0, CONF_KNX_STATE_UPDATER: CONF_KNX_DEFAULT_STATE_UPDATER, CONF_KNX_ROUTE_BACK: False, + CONF_KNX_TUNNEL_ENDPOINT_IA: None, + CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None, + CONF_KNX_SECURE_USER_ID: None, + CONF_KNX_SECURE_USER_PASSWORD: None, } @@ -1251,17 +1272,11 @@ async def test_options_flow_secure_manual_to_keyfile( assert result4["step_id"] == "secure_knxkeys" assert not result4["errors"] - with patch( - "xknx.secure.keyring.sync_load_keyring", - return_value=sync_load_keyring( - str(get_fixture_path("fixture.knxkeys", DOMAIN).absolute()), - FIXTURE_KNXKEYS_PASSWORD, - ), - ): + with patch_file_upload(): secure_knxkeys = await hass.config_entries.options.async_configure( result4["flow_id"], { - CONF_KNX_KNXKEY_FILENAME: "testcase.knxkeys", + CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID, CONF_KNX_KNXKEY_PASSWORD: "test", }, ) @@ -1270,7 +1285,7 @@ async def test_options_flow_secure_manual_to_keyfile( assert not result["errors"] secure_knxkeys = await hass.config_entries.options.async_configure( secure_knxkeys["flow_id"], - {CONF_KNX_SECURE_USER_ID: "2"}, + {CONF_KNX_TUNNEL_ENDPOINT_IA: "1.0.1"}, ) await hass.async_block_till_done() @@ -1278,11 +1293,12 @@ async def test_options_flow_secure_manual_to_keyfile( assert mock_config_entry.data == { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP_SECURE, - CONF_KNX_KNXKEY_FILENAME: "knx/testcase.knxkeys", + CONF_KNX_KNXKEY_FILENAME: "knx/keyring.knxkeys", CONF_KNX_KNXKEY_PASSWORD: "test", CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None, - CONF_KNX_SECURE_USER_ID: 2, + CONF_KNX_SECURE_USER_ID: None, CONF_KNX_SECURE_USER_PASSWORD: None, + CONF_KNX_TUNNEL_ENDPOINT_IA: "1.0.1", CONF_KNX_ROUTING_BACKBONE_KEY: None, CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: None, CONF_HOST: "192.168.0.1", @@ -1326,3 +1342,118 @@ async def test_options_communication_settings( CONF_KNX_RATE_LIMIT: 40, } knx_setup.assert_called_once() + + +async def test_options_update_keyfile(hass: HomeAssistant, knx_setup) -> None: + """Test options flow updating keyfile when tunnel endpoint is already configured.""" + start_data = { + **DEFAULT_ENTRY_DATA, + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP_SECURE, + CONF_KNX_SECURE_USER_ID: 2, + CONF_KNX_SECURE_USER_PASSWORD: "password", + CONF_KNX_SECURE_DEVICE_AUTHENTICATION: "device_auth", + CONF_KNX_KNXKEY_PASSWORD: "old_password", + CONF_HOST: "192.168.0.1", + CONF_PORT: 3675, + CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240", + CONF_KNX_ROUTE_BACK: False, + CONF_KNX_LOCAL_IP: None, + CONF_KNX_TUNNEL_ENDPOINT_IA: "1.0.1", + } + mock_config_entry = MockConfigEntry( + title="KNX", + domain="knx", + data=start_data, + ) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + menu_step = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + + result = await hass.config_entries.options.async_configure( + menu_step["flow_id"], + {"next_step_id": "secure_knxkeys"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "secure_knxkeys" + + with patch_file_upload(): + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + { + CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID, + CONF_KNX_KNXKEY_PASSWORD: "password", + }, + ) + await hass.async_block_till_done() + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert not result2.get("data") + assert mock_config_entry.data == { + **start_data, + CONF_KNX_KNXKEY_FILENAME: "knx/keyring.knxkeys", + CONF_KNX_KNXKEY_PASSWORD: "password", + CONF_KNX_ROUTING_BACKBONE_KEY: None, + CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: None, + } + knx_setup.assert_called_once() + + +async def test_options_keyfile_upload(hass: HomeAssistant, knx_setup) -> None: + """Test options flow uploading a keyfile for the first time.""" + start_data = { + **DEFAULT_ENTRY_DATA, + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP, + CONF_HOST: "192.168.0.1", + CONF_PORT: 3675, + CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240", + CONF_KNX_ROUTE_BACK: False, + CONF_KNX_LOCAL_IP: None, + } + mock_config_entry = MockConfigEntry( + title="KNX", + domain="knx", + data=start_data, + ) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + menu_step = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + + result = await hass.config_entries.options.async_configure( + menu_step["flow_id"], + {"next_step_id": "secure_knxkeys"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "secure_knxkeys" + + with patch_file_upload(): + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + { + CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID, + CONF_KNX_KNXKEY_PASSWORD: "password", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "knxkeys_tunnel_select" + + result3 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_KNX_TUNNEL_ENDPOINT_IA: "1.0.1", + }, + ) + await hass.async_block_till_done() + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert not result3.get("data") + assert mock_config_entry.data == { + **start_data, + CONF_KNX_KNXKEY_FILENAME: "knx/keyring.knxkeys", + CONF_KNX_KNXKEY_PASSWORD: "password", + CONF_KNX_TUNNEL_ENDPOINT_IA: "1.0.1", + CONF_KNX_SECURE_USER_ID: None, + CONF_KNX_SECURE_USER_PASSWORD: None, + CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None, + CONF_KNX_ROUTING_BACKBONE_KEY: None, + CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: None, + } + knx_setup.assert_called_once() diff --git a/tests/components/knx/test_init.py b/tests/components/knx/test_init.py index ff763f94b6..785ff9d831 100644 --- a/tests/components/knx/test_init.py +++ b/tests/components/knx/test_init.py @@ -1,4 +1,6 @@ """Test KNX init.""" +from unittest.mock import patch + import pytest from xknx.io import ( DEFAULT_MCAST_GRP, @@ -8,6 +10,7 @@ from xknx.io import ( SecureConfig, ) +from homeassistant import config_entries from homeassistant.components.knx.config_flow import DEFAULT_ROUTING_IA from homeassistant.components.knx.const import ( CONF_KNX_AUTOMATIC, @@ -110,12 +113,17 @@ from tests.common import MockConfigEntry CONF_KNX_MCAST_PORT: DEFAULT_MCAST_PORT, CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, CONF_KNX_INDIVIDUAL_ADDRESS: DEFAULT_ROUTING_IA, + CONF_KNX_KNXKEY_FILENAME: "knx/keyring.knxkeys", + CONF_KNX_KNXKEY_PASSWORD: "password", }, ConnectionConfig( connection_type=ConnectionType.TUNNELING_TCP, gateway_ip="192.168.0.2", gateway_port=3675, auto_reconnect=True, + secure_config=SecureConfig( + knxkeys_file_path="keyring.knxkeys", knxkeys_password="password" + ), threaded=True, ), ), @@ -251,3 +259,30 @@ async def test_init_connection_handling( .connection_config() .secure_config.knxkeys_file_path ) + + +async def test_async_remove_entry( + hass: HomeAssistant, + knx: KNXTestKit, +) -> None: + """Test async_setup_entry (for coverage).""" + config_entry = MockConfigEntry( + title="KNX", + domain=KNX_DOMAIN, + data={ + CONF_KNX_KNXKEY_FILENAME: "knx/testcase.knxkeys", + }, + ) + knx.mock_config_entry = config_entry + await knx.setup_integration({}) + + with patch("pathlib.Path.unlink") as unlink_mock, patch( + "pathlib.Path.rmdir" + ) as rmdir_mock: + assert await hass.config_entries.async_remove(config_entry.entry_id) + unlink_mock.assert_called_once() + rmdir_mock.assert_called_once() + await hass.async_block_till_done() + + assert hass.config_entries.async_entries() == [] + assert config_entry.state is config_entries.ConfigEntryState.NOT_LOADED From 1759f58fc110b6c3ad81d859d6e5716182ad29d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Feb 2023 07:58:59 -0600 Subject: [PATCH 0233/2810] Ensure new MariaDB/MySQL tables are created using the InnoDB engine (#88468) Ensure new tables are created using InnoDB InnoDB is the only supported engine to use with MariaDB or MySQL as we currently have large keys in the states table that will not work with MyIASM. Other storage engines including Aria will likely work fine, but they are not officially supported. --- .../components/recorder/db_schema.py | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 3bdbb0d3c2..9a059c570c 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -111,6 +111,15 @@ ENTITY_ID_LAST_UPDATED_INDEX_TS = "ix_states_entity_id_last_updated_ts" EVENTS_CONTEXT_ID_INDEX = "ix_events_context_id" STATES_CONTEXT_ID_INDEX = "ix_states_context_id" +_DEFAULT_TABLE_ARGS = { + "mysql_default_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + "mysql_engine": "InnoDB", + "mariadb_default_charset": "utf8mb4", + "mariadb_collate": "utf8mb4_unicode_ci", + "mariadb_engine": "InnoDB", +} + class FAST_PYSQLITE_DATETIME(sqlite.DATETIME): """Use ciso8601 to parse datetimes instead of sqlalchemy built-in regex.""" @@ -165,7 +174,7 @@ class Events(Base): # Used for fetching events at a specific time # see logbook Index("ix_events_event_type_time_fired_ts", "event_type", "time_fired_ts"), - {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + _DEFAULT_TABLE_ARGS, ) __tablename__ = TABLE_EVENTS event_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) @@ -256,9 +265,7 @@ class Events(Base): class EventData(Base): """Event data history.""" - __table_args__ = ( - {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, - ) + __table_args__ = (_DEFAULT_TABLE_ARGS,) __tablename__ = TABLE_EVENT_DATA data_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) hash: Mapped[int | None] = mapped_column(BigInteger, index=True) @@ -309,7 +316,7 @@ class States(Base): # Used for fetching the state of entities at a specific time # (get_states in history.py) Index(ENTITY_ID_LAST_UPDATED_INDEX_TS, "entity_id", "last_updated_ts"), - {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + _DEFAULT_TABLE_ARGS, ) __tablename__ = TABLE_STATES state_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) @@ -440,9 +447,7 @@ class States(Base): class StateAttributes(Base): """State attribute change history.""" - __table_args__ = ( - {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, - ) + __table_args__ = (_DEFAULT_TABLE_ARGS,) __tablename__ = TABLE_STATE_ATTRIBUTES attributes_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) hash: Mapped[int | None] = mapped_column(BigInteger, index=True) @@ -611,9 +616,7 @@ class StatisticsShortTerm(Base, StatisticsBase): class StatisticsMeta(Base): """Statistics meta data.""" - __table_args__ = ( - {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, - ) + __table_args__ = (_DEFAULT_TABLE_ARGS,) __tablename__ = TABLE_STATISTICS_META id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) statistic_id: Mapped[str | None] = mapped_column( From 4f6a25b470a6779a2a3cbdfd0634e2183278809d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 20 Feb 2023 16:57:12 +0100 Subject: [PATCH 0234/2810] Allow parameterizing YAML config in tests (#87981) * Add fixture to parameterize yaml config * Apply to more tests * Re-add @fixture label * Add fixtures to patch yaml content and targets * Typo * Improve docstr Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update references to mock_yaml_configuration * Apply new fixtures * Apply to check_config tests * Follow up comments * Rename fixtures, update docstr * Split paths * Patch load_yaml_config_file instead * sort * Fix tests * improve docst * Rename fixtures * sorting Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Improve docstr Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Improve docstr Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Improve docstr Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Improve docstr Co-authored-by: Erik Montnemery * Improve docstr Co-authored-by: Erik Montnemery * Improve docstr Co-authored-by: Erik Montnemery --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> Co-authored-by: Erik Montnemery --- pylint/plugins/hass_enforce_type_hints.py | 5 + .../triggers/test_homeassistant.py | 48 +-- tests/components/knx/test_diagnostic.py | 124 +++---- tests/conftest.py | 62 ++++ tests/scripts/test_check_config.py | 190 +++++------ tests/test_bootstrap.py | 113 ++++--- tests/test_config.py | 33 +- tests/util/yaml/test_init.py | 306 ++++++++++-------- 8 files changed, 498 insertions(+), 383 deletions(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index b117a42764..5ee0489e8c 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -109,6 +109,9 @@ _TEST_FIXTURES: dict[str, list[str] | str] = { "hass_admin_user": "MockUser", "hass_client": "ClientSessionGenerator", "hass_client_no_auth": "ClientSessionGenerator", + "hass_config": "ConfigType | None", + "hass_config_yaml": "str | None", + "hass_config_yaml_files": "dict[str, str] | None", "hass_owner_user": "MockUser", "hass_read_only_access_token": "str", "hass_read_only_user": "MockUser", @@ -126,6 +129,8 @@ _TEST_FIXTURES: dict[str, list[str] | str] = { "mock_bluetooth_adapters": "None", "mock_device_tracker_conf": "list[Device]", "mock_get_source_ip": "None", + "mock_hass_config": "None", + "mock_hass_config_yaml": "None", "mock_zeroconf": "None", "mqtt_client_mock": "MqttMockPahoClient", "mqtt_mock": "MqttMockHAClient", diff --git a/tests/components/homeassistant/triggers/test_homeassistant.py b/tests/components/homeassistant/triggers/test_homeassistant.py index 231b81e386..1bc28a92ba 100644 --- a/tests/components/homeassistant/triggers/test_homeassistant.py +++ b/tests/components/homeassistant/triggers/test_homeassistant.py @@ -1,29 +1,39 @@ """The tests for the Event automation.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import patch + +import pytest import homeassistant.components.automation as automation -from homeassistant.core import CoreState +from homeassistant.core import CoreState, HomeAssistant +from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component from tests.common import async_mock_service -async def test_if_fires_on_hass_start(hass): +@pytest.mark.parametrize( + "hass_config", + [ + { + automation.DOMAIN: { + "alias": "hello", + "trigger": {"platform": "homeassistant", "event": "start"}, + "action": { + "service": "test.automation", + "data_template": {"id": "{{ trigger.id}}"}, + }, + } + } + ], +) +async def test_if_fires_on_hass_start( + hass: HomeAssistant, mock_hass_config: None, hass_config: ConfigType | None +) -> None: """Test the firing when Home Assistant starts.""" calls = async_mock_service(hass, "test", "automation") hass.state = CoreState.not_running - config = { - automation.DOMAIN: { - "alias": "hello", - "trigger": {"platform": "homeassistant", "event": "start"}, - "action": { - "service": "test.automation", - "data_template": {"id": "{{ trigger.id}}"}, - }, - } - } - assert await async_setup_component(hass, automation.DOMAIN, config) + assert await async_setup_component(hass, automation.DOMAIN, hass_config) assert automation.is_on(hass, "automation.hello") assert len(calls) == 0 @@ -32,13 +42,9 @@ async def test_if_fires_on_hass_start(hass): assert automation.is_on(hass, "automation.hello") assert len(calls) == 1 - with patch( - "homeassistant.config.async_hass_config_yaml", - AsyncMock(return_value=config), - ): - await hass.services.async_call( - automation.DOMAIN, automation.SERVICE_RELOAD, blocking=True - ) + await hass.services.async_call( + automation.DOMAIN, automation.SERVICE_RELOAD, blocking=True + ) assert automation.is_on(hass, "automation.hello") assert len(calls) == 1 diff --git a/tests/components/knx/test_diagnostic.py b/tests/components/knx/test_diagnostic.py index 0c690c4f24..99ce439989 100644 --- a/tests/components/knx/test_diagnostic.py +++ b/tests/components/knx/test_diagnostic.py @@ -1,6 +1,6 @@ """Tests for the diagnostics data provided by the KNX integration.""" -from unittest.mock import patch +import pytest from xknx.io import DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT from homeassistant.components.knx.const import ( @@ -29,37 +29,40 @@ from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator +@pytest.mark.parametrize("hass_config", [{}]) async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_config_entry: MockConfigEntry, knx: KNXTestKit, + mock_hass_config: None, ) -> None: """Test diagnostics.""" await knx.setup_integration({}) - with patch("homeassistant.config.async_hass_config_yaml", return_value={}): - # Overwrite the version for this test since we don't want to change this with every library bump - knx.xknx.version = "1.0.0" - assert await get_diagnostics_for_config_entry( - hass, hass_client, mock_config_entry - ) == { - "config_entry_data": { - "connection_type": "automatic", - "individual_address": "0.0.240", - "multicast_group": "224.0.23.12", - "multicast_port": 3671, - "rate_limit": 0, - "state_updater": True, - }, - "configuration_error": None, - "configuration_yaml": None, - "xknx": {"current_address": "0.0.0", "version": "1.0.0"}, - } + # Overwrite the version for this test since we don't want to change this with every library bump + knx.xknx.version = "1.0.0" + assert await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) == { + "config_entry_data": { + "connection_type": "automatic", + "individual_address": "0.0.240", + "multicast_group": "224.0.23.12", + "multicast_port": 3671, + "rate_limit": 0, + "state_updater": True, + }, + "configuration_error": None, + "configuration_yaml": None, + "xknx": {"current_address": "0.0.0", "version": "1.0.0"}, + } +@pytest.mark.parametrize("hass_config", [{"knx": {"wrong_key": {}}}]) async def test_diagnostic_config_error( hass: HomeAssistant, + mock_hass_config: None, hass_client: ClientSessionGenerator, mock_config_entry: MockConfigEntry, knx: KNXTestKit, @@ -67,32 +70,30 @@ async def test_diagnostic_config_error( """Test diagnostics.""" await knx.setup_integration({}) - with patch( - "homeassistant.config.async_hass_config_yaml", - return_value={"knx": {"wrong_key": {}}}, - ): - # Overwrite the version for this test since we don't want to change this with every library bump - knx.xknx.version = "1.0.0" - assert await get_diagnostics_for_config_entry( - hass, hass_client, mock_config_entry - ) == { - "config_entry_data": { - "connection_type": "automatic", - "individual_address": "0.0.240", - "multicast_group": "224.0.23.12", - "multicast_port": 3671, - "rate_limit": 0, - "state_updater": True, - }, - "configuration_error": "extra keys not allowed @ data['knx']['wrong_key']", - "configuration_yaml": {"wrong_key": {}}, - "xknx": {"current_address": "0.0.0", "version": "1.0.0"}, - } + # Overwrite the version for this test since we don't want to change this with every library bump + knx.xknx.version = "1.0.0" + assert await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) == { + "config_entry_data": { + "connection_type": "automatic", + "individual_address": "0.0.240", + "multicast_group": "224.0.23.12", + "multicast_port": 3671, + "rate_limit": 0, + "state_updater": True, + }, + "configuration_error": "extra keys not allowed @ data['knx']['wrong_key']", + "configuration_yaml": {"wrong_key": {}}, + "xknx": {"current_address": "0.0.0", "version": "1.0.0"}, + } +@pytest.mark.parametrize("hass_config", [{}]) async def test_diagnostic_redact( hass: HomeAssistant, hass_client: ClientSessionGenerator, + mock_hass_config: None, ) -> None: """Test diagnostics redacting data.""" mock_config_entry: MockConfigEntry = MockConfigEntry( @@ -114,25 +115,24 @@ async def test_diagnostic_redact( knx: KNXTestKit = KNXTestKit(hass, mock_config_entry) await knx.setup_integration({}) - with patch("homeassistant.config.async_hass_config_yaml", return_value={}): - # Overwrite the version for this test since we don't want to change this with every library bump - knx.xknx.version = "1.0.0" - assert await get_diagnostics_for_config_entry( - hass, hass_client, mock_config_entry - ) == { - "config_entry_data": { - "connection_type": "automatic", - "individual_address": "0.0.240", - "multicast_group": "224.0.23.12", - "multicast_port": 3671, - "rate_limit": 0, - "state_updater": True, - "knxkeys_password": "**REDACTED**", - "user_password": "**REDACTED**", - "device_authentication": "**REDACTED**", - "backbone_key": "**REDACTED**", - }, - "configuration_error": None, - "configuration_yaml": None, - "xknx": {"current_address": "0.0.0", "version": "1.0.0"}, - } + # Overwrite the version for this test since we don't want to change this with every library bump + knx.xknx.version = "1.0.0" + assert await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) == { + "config_entry_data": { + "connection_type": "automatic", + "individual_address": "0.0.240", + "multicast_group": "224.0.23.12", + "multicast_port": 3671, + "rate_limit": 0, + "state_updater": True, + "knxkeys_password": "**REDACTED**", + "user_password": "**REDACTED**", + "device_authentication": "**REDACTED**", + "backbone_key": "**REDACTED**", + }, + "configuration_error": None, + "configuration_yaml": None, + "xknx": {"current_address": "0.0.0", "version": "1.0.0"}, + } diff --git a/tests/conftest.py b/tests/conftest.py index 5a46971730..2717edf702 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -44,6 +44,7 @@ from homeassistant.components.websocket_api.auth import ( TYPE_AUTH_REQUIRED, ) from homeassistant.components.websocket_api.http import URL +from homeassistant.config import YAML_CONFIG_FILE from homeassistant.config_entries import ConfigEntry from homeassistant.const import HASSIO_USER_NAME from homeassistant.core import CoreState, HomeAssistant @@ -90,6 +91,7 @@ from .common import ( # noqa: E402, isort:skip get_test_home_assistant, init_recorder_component, mock_storage, + patch_yaml_files, ) from .test_util.aiohttp import ( # noqa: E402, isort:skip AiohttpClientMocker, @@ -917,6 +919,66 @@ async def _mqtt_mock_entry( yield _setup_mqtt_entry +@pytest.fixture +def hass_config() -> ConfigType | None: + """Fixture to parametrize the content of main configuration using mock_hass_config. + + To set a configuration, tests can be marked with: + @pytest.mark.parametrize("hass_config", [{integration: {...}}]) + Add the `mock_hass_config: None` fixture to the test. + """ + return None + + +@pytest.fixture +def mock_hass_config( + hass: HomeAssistant, hass_config: ConfigType | None +) -> Generator[None, None, None]: + """Fixture to mock the content of main configuration. + + Patches homeassistant.config.load_yaml_config_file with `hass_config` parameterized as content. + """ + with patch("homeassistant.config.load_yaml_config_file", return_value=hass_config): + yield + + +@pytest.fixture +def hass_config_yaml() -> str | None: + """Fixture to parametrize the content of configuration.yaml file. + + To set yaml content, tests can be marked with: + @pytest.mark.parametrize("hass_config_yaml", ["..."]) + Add the `mock_hass_config_yaml: None` fixture to the test. + """ + return None + + +@pytest.fixture +def hass_config_yaml_files(hass_config_yaml: str | None) -> dict[str, str] | None: + """Fixture to parametrize multiple yaml configuration files. + + To set the YAML files to patch, tests can be marked with: + @pytest.mark.parametrize( + "hass_config_yaml_files", [{"configuration.yaml": "..."}] + ) + Add the `mock_hass_config_yaml: None` fixture to the test. + """ + return None if hass_config_yaml is None else {YAML_CONFIG_FILE: hass_config_yaml} + + +@pytest.fixture +def mock_hass_config_yaml( + hass: HomeAssistant, hass_config_yaml_files: dict[str, str] | None +) -> Generator[None, None, None]: + """Fixture to mock the content of the yaml configuration files. + + Patches yaml configuration files using the `hass_config_yaml` + and `hass_config_yaml_files` fixtures. + """ + with patch_yaml_files(hass_config_yaml_files): + yield + + @pytest.fixture async def mqtt_mock_entry_no_yaml_config( hass: HomeAssistant, diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 1e6f1c184b..44a4d55d54 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -6,7 +6,7 @@ import pytest from homeassistant.config import YAML_CONFIG_FILE import homeassistant.scripts.check_config as check_config -from tests.common import get_test_config_dir, patch_yaml_files +from tests.common import get_test_config_dir BASE_CONFIG = ( "homeassistant:\n" @@ -43,114 +43,118 @@ def normalize_yaml_files(check_dict): return [key.replace(root, "...") for key in sorted(check_dict["yaml_files"].keys())] -def test_bad_core_config(mock_is_file, event_loop) -> None: +@pytest.mark.parametrize("hass_config_yaml", [BAD_CORE_CONFIG]) +def test_bad_core_config(mock_is_file, event_loop, mock_hass_config_yaml: None) -> None: """Test a bad core config setup.""" - files = {YAML_CONFIG_FILE: BAD_CORE_CONFIG} - with patch_yaml_files(files): - res = check_config.check(get_test_config_dir()) - assert res["except"].keys() == {"homeassistant"} - assert res["except"]["homeassistant"][1] == {"unit_system": "bad"} + res = check_config.check(get_test_config_dir()) + assert res["except"].keys() == {"homeassistant"} + assert res["except"]["homeassistant"][1] == {"unit_system": "bad"} -def test_config_platform_valid(mock_is_file, event_loop) -> None: +@pytest.mark.parametrize("hass_config_yaml", [BASE_CONFIG + "light:\n platform: demo"]) +def test_config_platform_valid( + mock_is_file, event_loop, mock_hass_config_yaml: None +) -> None: """Test a valid platform setup.""" - files = {YAML_CONFIG_FILE: BASE_CONFIG + "light:\n platform: demo"} - with patch_yaml_files(files): - res = check_config.check(get_test_config_dir()) - assert res["components"].keys() == {"homeassistant", "light"} - assert res["components"]["light"] == [{"platform": "demo"}] - assert res["except"] == {} - assert res["secret_cache"] == {} - assert res["secrets"] == {} - assert len(res["yaml_files"]) == 1 + res = check_config.check(get_test_config_dir()) + assert res["components"].keys() == {"homeassistant", "light"} + assert res["components"]["light"] == [{"platform": "demo"}] + assert res["except"] == {} + assert res["secret_cache"] == {} + assert res["secrets"] == {} + assert len(res["yaml_files"]) == 1 -def test_component_platform_not_found(mock_is_file, event_loop) -> None: +@pytest.mark.parametrize( + ("hass_config_yaml", "platforms", "error"), + [ + ( + BASE_CONFIG + "beer:", + {"homeassistant"}, + "Integration error: beer - Integration 'beer' not found.", + ), + ( + BASE_CONFIG + "light:\n platform: beer", + {"homeassistant", "light"}, + "Platform error light.beer - Integration 'beer' not found.", + ), + ], +) +def test_component_platform_not_found( + mock_is_file, event_loop, mock_hass_config_yaml: None, platforms, error +) -> None: """Test errors if component or platform not found.""" # Make sure they don't exist - files = {YAML_CONFIG_FILE: BASE_CONFIG + "beer:"} - with patch_yaml_files(files): - res = check_config.check(get_test_config_dir()) - assert res["components"].keys() == {"homeassistant"} - assert res["except"] == { - check_config.ERROR_STR: [ - "Integration error: beer - Integration 'beer' not found." - ] + res = check_config.check(get_test_config_dir()) + assert res["components"].keys() == platforms + assert res["except"] == {check_config.ERROR_STR: [error]} + assert res["secret_cache"] == {} + assert res["secrets"] == {} + assert len(res["yaml_files"]) == 1 + + +@pytest.mark.parametrize( + "hass_config_yaml_files", + [ + { + get_test_config_dir(YAML_CONFIG_FILE): BASE_CONFIG + + "http:\n cors_allowed_origins: !secret http_pw", + get_test_config_dir( + "secrets.yaml" + ): "logger: debug\nhttp_pw: http://google.com", } - assert res["secret_cache"] == {} - assert res["secrets"] == {} - assert len(res["yaml_files"]) == 1 - - files = {YAML_CONFIG_FILE: BASE_CONFIG + "light:\n platform: beer"} - with patch_yaml_files(files): - res = check_config.check(get_test_config_dir()) - assert res["components"].keys() == {"homeassistant", "light"} - assert res["components"]["light"] == [] - assert res["except"] == { - check_config.ERROR_STR: [ - "Platform error light.beer - Integration 'beer' not found." - ] - } - assert res["secret_cache"] == {} - assert res["secrets"] == {} - assert len(res["yaml_files"]) == 1 - - -def test_secrets(mock_is_file, event_loop) -> None: + ], +) +def test_secrets(mock_is_file, event_loop, mock_hass_config_yaml: None) -> None: """Test secrets config checking method.""" - secrets_path = get_test_config_dir("secrets.yaml") + res = check_config.check(get_test_config_dir(), True) - files = { - get_test_config_dir(YAML_CONFIG_FILE): BASE_CONFIG - + "http:\n cors_allowed_origins: !secret http_pw", - secrets_path: "logger: debug\nhttp_pw: http://google.com", + assert res["except"] == {} + assert res["components"].keys() == {"homeassistant", "http"} + assert res["components"]["http"] == { + "cors_allowed_origins": ["http://google.com"], + "ip_ban_enabled": True, + "login_attempts_threshold": -1, + "server_port": 8123, + "ssl_profile": "modern", } - - with patch_yaml_files(files): - res = check_config.check(get_test_config_dir(), True) - - assert res["except"] == {} - assert res["components"].keys() == {"homeassistant", "http"} - assert res["components"]["http"] == { - "cors_allowed_origins": ["http://google.com"], - "ip_ban_enabled": True, - "login_attempts_threshold": -1, - "server_port": 8123, - "ssl_profile": "modern", - } - assert res["secret_cache"] == {secrets_path: {"http_pw": "http://google.com"}} - assert res["secrets"] == {"http_pw": "http://google.com"} - assert normalize_yaml_files(res) == [ - ".../configuration.yaml", - ".../secrets.yaml", - ] + assert res["secret_cache"] == { + get_test_config_dir("secrets.yaml"): {"http_pw": "http://google.com"} + } + assert res["secrets"] == {"http_pw": "http://google.com"} + assert normalize_yaml_files(res) == [ + ".../configuration.yaml", + ".../secrets.yaml", + ] -def test_package_invalid(mock_is_file, event_loop) -> None: +@pytest.mark.parametrize( + "hass_config_yaml", [BASE_CONFIG + ' packages:\n p1:\n group: ["a"]'] +) +def test_package_invalid(mock_is_file, event_loop, mock_hass_config_yaml: None) -> None: """Test an invalid package.""" - files = {YAML_CONFIG_FILE: BASE_CONFIG + ' packages:\n p1:\n group: ["a"]'} - with patch_yaml_files(files): - res = check_config.check(get_test_config_dir()) + res = check_config.check(get_test_config_dir()) - assert res["except"].keys() == {"homeassistant.packages.p1.group"} - assert res["except"]["homeassistant.packages.p1.group"][1] == {"group": ["a"]} - assert len(res["except"]) == 1 - assert res["components"].keys() == {"homeassistant"} - assert len(res["components"]) == 1 - assert res["secret_cache"] == {} - assert res["secrets"] == {} - assert len(res["yaml_files"]) == 1 + assert res["except"].keys() == {"homeassistant.packages.p1.group"} + assert res["except"]["homeassistant.packages.p1.group"][1] == {"group": ["a"]} + assert len(res["except"]) == 1 + assert res["components"].keys() == {"homeassistant"} + assert len(res["components"]) == 1 + assert res["secret_cache"] == {} + assert res["secrets"] == {} + assert len(res["yaml_files"]) == 1 -def test_bootstrap_error(event_loop) -> None: +@pytest.mark.parametrize( + "hass_config_yaml", [BASE_CONFIG + "automation: !include no.yaml"] +) +def test_bootstrap_error(event_loop, mock_hass_config_yaml: None) -> None: """Test a valid platform setup.""" - files = {YAML_CONFIG_FILE: BASE_CONFIG + "automation: !include no.yaml"} - with patch_yaml_files(files): - res = check_config.check(get_test_config_dir(YAML_CONFIG_FILE)) - err = res["except"].pop(check_config.ERROR_STR) - assert len(err) == 1 - assert res["except"] == {} - assert res["components"] == {} # No components, load failed - assert res["secret_cache"] == {} - assert res["secrets"] == {} - assert res["yaml_files"] == {} + res = check_config.check(get_test_config_dir(YAML_CONFIG_FILE)) + err = res["except"].pop(check_config.ERROR_STR) + assert len(err) == 1 + assert res["except"] == {} + assert res["components"] == {} # No components, load failed + assert res["secret_cache"] == {} + assert res["secrets"] == {} + assert res["yaml_files"] == {} diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 48d161d5b5..bf10aa732a 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -454,7 +454,9 @@ def mock_ensure_config_exists(): yield ensure_config_exists +@pytest.mark.parametrize("hass_config", [{"browser": {}, "frontend": {}}]) async def test_setup_hass( + mock_hass_config: None, mock_enable_logging, mock_is_virtual_env, mock_mount_local_lib_path, @@ -462,17 +464,14 @@ async def test_setup_hass( mock_process_ha_config_upgrade, caplog, event_loop, -): +) -> None: """Test it works.""" verbose = Mock() log_rotate_days = Mock() log_file = Mock() log_no_color = Mock() - with patch( - "homeassistant.config.async_hass_config_yaml", - return_value={"browser": {}, "frontend": {}}, - ), patch.object(bootstrap, "LOG_SLOW_STARTUP_INTERVAL", 5000): + with patch.object(bootstrap, "LOG_SLOW_STARTUP_INTERVAL", 5000): hass = await bootstrap.async_setup_hass( runner.RuntimeConfig( config_dir=get_test_config_dir(), @@ -505,7 +504,9 @@ async def test_setup_hass( assert hass == async_get_hass() +@pytest.mark.parametrize("hass_config", [{"browser": {}, "frontend": {}}]) async def test_setup_hass_takes_longer_than_log_slow_startup( + mock_hass_config: None, mock_enable_logging, mock_is_virtual_env, mock_mount_local_lib_path, @@ -513,7 +514,7 @@ async def test_setup_hass_takes_longer_than_log_slow_startup( mock_process_ha_config_upgrade, caplog, event_loop, -): +) -> None: """Test it works.""" verbose = Mock() log_rotate_days = Mock() @@ -524,10 +525,7 @@ async def test_setup_hass_takes_longer_than_log_slow_startup( await asyncio.sleep(0.6) return True - with patch( - "homeassistant.config.async_hass_config_yaml", - return_value={"browser": {}, "frontend": {}}, - ), patch.object(bootstrap, "LOG_SLOW_STARTUP_INTERVAL", 0.3), patch.object( + with patch.object(bootstrap, "LOG_SLOW_STARTUP_INTERVAL", 0.3), patch.object( bootstrap, "SLOW_STARTUP_CHECK_INTERVAL", 0.05 ), patch( "homeassistant.components.frontend.async_setup", @@ -555,7 +553,7 @@ async def test_setup_hass_invalid_yaml( mock_ensure_config_exists, mock_process_ha_config_upgrade, event_loop, -): +) -> None: """Test it works.""" with patch( "homeassistant.config.async_hass_config_yaml", side_effect=HomeAssistantError @@ -636,70 +634,71 @@ async def test_setup_hass_safe_mode( assert len(browser_setup.mock_calls) == 0 +@pytest.mark.parametrize("hass_config", [{"homeassistant": {"non-existing": 1}}]) async def test_setup_hass_invalid_core_config( + mock_hass_config: None, mock_enable_logging, mock_is_virtual_env, mock_mount_local_lib_path, mock_ensure_config_exists, mock_process_ha_config_upgrade, event_loop, -): +) -> None: """Test it works.""" - with patch( - "homeassistant.config.async_hass_config_yaml", - return_value={"homeassistant": {"non-existing": 1}}, - ): - hass = await bootstrap.async_setup_hass( - runner.RuntimeConfig( - config_dir=get_test_config_dir(), - verbose=False, - log_rotate_days=10, - log_file="", - log_no_color=False, - skip_pip=True, - safe_mode=False, - ), - ) + hass = await bootstrap.async_setup_hass( + runner.RuntimeConfig( + config_dir=get_test_config_dir(), + verbose=False, + log_rotate_days=10, + log_file="", + log_no_color=False, + skip_pip=True, + safe_mode=False, + ), + ) assert "safe_mode" in hass.config.components -async def test_setup_safe_mode_if_no_frontend( - mock_enable_logging, - mock_is_virtual_env, - mock_mount_local_lib_path, - mock_ensure_config_exists, - mock_process_ha_config_upgrade, - event_loop, -): - """Test we setup safe mode if frontend didn't load.""" - verbose = Mock() - log_rotate_days = Mock() - log_file = Mock() - log_no_color = Mock() - - with patch( - "homeassistant.config.async_hass_config_yaml", - return_value={ +@pytest.mark.parametrize( + "hass_config", + [ + { "homeassistant": { "internal_url": "http://192.168.1.100:8123", "external_url": "https://abcdef.ui.nabu.casa", }, "map": {}, "person": {"invalid": True}, - }, - ): - hass = await bootstrap.async_setup_hass( - runner.RuntimeConfig( - config_dir=get_test_config_dir(), - verbose=verbose, - log_rotate_days=log_rotate_days, - log_file=log_file, - log_no_color=log_no_color, - skip_pip=True, - safe_mode=False, - ), - ) + } + ], +) +async def test_setup_safe_mode_if_no_frontend( + mock_hass_config: None, + mock_enable_logging, + mock_is_virtual_env, + mock_mount_local_lib_path, + mock_ensure_config_exists, + mock_process_ha_config_upgrade, + event_loop, +) -> None: + """Test we setup safe mode if frontend didn't load.""" + verbose = Mock() + log_rotate_days = Mock() + log_file = Mock() + log_no_color = Mock() + + hass = await bootstrap.async_setup_hass( + runner.RuntimeConfig( + config_dir=get_test_config_dir(), + verbose=verbose, + log_rotate_days=log_rotate_days, + log_file=log_file, + log_no_color=log_no_color, + skip_pip=True, + safe_mode=False, + ), + ) assert "safe_mode" in hass.config.components assert hass.config.config_dir == get_test_config_dir() diff --git a/tests/test_config.py b/tests/test_config.py index 606770d492..5dfd674daf 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -40,7 +40,7 @@ from homeassistant.util.unit_system import ( ) from homeassistant.util.yaml import SECRET_YAML -from .common import MockUser, get_test_config_dir, patch_yaml_files +from .common import MockUser, get_test_config_dir CONFIG_DIR = get_test_config_dir() YAML_PATH = os.path.join(CONFIG_DIR, config_util.YAML_CONFIG_FILE) @@ -736,20 +736,25 @@ async def test_check_ha_config_file_wrong(mock_check, hass): assert await config_util.async_check_ha_config_file(hass) == "bad" -@patch("homeassistant.config.os.path.isfile", mock.Mock(return_value=True)) -async def test_async_hass_config_yaml_merge(merge_log_err, hass): +@pytest.mark.parametrize( + "hass_config", + [ + { + config_util.CONF_CORE: { + config_util.CONF_PACKAGES: { + "pack_dict": {"input_boolean": {"ib1": None}} + } + }, + "input_boolean": {"ib2": None}, + "light": {"platform": "test"}, + } + ], +) +async def test_async_hass_config_yaml_merge( + merge_log_err, hass: HomeAssistant, mock_hass_config: None +) -> None: """Test merge during async config reload.""" - config = { - config_util.CONF_CORE: { - config_util.CONF_PACKAGES: {"pack_dict": {"input_boolean": {"ib1": None}}} - }, - "input_boolean": {"ib2": None}, - "light": {"platform": "test"}, - } - - files = {config_util.YAML_CONFIG_FILE: yaml.dump(config)} - with patch_yaml_files(files, True): - conf = await config_util.async_hass_config_yaml(hass) + conf = await config_util.async_hass_config_yaml(hass) assert merge_log_err.call_count == 0 assert conf[config_util.CONF_CORE].get(config_util.CONF_PACKAGES) is not None diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index aa8276ea2f..18aca8ea5f 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -3,6 +3,7 @@ import importlib import io import os import pathlib +from typing import Any import unittest from unittest.mock import patch @@ -67,17 +68,17 @@ def test_simple_dict(try_both_loaders): assert doc["key"] == "value" -def test_unhashable_key() -> None: +@pytest.mark.parametrize("hass_config_yaml", ["message:\n {{ states.state }}"]) +def test_unhashable_key(mock_hass_config_yaml: None) -> None: """Test an unhashable key.""" - files = {YAML_CONFIG_FILE: "message:\n {{ states.state }}"} - with pytest.raises(HomeAssistantError), patch_yaml_files(files): + with pytest.raises(HomeAssistantError): load_yaml_config_file(YAML_CONFIG_FILE) -def test_no_key(try_both_loaders): +@pytest.mark.parametrize("hass_config_yaml", ["a: a\nnokeyhere"]) +def test_no_key(try_both_loaders, mock_hass_config_yaml: None) -> None: """Test item without a key.""" - files = {YAML_CONFIG_FILE: "a: a\nnokeyhere"} - with pytest.raises(HomeAssistantError), patch_yaml_files(files): + with pytest.raises(HomeAssistantError): yaml.load_yaml(YAML_CONFIG_FILE) @@ -106,35 +107,50 @@ def test_invalid_environment_variable(try_both_loaders): yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) -def test_include_yaml(try_both_loaders): +@pytest.mark.parametrize( + ("hass_config_yaml_files", "value"), + [({"test.yaml": "value"}, "value"), ({"test.yaml": None}, {})], +) +def test_include_yaml( + try_both_loaders, mock_hass_config_yaml: None, value: Any +) -> None: """Test include yaml.""" - with patch_yaml_files({"test.yaml": "value"}): - conf = "key: !include test.yaml" - with io.StringIO(conf) as file: - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) - assert doc["key"] == "value" - - with patch_yaml_files({"test.yaml": None}): - conf = "key: !include test.yaml" - with io.StringIO(conf) as file: - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) - assert doc["key"] == {} + conf = "key: !include test.yaml" + with io.StringIO(conf) as file: + doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) + assert doc["key"] == value @patch("homeassistant.util.yaml.loader.os.walk") -def test_include_dir_list(mock_walk, try_both_loaders): +@pytest.mark.parametrize( + "hass_config_yaml_files", [{"/test/one.yaml": "one", "/test/two.yaml": "two"}] +) +def test_include_dir_list( + mock_walk, try_both_loaders, mock_hass_config_yaml: None +) -> None: """Test include dir list yaml.""" mock_walk.return_value = [["/test", [], ["two.yaml", "one.yaml"]]] - with patch_yaml_files({"/test/one.yaml": "one", "/test/two.yaml": "two"}): - conf = "key: !include_dir_list /test" - with io.StringIO(conf) as file: - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) - assert doc["key"] == sorted(["one", "two"]) + conf = "key: !include_dir_list /test" + with io.StringIO(conf) as file: + doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) + assert doc["key"] == sorted(["one", "two"]) @patch("homeassistant.util.yaml.loader.os.walk") -def test_include_dir_list_recursive(mock_walk, try_both_loaders): +@pytest.mark.parametrize( + "hass_config_yaml_files", + [ + { + "/test/zero.yaml": "zero", + "/test/tmp2/one.yaml": "one", + "/test/tmp2/two.yaml": "two", + } + ], +) +def test_include_dir_list_recursive( + mock_walk, try_both_loaders, mock_hass_config_yaml: None +) -> None: """Test include dir recursive list yaml.""" mock_walk.return_value = [ ["/test", ["tmp2", ".ignore", "ignore"], ["zero.yaml"]], @@ -142,41 +158,49 @@ def test_include_dir_list_recursive(mock_walk, try_both_loaders): ["/test/ignore", [], [".ignore.yaml"]], ] - with patch_yaml_files( - { - "/test/zero.yaml": "zero", - "/test/tmp2/one.yaml": "one", - "/test/tmp2/two.yaml": "two", - } - ): - conf = "key: !include_dir_list /test" - with io.StringIO(conf) as file: - assert ( - ".ignore" in mock_walk.return_value[0][1] - ), "Expecting .ignore in here" - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) - assert "tmp2" in mock_walk.return_value[0][1] - assert ".ignore" not in mock_walk.return_value[0][1] - assert sorted(doc["key"]) == sorted(["zero", "one", "two"]) + conf = "key: !include_dir_list /test" + with io.StringIO(conf) as file: + assert ".ignore" in mock_walk.return_value[0][1], "Expecting .ignore in here" + doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) + assert "tmp2" in mock_walk.return_value[0][1] + assert ".ignore" not in mock_walk.return_value[0][1] + assert sorted(doc["key"]) == sorted(["zero", "one", "two"]) @patch("homeassistant.util.yaml.loader.os.walk") -def test_include_dir_named(mock_walk, try_both_loaders): +@pytest.mark.parametrize( + "hass_config_yaml_files", + [{"/test/first.yaml": "one", "/test/second.yaml": "two"}], +) +def test_include_dir_named( + mock_walk, try_both_loaders, mock_hass_config_yaml: None +) -> None: """Test include dir named yaml.""" mock_walk.return_value = [ ["/test", [], ["first.yaml", "second.yaml", "secrets.yaml"]] ] - with patch_yaml_files({"/test/first.yaml": "one", "/test/second.yaml": "two"}): - conf = "key: !include_dir_named /test" - correct = {"first": "one", "second": "two"} - with io.StringIO(conf) as file: - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) - assert doc["key"] == correct + conf = "key: !include_dir_named /test" + correct = {"first": "one", "second": "two"} + with io.StringIO(conf) as file: + doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) + assert doc["key"] == correct @patch("homeassistant.util.yaml.loader.os.walk") -def test_include_dir_named_recursive(mock_walk, try_both_loaders): +@pytest.mark.parametrize( + "hass_config_yaml_files", + [ + { + "/test/first.yaml": "one", + "/test/tmp2/second.yaml": "two", + "/test/tmp2/third.yaml": "three", + } + ], +) +def test_include_dir_named_recursive( + mock_walk, try_both_loaders, mock_hass_config_yaml: None +) -> None: """Test include dir named yaml.""" mock_walk.return_value = [ ["/test", ["tmp2", ".ignore", "ignore"], ["first.yaml"]], @@ -184,85 +208,99 @@ def test_include_dir_named_recursive(mock_walk, try_both_loaders): ["/test/ignore", [], [".ignore.yaml"]], ] - with patch_yaml_files( - { - "/test/first.yaml": "one", - "/test/tmp2/second.yaml": "two", - "/test/tmp2/third.yaml": "three", - } - ): - conf = "key: !include_dir_named /test" - correct = {"first": "one", "second": "two", "third": "three"} - with io.StringIO(conf) as file: - assert ( - ".ignore" in mock_walk.return_value[0][1] - ), "Expecting .ignore in here" - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) - assert "tmp2" in mock_walk.return_value[0][1] - assert ".ignore" not in mock_walk.return_value[0][1] - assert doc["key"] == correct + conf = "key: !include_dir_named /test" + correct = {"first": "one", "second": "two", "third": "three"} + with io.StringIO(conf) as file: + assert ".ignore" in mock_walk.return_value[0][1], "Expecting .ignore in here" + doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) + assert "tmp2" in mock_walk.return_value[0][1] + assert ".ignore" not in mock_walk.return_value[0][1] + assert doc["key"] == correct @patch("homeassistant.util.yaml.loader.os.walk") -def test_include_dir_merge_list(mock_walk, try_both_loaders): +@pytest.mark.parametrize( + "hass_config_yaml_files", + [{"/test/first.yaml": "- one", "/test/second.yaml": "- two\n- three"}], +) +def test_include_dir_merge_list( + mock_walk, try_both_loaders, mock_hass_config_yaml: None +) -> None: """Test include dir merge list yaml.""" mock_walk.return_value = [["/test", [], ["first.yaml", "second.yaml"]]] - with patch_yaml_files( - {"/test/first.yaml": "- one", "/test/second.yaml": "- two\n- three"} - ): - conf = "key: !include_dir_merge_list /test" - with io.StringIO(conf) as file: - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) - assert sorted(doc["key"]) == sorted(["one", "two", "three"]) + conf = "key: !include_dir_merge_list /test" + with io.StringIO(conf) as file: + doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) + assert sorted(doc["key"]) == sorted(["one", "two", "three"]) @patch("homeassistant.util.yaml.loader.os.walk") -def test_include_dir_merge_list_recursive(mock_walk, try_both_loaders): - """Test include dir merge list yaml.""" - mock_walk.return_value = [ - ["/test", ["tmp2", ".ignore", "ignore"], ["first.yaml"]], - ["/test/tmp2", [], ["second.yaml", "third.yaml"]], - ["/test/ignore", [], [".ignore.yaml"]], - ] - - with patch_yaml_files( +@pytest.mark.parametrize( + "hass_config_yaml_files", + [ { "/test/first.yaml": "- one", "/test/tmp2/second.yaml": "- two", "/test/tmp2/third.yaml": "- three\n- four", } - ): - conf = "key: !include_dir_merge_list /test" - with io.StringIO(conf) as file: - assert ( - ".ignore" in mock_walk.return_value[0][1] - ), "Expecting .ignore in here" - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) - assert "tmp2" in mock_walk.return_value[0][1] - assert ".ignore" not in mock_walk.return_value[0][1] - assert sorted(doc["key"]) == sorted(["one", "two", "three", "four"]) + ], +) +def test_include_dir_merge_list_recursive( + mock_walk, try_both_loaders, mock_hass_config_yaml: None +) -> None: + """Test include dir merge list yaml.""" + mock_walk.return_value = [ + ["/test", ["tmp2", ".ignore", "ignore"], ["first.yaml"]], + ["/test/tmp2", [], ["second.yaml", "third.yaml"]], + ["/test/ignore", [], [".ignore.yaml"]], + ] + + conf = "key: !include_dir_merge_list /test" + with io.StringIO(conf) as file: + assert ".ignore" in mock_walk.return_value[0][1], "Expecting .ignore in here" + doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) + assert "tmp2" in mock_walk.return_value[0][1] + assert ".ignore" not in mock_walk.return_value[0][1] + assert sorted(doc["key"]) == sorted(["one", "two", "three", "four"]) @patch("homeassistant.util.yaml.loader.os.walk") -def test_include_dir_merge_named(mock_walk, try_both_loaders): +@pytest.mark.parametrize( + "hass_config_yaml_files", + [ + { + "/test/first.yaml": "key1: one", + "/test/second.yaml": "key2: two\nkey3: three", + } + ], +) +def test_include_dir_merge_named( + mock_walk, try_both_loaders, mock_hass_config_yaml: None +) -> None: """Test include dir merge named yaml.""" mock_walk.return_value = [["/test", [], ["first.yaml", "second.yaml"]]] - files = { - "/test/first.yaml": "key1: one", - "/test/second.yaml": "key2: two\nkey3: three", - } - - with patch_yaml_files(files): - conf = "key: !include_dir_merge_named /test" - with io.StringIO(conf) as file: - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) - assert doc["key"] == {"key1": "one", "key2": "two", "key3": "three"} + conf = "key: !include_dir_merge_named /test" + with io.StringIO(conf) as file: + doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) + assert doc["key"] == {"key1": "one", "key2": "two", "key3": "three"} @patch("homeassistant.util.yaml.loader.os.walk") -def test_include_dir_merge_named_recursive(mock_walk, try_both_loaders): +@pytest.mark.parametrize( + "hass_config_yaml_files", + [ + { + "/test/first.yaml": "key1: one", + "/test/tmp2/second.yaml": "key2: two", + "/test/tmp2/third.yaml": "key3: three\nkey4: four", + } + ], +) +def test_include_dir_merge_named_recursive( + mock_walk, try_both_loaders, mock_hass_config_yaml: None +) -> None: """Test include dir merge named yaml.""" mock_walk.return_value = [ ["/test", ["tmp2", ".ignore", "ignore"], ["first.yaml"]], @@ -270,27 +308,18 @@ def test_include_dir_merge_named_recursive(mock_walk, try_both_loaders): ["/test/ignore", [], [".ignore.yaml"]], ] - with patch_yaml_files( - { - "/test/first.yaml": "key1: one", - "/test/tmp2/second.yaml": "key2: two", - "/test/tmp2/third.yaml": "key3: three\nkey4: four", + conf = "key: !include_dir_merge_named /test" + with io.StringIO(conf) as file: + assert ".ignore" in mock_walk.return_value[0][1], "Expecting .ignore in here" + doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) + assert "tmp2" in mock_walk.return_value[0][1] + assert ".ignore" not in mock_walk.return_value[0][1] + assert doc["key"] == { + "key1": "one", + "key2": "two", + "key3": "three", + "key4": "four", } - ): - conf = "key: !include_dir_merge_named /test" - with io.StringIO(conf) as file: - assert ( - ".ignore" in mock_walk.return_value[0][1] - ), "Expecting .ignore in here" - doc = yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) - assert "tmp2" in mock_walk.return_value[0][1] - assert ".ignore" not in mock_walk.return_value[0][1] - assert doc["key"] == { - "key1": "one", - "key2": "two", - "key3": "three", - "key4": "four", - } @patch("homeassistant.util.yaml.loader.open", create=True) @@ -452,26 +481,31 @@ class TestSecrets(unittest.TestCase): ) -def test_representing_yaml_loaded_data(try_both_dumpers): +@pytest.mark.parametrize("hass_config_yaml", ['key: [1, "2", 3]']) +def test_representing_yaml_loaded_data( + try_both_dumpers, mock_hass_config_yaml: None +) -> None: """Test we can represent YAML loaded data.""" - files = {YAML_CONFIG_FILE: 'key: [1, "2", 3]'} - with patch_yaml_files(files): - data = load_yaml_config_file(YAML_CONFIG_FILE) + data = load_yaml_config_file(YAML_CONFIG_FILE) assert yaml.dump(data) == "key:\n- 1\n- '2'\n- 3\n" -def test_duplicate_key(caplog, try_both_loaders): +@pytest.mark.parametrize("hass_config_yaml", ["key: thing1\nkey: thing2"]) +def test_duplicate_key(caplog, try_both_loaders, mock_hass_config_yaml: None) -> None: """Test duplicate dict keys.""" - files = {YAML_CONFIG_FILE: "key: thing1\nkey: thing2"} - with patch_yaml_files(files): - load_yaml_config_file(YAML_CONFIG_FILE) + load_yaml_config_file(YAML_CONFIG_FILE) assert "contains duplicate key" in caplog.text -def test_no_recursive_secrets(caplog, try_both_loaders): +@pytest.mark.parametrize( + "hass_config_yaml_files", + [{YAML_CONFIG_FILE: "key: !secret a", yaml.SECRET_YAML: "a: 1\nb: !secret a"}], +) +def test_no_recursive_secrets( + caplog, try_both_loaders, mock_hass_config_yaml: None +) -> None: """Test that loading of secrets from the secrets file fails correctly.""" - files = {YAML_CONFIG_FILE: "key: !secret a", yaml.SECRET_YAML: "a: 1\nb: !secret a"} - with patch_yaml_files(files), pytest.raises(HomeAssistantError) as e: + with pytest.raises(HomeAssistantError) as e: load_yaml_config_file(YAML_CONFIG_FILE) assert e.value.args == ("Secrets not supported in this YAML file",) From 7119a0f811265ef39225daebac950a9946436f1d Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 20 Feb 2023 17:20:52 +0100 Subject: [PATCH 0235/2810] Use `suggested_display_precision` instead of rounding in Shelly sensor platform (#87084) --- homeassistant/components/shelly/sensor.py | 132 +++++++++++++--------- tests/components/shelly/conftest.py | 13 ++- tests/components/shelly/test_climate.py | 4 + tests/components/shelly/test_sensor.py | 57 +++++++++- 4 files changed, 152 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index e0196fbea5..2737270ac7 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -82,7 +82,7 @@ SENSORS: Final = { key="device|deviceTemp", name="Device temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, - value=lambda value: round(value, 1), + suggested_display_precision=1, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -92,7 +92,6 @@ SENSORS: Final = { key="emeter|current", name="Current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - value=lambda value: value, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, ), @@ -100,7 +99,7 @@ SENSORS: Final = { key="light|power", name="Power", native_unit_of_measurement=UnitOfPower.WATT, - value=lambda value: round(value, 1), + suggested_display_precision=1, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -109,7 +108,7 @@ SENSORS: Final = { key="device|power", name="Power", native_unit_of_measurement=UnitOfPower.WATT, - value=lambda value: round(value, 1), + suggested_display_precision=1, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), @@ -117,7 +116,7 @@ SENSORS: Final = { key="emeter|power", name="Power", native_unit_of_measurement=UnitOfPower.WATT, - value=lambda value: round(value, 1), + suggested_display_precision=1, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), @@ -125,7 +124,7 @@ SENSORS: Final = { key="device|voltage", name="Voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, - value=lambda value: round(value, 1), + suggested_display_precision=1, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -134,15 +133,14 @@ SENSORS: Final = { key="emeter|voltage", name="Voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, - value=lambda value: round(value, 1), + suggested_display_precision=1, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, ), ("emeter", "powerFactor"): BlockSensorDescription( key="emeter|powerFactor", name="Power factor", - native_unit_of_measurement=PERCENTAGE, - value=lambda value: round(value * 100, 1), + suggested_display_precision=2, device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, ), @@ -150,7 +148,7 @@ SENSORS: Final = { key="relay|power", name="Power", native_unit_of_measurement=UnitOfPower.WATT, - value=lambda value: round(value, 1), + suggested_display_precision=1, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), @@ -158,23 +156,26 @@ SENSORS: Final = { key="roller|rollerPower", name="Power", native_unit_of_measurement=UnitOfPower.WATT, - value=lambda value: round(value, 1), + suggested_display_precision=1, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), ("device", "energy"): BlockSensorDescription( key="device|energy", name="Energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda value: round(value / 60 / 1000, 2), + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda value: value / 60, + suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), ("emeter", "energy"): BlockSensorDescription( key="emeter|energy", name="Energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda value: round(value / 1000, 2), + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, available=lambda block: cast(int, block.energy) != -1, @@ -182,8 +183,9 @@ SENSORS: Final = { ("emeter", "energyReturned"): BlockSensorDescription( key="emeter|energyReturned", name="Energy returned", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda value: round(value / 1000, 2), + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, available=lambda block: cast(int, block.energyReturned) != -1, @@ -191,8 +193,10 @@ SENSORS: Final = { ("light", "energy"): BlockSensorDescription( key="light|energy", name="Energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda value: round(value / 60 / 1000, 2), + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda value: value / 60, + suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, @@ -200,16 +204,20 @@ SENSORS: Final = { ("relay", "energy"): BlockSensorDescription( key="relay|energy", name="Energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda value: round(value / 60 / 1000, 2), + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda value: value / 60, + suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), ("roller", "rollerEnergy"): BlockSensorDescription( key="roller|rollerEnergy", name="Energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda value: round(value / 60 / 1000, 2), + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda value: value / 60, + suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -224,7 +232,7 @@ SENSORS: Final = { key="sensor|temp", name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, - value=lambda value: round(value, 1), + suggested_display_precision=1, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -233,7 +241,7 @@ SENSORS: Final = { key="sensor|extTemp", name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, - value=lambda value: round(value, 1), + suggested_display_precision=1, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, available=lambda block: cast(int, block.extTemp) != 999 @@ -243,7 +251,7 @@ SENSORS: Final = { key="sensor|humidity", name="Humidity", native_unit_of_measurement=PERCENTAGE, - value=lambda value: round(value, 1), + suggested_display_precision=1, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, available=lambda block: cast(int, block.humidity) != 999 @@ -269,7 +277,8 @@ SENSORS: Final = { name="Lamp life", native_unit_of_measurement=PERCENTAGE, icon="mdi:progress-wrench", - value=lambda value: round(100 - (value / 3600 / SHAIR_MAX_WORK_HOURS), 1), + value=lambda value: 100 - (value / 3600 / SHAIR_MAX_WORK_HOURS), + suggested_display_precision=1, extra_state_attributes=lambda block: { "Operational hours": round(cast(int, block.totalWorkTime) / 3600, 1) }, @@ -279,7 +288,7 @@ SENSORS: Final = { key="adc|adc", name="ADC", native_unit_of_measurement=UnitOfElectricPotential.VOLT, - value=lambda value: round(value, 2), + suggested_display_precision=2, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, ), @@ -397,7 +406,8 @@ RPC_SENSORS: Final = { sub_key="voltage", name="Voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, - value=lambda status, _: None if status is None else round(float(status), 1), + value=lambda status, _: None if status is None else float(status), + suggested_display_precision=1, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -460,8 +470,10 @@ RPC_SENSORS: Final = { key="switch", sub_key="aenergy", name="Energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda status, _: round(status["total"] / 1000, 2), + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: status["total"], + suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -469,8 +481,10 @@ RPC_SENSORS: Final = { key="emdata", sub_key="total_act", name="Total active energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda status, _: round(float(status) / 1000, 2), + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: float(status), + suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -478,8 +492,10 @@ RPC_SENSORS: Final = { key="emdata", sub_key="a_total_act_energy", name="Phase A total active energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda status, _: round(float(status) / 1000, 2), + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: float(status), + suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, @@ -488,8 +504,10 @@ RPC_SENSORS: Final = { key="emdata", sub_key="b_total_act_energy", name="Phase B total active energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda status, _: round(float(status) / 1000, 2), + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: float(status), + suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, @@ -498,8 +516,10 @@ RPC_SENSORS: Final = { key="emdata", sub_key="c_total_act_energy", name="Phase C total active energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda status, _: round(float(status) / 1000, 2), + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: float(status), + suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, @@ -508,8 +528,10 @@ RPC_SENSORS: Final = { key="emdata", sub_key="total_act_ret", name="Total active returned energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda status, _: round(float(status) / 1000, 2), + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: float(status), + suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -517,8 +539,10 @@ RPC_SENSORS: Final = { key="emdata", sub_key="a_total_act_ret_energy", name="Phase A total active returned energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda status, _: round(float(status) / 1000, 2), + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: float(status), + suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, @@ -527,8 +551,10 @@ RPC_SENSORS: Final = { key="emdata", sub_key="b_total_act_ret_energy", name="Phase B total active returned energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda status, _: round(float(status) / 1000, 2), + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: float(status), + suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, @@ -537,8 +563,10 @@ RPC_SENSORS: Final = { key="emdata", sub_key="c_total_act_ret_energy", name="Phase C total active returned energy", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda status, _: round(float(status) / 1000, 2), + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: float(status), + suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, @@ -548,7 +576,8 @@ RPC_SENSORS: Final = { sub_key="temperature", name="Device temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, - value=lambda status, _: round(status["tC"], 1), + value=lambda status, _: status["tC"], + suggested_display_precision=1, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -560,7 +589,7 @@ RPC_SENSORS: Final = { sub_key="tC", name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, - value=lambda status, _: round(status, 1), + suggested_display_precision=1, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), @@ -590,7 +619,7 @@ RPC_SENSORS: Final = { sub_key="rh", name="Humidity", native_unit_of_measurement=PERCENTAGE, - value=lambda status, _: round(status, 1), + suggested_display_precision=1, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), @@ -609,7 +638,8 @@ RPC_SENSORS: Final = { sub_key="voltage", name="Voltmeter", native_unit_of_measurement=UnitOfElectricPotential.VOLT, - value=lambda status, _: round(float(status), 2), + value=lambda status, _: float(status), + suggested_display_precision=2, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, available=lambda status: status is not None, diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index c8cb1b4d08..8fa5b0eaea 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -70,11 +70,13 @@ MOCK_BLOCKS = [ "inputEventCnt": 2, "overpower": 0, "power": 53.4, + "energy": 1234567.89, }, channel="0", type="relay", overpower=0, power=53.4, + energy=1234567.89, description="relay_0", set_state=AsyncMock(side_effect=lambda turn: {"ison": turn == "on"}), ), @@ -120,6 +122,15 @@ MOCK_BLOCKS = [ description="device_0", type="device", ), + Mock( + sensor_ids={"powerFactor": 0.98}, + channel="0", + powerFactor=0.98, + targetTemp=4, + temp=22.1, + description="emeter_0", + type="emeter", + ), ] MOCK_CONFIG = { @@ -185,7 +196,7 @@ MOCK_STATUS_RPC = { "stable": {"version": "some_beta_version"}, } }, - "voltmeter": {"voltage": 4.3}, + "voltmeter": {"voltage": 4.321}, "wifi": {"rssi": -63}, } diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index 7daf640cc7..6c0ac74296 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -29,6 +29,7 @@ from tests.common import mock_restore_cache, mock_restore_cache_with_extra_data SENSOR_BLOCK_ID = 3 DEVICE_BLOCK_ID = 4 +EMETER_BLOCK_ID = 5 ENTITY_ID = f"{CLIMATE_DOMAIN}.test_name" @@ -43,6 +44,7 @@ async def test_climate_hvac_mode( {"battery": 98, "valvePos": 50, "targetTemp": 21.0}, ) monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0) + monkeypatch.delattr(mock_block_device.blocks[EMETER_BLOCK_ID], "targetTemp") await init_integration(hass, 1, sleep_period=1000, model="SHTRV-01") # Make device online @@ -195,6 +197,7 @@ async def test_block_restored_climate( """Test block restored climate.""" monkeypatch.delattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "targetTemp") monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0) + monkeypatch.delattr(mock_block_device.blocks[EMETER_BLOCK_ID], "targetTemp") entry = await init_integration(hass, 1, sleep_period=1000, skip_setup=True) register_device(device_reg, entry) entity_id = register_entity( @@ -257,6 +260,7 @@ async def test_block_restored_climate_us_customery( hass.config.units = US_CUSTOMARY_SYSTEM monkeypatch.delattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "targetTemp") monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0) + monkeypatch.delattr(mock_block_device.blocks[EMETER_BLOCK_ID], "targetTemp") entry = await init_integration(hass, 1, sleep_period=1000, skip_setup=True) register_device(device_reg, entry) entity_id = register_entity( diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index 822f007525..d5da1e96bc 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -1,6 +1,13 @@ """Tests for Shelly sensor platform.""" from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.components.shelly.const import DOMAIN +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + UnitOfEnergy, +) from homeassistant.core import HomeAssistant, State from homeassistant.helpers.entity_registry import async_get @@ -35,6 +42,52 @@ async def test_block_sensor( assert hass.states.get(entity_id).state == "60.1" +async def test_energy_sensor(hass: HomeAssistant, mock_block_device) -> None: + """Test energy sensor.""" + entity_id = f"{SENSOR_DOMAIN}.test_name_channel_1_energy" + await init_integration(hass, 1) + + state = hass.states.get(entity_id) + # 1234567.89 Wmin / 60 / 1000 = 20.5761315 kWh + assert state.state == "20.5761315" + # suggested unit is KWh + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR + + +async def test_power_factory_unit_migration( + hass: HomeAssistant, mock_block_device +) -> None: + """Test migration unit of the power factory sensor.""" + registry = async_get(hass) + registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + "123456789ABC-emeter_0-powerFactor", + suggested_object_id="test_name_power_factor", + unit_of_measurement="%", + ) + + entity_id = f"{SENSOR_DOMAIN}.test_name_power_factor" + await init_integration(hass, 1) + + state = hass.states.get(entity_id) + # Value of 0.98 is converted to 98.0% + assert state.state == "98.0" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE + + +async def test_power_factory_without_unit_migration( + hass: HomeAssistant, mock_block_device +) -> None: + """Test unit and value of the power factory sensor without unit migration.""" + entity_id = f"{SENSOR_DOMAIN}.test_name_power_factor" + await init_integration(hass, 1) + + state = hass.states.get(entity_id) + assert state.state == "0.98" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + + async def test_block_rest_sensor( hass: HomeAssistant, mock_block_device, monkeypatch ) -> None: @@ -228,7 +281,7 @@ async def test_rpc_sensor_error( entity_id = f"{SENSOR_DOMAIN}.test_name_voltmeter" await init_integration(hass, 2) - assert hass.states.get(entity_id).state == "4.3" + assert hass.states.get(entity_id).state == "4.321" mutate_rpc_device_status(monkeypatch, mock_rpc_device, "voltmeter", "voltage", None) mock_rpc_device.mock_update() From 5bf3a0b7af92f83b19cf47511a21fd7c4b23d2f9 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 20 Feb 2023 11:28:04 -0600 Subject: [PATCH 0236/2810] Load custom sentences for language variations (en-us, etc.) (#88498) --- .../components/conversation/default_agent.py | 51 ++++++++++++------- tests/components/conversation/test_init.py | 10 +++- 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 13dec808be..2c531fea14 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -321,6 +321,9 @@ class DefaultAgent(AbstractConversationAgent): intents_dict = lang_intents.intents_dict loaded_components = lang_intents.loaded_components + # en-US, en_US, en, ... + language_variations = list(_get_language_variations(language)) + # Check if any new components have been loaded intents_changed = False for component in hass_components: @@ -332,7 +335,7 @@ class DefaultAgent(AbstractConversationAgent): # Check for intents for this component with the target language. # Try en-US, en, etc. - for language_variation in _get_language_variations(language): + for language_variation in language_variations: component_intents = get_intents( component, language_variation, json_load=json_load ) @@ -343,7 +346,10 @@ class DefaultAgent(AbstractConversationAgent): # Will need to recreate graph intents_changed = True _LOGGER.debug( - "Loaded intents component=%s, language=%s", component, language + "Loaded intents component=%s, language=%s (%s)", + component, + language, + language_variation, ) break @@ -351,24 +357,31 @@ class DefaultAgent(AbstractConversationAgent): if lang_intents is None: # Only load custom sentences once, otherwise they will be re-loaded # when components change. - custom_sentences_dir = Path( - self.hass.config.path("custom_sentences", language) - ) - if custom_sentences_dir.is_dir(): - for custom_sentences_path in custom_sentences_dir.rglob("*.yaml"): - with custom_sentences_path.open( - encoding="utf-8" - ) as custom_sentences_file: - # Merge custom sentences - merge_dict(intents_dict, yaml.safe_load(custom_sentences_file)) + for language_variation in language_variations: + custom_sentences_dir = Path( + self.hass.config.path("custom_sentences", language_variation) + ) + if custom_sentences_dir.is_dir(): + for custom_sentences_path in custom_sentences_dir.rglob("*.yaml"): + with custom_sentences_path.open( + encoding="utf-8" + ) as custom_sentences_file: + # Merge custom sentences + merge_dict( + intents_dict, yaml.safe_load(custom_sentences_file) + ) - # Will need to recreate graph - intents_changed = True - _LOGGER.debug( - "Loaded custom sentences language=%s, path=%s", - language, - custom_sentences_path, - ) + # Will need to recreate graph + intents_changed = True + _LOGGER.debug( + "Loaded custom sentences language=%s (%s), path=%s", + language, + language_variation, + custom_sentences_path, + ) + + # Stop after first matched language variation + break # Load sentences from HA config for default language only if self._config_intents and (language == self.hass.config.language): diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 3f60d38959..23914115c0 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -595,12 +595,18 @@ async def test_custom_sentences( # Expecting testing_config/custom_sentences/en/beer.yaml intent.async_register(hass, OrderBeerIntentHandler()) + # Don't use "en" to test loading custom sentences with language variants. + language = "en-us" + # Invoke intent via HTTP API client = await hass_client() for beer_style in ("stout", "lager"): resp = await client.post( "/api/conversation/process", - json={"text": f"I'd like to order a {beer_style}, please"}, + json={ + "text": f"I'd like to order a {beer_style}, please", + "language": language, + }, ) assert resp.status == HTTPStatus.OK data = await resp.json() @@ -614,7 +620,7 @@ async def test_custom_sentences( "speech": f"You ordered a {beer_style}", } }, - "language": hass.config.language, + "language": language, "response_type": "action_done", "data": { "targets": [], From 9c82cfecca082587f1fb117364dd8f6bce44a675 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Feb 2023 11:39:42 -0600 Subject: [PATCH 0237/2810] Avoid asking recorder platforms for list_statistic_ids when already complete (#88495) * Avoid asking recorder platforms for list_statistic_ids when already complete If we already had all the data needed for list_statistic_ids, we would still query recorder platforms and throw away the results * Update homeassistant/components/recorder/statistics.py --- .../components/recorder/statistics.py | 44 +++++++++++-------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index fece82b4ff..294c521762 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -987,6 +987,7 @@ def list_statistic_ids( period. """ result = {} + statistic_ids_set = set(statistic_ids) if statistic_ids else None # Query the database with session_scope(hass=hass) as session: @@ -1009,26 +1010,31 @@ def list_statistic_ids( for _, meta in metadata.values() } - # Query all integrations with a registered recorder platform - for platform in hass.data[DOMAIN].recorder_platforms.values(): - if not hasattr(platform, "list_statistic_ids"): - continue - platform_statistic_ids = platform.list_statistic_ids( - hass, statistic_ids=statistic_ids, statistic_type=statistic_type - ) - - for key, meta in platform_statistic_ids.items(): - if key in result: + if not statistic_ids_set or statistic_ids_set.difference(result): + # If we want all statistic_ids, or some are missing, we need to query + # the integrations for the missing ones. + # + # Query all integrations with a registered recorder platform + for platform in hass.data[DOMAIN].recorder_platforms.values(): + if not hasattr(platform, "list_statistic_ids"): continue - result[key] = { - "display_unit_of_measurement": meta["unit_of_measurement"], - "has_mean": meta["has_mean"], - "has_sum": meta["has_sum"], - "name": meta["name"], - "source": meta["source"], - "unit_class": _get_unit_class(meta["unit_of_measurement"]), - "unit_of_measurement": meta["unit_of_measurement"], - } + platform_statistic_ids = platform.list_statistic_ids( + hass, statistic_ids=statistic_ids, statistic_type=statistic_type + ) + + for key, meta in platform_statistic_ids.items(): + if key in result: + # The database has a higher priority than the integration + continue + result[key] = { + "display_unit_of_measurement": meta["unit_of_measurement"], + "has_mean": meta["has_mean"], + "has_sum": meta["has_sum"], + "name": meta["name"], + "source": meta["source"], + "unit_class": _get_unit_class(meta["unit_of_measurement"]), + "unit_of_measurement": meta["unit_of_measurement"], + } # Return a list of statistic_id + metadata return [ From 0b81c836ef0e5f1604b4abb7b830330762351fb9 Mon Sep 17 00:00:00 2001 From: emanuelst <9994339+emanuelst@users.noreply.github.com> Date: Mon, 20 Feb 2023 18:56:41 +0100 Subject: [PATCH 0238/2810] Update HomeKit VOC mappings (#87663) Co-authored-by: J. Nick Koston --- .../components/homekit/type_sensors.py | 21 +++++++++++++++---- homeassistant/components/homekit/util.py | 17 +++++++++------ tests/components/homekit/test_type_sensors.py | 20 +++++++++--------- 3 files changed, 38 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/homekit/type_sensors.py b/homeassistant/components/homekit/type_sensors.py index 27a1156111..240cdd888d 100644 --- a/homeassistant/components/homekit/type_sensors.py +++ b/homeassistant/components/homekit/type_sensors.py @@ -6,6 +6,7 @@ import logging from typing import NamedTuple from pyhap.const import CATEGORY_SENSOR +from pyhap.service import Service from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.const import ( @@ -40,6 +41,8 @@ from .const import ( CHAR_SMOKE_DETECTED, CHAR_VOC_DENSITY, PROP_CELSIUS, + PROP_MAX_VALUE, + PROP_MIN_VALUE, SERV_AIR_QUALITY_SENSOR, SERV_CARBON_DIOXIDE_SENSOR, SERV_CARBON_MONOXIDE_SENSOR, @@ -281,15 +284,25 @@ class NitrogenDioxideSensor(AirQualitySensor): @TYPES.register("VolatileOrganicCompoundsSensor") class VolatileOrganicCompoundsSensor(AirQualitySensor): - """Generate a VolatileOrganicCompoundsSensor accessory as VOCs sensor.""" + """Generate a VolatileOrganicCompoundsSensor accessory as VOCs sensor. + + Sensor entity must return VOC in µg/m3. + """ def create_services(self): - """Override the init function for PM 2.5 Sensor.""" - serv_air_quality = self.add_preload_service( + """Override the init function for VOC Sensor.""" + serv_air_quality: Service = self.add_preload_service( SERV_AIR_QUALITY_SENSOR, [CHAR_VOC_DENSITY] ) self.char_quality = serv_air_quality.configure_char(CHAR_AIR_QUALITY, value=0) - self.char_density = serv_air_quality.configure_char(CHAR_VOC_DENSITY, value=0) + self.char_density = serv_air_quality.configure_char( + CHAR_VOC_DENSITY, + value=0, + properties={ + PROP_MIN_VALUE: 0, + PROP_MAX_VALUE: 5000, + }, + ) @callback def async_update_state(self, new_state): diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 53fd9fbfed..5f0838d91a 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -447,16 +447,21 @@ def density_to_air_quality_nitrogen_dioxide(density: float) -> int: def density_to_air_quality_voc(density: float) -> int: - """Map VOCs µg/m3 to HomeKit AirQuality level.""" - if density <= 24: + """Map VOCs µg/m3 to HomeKit AirQuality level. + + The VOC mappings use the IAQ guidelines for Europe released by the WHO (World Health Organization). + Referenced from Sensirion_Gas_Sensors_SGP3x_TVOC_Concept.pdf + https://github.com/paulvha/svm30/blob/master/extras/Sensirion_Gas_Sensors_SGP3x_TVOC_Concept.pdf + """ + if density <= 250: # WHO IAQ 1 (HomeKit: Excellent) return 1 - if density <= 48: + if density <= 500: # WHO IAQ 2 (HomeKit: Good) return 2 - if density <= 64: + if density <= 1000: # WHO IAQ 3 (HomeKit: Fair) return 3 - if density <= 96: + if density <= 3000: # WHO IAQ 4 (HomeKit: Inferior) return 4 - return 5 + return 5 # WHOA IAQ 5 (HomeKit: Poor) def get_persist_filename_for_entry_id(entry_id: str) -> str: diff --git a/tests/components/homekit/test_type_sensors.py b/tests/components/homekit/test_type_sensors.py index eb60acde05..c48ebb86ce 100644 --- a/tests/components/homekit/test_type_sensors.py +++ b/tests/components/homekit/test_type_sensors.py @@ -304,29 +304,29 @@ async def test_voc(hass: HomeAssistant, hk_driver) -> None: assert acc.char_density.value == 0 assert acc.char_quality.value == 0 - hass.states.async_set(entity_id, "24") + hass.states.async_set(entity_id, "250") await hass.async_block_till_done() - assert acc.char_density.value == 24 + assert acc.char_density.value == 250 assert acc.char_quality.value == 1 - hass.states.async_set(entity_id, "48") + hass.states.async_set(entity_id, "500") await hass.async_block_till_done() - assert acc.char_density.value == 48 + assert acc.char_density.value == 500 assert acc.char_quality.value == 2 - hass.states.async_set(entity_id, "64") + hass.states.async_set(entity_id, "1000") await hass.async_block_till_done() - assert acc.char_density.value == 64 + assert acc.char_density.value == 1000 assert acc.char_quality.value == 3 - hass.states.async_set(entity_id, "96") + hass.states.async_set(entity_id, "3000") await hass.async_block_till_done() - assert acc.char_density.value == 96 + assert acc.char_density.value == 3000 assert acc.char_quality.value == 4 - hass.states.async_set(entity_id, "128") + hass.states.async_set(entity_id, "5000") await hass.async_block_till_done() - assert acc.char_density.value == 128 + assert acc.char_density.value == 5000 assert acc.char_quality.value == 5 From cc4a179ca8f913e5fa5046451121777ed8e1c6ba Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 20 Feb 2023 18:57:00 +0100 Subject: [PATCH 0239/2810] Support templating for in state conditions (#88411) --- homeassistant/helpers/condition.py | 22 +++++- homeassistant/helpers/config_validation.py | 24 +++++- .../triggers/test_numeric_state.py | 38 ++++----- .../homeassistant/triggers/test_state.py | 36 ++++----- tests/helpers/test_condition.py | 77 ++++++++++++++++++- tests/helpers/test_config_validation.py | 25 ++++++ 6 files changed, 173 insertions(+), 49 deletions(-) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 30181751f8..7513e2b008 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -12,6 +12,8 @@ import re import sys from typing import Any, cast +import voluptuous as vol + from homeassistant.components import zone as zone_cmp from homeassistant.components.device_automation import condition as device_condition from homeassistant.components.sensor import SensorDeviceClass @@ -29,6 +31,7 @@ from homeassistant.const import ( CONF_DEVICE_ID, CONF_ENABLED, CONF_ENTITY_ID, + CONF_FOR, CONF_ID, CONF_MATCH, CONF_STATE, @@ -57,7 +60,7 @@ import homeassistant.util.dt as dt_util from . import config_validation as cv, entity_registry as er from .sun import get_astral_event_date -from .template import Template +from .template import Template, attach as template_attach, render_complex from .trace import ( TraceElement, trace_append_element, @@ -481,6 +484,7 @@ def state( req_state: Any, for_period: timedelta | None = None, attribute: str | None = None, + variables: TemplateVarsType = None, ) -> bool: """Test if state matches requirements. @@ -534,7 +538,14 @@ def state( condition_trace_set_result(is_state, state=value, wanted_state=state_value) return is_state - duration = dt_util.utcnow() - for_period + try: + for_period = cv.positive_time_period(render_complex(for_period, variables)) + except TemplateError as ex: + raise ConditionErrorMessage("state", f"template error: {ex}") from ex + except vol.Invalid as ex: + raise ConditionErrorMessage("state", f"schema error: {ex}") from ex + + duration = dt_util.utcnow() - cast(timedelta, for_period) duration_ok = duration > entity.last_changed condition_trace_set_result(duration_ok, state=value, duration=duration) return duration_ok @@ -544,7 +555,7 @@ def state_from_config(config: ConfigType) -> ConditionCheckerType: """Wrap action method with state based condition.""" entity_ids = config.get(CONF_ENTITY_ID, []) req_states: str | list[str] = config.get(CONF_STATE, []) - for_period = config.get("for") + for_period = config.get(CONF_FOR) attribute = config.get(CONF_ATTRIBUTE) match = config.get(CONF_MATCH, ENTITY_MATCH_ALL) @@ -554,12 +565,15 @@ def state_from_config(config: ConfigType) -> ConditionCheckerType: @trace_condition_function def if_state(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: """Test if condition.""" + template_attach(hass, for_period) errors = [] result: bool = match != ENTITY_MATCH_ANY for index, entity_id in enumerate(entity_ids): try: with trace_path(["entity_id", str(index)]), trace_condition(variables): - if state(hass, entity_id, req_states, for_period, attribute): + if state( + hass, entity_id, req_states, for_period, attribute, variables + ): result = True elif match == ENTITY_MATCH_ALL: return False diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index c13b703cae..42e1927e09 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -386,6 +386,8 @@ def icon(value: Any) -> str: raise vol.Invalid('Icons should be specified in the form "prefix:name"') +_TIME_PERIOD_DICT_KEYS = ("days", "hours", "minutes", "seconds", "milliseconds") + time_period_dict = vol.All( dict, vol.Schema( @@ -397,7 +399,7 @@ time_period_dict = vol.All( "milliseconds": vol.Coerce(float), } ), - has_at_least_one_key("days", "hours", "minutes", "seconds", "milliseconds"), + has_at_least_one_key(*_TIME_PERIOD_DICT_KEYS), lambda value: timedelta(**value), ) @@ -639,8 +641,24 @@ def template_complex(value: Any) -> Any: return value +def _positive_time_period_template_complex(value: Any) -> Any: + """Do basic validation of a positive time period expressed as a templated dict.""" + if not isinstance(value, dict) or not value: + raise vol.Invalid("template should be a dict") + for key, element in value.items(): + if not isinstance(key, str): + raise vol.Invalid("key should be a string") + if not template_helper.is_template_string(key): + vol.In(_TIME_PERIOD_DICT_KEYS)(key) + if not isinstance(element, str) or ( + isinstance(element, str) and not template_helper.is_template_string(element) + ): + vol.All(vol.Coerce(float), vol.Range(min=0))(element) + return template_complex(value) + + positive_time_period_template = vol.Any( - positive_time_period, template, template_complex + positive_time_period, dynamic_template, _positive_time_period_template_complex ) @@ -1166,7 +1184,7 @@ STATE_CONDITION_BASE_SCHEMA = { vol.Lower, vol.Any(ENTITY_MATCH_ALL, ENTITY_MATCH_ANY) ), vol.Optional(CONF_ATTRIBUTE): str, - vol.Optional(CONF_FOR): positive_time_period, + vol.Optional(CONF_FOR): positive_time_period_template, # To support use_trigger_value in automation # Deprecated 2016/04/25 vol.Optional("from"): str, diff --git a/tests/components/homeassistant/triggers/test_numeric_state.py b/tests/components/homeassistant/triggers/test_numeric_state.py index 477751556c..0940542bdb 100644 --- a/tests/components/homeassistant/triggers/test_numeric_state.py +++ b/tests/components/homeassistant/triggers/test_numeric_state.py @@ -1044,27 +1044,23 @@ async def test_if_fails_setup_bad_for(hass, calls, above, below): hass.states.async_set("test.entity", 5) await hass.async_block_till_done() - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "numeric_state", - "entity_id": "test.entity", - "above": above, - "below": below, - "for": {"invalid": 5}, - }, - "action": {"service": "homeassistant.turn_on"}, - } - }, - ) - - with patch.object(numeric_state_trigger, "_LOGGER") as mock_logger: - hass.states.async_set("test.entity", 9) - await hass.async_block_till_done() - assert mock_logger.error.called + with assert_setup_component(0, automation.DOMAIN): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "numeric_state", + "entity_id": "test.entity", + "above": above, + "below": below, + "for": {"invalid": 5}, + }, + "action": {"service": "homeassistant.turn_on"}, + } + }, + ) async def test_if_fails_setup_for_without_above_below(hass, calls): diff --git a/tests/components/homeassistant/triggers/test_state.py b/tests/components/homeassistant/triggers/test_state.py index 2cd90d5054..26f02ce8d7 100644 --- a/tests/components/homeassistant/triggers/test_state.py +++ b/tests/components/homeassistant/triggers/test_state.py @@ -578,26 +578,22 @@ async def test_if_fails_setup_if_from_boolean_value(hass, calls): async def test_if_fails_setup_bad_for(hass, calls): """Test for setup failure for bad for.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "state", - "entity_id": "test.entity", - "to": "world", - "for": {"invalid": 5}, - }, - "action": {"service": "homeassistant.turn_on"}, - } - }, - ) - - with patch.object(state_trigger, "_LOGGER") as mock_logger: - hass.states.async_set("test.entity", "world") - await hass.async_block_till_done() - assert mock_logger.error.called + with assert_setup_component(0, automation.DOMAIN): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "state", + "entity_id": "test.entity", + "to": "world", + "for": {"invalid": 5}, + }, + "action": {"service": "homeassistant.turn_on"}, + } + }, + ) async def test_if_not_fires_on_entity_change_with_for(hass, calls): diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 364e4c9fb9..bb4f862711 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -1,5 +1,5 @@ """Test the condition helper.""" -from datetime import datetime +from datetime import datetime, timedelta from unittest.mock import AsyncMock, patch import pytest @@ -1125,6 +1125,81 @@ async def test_state_raises(hass: HomeAssistant) -> None: test(hass) +async def test_state_for(hass: HomeAssistant) -> None: + """Test state with duration.""" + config = { + "condition": "and", + "conditions": [ + { + "condition": "state", + "entity_id": ["sensor.temperature"], + "state": "100", + "for": {"seconds": 5}, + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + + hass.states.async_set("sensor.temperature", 100) + assert not test(hass) + + now = dt_util.utcnow() + timedelta(seconds=5) + with patch("homeassistant.util.dt.utcnow", return_value=now): + assert test(hass) + + +async def test_state_for_template(hass: HomeAssistant) -> None: + """Test state with templated duration.""" + config = { + "condition": "and", + "conditions": [ + { + "condition": "state", + "entity_id": ["sensor.temperature"], + "state": "100", + "for": {"seconds": "{{ states('input_number.test')|int }}"}, + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + + hass.states.async_set("sensor.temperature", 100) + hass.states.async_set("input_number.test", 5) + assert not test(hass) + + now = dt_util.utcnow() + timedelta(seconds=5) + with patch("homeassistant.util.dt.utcnow", return_value=now): + assert test(hass) + + +@pytest.mark.parametrize("for_template", [{"{{invalid}}": 5}, {"hours": "{{ 1/0 }}"}]) +async def test_state_for_invalid_template(hass: HomeAssistant, for_template) -> None: + """Test state with invalid templated duration.""" + config = { + "condition": "and", + "conditions": [ + { + "condition": "state", + "entity_id": ["sensor.temperature"], + "state": "100", + "for": for_template, + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + + hass.states.async_set("sensor.temperature", 100) + hass.states.async_set("input_number.test", 5) + with pytest.raises(ConditionError): + assert not test(hass) + + async def test_state_unknown_attribute(hass: HomeAssistant) -> None: """Test that state returns False on unknown attribute.""" # Unknown attribute diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 9e1c84151c..6823e9655b 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -1376,3 +1376,28 @@ def test_language() -> None: for value in ("en", "sv"): assert schema(value) + + +def test_positive_time_period_template() -> None: + """Test positive time period template validation.""" + schema = vol.Schema(cv.positive_time_period_template) + + with pytest.raises(vol.MultipleInvalid): + schema({}) + with pytest.raises(vol.MultipleInvalid): + schema({5: 5}) + with pytest.raises(vol.MultipleInvalid): + schema({"invalid": 5}) + with pytest.raises(vol.MultipleInvalid): + schema("invalid") + + # Time periods pass + schema("00:01") + schema("00:00:01") + schema("00:00:00.500") + schema({"minutes": 5}) + + # Templates are not evaluated and will pass + schema("{{ 'invalid' }}") + schema({"{{ 'invalid' }}": 5}) + schema({"minutes": "{{ 'invalid' }}"}) From ecf87ae979dec155c1b4e063c2b48585db47893a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Feb 2023 12:51:34 -0600 Subject: [PATCH 0240/2810] Improve performance of websocket_api dispatch (#88496) Co-authored-by: Paulus Schoutsen --- .../components/websocket_api/connection.py | 24 ++++++++++++------- .../websocket_api/test_connection.py | 6 ++++- tests/components/websocket_api/test_http.py | 2 +- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 3e8328c117..90c7e9906e 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -46,6 +46,7 @@ class ActiveConnection: self.subscriptions: dict[Hashable, Callable[[], Any]] = {} self.last_id = 0 self.supported_features: dict[str, float] = {} + self.handlers = self.hass.data[const.DOMAIN] current_connection.set(self) def get_description(self, request: web.Request | None) -> str: @@ -72,12 +73,17 @@ class ActiveConnection: @callback def async_handle(self, msg: dict[str, Any]) -> None: """Handle a single incoming message.""" - handlers = self.hass.data[const.DOMAIN] - - try: - msg = messages.MINIMAL_MESSAGE_SCHEMA(msg) - cur_id = msg["id"] - except vol.Invalid: + if ( + # Not using isinstance as we don't care about children + # as these are always coming from JSON + type(msg) is not dict # pylint: disable=unidiomatic-typecheck + or ( + not (cur_id := msg.get("id")) + or type(cur_id) is not int # pylint: disable=unidiomatic-typecheck + or not (type_ := msg.get("type")) + or type(type_) is not str # pylint: disable=unidiomatic-typecheck + ) + ): self.logger.error("Received invalid command", msg) self.send_message( messages.error_message( @@ -96,8 +102,8 @@ class ActiveConnection: ) return - if msg["type"] not in handlers: - self.logger.info("Received unknown command: {}".format(msg["type"])) + if not (handler_schema := self.handlers.get(type_)): + self.logger.info(f"Received unknown command: {type_}") self.send_message( messages.error_message( cur_id, const.ERR_UNKNOWN_COMMAND, "Unknown command." @@ -105,7 +111,7 @@ class ActiveConnection: ) return - handler, schema = handlers[msg["type"]] + handler, schema = handler_schema try: handler(self.hass, self, schema(msg)) diff --git a/tests/components/websocket_api/test_connection.py b/tests/components/websocket_api/test_connection.py index ce484939d8..53baab98b4 100644 --- a/tests/components/websocket_api/test_connection.py +++ b/tests/components/websocket_api/test_connection.py @@ -10,6 +10,8 @@ import voluptuous as vol from homeassistant import exceptions from homeassistant.components import websocket_api +from homeassistant.components.websocket_api.const import DOMAIN +from homeassistant.core import HomeAssistant from tests.common import MockUser @@ -56,6 +58,7 @@ from tests.common import MockUser ], ) async def test_exception_handling( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, exc: Exception, code: str, @@ -67,6 +70,7 @@ async def test_exception_handling( user = MockUser() refresh_token = Mock() current_request = AsyncMock() + hass.data[DOMAIN] = {} def get_extra_info(key: str) -> Any: if key == "sslcontext": @@ -89,7 +93,7 @@ async def test_exception_handling( ) as current_request: current_request.get.return_value = mocked_request conn = websocket_api.ActiveConnection( - logging.getLogger(__name__), None, send_messages.append, user, refresh_token + logging.getLogger(__name__), hass, send_messages.append, user, refresh_token ) conn.async_handle_exception({"id": 5}, exc) diff --git a/tests/components/websocket_api/test_http.py b/tests/components/websocket_api/test_http.py index e68a6d4c49..fce6eb428a 100644 --- a/tests/components/websocket_api/test_http.py +++ b/tests/components/websocket_api/test_http.py @@ -17,7 +17,7 @@ from tests.typing import WebSocketGenerator @pytest.fixture def mock_low_queue(): """Mock a low queue.""" - with patch("homeassistant.components.websocket_api.http.MAX_PENDING_MSG", 5): + with patch("homeassistant.components.websocket_api.http.MAX_PENDING_MSG", 1): yield From bee79e351e0690b89bf68f121ac0ca3459e7906c Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 20 Feb 2023 19:52:39 +0100 Subject: [PATCH 0241/2810] Allow translation for data entry submit button (#88499) --- homeassistant/components/demo/strings.json | 3 ++- script/hassfest/translations.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/demo/strings.json b/homeassistant/components/demo/strings.json index 286bd5d093..cdbe8dc1bd 100644 --- a/homeassistant/components/demo/strings.json +++ b/homeassistant/components/demo/strings.json @@ -51,7 +51,8 @@ "constant": "Constant", "bool": "Optional boolean", "int": "Numeric input" - } + }, + "submit": "Save!" }, "options_2": { "data": { diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index be73692cb2..5233911111 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -135,6 +135,7 @@ def gen_data_entry_schema( vol.Optional("data"): {str: cv.string_with_no_html}, vol.Optional("data_description"): {str: cv.string_with_no_html}, vol.Optional("menu_options"): {str: cv.string_with_no_html}, + vol.Optional("submit"): cv.string_with_no_html, } }, vol.Optional("error"): {str: cv.string_with_no_html}, From fa2e7aa59262a5a65bc095fbcccd54b44c15bae7 Mon Sep 17 00:00:00 2001 From: Carlos Gustavo Sarmiento Date: Mon, 20 Feb 2023 14:57:58 -0600 Subject: [PATCH 0242/2810] Add `state_class = MEASUREMENT` to Derivative sensor (#88408) * Add `state_class = MEASUREMENT` to Derivative sensor One line change per title. * Update sensor.py * Update tests for Derivative Sensor --- homeassistant/components/derivative/sensor.py | 7 ++++++- tests/components/derivative/test_sensor.py | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index adf91eb706..a8cacc0e20 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -8,7 +8,11 @@ from typing import TYPE_CHECKING import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, @@ -131,6 +135,7 @@ 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 c1541812d1..9c2f68fd68 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -4,6 +4,7 @@ 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 @@ -78,6 +79,7 @@ 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 35adb2f7e8fe1edfc80bd699ec675cbbc58e65b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Feb 2023 15:14:01 -0600 Subject: [PATCH 0243/2810] Remove previously deprecated Detected Object from unifiprotect (#85656) --- .../components/unifiprotect/__init__.py | 2 - .../components/unifiprotect/repairs.py | 76 +-------- .../components/unifiprotect/sensor.py | 9 - tests/components/unifiprotect/__init__.py | 3 + tests/components/unifiprotect/test_repairs.py | 158 +----------------- tests/components/unifiprotect/test_sensor.py | 80 +-------- 6 files changed, 15 insertions(+), 313 deletions(-) diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index 1e9f168820..4e659d39cc 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -27,7 +27,6 @@ from .const import ( from .data import ProtectData, async_ufp_instance_for_config_entry_ids from .discovery import async_start_discovery from .migrate import async_migrate_data -from .repairs import async_create_repairs from .services import async_cleanup_services, async_setup_services from .utils import ( _async_unifi_mac_from_hass, @@ -122,7 +121,6 @@ async def _async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, data_service: ProtectData ) -> None: await async_migrate_data(hass, entry, data_service.api) - await async_create_repairs(hass, entry, data_service.api) await data_service.async_setup() if not data_service.last_update_success: diff --git a/homeassistant/components/unifiprotect/repairs.py b/homeassistant/components/unifiprotect/repairs.py index 72f297c8c2..49473744d0 100644 --- a/homeassistant/components/unifiprotect/repairs.py +++ b/homeassistant/components/unifiprotect/repairs.py @@ -2,94 +2,24 @@ from __future__ import annotations -from functools import partial -from itertools import chain import logging -from typing import Any, cast +from typing import cast from pyunifiprotect import ProtectApiClient import voluptuous as vol from homeassistant import data_entry_flow -from homeassistant.components.automation import ( - EVENT_AUTOMATION_RELOADED, - automations_with_entity, -) from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow -from homeassistant.components.script import scripts_with_entity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er, issue_registry as ir -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_get as async_get_issue_registry, -) +from homeassistant.helpers.issue_registry import async_get as async_get_issue_registry -from .const import CONF_ALLOW_EA, DOMAIN +from .const import CONF_ALLOW_EA from .utils import async_create_api_client _LOGGER = logging.getLogger(__name__) -async def async_create_repairs( - hass: HomeAssistant, entry: ConfigEntry, protect: ProtectApiClient -) -> None: - """Create any additional repairs for deprecations.""" - - await _deprecate_smart_sensor(hass, entry, protect) - entry.async_on_unload( - hass.bus.async_listen( - EVENT_AUTOMATION_RELOADED, - partial(_deprecate_smart_sensor, hass, entry, protect), - ) - ) - - -async def _deprecate_smart_sensor( - hass: HomeAssistant, - entry: ConfigEntry, - protect: ProtectApiClient, - *args: Any, - **kwargs: Any, -) -> None: - entity_registry = er.async_get(hass) - automations: dict[str, list[str]] = {} - scripts: dict[str, list[str]] = {} - for entity in er.async_entries_for_config_entry(entity_registry, entry.entry_id): - if ( - entity.domain == Platform.SENSOR - and entity.disabled_by is None - and "detected_object" in entity.unique_id - ): - entity_automations = automations_with_entity(hass, entity.entity_id) - entity_scripts = scripts_with_entity(hass, entity.entity_id) - if entity_automations: - automations[entity.entity_id] = entity_automations - if entity_scripts: - scripts[entity.entity_id] = entity_scripts - - if automations or scripts: - items = sorted( - set( - chain.from_iterable(list(automations.values()) + list(scripts.values())) - ) - ) - ir.async_create_issue( - hass, - DOMAIN, - "deprecate_smart_sensor", - is_fixable=False, - breaks_in_ha_version="2023.3.0", - severity=IssueSeverity.WARNING, - translation_key="deprecate_smart_sensor", - translation_placeholders={"items": "* `" + "`\n* `".join(items) + "`\n"}, - ) - else: - _LOGGER.debug("No found usages of Detected Object sensor") - ir.async_delete_issue(hass, DOMAIN, "deprecate_smart_sensor") - - class EAConfirm(RepairsFlow): """Handler for an issue fixing flow.""" diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 7a31fffee4..5b17ed0020 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -53,7 +53,6 @@ from .utils import async_dispatch_id as _ufpd, async_get_light_motion_current _LOGGER = logging.getLogger(__name__) OBJECT_TYPE_NONE = "none" -DEVICE_CLASS_DETECTION = "unifiprotect__detection" @dataclass @@ -524,14 +523,6 @@ NVR_DISABLED_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ) EVENT_SENSORS: tuple[ProtectSensorEventEntityDescription, ...] = ( - ProtectSensorEventEntityDescription( - key="detected_object", - name="Detected Object", - device_class=DEVICE_CLASS_DETECTION, - entity_registry_enabled_default=False, - ufp_value="is_smart_detected", - ufp_event_obj="last_smart_detect_event", - ), ProtectSensorEventEntityDescription( key="smart_obj_licenseplate", name="License Plate Detected", diff --git a/tests/components/unifiprotect/__init__.py b/tests/components/unifiprotect/__init__.py index 1bbe9fb435..cc59bca350 100644 --- a/tests/components/unifiprotect/__init__.py +++ b/tests/components/unifiprotect/__init__.py @@ -3,6 +3,7 @@ from contextlib import contextmanager from unittest.mock import AsyncMock, MagicMock, patch +import pytest from unifi_discovery import AIOUnifiScanner, UnifiDevice, UnifiService DEVICE_HOSTNAME = "unvr" @@ -27,6 +28,8 @@ UNIFI_DISCOVERY_PARTIAL = UnifiDevice( services={UnifiService.Protect: True}, ) +pytest.register_assert_rewrite("tests.components.unifiprotect.utils") + def _patch_discovery(device=None, no_device=False): mock_aio_discovery = MagicMock(auto_spec=AIOUnifiScanner) diff --git a/tests/components/unifiprotect/test_repairs.py b/tests/components/unifiprotect/test_repairs.py index 829e695ea2..b9fa9bc57b 100644 --- a/tests/components/unifiprotect/test_repairs.py +++ b/tests/components/unifiprotect/test_repairs.py @@ -3,11 +3,10 @@ from __future__ import annotations from copy import copy from http import HTTPStatus -from unittest.mock import Mock, patch +from unittest.mock import Mock from pyunifiprotect.data import Camera, Version -from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN from homeassistant.components.repairs.issue_handler import ( async_process_repairs_platforms, ) @@ -15,12 +14,10 @@ from homeassistant.components.repairs.websocket_api import ( RepairsFlowIndexView, RepairsFlowResourceView, ) -from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN from homeassistant.components.unifiprotect.const import DOMAIN -from homeassistant.const import SERVICE_RELOAD, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component from .utils import MockUFPFixture, init_entry @@ -186,154 +183,3 @@ async def test_deprecate_smart_no_automations( if i["issue_id"] == "deprecate_smart_sensor": issue = i assert issue is None - - -async def _load_automation(hass: HomeAssistant, entity_id: str): - assert await async_setup_component( - hass, - AUTOMATION_DOMAIN, - { - AUTOMATION_DOMAIN: [ - { - "alias": "test1", - "trigger": [ - {"platform": "state", "entity_id": entity_id}, - { - "platform": "event", - "event_type": "state_changed", - "event_data": {"entity_id": entity_id}, - }, - ], - "condition": { - "condition": "state", - "entity_id": entity_id, - "state": "on", - }, - "action": [ - { - "service": "test.script", - "data": {"entity_id": entity_id}, - }, - ], - }, - ] - }, - ) - - -async def test_deprecate_smart_automation( - hass: HomeAssistant, - ufp: MockUFPFixture, - hass_ws_client: WebSocketGenerator, - doorbell: Camera, -) -> None: - """Test Deprecate Sensor repair exists for existing installs.""" - - registry = er.async_get(hass) - entry = registry.async_get_or_create( - Platform.SENSOR, - DOMAIN, - f"{doorbell.mac}_detected_object", - config_entry=ufp.entry, - ) - await _load_automation(hass, entry.entity_id) - await init_entry(hass, ufp, [doorbell]) - - await async_process_repairs_platforms(hass) - ws_client = await hass_ws_client(hass) - - await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) - msg = await ws_client.receive_json() - - assert msg["success"] - issue = None - for i in msg["result"]["issues"]: - if i["issue_id"] == "deprecate_smart_sensor": - issue = i - assert issue is not None - - with patch( - "homeassistant.config.load_yaml_config_file", - autospec=True, - return_value={AUTOMATION_DOMAIN: []}, - ): - await hass.services.async_call(AUTOMATION_DOMAIN, SERVICE_RELOAD, blocking=True) - await hass.async_block_till_done() - - await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) - msg = await ws_client.receive_json() - - assert msg["success"] - issue = None - for i in msg["result"]["issues"]: - if i["issue_id"] == "deprecate_smart_sensor": - issue = i - assert issue is None - - -async def _load_script(hass: HomeAssistant, entity_id: str): - assert await async_setup_component( - hass, - SCRIPT_DOMAIN, - { - SCRIPT_DOMAIN: { - "test": { - "sequence": { - "service": "test.script", - "data": {"entity_id": entity_id}, - } - } - }, - }, - ) - - -async def test_deprecate_smart_script( - hass: HomeAssistant, - ufp: MockUFPFixture, - hass_ws_client: WebSocketGenerator, - doorbell: Camera, -) -> None: - """Test Deprecate Sensor repair exists for existing installs.""" - - registry = er.async_get(hass) - entry = registry.async_get_or_create( - Platform.SENSOR, - DOMAIN, - f"{doorbell.mac}_detected_object", - config_entry=ufp.entry, - ) - await _load_script(hass, entry.entity_id) - await init_entry(hass, ufp, [doorbell]) - - await async_process_repairs_platforms(hass) - ws_client = await hass_ws_client(hass) - - await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) - msg = await ws_client.receive_json() - - assert msg["success"] - issue = None - for i in msg["result"]["issues"]: - if i["issue_id"] == "deprecate_smart_sensor": - issue = i - assert issue is not None - - with patch( - "homeassistant.config.load_yaml_config_file", - autospec=True, - return_value={SCRIPT_DOMAIN: {}}, - ): - await hass.services.async_call(SCRIPT_DOMAIN, SERVICE_RELOAD, blocking=True) - await hass.config_entries.async_reload(ufp.entry.entry_id) - await hass.async_block_till_done() - - await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) - msg = await ws_client.receive_json() - - assert msg["success"] - issue = None - for i in msg["result"]["issues"]: - if i["issue_id"] == "deprecate_smart_sensor": - issue = i - assert issue is None diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index 2daa44699b..0d763e6f90 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -15,10 +15,7 @@ from pyunifiprotect.data import ( ) from pyunifiprotect.data.nvr import EventMetadata, LicensePlateMetadata -from homeassistant.components.unifiprotect.const import ( - ATTR_EVENT_SCORE, - DEFAULT_ATTRIBUTION, -) +from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION from homeassistant.components.unifiprotect.sensor import ( ALL_DEVICES_SENSORS, CAMERA_DISABLED_SENSORS, @@ -27,7 +24,6 @@ from homeassistant.components.unifiprotect.sensor import ( MOTION_TRIP_SENSORS, NVR_DISABLED_SENSORS, NVR_SENSORS, - OBJECT_TYPE_NONE, SENSE_SENSORS, ) from homeassistant.const import ( @@ -62,11 +58,11 @@ async def test_sensor_camera_remove( ufp.api.bootstrap.nvr.system_info.ustorage = None await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.SENSOR, 25, 12) + assert_entity_counts(hass, Platform.SENSOR, 24, 12) await remove_entities(hass, ufp, [doorbell, unadopted_camera]) assert_entity_counts(hass, Platform.SENSOR, 12, 9) await adopt_devices(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.SENSOR, 25, 12) + assert_entity_counts(hass, Platform.SENSOR, 24, 12) async def test_sensor_sensor_remove( @@ -320,7 +316,7 @@ async def test_sensor_setup_camera( """Test sensor entity setup for camera devices.""" await init_entry(hass, ufp, [doorbell]) - assert_entity_counts(hass, Platform.SENSOR, 25, 12) + assert_entity_counts(hass, Platform.SENSOR, 24, 12) entity_registry = er.async_get(hass) @@ -399,22 +395,6 @@ async def test_sensor_setup_camera( assert state.state == "-50" assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - # Detected Object - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, doorbell, EVENT_SENSORS[0] - ) - - entity = entity_registry.async_get(entity_id) - assert entity - assert entity.unique_id == unique_id - - await enable_entity(hass, ufp.entry.entry_id, entity_id) - - state = hass.states.get(entity_id) - assert state - assert state.state == OBJECT_TYPE_NONE - assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - async def test_sensor_setup_camera_with_last_trip_time( hass: HomeAssistant, @@ -426,7 +406,7 @@ async def test_sensor_setup_camera_with_last_trip_time( """Test sensor entity setup for camera devices with last trip time.""" await init_entry(hass, ufp, [doorbell]) - assert_entity_counts(hass, Platform.SENSOR, 25, 25) + assert_entity_counts(hass, Platform.SENSOR, 24, 24) entity_registry = er.async_get(hass) @@ -448,52 +428,6 @@ async def test_sensor_setup_camera_with_last_trip_time( assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION -async def test_sensor_update_motion( - hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera, fixed_now: datetime -) -> None: - """Test sensor motion entity.""" - - await init_entry(hass, ufp, [doorbell]) - assert_entity_counts(hass, Platform.SENSOR, 25, 12) - - _, entity_id = ids_from_device_description( - Platform.SENSOR, doorbell, EVENT_SENSORS[0] - ) - - await enable_entity(hass, ufp.entry.entry_id, entity_id) - - event = Event( - id="test_event_id", - type=EventType.SMART_DETECT, - start=fixed_now - timedelta(seconds=1), - end=None, - score=100, - smart_detect_types=[SmartDetectObjectType.PERSON], - smart_detect_event_ids=[], - camera_id=doorbell.id, - api=ufp.api, - ) - - new_camera = doorbell.copy() - new_camera.is_smart_detected = True - new_camera.last_smart_detect_event_id = event.id - - mock_msg = Mock() - mock_msg.changed_data = {} - mock_msg.new_obj = event - - ufp.api.bootstrap.cameras = {new_camera.id: new_camera} - ufp.api.bootstrap.events = {event.id: event} - ufp.ws_msg(mock_msg) - await hass.async_block_till_done() - - state = hass.states.get(entity_id) - assert state - assert state.state == SmartDetectObjectType.PERSON.value - assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - assert state.attributes[ATTR_EVENT_SCORE] == 100 - - async def test_sensor_update_alarm( hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor, fixed_now: datetime ) -> None: @@ -581,10 +515,10 @@ async def test_camera_update_licenseplate( ) await init_entry(hass, ufp, [camera]) - assert_entity_counts(hass, Platform.SENSOR, 24, 13) + assert_entity_counts(hass, Platform.SENSOR, 23, 13) _, entity_id = ids_from_device_description( - Platform.SENSOR, camera, EVENT_SENSORS[1] + Platform.SENSOR, camera, EVENT_SENSORS[0] ) event_metadata = EventMetadata( From 30ebc6604f0e7bb6edfc71eea0026e81fc7a5e6e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 20 Feb 2023 22:14:41 +0100 Subject: [PATCH 0244/2810] Invert trigger._PLATFORM_ALIASES (#88503) --- homeassistant/helpers/trigger.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 1e364878f0..52c5ab0e15 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -37,8 +37,12 @@ if TYPE_CHECKING: ) _PLATFORM_ALIASES = { - "device_automation": ("device",), - "homeassistant": ("event", "numeric_state", "state", "time_pattern", "time"), + "device": "device_automation", + "event": "homeassistant", + "numeric_state": "homeassistant", + "state": "homeassistant", + "time_pattern": "homeassistant", + "time": "homeassistant", } DATA_PLUGGABLE_ACTIONS = "pluggable_actions" @@ -194,10 +198,7 @@ async def _async_get_trigger_platform( ) -> DeviceAutomationTriggerProtocol: platform_and_sub_type = config[CONF_PLATFORM].split(".") platform = platform_and_sub_type[0] - for alias, triggers in _PLATFORM_ALIASES.items(): - if platform in triggers: - platform = alias - break + platform = _PLATFORM_ALIASES.get(platform, platform) try: integration = await async_get_integration(hass, platform) except IntegrationNotFound: From 7a884a9d612a461ba4fc2e2f263949af5c430c77 Mon Sep 17 00:00:00 2001 From: Chris Xiao <30990835+chrisx8@users.noreply.github.com> Date: Mon, 20 Feb 2023 16:46:13 -0500 Subject: [PATCH 0245/2810] Only do unit conversion of `met` elevation when set in config entry (#88491) convert met elevation unit only when it exists in config entry Co-authored-by: Erik Montnemery --- homeassistant/components/met/__init__.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/met/__init__.py b/homeassistant/components/met/__init__.py index 5c7738f0de..2ccd456936 100644 --- a/homeassistant/components/met/__init__.py +++ b/homeassistant/components/met/__init__.py @@ -149,7 +149,7 @@ class MetWeatherData: self._coordinates: dict[str, str] | None = None def set_coordinates(self) -> bool: - """Weather data inialization - set the coordinates.""" + """Weather data initialization - set the coordinates.""" if self._config.get(CONF_TRACK_HOME, False): latitude = self.hass.config.latitude longitude = self.hass.config.longitude @@ -158,15 +158,14 @@ class MetWeatherData: latitude = self._config[CONF_LATITUDE] longitude = self._config[CONF_LONGITUDE] elevation = self._config[CONF_ELEVATION] - - if not self._is_metric: - elevation = int( - round( - DistanceConverter.convert( - elevation, UnitOfLength.FEET, UnitOfLength.METERS + if not self._is_metric: + elevation = int( + round( + DistanceConverter.convert( + elevation, UnitOfLength.FEET, UnitOfLength.METERS + ) ) ) - ) coordinates = { "lat": str(latitude), From 2a1e046d46c426f6bca466ed3d89a1410b33ea5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vincent=20B=C3=A9dard?= Date: Mon, 20 Feb 2023 16:54:03 -0500 Subject: [PATCH 0246/2810] Increase the maximum value for the transition parameter in the service hue.activate_scene (#88414) * Increase hue.activate_scene max transition * Adjust the maximum transition (hue.activate_scene) --- homeassistant/components/hue/scene.py | 2 +- homeassistant/components/hue/services.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hue/scene.py b/homeassistant/components/hue/scene.py index 8d4ca5d724..abf3e5412e 100644 --- a/homeassistant/components/hue/scene.py +++ b/homeassistant/components/hue/scene.py @@ -71,7 +71,7 @@ async def async_setup_entry( vol.Coerce(int), vol.Range(min=0, max=100) ), vol.Optional(ATTR_TRANSITION): vol.All( - vol.Coerce(float), vol.Range(min=0, max=600) + vol.Coerce(float), vol.Range(min=0, max=3600) ), vol.Optional(ATTR_BRIGHTNESS): vol.All( vol.Coerce(int), vol.Range(min=1, max=255) diff --git a/homeassistant/components/hue/services.yaml b/homeassistant/components/hue/services.yaml index 790100373f..b06c393415 100644 --- a/homeassistant/components/hue/services.yaml +++ b/homeassistant/components/hue/services.yaml @@ -39,7 +39,7 @@ activate_scene: selector: number: min: 0 - max: 300 + max: 3600 unit_of_measurement: seconds dynamic: name: Dynamic From d8f47c0e8c40e648d0524581804e556d7f6474d2 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Mon, 20 Feb 2023 22:58:57 +0100 Subject: [PATCH 0247/2810] Bump Axis to v47 (catch lingering tasks) (#88508) Bump Axis to v47 Primary fix is to catch lingering tasks from create_task https://github.com/Kane610/axis/compare/v46...v47 --- homeassistant/components/axis/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index 7b4e9be030..b8cd005a26 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -26,7 +26,7 @@ "iot_class": "local_push", "loggers": ["axis"], "quality_scale": "platinum", - "requirements": ["axis==46"], + "requirements": ["axis==47"], "ssdp": [ { "manufacturer": "AXIS" diff --git a/requirements_all.txt b/requirements_all.txt index 1da11ae9ea..055d704eaa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -395,7 +395,7 @@ aurorapy==0.2.7 # avion==0.10 # homeassistant.components.axis -axis==46 +axis==47 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b5edd509cd..16823e4d03 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -340,7 +340,7 @@ auroranoaa==0.0.2 aurorapy==0.2.7 # homeassistant.components.axis -axis==46 +axis==47 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 From 8e7081a8ce6e27622bcf4182359905af271a81b7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Feb 2023 16:25:13 -0600 Subject: [PATCH 0248/2810] Bump pySwitchbot to 0.37.3 (#88512) --- homeassistant/components/switchbot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 16d4e1cccd..2637f578b8 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -40,5 +40,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.37.1"] + "requirements": ["PySwitchbot==0.37.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 055d704eaa..14c50f7d06 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -40,7 +40,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.37.1 +PySwitchbot==0.37.3 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 16823e4d03..887f4f4b25 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -36,7 +36,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.37.1 +PySwitchbot==0.37.3 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 From 4f4defc0558fa3ca4c4b364e76bc84baa2bc7856 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Feb 2023 16:25:50 -0600 Subject: [PATCH 0249/2810] Bump yalexs-ble to 2.0.2 (#88513) --- 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 e8b00ac921..64d24504ef 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.1"] + "requirements": ["yalexs==1.2.7", "yalexs_ble==2.0.2"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 2953e33f19..1a817c8a52 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.1"] + "requirements": ["yalexs-ble==2.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 14c50f7d06..b8bed339eb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2664,13 +2664,13 @@ xs1-api-client==3.0.0 yalesmartalarmclient==0.3.9 # homeassistant.components.yalexs_ble -yalexs-ble==2.0.1 +yalexs-ble==2.0.2 # homeassistant.components.august yalexs==1.2.7 # homeassistant.components.august -yalexs_ble==2.0.1 +yalexs_ble==2.0.2 # homeassistant.components.yeelight yeelight==0.7.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 887f4f4b25..38df7c78f3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1886,13 +1886,13 @@ xmltodict==0.13.0 yalesmartalarmclient==0.3.9 # homeassistant.components.yalexs_ble -yalexs-ble==2.0.1 +yalexs-ble==2.0.2 # homeassistant.components.august yalexs==1.2.7 # homeassistant.components.august -yalexs_ble==2.0.1 +yalexs_ble==2.0.2 # homeassistant.components.yeelight yeelight==0.7.10 From d54858044cda6a785b07d997d125d51d16aa13ab Mon Sep 17 00:00:00 2001 From: Kevin Worrel <37058192+dieselrabbit@users.noreply.github.com> Date: Mon, 20 Feb 2023 15:30:21 -0800 Subject: [PATCH 0250/2810] Disable circuits set as 'Don't Show' in ScreenLogic by default (#88518) --- homeassistant/components/screenlogic/manifest.json | 2 +- homeassistant/components/screenlogic/switch.py | 10 ++++++++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/screenlogic/manifest.json b/homeassistant/components/screenlogic/manifest.json index 0c2a427d7f..977ef59f9e 100644 --- a/homeassistant/components/screenlogic/manifest.json +++ b/homeassistant/components/screenlogic/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/screenlogic", "iot_class": "local_push", "loggers": ["screenlogicpy"], - "requirements": ["screenlogicpy==0.7.1"] + "requirements": ["screenlogicpy==0.7.2"] } diff --git a/homeassistant/components/screenlogic/switch.py b/homeassistant/components/screenlogic/switch.py index 35bd1edb84..96bced7086 100644 --- a/homeassistant/components/screenlogic/switch.py +++ b/homeassistant/components/screenlogic/switch.py @@ -1,7 +1,12 @@ """Support for a ScreenLogic 'circuit' switch.""" import logging -from screenlogicpy.const import CODE, DATA as SL_DATA, GENERIC_CIRCUIT_NAMES +from screenlogicpy.const import ( + CODE, + DATA as SL_DATA, + GENERIC_CIRCUIT_NAMES, + INTERFACE_GROUP, +) from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry @@ -31,7 +36,8 @@ async def async_setup_entry( coordinator, circuit_num, CODE.STATUS_CHANGED, - circuit["name"] not in GENERIC_CIRCUIT_NAMES, + circuit["name"] not in GENERIC_CIRCUIT_NAMES + and circuit["interface"] != INTERFACE_GROUP.DONT_SHOW, ) for circuit_num, circuit in circuits.items() if circuit["function"] not in LIGHT_CIRCUIT_FUNCTIONS diff --git a/requirements_all.txt b/requirements_all.txt index b8bed339eb..f3a6a0c32b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2300,7 +2300,7 @@ satel_integra==0.3.7 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.7.1 +screenlogicpy==0.7.2 # homeassistant.components.scsgate scsgate==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 38df7c78f3..3b80b4e162 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1621,7 +1621,7 @@ samsungtvws[async,encrypted]==2.5.0 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.7.1 +screenlogicpy==0.7.2 # homeassistant.components.backup securetar==2022.2.0 From f0af0e2b42c495571706313d8d6797c840efb046 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 21 Feb 2023 00:34:00 +0100 Subject: [PATCH 0251/2810] Bump pydeconz to v108 (catch lingering tasks) (#88510) --- homeassistant/components/deconz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index 05d4e81a4e..b0ad90857b 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["pydeconz"], "quality_scale": "platinum", - "requirements": ["pydeconz==107"], + "requirements": ["pydeconz==108"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/requirements_all.txt b/requirements_all.txt index f3a6a0c32b..87b6c769ef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1573,7 +1573,7 @@ pydaikin==2.9.0 pydanfossair==0.1.0 # homeassistant.components.deconz -pydeconz==107 +pydeconz==108 # homeassistant.components.delijn pydelijn==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3b80b4e162..3083cf875d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1134,7 +1134,7 @@ pycoolmasternet-async==0.1.5 pydaikin==2.9.0 # homeassistant.components.deconz -pydeconz==107 +pydeconz==108 # homeassistant.components.dexcom pydexcom==0.2.3 From 84763c793d883bf195e8e8d0253f89657b3e99e6 Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 20 Feb 2023 18:56:03 -0500 Subject: [PATCH 0252/2810] Support Ecobee climate Aux Heat on/off (#86100) Co-authored-by: J. Nick Koston --- homeassistant/components/ecobee/climate.py | 67 +++++++----- tests/components/ecobee/__init__.py | 118 +++++++++++++++++++++ tests/components/ecobee/common.py | 2 +- tests/components/ecobee/conftest.py | 16 +++ tests/components/ecobee/test_climate.py | 103 ++++++++++++++++-- 5 files changed, 272 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index 2c2225ff9a..7925832953 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -35,6 +35,7 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_conversion import TemperatureConverter +from . import EcobeeData from .const import _LOGGER, DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER from .util import ecobee_date, ecobee_time @@ -61,11 +62,14 @@ PRESET_HOLD_INDEFINITE = "indefinite" AWAY_MODE = "awayMode" PRESET_HOME = "home" PRESET_SLEEP = "sleep" +HAS_HEAT_PUMP = "hasHeatPump" DEFAULT_MIN_HUMIDITY = 15 DEFAULT_MAX_HUMIDITY = 50 HUMIDIFIER_MANUAL_MODE = "manual" +ECOBEE_AUX_HEAT_ONLY = "auxHeatOnly" + # Order matters, because for reverse mapping we don't want to map HEAT to AUX ECOBEE_HVAC_TO_HASS = collections.OrderedDict( @@ -161,7 +165,6 @@ SET_FAN_MIN_ON_TIME_SCHEMA = vol.Schema( SUPPORT_FLAGS = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE - | ClimateEntityFeature.AUX_HEAT | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.FAN_MODE ) @@ -308,7 +311,9 @@ class Thermostat(ClimateEntity): _attr_precision = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT - def __init__(self, data, thermostat_index, thermostat): + def __init__( + self, data: EcobeeData, thermostat_index: int, thermostat: dict + ) -> None: """Initialize the thermostat.""" self.data = data self.thermostat_index = thermostat_index @@ -318,12 +323,9 @@ class Thermostat(ClimateEntity): self._last_active_hvac_mode = HVACMode.HEAT_COOL self._operation_list = [] - if ( - self.thermostat["settings"]["heatStages"] - or self.thermostat["settings"]["hasHeatPump"] - ): + if self.settings["heatStages"] or self.settings["hasHeatPump"]: self._operation_list.append(HVACMode.HEAT) - if self.thermostat["settings"]["coolStages"]: + if self.settings["coolStages"]: self._operation_list.append(HVACMode.COOL) if len(self._operation_list) == 2: self._operation_list.insert(0, HVACMode.HEAT_COOL) @@ -355,9 +357,12 @@ class Thermostat(ClimateEntity): @property def supported_features(self) -> ClimateEntityFeature: """Return the list of supported features.""" + supported = SUPPORT_FLAGS if self.has_humidifier_control: - return SUPPORT_FLAGS | ClimateEntityFeature.TARGET_HUMIDITY - return SUPPORT_FLAGS + supported = supported | ClimateEntityFeature.TARGET_HUMIDITY + if self.has_aux_heat: + supported = supported | ClimateEntityFeature.AUX_HEAT + return supported @property def name(self): @@ -411,13 +416,23 @@ class Thermostat(ClimateEntity): return PRECISION_HALVES @property - def has_humidifier_control(self): + def settings(self) -> dict[str, Any]: + """Return the settings of the thermostat.""" + return self.thermostat["settings"] + + @property + def has_humidifier_control(self) -> bool: """Return true if humidifier connected to thermostat and set to manual/on mode.""" return ( - self.thermostat["settings"]["hasHumidifier"] - and self.thermostat["settings"]["humidifierMode"] == HUMIDIFIER_MANUAL_MODE + bool(self.settings.get("hasHumidifier")) + and self.settings.get("humidifierMode") == HUMIDIFIER_MANUAL_MODE ) + @property + def has_aux_heat(self) -> bool: + """Return true if the ecobee has a heat pump.""" + return bool(self.settings.get(HAS_HEAT_PUMP)) + @property def target_humidity(self) -> int | None: """Return the desired humidity set point.""" @@ -489,7 +504,7 @@ class Thermostat(ClimateEntity): @property def hvac_mode(self): """Return current operation.""" - return ECOBEE_HVAC_TO_HASS[self.thermostat["settings"]["hvacMode"]] + return ECOBEE_HVAC_TO_HASS[self.settings["hvacMode"]] @property def hvac_modes(self): @@ -541,23 +556,25 @@ class Thermostat(ClimateEntity): self.thermostat["program"]["currentClimateRef"] ], "equipment_running": status, - "fan_min_on_time": self.thermostat["settings"]["fanMinOnTime"], + "fan_min_on_time": self.settings["fanMinOnTime"], } @property - def is_aux_heat(self): + def is_aux_heat(self) -> bool: """Return true if aux heater.""" - return "auxHeat" in self.thermostat["equipmentStatus"] + return self.settings["hvacMode"] == ECOBEE_AUX_HEAT_ONLY - async def async_turn_aux_heat_on(self) -> None: + def turn_aux_heat_on(self) -> None: """Turn auxiliary heater on.""" - if not self.is_aux_heat: - _LOGGER.warning("# Changing aux heat is not supported") + _LOGGER.debug("Setting HVAC mode to auxHeatOnly to turn on aux heat") + self.data.ecobee.set_hvac_mode(self.thermostat_index, ECOBEE_AUX_HEAT_ONLY) + self.update_without_throttle = True - async def async_turn_aux_heat_off(self) -> None: + def turn_aux_heat_off(self) -> None: """Turn auxiliary heater off.""" - if self.is_aux_heat: - _LOGGER.warning("# Changing aux heat is not supported") + _LOGGER.debug("Setting HVAC mode to last mode to disable aux heat") + self.set_hvac_mode(self._last_active_hvac_mode) + self.update_without_throttle = True def set_preset_mode(self, preset_mode: str) -> None: """Activate a preset.""" @@ -680,7 +697,7 @@ class Thermostat(ClimateEntity): heat_temp = temp cool_temp = temp else: - delta = self.thermostat["settings"]["heatCoolMinDelta"] / 10.0 + delta = self.settings["heatCoolMinDelta"] / 10.0 heat_temp = temp - delta cool_temp = temp + delta self.set_auto_temp_hold(heat_temp, cool_temp) @@ -739,7 +756,7 @@ class Thermostat(ClimateEntity): # "useEndTime2hour", "useEndTime4hour" # "nextPeriod", "askMe" # "indefinite" - device_preference = self.thermostat["settings"]["holdAction"] + device_preference = self.settings["holdAction"] # Currently supported pyecobee holdTypes: # dateTime, nextTransition, indefinite, holdHours hold_pref_map = { @@ -755,7 +772,7 @@ class Thermostat(ClimateEntity): # "useEndTime2hour", "useEndTime4hour" # "nextPeriod", "askMe" # "indefinite" - device_preference = self.thermostat["settings"]["holdAction"] + device_preference = self.settings["holdAction"] hold_hours_map = { "useEndTime2hour": 2, "useEndTime4hour": 4, diff --git a/tests/components/ecobee/__init__.py b/tests/components/ecobee/__init__.py index 389dc7101f..3dba80090d 100644 --- a/tests/components/ecobee/__init__.py +++ b/tests/components/ecobee/__init__.py @@ -1 +1,119 @@ """Tests for Ecobee integration.""" + +GENERIC_THERMOSTAT_INFO = { + "identifier": 8675309, + "name": "ecobee", + "modelNumber": "athenaSmart", + "program": { + "climates": [ + {"name": "Climate1", "climateRef": "c1"}, + {"name": "Climate2", "climateRef": "c2"}, + ], + "currentClimateRef": "c1", + }, + "runtime": { + "connected": True, + "actualTemperature": 300, + "actualHumidity": 15, + "desiredHeat": 400, + "desiredCool": 200, + "desiredFanMode": "on", + "desiredHumidity": 40, + }, + "settings": { + "hvacMode": "auto", + "heatStages": 1, + "coolStages": 1, + "fanMinOnTime": 10, + "heatCoolMinDelta": 50, + "holdAction": "nextTransition", + "hasHumidifier": False, + "humidifierMode": "manual", + "humidity": "30", + "ventilatorType": "none", + }, + "equipmentStatus": "fan", + "events": [ + { + "name": "Event1", + "running": True, + "type": "hold", + "holdClimateRef": "away", + "endDate": "2022-01-01 10:00:00", + "startDate": "2022-02-02 11:00:00", + } + ], + "remoteSensors": [ + { + "id": "rs:100", + "name": "Remote Sensor 1", + "type": "ecobee3_remote_sensor", + "code": "WKRP", + "inUse": False, + "capability": [ + {"id": "1", "type": "temperature", "value": "782"}, + {"id": "2", "type": "occupancy", "value": "false"}, + ], + } + ], +} + + +GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP = { + "identifier": 8675309, + "name": "ecobee", + "modelNumber": "athenaSmart", + "program": { + "climates": [ + {"name": "Climate1", "climateRef": "c1"}, + {"name": "Climate2", "climateRef": "c2"}, + ], + "currentClimateRef": "c1", + }, + "runtime": { + "connected": True, + "actualTemperature": 300, + "actualHumidity": 15, + "desiredHeat": 400, + "desiredCool": 200, + "desiredFanMode": "on", + "desiredHumidity": 40, + }, + "settings": { + "hvacMode": "auto", + "heatStages": 1, + "coolStages": 1, + "fanMinOnTime": 10, + "heatCoolMinDelta": 50, + "holdAction": "nextTransition", + "hasHumidifier": False, + "humidifierMode": "manual", + "humidity": "30", + "hasHeatPump": True, + "ventilatorType": "none", + }, + "equipmentStatus": "fan", + "events": [ + { + "name": "Event1", + "running": True, + "type": "hold", + "holdClimateRef": "away", + "endDate": "2022-01-01 10:00:00", + "startDate": "2022-02-02 11:00:00", + } + ], + "remoteSensors": [ + { + "id": "rs:100", + "name": "Remote Sensor 1", + "type": "ecobee3_remote_sensor", + "code": "WKRP", + "inUse": False, + "capability": [ + {"id": "1", "type": "temperature", "value": "782"}, + {"id": "2", "type": "occupancy", "value": "false"}, + ], + } + ], +} diff --git a/tests/components/ecobee/common.py b/tests/components/ecobee/common.py index 0422b35f78..ff9bc1b61f 100644 --- a/tests/components/ecobee/common.py +++ b/tests/components/ecobee/common.py @@ -8,7 +8,7 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -async def setup_platform(hass, platform): +async def setup_platform(hass, platform) -> MockConfigEntry: """Set up the ecobee platform.""" mock_entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/ecobee/conftest.py b/tests/components/ecobee/conftest.py index a7766af2ff..05700fa2e2 100644 --- a/tests/components/ecobee/conftest.py +++ b/tests/components/ecobee/conftest.py @@ -1,6 +1,10 @@ """Fixtures for tests.""" +from unittest.mock import MagicMock, patch + import pytest +from homeassistant.components.ecobee import ECOBEE_API_KEY, ECOBEE_REFRESH_TOKEN + from tests.common import load_fixture @@ -15,3 +19,15 @@ def requests_mock_fixture(requests_mock): "https://api.ecobee.com/token", text=load_fixture("ecobee/ecobee-token.json"), ) + + +@pytest.fixture +def mock_ecobee(): + """Mock an Ecobee object.""" + ecobee = MagicMock() + ecobee.request_pin.return_value = True + ecobee.refresh_tokens.return_value = True + + ecobee.config = {ECOBEE_API_KEY: "mocked_key", ECOBEE_REFRESH_TOKEN: "mocked_token"} + with patch("homeassistant.components.ecobee.Ecobee", return_value=ecobee): + yield ecobee diff --git a/tests/components/ecobee/test_climate.py b/tests/components/ecobee/test_climate.py index 1411a1f108..75722d68c0 100644 --- a/tests/components/ecobee/test_climate.py +++ b/tests/components/ecobee/test_climate.py @@ -1,12 +1,22 @@ """The test for the Ecobee thermostat module.""" +import copy from http import HTTPStatus from unittest import mock +from unittest.mock import MagicMock import pytest -from homeassistant.components.ecobee import climate as ecobee +from homeassistant.components import climate +from homeassistant.components.climate import ClimateEntityFeature +from homeassistant.components.ecobee.climate import ECOBEE_AUX_HEAT_ONLY, Thermostat import homeassistant.const as const -from homeassistant.const import STATE_OFF +from homeassistant.const import ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, STATE_OFF +from homeassistant.core import HomeAssistant + +from tests.components.ecobee import GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP +from tests.components.ecobee.common import setup_platform + +ENTITY_ID = "climate.ecobee" @pytest.fixture @@ -68,7 +78,7 @@ def data_fixture(ecobee_fixture): def thermostat_fixture(data): """Set up ecobee thermostat object.""" thermostat = data.ecobee.get_thermostat(1) - return ecobee.Thermostat(data, 1, thermostat) + return Thermostat(data, 1, thermostat) async def test_name(thermostat) -> None: @@ -76,6 +86,37 @@ async def test_name(thermostat) -> None: assert thermostat.name == "Ecobee" +async def test_aux_heat_not_supported_by_default(hass): + """Default setup should not support Aux heat.""" + await setup_platform(hass, const.Platform.CLIMATE) + state = hass.states.get(ENTITY_ID) + assert ( + state.attributes.get(ATTR_SUPPORTED_FEATURES) + == ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TARGET_HUMIDITY + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TARGET_TEMPERATURE + ) + + +async def test_aux_heat_supported_with_heat_pump(hass): + """Aux Heat should be supported if thermostat has heatpump.""" + mock_get_thermostat = mock.Mock() + mock_get_thermostat.return_value = GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP + with mock.patch("pyecobee.Ecobee.get_thermostat", mock_get_thermostat): + await setup_platform(hass, const.Platform.CLIMATE) + state = hass.states.get(ENTITY_ID) + assert ( + state.attributes.get(ATTR_SUPPORTED_FEATURES) + == ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.AUX_HEAT + ) + + async def test_current_temperature(ecobee_fixture, thermostat) -> None: """Test current temperature.""" assert thermostat.current_temperature == 30 @@ -201,11 +242,27 @@ async def test_extra_state_attributes(ecobee_fixture, thermostat) -> None: } == thermostat.extra_state_attributes -async def test_is_aux_heat_on(ecobee_fixture, thermostat) -> None: - """Test aux heat property.""" - assert not thermostat.is_aux_heat - ecobee_fixture["equipmentStatus"] = "fan, auxHeat" - assert thermostat.is_aux_heat +async def test_is_aux_heat_on(hass): + """Test aux heat property is only enabled for auxHeatOnly.""" + mock_get_thermostat = mock.Mock() + mock_get_thermostat.return_value = copy.deepcopy( + GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP + ) + mock_get_thermostat.return_value["settings"]["hvacMode"] = "auxHeatOnly" + with mock.patch("pyecobee.Ecobee.get_thermostat", mock_get_thermostat): + await setup_platform(hass, const.Platform.CLIMATE) + state = hass.states.get(ENTITY_ID) + assert state.attributes[climate.ATTR_AUX_HEAT] == "on" + + +async def test_is_aux_heat_off(hass): + """Test aux heat property is only enabled for auxHeatOnly.""" + mock_get_thermostat = mock.Mock() + mock_get_thermostat.return_value = GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP + with mock.patch("pyecobee.Ecobee.get_thermostat", mock_get_thermostat): + await setup_platform(hass, const.Platform.CLIMATE) + state = hass.states.get(ENTITY_ID) + assert state.attributes[climate.ATTR_AUX_HEAT] == "off" async def test_set_temperature(ecobee_fixture, thermostat, data) -> None: @@ -335,3 +392,33 @@ async def test_set_fan_mode_auto(thermostat, data) -> None: data.ecobee.set_fan_mode.assert_has_calls( [mock.call(1, "auto", "nextTransition", holdHours=None)] ) + + +async def test_turn_aux_heat_on(hass: HomeAssistant, mock_ecobee: MagicMock) -> None: + """Test when aux heat is set on. This must change the HVAC mode.""" + mock_ecobee.get_thermostat.return_value = GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP + mock_ecobee.thermostats = [GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP] + await setup_platform(hass, const.Platform.CLIMATE) + await hass.services.async_call( + climate.DOMAIN, + climate.SERVICE_SET_AUX_HEAT, + {ATTR_ENTITY_ID: ENTITY_ID, climate.ATTR_AUX_HEAT: True}, + blocking=True, + ) + assert mock_ecobee.set_hvac_mode.call_count == 1 + assert mock_ecobee.set_hvac_mode.call_args == mock.call(0, ECOBEE_AUX_HEAT_ONLY) + + +async def test_turn_aux_heat_off(hass: HomeAssistant, mock_ecobee: MagicMock) -> None: + """Test when aux heat is tuned off. Must change HVAC mode back to last used.""" + mock_ecobee.get_thermostat.return_value = GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP + mock_ecobee.thermostats = [GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP] + await setup_platform(hass, const.Platform.CLIMATE) + await hass.services.async_call( + climate.DOMAIN, + climate.SERVICE_SET_AUX_HEAT, + {ATTR_ENTITY_ID: ENTITY_ID, climate.ATTR_AUX_HEAT: False}, + blocking=True, + ) + assert mock_ecobee.set_hvac_mode.call_count == 1 + assert mock_ecobee.set_hvac_mode.call_args == mock.call(0, "auto") From 8722f5b42bf50af8a52b3b029d2642b029420b51 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 21 Feb 2023 01:15:55 -0500 Subject: [PATCH 0253/2810] Bump zwave-js-server-python to 0.46.0 (#88520) --- homeassistant/components/zwave_js/api.py | 4 ++-- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zwave_js/test_api.py | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index ba61de3bce..2f23d06b38 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -35,11 +35,11 @@ from zwave_js_server.model.controller import ( QRProvisioningInformation, ) from zwave_js_server.model.driver import Driver -from zwave_js_server.model.firmware import FirmwareUpdateData from zwave_js_server.model.log_config import LogConfig from zwave_js_server.model.log_message import LogMessage from zwave_js_server.model.node import Node, NodeStatistics from zwave_js_server.model.node.firmware import ( + NodeFirmwareUpdateData, NodeFirmwareUpdateProgress, NodeFirmwareUpdateResult, ) @@ -2072,7 +2072,7 @@ class FirmwareUploadView(HomeAssistantView): node.client.ws_server_url, node, [ - FirmwareUpdateData( + NodeFirmwareUpdateData( uploaded_file.filename, await hass.async_add_executor_job(uploaded_file.file.read), ) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index a1367e445e..a21f7a6f30 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -8,7 +8,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.45.2"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.46.0"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index 87b6c769ef..2ddd17b85c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2727,7 +2727,7 @@ zigpy==0.53.0 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.45.2 +zwave-js-server-python==0.46.0 # homeassistant.components.zwave_me zwave_me_ws==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3083cf875d..fbb8c8b0ca 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1931,7 +1931,7 @@ zigpy-znp==0.9.2 zigpy==0.53.0 # homeassistant.components.zwave_js -zwave-js-server-python==0.45.2 +zwave-js-server-python==0.46.0 # homeassistant.components.zwave_me zwave_me_ws==0.3.1 diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 1809695643..8b34b3548f 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -28,8 +28,8 @@ from zwave_js_server.model.controller import ( ProvisioningEntry, QRProvisioningInformation, ) -from zwave_js_server.model.firmware import FirmwareUpdateData from zwave_js_server.model.node import Node +from zwave_js_server.model.node.firmware import NodeFirmwareUpdateData from homeassistant.components.websocket_api import ERR_INVALID_FORMAT, ERR_NOT_FOUND from homeassistant.components.zwave_js.api import ( @@ -2915,7 +2915,7 @@ async def test_firmware_upload_view( ) assert mock_cmd.call_args[0][1:3] == ( multisensor_6, - [FirmwareUpdateData("file", bytes(10))], + [NodeFirmwareUpdateData("file", bytes(10))], ) assert mock_cmd.call_args[1] == { "additional_user_agent_components": {"HomeAssistant": "0.0.0"}, From 60ca3b32237e77f790d8c1089e1756b7393f6b74 Mon Sep 17 00:00:00 2001 From: mletenay Date: Tue, 21 Feb 2023 08:14:12 +0100 Subject: [PATCH 0254/2810] Extract goodwe data update coordinator to a separate module (#88396) * Extract coordinator to separate module * Make field protected and replace cast --- .coveragerc | 1 + homeassistant/components/goodwe/__init__.py | 42 +--------- .../components/goodwe/coordinator.py | 80 +++++++++++++++++++ homeassistant/components/goodwe/sensor.py | 48 ++++++----- 4 files changed, 107 insertions(+), 64 deletions(-) create mode 100644 homeassistant/components/goodwe/coordinator.py diff --git a/.coveragerc b/.coveragerc index 957ec8620c..52dfe08952 100644 --- a/.coveragerc +++ b/.coveragerc @@ -414,6 +414,7 @@ omit = homeassistant/components/goalfeed/* homeassistant/components/goodwe/__init__.py homeassistant/components/goodwe/button.py + homeassistant/components/goodwe/coordinator.py homeassistant/components/goodwe/number.py homeassistant/components/goodwe/select.py homeassistant/components/goodwe/sensor.py diff --git a/homeassistant/components/goodwe/__init__.py b/homeassistant/components/goodwe/__init__.py index a888752f50..b5872ed3de 100644 --- a/homeassistant/components/goodwe/__init__.py +++ b/homeassistant/components/goodwe/__init__.py @@ -1,14 +1,12 @@ """The Goodwe inverter component.""" -import logging -from goodwe import InverterError, RequestFailedException, connect +from goodwe import InverterError, connect from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( CONF_MODEL_FAMILY, @@ -17,16 +15,13 @@ from .const import ( KEY_DEVICE_INFO, KEY_INVERTER, PLATFORMS, - SCAN_INTERVAL, ) - -_LOGGER = logging.getLogger(__name__) +from .coordinator import GoodweUpdateCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the Goodwe components from a config entry.""" hass.data.setdefault(DOMAIN, {}) - name = entry.title host = entry.data[CONF_HOST] model_family = entry.data[CONF_MODEL_FAMILY] @@ -49,39 +44,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: sw_version=f"{inverter.firmware} / {inverter.arm_firmware}", ) - async def async_update_data(): - """Fetch data from the inverter.""" - try: - return await inverter.read_runtime_data() - except RequestFailedException as ex: - # UDP communication with inverter is by definition unreliable. - # It is rather normal in many environments to fail to receive - # proper response in usual time, so we intentionally ignore isolated - # failures and report problem with availability only after - # consecutive streak of 3 of failed requests. - if ex.consecutive_failures_count < 3: - _LOGGER.debug( - "No response received (streak of %d)", ex.consecutive_failures_count - ) - # return empty dictionary, sensors will keep their previous values - return {} - # Inverter does not respond anymore (e.g. it went to sleep mode) - _LOGGER.debug( - "Inverter not responding (streak of %d)", ex.consecutive_failures_count - ) - raise UpdateFailed(ex) from ex - except InverterError as ex: - raise UpdateFailed(ex) from ex - # Create update coordinator - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name=name, - update_method=async_update_data, - # Polling interval. Will only be polled if there are subscribers. - update_interval=SCAN_INTERVAL, - ) + coordinator = GoodweUpdateCoordinator(hass, entry, inverter) # Fetch initial data so we have data when entities subscribe await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/goodwe/coordinator.py b/homeassistant/components/goodwe/coordinator.py new file mode 100644 index 0000000000..0ae064e0e9 --- /dev/null +++ b/homeassistant/components/goodwe/coordinator.py @@ -0,0 +1,80 @@ +"""Update coordinator for Goodwe.""" +from __future__ import annotations + +import logging +from typing import Any + +from goodwe import Inverter, InverterError, RequestFailedException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class GoodweUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Gather data for the energy device.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + inverter: Inverter, + ) -> None: + """Initialize update coordinator.""" + super().__init__( + hass, + _LOGGER, + name=entry.title, + update_interval=SCAN_INTERVAL, + update_method=self._async_update_data, + ) + self.inverter: Inverter = inverter + self._last_data: dict[str, Any] = {} + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from the inverter.""" + try: + self._last_data = self.data if self.data else {} + return await self.inverter.read_runtime_data() + except RequestFailedException as ex: + # UDP communication with inverter is by definition unreliable. + # It is rather normal in many environments to fail to receive + # proper response in usual time, so we intentionally ignore isolated + # failures and report problem with availability only after + # consecutive streak of 3 of failed requests. + if ex.consecutive_failures_count < 3: + _LOGGER.debug( + "No response received (streak of %d)", ex.consecutive_failures_count + ) + # return last known data + return self._last_data + # Inverter does not respond anymore (e.g. it went to sleep mode) + _LOGGER.debug( + "Inverter not responding (streak of %d)", ex.consecutive_failures_count + ) + raise UpdateFailed(ex) from ex + except InverterError as ex: + raise UpdateFailed(ex) from ex + + def sensor_value(self, sensor: str) -> Any: + """Answer current (or last known) value of the sensor.""" + val = self.data.get(sensor) + return val if val is not None else self._last_data.get(sensor) + + def total_sensor_value(self, sensor: str) -> Any: + """Answer current value of the 'total' (never 0) sensor.""" + val = self.data.get(sensor) + return val if val else self._last_data.get(sensor) + + def reset_sensor(self, sensor: str) -> None: + """Reset sensor value to 0. + + Intended for "daily" cumulative sensors (e.g. PV energy produced today), + which should be explicitly reset to 0 at midnight if inverter is suspended. + """ + self._last_data[sensor] = 0 + self.data[sensor] = 0 diff --git a/homeassistant/components/goodwe/sensor.py b/homeassistant/components/goodwe/sensor.py index 2fbf56624c..b4adf97c3e 100644 --- a/homeassistant/components/goodwe/sensor.py +++ b/homeassistant/components/goodwe/sensor.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from datetime import date, datetime, timedelta from decimal import Decimal import logging -from typing import Any, cast +from typing import Any from goodwe import Inverter, Sensor, SensorKind @@ -32,13 +32,11 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_time from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity import homeassistant.util.dt as dt_util from .const import DOMAIN, KEY_COORDINATOR, KEY_DEVICE_INFO, KEY_INVERTER +from .coordinator import GoodweUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -78,10 +76,12 @@ _ICONS: dict[SensorKind, str] = { class GoodweSensorEntityDescription(SensorEntityDescription): """Class describing Goodwe sensor entities.""" - value: Callable[[Any, Any], Any] = lambda prev, val: val + value: Callable[ + [GoodweUpdateCoordinator, str], Any + ] = lambda coordinator, sensor: coordinator.sensor_value(sensor) available: Callable[ - [CoordinatorEntity], bool - ] = lambda entity: entity.coordinator.last_update_success + [GoodweUpdateCoordinator], bool + ] = lambda coordinator: coordinator.last_update_success _DESCRIPTIONS: dict[str, GoodweSensorEntityDescription] = { @@ -108,8 +108,8 @@ _DESCRIPTIONS: dict[str, GoodweSensorEntityDescription] = { device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda prev, val: val if val else prev, - available=lambda entity: entity.coordinator.data is not None, + value=lambda coordinator, sensor: coordinator.total_sensor_value(sensor), + available=lambda coordinator: coordinator.data is not None, ), "C": GoodweSensorEntityDescription( key="C", @@ -159,12 +159,14 @@ async def async_setup_entry( async_add_entities(entities) -class InverterSensor(CoordinatorEntity, SensorEntity): +class InverterSensor(CoordinatorEntity[GoodweUpdateCoordinator], SensorEntity): """Entity representing individual inverter sensor.""" + entity_description: GoodweSensorEntityDescription + def __init__( self, - coordinator: DataUpdateCoordinator, + coordinator: GoodweUpdateCoordinator, device_info: DeviceInfo, inverter: Inverter, sensor: Sensor, @@ -190,18 +192,12 @@ class InverterSensor(CoordinatorEntity, SensorEntity): if sensor.id_ == BATTERY_SOC: self._attr_device_class = SensorDeviceClass.BATTERY self._sensor = sensor - self._previous_value = None self._stop_reset: Callable[[], None] | None = None @property def native_value(self) -> StateType | date | datetime | Decimal: """Return the value reported by the sensor.""" - value = cast(GoodweSensorEntityDescription, self.entity_description).value( - self._previous_value, - self.coordinator.data.get(self._sensor.id_, self._previous_value), - ) - self._previous_value = value - return value + return self.entity_description.value(self.coordinator, self._sensor.id_) @property def available(self) -> bool: @@ -212,16 +208,18 @@ class InverterSensor(CoordinatorEntity, SensorEntity): as available even when the (non-battery) pv inverter is off-line during night and most of the sensors are actually unavailable. """ - return cast(GoodweSensorEntityDescription, self.entity_description).available( - self - ) + return self.entity_description.available(self.coordinator) @callback def async_reset(self, now): - """Reset the value back to 0 at midnight.""" + """Reset the value back to 0 at midnight. + + Some sensors values like daily produced energy are kept available, + even when the inverter is in sleep mode and no longer responds to request. + In contrast to "total" sensors, these "daily" sensors need to be reset to 0 on midnight. + """ if not self.coordinator.last_update_success: - self._previous_value = 0 - self.coordinator.data[self._sensor.id_] = 0 + self.coordinator.reset_sensor(self._sensor.id) self.async_write_ha_state() _LOGGER.debug("Goodwe reset %s to 0", self.name) next_midnight = dt_util.start_of_local_day( From ebb450c94698e2240b2fa49ce44655d247b32f99 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 21 Feb 2023 08:48:44 +0100 Subject: [PATCH 0255/2810] Default `hass_config` to contain an empty config (#88515) * Default hass_config to {} * Correct type hint --- pylint/plugins/hass_enforce_type_hints.py | 2 +- .../components/homeassistant/triggers/test_homeassistant.py | 2 +- tests/conftest.py | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 5ee0489e8c..41c718704b 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -109,7 +109,7 @@ _TEST_FIXTURES: dict[str, list[str] | str] = { "hass_admin_user": "MockUser", "hass_client": "ClientSessionGenerator", "hass_client_no_auth": "ClientSessionGenerator", - "hass_config": "ConfigType | None", + "hass_config": "ConfigType", "hass_config_yaml": "str | None", "hass_config_yaml_files": "dict[str, str] | None", "hass_owner_user": "MockUser", diff --git a/tests/components/homeassistant/triggers/test_homeassistant.py b/tests/components/homeassistant/triggers/test_homeassistant.py index 1bc28a92ba..ef57e1628d 100644 --- a/tests/components/homeassistant/triggers/test_homeassistant.py +++ b/tests/components/homeassistant/triggers/test_homeassistant.py @@ -27,7 +27,7 @@ from tests.common import async_mock_service ], ) async def test_if_fires_on_hass_start( - hass: HomeAssistant, mock_hass_config: None, hass_config: ConfigType | None + hass: HomeAssistant, mock_hass_config: None, hass_config: ConfigType ) -> None: """Test the firing when Home Assistant starts.""" calls = async_mock_service(hass, "test", "automation") diff --git a/tests/conftest.py b/tests/conftest.py index 2717edf702..356e2ba0d8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -920,19 +920,19 @@ async def _mqtt_mock_entry( @pytest.fixture -def hass_config() -> ConfigType | None: +def hass_config() -> ConfigType: """Fixture to parametrize the content of main configuration using mock_hass_config. To set a configuration, tests can be marked with: @pytest.mark.parametrize("hass_config", [{integration: {...}}]) Add the `mock_hass_config: None` fixture to the test. """ - return None + return {} @pytest.fixture def mock_hass_config( - hass: HomeAssistant, hass_config: ConfigType | None + hass: HomeAssistant, hass_config: ConfigType ) -> Generator[None, None, None]: """Fixture to mock the content of main configuration. From a102883effe5a5cac741a17a43e58927372ae587 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 21 Feb 2023 09:25:05 +0100 Subject: [PATCH 0256/2810] Add type hints to integration tests (part 2) (#88493) --- tests/components/apcupsd/test_init.py | 2 +- .../homeassistant/triggers/test_event.py | 40 ++-- .../triggers/test_homeassistant.py | 2 +- .../triggers/test_numeric_state.py | 190 ++++++++++++------ .../homeassistant/triggers/test_state.py | 144 ++++++++----- .../homeassistant/triggers/test_time.py | 29 +-- .../triggers/test_time_pattern.py | 23 ++- .../specific_devices/test_airversa_ap2.py | 4 +- .../specific_devices/test_anker_eufycam.py | 3 +- .../specific_devices/test_aqara_gateway.py | 5 +- .../specific_devices/test_aqara_switch.py | 4 +- .../specific_devices/test_arlo_baby.py | 4 +- .../specific_devices/test_connectsense.py | 4 +- .../specific_devices/test_ecobee3.py | 13 +- .../specific_devices/test_ecobee_501.py | 5 +- .../specific_devices/test_ecobee_occupancy.py | 3 +- .../specific_devices/test_eve_degree.py | 4 +- .../specific_devices/test_eve_energy.py | 4 +- .../specific_devices/test_haa_fan.py | 4 +- .../test_homeassistant_bridge.py | 4 +- .../specific_devices/test_hue_bridge.py | 4 +- .../specific_devices/test_koogeek_ls1.py | 6 +- .../specific_devices/test_koogeek_p1eu.py | 4 +- .../specific_devices/test_koogeek_sw2.py | 4 +- .../specific_devices/test_lennox_e30.py | 4 +- .../specific_devices/test_lg_tv.py | 4 +- .../test_lutron_caseta_bridge.py | 4 +- .../specific_devices/test_mss425f.py | 5 +- .../specific_devices/test_mss565.py | 5 +- .../specific_devices/test_mysa_living.py | 4 +- .../test_nanoleaf_strip_nl55.py | 4 +- .../specific_devices/test_netamo_doorbell.py | 3 +- .../test_netamo_smart_co_alarm.py | 3 +- .../test_netatmo_home_coach.py | 3 +- .../test_rainmachine_pro_8.py | 3 +- .../test_ryse_smart_bridge.py | 6 +- .../specific_devices/test_schlage_sense.py | 4 +- .../test_simpleconnect_fan.py | 4 +- .../specific_devices/test_velux_gateway.py | 4 +- .../test_vocolinc_flowerbud.py | 4 +- .../specific_devices/test_vocolinc_vp3.py | 4 +- .../shelly/bluetooth/test_scanner.py | 20 +- 42 files changed, 367 insertions(+), 230 deletions(-) diff --git a/tests/components/apcupsd/test_init.py b/tests/components/apcupsd/test_init.py index 8f2e7901c5..93cd817130 100644 --- a/tests/components/apcupsd/test_init.py +++ b/tests/components/apcupsd/test_init.py @@ -66,7 +66,7 @@ async def test_connection_error(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.SETUP_ERROR -async def test_unload_remove(hass): +async def test_unload_remove(hass: HomeAssistant) -> None: """Test successful unload of entry.""" # Load two integrations from two mock hosts. entries = ( diff --git a/tests/components/homeassistant/triggers/test_event.py b/tests/components/homeassistant/triggers/test_event.py index 8ca7caaa0a..6fc7e5055e 100644 --- a/tests/components/homeassistant/triggers/test_event.py +++ b/tests/components/homeassistant/triggers/test_event.py @@ -3,7 +3,7 @@ import pytest import homeassistant.components.automation as automation from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, SERVICE_TURN_OFF -from homeassistant.core import Context +from homeassistant.core import Context, HomeAssistant from homeassistant.setup import async_setup_component from tests.common import async_mock_service, mock_component @@ -27,7 +27,7 @@ def setup_comp(hass): mock_component(hass, "group") -async def test_if_fires_on_event(hass, calls): +async def test_if_fires_on_event(hass: HomeAssistant, calls) -> None: """Test the firing of events.""" context = Context() @@ -63,7 +63,7 @@ async def test_if_fires_on_event(hass, calls): assert calls[0].data["id"] == 0 -async def test_if_fires_on_templated_event(hass, calls): +async def test_if_fires_on_templated_event(hass: HomeAssistant, calls) -> None: """Test the firing of events.""" context = Context() @@ -96,7 +96,7 @@ async def test_if_fires_on_templated_event(hass, calls): assert len(calls) == 1 -async def test_if_fires_on_multiple_events(hass, calls): +async def test_if_fires_on_multiple_events(hass: HomeAssistant, calls) -> None: """Test the firing of events.""" context = Context() @@ -123,7 +123,9 @@ async def test_if_fires_on_multiple_events(hass, calls): assert calls[1].context.parent_id == context.id -async def test_if_fires_on_event_extra_data(hass, calls, context_with_user): +async def test_if_fires_on_event_extra_data( + hass: HomeAssistant, calls, context_with_user +) -> None: """Test the firing of events still matches with event data and context.""" assert await async_setup_component( hass, @@ -153,7 +155,9 @@ async def test_if_fires_on_event_extra_data(hass, calls, context_with_user): assert len(calls) == 1 -async def test_if_fires_on_event_with_data_and_context(hass, calls, context_with_user): +async def test_if_fires_on_event_with_data_and_context( + hass: HomeAssistant, calls, context_with_user +) -> None: """Test the firing of events with data and context.""" assert await async_setup_component( hass, @@ -199,8 +203,8 @@ async def test_if_fires_on_event_with_data_and_context(hass, calls, context_with async def test_if_fires_on_event_with_templated_data_and_context( - hass, calls, context_with_user -): + hass: HomeAssistant, calls, context_with_user +) -> None: """Test the firing of events with templated data and context.""" assert await async_setup_component( hass, @@ -251,8 +255,8 @@ async def test_if_fires_on_event_with_templated_data_and_context( async def test_if_fires_on_event_with_empty_data_and_context_config( - hass, calls, context_with_user -): + hass: HomeAssistant, calls, context_with_user +) -> None: """Test the firing of events with empty data and context config. The frontend automation editor can produce configurations with an @@ -283,7 +287,7 @@ async def test_if_fires_on_event_with_empty_data_and_context_config( assert len(calls) == 1 -async def test_if_fires_on_event_with_nested_data(hass, calls): +async def test_if_fires_on_event_with_nested_data(hass: HomeAssistant, calls) -> None: """Test the firing of events with nested data.""" assert await async_setup_component( hass, @@ -307,7 +311,9 @@ async def test_if_fires_on_event_with_nested_data(hass, calls): assert len(calls) == 1 -async def test_if_not_fires_if_event_data_not_matches(hass, calls): +async def test_if_not_fires_if_event_data_not_matches( + hass: HomeAssistant, calls +) -> None: """Test firing of event if no data match.""" assert await async_setup_component( hass, @@ -330,8 +336,8 @@ async def test_if_not_fires_if_event_data_not_matches(hass, calls): async def test_if_not_fires_if_event_context_not_matches( - hass, calls, context_with_user -): + hass: HomeAssistant, calls, context_with_user +) -> None: """Test firing of event if no context match.""" assert await async_setup_component( hass, @@ -353,7 +359,9 @@ async def test_if_not_fires_if_event_context_not_matches( assert len(calls) == 0 -async def test_if_fires_on_multiple_user_ids(hass, calls, context_with_user): +async def test_if_fires_on_multiple_user_ids( + hass: HomeAssistant, calls, context_with_user +) -> None: """Test the firing of event when the trigger has multiple user ids.""" assert await async_setup_component( hass, @@ -376,7 +384,7 @@ async def test_if_fires_on_multiple_user_ids(hass, calls, context_with_user): assert len(calls) == 1 -async def test_event_data_with_list(hass, calls): +async def test_event_data_with_list(hass: HomeAssistant, calls) -> None: """Test the (non)firing of event when the data schema has lists.""" assert await async_setup_component( hass, diff --git a/tests/components/homeassistant/triggers/test_homeassistant.py b/tests/components/homeassistant/triggers/test_homeassistant.py index ef57e1628d..6ac8291ee5 100644 --- a/tests/components/homeassistant/triggers/test_homeassistant.py +++ b/tests/components/homeassistant/triggers/test_homeassistant.py @@ -51,7 +51,7 @@ async def test_if_fires_on_hass_start( assert calls[0].data["id"] == 0 -async def test_if_fires_on_hass_shutdown(hass): +async def test_if_fires_on_hass_shutdown(hass: HomeAssistant) -> None: """Test the firing when Home Assistant shuts down.""" calls = async_mock_service(hass, "test", "automation") hass.state = CoreState.not_running diff --git a/tests/components/homeassistant/triggers/test_numeric_state.py b/tests/components/homeassistant/triggers/test_numeric_state.py index 0940542bdb..ff82fa3e69 100644 --- a/tests/components/homeassistant/triggers/test_numeric_state.py +++ b/tests/components/homeassistant/triggers/test_numeric_state.py @@ -11,7 +11,7 @@ from homeassistant.components.homeassistant.triggers import ( numeric_state as numeric_state_trigger, ) from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, SERVICE_TURN_OFF -from homeassistant.core import Context +from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -55,7 +55,9 @@ async def setup_comp(hass): @pytest.mark.parametrize( "below", (10, "input_number.value_10", "number.value_10", "sensor.value_10") ) -async def test_if_not_fires_on_entity_removal(hass, calls, below): +async def test_if_not_fires_on_entity_removal( + hass: HomeAssistant, calls, below +) -> None: """Test the firing with removed entity.""" hass.states.async_set("test.entity", 11) @@ -83,7 +85,9 @@ async def test_if_not_fires_on_entity_removal(hass, calls, below): @pytest.mark.parametrize( "below", (10, "input_number.value_10", "number.value_10", "sensor.value_10") ) -async def test_if_fires_on_entity_change_below(hass, calls, below): +async def test_if_fires_on_entity_change_below( + hass: HomeAssistant, calls, below +) -> None: """Test the firing with changed entity.""" hass.states.async_set("test.entity", 11) await hass.async_block_till_done() @@ -130,7 +134,9 @@ async def test_if_fires_on_entity_change_below(hass, calls, below): @pytest.mark.parametrize( "below", (10, "input_number.value_10", "number.value_10", "sensor.value_10") ) -async def test_if_fires_on_entity_change_below_uuid(hass, calls, below): +async def test_if_fires_on_entity_change_below_uuid( + hass: HomeAssistant, calls, below +) -> None: """Test the firing with changed entity specified by registry entry id.""" registry = er.async_get(hass) entry = registry.async_get_or_create( @@ -183,7 +189,9 @@ async def test_if_fires_on_entity_change_below_uuid(hass, calls, below): @pytest.mark.parametrize( "below", (10, "input_number.value_10", "number.value_10", "sensor.value_10") ) -async def test_if_fires_on_entity_change_over_to_below(hass, calls, below): +async def test_if_fires_on_entity_change_over_to_below( + hass: HomeAssistant, calls, below +) -> None: """Test the firing with changed entity.""" hass.states.async_set("test.entity", 11) await hass.async_block_till_done() @@ -212,7 +220,9 @@ async def test_if_fires_on_entity_change_over_to_below(hass, calls, below): @pytest.mark.parametrize( "below", (10, "input_number.value_10", "number.value_10", "sensor.value_10") ) -async def test_if_fires_on_entities_change_over_to_below(hass, calls, below): +async def test_if_fires_on_entities_change_over_to_below( + hass: HomeAssistant, calls, below +) -> None: """Test the firing with changed entities.""" hass.states.async_set("test.entity_1", 11) hass.states.async_set("test.entity_2", 11) @@ -245,7 +255,9 @@ async def test_if_fires_on_entities_change_over_to_below(hass, calls, below): @pytest.mark.parametrize( "below", (10, "input_number.value_10", "number.value_10", "sensor.value_10") ) -async def test_if_not_fires_on_entity_change_below_to_below(hass, calls, below): +async def test_if_not_fires_on_entity_change_below_to_below( + hass: HomeAssistant, calls, below +) -> None: """Test the firing with changed entity.""" context = Context() hass.states.async_set("test.entity", 11) @@ -286,7 +298,9 @@ async def test_if_not_fires_on_entity_change_below_to_below(hass, calls, below): @pytest.mark.parametrize( "below", (10, "input_number.value_10", "number.value_10", "sensor.value_10") ) -async def test_if_not_below_fires_on_entity_change_to_equal(hass, calls, below): +async def test_if_not_below_fires_on_entity_change_to_equal( + hass: HomeAssistant, calls, below +) -> None: """Test the firing with changed entity.""" hass.states.async_set("test.entity", 11) await hass.async_block_till_done() @@ -315,7 +329,9 @@ async def test_if_not_below_fires_on_entity_change_to_equal(hass, calls, below): @pytest.mark.parametrize( "below", (10, "input_number.value_10", "number.value_10", "sensor.value_10") ) -async def test_if_not_fires_on_initial_entity_below(hass, calls, below): +async def test_if_not_fires_on_initial_entity_below( + hass: HomeAssistant, calls, below +) -> None: """Test the firing when starting with a match.""" hass.states.async_set("test.entity", 9) await hass.async_block_till_done() @@ -344,7 +360,9 @@ async def test_if_not_fires_on_initial_entity_below(hass, calls, below): @pytest.mark.parametrize( "above", (10, "input_number.value_10", "number.value_10", "sensor.value_10") ) -async def test_if_not_fires_on_initial_entity_above(hass, calls, above): +async def test_if_not_fires_on_initial_entity_above( + hass: HomeAssistant, calls, above +) -> None: """Test the firing when starting with a match.""" hass.states.async_set("test.entity", 11) await hass.async_block_till_done() @@ -373,7 +391,9 @@ async def test_if_not_fires_on_initial_entity_above(hass, calls, above): @pytest.mark.parametrize( "above", (10, "input_number.value_10", "number.value_10", "sensor.value_10") ) -async def test_if_fires_on_entity_change_above(hass, calls, above): +async def test_if_fires_on_entity_change_above( + hass: HomeAssistant, calls, above +) -> None: """Test the firing with changed entity.""" hass.states.async_set("test.entity", 9) await hass.async_block_till_done() @@ -398,7 +418,9 @@ async def test_if_fires_on_entity_change_above(hass, calls, above): assert len(calls) == 1 -async def test_if_fires_on_entity_unavailable_at_startup(hass, calls): +async def test_if_fires_on_entity_unavailable_at_startup( + hass: HomeAssistant, calls +) -> None: """Test the firing with changed entity at startup.""" assert await async_setup_component( hass, @@ -421,7 +443,9 @@ async def test_if_fires_on_entity_unavailable_at_startup(hass, calls): @pytest.mark.parametrize("above", (10, "input_number.value_10")) -async def test_if_fires_on_entity_change_below_to_above(hass, calls, above): +async def test_if_fires_on_entity_change_below_to_above( + hass: HomeAssistant, calls, above +) -> None: """Test the firing with changed entity.""" # set initial state hass.states.async_set("test.entity", 9) @@ -449,7 +473,9 @@ async def test_if_fires_on_entity_change_below_to_above(hass, calls, above): @pytest.mark.parametrize("above", (10, "input_number.value_10")) -async def test_if_not_fires_on_entity_change_above_to_above(hass, calls, above): +async def test_if_not_fires_on_entity_change_above_to_above( + hass: HomeAssistant, calls, above +) -> None: """Test the firing with changed entity.""" # set initial state hass.states.async_set("test.entity", 9) @@ -482,7 +508,9 @@ async def test_if_not_fires_on_entity_change_above_to_above(hass, calls, above): @pytest.mark.parametrize("above", (10, "input_number.value_10")) -async def test_if_not_above_fires_on_entity_change_to_equal(hass, calls, above): +async def test_if_not_above_fires_on_entity_change_to_equal( + hass: HomeAssistant, calls, above +) -> None: """Test the firing with changed entity.""" # set initial state hass.states.async_set("test.entity", 9) @@ -518,7 +546,9 @@ async def test_if_not_above_fires_on_entity_change_to_equal(hass, calls, above): ("input_number.value_5", "input_number.value_10"), ), ) -async def test_if_fires_on_entity_change_below_range(hass, calls, above, below): +async def test_if_fires_on_entity_change_below_range( + hass: HomeAssistant, calls, above, below +) -> None: """Test the firing with changed entity.""" hass.states.async_set("test.entity", 11) await hass.async_block_till_done() @@ -553,7 +583,9 @@ async def test_if_fires_on_entity_change_below_range(hass, calls, above, below): ("input_number.value_5", "input_number.value_10"), ), ) -async def test_if_fires_on_entity_change_below_above_range(hass, calls, above, below): +async def test_if_fires_on_entity_change_below_above_range( + hass: HomeAssistant, calls, above, below +) -> None: """Test the firing with changed entity.""" assert await async_setup_component( hass, @@ -585,7 +617,9 @@ async def test_if_fires_on_entity_change_below_above_range(hass, calls, above, b ("input_number.value_5", "input_number.value_10"), ), ) -async def test_if_fires_on_entity_change_over_to_below_range(hass, calls, above, below): +async def test_if_fires_on_entity_change_over_to_below_range( + hass: HomeAssistant, calls, above, below +) -> None: """Test the firing with changed entity.""" hass.states.async_set("test.entity", 11) await hass.async_block_till_done() @@ -622,8 +656,8 @@ async def test_if_fires_on_entity_change_over_to_below_range(hass, calls, above, ), ) async def test_if_fires_on_entity_change_over_to_below_above_range( - hass, calls, above, below -): + hass: HomeAssistant, calls, above, below +) -> None: """Test the firing with changed entity.""" hass.states.async_set("test.entity", 11) await hass.async_block_till_done() @@ -651,7 +685,9 @@ async def test_if_fires_on_entity_change_over_to_below_above_range( @pytest.mark.parametrize("below", (100, "input_number.value_100")) -async def test_if_not_fires_if_entity_not_match(hass, calls, below): +async def test_if_not_fires_if_entity_not_match( + hass: HomeAssistant, calls, below +) -> None: """Test if not fired with non matching entity.""" assert await async_setup_component( hass, @@ -673,7 +709,9 @@ async def test_if_not_fires_if_entity_not_match(hass, calls, below): assert len(calls) == 0 -async def test_if_not_fires_and_warns_if_below_entity_unknown(hass, caplog, calls): +async def test_if_not_fires_and_warns_if_below_entity_unknown( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, calls +) -> None: """Test if warns with unknown below entity.""" assert await async_setup_component( hass, @@ -702,7 +740,9 @@ async def test_if_not_fires_and_warns_if_below_entity_unknown(hass, caplog, call @pytest.mark.parametrize("below", (10, "input_number.value_10")) -async def test_if_fires_on_entity_change_below_with_attribute(hass, calls, below): +async def test_if_fires_on_entity_change_below_with_attribute( + hass: HomeAssistant, calls, below +) -> None: """Test attributes change.""" hass.states.async_set("test.entity", 11, {"test_attribute": 11}) await hass.async_block_till_done() @@ -729,8 +769,8 @@ async def test_if_fires_on_entity_change_below_with_attribute(hass, calls, below @pytest.mark.parametrize("below", (10, "input_number.value_10")) async def test_if_not_fires_on_entity_change_not_below_with_attribute( - hass, calls, below -): + hass: HomeAssistant, calls, below +) -> None: """Test attributes.""" assert await async_setup_component( hass, @@ -753,7 +793,9 @@ async def test_if_not_fires_on_entity_change_not_below_with_attribute( @pytest.mark.parametrize("below", (10, "input_number.value_10")) -async def test_if_fires_on_attribute_change_with_attribute_below(hass, calls, below): +async def test_if_fires_on_attribute_change_with_attribute_below( + hass: HomeAssistant, calls, below +) -> None: """Test attributes change.""" hass.states.async_set("test.entity", "entity", {"test_attribute": 11}) await hass.async_block_till_done() @@ -781,8 +823,8 @@ async def test_if_fires_on_attribute_change_with_attribute_below(hass, calls, be @pytest.mark.parametrize("below", (10, "input_number.value_10")) async def test_if_not_fires_on_attribute_change_with_attribute_not_below( - hass, calls, below -): + hass: HomeAssistant, calls, below +) -> None: """Test attributes change.""" assert await async_setup_component( hass, @@ -806,7 +848,9 @@ async def test_if_not_fires_on_attribute_change_with_attribute_not_below( @pytest.mark.parametrize("below", (10, "input_number.value_10")) -async def test_if_not_fires_on_entity_change_with_attribute_below(hass, calls, below): +async def test_if_not_fires_on_entity_change_with_attribute_below( + hass: HomeAssistant, calls, below +) -> None: """Test attributes change.""" assert await async_setup_component( hass, @@ -831,8 +875,8 @@ async def test_if_not_fires_on_entity_change_with_attribute_below(hass, calls, b @pytest.mark.parametrize("below", (10, "input_number.value_10")) async def test_if_not_fires_on_entity_change_with_not_attribute_below( - hass, calls, below -): + hass: HomeAssistant, calls, below +) -> None: """Test attributes change.""" assert await async_setup_component( hass, @@ -857,8 +901,8 @@ async def test_if_not_fires_on_entity_change_with_not_attribute_below( @pytest.mark.parametrize("below", (10, "input_number.value_10")) async def test_fires_on_attr_change_with_attribute_below_and_multiple_attr( - hass, calls, below -): + hass: HomeAssistant, calls, below +) -> None: """Test attributes change.""" hass.states.async_set( "test.entity", "entity", {"test_attribute": 11, "not_test_attribute": 11} @@ -888,7 +932,7 @@ async def test_fires_on_attr_change_with_attribute_below_and_multiple_attr( @pytest.mark.parametrize("below", (10, "input_number.value_10")) -async def test_template_list(hass, calls, below): +async def test_template_list(hass: HomeAssistant, calls, below) -> None: """Test template list.""" hass.states.async_set("test.entity", "entity", {"test_attribute": [11, 15, 11]}) await hass.async_block_till_done() @@ -914,7 +958,7 @@ async def test_template_list(hass, calls, below): @pytest.mark.parametrize("below", (10.0, "input_number.value_10")) -async def test_template_string(hass, calls, below): +async def test_template_string(hass: HomeAssistant, calls, below) -> None: """Test template string.""" assert await async_setup_component( hass, @@ -957,7 +1001,9 @@ async def test_template_string(hass, calls, below): ) -async def test_not_fires_on_attr_change_with_attr_not_below_multiple_attr(hass, calls): +async def test_not_fires_on_attr_change_with_attr_not_below_multiple_attr( + hass: HomeAssistant, calls +) -> None: """Test if not fired changed attributes.""" assert await async_setup_component( hass, @@ -991,7 +1037,7 @@ async def test_not_fires_on_attr_change_with_attr_not_below_multiple_attr(hass, ("input_number.value_8", "input_number.value_12"), ), ) -async def test_if_action(hass, calls, above, below): +async def test_if_action(hass: HomeAssistant, calls, above, below) -> None: """Test if action.""" entity_id = "domain.test_entity" assert await async_setup_component( @@ -1039,7 +1085,7 @@ async def test_if_action(hass, calls, above, below): ("input_number.value_8", "input_number.value_12"), ), ) -async def test_if_fails_setup_bad_for(hass, calls, above, below): +async def test_if_fails_setup_bad_for(hass: HomeAssistant, calls, above, below) -> None: """Test for setup failure for bad for.""" hass.states.async_set("test.entity", 5) await hass.async_block_till_done() @@ -1063,7 +1109,9 @@ async def test_if_fails_setup_bad_for(hass, calls, above, below): ) -async def test_if_fails_setup_for_without_above_below(hass, calls): +async def test_if_fails_setup_for_without_above_below( + hass: HomeAssistant, calls +) -> None: """Test for setup failures for missing above or below.""" with assert_setup_component(0, automation.DOMAIN): assert await async_setup_component( @@ -1091,7 +1139,9 @@ async def test_if_fails_setup_for_without_above_below(hass, calls): ("input_number.value_8", "input_number.value_12"), ), ) -async def test_if_not_fires_on_entity_change_with_for(hass, calls, above, below): +async def test_if_not_fires_on_entity_change_with_for( + hass: HomeAssistant, calls, above, below +) -> None: """Test for not firing on entity change with for.""" assert await async_setup_component( hass, @@ -1129,8 +1179,8 @@ async def test_if_not_fires_on_entity_change_with_for(hass, calls, above, below) ), ) async def test_if_not_fires_on_entities_change_with_for_after_stop( - hass, calls, above, below -): + hass: HomeAssistant, calls, above, below +) -> None: """Test for not firing on entities change with for after stop.""" hass.states.async_set("test.entity_1", 0) hass.states.async_set("test.entity_2", 0) @@ -1187,8 +1237,8 @@ async def test_if_not_fires_on_entities_change_with_for_after_stop( ), ) async def test_if_fires_on_entity_change_with_for_attribute_change( - hass, calls, above, below -): + hass: HomeAssistant, calls, above, below +) -> None: """Test for firing on entity change with for and attribute change.""" hass.states.async_set("test.entity", 0) await hass.async_block_till_done() @@ -1235,7 +1285,9 @@ async def test_if_fires_on_entity_change_with_for_attribute_change( ("input_number.value_8", "input_number.value_12"), ), ) -async def test_if_fires_on_entity_change_with_for(hass, calls, above, below): +async def test_if_fires_on_entity_change_with_for( + hass: HomeAssistant, calls, above, below +) -> None: """Test for firing on entity change with for.""" hass.states.async_set("test.entity", 0) await hass.async_block_till_done() @@ -1265,7 +1317,7 @@ async def test_if_fires_on_entity_change_with_for(hass, calls, above, below): @pytest.mark.parametrize("above", (10, "input_number.value_10")) -async def test_wait_template_with_trigger(hass, calls, above): +async def test_wait_template_with_trigger(hass: HomeAssistant, calls, above) -> None: """Test using wait template with 'trigger.entity_id'.""" hass.states.async_set("test.entity", "0") await hass.async_block_till_done() @@ -1314,7 +1366,9 @@ async def test_wait_template_with_trigger(hass, calls, above): ("input_number.value_8", "input_number.value_12"), ), ) -async def test_if_fires_on_entities_change_no_overlap(hass, calls, above, below): +async def test_if_fires_on_entities_change_no_overlap( + hass: HomeAssistant, calls, above, below +) -> None: """Test for firing on entities change with no overlap.""" hass.states.async_set("test.entity_1", 0) hass.states.async_set("test.entity_2", 0) @@ -1370,7 +1424,9 @@ async def test_if_fires_on_entities_change_no_overlap(hass, calls, above, below) ("input_number.value_8", "input_number.value_12"), ), ) -async def test_if_fires_on_entities_change_overlap(hass, calls, above, below): +async def test_if_fires_on_entities_change_overlap( + hass: HomeAssistant, calls, above, below +) -> None: """Test for firing on entities change with overlap.""" hass.states.async_set("test.entity_1", 0) hass.states.async_set("test.entity_2", 0) @@ -1437,7 +1493,9 @@ async def test_if_fires_on_entities_change_overlap(hass, calls, above, below): ("input_number.value_8", "input_number.value_12"), ), ) -async def test_if_fires_on_change_with_for_template_1(hass, calls, above, below): +async def test_if_fires_on_change_with_for_template_1( + hass: HomeAssistant, calls, above, below +) -> None: """Test for firing on change with for template.""" hass.states.async_set("test.entity", 0) await hass.async_block_till_done() @@ -1476,7 +1534,9 @@ async def test_if_fires_on_change_with_for_template_1(hass, calls, above, below) ("input_number.value_8", "input_number.value_12"), ), ) -async def test_if_fires_on_change_with_for_template_2(hass, calls, above, below): +async def test_if_fires_on_change_with_for_template_2( + hass: HomeAssistant, calls, above, below +) -> None: """Test for firing on change with for template.""" hass.states.async_set("test.entity", 0) await hass.async_block_till_done() @@ -1515,7 +1575,9 @@ async def test_if_fires_on_change_with_for_template_2(hass, calls, above, below) ("input_number.value_8", "input_number.value_12"), ), ) -async def test_if_fires_on_change_with_for_template_3(hass, calls, above, below): +async def test_if_fires_on_change_with_for_template_3( + hass: HomeAssistant, calls, above, below +) -> None: """Test for firing on change with for template.""" hass.states.async_set("test.entity", 0) await hass.async_block_till_done() @@ -1545,7 +1607,9 @@ async def test_if_fires_on_change_with_for_template_3(hass, calls, above, below) assert len(calls) == 1 -async def test_if_not_fires_on_error_with_for_template(hass, calls): +async def test_if_not_fires_on_error_with_for_template( + hass: HomeAssistant, calls +) -> None: """Test for not firing on error with for template.""" hass.states.async_set("test.entity", 0) await hass.async_block_till_done() @@ -1590,7 +1654,7 @@ async def test_if_not_fires_on_error_with_for_template(hass, calls): ("input_number.value_8", "input_number.value_12"), ), ) -async def test_invalid_for_template(hass, calls, above, below): +async def test_invalid_for_template(hass: HomeAssistant, calls, above, below) -> None: """Test for invalid for template.""" hass.states.async_set("test.entity", 0) await hass.async_block_till_done() @@ -1628,8 +1692,8 @@ async def test_invalid_for_template(hass, calls, above, below): ), ) async def test_if_fires_on_entities_change_overlap_for_template( - hass, calls, above, below -): + hass: HomeAssistant, calls, above, below +) -> None: """Test for firing on entities change with overlap and for template.""" hass.states.async_set("test.entity_1", 0) hass.states.async_set("test.entity_2", 0) @@ -1694,7 +1758,7 @@ async def test_if_fires_on_entities_change_overlap_for_template( assert calls[1].data["some"] == "test.entity_2 - 0:00:10" -async def test_below_above(hass): +async def test_below_above(hass: HomeAssistant) -> None: """Test above cannot be above below.""" with pytest.raises(vol.Invalid): await numeric_state_trigger.async_validate_trigger_config( @@ -1702,7 +1766,7 @@ async def test_below_above(hass): ) -async def test_schema_unacceptable_entities(hass): +async def test_schema_unacceptable_entities(hass: HomeAssistant) -> None: """Test input_number, number & sensor only is accepted for above/below.""" with pytest.raises(vol.Invalid): await numeric_state_trigger.async_validate_trigger_config( @@ -1726,8 +1790,8 @@ async def test_schema_unacceptable_entities(hass): @pytest.mark.parametrize("above", (3, "input_number.value_3")) async def test_attribute_if_fires_on_entity_change_with_both_filters( - hass, calls, above -): + hass: HomeAssistant, calls, above +) -> None: """Test for firing if both filters are match attribute.""" hass.states.async_set("test.entity", "bla", {"test-measurement": 1}) @@ -1755,8 +1819,8 @@ async def test_attribute_if_fires_on_entity_change_with_both_filters( @pytest.mark.parametrize("above", (3, "input_number.value_3")) async def test_attribute_if_not_fires_on_entities_change_with_for_after_stop( - hass, calls, above -): + hass: HomeAssistant, calls, above +) -> None: """Test for not firing on entity change with for after stop trigger.""" hass.states.async_set("test.entity", "bla", {"test-measurement": 1}) @@ -1790,7 +1854,7 @@ async def test_attribute_if_not_fires_on_entities_change_with_for_after_stop( ("above", "below"), ((8, 12),), ) -async def test_variables_priority(hass, calls, above, below): +async def test_variables_priority(hass: HomeAssistant, calls, above, below) -> None: """Test an externally defined trigger variable is overridden.""" hass.states.async_set("test.entity_1", 0) hass.states.async_set("test.entity_2", 0) @@ -1847,7 +1911,7 @@ async def test_variables_priority(hass, calls, above, below): @pytest.mark.parametrize("multiplier", (1, 5)) -async def test_template_variable(hass, calls, multiplier): +async def test_template_variable(hass: HomeAssistant, calls, multiplier) -> None: """Test template variable.""" hass.states.async_set("test.entity", "entity", {"test_attribute": [11, 15, 11]}) await hass.async_block_till_done() diff --git a/tests/components/homeassistant/triggers/test_state.py b/tests/components/homeassistant/triggers/test_state.py index 26f02ce8d7..a16d542bec 100644 --- a/tests/components/homeassistant/triggers/test_state.py +++ b/tests/components/homeassistant/triggers/test_state.py @@ -33,7 +33,7 @@ def setup_comp(hass): hass.states.async_set("test.entity", "hello") -async def test_if_fires_on_entity_change(hass, calls): +async def test_if_fires_on_entity_change(hass: HomeAssistant, calls) -> None: """Test for firing on entity change.""" context = Context() hass.states.async_set("test.entity", "hello") @@ -83,7 +83,7 @@ async def test_if_fires_on_entity_change(hass, calls): assert len(calls) == 1 -async def test_if_fires_on_entity_change_uuid(hass, calls): +async def test_if_fires_on_entity_change_uuid(hass: HomeAssistant, calls) -> None: """Test for firing on entity change.""" context = Context() @@ -141,7 +141,9 @@ async def test_if_fires_on_entity_change_uuid(hass, calls): assert len(calls) == 1 -async def test_if_fires_on_entity_change_with_from_filter(hass, calls): +async def test_if_fires_on_entity_change_with_from_filter( + hass: HomeAssistant, calls +) -> None: """Test for firing on entity change with filter.""" assert await async_setup_component( hass, @@ -194,7 +196,9 @@ async def test_if_fires_on_entity_change_with_not_from_filter( assert len(calls) == 1 -async def test_if_fires_on_entity_change_with_to_filter(hass, calls): +async def test_if_fires_on_entity_change_with_to_filter( + hass: HomeAssistant, calls +) -> None: """Test for firing on entity change with to filter.""" assert await async_setup_component( hass, @@ -247,7 +251,9 @@ async def test_if_fires_on_entity_change_with_not_to_filter( assert len(calls) == 1 -async def test_if_fires_on_entity_change_with_from_filter_all(hass, calls): +async def test_if_fires_on_entity_change_with_from_filter_all( + hass: HomeAssistant, calls +) -> None: """Test for firing on entity change with filter.""" assert await async_setup_component( hass, @@ -271,7 +277,9 @@ async def test_if_fires_on_entity_change_with_from_filter_all(hass, calls): assert len(calls) == 1 -async def test_if_fires_on_entity_change_with_to_filter_all(hass, calls): +async def test_if_fires_on_entity_change_with_to_filter_all( + hass: HomeAssistant, calls +) -> None: """Test for firing on entity change with to filter.""" assert await async_setup_component( hass, @@ -295,7 +303,9 @@ async def test_if_fires_on_entity_change_with_to_filter_all(hass, calls): assert len(calls) == 1 -async def test_if_fires_on_attribute_change_with_to_filter(hass, calls): +async def test_if_fires_on_attribute_change_with_to_filter( + hass: HomeAssistant, calls +) -> None: """Test for not firing on attribute change.""" assert await async_setup_component( hass, @@ -319,7 +329,9 @@ async def test_if_fires_on_attribute_change_with_to_filter(hass, calls): assert len(calls) == 1 -async def test_if_fires_on_entity_change_with_both_filters(hass, calls): +async def test_if_fires_on_entity_change_with_both_filters( + hass: HomeAssistant, calls +) -> None: """Test for firing if both filters are a non match.""" assert await async_setup_component( hass, @@ -437,7 +449,7 @@ async def test_if_fires_on_entity_change_with_from_not_to( assert len(calls) == 2 -async def test_if_not_fires_if_to_filter_not_match(hass, calls): +async def test_if_not_fires_if_to_filter_not_match(hass: HomeAssistant, calls) -> None: """Test for not firing if to filter is not a match.""" assert await async_setup_component( hass, @@ -461,7 +473,9 @@ async def test_if_not_fires_if_to_filter_not_match(hass, calls): assert len(calls) == 0 -async def test_if_not_fires_if_from_filter_not_match(hass, calls): +async def test_if_not_fires_if_from_filter_not_match( + hass: HomeAssistant, calls +) -> None: """Test for not firing if from filter is not a match.""" hass.states.async_set("test.entity", "bye") @@ -487,7 +501,7 @@ async def test_if_not_fires_if_from_filter_not_match(hass, calls): assert len(calls) == 0 -async def test_if_not_fires_if_entity_not_match(hass, calls): +async def test_if_not_fires_if_entity_not_match(hass: HomeAssistant, calls) -> None: """Test for not firing if entity is not matching.""" assert await async_setup_component( hass, @@ -506,7 +520,7 @@ async def test_if_not_fires_if_entity_not_match(hass, calls): assert len(calls) == 0 -async def test_if_action(hass, calls): +async def test_if_action(hass: HomeAssistant, calls) -> None: """Test for to action.""" entity_id = "domain.test_entity" test_state = "new_state" @@ -538,7 +552,7 @@ async def test_if_action(hass, calls): assert len(calls) == 1 -async def test_if_fails_setup_if_to_boolean_value(hass, calls): +async def test_if_fails_setup_if_to_boolean_value(hass: HomeAssistant, calls) -> None: """Test for setup failure for boolean to.""" with assert_setup_component(0, automation.DOMAIN): assert await async_setup_component( @@ -557,7 +571,7 @@ async def test_if_fails_setup_if_to_boolean_value(hass, calls): ) -async def test_if_fails_setup_if_from_boolean_value(hass, calls): +async def test_if_fails_setup_if_from_boolean_value(hass: HomeAssistant, calls) -> None: """Test for setup failure for boolean from.""" with assert_setup_component(0, automation.DOMAIN): assert await async_setup_component( @@ -576,7 +590,7 @@ async def test_if_fails_setup_if_from_boolean_value(hass, calls): ) -async def test_if_fails_setup_bad_for(hass, calls): +async def test_if_fails_setup_bad_for(hass: HomeAssistant, calls) -> None: """Test for setup failure for bad for.""" with assert_setup_component(0, automation.DOMAIN): assert await async_setup_component( @@ -596,7 +610,9 @@ async def test_if_fails_setup_bad_for(hass, calls): ) -async def test_if_not_fires_on_entity_change_with_for(hass, calls): +async def test_if_not_fires_on_entity_change_with_for( + hass: HomeAssistant, calls +) -> None: """Test for not firing on entity change with for.""" assert await async_setup_component( hass, @@ -624,7 +640,9 @@ async def test_if_not_fires_on_entity_change_with_for(hass, calls): assert len(calls) == 0 -async def test_if_not_fires_on_entities_change_with_for_after_stop(hass, calls): +async def test_if_not_fires_on_entities_change_with_for_after_stop( + hass: HomeAssistant, calls +) -> None: """Test for not firing on entity change with for after stop trigger.""" assert await async_setup_component( hass, @@ -668,7 +686,9 @@ async def test_if_not_fires_on_entities_change_with_for_after_stop(hass, calls): assert len(calls) == 1 -async def test_if_fires_on_entity_change_with_for_attribute_change(hass, calls): +async def test_if_fires_on_entity_change_with_for_attribute_change( + hass: HomeAssistant, calls +) -> None: """Test for firing on entity change with for and attribute change.""" assert await async_setup_component( hass, @@ -705,7 +725,9 @@ async def test_if_fires_on_entity_change_with_for_attribute_change(hass, calls): assert len(calls) == 1 -async def test_if_fires_on_entity_change_with_for_multiple_force_update(hass, calls): +async def test_if_fires_on_entity_change_with_for_multiple_force_update( + hass: HomeAssistant, calls +) -> None: """Test for firing on entity change with for and force update.""" assert await async_setup_component( hass, @@ -741,7 +763,7 @@ async def test_if_fires_on_entity_change_with_for_multiple_force_update(hass, ca assert len(calls) == 1 -async def test_if_fires_on_entity_change_with_for(hass, calls): +async def test_if_fires_on_entity_change_with_for(hass: HomeAssistant, calls) -> None: """Test for firing on entity change with for.""" assert await async_setup_component( hass, @@ -767,7 +789,9 @@ async def test_if_fires_on_entity_change_with_for(hass, calls): assert len(calls) == 1 -async def test_if_fires_on_entity_change_with_for_without_to(hass, calls): +async def test_if_fires_on_entity_change_with_for_without_to( + hass: HomeAssistant, calls +) -> None: """Test for firing on entity change with for.""" assert await async_setup_component( hass, @@ -804,7 +828,9 @@ async def test_if_fires_on_entity_change_with_for_without_to(hass, calls): assert len(calls) == 1 -async def test_if_does_not_fires_on_entity_change_with_for_without_to_2(hass, calls): +async def test_if_does_not_fires_on_entity_change_with_for_without_to_2( + hass: HomeAssistant, calls +) -> None: """Test for firing on entity change with for.""" assert await async_setup_component( hass, @@ -837,7 +863,9 @@ async def test_if_does_not_fires_on_entity_change_with_for_without_to_2(hass, ca assert len(calls) == 0 -async def test_if_fires_on_entity_creation_and_removal(hass, calls): +async def test_if_fires_on_entity_creation_and_removal( + hass: HomeAssistant, calls +) -> None: """Test for firing on entity creation and removal, with to/from constraints.""" # set automations for multiple combinations to/from assert await async_setup_component( @@ -902,7 +930,7 @@ async def test_if_fires_on_entity_creation_and_removal(hass, calls): assert calls[3].context.parent_id == context_0.id -async def test_if_fires_on_for_condition(hass, calls): +async def test_if_fires_on_for_condition(hass: HomeAssistant, calls) -> None: """Test for firing if condition is on.""" point1 = dt_util.utcnow() point2 = point1 + timedelta(seconds=10) @@ -939,7 +967,9 @@ async def test_if_fires_on_for_condition(hass, calls): assert len(calls) == 1 -async def test_if_fires_on_for_condition_attribute_change(hass, calls): +async def test_if_fires_on_for_condition_attribute_change( + hass: HomeAssistant, calls +) -> None: """Test for firing if condition is on with attribute change.""" point1 = dt_util.utcnow() point2 = point1 + timedelta(seconds=4) @@ -986,7 +1016,7 @@ async def test_if_fires_on_for_condition_attribute_change(hass, calls): assert len(calls) == 1 -async def test_if_fails_setup_for_without_time(hass, calls): +async def test_if_fails_setup_for_without_time(hass: HomeAssistant, calls) -> None: """Test for setup failure if no time is provided.""" with assert_setup_component(0, automation.DOMAIN): assert await async_setup_component( @@ -1007,7 +1037,7 @@ async def test_if_fails_setup_for_without_time(hass, calls): ) -async def test_if_fails_setup_for_without_entity(hass, calls): +async def test_if_fails_setup_for_without_entity(hass: HomeAssistant, calls) -> None: """Test for setup failure if no entity is provided.""" with assert_setup_component(0, automation.DOMAIN): assert await async_setup_component( @@ -1027,7 +1057,7 @@ async def test_if_fails_setup_for_without_entity(hass, calls): ) -async def test_wait_template_with_trigger(hass, calls): +async def test_wait_template_with_trigger(hass: HomeAssistant, calls) -> None: """Test using wait template with 'trigger.entity_id'.""" assert await async_setup_component( hass, @@ -1069,7 +1099,9 @@ async def test_wait_template_with_trigger(hass, calls): assert calls[0].data["some"] == "state - test.entity - hello - world" -async def test_if_fires_on_entities_change_no_overlap(hass, calls): +async def test_if_fires_on_entities_change_no_overlap( + hass: HomeAssistant, calls +) -> None: """Test for firing on entities change with no overlap.""" assert await async_setup_component( hass, @@ -1111,7 +1143,7 @@ async def test_if_fires_on_entities_change_no_overlap(hass, calls): assert calls[1].data["some"] == "test.entity_2" -async def test_if_fires_on_entities_change_overlap(hass, calls): +async def test_if_fires_on_entities_change_overlap(hass: HomeAssistant, calls) -> None: """Test for firing on entities change with overlap.""" assert await async_setup_component( hass, @@ -1164,7 +1196,9 @@ async def test_if_fires_on_entities_change_overlap(hass, calls): assert calls[1].data["some"] == "test.entity_2" -async def test_if_fires_on_change_with_for_template_1(hass, calls): +async def test_if_fires_on_change_with_for_template_1( + hass: HomeAssistant, calls +) -> None: """Test for firing on change with for template.""" assert await async_setup_component( hass, @@ -1190,7 +1224,9 @@ async def test_if_fires_on_change_with_for_template_1(hass, calls): assert len(calls) == 1 -async def test_if_fires_on_change_with_for_template_2(hass, calls): +async def test_if_fires_on_change_with_for_template_2( + hass: HomeAssistant, calls +) -> None: """Test for firing on change with for template.""" assert await async_setup_component( hass, @@ -1216,7 +1252,9 @@ async def test_if_fires_on_change_with_for_template_2(hass, calls): assert len(calls) == 1 -async def test_if_fires_on_change_with_for_template_3(hass, calls): +async def test_if_fires_on_change_with_for_template_3( + hass: HomeAssistant, calls +) -> None: """Test for firing on change with for template.""" assert await async_setup_component( hass, @@ -1242,7 +1280,9 @@ async def test_if_fires_on_change_with_for_template_3(hass, calls): assert len(calls) == 1 -async def test_if_fires_on_change_with_for_template_4(hass, calls): +async def test_if_fires_on_change_with_for_template_4( + hass: HomeAssistant, calls +) -> None: """Test for firing on change with for template.""" assert await async_setup_component( hass, @@ -1269,7 +1309,7 @@ async def test_if_fires_on_change_with_for_template_4(hass, calls): assert len(calls) == 1 -async def test_if_fires_on_change_from_with_for(hass, calls): +async def test_if_fires_on_change_from_with_for(hass: HomeAssistant, calls) -> None: """Test for firing on change with from/for.""" assert await async_setup_component( hass, @@ -1298,7 +1338,7 @@ async def test_if_fires_on_change_from_with_for(hass, calls): assert len(calls) == 1 -async def test_if_not_fires_on_change_from_with_for(hass, calls): +async def test_if_not_fires_on_change_from_with_for(hass: HomeAssistant, calls) -> None: """Test for firing on change with from/for.""" assert await async_setup_component( hass, @@ -1327,7 +1367,7 @@ async def test_if_not_fires_on_change_from_with_for(hass, calls): assert len(calls) == 0 -async def test_invalid_for_template_1(hass, calls): +async def test_invalid_for_template_1(hass: HomeAssistant, calls) -> None: """Test for invalid for template.""" assert await async_setup_component( hass, @@ -1351,7 +1391,9 @@ async def test_invalid_for_template_1(hass, calls): assert mock_logger.error.called -async def test_if_fires_on_entities_change_overlap_for_template(hass, calls): +async def test_if_fires_on_entities_change_overlap_for_template( + hass: HomeAssistant, calls +) -> None: """Test for firing on entities change with overlap and for template.""" assert await async_setup_component( hass, @@ -1411,7 +1453,9 @@ async def test_if_fires_on_entities_change_overlap_for_template(hass, calls): assert calls[1].data["some"] == "test.entity_2 - 0:00:10" -async def test_attribute_if_fires_on_entity_change_with_both_filters(hass, calls): +async def test_attribute_if_fires_on_entity_change_with_both_filters( + hass: HomeAssistant, calls +) -> None: """Test for firing if both filters are match attribute.""" hass.states.async_set("test.entity", "bla", {"name": "hello"}) @@ -1438,7 +1482,9 @@ async def test_attribute_if_fires_on_entity_change_with_both_filters(hass, calls assert len(calls) == 1 -async def test_attribute_if_fires_on_entity_where_attr_stays_constant(hass, calls): +async def test_attribute_if_fires_on_entity_where_attr_stays_constant( + hass: HomeAssistant, calls +) -> None: """Test for firing if attribute stays the same.""" hass.states.async_set("test.entity", "bla", {"name": "hello", "other": "old_value"}) @@ -1475,8 +1521,8 @@ async def test_attribute_if_fires_on_entity_where_attr_stays_constant(hass, call async def test_attribute_if_fires_on_entity_where_attr_stays_constant_filter( - hass, calls -): + hass: HomeAssistant, calls +) -> None: """Test for firing if attribute stays the same.""" hass.states.async_set("test.entity", "bla", {"name": "other_name"}) @@ -1519,7 +1565,9 @@ async def test_attribute_if_fires_on_entity_where_attr_stays_constant_filter( assert len(calls) == 1 -async def test_attribute_if_fires_on_entity_where_attr_stays_constant_all(hass, calls): +async def test_attribute_if_fires_on_entity_where_attr_stays_constant_all( + hass: HomeAssistant, calls +) -> None: """Test for firing if attribute stays the same.""" hass.states.async_set("test.entity", "bla", {"name": "hello", "other": "old_value"}) @@ -1563,8 +1611,8 @@ async def test_attribute_if_fires_on_entity_where_attr_stays_constant_all(hass, async def test_attribute_if_not_fires_on_entities_change_with_for_after_stop( - hass, calls -): + hass: HomeAssistant, calls +) -> None: """Test for not firing on entity change with for after stop trigger.""" hass.states.async_set("test.entity", "bla", {"name": "hello"}) @@ -1616,8 +1664,8 @@ async def test_attribute_if_not_fires_on_entities_change_with_for_after_stop( async def test_attribute_if_fires_on_entity_change_with_both_filters_boolean( - hass, calls -): + hass: HomeAssistant, calls +) -> None: """Test for firing if both filters are match attribute.""" hass.states.async_set("test.entity", "bla", {"happening": False}) @@ -1644,7 +1692,7 @@ async def test_attribute_if_fires_on_entity_change_with_both_filters_boolean( assert len(calls) == 1 -async def test_variables_priority(hass, calls): +async def test_variables_priority(hass: HomeAssistant, calls) -> None: """Test an externally defined trigger variable is overridden.""" assert await async_setup_component( hass, diff --git a/tests/components/homeassistant/triggers/test_time.py b/tests/components/homeassistant/triggers/test_time.py index 9b64c64d09..1af687a4f5 100644 --- a/tests/components/homeassistant/triggers/test_time.py +++ b/tests/components/homeassistant/triggers/test_time.py @@ -9,6 +9,7 @@ from homeassistant.components import automation from homeassistant.components.homeassistant.triggers import time from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, SERVICE_TURN_OFF +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -32,7 +33,7 @@ def setup_comp(hass): mock_component(hass, "group") -async def test_if_fires_using_at(hass, calls): +async def test_if_fires_using_at(hass: HomeAssistant, calls) -> None: """Test for firing at.""" now = dt_util.now() @@ -72,7 +73,9 @@ async def test_if_fires_using_at(hass, calls): @pytest.mark.parametrize( ("has_date", "has_time"), [(True, True), (True, False), (False, True)] ) -async def test_if_fires_using_at_input_datetime(hass, calls, has_date, has_time): +async def test_if_fires_using_at_input_datetime( + hass: HomeAssistant, calls, has_date, has_time +) -> None: """Test for firing at input_datetime.""" await async_setup_component( hass, @@ -151,7 +154,7 @@ async def test_if_fires_using_at_input_datetime(hass, calls, has_date, has_time) ) -async def test_if_fires_using_multiple_at(hass, calls): +async def test_if_fires_using_multiple_at(hass: HomeAssistant, calls) -> None: """Test for firing at.""" now = dt_util.now() @@ -193,7 +196,7 @@ async def test_if_fires_using_multiple_at(hass, calls): assert calls[1].data["some"] == "time - 6" -async def test_if_not_fires_using_wrong_at(hass, calls): +async def test_if_not_fires_using_wrong_at(hass: HomeAssistant, calls) -> None: """YAML translates time values to total seconds. This should break the before rule. @@ -232,7 +235,7 @@ async def test_if_not_fires_using_wrong_at(hass, calls): assert len(calls) == 0 -async def test_if_action_before(hass, calls): +async def test_if_action_before(hass: HomeAssistant, calls) -> None: """Test for if action before.""" assert await async_setup_component( hass, @@ -263,7 +266,7 @@ async def test_if_action_before(hass, calls): assert len(calls) == 1 -async def test_if_action_after(hass, calls): +async def test_if_action_after(hass: HomeAssistant, calls) -> None: """Test for if action after.""" assert await async_setup_component( hass, @@ -294,7 +297,7 @@ async def test_if_action_after(hass, calls): assert len(calls) == 1 -async def test_if_action_one_weekday(hass, calls): +async def test_if_action_one_weekday(hass: HomeAssistant, calls) -> None: """Test for if action with one weekday.""" assert await async_setup_component( hass, @@ -326,7 +329,7 @@ async def test_if_action_one_weekday(hass, calls): assert len(calls) == 1 -async def test_if_action_list_weekday(hass, calls): +async def test_if_action_list_weekday(hass: HomeAssistant, calls) -> None: """Test for action with a list of weekdays.""" assert await async_setup_component( hass, @@ -365,7 +368,7 @@ async def test_if_action_list_weekday(hass, calls): assert len(calls) == 2 -async def test_untrack_time_change(hass): +async def test_untrack_time_change(hass: HomeAssistant) -> None: """Test for removing tracked time changes.""" mock_track_time_change = Mock() with patch( @@ -398,7 +401,7 @@ async def test_untrack_time_change(hass): assert len(mock_track_time_change.mock_calls) == 3 -async def test_if_fires_using_at_sensor(hass, calls): +async def test_if_fires_using_at_sensor(hass: HomeAssistant, calls) -> None: """Test for firing at sensor time.""" now = dt_util.now() @@ -507,7 +510,7 @@ async def test_if_fires_using_at_sensor(hass, calls): {"platform": "time", "at": "12:34"}, ], ) -def test_schema_valid(conf): +def test_schema_valid(conf) -> None: """Make sure we don't accept number for 'at' value.""" time.TRIGGER_SCHEMA(conf) @@ -520,13 +523,13 @@ def test_schema_valid(conf): {"platform": "time", "at": "25:00"}, ], ) -def test_schema_invalid(conf): +def test_schema_invalid(conf) -> None: """Make sure we don't accept number for 'at' value.""" with pytest.raises(vol.Invalid): time.TRIGGER_SCHEMA(conf) -async def test_datetime_in_past_on_load(hass, calls): +async def test_datetime_in_past_on_load(hass: HomeAssistant, calls) -> None: """Test time trigger works if input_datetime is in past.""" await async_setup_component( hass, diff --git a/tests/components/homeassistant/triggers/test_time_pattern.py b/tests/components/homeassistant/triggers/test_time_pattern.py index 1018bf7ab1..e7a6a98bb9 100644 --- a/tests/components/homeassistant/triggers/test_time_pattern.py +++ b/tests/components/homeassistant/triggers/test_time_pattern.py @@ -8,6 +8,7 @@ import voluptuous as vol import homeassistant.components.automation as automation import homeassistant.components.homeassistant.triggers.time_pattern as time_pattern from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, SERVICE_TURN_OFF +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -26,7 +27,7 @@ def setup_comp(hass): mock_component(hass, "group") -async def test_if_fires_when_hour_matches(hass, calls): +async def test_if_fires_when_hour_matches(hass: HomeAssistant, calls) -> None: """Test for firing if hour is matching.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( @@ -71,7 +72,7 @@ async def test_if_fires_when_hour_matches(hass, calls): assert calls[0].data["id"] == 0 -async def test_if_fires_when_minute_matches(hass, calls): +async def test_if_fires_when_minute_matches(hass: HomeAssistant, calls) -> None: """Test for firing if minutes are matching.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( @@ -102,7 +103,7 @@ async def test_if_fires_when_minute_matches(hass, calls): assert len(calls) == 1 -async def test_if_fires_when_second_matches(hass, calls): +async def test_if_fires_when_second_matches(hass: HomeAssistant, calls) -> None: """Test for firing if seconds are matching.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( @@ -133,7 +134,9 @@ async def test_if_fires_when_second_matches(hass, calls): assert len(calls) == 1 -async def test_if_fires_when_second_as_string_matches(hass, calls): +async def test_if_fires_when_second_as_string_matches( + hass: HomeAssistant, calls +) -> None: """Test for firing if seconds are matching.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( @@ -166,7 +169,7 @@ async def test_if_fires_when_second_as_string_matches(hass, calls): assert len(calls) == 1 -async def test_if_fires_when_all_matches(hass, calls): +async def test_if_fires_when_all_matches(hass: HomeAssistant, calls) -> None: """Test for firing if everything matches.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( @@ -199,7 +202,7 @@ async def test_if_fires_when_all_matches(hass, calls): assert len(calls) == 1 -async def test_if_fires_periodic_seconds(hass, calls): +async def test_if_fires_periodic_seconds(hass: HomeAssistant, calls) -> None: """Test for firing periodically every second.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( @@ -232,7 +235,7 @@ async def test_if_fires_periodic_seconds(hass, calls): assert len(calls) >= 1 -async def test_if_fires_periodic_minutes(hass, calls): +async def test_if_fires_periodic_minutes(hass: HomeAssistant, calls) -> None: """Test for firing periodically every minute.""" now = dt_util.utcnow() @@ -266,7 +269,7 @@ async def test_if_fires_periodic_minutes(hass, calls): assert len(calls) == 1 -async def test_if_fires_periodic_hours(hass, calls): +async def test_if_fires_periodic_hours(hass: HomeAssistant, calls) -> None: """Test for firing periodically every hour.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( @@ -299,7 +302,7 @@ async def test_if_fires_periodic_hours(hass, calls): assert len(calls) == 1 -async def test_default_values(hass, calls): +async def test_default_values(hass: HomeAssistant, calls) -> None: """Test for firing at 2 minutes every hour.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( @@ -341,7 +344,7 @@ async def test_default_values(hass, calls): assert len(calls) == 2 -async def test_invalid_schemas(hass, calls): +async def test_invalid_schemas(hass: HomeAssistant, calls) -> None: """Test invalid schemas.""" schemas = ( None, diff --git a/tests/components/homekit_controller/specific_devices/test_airversa_ap2.py b/tests/components/homekit_controller/specific_devices/test_airversa_ap2.py index cd6ac03f9f..d1c9398bb2 100644 --- a/tests/components/homekit_controller/specific_devices/test_airversa_ap2.py +++ b/tests/components/homekit_controller/specific_devices/test_airversa_ap2.py @@ -1,7 +1,7 @@ """Tests for Airversa AP2 Air Purifier.""" - from homeassistant.components.sensor import SensorStateClass from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, PERCENTAGE +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory from ..common import ( @@ -14,7 +14,7 @@ from ..common import ( ) -async def test_airversa_ap2_setup(hass): +async def test_airversa_ap2_setup(hass: HomeAssistant) -> None: """Test that an Ecbobee occupancy sensor be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "airversa_ap2.json") await setup_test_accessories(hass, accessories) diff --git a/tests/components/homekit_controller/specific_devices/test_anker_eufycam.py b/tests/components/homekit_controller/specific_devices/test_anker_eufycam.py index f2e209a9fd..603036c00f 100644 --- a/tests/components/homekit_controller/specific_devices/test_anker_eufycam.py +++ b/tests/components/homekit_controller/specific_devices/test_anker_eufycam.py @@ -1,4 +1,5 @@ """Test against characteristics captured from a eufycam.""" +from homeassistant.core import HomeAssistant from ..common import ( HUB_TEST_ACCESSORY_ID, @@ -10,7 +11,7 @@ from ..common import ( ) -async def test_eufycam_setup(hass): +async def test_eufycam_setup(hass: HomeAssistant) -> None: """Test that a eufycam can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "anker_eufycam.json") await setup_test_accessories(hass, accessories) diff --git a/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py b/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py index 4921fca21c..30ecc298d4 100644 --- a/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py +++ b/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py @@ -5,6 +5,7 @@ https://github.com/home-assistant/core/issues/20957 from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature from homeassistant.components.number import NumberMode from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant from ..common import ( HUB_TEST_ACCESSORY_ID, @@ -16,7 +17,7 @@ from ..common import ( ) -async def test_aqara_gateway_setup(hass): +async def test_aqara_gateway_setup(hass: HomeAssistant) -> None: """Test that a Aqara Gateway can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "aqara_gateway.json") await setup_test_accessories(hass, accessories) @@ -75,7 +76,7 @@ async def test_aqara_gateway_setup(hass): ) -async def test_aqara_gateway_e1_setup(hass): +async def test_aqara_gateway_e1_setup(hass: HomeAssistant) -> None: """Test that an Aqara E1 Gateway can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "aqara_e1.json") await setup_test_accessories(hass, accessories) diff --git a/tests/components/homekit_controller/specific_devices/test_aqara_switch.py b/tests/components/homekit_controller/specific_devices/test_aqara_switch.py index da0c0f81e4..338f2bc6e9 100644 --- a/tests/components/homekit_controller/specific_devices/test_aqara_switch.py +++ b/tests/components/homekit_controller/specific_devices/test_aqara_switch.py @@ -5,9 +5,9 @@ service-label-index despite not being linked to a service-label. https://github.com/home-assistant/core/pull/39090 """ - from homeassistant.components.sensor import SensorStateClass from homeassistant.const import PERCENTAGE, EntityCategory +from homeassistant.core import HomeAssistant from ..common import ( HUB_TEST_ACCESSORY_ID, @@ -20,7 +20,7 @@ from ..common import ( ) -async def test_aqara_switch_setup(hass): +async def test_aqara_switch_setup(hass: HomeAssistant) -> None: """Test that a Aqara Switch can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "aqara_switch.json") await setup_test_accessories(hass, accessories) diff --git a/tests/components/homekit_controller/specific_devices/test_arlo_baby.py b/tests/components/homekit_controller/specific_devices/test_arlo_baby.py index 3a72b1c69f..c90ff20c59 100644 --- a/tests/components/homekit_controller/specific_devices/test_arlo_baby.py +++ b/tests/components/homekit_controller/specific_devices/test_arlo_baby.py @@ -1,7 +1,7 @@ """Make sure that an Arlo Baby can be setup.""" - from homeassistant.components.sensor import SensorStateClass from homeassistant.const import PERCENTAGE, TEMP_CELSIUS, EntityCategory +from homeassistant.core import HomeAssistant from ..common import ( HUB_TEST_ACCESSORY_ID, @@ -13,7 +13,7 @@ from ..common import ( ) -async def test_arlo_baby_setup(hass): +async def test_arlo_baby_setup(hass: HomeAssistant) -> None: """Test that an Arlo Baby can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "arlo_baby.json") await setup_test_accessories(hass, accessories) diff --git a/tests/components/homekit_controller/specific_devices/test_connectsense.py b/tests/components/homekit_controller/specific_devices/test_connectsense.py index 096ed39a33..aa24a3dea6 100644 --- a/tests/components/homekit_controller/specific_devices/test_connectsense.py +++ b/tests/components/homekit_controller/specific_devices/test_connectsense.py @@ -1,11 +1,11 @@ """Make sure that ConnectSense Smart Outlet2 / In-Wall Outlet is enumerated properly.""" - from homeassistant.components.sensor import SensorStateClass from homeassistant.const import ( ELECTRIC_CURRENT_AMPERE, ENERGY_KILO_WATT_HOUR, POWER_WATT, ) +from homeassistant.core import HomeAssistant from ..common import ( HUB_TEST_ACCESSORY_ID, @@ -17,7 +17,7 @@ from ..common import ( ) -async def test_connectsense_setup(hass): +async def test_connectsense_setup(hass: HomeAssistant) -> None: """Test that the accessory can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "connectsense.json") await setup_test_accessories(hass, accessories) diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee3.py b/tests/components/homekit_controller/specific_devices/test_ecobee3.py index c203883d4a..d9af858de9 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee3.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py @@ -2,7 +2,7 @@ https://github.com/home-assistant/core/issues/15336 """ - +from typing import Any from unittest import mock from aiohomekit import AccessoryNotFoundError @@ -16,6 +16,7 @@ from homeassistant.components.climate import ( from homeassistant.components.sensor import SensorStateClass from homeassistant.config_entries import ConfigEntryState from homeassistant.const import TEMP_CELSIUS +from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from ..common import ( @@ -30,7 +31,7 @@ from ..common import ( ) -async def test_ecobee3_setup(hass): +async def test_ecobee3_setup(hass: HomeAssistant) -> None: """Test that a Ecbobee 3 can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "ecobee3.json") await setup_test_accessories(hass, accessories) @@ -140,7 +141,9 @@ async def test_ecobee3_setup(hass): ) -async def test_ecobee3_setup_from_cache(hass, hass_storage): +async def test_ecobee3_setup_from_cache( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: """Test that Ecbobee can be correctly setup from its cached entity map.""" accessories = await setup_accessories_from_file(hass, "ecobee3.json") @@ -175,7 +178,7 @@ async def test_ecobee3_setup_from_cache(hass, hass_storage): assert occ3.unique_id == "00:00:00:00:00:00_4_56" -async def test_ecobee3_setup_connection_failure(hass): +async def test_ecobee3_setup_connection_failure(hass: HomeAssistant) -> None: """Test that Ecbobee can be correctly setup from its cached entity map.""" accessories = await setup_accessories_from_file(hass, "ecobee3.json") @@ -215,7 +218,7 @@ async def test_ecobee3_setup_connection_failure(hass): assert occ3.unique_id == "00:00:00:00:00:00_4_56" -async def test_ecobee3_add_sensors_at_runtime(hass): +async def test_ecobee3_add_sensors_at_runtime(hass: HomeAssistant) -> None: """Test that new sensors are automatically added.""" entity_registry = er.async_get(hass) diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee_501.py b/tests/components/homekit_controller/specific_devices/test_ecobee_501.py index 3d508df3a9..c833ea7111 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee_501.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee_501.py @@ -1,6 +1,4 @@ """Tests for Ecobee 501.""" - - from homeassistant.components.climate import ( SUPPORT_FAN_MODE, SUPPORT_TARGET_HUMIDITY, @@ -8,6 +6,7 @@ from homeassistant.components.climate import ( SUPPORT_TARGET_TEMPERATURE_RANGE, ) from homeassistant.const import STATE_ON +from homeassistant.core import HomeAssistant from ..common import ( HUB_TEST_ACCESSORY_ID, @@ -19,7 +18,7 @@ from ..common import ( ) -async def test_ecobee501_setup(hass): +async def test_ecobee501_setup(hass: HomeAssistant) -> None: """Test that a Ecobee 501 can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "ecobee_501.json") await setup_test_accessories(hass, accessories) diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py b/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py index 4018701cfe..f9d19c5f9c 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py @@ -2,6 +2,7 @@ https://github.com/home-assistant/core/issues/31827 """ +from homeassistant.core import HomeAssistant from ..common import ( HUB_TEST_ACCESSORY_ID, @@ -13,7 +14,7 @@ from ..common import ( ) -async def test_ecobee_occupancy_setup(hass): +async def test_ecobee_occupancy_setup(hass: HomeAssistant) -> None: """Test that an Ecbobee occupancy sensor be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "ecobee_occupancy.json") await setup_test_accessories(hass, accessories) diff --git a/tests/components/homekit_controller/specific_devices/test_eve_degree.py b/tests/components/homekit_controller/specific_devices/test_eve_degree.py index 4d4b4ad802..e866069ef1 100644 --- a/tests/components/homekit_controller/specific_devices/test_eve_degree.py +++ b/tests/components/homekit_controller/specific_devices/test_eve_degree.py @@ -1,8 +1,8 @@ """Make sure that Eve Degree (via Eve Extend) is enumerated properly.""" - from homeassistant.components.number import NumberMode from homeassistant.components.sensor import SensorStateClass from homeassistant.const import PERCENTAGE, TEMP_CELSIUS, EntityCategory, UnitOfPressure +from homeassistant.core import HomeAssistant from ..common import ( HUB_TEST_ACCESSORY_ID, @@ -14,7 +14,7 @@ from ..common import ( ) -async def test_eve_degree_setup(hass): +async def test_eve_degree_setup(hass: HomeAssistant) -> None: """Test that the accessory can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "eve_degree.json") await setup_test_accessories(hass, accessories) diff --git a/tests/components/homekit_controller/specific_devices/test_eve_energy.py b/tests/components/homekit_controller/specific_devices/test_eve_energy.py index a870cd090b..5f8415c507 100644 --- a/tests/components/homekit_controller/specific_devices/test_eve_energy.py +++ b/tests/components/homekit_controller/specific_devices/test_eve_energy.py @@ -1,5 +1,4 @@ """Make sure that Eve Degree (via Eve Extend) is enumerated properly.""" - from homeassistant.components.sensor import SensorStateClass from homeassistant.const import ( ELECTRIC_CURRENT_AMPERE, @@ -8,6 +7,7 @@ from homeassistant.const import ( POWER_WATT, EntityCategory, ) +from homeassistant.core import HomeAssistant from ..common import ( HUB_TEST_ACCESSORY_ID, @@ -19,7 +19,7 @@ from ..common import ( ) -async def test_eve_energy_setup(hass): +async def test_eve_energy_setup(hass: HomeAssistant) -> None: """Test that the accessory can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "eve_energy.json") await setup_test_accessories(hass, accessories) diff --git a/tests/components/homekit_controller/specific_devices/test_haa_fan.py b/tests/components/homekit_controller/specific_devices/test_haa_fan.py index c65426cd58..07a7324032 100644 --- a/tests/components/homekit_controller/specific_devices/test_haa_fan.py +++ b/tests/components/homekit_controller/specific_devices/test_haa_fan.py @@ -1,7 +1,7 @@ """Make sure that a H.A.A. fan can be setup.""" - from homeassistant.components.fan import ATTR_PERCENTAGE, FanEntityFeature from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant from ..common import ( HUB_TEST_ACCESSORY_ID, @@ -13,7 +13,7 @@ from ..common import ( ) -async def test_haa_fan_setup(hass): +async def test_haa_fan_setup(hass: HomeAssistant) -> None: """Test that a H.A.A. fan can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "haa_fan.json") await setup_test_accessories(hass, accessories) diff --git a/tests/components/homekit_controller/specific_devices/test_homeassistant_bridge.py b/tests/components/homekit_controller/specific_devices/test_homeassistant_bridge.py index 6848f4079b..84a14a8488 100644 --- a/tests/components/homekit_controller/specific_devices/test_homeassistant_bridge.py +++ b/tests/components/homekit_controller/specific_devices/test_homeassistant_bridge.py @@ -1,6 +1,6 @@ """Test against characteristics captured from the Home Assistant HomeKit bridge running demo platforms.""" - from homeassistant.components.fan import FanEntityFeature +from homeassistant.core import HomeAssistant from ..common import ( HUB_TEST_ACCESSORY_ID, @@ -12,7 +12,7 @@ from ..common import ( ) -async def test_homeassistant_bridge_fan_setup(hass): +async def test_homeassistant_bridge_fan_setup(hass: HomeAssistant) -> None: """Test that a SIMPLEconnect fan can be correctly setup in HA.""" accessories = await setup_accessories_from_file( hass, "home_assistant_bridge_fan.json" diff --git a/tests/components/homekit_controller/specific_devices/test_hue_bridge.py b/tests/components/homekit_controller/specific_devices/test_hue_bridge.py index bf118af7dc..f1f8b69038 100644 --- a/tests/components/homekit_controller/specific_devices/test_hue_bridge.py +++ b/tests/components/homekit_controller/specific_devices/test_hue_bridge.py @@ -1,7 +1,7 @@ """Tests for handling accessories on a Hue bridge via HomeKit.""" - from homeassistant.components.sensor import SensorStateClass from homeassistant.const import PERCENTAGE, EntityCategory +from homeassistant.core import HomeAssistant from ..common import ( HUB_TEST_ACCESSORY_ID, @@ -14,7 +14,7 @@ from ..common import ( ) -async def test_hue_bridge_setup(hass): +async def test_hue_bridge_setup(hass: HomeAssistant) -> None: """Test that a Hue hub can be correctly setup in HA via HomeKit.""" accessories = await setup_accessories_from_file(hass, "hue_bridge.json") await setup_test_accessories(hass, accessories) diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py index 934638d644..ca2392be4c 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py @@ -1,5 +1,4 @@ """Make sure that existing Koogeek LS1 support isn't broken.""" - from datetime import timedelta from unittest import mock @@ -9,6 +8,7 @@ from aiohomekit.testing import FakePairing import pytest from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util from ..common import ( @@ -26,7 +26,7 @@ from tests.common import async_fire_time_changed LIGHT_ON = ("lightbulb", "on") -async def test_koogeek_ls1_setup(hass): +async def test_koogeek_ls1_setup(hass: HomeAssistant) -> None: """Test that a Koogeek LS1 can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "koogeek_ls1.json") await setup_test_accessories(hass, accessories) @@ -64,7 +64,7 @@ async def test_koogeek_ls1_setup(hass): @pytest.mark.parametrize("failure_cls", [AccessoryDisconnectedError, EncryptionError]) -async def test_recover_from_failure(hass, utcnow, failure_cls): +async def test_recover_from_failure(hass: HomeAssistant, utcnow, failure_cls) -> None: """Test that entity actually recovers from a network connection drop. See https://github.com/home-assistant/core/issues/18949 diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py b/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py index ee8c273904..91506382a8 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py @@ -1,7 +1,7 @@ """Make sure that existing Koogeek P1EU support isn't broken.""" - from homeassistant.components.sensor import SensorStateClass from homeassistant.const import POWER_WATT +from homeassistant.core import HomeAssistant from ..common import ( HUB_TEST_ACCESSORY_ID, @@ -13,7 +13,7 @@ from ..common import ( ) -async def test_koogeek_p1eu_setup(hass): +async def test_koogeek_p1eu_setup(hass: HomeAssistant) -> None: """Test that a Koogeek P1EU can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "koogeek_p1eu.json") await setup_test_accessories(hass, accessories) diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py b/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py index 21c434266a..44293ac439 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py @@ -4,9 +4,9 @@ This Koogeek device has a custom power sensor that extra handling. It should have 2 entities - the actual switch and a sensor for power usage. """ - from homeassistant.components.sensor import SensorStateClass from homeassistant.const import POWER_WATT +from homeassistant.core import HomeAssistant from ..common import ( HUB_TEST_ACCESSORY_ID, @@ -18,7 +18,7 @@ from ..common import ( ) -async def test_koogeek_sw2_setup(hass): +async def test_koogeek_sw2_setup(hass: HomeAssistant) -> None: """Test that a Koogeek LS1 can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "koogeek_sw2.json") await setup_test_accessories(hass, accessories) diff --git a/tests/components/homekit_controller/specific_devices/test_lennox_e30.py b/tests/components/homekit_controller/specific_devices/test_lennox_e30.py index 9d37c1d4b7..4578014f00 100644 --- a/tests/components/homekit_controller/specific_devices/test_lennox_e30.py +++ b/tests/components/homekit_controller/specific_devices/test_lennox_e30.py @@ -2,11 +2,11 @@ https://github.com/home-assistant/core/issues/20885 """ - from homeassistant.components.climate import ( SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE_RANGE, ) +from homeassistant.core import HomeAssistant from ..common import ( HUB_TEST_ACCESSORY_ID, @@ -18,7 +18,7 @@ from ..common import ( ) -async def test_lennox_e30_setup(hass): +async def test_lennox_e30_setup(hass: HomeAssistant) -> None: """Test that a Lennox E30 can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "lennox_e30.json") await setup_test_accessories(hass, accessories) diff --git a/tests/components/homekit_controller/specific_devices/test_lg_tv.py b/tests/components/homekit_controller/specific_devices/test_lg_tv.py index e61ac6dc02..f35e7da2bd 100644 --- a/tests/components/homekit_controller/specific_devices/test_lg_tv.py +++ b/tests/components/homekit_controller/specific_devices/test_lg_tv.py @@ -1,6 +1,6 @@ """Make sure that handling real world LG HomeKit characteristics isn't broken.""" - from homeassistant.components.media_player import MediaPlayerEntityFeature +from homeassistant.core import HomeAssistant from ..common import ( HUB_TEST_ACCESSORY_ID, @@ -12,7 +12,7 @@ from ..common import ( ) -async def test_lg_tv(hass): +async def test_lg_tv(hass: HomeAssistant) -> None: """Test that a Koogeek LS1 can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "lg_tv.json") await setup_test_accessories(hass, accessories) diff --git a/tests/components/homekit_controller/specific_devices/test_lutron_caseta_bridge.py b/tests/components/homekit_controller/specific_devices/test_lutron_caseta_bridge.py index 76c5bc70bf..9cb65907e8 100644 --- a/tests/components/homekit_controller/specific_devices/test_lutron_caseta_bridge.py +++ b/tests/components/homekit_controller/specific_devices/test_lutron_caseta_bridge.py @@ -1,6 +1,6 @@ """Tests for handling accessories on a Lutron Caseta bridge via HomeKit.""" - from homeassistant.const import STATE_OFF +from homeassistant.core import HomeAssistant from ..common import ( HUB_TEST_ACCESSORY_ID, @@ -12,7 +12,7 @@ from ..common import ( ) -async def test_lutron_caseta_bridge_setup(hass): +async def test_lutron_caseta_bridge_setup(hass: HomeAssistant) -> None: """Test that a Lutron Caseta bridge can be correctly setup in HA via HomeKit.""" accessories = await setup_accessories_from_file(hass, "lutron_caseta_bridge.json") await setup_test_accessories(hass, accessories) diff --git a/tests/components/homekit_controller/specific_devices/test_mss425f.py b/tests/components/homekit_controller/specific_devices/test_mss425f.py index b358c15abc..1ab608e3d2 100644 --- a/tests/components/homekit_controller/specific_devices/test_mss425f.py +++ b/tests/components/homekit_controller/specific_devices/test_mss425f.py @@ -1,7 +1,6 @@ """Tests for the Meross MSS425f power strip.""" - - from homeassistant.const import STATE_ON, STATE_UNKNOWN, EntityCategory +from homeassistant.core import HomeAssistant from ..common import ( HUB_TEST_ACCESSORY_ID, @@ -13,7 +12,7 @@ from ..common import ( ) -async def test_meross_mss425f_setup(hass): +async def test_meross_mss425f_setup(hass: HomeAssistant) -> None: """Test that a MSS425f can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "mss425f.json") await setup_test_accessories(hass, accessories) diff --git a/tests/components/homekit_controller/specific_devices/test_mss565.py b/tests/components/homekit_controller/specific_devices/test_mss565.py index 5140b563a9..78d8d8f525 100644 --- a/tests/components/homekit_controller/specific_devices/test_mss565.py +++ b/tests/components/homekit_controller/specific_devices/test_mss565.py @@ -1,7 +1,6 @@ """Tests for the Meross MSS565 wall switch.""" - - from homeassistant.const import STATE_ON +from homeassistant.core import HomeAssistant from ..common import ( HUB_TEST_ACCESSORY_ID, @@ -13,7 +12,7 @@ from ..common import ( ) -async def test_meross_mss565_setup(hass): +async def test_meross_mss565_setup(hass: HomeAssistant) -> None: """Test that a MSS565 can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "mss565.json") await setup_test_accessories(hass, accessories) diff --git a/tests/components/homekit_controller/specific_devices/test_mysa_living.py b/tests/components/homekit_controller/specific_devices/test_mysa_living.py index 83404d9dd9..ca9c6cecde 100644 --- a/tests/components/homekit_controller/specific_devices/test_mysa_living.py +++ b/tests/components/homekit_controller/specific_devices/test_mysa_living.py @@ -1,8 +1,8 @@ """Make sure that Mysa Living is enumerated properly.""" - from homeassistant.components.climate import ClimateEntityFeature from homeassistant.components.sensor import SensorStateClass from homeassistant.const import PERCENTAGE, TEMP_CELSIUS +from homeassistant.core import HomeAssistant from ..common import ( HUB_TEST_ACCESSORY_ID, @@ -14,7 +14,7 @@ from ..common import ( ) -async def test_mysa_living_setup(hass): +async def test_mysa_living_setup(hass: HomeAssistant) -> None: """Test that the accessory can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "mysa_living.json") await setup_test_accessories(hass, accessories) diff --git a/tests/components/homekit_controller/specific_devices/test_nanoleaf_strip_nl55.py b/tests/components/homekit_controller/specific_devices/test_nanoleaf_strip_nl55.py index 16358ab96e..629059935c 100644 --- a/tests/components/homekit_controller/specific_devices/test_nanoleaf_strip_nl55.py +++ b/tests/components/homekit_controller/specific_devices/test_nanoleaf_strip_nl55.py @@ -1,6 +1,6 @@ """Make sure that Nanoleaf NL55 works with BLE.""" - from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant from ..common import ( HUB_TEST_ACCESSORY_ID, @@ -14,7 +14,7 @@ from ..common import ( LIGHT_ON = ("lightbulb", "on") -async def test_nanoleaf_nl55_setup(hass): +async def test_nanoleaf_nl55_setup(hass: HomeAssistant) -> None: """Test that a Nanoleaf NL55 can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "nanoleaf_strip_nl55.json") await setup_test_accessories(hass, accessories) diff --git a/tests/components/homekit_controller/specific_devices/test_netamo_doorbell.py b/tests/components/homekit_controller/specific_devices/test_netamo_doorbell.py index 0ebd38b9ba..d65baf9388 100644 --- a/tests/components/homekit_controller/specific_devices/test_netamo_doorbell.py +++ b/tests/components/homekit_controller/specific_devices/test_netamo_doorbell.py @@ -2,6 +2,7 @@ https://github.com/home-assistant/core/issues/44596 """ +from homeassistant.core import HomeAssistant from ..common import ( HUB_TEST_ACCESSORY_ID, @@ -14,7 +15,7 @@ from ..common import ( ) -async def test_netamo_doorbell_setup(hass): +async def test_netamo_doorbell_setup(hass: HomeAssistant) -> None: """Test that a Netamo Doorbell can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "netamo_doorbell.json") await setup_test_accessories(hass, accessories) diff --git a/tests/components/homekit_controller/specific_devices/test_netamo_smart_co_alarm.py b/tests/components/homekit_controller/specific_devices/test_netamo_smart_co_alarm.py index 4157b6e424..71807871cc 100644 --- a/tests/components/homekit_controller/specific_devices/test_netamo_smart_co_alarm.py +++ b/tests/components/homekit_controller/specific_devices/test_netamo_smart_co_alarm.py @@ -3,6 +3,7 @@ https://github.com/home-assistant/core/issues/78903 """ from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant from ..common import ( HUB_TEST_ACCESSORY_ID, @@ -14,7 +15,7 @@ from ..common import ( ) -async def test_netamo_smart_co_alarm_setup(hass): +async def test_netamo_smart_co_alarm_setup(hass: HomeAssistant) -> None: """Test that a Netamo Smart CO Alarm can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "netamo_smart_co_alarm.json") await setup_test_accessories(hass, accessories) diff --git a/tests/components/homekit_controller/specific_devices/test_netatmo_home_coach.py b/tests/components/homekit_controller/specific_devices/test_netatmo_home_coach.py index 34270cd555..e9e6749bd3 100644 --- a/tests/components/homekit_controller/specific_devices/test_netatmo_home_coach.py +++ b/tests/components/homekit_controller/specific_devices/test_netatmo_home_coach.py @@ -3,6 +3,7 @@ https://github.com/home-assistant/core/issues/73360 """ from homeassistant.components.sensor import SensorStateClass +from homeassistant.core import HomeAssistant from ..common import ( HUB_TEST_ACCESSORY_ID, @@ -14,7 +15,7 @@ from ..common import ( ) -async def test_netamo_smart_co_alarm_setup(hass): +async def test_netamo_smart_co_alarm_setup(hass: HomeAssistant) -> None: """Test that a Netamo Smart CO Alarm can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "netatmo_home_coach.json") await setup_test_accessories(hass, accessories) diff --git a/tests/components/homekit_controller/specific_devices/test_rainmachine_pro_8.py b/tests/components/homekit_controller/specific_devices/test_rainmachine_pro_8.py index 42696eb071..24a4dbe034 100644 --- a/tests/components/homekit_controller/specific_devices/test_rainmachine_pro_8.py +++ b/tests/components/homekit_controller/specific_devices/test_rainmachine_pro_8.py @@ -2,6 +2,7 @@ https://github.com/home-assistant/core/issues/31745 """ +from homeassistant.core import HomeAssistant from ..common import ( HUB_TEST_ACCESSORY_ID, @@ -13,7 +14,7 @@ from ..common import ( ) -async def test_rainmachine_pro_8_setup(hass): +async def test_rainmachine_pro_8_setup(hass: HomeAssistant) -> None: """Test that a RainMachine can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "rainmachine-pro-8.json") await setup_test_accessories(hass, accessories) diff --git a/tests/components/homekit_controller/specific_devices/test_ryse_smart_bridge.py b/tests/components/homekit_controller/specific_devices/test_ryse_smart_bridge.py index fe6dc0acfe..d56aa4ad48 100644 --- a/tests/components/homekit_controller/specific_devices/test_ryse_smart_bridge.py +++ b/tests/components/homekit_controller/specific_devices/test_ryse_smart_bridge.py @@ -1,8 +1,8 @@ """Test against characteristics captured from a ryse smart bridge platforms.""" - from homeassistant.components.cover import CoverEntityFeature from homeassistant.components.sensor import SensorStateClass from homeassistant.const import PERCENTAGE, EntityCategory +from homeassistant.core import HomeAssistant from ..common import ( HUB_TEST_ACCESSORY_ID, @@ -18,7 +18,7 @@ RYSE_SUPPORTED_FEATURES = ( ) -async def test_ryse_smart_bridge_setup(hass): +async def test_ryse_smart_bridge_setup(hass: HomeAssistant) -> None: """Test that a Ryse smart bridge can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "ryse_smart_bridge.json") await setup_test_accessories(hass, accessories) @@ -95,7 +95,7 @@ async def test_ryse_smart_bridge_setup(hass): ) -async def test_ryse_smart_bridge_four_shades_setup(hass): +async def test_ryse_smart_bridge_four_shades_setup(hass: HomeAssistant) -> None: """Test that a Ryse smart bridge with four shades can be correctly setup in HA.""" accessories = await setup_accessories_from_file( hass, "ryse_smart_bridge_four_shades.json" diff --git a/tests/components/homekit_controller/specific_devices/test_schlage_sense.py b/tests/components/homekit_controller/specific_devices/test_schlage_sense.py index e1b55f6bd8..6ed0a97c23 100644 --- a/tests/components/homekit_controller/specific_devices/test_schlage_sense.py +++ b/tests/components/homekit_controller/specific_devices/test_schlage_sense.py @@ -1,5 +1,5 @@ """Make sure that Schlage Sense is enumerated properly.""" - +from homeassistant.core import HomeAssistant from ..common import ( HUB_TEST_ACCESSORY_ID, @@ -11,7 +11,7 @@ from ..common import ( ) -async def test_schlage_sense_setup(hass): +async def test_schlage_sense_setup(hass: HomeAssistant) -> None: """Test that the accessory can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "schlage_sense.json") await setup_test_accessories(hass, accessories) diff --git a/tests/components/homekit_controller/specific_devices/test_simpleconnect_fan.py b/tests/components/homekit_controller/specific_devices/test_simpleconnect_fan.py index 3918f30ecd..59e7d2855e 100644 --- a/tests/components/homekit_controller/specific_devices/test_simpleconnect_fan.py +++ b/tests/components/homekit_controller/specific_devices/test_simpleconnect_fan.py @@ -2,8 +2,8 @@ https://github.com/home-assistant/core/issues/26180 """ - from homeassistant.components.fan import FanEntityFeature +from homeassistant.core import HomeAssistant from ..common import ( HUB_TEST_ACCESSORY_ID, @@ -15,7 +15,7 @@ from ..common import ( ) -async def test_simpleconnect_fan_setup(hass): +async def test_simpleconnect_fan_setup(hass: HomeAssistant) -> None: """Test that a SIMPLEconnect fan can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "simpleconnect_fan.json") await setup_test_accessories(hass, accessories) diff --git a/tests/components/homekit_controller/specific_devices/test_velux_gateway.py b/tests/components/homekit_controller/specific_devices/test_velux_gateway.py index 213a89be7b..384b1d49d7 100644 --- a/tests/components/homekit_controller/specific_devices/test_velux_gateway.py +++ b/tests/components/homekit_controller/specific_devices/test_velux_gateway.py @@ -2,7 +2,6 @@ https://github.com/home-assistant/core/issues/44314 """ - from homeassistant.components.cover import CoverEntityFeature from homeassistant.components.sensor import SensorStateClass from homeassistant.const import ( @@ -10,6 +9,7 @@ from homeassistant.const import ( PERCENTAGE, TEMP_CELSIUS, ) +from homeassistant.core import HomeAssistant from ..common import ( HUB_TEST_ACCESSORY_ID, @@ -21,7 +21,7 @@ from ..common import ( ) -async def test_velux_cover_setup(hass): +async def test_velux_cover_setup(hass: HomeAssistant) -> None: """Test that a velux gateway can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "velux_gateway.json") await setup_test_accessories(hass, accessories) diff --git a/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py b/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py index 5435a1b33a..fed8f05b0b 100644 --- a/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py +++ b/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py @@ -1,9 +1,9 @@ """Make sure that Vocolinc Flowerbud is enumerated properly.""" - from homeassistant.components.humidifier import HumidifierEntityFeature from homeassistant.components.number import NumberMode from homeassistant.components.sensor import SensorStateClass from homeassistant.const import PERCENTAGE, EntityCategory +from homeassistant.core import HomeAssistant from ..common import ( HUB_TEST_ACCESSORY_ID, @@ -15,7 +15,7 @@ from ..common import ( ) -async def test_vocolinc_flowerbud_setup(hass): +async def test_vocolinc_flowerbud_setup(hass: HomeAssistant) -> None: """Test that a Vocolinc Flowerbud can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "vocolinc_flowerbud.json") await setup_test_accessories(hass, accessories) diff --git a/tests/components/homekit_controller/specific_devices/test_vocolinc_vp3.py b/tests/components/homekit_controller/specific_devices/test_vocolinc_vp3.py index 7ced8979c8..9c51707b80 100644 --- a/tests/components/homekit_controller/specific_devices/test_vocolinc_vp3.py +++ b/tests/components/homekit_controller/specific_devices/test_vocolinc_vp3.py @@ -1,7 +1,7 @@ """Make sure that existing VOCOlinc VP3 support isn't broken.""" - from homeassistant.components.sensor import SensorStateClass from homeassistant.const import POWER_WATT +from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from ..common import ( @@ -14,7 +14,7 @@ from ..common import ( ) -async def test_vocolinc_vp3_setup(hass): +async def test_vocolinc_vp3_setup(hass: HomeAssistant) -> None: """Test that a VOCOlinc VP3 can be correctly setup in HA.""" entity_registry = er.async_get(hass) diff --git a/tests/components/shelly/bluetooth/test_scanner.py b/tests/components/shelly/bluetooth/test_scanner.py index 160dc55789..bd44782f92 100644 --- a/tests/components/shelly/bluetooth/test_scanner.py +++ b/tests/components/shelly/bluetooth/test_scanner.py @@ -2,14 +2,16 @@ from __future__ import annotations from aioshelly.ble.const import BLE_SCAN_RESULT_EVENT +import pytest from homeassistant.components import bluetooth from homeassistant.components.shelly.const import CONF_BLE_SCANNER_MODE, BLEScannerMode +from homeassistant.core import HomeAssistant from .. import init_integration, inject_rpc_device_event -async def test_scanner(hass, mock_rpc_device, monkeypatch): +async def test_scanner(hass: HomeAssistant, mock_rpc_device, monkeypatch) -> None: """Test injecting data into the scanner.""" await init_integration( hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} @@ -47,7 +49,9 @@ async def test_scanner(hass, mock_rpc_device, monkeypatch): assert ble_device is None -async def test_scanner_ignores_non_ble_events(hass, mock_rpc_device, monkeypatch): +async def test_scanner_ignores_non_ble_events( + hass: HomeAssistant, mock_rpc_device, monkeypatch +) -> None: """Test injecting non ble data into the scanner.""" await init_integration( hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} @@ -72,8 +76,8 @@ async def test_scanner_ignores_non_ble_events(hass, mock_rpc_device, monkeypatch async def test_scanner_ignores_wrong_version_and_logs( - hass, mock_rpc_device, monkeypatch, caplog -): + hass: HomeAssistant, mock_rpc_device, monkeypatch, caplog: pytest.LogCaptureFixture +) -> None: """Test injecting wrong version of ble data into the scanner.""" await init_integration( hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} @@ -105,8 +109,8 @@ async def test_scanner_ignores_wrong_version_and_logs( async def test_scanner_minimum_firmware_log_error( - hass, mock_rpc_device, monkeypatch, caplog -): + hass: HomeAssistant, mock_rpc_device, monkeypatch, caplog: pytest.LogCaptureFixture +) -> None: """Test scanner log error if device firmware incompatible.""" monkeypatch.setattr(mock_rpc_device, "version", "0.11.0") await init_integration( @@ -118,8 +122,8 @@ async def test_scanner_minimum_firmware_log_error( async def test_scanner_warns_on_corrupt_event( - hass, mock_rpc_device, monkeypatch, caplog -): + hass: HomeAssistant, mock_rpc_device, monkeypatch, caplog: pytest.LogCaptureFixture +) -> None: """Test injecting garbage ble data into the scanner.""" await init_integration( hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} From a51cc75f03d4aa17ef69558571ea647b3752dc48 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 21 Feb 2023 09:27:13 +0100 Subject: [PATCH 0257/2810] Add type hints to core tests (part 2) (#88492) --- tests/auth/providers/test_command_line.py | 18 +- tests/auth/providers/test_homeassistant.py | 44 ++-- tests/auth/providers/test_insecure_example.py | 10 +- .../providers/test_legacy_api_password.py | 9 +- tests/auth/providers/test_trusted_networks.py | 19 +- tests/test_bootstrap.py | 20 +- tests/test_config.py | 69 ++++-- tests/test_config_entries.py | 211 ++++++++++++------ tests/test_core.py | 40 ++-- tests/test_data_entry_flow.py | 38 ++-- tests/test_loader.py | 36 ++- tests/test_main.py | 4 +- tests/test_requirements.py | 8 +- tests/test_runner.py | 18 +- tests/test_setup.py | 30 ++- tests/test_test_fixtures.py | 2 +- tests/util/yaml/test_init.py | 23 +- 17 files changed, 383 insertions(+), 216 deletions(-) diff --git a/tests/auth/providers/test_command_line.py b/tests/auth/providers/test_command_line.py index 2216981215..97f8f65939 100644 --- a/tests/auth/providers/test_command_line.py +++ b/tests/auth/providers/test_command_line.py @@ -41,7 +41,7 @@ def manager(hass, store, provider): return AuthManager(hass, store, {(provider.type, provider.id): provider}, {}) -async def test_create_new_credential(manager, provider): +async def test_create_new_credential(manager, provider) -> None: """Test that we create a new credential.""" credentials = await provider.async_get_or_create_credentials( {"username": "good-user", "password": "good-pass"} @@ -52,7 +52,7 @@ async def test_create_new_credential(manager, provider): assert user.is_active -async def test_match_existing_credentials(store, provider): +async def test_match_existing_credentials(store, provider) -> None: """See if we match existing users.""" existing = auth_models.Credentials( id=uuid.uuid4(), @@ -68,24 +68,24 @@ async def test_match_existing_credentials(store, provider): assert credentials is existing -async def test_invalid_username(provider): +async def test_invalid_username(provider) -> None: """Test we raise if incorrect user specified.""" with pytest.raises(command_line.InvalidAuthError): await provider.async_validate_login("bad-user", "good-pass") -async def test_invalid_password(provider): +async def test_invalid_password(provider) -> None: """Test we raise if incorrect password specified.""" with pytest.raises(command_line.InvalidAuthError): await provider.async_validate_login("good-user", "bad-pass") -async def test_good_auth(provider): +async def test_good_auth(provider) -> None: """Test nothing is raised with good credentials.""" await provider.async_validate_login("good-user", "good-pass") -async def test_good_auth_with_meta(manager, provider): +async def test_good_auth_with_meta(manager, provider) -> None: """Test metadata is added upon successful authentication.""" provider.config[command_line.CONF_ARGS] = ["--with-meta"] provider.config[command_line.CONF_META] = True @@ -102,7 +102,7 @@ async def test_good_auth_with_meta(manager, provider): assert user.is_active -async def test_utf_8_username_password(provider): +async def test_utf_8_username_password(provider) -> None: """Test that we create a new credential.""" credentials = await provider.async_get_or_create_credentials( {"username": "ßßß", "password": "äöü"} @@ -110,7 +110,7 @@ async def test_utf_8_username_password(provider): assert credentials.is_new is True -async def test_login_flow_validates(provider): +async def test_login_flow_validates(provider) -> None: """Test login flow.""" flow = await provider.async_login_flow({}) result = await flow.async_step_init() @@ -129,7 +129,7 @@ async def test_login_flow_validates(provider): assert result["data"]["username"] == "good-user" -async def test_strip_username(provider): +async def test_strip_username(provider) -> None: """Test authentication works with username with whitespace around.""" flow = await provider.async_login_flow({}) result = await flow.async_step_init( diff --git a/tests/auth/providers/test_homeassistant.py b/tests/auth/providers/test_homeassistant.py index 1fecdf583a..222911a61c 100644 --- a/tests/auth/providers/test_homeassistant.py +++ b/tests/auth/providers/test_homeassistant.py @@ -31,7 +31,7 @@ def legacy_data(hass): return data -async def test_validating_password_invalid_user(data, hass): +async def test_validating_password_invalid_user(data, hass: HomeAssistant) -> None: """Test validating an invalid user.""" with pytest.raises(hass_auth.InvalidAuth): data.validate_login("non-existing", "pw") @@ -46,7 +46,7 @@ async def test_not_allow_set_id() -> None: ) -async def test_new_users_populate_values(hass, data): +async def test_new_users_populate_values(hass: HomeAssistant, data) -> None: """Test that we populate data for new users.""" data.add_auth("hello", "test-pass") await data.async_save() @@ -59,7 +59,7 @@ async def test_new_users_populate_values(hass, data): assert user.is_active -async def test_changing_password_raises_invalid_user(data, hass): +async def test_changing_password_raises_invalid_user(data, hass: HomeAssistant) -> None: """Test that changing password raises invalid user.""" with pytest.raises(hass_auth.InvalidUser): data.change_password("non-existing", "pw") @@ -68,20 +68,20 @@ async def test_changing_password_raises_invalid_user(data, hass): # Modern mode -async def test_adding_user(data, hass): +async def test_adding_user(data, hass: HomeAssistant) -> None: """Test adding a user.""" data.add_auth("test-user", "test-pass") data.validate_login(" test-user ", "test-pass") -async def test_adding_user_duplicate_username(data, hass): +async def test_adding_user_duplicate_username(data, hass: HomeAssistant) -> None: """Test adding a user with duplicate username.""" data.add_auth("test-user", "test-pass") with pytest.raises(hass_auth.InvalidUser): data.add_auth("TEST-user ", "other-pass") -async def test_validating_password_invalid_password(data, hass): +async def test_validating_password_invalid_password(data, hass: HomeAssistant) -> None: """Test validating an invalid password.""" data.add_auth("test-user", "test-pass") @@ -95,7 +95,7 @@ async def test_validating_password_invalid_password(data, hass): data.validate_login("test-user", "Test-pass") -async def test_changing_password(data, hass): +async def test_changing_password(data, hass: HomeAssistant) -> None: """Test adding a user.""" data.add_auth("test-user", "test-pass") data.change_password("TEST-USER ", "new-pass") @@ -106,7 +106,7 @@ async def test_changing_password(data, hass): data.validate_login("test-UsEr", "new-pass") -async def test_login_flow_validates(data, hass): +async def test_login_flow_validates(data, hass: HomeAssistant) -> None: """Test login flow.""" data.add_auth("test-user", "test-pass") await data.async_save() @@ -137,7 +137,7 @@ async def test_login_flow_validates(data, hass): assert result["data"]["username"] == "test-USER" -async def test_saving_loading(data, hass): +async def test_saving_loading(data, hass: HomeAssistant) -> None: """Test saving and loading JSON.""" data.add_auth("test-user", "test-pass") data.add_auth("second-user", "second-pass") @@ -149,7 +149,7 @@ async def test_saving_loading(data, hass): data.validate_login("second-user ", "second-pass") -async def test_get_or_create_credentials(hass, data): +async def test_get_or_create_credentials(hass: HomeAssistant, data) -> None: """Test that we can get or create credentials.""" manager = await auth_manager_from_config(hass, [{"type": "homeassistant"}], []) provider = manager.auth_providers[0] @@ -165,13 +165,15 @@ async def test_get_or_create_credentials(hass, data): # Legacy mode -async def test_legacy_adding_user(legacy_data, hass): +async def test_legacy_adding_user(legacy_data, hass: HomeAssistant) -> None: """Test in legacy mode adding a user.""" legacy_data.add_auth("test-user", "test-pass") legacy_data.validate_login("test-user", "test-pass") -async def test_legacy_adding_user_duplicate_username(legacy_data, hass): +async def test_legacy_adding_user_duplicate_username( + legacy_data, hass: HomeAssistant +) -> None: """Test in legacy mode adding a user with duplicate username.""" legacy_data.add_auth("test-user", "test-pass") with pytest.raises(hass_auth.InvalidUser): @@ -181,7 +183,9 @@ async def test_legacy_adding_user_duplicate_username(legacy_data, hass): legacy_data.add_auth("Test-user", "test-pass") -async def test_legacy_validating_password_invalid_password(legacy_data, hass): +async def test_legacy_validating_password_invalid_password( + legacy_data, hass: HomeAssistant +) -> None: """Test in legacy mode validating an invalid password.""" legacy_data.add_auth("test-user", "test-pass") @@ -189,7 +193,7 @@ async def test_legacy_validating_password_invalid_password(legacy_data, hass): legacy_data.validate_login("test-user", "invalid-pass") -async def test_legacy_changing_password(legacy_data, hass): +async def test_legacy_changing_password(legacy_data, hass: HomeAssistant) -> None: """Test in legacy mode adding a user.""" user = "test-user" legacy_data.add_auth(user, "test-pass") @@ -201,13 +205,15 @@ async def test_legacy_changing_password(legacy_data, hass): legacy_data.validate_login(user, "new-pass") -async def test_legacy_changing_password_raises_invalid_user(legacy_data, hass): +async def test_legacy_changing_password_raises_invalid_user( + legacy_data, hass: HomeAssistant +) -> None: """Test in legacy mode that we initialize an empty config.""" with pytest.raises(hass_auth.InvalidUser): legacy_data.change_password("non-existing", "pw") -async def test_legacy_login_flow_validates(legacy_data, hass): +async def test_legacy_login_flow_validates(legacy_data, hass: HomeAssistant) -> None: """Test in legacy mode login flow.""" legacy_data.add_auth("test-user", "test-pass") await legacy_data.async_save() @@ -238,7 +244,7 @@ async def test_legacy_login_flow_validates(legacy_data, hass): assert result["data"]["username"] == "test-user" -async def test_legacy_saving_loading(legacy_data, hass): +async def test_legacy_saving_loading(legacy_data, hass: HomeAssistant) -> None: """Test in legacy mode saving and loading JSON.""" legacy_data.add_auth("test-user", "test-pass") legacy_data.add_auth("second-user", "second-pass") @@ -254,7 +260,9 @@ async def test_legacy_saving_loading(legacy_data, hass): legacy_data.validate_login("test-user ", "test-pass") -async def test_legacy_get_or_create_credentials(hass, legacy_data): +async def test_legacy_get_or_create_credentials( + hass: HomeAssistant, legacy_data +) -> None: """Test in legacy mode that we can get or create credentials.""" manager = await auth_manager_from_config(hass, [{"type": "homeassistant"}], []) provider = manager.auth_providers[0] diff --git a/tests/auth/providers/test_insecure_example.py b/tests/auth/providers/test_insecure_example.py index 235f9a4735..6054b7937c 100644 --- a/tests/auth/providers/test_insecure_example.py +++ b/tests/auth/providers/test_insecure_example.py @@ -40,7 +40,7 @@ def manager(hass, store, provider): return AuthManager(hass, store, {(provider.type, provider.id): provider}, {}) -async def test_create_new_credential(manager, provider): +async def test_create_new_credential(manager, provider) -> None: """Test that we create a new credential.""" credentials = await provider.async_get_or_create_credentials( {"username": "user-test", "password": "password-test"} @@ -52,7 +52,7 @@ async def test_create_new_credential(manager, provider): assert user.is_active -async def test_match_existing_credentials(store, provider): +async def test_match_existing_credentials(store, provider) -> None: """See if we match existing users.""" existing = auth_models.Credentials( id=uuid.uuid4(), @@ -68,19 +68,19 @@ async def test_match_existing_credentials(store, provider): assert credentials is existing -async def test_verify_username(provider): +async def test_verify_username(provider) -> None: """Test we raise if incorrect user specified.""" with pytest.raises(insecure_example.InvalidAuthError): await provider.async_validate_login("non-existing-user", "password-test") -async def test_verify_password(provider): +async def test_verify_password(provider) -> None: """Test we raise if incorrect user specified.""" with pytest.raises(insecure_example.InvalidAuthError): await provider.async_validate_login("user-test", "incorrect-password") -async def test_utf_8_username_password(provider): +async def test_utf_8_username_password(provider) -> None: """Test that we create a new credential.""" credentials = await provider.async_get_or_create_credentials( {"username": "🎉", "password": "😎"} diff --git a/tests/auth/providers/test_legacy_api_password.py b/tests/auth/providers/test_legacy_api_password.py index 19af0a1e74..7c2335f7cc 100644 --- a/tests/auth/providers/test_legacy_api_password.py +++ b/tests/auth/providers/test_legacy_api_password.py @@ -4,6 +4,7 @@ import pytest from homeassistant import auth, data_entry_flow from homeassistant.auth import auth_store from homeassistant.auth.providers import legacy_api_password +from homeassistant.core import HomeAssistant @pytest.fixture @@ -26,7 +27,7 @@ def manager(hass, store, provider): return auth.AuthManager(hass, store, {(provider.type, provider.id): provider}, {}) -async def test_create_new_credential(manager, provider): +async def test_create_new_credential(manager, provider) -> None: """Test that we create a new credential.""" credentials = await provider.async_get_or_create_credentials({}) assert credentials.is_new is True @@ -36,7 +37,7 @@ async def test_create_new_credential(manager, provider): assert user.is_active -async def test_only_one_credentials(manager, provider): +async def test_only_one_credentials(manager, provider) -> None: """Call create twice will return same credential.""" credentials = await provider.async_get_or_create_credentials({}) await manager.async_get_or_create_user(credentials) @@ -45,14 +46,14 @@ async def test_only_one_credentials(manager, provider): assert credentials2.is_new is False -async def test_verify_login(hass, provider): +async def test_verify_login(hass: HomeAssistant, provider) -> None: """Test login using legacy api password auth provider.""" provider.async_validate_login("test-password") with pytest.raises(legacy_api_password.InvalidAuthError): provider.async_validate_login("invalid-password") -async def test_login_flow_works(hass, manager): +async def test_login_flow_works(hass: HomeAssistant, manager) -> None: """Test wrong config.""" result = await manager.login_flow.async_init(handler=("legacy_api_password", None)) assert result["type"] == data_entry_flow.FlowResultType.FORM diff --git a/tests/auth/providers/test_trusted_networks.py b/tests/auth/providers/test_trusted_networks.py index 15e831f551..53d1f0a9fa 100644 --- a/tests/auth/providers/test_trusted_networks.py +++ b/tests/auth/providers/test_trusted_networks.py @@ -10,6 +10,7 @@ from homeassistant import auth from homeassistant.auth import auth_store from homeassistant.auth.providers import trusted_networks as tn_auth from homeassistant.components.http import CONF_TRUSTED_PROXIES, CONF_USE_X_FORWARDED_FOR +from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.setup import async_setup_component @@ -115,7 +116,7 @@ def manager_bypass_login(hass, store, provider_bypass_login): ) -async def test_trusted_networks_credentials(manager, provider): +async def test_trusted_networks_credentials(manager, provider) -> None: """Test trusted_networks credentials related functions.""" owner = await manager.async_create_user("test-owner") tn_owner_cred = await provider.async_get_or_create_credentials({"user": owner.id}) @@ -132,7 +133,7 @@ async def test_trusted_networks_credentials(manager, provider): await provider.async_get_or_create_credentials({"user": "invalid-user"}) -async def test_validate_access(provider): +async def test_validate_access(provider) -> None: """Test validate access from trusted networks.""" provider.async_validate_access(ip_address("192.168.0.1")) provider.async_validate_access(ip_address("192.168.128.10")) @@ -147,7 +148,7 @@ async def test_validate_access(provider): provider.async_validate_access(ip_address("2001:db8::ff00:42:8329")) -async def test_validate_access_proxy(hass, provider): +async def test_validate_access_proxy(hass: HomeAssistant, provider) -> None: """Test validate access from trusted networks are blocked from proxy.""" await async_setup_component( @@ -170,7 +171,7 @@ async def test_validate_access_proxy(hass, provider): provider.async_validate_access(ip_address("fd00::1")) -async def test_validate_access_cloud(hass, provider): +async def test_validate_access_cloud(hass: HomeAssistant, provider) -> None: """Test validate access from trusted networks are blocked from cloud.""" await async_setup_component( hass, @@ -191,7 +192,7 @@ async def test_validate_access_cloud(hass, provider): provider.async_validate_access(ip_address("192.168.128.2")) -async def test_validate_refresh_token(provider): +async def test_validate_refresh_token(provider) -> None: """Verify re-validation of refresh token.""" with patch.object(provider, "async_validate_access") as mock: with pytest.raises(tn_auth.InvalidAuthError): @@ -201,7 +202,7 @@ async def test_validate_refresh_token(provider): mock.assert_called_once_with(ip_address("127.0.0.1")) -async def test_login_flow(manager, provider): +async def test_login_flow(manager, provider) -> None: """Test login flow.""" owner = await manager.async_create_user("test-owner") user = await manager.async_create_user("test-user") @@ -228,7 +229,7 @@ async def test_login_flow(manager, provider): assert step["data"]["user"] == user.id -async def test_trusted_users_login(manager_with_user, provider_with_user): +async def test_trusted_users_login(manager_with_user, provider_with_user) -> None: """Test available user list changed per different IP.""" owner = await manager_with_user.async_create_user("test-owner") sys_user = await manager_with_user.async_create_system_user( @@ -308,7 +309,7 @@ async def test_trusted_users_login(manager_with_user, provider_with_user): assert schema({"user": sys_user.id}) -async def test_trusted_group_login(manager_with_user, provider_with_user): +async def test_trusted_group_login(manager_with_user, provider_with_user) -> None: """Test config trusted_user with group_id.""" owner = await manager_with_user.async_create_user("test-owner") # create a user in user group @@ -361,7 +362,7 @@ async def test_trusted_group_login(manager_with_user, provider_with_user): assert schema({"user": user.id}) -async def test_bypass_login_flow(manager_bypass_login, provider_bypass_login): +async def test_bypass_login_flow(manager_bypass_login, provider_bypass_login) -> None: """Test login flow can be bypass if only one user available.""" owner = await manager_bypass_login.async_create_user("test-owner") diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index bf10aa732a..8fe35a20bc 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -55,7 +55,9 @@ async def test_home_assistant_core_config_validation(hass: HomeAssistant) -> Non assert result is None -async def test_async_enable_logging(hass, caplog): +async def test_async_enable_logging( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test to ensure logging is migrated to the queue handlers.""" with patch("logging.getLogger"), patch( "homeassistant.bootstrap.async_activate_log_queue_handler" @@ -97,7 +99,9 @@ async def test_empty_setup(hass: HomeAssistant) -> None: assert domain in hass.config.components, domain -async def test_core_failure_loads_safe_mode(hass, caplog): +async def test_core_failure_loads_safe_mode( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test failing core setup aborts further setup.""" with patch( "homeassistant.components.homeassistant.async_setup", @@ -462,7 +466,7 @@ async def test_setup_hass( mock_mount_local_lib_path, mock_ensure_config_exists, mock_process_ha_config_upgrade, - caplog, + caplog: pytest.LogCaptureFixture, event_loop, ) -> None: """Test it works.""" @@ -512,7 +516,7 @@ async def test_setup_hass_takes_longer_than_log_slow_startup( mock_mount_local_lib_path, mock_ensure_config_exists, mock_process_ha_config_upgrade, - caplog, + caplog: pytest.LogCaptureFixture, event_loop, ) -> None: """Test it works.""" @@ -581,7 +585,7 @@ async def test_setup_hass_config_dir_nonexistent( mock_ensure_config_exists, mock_process_ha_config_upgrade, event_loop, -): +) -> None: """Test it works.""" mock_ensure_config_exists.return_value = False @@ -608,7 +612,7 @@ async def test_setup_hass_safe_mode( mock_ensure_config_exists, mock_process_ha_config_upgrade, event_loop, -): +) -> None: """Test it works.""" with patch("homeassistant.components.browser.setup") as browser_setup, patch( "homeassistant.config_entries.ConfigEntries.async_domains", @@ -768,7 +772,9 @@ async def test_empty_integrations_list_is_only_sent_at_the_end_of_bootstrap( @pytest.mark.parametrize("load_registries", [False]) -async def test_warning_logged_on_wrap_up_timeout(hass, caplog): +async def test_warning_logged_on_wrap_up_timeout( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test we log a warning on bootstrap timeout.""" def gen_domain_setup(domain): diff --git a/tests/test_config.py b/tests/test_config.py index 5dfd674daf..407ca9ef54 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,9 +1,9 @@ """Test config utils.""" - from collections import OrderedDict import contextlib import copy import os +from typing import Any from unittest import mock from unittest.mock import AsyncMock, Mock, patch @@ -238,7 +238,9 @@ def test_core_config_schema() -> None: ) -def test_core_config_schema_internal_external_warning(caplog): +def test_core_config_schema_internal_external_warning( + caplog: pytest.LogCaptureFixture, +) -> None: """Test that we warn for internal/external URL with path.""" config_util.CORE_CONFIG_SCHEMA( { @@ -300,7 +302,9 @@ async def test_entity_customization(hass: HomeAssistant) -> None: @patch("homeassistant.config.shutil") @patch("homeassistant.config.os") @patch("homeassistant.config.is_docker_env", return_value=False) -def test_remove_lib_on_upgrade(mock_docker, mock_os, mock_shutil, hass): +def test_remove_lib_on_upgrade( + mock_docker, mock_os, mock_shutil, hass: HomeAssistant +) -> None: """Test removal of library on upgrade from before 0.50.""" ha_version = "0.49.0" mock_os.path.isdir = mock.Mock(return_value=True) @@ -322,7 +326,9 @@ def test_remove_lib_on_upgrade(mock_docker, mock_os, mock_shutil, hass): @patch("homeassistant.config.shutil") @patch("homeassistant.config.os") @patch("homeassistant.config.is_docker_env", return_value=True) -def test_remove_lib_on_upgrade_94(mock_docker, mock_os, mock_shutil, hass): +def test_remove_lib_on_upgrade_94( + mock_docker, mock_os, mock_shutil, hass: HomeAssistant +) -> None: """Test removal of library on upgrade from before 0.94 and in Docker.""" ha_version = "0.93.0.dev0" mock_os.path.isdir = mock.Mock(return_value=True) @@ -386,7 +392,9 @@ def test_config_upgrade_no_file(hass: HomeAssistant) -> None: assert opened_file.write.call_args == mock.call(__version__) -async def test_loading_configuration_from_storage(hass, hass_storage): +async def test_loading_configuration_from_storage( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: """Test loading core config onto hass object.""" hass_storage["core.config"] = { "data": { @@ -426,7 +434,9 @@ async def test_loading_configuration_from_storage(hass, hass_storage): assert hass.config.config_source is ConfigSource.STORAGE -async def test_loading_configuration_from_storage_with_yaml_only(hass, hass_storage): +async def test_loading_configuration_from_storage_with_yaml_only( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: """Test loading core and YAML config onto hass object.""" hass_storage["core.config"] = { "data": { @@ -456,7 +466,9 @@ async def test_loading_configuration_from_storage_with_yaml_only(hass, hass_stor assert hass.config.config_source is ConfigSource.STORAGE -async def test_migration_and_updating_configuration(hass, hass_storage): +async def test_migration_and_updating_configuration( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: """Test updating configuration stores the new configuration.""" core_data = { "data": { @@ -497,7 +509,9 @@ async def test_migration_and_updating_configuration(hass, hass_storage): assert hass.config.language == "en" -async def test_override_stored_configuration(hass, hass_storage): +async def test_override_stored_configuration( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: """Test loading core and YAML config onto hass object.""" hass_storage["core.config"] = { "data": { @@ -601,8 +615,13 @@ async def test_loading_configuration(hass: HomeAssistant) -> None: ), ) async def test_language_default( - hass, hass_storage, minor_version, users, user_data, default_language -): + hass: HomeAssistant, + hass_storage: dict[str, Any], + minor_version, + users, + user_data, + default_language, +) -> None: """Test language config default to owner user's language during migration. This should only happen if the core store version < 1.3 @@ -721,14 +740,14 @@ async def test_loading_configuration_unit_system( @patch("homeassistant.helpers.check_config.async_check_ha_config_file") -async def test_check_ha_config_file_correct(mock_check, hass): +async def test_check_ha_config_file_correct(mock_check, hass: HomeAssistant) -> None: """Check that restart propagates to stop.""" mock_check.return_value = check_config.HomeAssistantConfig() assert await config_util.async_check_ha_config_file(hass) is None @patch("homeassistant.helpers.check_config.async_check_ha_config_file") -async def test_check_ha_config_file_wrong(mock_check, hass): +async def test_check_ha_config_file_wrong(mock_check, hass: HomeAssistant) -> None: """Check that restart with a bad config doesn't propagate to stop.""" mock_check.return_value = check_config.HomeAssistantConfig() mock_check.return_value.add_error("bad") @@ -770,7 +789,7 @@ def merge_log_err(hass): yield logerr -async def test_merge(merge_log_err, hass): +async def test_merge(merge_log_err, hass: HomeAssistant) -> None: """Test if we can merge packages.""" packages = { "pack_dict": {"input_boolean": {"ib1": None}}, @@ -805,7 +824,7 @@ async def test_merge(merge_log_err, hass): assert isinstance(config["wake_on_lan"], OrderedDict) -async def test_merge_try_falsy(merge_log_err, hass): +async def test_merge_try_falsy(merge_log_err, hass: HomeAssistant) -> None: """Ensure we don't add falsy items like empty OrderedDict() to list.""" packages = { "pack_falsy_to_lst": {"automation": OrderedDict()}, @@ -824,7 +843,7 @@ async def test_merge_try_falsy(merge_log_err, hass): assert len(config["light"]) == 1 -async def test_merge_new(merge_log_err, hass): +async def test_merge_new(merge_log_err, hass: HomeAssistant) -> None: """Test adding new components to outer scope.""" packages = { "pack_1": {"light": [{"platform": "one"}]}, @@ -845,7 +864,7 @@ async def test_merge_new(merge_log_err, hass): assert len(config["panel_custom"]) == 1 -async def test_merge_type_mismatch(merge_log_err, hass): +async def test_merge_type_mismatch(merge_log_err, hass: HomeAssistant) -> None: """Test if we have a type mismatch for packages.""" packages = { "pack_1": {"input_boolean": [{"ib1": None}]}, @@ -866,7 +885,7 @@ async def test_merge_type_mismatch(merge_log_err, hass): assert len(config["light"]) == 2 -async def test_merge_once_only_keys(merge_log_err, hass): +async def test_merge_once_only_keys(merge_log_err, hass: HomeAssistant) -> None: """Test if we have a merge for a comp that may occur only once. Keys.""" packages = {"pack_2": {"api": None}} config = {config_util.CONF_CORE: {config_util.CONF_PACKAGES: packages}, "api": None} @@ -952,7 +971,7 @@ async def test_merge_id_schema(hass: HomeAssistant) -> None: assert typ == expected_type, f"{domain} expected {expected_type}, got {typ}" -async def test_merge_duplicate_keys(merge_log_err, hass): +async def test_merge_duplicate_keys(merge_log_err, hass: HomeAssistant) -> None: """Test if keys in dicts are duplicates.""" packages = {"pack_1": {"input_select": {"ib1": None}}} config = { @@ -1125,7 +1144,9 @@ async def test_merge_split_component_definition(hass: HomeAssistant) -> None: assert len(config["light three"]) == 1 -async def test_component_config_exceptions(hass, caplog): +async def test_component_config_exceptions( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test unexpected exceptions validating component config.""" # Config validator assert ( @@ -1296,7 +1317,7 @@ async def test_component_config_exceptions(hass, caplog): ("openuv", cv.deprecated("openuv"), None), ], ) -def test_identify_config_schema(domain, schema, expected): +def test_identify_config_schema(domain, schema, expected) -> None: """Test identify config schema.""" assert ( config_util._identify_config_schema(Mock(DOMAIN=domain, CONFIG_SCHEMA=schema)) @@ -1314,7 +1335,9 @@ async def test_core_config_schema_historic_currency(hass: HomeAssistant) -> None assert issue.translation_placeholders == {"currency": "LTT"} -async def test_core_store_historic_currency(hass, hass_storage): +async def test_core_store_historic_currency( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: """Test core config store.""" core_data = { "data": { @@ -1347,7 +1370,9 @@ async def test_core_config_schema_no_country(hass: HomeAssistant) -> None: assert issue -async def test_core_store_no_country(hass, hass_storage): +async def test_core_store_no_country( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: """Test core config store.""" core_data = { "data": {}, diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index be508543bd..aecdc79da9 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from datetime import timedelta import logging +from typing import Any from unittest.mock import AsyncMock, Mock, patch import pytest @@ -261,7 +262,7 @@ async def test_call_async_migrate_entry_failure_not_supported( assert not entry.supports_unload -async def test_remove_entry(hass, manager): +async def test_remove_entry(hass: HomeAssistant, manager) -> None: """Test that we can remove an entry.""" async def mock_setup_entry(hass, entry): @@ -345,7 +346,7 @@ async def test_remove_entry(hass, manager): assert not entity_entry_list -async def test_remove_entry_cancels_reauth(hass, manager): +async def test_remove_entry_cancels_reauth(hass: HomeAssistant, manager) -> None: """Tests that removing a config entry, also aborts existing reauth flows.""" entry = MockConfigEntry(title="test_title", domain="test") @@ -369,7 +370,9 @@ async def test_remove_entry_cancels_reauth(hass, manager): assert len(flows) == 0 -async def test_remove_entry_handles_callback_error(hass, manager): +async def test_remove_entry_handles_callback_error( + hass: HomeAssistant, manager +) -> None: """Test that exceptions in the remove callback are handled.""" mock_setup_entry = AsyncMock(return_value=True) mock_unload_entry = AsyncMock(return_value=True) @@ -402,7 +405,7 @@ async def test_remove_entry_handles_callback_error(hass, manager): assert [item.entry_id for item in manager.async_entries()] == [] -async def test_remove_entry_raises(hass, manager): +async def test_remove_entry_raises(hass: HomeAssistant, manager) -> None: """Test if a component raises while removing entry.""" async def mock_unload_entry(hass, entry): @@ -429,7 +432,7 @@ async def test_remove_entry_raises(hass, manager): assert [item.entry_id for item in manager.async_entries()] == ["test1", "test3"] -async def test_remove_entry_if_not_loaded(hass, manager): +async def test_remove_entry_if_not_loaded(hass: HomeAssistant, manager) -> None: """Test that we can remove an entry that is not loaded.""" mock_unload_entry = AsyncMock(return_value=True) @@ -453,7 +456,9 @@ async def test_remove_entry_if_not_loaded(hass, manager): assert len(mock_unload_entry.mock_calls) == 0 -async def test_remove_entry_if_integration_deleted(hass, manager): +async def test_remove_entry_if_integration_deleted( + hass: HomeAssistant, manager +) -> None: """Test that we can remove an entry when the integration is deleted.""" mock_unload_entry = AsyncMock(return_value=True) @@ -475,7 +480,7 @@ async def test_remove_entry_if_integration_deleted(hass, manager): assert len(mock_unload_entry.mock_calls) == 0 -async def test_add_entry_calls_setup_entry(hass, manager): +async def test_add_entry_calls_setup_entry(hass: HomeAssistant, manager) -> None: """Test we call setup_config_entry.""" mock_setup_entry = AsyncMock(return_value=True) @@ -504,7 +509,7 @@ async def test_add_entry_calls_setup_entry(hass, manager): assert p_entry.data == {"token": "supersecret"} -async def test_entries_gets_entries(manager): +async def test_entries_gets_entries(manager) -> None: """Test entries are filtered by domain.""" MockConfigEntry(domain="test").add_to_manager(manager) entry1 = MockConfigEntry(domain="test2") @@ -515,7 +520,7 @@ async def test_entries_gets_entries(manager): assert manager.async_entries("test2") == [entry1, entry2] -async def test_domains_gets_domains_uniques(manager): +async def test_domains_gets_domains_uniques(manager) -> None: """Test we only return each domain once.""" MockConfigEntry(domain="test").add_to_manager(manager) MockConfigEntry(domain="test2").add_to_manager(manager) @@ -526,7 +531,7 @@ async def test_domains_gets_domains_uniques(manager): assert manager.async_domains() == ["test", "test2", "test3"] -async def test_domains_gets_domains_excludes_ignore_and_disabled(manager): +async def test_domains_gets_domains_excludes_ignore_and_disabled(manager) -> None: """Test we only return each domain once.""" MockConfigEntry(domain="test").add_to_manager(manager) MockConfigEntry(domain="test2").add_to_manager(manager) @@ -832,7 +837,7 @@ async def test_loading_default_config(hass: HomeAssistant) -> None: assert len(manager.async_entries()) == 0 -async def test_updating_entry_data(manager): +async def test_updating_entry_data(manager) -> None: """Test that we can update an entry data.""" entry = MockConfigEntry( domain="test", @@ -848,7 +853,7 @@ async def test_updating_entry_data(manager): assert entry.data == {"second": True} -async def test_updating_entry_system_options(manager): +async def test_updating_entry_system_options(manager) -> None: """Test that we can update an entry data.""" entry = MockConfigEntry( domain="test", @@ -869,7 +874,9 @@ async def test_updating_entry_system_options(manager): assert entry.pref_disable_polling is True -async def test_update_entry_options_and_trigger_listener(hass, manager): +async def test_update_entry_options_and_trigger_listener( + hass: HomeAssistant, manager +) -> None: """Test that we can update entry options and trigger listener.""" entry = MockConfigEntry(domain="test", options={"first": True}) entry.add_to_manager(manager) @@ -885,7 +892,9 @@ async def test_update_entry_options_and_trigger_listener(hass, manager): assert entry.options == {"second": True} -async def test_setup_raise_not_ready(hass, caplog): +async def test_setup_raise_not_ready( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test a setup raising not ready.""" entry = MockConfigEntry(title="test_title", domain="test") @@ -919,7 +928,9 @@ async def test_setup_raise_not_ready(hass, caplog): assert entry.reason is None -async def test_setup_raise_not_ready_from_exception(hass, caplog): +async def test_setup_raise_not_ready_from_exception( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test a setup raising not ready from another exception.""" entry = MockConfigEntry(title="test_title", domain="test") @@ -1037,7 +1048,7 @@ async def test_create_entry_options(hass: HomeAssistant) -> None: assert entries[0].options == {"example": "option"} -async def test_entry_options(hass, manager): +async def test_entry_options(hass: HomeAssistant, manager) -> None: """Test that we can set options on an entry.""" entry = MockConfigEntry(domain="test", data={"first": True}, options=None) entry.add_to_manager(manager) @@ -1071,7 +1082,7 @@ async def test_entry_options(hass, manager): assert entry.options == {"second": True} -async def test_entry_options_abort(hass, manager): +async def test_entry_options_abort(hass: HomeAssistant, manager) -> None: """Test that we can abort options flow.""" entry = MockConfigEntry(domain="test", data={"first": True}, options=None) entry.add_to_manager(manager) @@ -1101,7 +1112,7 @@ async def test_entry_options_abort(hass, manager): ) -async def test_entry_setup_succeed(hass, manager): +async def test_entry_setup_succeed(hass: HomeAssistant, manager) -> None: """Test that we can setup an entry.""" entry = MockConfigEntry( domain="comp", state=config_entries.ConfigEntryState.NOT_LOADED @@ -1133,7 +1144,7 @@ async def test_entry_setup_succeed(hass, manager): config_entries.ConfigEntryState.FAILED_UNLOAD, ), ) -async def test_entry_setup_invalid_state(hass, manager, state): +async def test_entry_setup_invalid_state(hass: HomeAssistant, manager, state) -> None: """Test that we cannot setup an entry with invalid state.""" entry = MockConfigEntry(domain="comp", state=state) entry.add_to_hass(hass) @@ -1154,7 +1165,7 @@ async def test_entry_setup_invalid_state(hass, manager, state): assert entry.state is state -async def test_entry_unload_succeed(hass, manager): +async def test_entry_unload_succeed(hass: HomeAssistant, manager) -> None: """Test that we can unload an entry.""" entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) entry.add_to_hass(hass) @@ -1176,7 +1187,7 @@ async def test_entry_unload_succeed(hass, manager): config_entries.ConfigEntryState.SETUP_RETRY, ), ) -async def test_entry_unload_failed_to_load(hass, manager, state): +async def test_entry_unload_failed_to_load(hass: HomeAssistant, manager, state) -> None: """Test that we can unload an entry.""" entry = MockConfigEntry(domain="comp", state=state) entry.add_to_hass(hass) @@ -1197,7 +1208,7 @@ async def test_entry_unload_failed_to_load(hass, manager, state): config_entries.ConfigEntryState.FAILED_UNLOAD, ), ) -async def test_entry_unload_invalid_state(hass, manager, state): +async def test_entry_unload_invalid_state(hass: HomeAssistant, manager, state) -> None: """Test that we cannot unload an entry with invalid state.""" entry = MockConfigEntry(domain="comp", state=state) entry.add_to_hass(hass) @@ -1213,7 +1224,7 @@ async def test_entry_unload_invalid_state(hass, manager, state): assert entry.state is state -async def test_entry_reload_succeed(hass, manager): +async def test_entry_reload_succeed(hass: HomeAssistant, manager) -> None: """Test that we can reload an entry.""" entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) entry.add_to_hass(hass) @@ -1248,7 +1259,7 @@ async def test_entry_reload_succeed(hass, manager): config_entries.ConfigEntryState.SETUP_RETRY, ), ) -async def test_entry_reload_not_loaded(hass, manager, state): +async def test_entry_reload_not_loaded(hass: HomeAssistant, manager, state) -> None: """Test that we can reload an entry.""" entry = MockConfigEntry(domain="comp", state=state) entry.add_to_hass(hass) @@ -1282,7 +1293,7 @@ async def test_entry_reload_not_loaded(hass, manager, state): config_entries.ConfigEntryState.FAILED_UNLOAD, ), ) -async def test_entry_reload_error(hass, manager, state): +async def test_entry_reload_error(hass: HomeAssistant, manager, state) -> None: """Test that we can reload an entry.""" entry = MockConfigEntry(domain="comp", state=state) entry.add_to_hass(hass) @@ -1311,7 +1322,7 @@ async def test_entry_reload_error(hass, manager, state): assert entry.state == state -async def test_entry_disable_succeed(hass, manager): +async def test_entry_disable_succeed(hass: HomeAssistant, manager) -> None: """Test that we can disable an entry.""" entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) entry.add_to_hass(hass) @@ -1348,7 +1359,9 @@ async def test_entry_disable_succeed(hass, manager): assert entry.state is config_entries.ConfigEntryState.LOADED -async def test_entry_disable_without_reload_support(hass, manager): +async def test_entry_disable_without_reload_support( + hass: HomeAssistant, manager +) -> None: """Test that we can disable an entry without reload support.""" entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) entry.add_to_hass(hass) @@ -1385,7 +1398,9 @@ async def test_entry_disable_without_reload_support(hass, manager): assert entry.state is config_entries.ConfigEntryState.FAILED_UNLOAD -async def test_entry_enable_without_reload_support(hass, manager): +async def test_entry_enable_without_reload_support( + hass: HomeAssistant, manager +) -> None: """Test that we can disable an entry without reload support.""" entry = MockConfigEntry( domain="comp", disabled_by=config_entries.ConfigEntryDisabler.USER @@ -1513,7 +1528,7 @@ async def test_reload_entry_entity_registry_works( assert len(mock_unload_entry.mock_calls) == 2 -async def test_unique_id_persisted(hass, manager): +async def test_unique_id_persisted(hass: HomeAssistant, manager) -> None: """Test that a unique ID is stored in the config entry.""" mock_setup_entry = AsyncMock(return_value=True) @@ -1542,7 +1557,7 @@ async def test_unique_id_persisted(hass, manager): assert p_entry.unique_id == "mock-unique-id" -async def test_unique_id_existing_entry(hass, manager): +async def test_unique_id_existing_entry(hass: HomeAssistant, manager) -> None: """Test that we remove an entry if there already is an entry with unique ID.""" hass.config.components.add("comp") MockConfigEntry( @@ -1595,7 +1610,7 @@ async def test_unique_id_existing_entry(hass, manager): assert len(async_remove_entry.mock_calls) == 1 -async def test_entry_id_existing_entry(hass, manager): +async def test_entry_id_existing_entry(hass: HomeAssistant, manager) -> None: """Test that we throw when the entry id collides.""" collide_entry_id = "collide" hass.config.components.add("comp") @@ -1632,7 +1647,9 @@ async def test_entry_id_existing_entry(hass, manager): ) -async def test_unique_id_update_existing_entry_without_reload(hass, manager): +async def test_unique_id_update_existing_entry_without_reload( + hass: HomeAssistant, manager +) -> None: """Test that we update an entry if there already is an entry with unique ID.""" hass.config.components.add("comp") entry = MockConfigEntry( @@ -1676,7 +1693,9 @@ async def test_unique_id_update_existing_entry_without_reload(hass, manager): assert len(async_reload.mock_calls) == 0 -async def test_unique_id_update_existing_entry_with_reload(hass, manager): +async def test_unique_id_update_existing_entry_with_reload( + hass: HomeAssistant, manager +) -> None: """Test that we update an entry if there already is an entry with unique ID and we reload on changes.""" hass.config.components.add("comp") entry = MockConfigEntry( @@ -1738,7 +1757,9 @@ async def test_unique_id_update_existing_entry_with_reload(hass, manager): assert len(async_reload.mock_calls) == 0 -async def test_unique_id_from_discovery_in_setup_retry(hass, manager): +async def test_unique_id_from_discovery_in_setup_retry( + hass: HomeAssistant, manager +) -> None: """Test that we reload when in a setup retry state from discovery.""" hass.config.components.add("comp") unique_id = "34:ea:34:b4:3b:5a" @@ -1807,7 +1828,9 @@ async def test_unique_id_from_discovery_in_setup_retry(hass, manager): assert len(async_reload.mock_calls) == 1 -async def test_unique_id_not_update_existing_entry(hass, manager): +async def test_unique_id_not_update_existing_entry( + hass: HomeAssistant, manager +) -> None: """Test that we do not update an entry if existing entry has the data.""" hass.config.components.add("comp") entry = MockConfigEntry( @@ -1850,7 +1873,7 @@ async def test_unique_id_not_update_existing_entry(hass, manager): assert len(async_reload.mock_calls) == 0 -async def test_unique_id_in_progress(hass, manager): +async def test_unique_id_in_progress(hass: HomeAssistant, manager) -> None: """Test that we abort if there is already a flow in progress with same unique id.""" mock_integration(hass, MockModule("comp")) mock_entity_platform(hass, "config_flow.comp", None) @@ -1881,7 +1904,7 @@ async def test_unique_id_in_progress(hass, manager): assert result2["reason"] == "already_in_progress" -async def test_finish_flow_aborts_progress(hass, manager): +async def test_finish_flow_aborts_progress(hass: HomeAssistant, manager) -> None: """Test that when finishing a flow, we abort other flows in progress with unique ID.""" mock_integration( hass, @@ -1920,7 +1943,7 @@ async def test_finish_flow_aborts_progress(hass, manager): assert len(hass.config_entries.flow.async_progress()) == 0 -async def test_unique_id_ignore(hass, manager): +async def test_unique_id_ignore(hass: HomeAssistant, manager) -> None: """Test that we can ignore flows that are in progress and have a unique ID.""" async_setup_entry = AsyncMock(return_value=False) mock_integration(hass, MockModule("comp", async_setup_entry=async_setup_entry)) @@ -1963,7 +1986,7 @@ async def test_unique_id_ignore(hass, manager): assert entry.title == "Ignored Title" -async def test_manual_add_overrides_ignored_entry(hass, manager): +async def test_manual_add_overrides_ignored_entry(hass: HomeAssistant, manager) -> None: """Test that we can ignore manually add entry, overriding ignored entry.""" hass.config.components.add("comp") entry = MockConfigEntry( @@ -2008,7 +2031,9 @@ async def test_manual_add_overrides_ignored_entry(hass, manager): assert len(async_reload.mock_calls) == 0 -async def test_manual_add_overrides_ignored_entry_singleton(hass, manager): +async def test_manual_add_overrides_ignored_entry_singleton( + hass: HomeAssistant, manager +) -> None: """Test that we can ignore manually add entry, overriding ignored entry.""" hass.config.components.add("comp") entry = MockConfigEntry( @@ -2047,7 +2072,9 @@ async def test_manual_add_overrides_ignored_entry_singleton(hass, manager): assert p_entry.data == {"token": "supersecret"} -async def test__async_current_entries_does_not_skip_ignore_non_user(hass, manager): +async def test__async_current_entries_does_not_skip_ignore_non_user( + hass: HomeAssistant, manager +) -> None: """Test that _async_current_entries does not skip ignore by default for non user step.""" hass.config.components.add("comp") entry = MockConfigEntry( @@ -2082,7 +2109,9 @@ async def test__async_current_entries_does_not_skip_ignore_non_user(hass, manage assert len(mock_setup_entry.mock_calls) == 0 -async def test__async_current_entries_explicit_skip_ignore(hass, manager): +async def test__async_current_entries_explicit_skip_ignore( + hass: HomeAssistant, manager +) -> None: """Test that _async_current_entries can explicitly include ignore.""" hass.config.components.add("comp") entry = MockConfigEntry( @@ -2121,7 +2150,9 @@ async def test__async_current_entries_explicit_skip_ignore(hass, manager): assert p_entry.data == {"token": "supersecret"} -async def test__async_current_entries_explicit_include_ignore(hass, manager): +async def test__async_current_entries_explicit_include_ignore( + hass: HomeAssistant, manager +) -> None: """Test that _async_current_entries can explicitly include ignore.""" hass.config.components.add("comp") entry = MockConfigEntry( @@ -2156,7 +2187,7 @@ async def test__async_current_entries_explicit_include_ignore(hass, manager): assert len(mock_setup_entry.mock_calls) == 0 -async def test_unignore_step_form(hass, manager): +async def test_unignore_step_form(hass: HomeAssistant, manager) -> None: """Test that we can ignore flows that are in progress and have a unique ID, then rediscover them.""" async_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=async_setup_entry)) @@ -2201,7 +2232,7 @@ async def test_unignore_step_form(hass, manager): assert len(hass.config_entries.async_entries("comp")) == 0 -async def test_unignore_create_entry(hass, manager): +async def test_unignore_create_entry(hass: HomeAssistant, manager) -> None: """Test that we can ignore flows that are in progress and have a unique ID, then rediscover them.""" async_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=async_setup_entry)) @@ -2249,7 +2280,7 @@ async def test_unignore_create_entry(hass, manager): assert len(hass.config_entries.flow.async_progress_by_handler("comp")) == 0 -async def test_unignore_default_impl(hass, manager): +async def test_unignore_default_impl(hass: HomeAssistant, manager) -> None: """Test that resdicovery is a no-op by default.""" async_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=async_setup_entry)) @@ -2281,7 +2312,7 @@ async def test_unignore_default_impl(hass, manager): assert len(hass.config_entries.flow.async_progress()) == 0 -async def test_partial_flows_hidden(hass, manager): +async def test_partial_flows_hidden(hass: HomeAssistant, manager) -> None: """Test that flows that don't have a cur_step and haven't finished initing are hidden.""" async_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=async_setup_entry)) @@ -2522,7 +2553,9 @@ async def test_async_setup_update_entry(hass: HomeAssistant) -> None: ), ), ) -async def test_flow_with_default_discovery(hass, manager, discovery_source): +async def test_flow_with_default_discovery( + hass: HomeAssistant, manager, discovery_source +) -> None: """Test that finishing a default discovery flow removes the unique ID in the entry.""" mock_integration( hass, @@ -2570,7 +2603,9 @@ async def test_flow_with_default_discovery(hass, manager, discovery_source): assert entry.unique_id is None -async def test_flow_with_default_discovery_with_unique_id(hass, manager): +async def test_flow_with_default_discovery_with_unique_id( + hass: HomeAssistant, manager +) -> None: """Test discovery flow using the default discovery is ignored when unique ID is set.""" mock_integration(hass, MockModule("comp")) mock_entity_platform(hass, "config_flow.comp", None) @@ -2598,7 +2633,9 @@ async def test_flow_with_default_discovery_with_unique_id(hass, manager): assert flows[0]["context"]["unique_id"] == "mock-unique-id" -async def test_default_discovery_abort_existing_entries(hass, manager): +async def test_default_discovery_abort_existing_entries( + hass: HomeAssistant, manager +) -> None: """Test that a flow without discovery implementation aborts when a config entry exists.""" hass.config.components.add("comp") entry = MockConfigEntry(domain="comp", data={}, unique_id="mock-unique-id") @@ -2620,7 +2657,7 @@ async def test_default_discovery_abort_existing_entries(hass, manager): assert result["reason"] == "already_configured" -async def test_default_discovery_in_progress(hass, manager): +async def test_default_discovery_in_progress(hass: HomeAssistant, manager) -> None: """Test that a flow using default discovery can only be triggered once.""" mock_integration(hass, MockModule("comp")) mock_entity_platform(hass, "config_flow.comp", None) @@ -2655,7 +2692,9 @@ async def test_default_discovery_in_progress(hass, manager): assert flows[0]["context"]["unique_id"] == "mock-unique-id" -async def test_default_discovery_abort_on_new_unique_flow(hass, manager): +async def test_default_discovery_abort_on_new_unique_flow( + hass: HomeAssistant, manager +) -> None: """Test that a flow using default discovery is aborted when a second flow with unique ID is created.""" mock_integration(hass, MockModule("comp")) mock_entity_platform(hass, "config_flow.comp", None) @@ -2692,7 +2731,9 @@ async def test_default_discovery_abort_on_new_unique_flow(hass, manager): assert flows[0]["context"]["unique_id"] == "mock-unique-id" -async def test_default_discovery_abort_on_user_flow_complete(hass, manager): +async def test_default_discovery_abort_on_user_flow_complete( + hass: HomeAssistant, manager +) -> None: """Test that a flow using default discovery is aborted when a second flow completes.""" mock_integration(hass, MockModule("comp")) mock_entity_platform(hass, "config_flow.comp", None) @@ -2741,7 +2782,7 @@ async def test_default_discovery_abort_on_user_flow_complete(hass, manager): assert len(flows) == 0 -async def test_flow_same_device_multiple_sources(hass, manager): +async def test_flow_same_device_multiple_sources(hass: HomeAssistant, manager) -> None: """Test discovery of the same devices from multiple discovery sources.""" mock_integration( hass, @@ -2809,7 +2850,7 @@ async def test_flow_same_device_multiple_sources(hass, manager): assert entry.unique_id == "thisid" -async def test_updating_entry_with_and_without_changes(manager): +async def test_updating_entry_with_and_without_changes(manager) -> None: """Test that we can update an entry data.""" entry = MockConfigEntry( domain="test", @@ -2836,7 +2877,9 @@ async def test_updating_entry_with_and_without_changes(manager): assert manager.async_update_entry(entry, **change) is False -async def test_entry_reload_calls_on_unload_listeners(hass, manager): +async def test_entry_reload_calls_on_unload_listeners( + hass: HomeAssistant, manager +) -> None: """Test reload calls the on unload listeners.""" entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) entry.add_to_hass(hass) @@ -2875,7 +2918,9 @@ async def test_entry_reload_calls_on_unload_listeners(hass, manager): assert entry.state is config_entries.ConfigEntryState.LOADED -async def test_setup_raise_entry_error(hass, caplog): +async def test_setup_raise_entry_error( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test a setup raising ConfigEntryError.""" entry = MockConfigEntry(title="test_title", domain="test") @@ -2896,7 +2941,9 @@ async def test_setup_raise_entry_error(hass, caplog): assert entry.reason == "Incompatible firmware version" -async def test_setup_raise_entry_error_from_first_coordinator_update(hass, caplog): +async def test_setup_raise_entry_error_from_first_coordinator_update( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test async_config_entry_first_refresh raises ConfigEntryError.""" entry = MockConfigEntry(title="test_title", domain="test") @@ -2931,7 +2978,9 @@ async def test_setup_raise_entry_error_from_first_coordinator_update(hass, caplo assert entry.reason == "Incompatible firmware version" -async def test_setup_not_raise_entry_error_from_future_coordinator_update(hass, caplog): +async def test_setup_not_raise_entry_error_from_future_coordinator_update( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test a coordinator not raises ConfigEntryError in the future.""" entry = MockConfigEntry(title="test_title", domain="test") @@ -2965,7 +3014,9 @@ async def test_setup_not_raise_entry_error_from_future_coordinator_update(hass, assert entry.state is config_entries.ConfigEntryState.LOADED -async def test_setup_raise_auth_failed(hass, caplog): +async def test_setup_raise_auth_failed( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test a setup raising ConfigEntryAuthFailed.""" entry = MockConfigEntry(title="test_title", domain="test") @@ -3001,7 +3052,9 @@ async def test_setup_raise_auth_failed(hass, caplog): assert len(flows) == 1 -async def test_setup_raise_auth_failed_from_first_coordinator_update(hass, caplog): +async def test_setup_raise_auth_failed_from_first_coordinator_update( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test async_config_entry_first_refresh raises ConfigEntryAuthFailed.""" entry = MockConfigEntry(title="test_title", domain="test") @@ -3048,7 +3101,9 @@ async def test_setup_raise_auth_failed_from_first_coordinator_update(hass, caplo assert len(flows) == 1 -async def test_setup_raise_auth_failed_from_future_coordinator_update(hass, caplog): +async def test_setup_raise_auth_failed_from_future_coordinator_update( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test a coordinator raises ConfigEntryAuthFailed in the future.""" entry = MockConfigEntry(title="test_title", domain="test") @@ -3158,7 +3213,9 @@ async def test_setup_retrying_during_shutdown(hass: HomeAssistant) -> None: ), # ensure options takes precedence over data ], ) -async def test__async_abort_entries_match(hass, manager, matchers, reason): +async def test__async_abort_entries_match( + hass: HomeAssistant, manager, matchers, reason +) -> None: """Test aborting if matching config entries exist.""" MockConfigEntry( domain="comp", data={"ip": "1.2.3.4", "host": "4.5.6.7", "port": 23} @@ -3210,7 +3267,9 @@ async def test__async_abort_entries_match(hass, manager, matchers, reason): assert result["reason"] == reason -async def test_loading_old_data(hass, hass_storage): +async def test_loading_old_data( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: """Test automatically migrating old data.""" hass_storage[config_entries.STORAGE_KEY] = { "version": 1, @@ -3242,14 +3301,18 @@ async def test_loading_old_data(hass, hass_storage): assert entry.pref_disable_new_entities is True -async def test_deprecated_disabled_by_str_ctor(hass, caplog): +async def test_deprecated_disabled_by_str_ctor( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test deprecated str disabled_by constructor enumizes and logs a warning.""" entry = MockConfigEntry(disabled_by=config_entries.ConfigEntryDisabler.USER.value) assert entry.disabled_by is config_entries.ConfigEntryDisabler.USER assert " str for config entry disabled_by. This is deprecated " in caplog.text -async def test_deprecated_disabled_by_str_set(hass, manager, caplog): +async def test_deprecated_disabled_by_str_set( + hass: HomeAssistant, manager, caplog: pytest.LogCaptureFixture +) -> None: """Test deprecated str set disabled_by enumizes and logs a warning.""" entry = MockConfigEntry() entry.add_to_manager(manager) @@ -3260,7 +3323,7 @@ async def test_deprecated_disabled_by_str_set(hass, manager, caplog): assert " str for config entry disabled_by. This is deprecated " in caplog.text -async def test_entry_reload_concurrency(hass, manager): +async def test_entry_reload_concurrency(hass: HomeAssistant, manager) -> None: """Test multiple reload calls do not cause a reload race.""" entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) entry.add_to_hass(hass) @@ -3368,7 +3431,9 @@ async def test_unique_id_update_while_setup_in_progress( assert entry.state is config_entries.ConfigEntryState.LOADED -async def test_disallow_entry_reload_with_setup_in_progresss(hass, manager): +async def test_disallow_entry_reload_with_setup_in_progresss( + hass: HomeAssistant, manager +) -> None: """Test we do not allow reload while the config entry is still setting up.""" entry = MockConfigEntry( domain="comp", state=config_entries.ConfigEntryState.SETUP_IN_PROGRESS @@ -3463,7 +3528,7 @@ async def test_get_active_flows(hass: HomeAssistant) -> None: assert active_user_flow is None -async def test_async_wait_component_dynamic(hass: HomeAssistant): +async def test_async_wait_component_dynamic(hass: HomeAssistant) -> None: """Test async_wait_component for a config entry which is dynamically loaded.""" entry = MockConfigEntry(title="test_title", domain="test") @@ -3483,7 +3548,7 @@ async def test_async_wait_component_dynamic(hass: HomeAssistant): assert await hass.config_entries.async_wait_component(entry) is True -async def test_async_wait_component_startup(hass: HomeAssistant): +async def test_async_wait_component_startup(hass: HomeAssistant) -> None: """Test async_wait_component for a config entry which is loaded at startup.""" entry = MockConfigEntry(title="test_title", domain="test") @@ -3547,7 +3612,9 @@ async def test_options_flow_options_not_mutated() -> None: assert entry.options == {"sub_dict": {"1": "one"}, "sub_list": ["one"]} -async def test_initializing_flows_canceled_on_shutdown(hass: HomeAssistant, manager): +async def test_initializing_flows_canceled_on_shutdown( + hass: HomeAssistant, manager +) -> None: """Test that initializing flows are canceled on shutdown.""" class MockFlowHandler(config_entries.ConfigFlow): @@ -3575,7 +3642,7 @@ async def test_initializing_flows_canceled_on_shutdown(hass: HomeAssistant, mana await task -async def test_task_tracking(hass): +async def test_task_tracking(hass: HomeAssistant) -> None: """Test task tracking for a config entry.""" entry = MockConfigEntry(title="test_title", domain="test") diff --git a/tests/test_core.py b/tests/test_core.py index 6c04062174..4749daa0c0 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -86,7 +86,7 @@ def test_async_add_hass_job_schedule_partial_callback() -> None: assert len(hass.add_job.mock_calls) == 0 -def test_async_add_hass_job_schedule_coroutinefunction(event_loop): +def test_async_add_hass_job_schedule_coroutinefunction(event_loop) -> None: """Test that we schedule coroutines and add jobs to the job pool.""" hass = MagicMock(loop=MagicMock(wraps=event_loop)) @@ -99,7 +99,7 @@ def test_async_add_hass_job_schedule_coroutinefunction(event_loop): assert len(hass.add_job.mock_calls) == 0 -def test_async_add_hass_job_schedule_partial_coroutinefunction(event_loop): +def test_async_add_hass_job_schedule_partial_coroutinefunction(event_loop) -> None: """Test that we schedule partial coros and add jobs to the job pool.""" hass = MagicMock(loop=MagicMock(wraps=event_loop)) @@ -127,7 +127,7 @@ def test_async_add_job_add_hass_threaded_job_to_pool() -> None: assert len(hass.loop.run_in_executor.mock_calls) == 2 -def test_async_create_task_schedule_coroutine(event_loop): +def test_async_create_task_schedule_coroutine(event_loop) -> None: """Test that we schedule coroutines and add jobs to the job pool.""" hass = MagicMock(loop=MagicMock(wraps=event_loop)) @@ -181,7 +181,7 @@ async def test_stage_shutdown(hass: HomeAssistant) -> None: assert len(test_all) == 2 -async def test_stage_shutdown_with_exit_code(hass): +async def test_stage_shutdown_with_exit_code(hass: HomeAssistant) -> None: """Simulate a shutdown, test calling stuff with exit code checks.""" test_stop = async_capture_events(hass, EVENT_HOMEASSISTANT_STOP) test_final_write = async_capture_events(hass, EVENT_HOMEASSISTANT_FINAL_WRITE) @@ -222,8 +222,8 @@ async def test_stage_shutdown_with_exit_code(hass): async def test_shutdown_calls_block_till_done_after_shutdown_run_callback_threadsafe( - hass, -): + hass: HomeAssistant, +) -> None: """Ensure shutdown_run_callback_threadsafe is called before the final async_block_till_done.""" stop_calls = [] @@ -1179,7 +1179,9 @@ async def test_bad_timezone_raises_value_error(hass: HomeAssistant) -> None: await hass.config.async_update(time_zone="not_a_timezone") -async def test_start_taking_too_long(event_loop, caplog): +async def test_start_taking_too_long( + event_loop, caplog: pytest.LogCaptureFixture +) -> None: """Test when async_start takes too long.""" hass = ha.HomeAssistant() caplog.set_level(logging.WARNING) @@ -1288,7 +1290,7 @@ async def test_async_functions_with_callback(hass: HomeAssistant) -> None: @pytest.mark.parametrize("cancel_call", [True, False]) -async def test_cancel_service_task(hass, cancel_call): +async def test_cancel_service_task(hass: HomeAssistant, cancel_call) -> None: """Test cancellation.""" service_called = asyncio.Event() service_cancelled = False @@ -1354,7 +1356,9 @@ def test_valid_entity_id() -> None: assert ha.valid_entity_id(valid), valid -async def test_additional_data_in_core_config(hass, hass_storage): +async def test_additional_data_in_core_config( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: """Test that we can handle additional data in core configuration.""" config = ha.Config(hass) hass_storage[ha.CORE_STORAGE_KEY] = { @@ -1365,7 +1369,9 @@ async def test_additional_data_in_core_config(hass, hass_storage): assert config.location_name == "Test Name" -async def test_incorrect_internal_external_url(hass, hass_storage, caplog): +async def test_incorrect_internal_external_url( + hass: HomeAssistant, hass_storage: dict[str, Any], caplog: pytest.LogCaptureFixture +) -> None: """Test that we warn when detecting invalid internal/external url.""" config = ha.Config(hass) @@ -1426,7 +1432,9 @@ async def test_start_events(hass: HomeAssistant) -> None: assert core_states == [ha.CoreState.starting, ha.CoreState.running] -async def test_log_blocking_events(hass, caplog): +async def test_log_blocking_events( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Ensure we log which task is blocking startup when debug logging is on.""" caplog.set_level(logging.DEBUG) @@ -1447,7 +1455,9 @@ async def test_log_blocking_events(hass, caplog): assert "_wait_a_bit_1" not in caplog.text -async def test_chained_logging_hits_log_timeout(hass, caplog): +async def test_chained_logging_hits_log_timeout( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Ensure we log which task is blocking startup when there is a task chain and debug logging is on.""" caplog.set_level(logging.DEBUG) @@ -1474,7 +1484,9 @@ async def test_chained_logging_hits_log_timeout(hass, caplog): assert "_task_chain_" in caplog.text -async def test_chained_logging_misses_log_timeout(hass, caplog): +async def test_chained_logging_misses_log_timeout( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Ensure we do not log which task is blocking startup if we do not hit the timeout.""" caplog.set_level(logging.DEBUG) @@ -1975,7 +1987,7 @@ async def test_state_changed_events_to_not_leak_contexts(hass: HomeAssistant) -> assert len(_get_by_type("homeassistant.core.Context")) == init_count -async def test_background_task(hass): +async def test_background_task(hass: HomeAssistant) -> None: """Test background tasks being quit.""" result = asyncio.Future() diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index ecb7300116..c3afc3bc8b 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -46,7 +46,7 @@ def manager(): return mgr -async def test_configure_reuses_handler_instance(manager): +async def test_configure_reuses_handler_instance(manager) -> None: """Test that we reuse instances.""" @manager.mock_reg_handler("test") @@ -109,7 +109,7 @@ async def test_configure_two_steps(manager: data_entry_flow.FlowManager) -> None assert result["data"] == ["INIT-DATA", "SECOND-DATA"] -async def test_show_form(manager): +async def test_show_form(manager) -> None: """Test that we can show a form.""" schema = vol.Schema({vol.Required("username"): str, vol.Required("password"): str}) @@ -128,7 +128,7 @@ async def test_show_form(manager): assert form["errors"] == {"username": "Should be unique."} -async def test_abort_removes_instance(manager): +async def test_abort_removes_instance(manager) -> None: """Test that abort removes the flow from progress.""" @manager.mock_reg_handler("test") @@ -150,7 +150,7 @@ async def test_abort_removes_instance(manager): assert len(manager.mock_created_entries) == 0 -async def test_abort_calls_async_remove(manager): +async def test_abort_calls_async_remove(manager) -> None: """Test abort calling the async_remove FlowHandler method.""" @manager.mock_reg_handler("test") @@ -168,7 +168,9 @@ async def test_abort_calls_async_remove(manager): assert len(manager.mock_created_entries) == 0 -async def test_abort_calls_async_remove_with_exception(manager, caplog): +async def test_abort_calls_async_remove_with_exception( + manager, caplog: pytest.LogCaptureFixture +) -> None: """Test abort calling the async_remove FlowHandler method, with an exception.""" @manager.mock_reg_handler("test") @@ -189,7 +191,7 @@ async def test_abort_calls_async_remove_with_exception(manager, caplog): assert len(manager.mock_created_entries) == 0 -async def test_create_saves_data(manager): +async def test_create_saves_data(manager) -> None: """Test creating a config entry.""" @manager.mock_reg_handler("test") @@ -211,7 +213,7 @@ async def test_create_saves_data(manager): assert entry["source"] is None -async def test_discovery_init_flow(manager): +async def test_discovery_init_flow(manager) -> None: """Test a flow initialized by discovery.""" @manager.mock_reg_handler("test") @@ -283,7 +285,7 @@ async def test_finish_callback_change_result_type(hass: HomeAssistant) -> None: assert result["result"] == 2 -async def test_external_step(hass, manager): +async def test_external_step(hass: HomeAssistant, manager) -> None: """Test external step logic.""" manager.hass = hass @@ -333,7 +335,7 @@ async def test_external_step(hass, manager): assert result["title"] == "Hello" -async def test_show_progress(hass, manager): +async def test_show_progress(hass: HomeAssistant, manager) -> None: """Test show progress logic.""" manager.hass = hass @@ -405,7 +407,7 @@ async def test_show_progress(hass, manager): assert result["title"] == "Hello" -async def test_abort_flow_exception(manager): +async def test_abort_flow_exception(manager) -> None: """Test that the AbortFlow exception works.""" @manager.mock_reg_handler("test") @@ -419,7 +421,7 @@ async def test_abort_flow_exception(manager): assert form["description_placeholders"] == {"placeholder": "yo"} -async def test_init_unknown_flow(manager): +async def test_init_unknown_flow(manager) -> None: """Test that UnknownFlow is raised when async_create_flow returns None.""" with pytest.raises(data_entry_flow.UnknownFlow), patch.object( @@ -428,7 +430,7 @@ async def test_init_unknown_flow(manager): await manager.async_init("test") -async def test_async_get_unknown_flow(manager): +async def test_async_get_unknown_flow(manager) -> None: """Test that UnknownFlow is raised when async_get is called with a flow_id that does not exist.""" with pytest.raises(data_entry_flow.UnknownFlow): @@ -437,7 +439,7 @@ async def test_async_get_unknown_flow(manager): async def test_async_has_matching_flow( hass: HomeAssistant, manager: data_entry_flow.FlowManager -): +) -> None: """Test we can check for matching flows.""" manager.hass = hass @@ -488,7 +490,9 @@ async def test_async_has_matching_flow( ) -async def test_move_to_unknown_step_raises_and_removes_from_in_progress(manager): +async def test_move_to_unknown_step_raises_and_removes_from_in_progress( + manager, +) -> None: """Test that moving to an unknown step raises and removes the flow from in progress.""" @manager.mock_reg_handler("test") @@ -501,13 +505,13 @@ async def test_move_to_unknown_step_raises_and_removes_from_in_progress(manager) assert manager.async_progress() == [] -async def test_configure_raises_unknown_flow_if_not_in_progress(manager): +async def test_configure_raises_unknown_flow_if_not_in_progress(manager) -> None: """Test configure raises UnknownFlow if the flow is not in progress.""" with pytest.raises(data_entry_flow.UnknownFlow): await manager.async_configure("wrong_flow_id") -async def test_abort_raises_unknown_flow_if_not_in_progress(manager): +async def test_abort_raises_unknown_flow_if_not_in_progress(manager) -> None: """Test abort raises UnknownFlow if the flow is not in progress.""" with pytest.raises(data_entry_flow.UnknownFlow): await manager.async_abort("wrong_flow_id") @@ -517,7 +521,7 @@ async def test_abort_raises_unknown_flow_if_not_in_progress(manager): "menu_options", (["target1", "target2"], {"target1": "Target 1", "target2": "Target 2"}), ) -async def test_show_menu(hass, manager, menu_options): +async def test_show_menu(hass: HomeAssistant, manager, menu_options) -> None: """Test show menu.""" manager.hass = hass diff --git a/tests/test_loader.py b/tests/test_loader.py index 62b87aff6b..a591ccbb20 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -83,7 +83,9 @@ async def test_helpers_wrapper(hass: HomeAssistant) -> None: assert result == ["hello"] -async def test_custom_component_name(hass, enable_custom_integrations): +async def test_custom_component_name( + hass: HomeAssistant, enable_custom_integrations: None +) -> None: """Test the name attribute of custom components.""" with pytest.raises(loader.IntegrationNotFound): await loader.async_get_integration(hass, "test_standalone") @@ -109,7 +111,11 @@ async def test_custom_component_name(hass, enable_custom_integrations): assert TEST == 5 -async def test_log_warning_custom_component(hass, caplog, enable_custom_integrations): +async def test_log_warning_custom_component( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + enable_custom_integrations: None, +) -> None: """Test that we log a warning when loading a custom component.""" await loader.async_get_integration(hass, "test_package") @@ -120,8 +126,10 @@ async def test_log_warning_custom_component(hass, caplog, enable_custom_integrat async def test_custom_integration_version_not_valid( - hass, caplog, enable_custom_integrations -): + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + enable_custom_integrations: None, +) -> None: """Test that we log a warning when custom integrations have a invalid version.""" with pytest.raises(loader.IntegrationNotFound): await loader.async_get_integration(hass, "test_no_version") @@ -161,14 +169,18 @@ async def test_get_integration_exceptions(hass: HomeAssistant) -> None: assert hue_light == integration.get_platform("light") -async def test_get_integration_legacy(hass, enable_custom_integrations): +async def test_get_integration_legacy( + hass: HomeAssistant, enable_custom_integrations: None +) -> None: """Test resolving integration.""" integration = await loader.async_get_integration(hass, "test_embedded") assert integration.get_component().DOMAIN == "test_embedded" assert integration.get_platform("switch") is not None -async def test_get_integration_custom_component(hass, enable_custom_integrations): +async def test_get_integration_custom_component( + hass: HomeAssistant, enable_custom_integrations: None +) -> None: """Test resolving integration.""" integration = await loader.async_get_integration(hass, "test_package") assert integration.get_component().DOMAIN == "test_package" @@ -461,7 +473,9 @@ def _get_test_integration_with_usb_matcher(hass, name, config_flow): ) -async def test_get_custom_components(hass, enable_custom_integrations): +async def test_get_custom_components( + hass: HomeAssistant, enable_custom_integrations: None +) -> None: """Verify that custom components are cached.""" test_1_integration = _get_test_integration(hass, "test_1", False) test_2_integration = _get_test_integration(hass, "test_2", True) @@ -663,7 +677,9 @@ async def test_get_custom_components_safe_mode(hass: HomeAssistant) -> None: assert await loader.async_get_custom_components(hass) == {} -async def test_custom_integration_missing_version(hass, caplog): +async def test_custom_integration_missing_version( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test trying to load a custom integration without a version twice does not deadlock.""" with pytest.raises(loader.IntegrationNotFound): await loader.async_get_integration(hass, "test_no_version") @@ -672,7 +688,9 @@ async def test_custom_integration_missing_version(hass, caplog): await loader.async_get_integration(hass, "test_no_version") -async def test_custom_integration_missing(hass, caplog): +async def test_custom_integration_missing( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test trying to load a custom integration that is missing twice not deadlock.""" with patch("homeassistant.loader.async_get_custom_components") as mock_get: mock_get.return_value = {} diff --git a/tests/test_main.py b/tests/test_main.py index 522515b0d3..b676040252 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -6,7 +6,7 @@ from homeassistant.const import REQUIRED_PYTHON_VER @patch("sys.exit") -def test_validate_python(mock_exit): +def test_validate_python(mock_exit) -> None: """Test validate Python version method.""" with patch("sys.version_info", new_callable=PropertyMock(return_value=(2, 7, 8))): main.validate_python() @@ -64,7 +64,7 @@ def test_validate_python(mock_exit): @patch("sys.exit") -def test_skip_pip_mutually_exclusive(mock_exit): +def test_skip_pip_mutually_exclusive(mock_exit) -> None: """Test --skip-pip and --skip-pip-package are mutually exclusive.""" def parse_args(*args): diff --git a/tests/test_requirements.py b/tests/test_requirements.py index 5fd54959ef..388e8607ec 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -95,7 +95,9 @@ async def test_install_missing_package(hass: HomeAssistant) -> None: assert len(mock_inst.mock_calls) == 3 -async def test_install_skipped_package(hass, caplog): +async def test_install_skipped_package( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test an install attempt on a dependency that should be skipped.""" with patch( "homeassistant.util.package.install_package", return_value=True @@ -453,7 +455,9 @@ async def test_discovery_requirements_ssdp(hass: HomeAssistant) -> None: "partial_manifest", [{"zeroconf": ["_googlecast._tcp.local."]}, {"homekit": {"models": ["LIFX"]}}], ) -async def test_discovery_requirements_zeroconf(hass, partial_manifest): +async def test_discovery_requirements_zeroconf( + hass: HomeAssistant, partial_manifest +) -> None: """Test that we load discovery requirements.""" hass.config.skip_pip = False zeroconf = await loader.async_get_integration(hass, "zeroconf") diff --git a/tests/test_runner.py b/tests/test_runner.py index c5222cd026..25b75b94c3 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -1,5 +1,4 @@ """Test the runner.""" - import asyncio import threading from unittest.mock import patch @@ -7,6 +6,7 @@ from unittest.mock import patch import pytest from homeassistant import core, runner +from homeassistant.core import HomeAssistant from homeassistant.util import executor, thread # https://github.com/home-assistant/supervisor/blob/main/supervisor/docker/homeassistant.py @@ -28,7 +28,7 @@ async def test_cumulative_shutdown_timeout_less_than_supervisor() -> None: ) -async def test_setup_and_run_hass(hass, tmpdir): +async def test_setup_and_run_hass(hass: HomeAssistant, tmpdir) -> None: """Test we can setup and run.""" test_dir = tmpdir.mkdir("config") default_config = runner.RuntimeConfig(test_dir) @@ -42,7 +42,7 @@ async def test_setup_and_run_hass(hass, tmpdir): assert mock_run.called -def test_run(hass, tmpdir): +def test_run(hass: HomeAssistant, tmpdir) -> None: """Test we can run.""" test_dir = tmpdir.mkdir("config") default_config = runner.RuntimeConfig(test_dir) @@ -57,7 +57,7 @@ def test_run(hass, tmpdir): assert mock_run.called -def test_run_executor_shutdown_throws(hass, tmpdir): +def test_run_executor_shutdown_throws(hass: HomeAssistant, tmpdir) -> None: """Test we can run and we still shutdown if the executor shutdown throws.""" test_dir = tmpdir.mkdir("config") default_config = runner.RuntimeConfig(test_dir) @@ -78,7 +78,9 @@ def test_run_executor_shutdown_throws(hass, tmpdir): assert mock_run.called -def test_run_does_not_block_forever_with_shielded_task(hass, tmpdir, caplog): +def test_run_does_not_block_forever_with_shielded_task( + hass: HomeAssistant, tmpdir, caplog: pytest.LogCaptureFixture +) -> None: """Test we can shutdown and not block forever.""" test_dir = tmpdir.mkdir("config") default_config = runner.RuntimeConfig(test_dir) @@ -119,7 +121,9 @@ def test_run_does_not_block_forever_with_shielded_task(hass, tmpdir, caplog): ) -async def test_unhandled_exception_traceback(hass, caplog): +async def test_unhandled_exception_traceback( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test an unhandled exception gets a traceback in debug mode.""" raised = asyncio.Event() @@ -142,7 +146,7 @@ async def test_unhandled_exception_traceback(hass, caplog): assert "_unhandled_exception" in caplog.text -def test__enable_posix_spawn(): +def test__enable_posix_spawn() -> None: """Test that we can enable posix_spawn on Alpine.""" def _mock_alpine_exists(path): diff --git a/tests/test_setup.py b/tests/test_setup.py index 29794bb110..a624929d8f 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -80,7 +80,9 @@ async def test_validate_component_config(hass: HomeAssistant) -> None: ) -async def test_validate_platform_config(hass, caplog): +async def test_validate_platform_config( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test validating platform configuration.""" platform_schema = PLATFORM_SCHEMA.extend({"hello": str}) platform_schema_base = PLATFORM_SCHEMA_BASE.extend({}) @@ -139,7 +141,9 @@ async def test_validate_platform_config(hass, caplog): assert not config["platform_conf"] # empty -async def test_validate_platform_config_2(hass, caplog): +async def test_validate_platform_config_2( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test component PLATFORM_SCHEMA_BASE prio over PLATFORM_SCHEMA.""" platform_schema = PLATFORM_SCHEMA.extend({"hello": str}) platform_schema_base = PLATFORM_SCHEMA_BASE.extend({"hello": "world"}) @@ -171,7 +175,9 @@ async def test_validate_platform_config_2(hass, caplog): ) -async def test_validate_platform_config_3(hass, caplog): +async def test_validate_platform_config_3( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test fallback to component PLATFORM_SCHEMA.""" component_schema = PLATFORM_SCHEMA_BASE.extend({"hello": str}) platform_schema = PLATFORM_SCHEMA.extend({"cheers": str, "hello": "world"}) @@ -500,7 +506,9 @@ async def test_platform_no_warn_slow(hass: HomeAssistant) -> None: assert len(mock_call.mock_calls) == 0 -async def test_platform_error_slow_setup(hass, caplog): +async def test_platform_error_slow_setup( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Don't block startup more than SLOW_SETUP_MAX_WAIT.""" with patch.object(setup, "SLOW_SETUP_MAX_WAIT", 0.1): @@ -589,7 +597,7 @@ async def test_setup_import_blows_up(hass: HomeAssistant) -> None: assert not await setup.async_setup_component(hass, "sun", {}) -async def test_parallel_entry_setup(hass, mock_handlers): +async def test_parallel_entry_setup(hass: HomeAssistant, mock_handlers) -> None: """Test config entries are set up in parallel.""" MockConfigEntry(domain="comp", data={"value": 1}).add_to_hass(hass) MockConfigEntry(domain="comp", data={"value": 2}).add_to_hass(hass) @@ -616,7 +624,9 @@ async def test_parallel_entry_setup(hass, mock_handlers): assert calls == [1, 2, 1, 2] -async def test_integration_disabled(hass, caplog): +async def test_integration_disabled( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test we can disable an integration.""" disabled_reason = "Dependency contains code that breaks Home Assistant" mock_integration( @@ -628,7 +638,9 @@ async def test_integration_disabled(hass, caplog): assert disabled_reason in caplog.text -async def test_integration_logs_is_custom(hass, caplog): +async def test_integration_logs_is_custom( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test we highlight it's a custom component when errors happen.""" mock_integration( hass, @@ -662,7 +674,9 @@ async def test_async_get_loaded_integrations(hass: HomeAssistant) -> None: } -async def test_integration_no_setup(hass, caplog): +async def test_integration_no_setup( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test we fail integration setup without setup functions.""" mock_integration( hass, diff --git a/tests/test_test_fixtures.py b/tests/test_test_fixtures.py index 7b07e8472b..1c0fe0a7ea 100644 --- a/tests/test_test_fixtures.py +++ b/tests/test_test_fixtures.py @@ -13,7 +13,7 @@ def test_sockets_disabled() -> None: socket.socket() -def test_sockets_enabled(socket_enabled): +def test_sockets_enabled(socket_enabled) -> None: """Test we can't connect to an address different from 127.0.0.1.""" mysocket = socket.socket() with pytest.raises(pytest_socket.SocketConnectBlockedError): diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 18aca8ea5f..28ccdcc589 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -11,6 +11,7 @@ import pytest import yaml as pyyaml from homeassistant.config import YAML_CONFIG_FILE, load_yaml_config_file +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError import homeassistant.util.yaml as yaml from homeassistant.util.yaml import loader as yaml_loader @@ -52,7 +53,7 @@ def try_both_dumpers(request): importlib.reload(yaml_loader) -def test_simple_list(try_both_loaders): +def test_simple_list(try_both_loaders) -> None: """Test simple list.""" conf = "config:\n - simple\n - list" with io.StringIO(conf) as file: @@ -60,7 +61,7 @@ def test_simple_list(try_both_loaders): assert doc["config"] == ["simple", "list"] -def test_simple_dict(try_both_loaders): +def test_simple_dict(try_both_loaders) -> None: """Test simple dict.""" conf = "key: value" with io.StringIO(conf) as file: @@ -82,7 +83,7 @@ def test_no_key(try_both_loaders, mock_hass_config_yaml: None) -> None: yaml.load_yaml(YAML_CONFIG_FILE) -def test_environment_variable(try_both_loaders): +def test_environment_variable(try_both_loaders) -> None: """Test config file with environment variable.""" os.environ["PASSWORD"] = "secret_password" conf = "password: !env_var PASSWORD" @@ -92,7 +93,7 @@ def test_environment_variable(try_both_loaders): del os.environ["PASSWORD"] -def test_environment_variable_default(try_both_loaders): +def test_environment_variable_default(try_both_loaders) -> None: """Test config file with default value for environment variable.""" conf = "password: !env_var PASSWORD secret_password" with io.StringIO(conf) as file: @@ -100,7 +101,7 @@ def test_environment_variable_default(try_both_loaders): assert doc["password"] == "secret_password" -def test_invalid_environment_variable(try_both_loaders): +def test_invalid_environment_variable(try_both_loaders) -> None: """Test config file with no environment variable sat.""" conf = "password: !env_var PASSWORD" with pytest.raises(HomeAssistantError), io.StringIO(conf) as file: @@ -323,19 +324,19 @@ def test_include_dir_merge_named_recursive( @patch("homeassistant.util.yaml.loader.open", create=True) -def test_load_yaml_encoding_error(mock_open, try_both_loaders): +def test_load_yaml_encoding_error(mock_open, try_both_loaders) -> None: """Test raising a UnicodeDecodeError.""" mock_open.side_effect = UnicodeDecodeError("", b"", 1, 0, "") with pytest.raises(HomeAssistantError): yaml_loader.load_yaml("test") -def test_dump(try_both_dumpers): +def test_dump(try_both_dumpers) -> None: """The that the dump method returns empty None values.""" assert yaml.dump({"a": None, "b": "b"}) == "a:\nb: b\n" -def test_dump_unicode(try_both_dumpers): +def test_dump_unicode(try_both_dumpers) -> None: """The that the dump method returns empty None values.""" assert yaml.dump({"a": None, "b": "привет"}) == "a:\nb: привет\n" @@ -522,7 +523,7 @@ def test_input_class() -> None: assert len({input, input2}) == 1 -def test_input(try_both_loaders, try_both_dumpers): +def test_input(try_both_loaders, try_both_dumpers) -> None: """Test loading inputs.""" data = {"hello": yaml.Input("test_name")} assert yaml.parse_yaml(yaml.dump(data)) == data @@ -537,7 +538,9 @@ def test_c_loader_is_available_in_ci() -> None: assert yaml.loader.HAS_C_LOADER is True -async def test_loading_actual_file_with_syntax(hass, try_both_loaders): +async def test_loading_actual_file_with_syntax( + hass: HomeAssistant, try_both_loaders +) -> None: """Test loading a real file with syntax errors.""" with pytest.raises(HomeAssistantError): fixture_path = pathlib.Path(__file__).parent.joinpath( From b5ead9e76f899ea8a771ee16f6e32ee3ae7dda0b Mon Sep 17 00:00:00 2001 From: "J.P. Krauss" Date: Tue, 21 Feb 2023 02:09:18 -0800 Subject: [PATCH 0258/2810] Bump pyairnow to 1.2.1 (#88516) --- homeassistant/components/airnow/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airnow/manifest.json b/homeassistant/components/airnow/manifest.json index 68efb618a2..28dada485b 100644 --- a/homeassistant/components/airnow/manifest.json +++ b/homeassistant/components/airnow/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airnow", "iot_class": "cloud_polling", "loggers": ["pyairnow"], - "requirements": ["pyairnow==1.1.0"] + "requirements": ["pyairnow==1.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2ddd17b85c..dc9282b0b4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1494,7 +1494,7 @@ pyaehw4a1==0.3.9 pyaftership==21.11.0 # homeassistant.components.airnow -pyairnow==1.1.0 +pyairnow==1.2.1 # homeassistant.components.airvisual # homeassistant.components.airvisual_pro diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fbb8c8b0ca..aa5ebe9ca3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1088,7 +1088,7 @@ py_nextbusnext==0.1.5 pyaehw4a1==0.3.9 # homeassistant.components.airnow -pyairnow==1.1.0 +pyairnow==1.2.1 # homeassistant.components.airvisual # homeassistant.components.airvisual_pro From 08aae9cccd1e2d11321dfc881e18a8072ef0f2bf Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 21 Feb 2023 15:39:15 +0100 Subject: [PATCH 0259/2810] Remove tibber monthly cost state class (#88542) --- homeassistant/components/tibber/sensor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index dc6a58b6b8..5f375ee22e 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -214,7 +214,6 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( key="month_cost", name="Monthly cost", device_class=SensorDeviceClass.MONETARY, - state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key="peak_hour", From 4e32b6569450128252f89eaa011ca58203a6896a Mon Sep 17 00:00:00 2001 From: Jorgen Evens Date: Tue, 21 Feb 2023 16:48:53 +0100 Subject: [PATCH 0260/2810] Improve pjlink reliability (#80745) Co-authored-by: Martin Hjelmare --- .coveragerc | 1 - .../components/pjlink/media_player.py | 87 +++-- requirements_test_all.txt | 3 + tests/components/pjlink/__init__.py | 1 + tests/components/pjlink/test_media_player.py | 365 ++++++++++++++++++ 5 files changed, 427 insertions(+), 30 deletions(-) create mode 100644 tests/components/pjlink/__init__.py create mode 100644 tests/components/pjlink/test_media_player.py diff --git a/.coveragerc b/.coveragerc index 52dfe08952..871f798a65 100644 --- a/.coveragerc +++ b/.coveragerc @@ -898,7 +898,6 @@ omit = homeassistant/components/ping/binary_sensor.py homeassistant/components/ping/device_tracker.py homeassistant/components/pioneer/media_player.py - homeassistant/components/pjlink/media_player.py homeassistant/components/plaato/__init__.py homeassistant/components/plaato/binary_sensor.py homeassistant/components/plaato/entity.py diff --git a/homeassistant/components/pjlink/media_player.py b/homeassistant/components/pjlink/media_player.py index e7c4eeea1e..3fb5facac5 100644 --- a/homeassistant/components/pjlink/media_player.py +++ b/homeassistant/components/pjlink/media_player.py @@ -1,6 +1,8 @@ """Support for controlling projector via the PJLink protocol.""" from __future__ import annotations +import socket + from pypjlink import MUTE_AUDIO, Projector from pypjlink.projector import ProjectorError import voluptuous as vol @@ -23,6 +25,8 @@ DEFAULT_PORT = 4352 DEFAULT_ENCODING = "utf-8" DEFAULT_TIMEOUT = 10 +ERR_PROJECTOR_UNAVAILABLE = "projector unavailable" + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, @@ -79,55 +83,80 @@ class PjLinkDevice(MediaPlayerEntity): """Iinitialize the PJLink device.""" self._host = host self._port = port - self._attr_name = name self._password = password self._encoding = encoding + self._source_name_mapping = {} + + self._attr_name = name self._attr_is_volume_muted = False self._attr_state = MediaPlayerState.OFF - with self.projector() as projector: - if not self._attr_name: - self._attr_name = projector.get_name() - inputs = projector.get_inputs() + self._attr_source = None + self._attr_source_list = [] + self._attr_available = False + + def _force_off(self): + self._attr_state = MediaPlayerState.OFF + self._attr_is_volume_muted = False + self._attr_source = None + + def _setup_projector(self): + try: + with self.projector() as projector: + if not self._attr_name: + self._attr_name = projector.get_name() + inputs = projector.get_inputs() + except ProjectorError as err: + if str(err) == ERR_PROJECTOR_UNAVAILABLE: + return False + raise + self._source_name_mapping = {format_input_source(*x): x for x in inputs} - self._attr_source_list = sorted(self._source_name_mapping.keys()) + self._attr_source_list = sorted(self._source_name_mapping) + return True def projector(self): """Create PJLink Projector instance.""" - projector = Projector.from_address( - self._host, self._port, self._encoding, DEFAULT_TIMEOUT - ) - projector.authenticate(self._password) + try: + projector = Projector.from_address(self._host, self._port) + projector.authenticate(self._password) + except (socket.timeout, OSError) as err: + self._attr_available = False + raise ProjectorError(ERR_PROJECTOR_UNAVAILABLE) from err + return projector def update(self) -> None: """Get the latest state from the device.""" - with self.projector() as projector: - try: + if not self._attr_available: + self._attr_available = self._setup_projector() + + if not self._attr_available: + self._force_off() + return + + try: + with self.projector() as projector: pwstate = projector.get_power() if pwstate in ("on", "warm-up"): self._attr_state = MediaPlayerState.ON self._attr_is_volume_muted = projector.get_mute()[1] self._attr_source = format_input_source(*projector.get_input()) else: - self._attr_state = MediaPlayerState.OFF - self._attr_is_volume_muted = False - self._attr_source = None - except KeyError as err: - if str(err) == "'OK'": - self._attr_state = MediaPlayerState.OFF - self._attr_is_volume_muted = False - self._attr_source = None - else: - raise - except ProjectorError as err: - if str(err) == "unavailable time": - self._attr_state = MediaPlayerState.OFF - self._attr_is_volume_muted = False - self._attr_source = None - else: - raise + self._force_off() + except KeyError as err: + if str(err) == "'OK'": + self._force_off() + else: + raise + except ProjectorError as err: + if str(err) == "unavailable time": + self._force_off() + elif str(err) == ERR_PROJECTOR_UNAVAILABLE: + self._attr_available = False + else: + raise def turn_off(self) -> None: """Turn projector off.""" diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aa5ebe9ca3..3abfe3267d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1351,6 +1351,9 @@ pyownet==0.10.0.post1 # homeassistant.components.lcn pypck==0.7.16 +# homeassistant.components.pjlink +pypjlink2==1.2.1 + # homeassistant.components.plaato pyplaato==0.0.18 diff --git a/tests/components/pjlink/__init__.py b/tests/components/pjlink/__init__.py new file mode 100644 index 0000000000..4a52b3c434 --- /dev/null +++ b/tests/components/pjlink/__init__.py @@ -0,0 +1 @@ +"""Test the pjlink integration.""" diff --git a/tests/components/pjlink/test_media_player.py b/tests/components/pjlink/test_media_player.py new file mode 100644 index 0000000000..c4a923c16e --- /dev/null +++ b/tests/components/pjlink/test_media_player.py @@ -0,0 +1,365 @@ +"""Test the pjlink media player platform.""" + +from datetime import timedelta +import socket +from unittest.mock import create_autospec, patch + +import pypjlink +from pypjlink import MUTE_AUDIO +from pypjlink.projector import ProjectorError +import pytest + +import homeassistant.components.media_player as media_player +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.setup import async_setup_component +from homeassistant.util import dt + +from tests.common import assert_setup_component, async_fire_time_changed + + +@pytest.fixture(name="projector_from_address") +def projector_from_address(): + """Create pjlink Projector mock.""" + + with patch("pypjlink.Projector.from_address") as from_address: + constructor = create_autospec(pypjlink.Projector) + from_address.return_value = constructor.return_value + yield from_address + + +@pytest.fixture(name="mocked_projector") +def mocked_projector(projector_from_address): + """Create pjlink Projector instance mock.""" + + instance = projector_from_address.return_value + + with instance as mocked_instance: + mocked_instance.get_name.return_value = "Test" + mocked_instance.get_power.return_value = "on" + mocked_instance.get_mute.return_value = [0, True] + mocked_instance.get_input.return_value = [0, 1] + mocked_instance.get_inputs.return_value = ( + ("HDMI", 1), + ("HDMI", 2), + ("VGA", 1), + ) + + yield mocked_instance + + +@pytest.mark.parametrize("side_effect", [socket.timeout, OSError]) +async def test_offline_initialization(projector_from_address, hass, side_effect): + """Test initialization of a device that is offline.""" + + with assert_setup_component(1, media_player.DOMAIN): + projector_from_address.side_effect = side_effect + + assert await async_setup_component( + hass, + media_player.DOMAIN, + { + media_player.DOMAIN: { + "platform": "pjlink", + "name": "test_offline", + "host": "127.0.0.1", + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("media_player.test_offline") + assert state.state == "unavailable" + + +async def test_initialization(projector_from_address, hass): + """Test a device that is available.""" + + with assert_setup_component(1, media_player.DOMAIN): + instance = projector_from_address.return_value + + with instance as mocked_instance: + mocked_instance.get_name.return_value = "Test" + mocked_instance.get_inputs.return_value = ( + ("HDMI", 1), + ("HDMI", 2), + ("VGA", 1), + ) + + assert await async_setup_component( + hass, + media_player.DOMAIN, + { + media_player.DOMAIN: { + "platform": "pjlink", + "host": "127.0.0.1", + } + }, + ) + + await hass.async_block_till_done() + + state = hass.states.get("media_player.test") + assert state.state == "off" + + assert "source_list" in state.attributes + source_list = state.attributes["source_list"] + + assert set(source_list) == {"HDMI 1", "HDMI 2", "VGA 1"} + + +@pytest.mark.parametrize("power_state", ["on", "warm-up"]) +async def test_on_state_init(projector_from_address, hass, power_state): + """Test a device that is available.""" + + with assert_setup_component(1, media_player.DOMAIN): + instance = projector_from_address.return_value + + with instance as mocked_instance: + mocked_instance.get_name.return_value = "Test" + mocked_instance.get_power.return_value = power_state + mocked_instance.get_inputs.return_value = (("HDMI", 1),) + mocked_instance.get_input.return_value = ("HDMI", 1) + + assert await async_setup_component( + hass, + media_player.DOMAIN, + { + media_player.DOMAIN: { + "platform": "pjlink", + "host": "127.0.0.1", + } + }, + ) + + await hass.async_block_till_done() + + state = hass.states.get("media_player.test") + assert state.state == "on" + + assert state.attributes["source"] == "HDMI 1" + + +async def test_api_error(projector_from_address, hass): + """Test invalid api responses.""" + + with assert_setup_component(1, media_player.DOMAIN): + instance = projector_from_address.return_value + + with instance as mocked_instance: + mocked_instance.get_name.return_value = "Test" + mocked_instance.get_inputs.return_value = ( + ("HDMI", 1), + ("HDMI", 2), + ("VGA", 1), + ) + mocked_instance.get_power.side_effect = KeyError("OK") + + assert await async_setup_component( + hass, + media_player.DOMAIN, + { + media_player.DOMAIN: { + "platform": "pjlink", + "host": "127.0.0.1", + } + }, + ) + + await hass.async_block_till_done() + + state = hass.states.get("media_player.test") + assert state.state == "off" + + +async def test_update_unavailable(projector_from_address, hass): + """Test update to a device that is unavailable.""" + + with assert_setup_component(1, media_player.DOMAIN): + instance = projector_from_address.return_value + + with instance as mocked_instance: + mocked_instance.get_name.return_value = "Test" + mocked_instance.get_inputs.return_value = ( + ("HDMI", 1), + ("HDMI", 2), + ("VGA", 1), + ) + + assert await async_setup_component( + hass, + media_player.DOMAIN, + { + media_player.DOMAIN: { + "platform": "pjlink", + "host": "127.0.0.1", + } + }, + ) + + await hass.async_block_till_done() + + state = hass.states.get("media_player.test") + assert state.state == "off" + + projector_from_address.side_effect = socket.timeout + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=10)) + await hass.async_block_till_done() + + state = hass.states.get("media_player.test") + assert state.state == "unavailable" + + +async def test_unavailable_time(mocked_projector, hass): + """Test unavailable time projector error.""" + + assert await async_setup_component( + hass, + media_player.DOMAIN, + { + media_player.DOMAIN: { + "platform": "pjlink", + "host": "127.0.0.1", + } + }, + ) + + await hass.async_block_till_done() + + state = hass.states.get("media_player.test") + assert state.state == "on" + assert state.attributes["source"] is not None + assert state.attributes["is_volume_muted"] is not False + + mocked_projector.get_power.side_effect = ProjectorError("unavailable time") + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=10)) + await hass.async_block_till_done() + + state = hass.states.get("media_player.test") + assert state.state == "off" + assert "source" not in state.attributes + assert "is_volume_muted" not in state.attributes + + +async def test_turn_off(mocked_projector, hass): + """Test turning off beamer.""" + + assert await async_setup_component( + hass, + media_player.DOMAIN, + { + media_player.DOMAIN: { + "platform": "pjlink", + "host": "127.0.0.1", + } + }, + ) + + await hass.async_block_till_done() + await hass.services.async_call( + domain=media_player.DOMAIN, + service="turn_off", + service_data={ATTR_ENTITY_ID: "media_player.test"}, + blocking=True, + ) + + mocked_projector.set_power.assert_called_with("off") + + +async def test_turn_on(mocked_projector, hass): + """Test turning on beamer.""" + + assert await async_setup_component( + hass, + media_player.DOMAIN, + { + media_player.DOMAIN: { + "platform": "pjlink", + "host": "127.0.0.1", + } + }, + ) + + await hass.async_block_till_done() + await hass.services.async_call( + domain=media_player.DOMAIN, + service="turn_on", + service_data={ATTR_ENTITY_ID: "media_player.test"}, + blocking=True, + ) + + mocked_projector.set_power.assert_called_with("on") + + +async def test_mute(mocked_projector, hass): + """Test muting beamer.""" + + assert await async_setup_component( + hass, + media_player.DOMAIN, + { + media_player.DOMAIN: { + "platform": "pjlink", + "host": "127.0.0.1", + } + }, + ) + + await hass.async_block_till_done() + await hass.services.async_call( + domain=media_player.DOMAIN, + service="volume_mute", + service_data={ATTR_ENTITY_ID: "media_player.test", "is_volume_muted": True}, + blocking=True, + ) + + mocked_projector.set_mute.assert_called_with(MUTE_AUDIO, True) + + +async def test_unmute(mocked_projector, hass): + """Test unmuting beamer.""" + + assert await async_setup_component( + hass, + media_player.DOMAIN, + { + media_player.DOMAIN: { + "platform": "pjlink", + "host": "127.0.0.1", + } + }, + ) + + await hass.async_block_till_done() + await hass.services.async_call( + domain=media_player.DOMAIN, + service="volume_mute", + service_data={ATTR_ENTITY_ID: "media_player.test", "is_volume_muted": False}, + blocking=True, + ) + + mocked_projector.set_mute.assert_called_with(MUTE_AUDIO, False) + + +async def test_select_source(mocked_projector, hass): + """Test selecting source.""" + + assert await async_setup_component( + hass, + media_player.DOMAIN, + { + media_player.DOMAIN: { + "platform": "pjlink", + "host": "127.0.0.1", + } + }, + ) + + await hass.async_block_till_done() + await hass.services.async_call( + domain=media_player.DOMAIN, + service="select_source", + service_data={ATTR_ENTITY_ID: "media_player.test", "source": "VGA 1"}, + blocking=True, + ) + + mocked_projector.set_input.assert_called_with("VGA", 1) From ce1ef1d7204b2631f390d3d2e3055817fb7d7c50 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Tue, 21 Feb 2023 16:22:00 +0000 Subject: [PATCH 0261/2810] Activate thread integration if thread border routers are present (#88551) --- .../components/thread/config_flow.py | 9 ++ homeassistant/components/thread/manifest.json | 3 +- homeassistant/generated/zeroconf.py | 5 + tests/components/thread/test_config_flow.py | 99 ++++++++++++++++++- 4 files changed, 114 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/thread/config_flow.py b/homeassistant/components/thread/config_flow.py index 978b4c1077..c6f151b8e6 100644 --- a/homeassistant/components/thread/config_flow.py +++ b/homeassistant/components/thread/config_flow.py @@ -1,6 +1,7 @@ """Config flow for the Thread integration.""" from __future__ import annotations +from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow from homeassistant.data_entry_flow import FlowResult @@ -12,8 +13,16 @@ 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={}) diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index a6e823de57..144a10b878 100644 --- a/homeassistant/components/thread/manifest.json +++ b/homeassistant/components/thread/manifest.json @@ -7,5 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/thread", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==1.0.3"] + "requirements": ["python-otbr-api==1.0.3"], + "zeroconf": ["_meshcop._udp.local."] } diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index ae9668f372..e00a0710c3 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -522,6 +522,11 @@ ZEROCONF = { "domain": "apple_tv", }, ], + "_meshcop._udp.local.": [ + { + "domain": "thread", + }, + ], "_miio._udp.local.": [ { "domain": "xiaomi_aqara", diff --git a/tests/components/thread/test_config_flow.py b/tests/components/thread/test_config_flow.py index 4b27144166..5f19f233e3 100644 --- a/tests/components/thread/test_config_flow.py +++ b/tests/components/thread/test_config_flow.py @@ -1,10 +1,34 @@ """Test the Thread config flow.""" from unittest.mock import patch -from homeassistant.components import thread +from homeassistant.components import thread, zeroconf from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +TEST_ZEROCONF_RECORD = zeroconf.ZeroconfServiceInfo( + host="127.0.0.1", + hostname="HomeAssistant OpenThreadBorderRouter #0BBF", + name="HomeAssistant OpenThreadBorderRouter #0BBF._meshcop._udp.local.", + addresses=["127.0.0.1"], + port=8080, + properties={ + "rv": "1", + "vn": "HomeAssistant", + "mn": "OpenThreadBorderRouter", + "nn": "OpenThread HC", + "xp": "\xe6\x0f\xc7\xc1\x86!,\xe5", + "tv": "1.3.0", + "xa": "\xae\xeb/YKW\x0b\xbf", + "sb": "\x00\x00\x01\xb1", + "at": "\x00\x00\x00\x00\x00\x01\x00\x00", + "pt": "\x8f\x06Q~", + "sq": "3", + "bb": "\xf0\xbf", + "dn": "DefaultDomain", + }, + type="_meshcop._udp.local.", +) + async def test_import(hass: HomeAssistant) -> None: """Test the import flow.""" @@ -27,3 +51,76 @@ async def test_import(hass: HomeAssistant) -> None: assert config_entry.options == {} assert config_entry.title == "Thread" assert config_entry.unique_id is None + + +async def test_import_then_zeroconf(hass: HomeAssistant) -> None: + """Test the import 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": "import"} + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + + 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 + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_zeroconf(hass: HomeAssistant) -> None: + """Test the zeroconf 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": "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 + + 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_then_import(hass: HomeAssistant) -> None: + """Test the import 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": "zeroconf"}, data=TEST_ZEROCONF_RECORD + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + + 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": "import"} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert len(mock_setup_entry.mock_calls) == 0 From 77ec6cb8797381d5a3b10efe2840ec2531b36919 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 21 Feb 2023 17:22:21 +0100 Subject: [PATCH 0262/2810] Update actions/cache to 3.2.6 (#88537) --- .github/workflows/ci.yaml | 46 +++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6c8c6b88b0..0c6e0c173a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -212,7 +212,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.2.5 + uses: actions/cache@v3.2.6 with: path: venv key: >- @@ -227,7 +227,7 @@ jobs: pip install "$(cat requirements_test.txt | grep pre-commit)" - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v3.2.5 + uses: actions/cache@v3.2.6 with: path: ${{ env.PRE_COMMIT_CACHE }} key: >- @@ -256,7 +256,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.5 + uses: actions/cache/restore@v3.2.6 with: path: venv fail-on-cache-miss: true @@ -265,7 +265,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.2.5 + uses: actions/cache/restore@v3.2.6 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -302,7 +302,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.5 + uses: actions/cache/restore@v3.2.6 with: path: venv fail-on-cache-miss: true @@ -311,7 +311,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.2.5 + uses: actions/cache/restore@v3.2.6 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -351,7 +351,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.5 + uses: actions/cache/restore@v3.2.6 with: path: venv fail-on-cache-miss: true @@ -360,7 +360,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.2.5 + uses: actions/cache/restore@v3.2.6 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -400,7 +400,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.5 + uses: actions/cache/restore@v3.2.6 with: path: venv fail-on-cache-miss: true @@ -409,7 +409,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.2.5 + uses: actions/cache/restore@v3.2.6 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -438,7 +438,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.5 + uses: actions/cache/restore@v3.2.6 with: path: venv fail-on-cache-miss: true @@ -447,7 +447,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.2.5 + uses: actions/cache/restore@v3.2.6 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -562,7 +562,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.2.5 + uses: actions/cache@v3.2.6 with: path: venv key: >- @@ -570,7 +570,7 @@ jobs: needs.info.outputs.python_cache_key }} - name: Restore pip wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@v3.2.5 + uses: actions/cache@v3.2.6 with: path: ${{ env.PIP_CACHE }} key: >- @@ -624,7 +624,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.5 + uses: actions/cache/restore@v3.2.6 with: path: venv fail-on-cache-miss: true @@ -656,7 +656,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.5 + uses: actions/cache/restore@v3.2.6 with: path: venv fail-on-cache-miss: true @@ -689,7 +689,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.5 + uses: actions/cache/restore@v3.2.6 with: path: venv fail-on-cache-miss: true @@ -740,7 +740,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.5 + uses: actions/cache/restore@v3.2.6 with: path: venv fail-on-cache-miss: true @@ -748,7 +748,7 @@ jobs: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore mypy cache - uses: actions/cache@v3.2.5 + uses: actions/cache@v3.2.6 with: path: .mypy_cache key: >- @@ -799,7 +799,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.5 + uses: actions/cache/restore@v3.2.6 with: path: venv fail-on-cache-miss: true @@ -852,7 +852,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.5 + uses: actions/cache/restore@v3.2.6 with: path: venv fail-on-cache-miss: true @@ -978,7 +978,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.5 + uses: actions/cache/restore@v3.2.6 with: path: venv fail-on-cache-miss: true @@ -1082,7 +1082,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v3.2.5 + uses: actions/cache@v3.2.6 with: path: venv fail-on-cache-miss: true From d14000f7049f955a08b9c164798f5f78335e54b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Mur=C3=A9?= Date: Tue, 21 Feb 2023 18:09:41 +0100 Subject: [PATCH 0263/2810] Add esphome climate quiet fan mode (#74303) Co-authored-by: J. Nick Koston Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- homeassistant/components/esphome/climate.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index 991ad9d3b1..bfb3dbb866 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -61,6 +61,8 @@ from . import ( platform_async_setup_entry, ) +FAN_QUIET = "quiet" + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -109,6 +111,7 @@ _FAN_MODES: EsphomeEnumMapper[ClimateFanMode, str] = EsphomeEnumMapper( ClimateFanMode.MIDDLE: FAN_MIDDLE, ClimateFanMode.FOCUS: FAN_FOCUS, ClimateFanMode.DIFFUSE: FAN_DIFFUSE, + ClimateFanMode.QUIET: FAN_QUIET, } ) _SWING_MODES: EsphomeEnumMapper[ClimateSwingMode, str] = EsphomeEnumMapper( From c06420689eb3dcc6671007877e4789466c2c869e Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 21 Feb 2023 19:43:40 +0100 Subject: [PATCH 0264/2810] Bump nextdns to version 1.3.0 (#88555) Bump nextdns --- homeassistant/components/nextdns/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nextdns/manifest.json b/homeassistant/components/nextdns/manifest.json index ce58d9e002..2a1240322b 100644 --- a/homeassistant/components/nextdns/manifest.json +++ b/homeassistant/components/nextdns/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["nextdns"], "quality_scale": "platinum", - "requirements": ["nextdns==1.2.2"] + "requirements": ["nextdns==1.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index dc9282b0b4..d1699ffcf5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1198,7 +1198,7 @@ nextcloudmonitor==1.1.0 nextcord==2.0.0a8 # homeassistant.components.nextdns -nextdns==1.2.2 +nextdns==1.3.0 # homeassistant.components.nibe_heatpump nibe==1.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3abfe3267d..99c092937a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -888,7 +888,7 @@ nexia==2.0.6 nextcord==2.0.0a8 # homeassistant.components.nextdns -nextdns==1.2.2 +nextdns==1.3.0 # homeassistant.components.nibe_heatpump nibe==1.6.0 From a93b4e7197da74935b914b4420c153695a0e2ab9 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 21 Feb 2023 19:52:50 +0100 Subject: [PATCH 0265/2810] Use device-native kelvins for tplink color temperature (#85646) --- homeassistant/components/tplink/light.py | 48 +++++++++--------------- tests/components/tplink/test_light.py | 12 +++--- 2 files changed, 24 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index 6739ea421b..7bbde327e1 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_HS_COLOR, ATTR_TRANSITION, @@ -23,10 +23,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.color import ( - color_temperature_kelvin_to_mired as kelvin_to_mired, - color_temperature_mired_to_kelvin as mired_to_kelvin, -) from . import legacy_device_id from .const import DOMAIN @@ -206,18 +202,6 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): return brightness, transition - async def _async_set_color_temp( - self, color_temp_mireds: int, brightness: int | None, transition: int | None - ) -> None: - # Handle temp conversion mireds -> kelvin being slightly outside of valid range - kelvin = mired_to_kelvin(color_temp_mireds) - kelvin_range = self.device.valid_temperature_range - color_tmp = max(kelvin_range.min, min(kelvin_range.max, kelvin)) - _LOGGER.debug("Changing color temp to %s", color_tmp) - await self.device.set_color_temp( - color_tmp, brightness=brightness, transition=transition - ) - async def _async_set_hsv( self, hs_color: tuple[int, int], brightness: int | None, transition: int | None ) -> None: @@ -238,9 +222,11 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" brightness, transition = self._async_extract_brightness_transition(**kwargs) - if ATTR_COLOR_TEMP in kwargs: - await self._async_set_color_temp( - int(kwargs[ATTR_COLOR_TEMP]), brightness, transition + if ATTR_COLOR_TEMP_KELVIN in kwargs: + await self.device.set_color_temp( + int(kwargs[ATTR_COLOR_TEMP_KELVIN]), + brightness=brightness, + transition=transition, ) if ATTR_HS_COLOR in kwargs: await self._async_set_hsv(kwargs[ATTR_HS_COLOR], brightness, transition) @@ -255,19 +241,19 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): await self.device.turn_off(transition=transition) @property - def min_mireds(self) -> int: + def min_color_temp_kelvin(self) -> int: """Return minimum supported color temperature.""" - return kelvin_to_mired(self.device.valid_temperature_range.max) + return cast(int, self.device.valid_temperature_range.min) @property - def max_mireds(self) -> int: + def max_color_temp_kelvin(self) -> int: """Return maximum supported color temperature.""" - return kelvin_to_mired(self.device.valid_temperature_range.min) + return cast(int, self.device.valid_temperature_range.max) @property - def color_temp(self) -> int | None: - """Return the color temperature of this light in mireds for HA.""" - return kelvin_to_mired(self.device.color_temp) + def color_temp_kelvin(self) -> int: + """Return the color temperature of this light.""" + return cast(int, self.device.color_temp) @property def brightness(self) -> int | None: @@ -341,14 +327,16 @@ class TPLinkSmartLightStrip(TPLinkSmartBulb): await self.device.set_effect( kwargs[ATTR_EFFECT], brightness=brightness, transition=transition ) - elif ATTR_COLOR_TEMP in kwargs: + elif ATTR_COLOR_TEMP_KELVIN in kwargs: if self.effect: # If there is an effect in progress # we have to set an HSV value to clear the effect # before we can set a color temp await self.device.set_hsv(0, 0, brightness) - await self._async_set_color_temp( - int(kwargs[ATTR_COLOR_TEMP]), brightness, transition + await self.device.set_color_temp( + int(kwargs[ATTR_COLOR_TEMP_KELVIN]), + brightness=brightness, + transition=transition, ) elif ATTR_HS_COLOR in kwargs: await self._async_set_hsv(kwargs[ATTR_HS_COLOR], brightness, transition) diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py index b5dc38b230..cd8494e9b9 100644 --- a/tests/components/tplink/test_light.py +++ b/tests/components/tplink/test_light.py @@ -10,7 +10,7 @@ from homeassistant.components import tplink from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_EFFECT_LIST, ATTR_HS_COLOR, @@ -113,7 +113,7 @@ async def test_color_light( await hass.services.async_call( LIGHT_DOMAIN, "turn_on", - {**BASE_PAYLOAD, ATTR_COLOR_TEMP: 150}, + {**BASE_PAYLOAD, ATTR_COLOR_TEMP_KELVIN: 6666}, blocking=True, ) bulb.set_color_temp.assert_called_with( @@ -124,7 +124,7 @@ async def test_color_light( await hass.services.async_call( LIGHT_DOMAIN, "turn_on", - {**BASE_PAYLOAD, ATTR_COLOR_TEMP: 150}, + {**BASE_PAYLOAD, ATTR_COLOR_TEMP_KELVIN: 6666}, blocking=True, ) bulb.set_color_temp.assert_called_with( @@ -233,7 +233,7 @@ async def test_color_temp_light( assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["brightness", "color_temp"] assert attributes[ATTR_MIN_MIREDS] == 111 assert attributes[ATTR_MAX_MIREDS] == 250 - assert attributes[ATTR_COLOR_TEMP] == 250 + assert attributes[ATTR_COLOR_TEMP_KELVIN] == 4000 await hass.services.async_call( LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -258,7 +258,7 @@ async def test_color_temp_light( await hass.services.async_call( LIGHT_DOMAIN, "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 150}, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 6666}, blocking=True, ) bulb.set_color_temp.assert_called_with(6666, brightness=None, transition=None) @@ -417,7 +417,7 @@ async def test_smart_strip_effects(hass: HomeAssistant) -> None: await hass.services.async_call( LIGHT_DOMAIN, "turn_on", - {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 250}, + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 4000}, blocking=True, ) strip.set_hsv.assert_called_once_with(0, 0, None) From 0c4c95394ea8d061996dcd66b8f02f1aa90f6add Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 21 Feb 2023 20:40:39 +0100 Subject: [PATCH 0266/2810] Add WS command config/entity_registry/list_for_display (#87787) * Add WS command config/entity_registry/list_for_display * Make more keys in the display dict optional * Move disabled_by check to ws command handler * Hide hidden_by if not hidden * Use send_json_auto_id in the new test * Don't include entities which have no data needed for display * Include platform for entries with translation_key --- .../components/config/entity_registry.py | 40 ++++- homeassistant/helpers/entity_registry.py | 87 ++++++++- .../components/config/test_entity_registry.py | 165 +++++++++++++++++- 3 files changed, 279 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index 0f48c64ae4..20e00ec11e 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -15,16 +15,18 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, ) +from homeassistant.helpers.json import json_dumps async def async_setup(hass: HomeAssistant) -> bool: """Enable the Entity Registry views.""" - websocket_api.async_register_command(hass, websocket_list_entities) - websocket_api.async_register_command(hass, websocket_get_entity) websocket_api.async_register_command(hass, websocket_get_entities) - websocket_api.async_register_command(hass, websocket_update_entity) + websocket_api.async_register_command(hass, websocket_get_entity) + websocket_api.async_register_command(hass, websocket_list_entities_for_display) + websocket_api.async_register_command(hass, websocket_list_entities) websocket_api.async_register_command(hass, websocket_remove_entity) + websocket_api.async_register_command(hass, websocket_update_entity) return True @@ -40,7 +42,7 @@ def websocket_list_entities( # Build start of response message msg_json_prefix = ( f'{{"id":{msg["id"]},"type": "{websocket_api.const.TYPE_RESULT}",' - f'"success":true,"result": [' + '"success":true,"result": [' ) # Concatenate cached entity registry item JSON serializations msg_json = ( @@ -55,6 +57,36 @@ def websocket_list_entities( connection.send_message(msg_json) +@websocket_api.websocket_command( + {vol.Required("type"): "config/entity_registry/list_for_display"} +) +@callback +def websocket_list_entities_for_display( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Handle list registry entries command.""" + registry = er.async_get(hass) + # Build start of response message + entity_categories = json_dumps(er.ENTITY_CATEGORY_INDEX_TO_VALUE) + msg_json_prefix = ( + f'{{"id":{msg["id"]},"type":"{websocket_api.const.TYPE_RESULT}","success":true,' + f'"result":{{"entity_categories":{entity_categories},"entities":[' + ) + # Concatenate cached entity registry item JSON serializations + msg_json = ( + msg_json_prefix + + ",".join( + entry.display_json_repr + for entry in registry.entities.values() + if entry.disabled_by is None and entry.display_json_repr is not None + ) + + "]}}" + ) + connection.send_message(msg_json) + + @websocket_api.websocket_command( { vol.Required("type"): "config/entity_registry/get", diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index f779fe00ed..78ccdcd12f 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -65,6 +65,13 @@ STORAGE_VERSION_MAJOR = 1 STORAGE_VERSION_MINOR = 10 STORAGE_KEY = "core.entity_registry" +ENTITY_CATEGORY_VALUE_TO_INDEX: dict[EntityCategory | None, int] = { + # mypy does not understand strenum + val: idx # type: ignore[misc] + for idx, val in enumerate(EntityCategory) +} +ENTITY_CATEGORY_INDEX_TO_VALUE = dict(enumerate(EntityCategory)) + # Attributes relevant to describing entity # to external services. ENTITY_DESCRIBING_ATTRIBUTES = { @@ -97,6 +104,12 @@ class RegistryEntryHider(StrEnum): EntityOptionsType = Mapping[str, Mapping[str, Any]] +DISLAY_DICT_OPTIONAL = ( + ("ai", "area_id"), + ("di", "device_id"), + ("tk", "translation_key"), +) + @attr.s(slots=True, frozen=True) class RegistryEntry: @@ -131,7 +144,12 @@ class RegistryEntry: translation_key: str | None = attr.ib(default=None) unit_of_measurement: str | None = attr.ib(default=None) - _json_repr: str | None = attr.ib(cmp=False, default=None, init=False, repr=False) + _partial_repr: str | None | UndefinedType = attr.ib( + cmp=False, default=UNDEFINED, init=False, repr=False + ) + _display_repr: str | None | UndefinedType = attr.ib( + cmp=False, default=UNDEFINED, init=False, repr=False + ) @domain.default def _domain_default(self) -> str: @@ -148,6 +166,63 @@ class RegistryEntry: """Return if entry is hidden.""" return self.hidden_by is not None + @property + def _as_display_dict(self) -> dict[str, Any] | None: + """Return a partial dict representation of the entry. + + This version only includes what's needed for display. + Returns None if there's no data needed for display. + """ + display_dict: dict[str, Any] = {} + for key, attr_name in DISLAY_DICT_OPTIONAL: + if (attr_val := getattr(self, attr_name)) is not None: + display_dict[key] = attr_val + if "tk" in display_dict: + display_dict["pl"] = self.platform + if (category := self.entity_category) is not None: + display_dict["ec"] = ENTITY_CATEGORY_VALUE_TO_INDEX[category] + if self.hidden_by is not None: + display_dict["hb"] = True + if not self.name and self.has_entity_name: + display_dict["en"] = self.original_name + if self.domain == "sensor" and (sensor_options := self.options.get("sensor")): + if (precision := sensor_options.get("display_precision")) is not None: + display_dict["dp"] = precision + elif ( + precision := sensor_options.get("suggested_display_precision") + ) is not None: + display_dict["dp"] = precision + if not display_dict: + # We didn't gather any data needed for display + return None + display_dict["ei"] = self.entity_id + return display_dict + + @property + def display_json_repr(self) -> str | None: + """Return a cached partial JSON representation of the entry. + + This version only includes what's needed for display. + """ + if self._display_repr is not UNDEFINED: + return self._display_repr + + try: + dict_repr = self._as_display_dict + json_repr: str | None = JSON_DUMP(dict_repr) if dict_repr else None + object.__setattr__(self, "_display_repr", json_repr) + except (ValueError, TypeError): + object.__setattr__(self, "_display_repr", None) + _LOGGER.error( + "Unable to serialize entry %s to JSON. Bad data found at %s", + self.entity_id, + format_unserializable_data( + find_paths_unserializable_data(dict_repr, dump=JSON_DUMP) + ), + ) + # Mypy doesn't understand the __setattr__ business + return self._display_repr # type: ignore[return-value] + @property def as_partial_dict(self) -> dict[str, Any]: """Return a partial dict representation of the entry.""" @@ -173,13 +248,14 @@ class RegistryEntry: @property def partial_json_repr(self) -> str | None: """Return a cached partial JSON representation of the entry.""" - if self._json_repr is not None: - return self._json_repr + if self._partial_repr is not UNDEFINED: + return self._partial_repr try: dict_repr = self.as_partial_dict - object.__setattr__(self, "_json_repr", JSON_DUMP(dict_repr)) + object.__setattr__(self, "_partial_repr", JSON_DUMP(dict_repr)) except (ValueError, TypeError): + object.__setattr__(self, "_partial_repr", None) _LOGGER.error( "Unable to serialize entry %s to JSON. Bad data found at %s", self.entity_id, @@ -187,7 +263,8 @@ class RegistryEntry: find_paths_unserializable_data(dict_repr, dump=JSON_DUMP) ), ) - return self._json_repr + # Mypy doesn't understand the __setattr__ business + return self._partial_repr # type: ignore[return-value] @callback def write_unavailable_state(self, hass: HomeAssistant) -> None: diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py index 8873c85dcf..66f1567ddb 100644 --- a/tests/components/config/test_entity_registry.py +++ b/tests/components/config/test_entity_registry.py @@ -3,7 +3,7 @@ import pytest from pytest_unordered import unordered from homeassistant.components.config import entity_registry -from homeassistant.const import ATTR_ICON +from homeassistant.const import ATTR_ICON, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceEntryDisabler @@ -22,13 +22,16 @@ from tests.common import ( mock_device_registry, mock_registry, ) +from tests.typing import MockHAClientWebSocket, WebSocketGenerator @pytest.fixture -def client(hass, hass_ws_client): +async def client( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> MockHAClientWebSocket: """Fixture that can interact with the config manager API.""" - hass.loop.run_until_complete(entity_registry.async_setup(hass)) - return hass.loop.run_until_complete(hass_ws_client(hass)) + await entity_registry.async_setup(hass) + return await hass_ws_client(hass) @pytest.fixture @@ -144,6 +147,160 @@ async def test_list_entities(hass: HomeAssistant, client) -> None: ] +async def test_list_entities_for_display( + hass: HomeAssistant, client: MockHAClientWebSocket +) -> None: + """Test list entries.""" + mock_registry( + hass, + { + "test_domain.test": RegistryEntry( + area_id="area52", + device_id="device123", + entity_category=EntityCategory.DIAGNOSTIC, + entity_id="test_domain.test", + has_entity_name=True, + original_name="Hello World", + platform="test_platform", + translation_key="translations_galore", + unique_id="1234", + ), + "test_domain.nameless": RegistryEntry( + area_id="area52", + device_id="device123", + entity_id="test_domain.nameless", + has_entity_name=True, + original_name=None, + platform="test_platform", + unique_id="2345", + ), + "test_domain.renamed": RegistryEntry( + area_id="area52", + device_id="device123", + entity_id="test_domain.renamed", + has_entity_name=True, + name="User name", + original_name="Hello World", + platform="test_platform", + unique_id="3456", + ), + "test_domain.boring": RegistryEntry( + entity_id="test_domain.boring", + platform="test_platform", + unique_id="4567", + ), + "test_domain.disabled": RegistryEntry( + disabled_by=RegistryEntryDisabler.USER, + entity_id="test_domain.disabled", + hidden_by=RegistryEntryHider.USER, + platform="test_platform", + unique_id="789A", + ), + "test_domain.hidden": RegistryEntry( + entity_id="test_domain.hidden", + hidden_by=RegistryEntryHider.USER, + platform="test_platform", + unique_id="89AB", + ), + "sensor.default_precision": RegistryEntry( + entity_id="sensor.default_precision", + options={"sensor": {"suggested_display_precision": 0}}, + platform="test_platform", + unique_id="9ABC", + ), + "sensor.user_precision": RegistryEntry( + entity_id="sensor.user_precision", + options={ + "sensor": {"display_precision": 0, "suggested_display_precision": 1} + }, + platform="test_platform", + unique_id="ABCD", + ), + }, + ) + + await client.send_json_auto_id({"type": "config/entity_registry/list_for_display"}) + msg = await client.receive_json() + + assert msg["result"] == { + "entity_categories": {"0": "config", "1": "diagnostic"}, + "entities": [ + { + "ai": "area52", + "di": "device123", + "ec": 1, + "ei": "test_domain.test", + "en": "Hello World", + "pl": "test_platform", + "tk": "translations_galore", + }, + { + "ai": "area52", + "di": "device123", + "ei": "test_domain.nameless", + "en": None, + }, + { + "ai": "area52", + "di": "device123", + "ei": "test_domain.renamed", + }, + { + "ei": "test_domain.hidden", + "hb": True, + }, + { + "dp": 0, + "ei": "sensor.default_precision", + }, + { + "dp": 0, + "ei": "sensor.user_precision", + }, + ], + } + + class Unserializable: + """Good luck serializing me.""" + + mock_registry( + hass, + { + "test_domain.test": RegistryEntry( + area_id="area52", + device_id="device123", + entity_id="test_domain.test", + has_entity_name=True, + original_name="Hello World", + platform="test_platform", + unique_id="1234", + ), + "test_domain.name_2": RegistryEntry( + entity_id="test_domain.name_2", + has_entity_name=True, + original_name=Unserializable(), + platform="test_platform", + unique_id="6789", + ), + }, + ) + + await client.send_json_auto_id({"type": "config/entity_registry/list_for_display"}) + msg = await client.receive_json() + + assert msg["result"] == { + "entity_categories": {"0": "config", "1": "diagnostic"}, + "entities": [ + { + "ai": "area52", + "di": "device123", + "ei": "test_domain.test", + "en": "Hello World", + }, + ], + } + + async def test_get_entity(hass: HomeAssistant, client) -> None: """Test get entry.""" mock_registry( From 7929e76ebbfd7fbd827294b1729193cec4d8f18d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Feb 2023 13:45:05 -0600 Subject: [PATCH 0267/2810] Only build the field_map once in recorder history (#88529) --- homeassistant/components/recorder/history.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/recorder/history.py b/homeassistant/components/recorder/history.py index e466cd07e0..fb1a55cebf 100644 --- a/homeassistant/components/recorder/history.py +++ b/homeassistant/components/recorder/history.py @@ -12,6 +12,7 @@ from typing import Any, cast from sqlalchemy import Column, Text, and_, func, lambda_stmt, or_, select from sqlalchemy.engine.row import Row +from sqlalchemy.orm.properties import MappedColumn from sqlalchemy.orm.query import Query from sqlalchemy.orm.session import Session from sqlalchemy.sql.expression import literal @@ -141,6 +142,14 @@ _QUERY_STATES_NO_LAST_CHANGED = ( States.attributes, StateAttributes.shared_attrs, ) +_FIELD_MAP = { + cast(MappedColumn, field).name: idx + for idx, field in enumerate(_QUERY_STATE_NO_ATTR) +} +_FIELD_MAP_PRE_SCHEMA_31 = { + cast(MappedColumn, field).name: idx + for idx, field in enumerate(_QUERY_STATES_PRE_SCHEMA_31) +} def _schema_version(hass: HomeAssistant) -> int: @@ -856,7 +865,7 @@ def _sorted_states_to_dict( """ schema_version = _schema_version(hass) _process_timestamp: Callable[[datetime], float | str] - field_map: dict[str, int] + field_map = _FIELD_MAP if schema_version >= 31 else _FIELD_MAP_PRE_SCHEMA_31 state_class: Callable[ [Row, dict[str, dict[str, Any]], datetime | None], State | dict[str, Any] ] @@ -919,7 +928,6 @@ def _sorted_states_to_dict( if row := initial_states.pop(ent_id, None): prev_state = row.state ent_results.append(state_class(row, attr_cache, start_time)) - field_map = {key: idx for idx, key in enumerate(row._fields)} if not minimal_response or split_entity_id(ent_id)[0] in NEED_ATTRIBUTE_DOMAINS: ent_results.extend( @@ -936,7 +944,6 @@ def _sorted_states_to_dict( continue prev_state = first_state.state ent_results.append(state_class(first_state, attr_cache, None)) - field_map = {key: idx for idx, key in enumerate(first_state._fields)} state_idx = field_map["state"] From 905a0f2eb6fce5ba913e67e32df97c88d3b4f4e7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Feb 2023 13:54:08 -0600 Subject: [PATCH 0268/2810] Fix sonos blocking I/O in the event loop (#88554) --- homeassistant/components/sonos/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index e7e0b3952e..e181e995c7 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -392,7 +392,7 @@ class SonosDiscoveryManager: ) elif not known_speaker.available: try: - known_speaker.ping() + await self.hass.async_add_executor_job(known_speaker.ping) except SonosUpdateError: _LOGGER.debug( "Manual poll to %s failed, keeping unavailable", ip_addr From ff93b7a01c11db5c0fd4088b7cdaf25e6b395aeb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Feb 2023 13:56:36 -0600 Subject: [PATCH 0269/2810] Restore vulcan integration (#88556) --- homeassistant/components/vulcan/__init__.py | 7 +-- homeassistant/components/vulcan/calendar.py | 2 +- .../components/vulcan/config_flow.py | 2 +- homeassistant/components/vulcan/manifest.json | 1 - homeassistant/components/vulcan/register.py | 2 +- requirements_all.txt | 3 ++ requirements_test_all.txt | 3 ++ tests/components/vulcan/test_config_flow.py | 44 ++++++++----------- 8 files changed, 28 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/vulcan/__init__.py b/homeassistant/components/vulcan/__init__.py index 829073b557..0bfd09d590 100644 --- a/homeassistant/components/vulcan/__init__.py +++ b/homeassistant/components/vulcan/__init__.py @@ -1,12 +1,7 @@ """The Vulcan component.""" from aiohttp import ClientConnectorError -from vulcan import ( # pylint: disable=import-error - Account, - Keystore, - UnauthorizedCertificateException, - Vulcan, -) +from vulcan import Account, Keystore, UnauthorizedCertificateException, Vulcan from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform diff --git a/homeassistant/components/vulcan/calendar.py b/homeassistant/components/vulcan/calendar.py index fa47286fb7..d9182bb990 100644 --- a/homeassistant/components/vulcan/calendar.py +++ b/homeassistant/components/vulcan/calendar.py @@ -5,7 +5,7 @@ from datetime import date, datetime, timedelta import logging from aiohttp import ClientConnectorError -from vulcan import UnauthorizedCertificateException # pylint: disable=import-error +from vulcan import UnauthorizedCertificateException from homeassistant.components.calendar import ( ENTITY_ID_FORMAT, diff --git a/homeassistant/components/vulcan/config_flow.py b/homeassistant/components/vulcan/config_flow.py index b92c20ae60..4547e6dd31 100644 --- a/homeassistant/components/vulcan/config_flow.py +++ b/homeassistant/components/vulcan/config_flow.py @@ -5,7 +5,7 @@ from typing import Any from aiohttp import ClientConnectionError import voluptuous as vol -from vulcan import ( # pylint: disable=import-error +from vulcan import ( Account, ExpiredTokenException, InvalidPINException, diff --git a/homeassistant/components/vulcan/manifest.json b/homeassistant/components/vulcan/manifest.json index b4771ed04d..fea87480cf 100644 --- a/homeassistant/components/vulcan/manifest.json +++ b/homeassistant/components/vulcan/manifest.json @@ -3,7 +3,6 @@ "name": "Uonet+ Vulcan", "codeowners": ["@Antoni-Czaplicki"], "config_flow": true, - "disabled": "Temporarily unblock nightly build", "documentation": "https://www.home-assistant.io/integrations/vulcan", "iot_class": "cloud_polling", "quality_scale": "silver", diff --git a/homeassistant/components/vulcan/register.py b/homeassistant/components/vulcan/register.py index c37d4ce9fc..67cceb8d7b 100644 --- a/homeassistant/components/vulcan/register.py +++ b/homeassistant/components/vulcan/register.py @@ -1,6 +1,6 @@ """Support for register Vulcan account.""" -from vulcan import Account, Keystore # pylint: disable=import-error +from vulcan import Account, Keystore async def register(hass, token, symbol, pin): diff --git a/requirements_all.txt b/requirements_all.txt index d1699ffcf5..6185b412d8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2594,6 +2594,9 @@ vsure==1.8.1 # homeassistant.components.vasttrafik vtjp==0.1.14 +# homeassistant.components.vulcan +vulcan-api==2.3.0 + # homeassistant.components.vultr vultr==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 99c092937a..c61e8d3944 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1837,6 +1837,9 @@ volvooncall==0.10.2 # homeassistant.components.verisure vsure==1.8.1 +# homeassistant.components.vulcan +vulcan-api==2.3.0 + # homeassistant.components.vultr vultr==0.1.2 diff --git a/tests/components/vulcan/test_config_flow.py b/tests/components/vulcan/test_config_flow.py index c68c013a34..d575fe7360 100644 --- a/tests/components/vulcan/test_config_flow.py +++ b/tests/components/vulcan/test_config_flow.py @@ -1,41 +1,33 @@ """Test the Uonet+ Vulcan config flow.""" -# flake8: noqa import json from unittest import mock from unittest.mock import patch -import pytest +from vulcan import ( + Account, + ExpiredTokenException, + InvalidPINException, + InvalidSymbolException, + InvalidTokenException, + UnauthorizedCertificateException, +) +from vulcan.model import Student from homeassistant import config_entries, data_entry_flow - -# from homeassistant.components.vulcan import config_flow, const, register -# from homeassistant.components.vulcan.config_flow import ClientConnectionError, Keystore +from homeassistant.components.vulcan import config_flow, const, register +from homeassistant.components.vulcan.config_flow import ClientConnectionError, Keystore from homeassistant.const import CONF_PIN, CONF_REGION, CONF_TOKEN from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture -# from vulcan import ( -# Account, -# ExpiredTokenException, -# InvalidPINException, -# InvalidSymbolException, -# InvalidTokenException, -# UnauthorizedCertificateException, -# ) -# from vulcan.model import Student - - -pytestmark = pytest.mark.skip - - -# fake_keystore = Keystore("", "", "", "", "") -# fake_account = Account( -# login_id=1, -# user_login="example@example.com", -# user_name="example@example.com", -# rest_url="rest_url", -# ) +fake_keystore = Keystore("", "", "", "", "") +fake_account = Account( + login_id=1, + user_login="example@example.com", + user_name="example@example.com", + rest_url="rest_url", +) async def test_show_form(hass: HomeAssistant) -> None: From e6c792deed7ba119aff7cb000a73948989db888c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Feb 2023 14:23:53 -0600 Subject: [PATCH 0270/2810] Add missing kelvin attributes to light recorder platform (#88561) --- homeassistant/components/light/recorder.py | 4 ++++ tests/components/light/test_recorder.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/homeassistant/components/light/recorder.py b/homeassistant/components/light/recorder.py index 9febb98fa4..e38ba888e7 100644 --- a/homeassistant/components/light/recorder.py +++ b/homeassistant/components/light/recorder.py @@ -5,7 +5,9 @@ from homeassistant.core import HomeAssistant, callback from . import ( ATTR_EFFECT_LIST, + ATTR_MAX_COLOR_TEMP_KELVIN, ATTR_MAX_MIREDS, + ATTR_MIN_COLOR_TEMP_KELVIN, ATTR_MIN_MIREDS, ATTR_SUPPORTED_COLOR_MODES, ) @@ -19,4 +21,6 @@ def exclude_attributes(hass: HomeAssistant) -> set[str]: ATTR_EFFECT_LIST, ATTR_MIN_MIREDS, ATTR_MAX_MIREDS, + ATTR_MIN_COLOR_TEMP_KELVIN, + ATTR_MAX_COLOR_TEMP_KELVIN, } diff --git a/tests/components/light/test_recorder.py b/tests/components/light/test_recorder.py index a4822b3cd7..b4e0621d23 100644 --- a/tests/components/light/test_recorder.py +++ b/tests/components/light/test_recorder.py @@ -6,7 +6,9 @@ from datetime import timedelta from homeassistant.components import light from homeassistant.components.light import ( ATTR_EFFECT, + ATTR_MAX_COLOR_TEMP_KELVIN, ATTR_MAX_MIREDS, + ATTR_MIN_COLOR_TEMP_KELVIN, ATTR_MIN_MIREDS, ATTR_SUPPORTED_COLOR_MODES, ) @@ -53,3 +55,5 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) assert ATTR_SUPPORTED_COLOR_MODES not in state.attributes assert ATTR_EFFECT not in state.attributes assert ATTR_FRIENDLY_NAME in state.attributes + assert ATTR_MAX_COLOR_TEMP_KELVIN not in state.attributes + assert ATTR_MIN_COLOR_TEMP_KELVIN not in state.attributes From 24234a55a5278c8cd7f6ff2dbe0d8608f580a1b1 Mon Sep 17 00:00:00 2001 From: rhpijnacker Date: Tue, 21 Feb 2023 21:43:39 +0100 Subject: [PATCH 0271/2810] Bump pyRFXtrx to 0.30.1 (#88538) --- homeassistant/components/rfxtrx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rfxtrx/manifest.json b/homeassistant/components/rfxtrx/manifest.json index ebbfba1fb5..1e2a3d6da2 100644 --- a/homeassistant/components/rfxtrx/manifest.json +++ b/homeassistant/components/rfxtrx/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/rfxtrx", "iot_class": "local_push", "loggers": ["RFXtrx"], - "requirements": ["pyRFXtrx==0.30.0"] + "requirements": ["pyRFXtrx==0.30.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6185b412d8..fbe3a52514 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1467,7 +1467,7 @@ pyMetEireann==2021.8.0 pyMetno==0.9.0 # homeassistant.components.rfxtrx -pyRFXtrx==0.30.0 +pyRFXtrx==0.30.1 # homeassistant.components.switchmate pySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c61e8d3944..58046e59a5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1073,7 +1073,7 @@ pyMetEireann==2021.8.0 pyMetno==0.9.0 # homeassistant.components.rfxtrx -pyRFXtrx==0.30.0 +pyRFXtrx==0.30.1 # homeassistant.components.tibber pyTibber==0.26.13 From 3f79155df6dc6ade5cbd229972818ea371b6c932 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 21 Feb 2023 22:21:00 +0100 Subject: [PATCH 0272/2810] Remove deprecated callback support for MQTT subscribe (#88543) * Remove deprecated callback support and fix tests * Add note with removal instruction --- homeassistant/components/mqtt/client.py | 77 ++---------- tests/components/mqtt/test_init.py | 150 ++++++++---------------- 2 files changed, 61 insertions(+), 166 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index dfc88844bd..ad89a35ec0 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -3,14 +3,14 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Coroutine, Iterable -from functools import lru_cache, partial, wraps +from functools import lru_cache import inspect from itertools import chain, groupby import logging from operator import attrgetter import ssl import time -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any import uuid import async_timeout @@ -72,7 +72,6 @@ from .models import ( PublishMessage, PublishPayloadType, ReceiveMessage, - ReceivePayloadType, ) from .util import get_file_path, get_mqtt_data, mqtt_config_entry_enabled @@ -148,55 +147,11 @@ async def async_publish( ) -AsyncDeprecatedMessageCallbackType = Callable[ - [str, ReceivePayloadType, int], Coroutine[Any, Any, None] -] -DeprecatedMessageCallbackType = Callable[[str, ReceivePayloadType, int], None] -DeprecatedMessageCallbackTypes = ( - AsyncDeprecatedMessageCallbackType | DeprecatedMessageCallbackType -) - - -# Support for a deprecated callback type will be removed from HA core 2023.2.0 -def wrap_msg_callback( - msg_callback: DeprecatedMessageCallbackTypes, -) -> AsyncMessageCallbackType | MessageCallbackType: - """Wrap an MQTT message callback to support deprecated signature.""" - # Check for partials to properly determine if coroutine function - check_func = msg_callback - while isinstance(check_func, partial): - check_func = check_func.func # type: ignore[unreachable] - - wrapper_func: AsyncMessageCallbackType | MessageCallbackType - if asyncio.iscoroutinefunction(check_func): - - @wraps(msg_callback) - async def async_wrapper(msg: ReceiveMessage) -> None: - """Call with deprecated signature.""" - await cast(AsyncDeprecatedMessageCallbackType, msg_callback)( - msg.topic, msg.payload, msg.qos - ) - - wrapper_func = async_wrapper - return wrapper_func - - @wraps(msg_callback) - def wrapper(msg: ReceiveMessage) -> None: - """Call with deprecated signature.""" - msg_callback(msg.topic, msg.payload, msg.qos) - - wrapper_func = wrapper - - return wrapper_func - - @bind_hass async def async_subscribe( hass: HomeAssistant, topic: str, - msg_callback: AsyncMessageCallbackType - | MessageCallbackType - | DeprecatedMessageCallbackTypes, + msg_callback: AsyncMessageCallbackType | MessageCallbackType, qos: int = DEFAULT_QOS, encoding: str | None = DEFAULT_ENCODING, ) -> CALLBACK_TYPE: @@ -209,8 +164,8 @@ async def async_subscribe( raise HomeAssistantError( f"Cannot subscribe to topic '{topic}', MQTT is not enabled" ) - # Support for a deprecated callback type will be removed from HA core 2023.2.0 - # Count callback parameters which don't have a default value + # Support for a deprecated callback type was removed with HA core 2023.3.0 + # The signature validation code can be removed from HA core 2023.5.0 non_default = 0 if msg_callback: non_default = sum( @@ -218,26 +173,20 @@ async def async_subscribe( for _, p in inspect.signature(msg_callback).parameters.items() ) - wrapped_msg_callback = msg_callback - # If we have 3 parameters with no default value, wrap the callback - if non_default == 3: + # Check for not supported callback signatures + # Can be removed from HA core 2023.5.0 + if non_default != 1: module = inspect.getmodule(msg_callback) - _LOGGER.warning( - ( - "Signature of MQTT msg_callback '%s.%s' is deprecated, " - "this will stop working with HA core 2023.2" - ), - module.__name__ if module else "", - msg_callback.__name__, - ) - wrapped_msg_callback = wrap_msg_callback( - cast(DeprecatedMessageCallbackTypes, msg_callback) + raise HomeAssistantError( + "Signature for MQTT msg_callback '{}.{}' is not supported".format( + module.__name__ if module else "", msg_callback.__name__ + ) ) async_remove = await mqtt_data.client.async_subscribe( topic, catch_log_exception( - wrapped_msg_callback, + msg_callback, lambda msg: ( f"Exception in {msg_callback.__name__} when handling msg on " f"'{msg.topic}': '{msg.payload}'" diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 8462e46318..47f8743f50 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -920,109 +920,42 @@ async def test_subscribe_bad_topic( await mqtt.async_subscribe(hass, 55, record_calls) # type: ignore[arg-type] -# Support for a deprecated callback type will be removed from HA core 2023.2.0 -async def test_subscribe_deprecated( +# Support for a deprecated callback type was removed with HA core 2023.3.0 +# Test can be removed from HA core 2023.5.0 +async def test_subscribe_with_deprecated_callback_fails( hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: - """Test the subscription of a topic using deprecated callback signature.""" - calls: list[tuple[str, ReceivePayloadType, int]] - - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + """Test the subscription of a topic using deprecated callback signature fails.""" async def record_calls(topic: str, payload: ReceivePayloadType, qos: int) -> None: """Record calls.""" - calls.append((topic, payload, qos)) - - calls = [] - unsub = await mqtt.async_subscribe(hass, "test-topic", record_calls) - - async_fire_mqtt_message(hass, "test-topic", "test-payload") - - await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0][0] == "test-topic" - assert calls[0][1] == "test-payload" - - unsub() - - async_fire_mqtt_message(hass, "test-topic", "test-payload") - - await hass.async_block_till_done() - assert len(calls) == 1 - mqtt_mock.async_publish.reset_mock() + with pytest.raises(HomeAssistantError): + await mqtt.async_subscribe(hass, "test-topic", record_calls) # Test with partial wrapper - calls = [] - unsub = await mqtt.async_subscribe( - hass, "test-topic", RecordCallsPartial(record_calls) - ) - - async_fire_mqtt_message(hass, "test-topic", "test-payload") - - await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0][0] == "test-topic" - assert calls[0][1] == "test-payload" - - unsub() - - async_fire_mqtt_message(hass, "test-topic", "test-payload") - - await hass.async_block_till_done() - assert len(calls) == 1 + with pytest.raises(HomeAssistantError): + await mqtt.async_subscribe(hass, "test-topic", RecordCallsPartial(record_calls)) -# Support for a deprecated callback type will be removed from HA core 2023.2.0 -async def test_subscribe_deprecated_async( +# Support for a deprecated callback type was removed with HA core 2023.3.0 +# Test can be removed from HA core 2023.5.0 +async def test_subscribe_deprecated_async_fails( hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: - """Test the subscription of a topic using deprecated coroutine signature.""" - calls: list[tuple[str, ReceivePayloadType, int]] - - mqtt_mock = await mqtt_mock_entry_no_yaml_config() + """Test the subscription of a topic using deprecated coroutine signature fails.""" @callback def async_record_calls(topic: str, payload: ReceivePayloadType, qos: int) -> None: """Record calls.""" - calls.append((topic, payload, qos)) - calls = [] - unsub = await mqtt.async_subscribe(hass, "test-topic", async_record_calls) - - async_fire_mqtt_message(hass, "test-topic", "test-payload") - - await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0][0] == "test-topic" - assert calls[0][1] == "test-payload" - - unsub() - - async_fire_mqtt_message(hass, "test-topic", "test-payload") - - await hass.async_block_till_done() - assert len(calls) == 1 - mqtt_mock.async_publish.reset_mock() + with pytest.raises(HomeAssistantError): + await mqtt.async_subscribe(hass, "test-topic", async_record_calls) # Test with partial wrapper - calls = [] - unsub = await mqtt.async_subscribe( - hass, "test-topic", RecordCallsPartial(async_record_calls) - ) - - async_fire_mqtt_message(hass, "test-topic", "test-payload") - - await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0][0] == "test-topic" - assert calls[0][1] == "test-payload" - - unsub() - - async_fire_mqtt_message(hass, "test-topic", "test-payload") - - await hass.async_block_till_done() - assert len(calls) == 1 + with pytest.raises(HomeAssistantError): + await mqtt.async_subscribe( + hass, "test-topic", RecordCallsPartial(async_record_calls) + ) async def test_subscribe_topic_not_match( @@ -1300,25 +1233,32 @@ async def test_subscribe_same_topic( # Fake that the client is connected mqtt_mock().connected = True - calls_a = MagicMock() - await mqtt.async_subscribe(hass, "test/state", calls_a) + calls_a: list[ReceiveMessage] = [] + calls_b: list[ReceiveMessage] = [] + + def _callback_a(msg: ReceiveMessage) -> None: + calls_a.append(msg) + + def _callback_b(msg: ReceiveMessage) -> None: + calls_b.append(msg) + + await mqtt.async_subscribe(hass, "test/state", _callback_a) async_fire_mqtt_message( hass, "test/state", "online" ) # Simulate a (retained) message await hass.async_block_till_done() - assert calls_a.called + assert len(calls_a) == 1 mqtt_client_mock.subscribe.assert_called() - calls_a.reset_mock() + calls_a = [] mqtt_client_mock.reset_mock() - calls_b = MagicMock() - await mqtt.async_subscribe(hass, "test/state", calls_b) + await mqtt.async_subscribe(hass, "test/state", _callback_b) async_fire_mqtt_message( hass, "test/state", "online" ) # Simulate a (retained) message await hass.async_block_till_done() - assert calls_a.called - assert calls_b.called + assert len(calls_a) == 1 + assert len(calls_b) == 1 mqtt_client_mock.subscribe.assert_called() @@ -1353,19 +1293,25 @@ async def test_unsubscribe_race( # Fake that the client is connected mqtt_mock().connected = True - calls_a = MagicMock() - calls_b = MagicMock() + calls_a: list[ReceiveMessage] = [] + calls_b: list[ReceiveMessage] = [] + + def _callback_a(msg: ReceiveMessage) -> None: + calls_a.append(msg) + + def _callback_b(msg: ReceiveMessage) -> None: + calls_b.append(msg) mqtt_client_mock.reset_mock() - unsub = await mqtt.async_subscribe(hass, "test/state", calls_a) + unsub = await mqtt.async_subscribe(hass, "test/state", _callback_a) unsub() - await mqtt.async_subscribe(hass, "test/state", calls_b) + await mqtt.async_subscribe(hass, "test/state", _callback_b) await hass.async_block_till_done() async_fire_mqtt_message(hass, "test/state", "online") await hass.async_block_till_done() - assert not calls_a.called - assert calls_b.called + assert not calls_a + assert calls_b # We allow either calls [subscribe, unsubscribe, subscribe] or [subscribe, subscribe] expected_calls_1 = [ @@ -1905,7 +1851,7 @@ async def test_custom_birth_message( await mqtt_mock_entry_no_yaml_config() birth = asyncio.Event() - async def wait_birth(topic, payload, qos) -> None: + async def wait_birth(msg: ReceiveMessage) -> None: """Handle birth message.""" birth.set() @@ -1940,7 +1886,7 @@ async def test_default_birth_message( await mqtt_mock_entry_no_yaml_config() birth = asyncio.Event() - async def wait_birth(topic, payload, qos) -> None: + async def wait_birth(msg: ReceiveMessage) -> None: """Handle birth message.""" birth.set() @@ -2015,7 +1961,7 @@ async def test_delayed_birth_message( mqtt_mock = hass.data["mqtt"].client mqtt_mock.reset_mock() - async def wait_birth(topic, payload, qos) -> None: + async def wait_birth(msg: ReceiveMessage) -> None: """Handle birth message.""" birth.set() From 3501a9788f6f16897d4f022508b738834cbfbdd6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 21 Feb 2023 22:37:13 +0100 Subject: [PATCH 0273/2810] Update entity registry snapshot serializer (#88566) Co-authored-by: Erik Montnemery --- tests/syrupy.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/syrupy.py b/tests/syrupy.py index 346b15259e..4f646a05eb 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -172,7 +172,8 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): "id": ANY, } ) - serialized.pop("_json_repr") + serialized.pop("_partial_repr") + serialized.pop("_display_repr") return serialized @classmethod From 93e37243f67cc62ed154e1b17a89459e74deb9a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Feb 2023 16:22:44 -0600 Subject: [PATCH 0274/2810] Fix logbook test that failed to exclude EVENT_HOMEASSISTANT_FINAL_WRITE (#88568) --- tests/components/logbook/test_websocket_api.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index 48799a26ab..b8ea095d8d 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -2475,7 +2475,9 @@ async def test_subscribe_all_entities_have_uom_multiple( await hass.async_block_till_done() # Check our listener got unsubscribed - assert hass.bus.async_listeners() == init_listeners + assert listeners_without_writes( + hass.bus.async_listeners() + ) == listeners_without_writes(init_listeners) @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) From ec53c26f9095237db3d17ca544b19bad2043f1a8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Feb 2023 16:32:15 -0600 Subject: [PATCH 0275/2810] Constrain faust-cchardet to first version that builds wheels successfully (#88563) --- homeassistant/package_constraints.txt | 4 ++++ script/gen_requirements_all.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 17b46e97cd..d47b4e304d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -149,3 +149,7 @@ pyOpenSSL>=23.0.0 # uamqp newer versions we currently can't build for armv7/armhf # Limit this to Python 3.10, to not block Python 3.11 dev for now uamqp==1.6.0;python_version<'3.11' + +# faust-cchardet: Ensure we have a version we can build wheels +# 2.1.18 is the first version that works with our wheel builder +faust-cchardet>=2.1.18 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index be1f623eb5..9f08f0b628 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -157,6 +157,10 @@ pyOpenSSL>=23.0.0 # uamqp newer versions we currently can't build for armv7/armhf # Limit this to Python 3.10, to not block Python 3.11 dev for now uamqp==1.6.0;python_version<'3.11' + +# faust-cchardet: Ensure we have a version we can build wheels +# 2.1.18 is the first version that works with our wheel builder +faust-cchardet>=2.1.18 """ IGNORE_PRE_COMMIT_HOOK_ID = ( From bfa315be51371a1b63e04342a0b275a57ae148bd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 21 Feb 2023 23:38:50 +0100 Subject: [PATCH 0276/2810] Bump hatasmota to 0.6.4 (#88565) --- homeassistant/components/tasmota/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json index 5360a5cda0..c9c135fccc 100644 --- a/homeassistant/components/tasmota/manifest.json +++ b/homeassistant/components/tasmota/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["hatasmota"], "mqtt": ["tasmota/discovery/#"], - "requirements": ["hatasmota==0.6.3"] + "requirements": ["hatasmota==0.6.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index fbe3a52514..64efb65e1c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -877,7 +877,7 @@ hass_splunk==0.1.1 hassil==0.2.6 # homeassistant.components.tasmota -hatasmota==0.6.3 +hatasmota==0.6.4 # homeassistant.components.jewish_calendar hdate==0.10.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 58046e59a5..9f55743de2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -669,7 +669,7 @@ hass-nabucasa==0.61.0 hassil==0.2.6 # homeassistant.components.tasmota -hatasmota==0.6.3 +hatasmota==0.6.4 # homeassistant.components.jewish_calendar hdate==0.10.4 From 356c316b39265f3fe1eaca63e28b149558cbeece Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 22 Feb 2023 00:19:38 +0100 Subject: [PATCH 0277/2810] Fix stray prints seen inbetween tests (#88570) --- tests/conftest.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 356e2ba0d8..148b8b8e47 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -101,6 +101,9 @@ from .test_util.aiohttp import ( # noqa: E402, isort:skip _LOGGER = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) +logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO) + asyncio.set_event_loop_policy(runner.HassEventLoopPolicy(False)) # Disable fixtures overriding our beautiful policy asyncio.set_event_loop_policy = lambda policy: None @@ -131,10 +134,7 @@ def pytest_configure(config: pytest.Config) -> None: "markers", "no_fail_on_log_exception: mark test to not fail on logged exception" ) if config.getoption("verbose") > 0: - logging.basicConfig(level=logging.DEBUG) - else: - logging.basicConfig(level=logging.INFO) - logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO) + logging.getLogger().setLevel(logging.DEBUG) def pytest_runtest_setup() -> None: From a54e5237311b34294d254bfce431dbd37cbe1ef2 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 22 Feb 2023 00:41:46 +0100 Subject: [PATCH 0278/2810] Bump `nettigo-air-monitor` to version 2.1.0 (#88569) Co-authored-by: J. Nick Koston --- homeassistant/components/nam/__init__.py | 8 ++++---- homeassistant/components/nam/config_flow.py | 17 +++++++++++------ homeassistant/components/nam/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/nam/test_config_flow.py | 12 ++++++------ tests/components/nam/test_init.py | 4 ++-- 7 files changed, 26 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/nam/__init__.py b/homeassistant/components/nam/__init__.py index c035184c37..c011bdfa42 100644 --- a/homeassistant/components/nam/__init__.py +++ b/homeassistant/components/nam/__init__.py @@ -9,9 +9,9 @@ from aiohttp.client_exceptions import ClientConnectorError, ClientError import async_timeout from nettigo_air_monitor import ( ApiError, - AuthFailed, + AuthFailedError, ConnectionOptions, - InvalidSensorData, + InvalidSensorDataError, NAMSensors, NettigoAirMonitor, ) @@ -58,7 +58,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await nam.async_check_credentials() except ApiError as err: raise ConfigEntryNotReady from err - except AuthFailed as err: + except AuthFailedError as err: raise ConfigEntryAuthFailed from err coordinator = NAMDataUpdateCoordinator(hass, nam, entry.unique_id) @@ -116,7 +116,7 @@ class NAMDataUpdateCoordinator(DataUpdateCoordinator[NAMSensors]): data = await self.nam.async_update() # We do not need to catch AuthFailed exception here because sensor data is # always available without authorization. - except (ApiError, ClientConnectorError, InvalidSensorData) as error: + except (ApiError, ClientConnectorError, InvalidSensorDataError) as error: raise UpdateFailed(error) from error return data diff --git a/homeassistant/components/nam/config_flow.py b/homeassistant/components/nam/config_flow.py index 6cf9dd5545..eef4c33e5f 100644 --- a/homeassistant/components/nam/config_flow.py +++ b/homeassistant/components/nam/config_flow.py @@ -11,8 +11,8 @@ from aiohttp.client_exceptions import ClientConnectorError import async_timeout from nettigo_air_monitor import ( ApiError, - AuthFailed, - CannotGetMac, + AuthFailedError, + CannotGetMacError, ConnectionOptions, NettigoAirMonitor, ) @@ -95,7 +95,7 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): config = await async_get_config(self.hass, self.host) except (ApiError, ClientConnectorError, asyncio.TimeoutError): errors["base"] = "cannot_connect" - except CannotGetMac: + except CannotGetMacError: return self.async_abort(reason="device_unsupported") except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") @@ -127,7 +127,7 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: try: await async_check_credentials(self.hass, self.host, user_input) - except AuthFailed: + except AuthFailedError: errors["base"] = "invalid_auth" except (ApiError, ClientConnectorError, asyncio.TimeoutError): errors["base"] = "cannot_connect" @@ -158,7 +158,7 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._config = await async_get_config(self.hass, self.host) except (ApiError, ClientConnectorError, asyncio.TimeoutError): return self.async_abort(reason="cannot_connect") - except CannotGetMac: + except CannotGetMacError: return self.async_abort(reason="device_unsupported") await self.async_set_unique_id(format_mac(self._config.mac_address)) @@ -206,7 +206,12 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: try: await async_check_credentials(self.hass, self.host, user_input) - except (ApiError, AuthFailed, ClientConnectorError, asyncio.TimeoutError): + except ( + ApiError, + AuthFailedError, + ClientConnectorError, + asyncio.TimeoutError, + ): return self.async_abort(reason="reauth_unsuccessful") self.hass.config_entries.async_update_entry( diff --git a/homeassistant/components/nam/manifest.json b/homeassistant/components/nam/manifest.json index fe53f137ee..32f7329a0a 100644 --- a/homeassistant/components/nam/manifest.json +++ b/homeassistant/components/nam/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["nettigo_air_monitor"], "quality_scale": "platinum", - "requirements": ["nettigo-air-monitor==2.0.0"], + "requirements": ["nettigo-air-monitor==2.1.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 64efb65e1c..19ad3fcd42 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1183,7 +1183,7 @@ netdisco==3.0.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==2.0.0 +nettigo-air-monitor==2.1.0 # homeassistant.components.neurio_energy neurio==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9f55743de2..a327405373 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -879,7 +879,7 @@ netdisco==3.0.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==2.0.0 +nettigo-air-monitor==2.1.0 # homeassistant.components.nexia nexia==2.0.6 diff --git a/tests/components/nam/test_config_flow.py b/tests/components/nam/test_config_flow.py index 3fd2c614f6..9aafcae248 100644 --- a/tests/components/nam/test_config_flow.py +++ b/tests/components/nam/test_config_flow.py @@ -2,7 +2,7 @@ import asyncio from unittest.mock import patch -from nettigo_air_monitor import ApiError, AuthFailed, CannotGetMac +from nettigo_air_monitor import ApiError, AuthFailedError, CannotGetMacError import pytest from homeassistant import data_entry_flow @@ -169,7 +169,7 @@ async def test_reauth_unsuccessful(hass: HomeAssistant) -> None: "error", [ (ApiError("API Error"), "cannot_connect"), - (AuthFailed("Auth Error"), "invalid_auth"), + (AuthFailedError("Auth Error"), "invalid_auth"), (asyncio.TimeoutError, "cannot_connect"), (ValueError, "unknown"), ], @@ -179,7 +179,7 @@ async def test_form_with_auth_errors(hass: HomeAssistant, error) -> None: exc, base_error = error with patch( "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - side_effect=AuthFailed("Auth Error"), + side_effect=AuthFailedError("Auth Error"), ), patch( "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", return_value="aa:bb:cc:dd:ee:ff", @@ -236,7 +236,7 @@ async def test_form_abort(hass: HomeAssistant) -> None: return_value=DEVICE_CONFIG, ), patch( "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - side_effect=CannotGetMac("Cannot get MAC address from device"), + side_effect=CannotGetMacError("Cannot get MAC address from device"), ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -323,7 +323,7 @@ async def test_zeroconf_with_auth(hass: HomeAssistant) -> None: """Test that the zeroconf step with auth works.""" with patch( "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - side_effect=AuthFailed("Auth Error"), + side_effect=AuthFailedError("Auth Error"), ), patch( "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", return_value="aa:bb:cc:dd:ee:ff", @@ -388,7 +388,7 @@ async def test_zeroconf_host_already_configured(hass: HomeAssistant) -> None: "error", [ (ApiError("API Error"), "cannot_connect"), - (CannotGetMac("Cannot get MAC address from device"), "device_unsupported"), + (CannotGetMacError("Cannot get MAC address from device"), "device_unsupported"), ], ) async def test_zeroconf_errors(hass: HomeAssistant, error) -> None: diff --git a/tests/components/nam/test_init.py b/tests/components/nam/test_init.py index 23e8275df2..24aaee1acb 100644 --- a/tests/components/nam/test_init.py +++ b/tests/components/nam/test_init.py @@ -1,7 +1,7 @@ """Test init of Nettigo Air Monitor integration.""" from unittest.mock import patch -from nettigo_air_monitor import ApiError, AuthFailed +from nettigo_air_monitor import ApiError, AuthFailedError from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM from homeassistant.components.nam.const import DOMAIN @@ -73,7 +73,7 @@ async def test_config_auth_failed(hass: HomeAssistant) -> None: with patch( "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - side_effect=AuthFailed("Authorization has failed"), + side_effect=AuthFailedError("Authorization has failed"), ): await hass.config_entries.async_setup(entry.entry_id) assert entry.state is ConfigEntryState.SETUP_ERROR From cbba0fee423f64de2327a6fc213964ac8b45f89b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Feb 2023 20:11:16 -0600 Subject: [PATCH 0279/2810] Bump sqlalchemy to 2.0.4 (#88574) changelog: https://docs.sqlalchemy.org/en/20/changelog/changelog_20.html#change-2.0.4 --- 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 87c889b9c8..f40f866808 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.3", "fnvhash==0.1.0"] + "requirements": ["sqlalchemy==2.0.4", "fnvhash==0.1.0"] } diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 5b8ae58e31..e3efa81e44 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.3"] + "requirements": ["sqlalchemy==2.0.4"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d47b4e304d..e45a5cc177 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.1 scapy==2.5.0 -sqlalchemy==2.0.3 +sqlalchemy==2.0.4 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 19ad3fcd42..798abda576 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2395,7 +2395,7 @@ spotipy==2.22.1 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==2.0.3 +sqlalchemy==2.0.4 # homeassistant.components.srp_energy srpenergy==1.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a327405373..01469edb83 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1695,7 +1695,7 @@ spotipy==2.22.1 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==2.0.3 +sqlalchemy==2.0.4 # homeassistant.components.srp_energy srpenergy==1.3.6 From e54eb7e2c8018c9daa8d7ea0b5ed73aa6a9dc99a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Feb 2023 20:11:54 -0600 Subject: [PATCH 0280/2810] Switch shell_command to use async_timeout instead of asyncio.wait_for (#88573) This avoids creating a task every time --- homeassistant/components/shell_command/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shell_command/__init__.py b/homeassistant/components/shell_command/__init__.py index cade4eaff6..0cc979a321 100644 --- a/homeassistant/components/shell_command/__init__.py +++ b/homeassistant/components/shell_command/__init__.py @@ -6,6 +6,7 @@ from contextlib import suppress import logging import shlex +import async_timeout import voluptuous as vol from homeassistant.core import HomeAssistant, ServiceCall @@ -82,9 +83,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: process = await create_process try: - stdout_data, stderr_data = await asyncio.wait_for( - process.communicate(), COMMAND_TIMEOUT - ) + async with async_timeout.timeout(COMMAND_TIMEOUT): + stdout_data, stderr_data = await process.communicate() except asyncio.TimeoutError: _LOGGER.exception( "Timed out running command: `%s`, after: %ss", cmd, COMMAND_TIMEOUT From 5bc0636905b4524deef7219ba2d3537b6cd0b8fb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Feb 2023 20:16:18 -0600 Subject: [PATCH 0281/2810] Replace fire_coroutine_threadsafe with asyncio.run_coroutine_threadsafe (#88572) fire_coroutine_threadsafe did not hold a reference to the asyncio task which meant the task had the risk of being prematurely garbage collected --- homeassistant/core.py | 20 +++++++++++-------- homeassistant/util/async_.py | 27 ++------------------------ tests/util/test_async.py | 37 ------------------------------------ 3 files changed, 14 insertions(+), 70 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 097e1ed716..b4a86a4655 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -14,6 +14,7 @@ from collections.abc import ( Iterable, Mapping, ) +import concurrent.futures from contextlib import suppress from contextvars import ContextVar import datetime @@ -79,11 +80,7 @@ from .exceptions import ( ) from .helpers.aiohttp_compat import restore_original_aiohttp_cancel_behavior from .util import dt as dt_util, location, ulid as ulid_util -from .util.async_ import ( - fire_coroutine_threadsafe, - run_callback_threadsafe, - shutdown_run_callback_threadsafe, -) +from .util.async_ import run_callback_threadsafe, shutdown_run_callback_threadsafe from .util.read_only_dict import ReadOnlyDict from .util.timeout import TimeoutManager from .util.unit_system import ( @@ -294,6 +291,7 @@ class HomeAssistant: self._stopped: asyncio.Event | None = None # Timeout handler for Core/Helper namespace self.timeout: TimeoutManager = TimeoutManager() + self._stop_future: concurrent.futures.Future[None] | None = None @property def is_running(self) -> bool: @@ -312,12 +310,14 @@ class HomeAssistant: For regular use, use "await hass.run()". """ # Register the async start - fire_coroutine_threadsafe(self.async_start(), self.loop) - + _future = asyncio.run_coroutine_threadsafe(self.async_start(), self.loop) # Run forever # Block until stopped _LOGGER.info("Starting Home Assistant core loop") self.loop.run_forever() + # The future is never retrieved but we still hold a reference to it + # to prevent the task from being garbage collected prematurely. + del _future return self.exit_code async def async_run(self, *, attach_signals: bool = True) -> int: @@ -682,7 +682,11 @@ class HomeAssistant: """Stop Home Assistant and shuts down all threads.""" if self.state == CoreState.not_running: # just ignore return - fire_coroutine_threadsafe(self.async_stop(), self.loop) + # The future is never retrieved, and we only hold a reference + # to it to prevent it from being garbage collected. + self._stop_future = asyncio.run_coroutine_threadsafe( + self.async_stop(), self.loop + ) async def async_stop(self, exit_code: int = 0, *, force: bool = False) -> None: """Stop Home Assistant and shuts down all threads. diff --git a/homeassistant/util/async_.py b/homeassistant/util/async_.py index f5164da480..5b119a58c2 100644 --- a/homeassistant/util/async_.py +++ b/homeassistant/util/async_.py @@ -1,9 +1,9 @@ """Asyncio utilities.""" from __future__ import annotations -from asyncio import Semaphore, coroutines, ensure_future, gather, get_running_loop +from asyncio import Semaphore, gather, get_running_loop from asyncio.events import AbstractEventLoop -from collections.abc import Awaitable, Callable, Coroutine +from collections.abc import Awaitable, Callable import concurrent.futures import functools import logging @@ -20,29 +20,6 @@ _R = TypeVar("_R") _P = ParamSpec("_P") -def fire_coroutine_threadsafe( - coro: Coroutine[Any, Any, Any], loop: AbstractEventLoop -) -> None: - """Submit a coroutine object to a given event loop. - - This method does not provide a way to retrieve the result and - is intended for fire-and-forget use. This reduces the - work involved to fire the function on the loop. - """ - ident = loop.__dict__.get("_thread_ident") - if ident is not None and ident == threading.get_ident(): - raise RuntimeError("Cannot be called from within the event loop") - - if not coroutines.iscoroutine(coro): - raise TypeError(f"A coroutine object is required: {coro}") - - def callback() -> None: - """Handle the firing of a coroutine.""" - ensure_future(coro, loop=loop) - - loop.call_soon_threadsafe(callback) - - def run_callback_threadsafe( loop: AbstractEventLoop, callback: Callable[..., _T], *args: Any ) -> concurrent.futures.Future[_T]: diff --git a/tests/util/test_async.py b/tests/util/test_async.py index 48d9ee02f8..7b0cc916ec 100644 --- a/tests/util/test_async.py +++ b/tests/util/test_async.py @@ -10,43 +10,6 @@ from homeassistant.core import HomeAssistant from homeassistant.util import async_ as hasync -@patch("asyncio.coroutines.iscoroutine") -@patch("concurrent.futures.Future") -@patch("threading.get_ident") -def test_fire_coroutine_threadsafe_from_inside_event_loop( - mock_ident, _, mock_iscoroutine -) -> None: - """Testing calling fire_coroutine_threadsafe from inside an event loop.""" - coro = MagicMock() - loop = MagicMock() - - loop._thread_ident = None - mock_ident.return_value = 5 - mock_iscoroutine.return_value = True - hasync.fire_coroutine_threadsafe(coro, loop) - assert len(loop.call_soon_threadsafe.mock_calls) == 1 - - loop._thread_ident = 5 - mock_ident.return_value = 5 - mock_iscoroutine.return_value = True - with pytest.raises(RuntimeError): - hasync.fire_coroutine_threadsafe(coro, loop) - assert len(loop.call_soon_threadsafe.mock_calls) == 1 - - loop._thread_ident = 1 - mock_ident.return_value = 5 - mock_iscoroutine.return_value = False - with pytest.raises(TypeError): - hasync.fire_coroutine_threadsafe(coro, loop) - assert len(loop.call_soon_threadsafe.mock_calls) == 1 - - loop._thread_ident = 1 - mock_ident.return_value = 5 - mock_iscoroutine.return_value = True - hasync.fire_coroutine_threadsafe(coro, loop) - assert len(loop.call_soon_threadsafe.mock_calls) == 2 - - @patch("concurrent.futures.Future") @patch("threading.get_ident") def test_run_callback_threadsafe_from_inside_event_loop(mock_ident, _) -> None: From e38836b6e1b9beeaf11a16c4274546c2701dd1a4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Feb 2023 20:17:18 -0600 Subject: [PATCH 0282/2810] Update async_matching_config_entries to use async_get_integrations (#88527) * Update async_matching_config_entries to use async_get_integrations * Update homeassistant/components/config/config_entries.py * Update homeassistant/components/config/config_entries.py --- homeassistant/components/config/config_entries.py | 14 ++++++-------- tests/components/config/test_config_entries.py | 12 ++++++------ 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 39c5bce25c..88715ab876 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -1,7 +1,6 @@ """Http views to control the config manager.""" from __future__ import annotations -import asyncio from http import HTTPStatus from typing import Any @@ -26,6 +25,7 @@ from homeassistant.loader import ( IntegrationNotFound, async_get_config_flows, async_get_integration, + async_get_integrations, ) @@ -493,14 +493,12 @@ async def async_matching_config_entries( integrations = {} # Fetch all the integrations so we can check their type - tasks = ( - async_get_integration(hass, domain) - for domain in {entry.domain for entry in entries} - ) - results = await asyncio.gather(*tasks, return_exceptions=True) - for integration_or_exc in results: + domains = {entry.domain for entry in entries} + for domain_key, integration_or_exc in ( + await async_get_integrations(hass, domains) + ).items(): if isinstance(integration_or_exc, Integration): - integrations[integration_or_exc.domain] = integration_or_exc + integrations[domain_key] = integration_or_exc elif not isinstance(integration_or_exc, IntegrationNotFound): raise integration_or_exc diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 396962900b..cf8df6aef6 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -1446,8 +1446,8 @@ async def test_get_entries_ws( # Verify we skip broken integrations with patch( - "homeassistant.components.config.config_entries.async_get_integration", - side_effect=IntegrationNotFound("any"), + "homeassistant.components.config.config_entries.async_get_integrations", + return_value={"any": IntegrationNotFound("any")}, ): await ws_client.send_json( { @@ -1534,8 +1534,8 @@ async def test_get_entries_ws( # Verify we don't send config entries when only helpers are requested with patch( - "homeassistant.components.config.config_entries.async_get_integration", - side_effect=IntegrationNotFound("any"), + "homeassistant.components.config.config_entries.async_get_integrations", + return_value={"any": IntegrationNotFound("any")}, ): await ws_client.send_json( { @@ -1552,8 +1552,8 @@ async def test_get_entries_ws( # Verify we raise if something really goes wrong with patch( - "homeassistant.components.config.config_entries.async_get_integration", - side_effect=Exception, + "homeassistant.components.config.config_entries.async_get_integrations", + return_value={"any": Exception()}, ): await ws_client.send_json( { From 8806c3dd20312a93efd076d50db118134269d48a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Feb 2023 20:18:33 -0600 Subject: [PATCH 0283/2810] Speed up loading non-english language translations (#88553) Speed up loading non-english languages We called async_get_integrations in each gathered task instead of once for both languages we were loading --- homeassistant/helpers/translation.py | 30 +++++++++++++++------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 0c59035fc7..7ccbea3653 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -143,25 +143,18 @@ def _build_resources( } -async def async_get_component_strings( - hass: HomeAssistant, language: str, components: set[str] +async def _async_get_component_strings( + hass: HomeAssistant, + language: str, + components: set[str], + integrations: dict[str, Integration], ) -> dict[str, Any]: """Load translations.""" - domains = list({loaded.rpartition(".")[-1] for loaded in components}) - - integrations: dict[str, Integration] = {} - ints_or_excs = await async_get_integrations(hass, domains) - for domain, int_or_exc in ints_or_excs.items(): - if isinstance(int_or_exc, Exception): - raise int_or_exc - integrations[domain] = int_or_exc translations: dict[str, Any] = {} - # Determine paths of missing components/platforms files_to_load = {} for loaded in components: - parts = loaded.split(".") - domain = parts[-1] + domain = loaded.rpartition(".")[-1] integration = integrations[domain] path = component_translation_path(loaded, language, integration) @@ -228,9 +221,18 @@ class _TranslationCache: ) # Fetch the English resources, as a fallback for missing keys languages = [LOCALE_EN] if language == LOCALE_EN else [LOCALE_EN, language] + + integrations: dict[str, Integration] = {} + domains = list({loaded.rpartition(".")[-1] for loaded in components}) + ints_or_excs = await async_get_integrations(self.hass, domains) + for domain, int_or_exc in ints_or_excs.items(): + if isinstance(int_or_exc, Exception): + raise int_or_exc + integrations[domain] = int_or_exc + for translation_strings in await asyncio.gather( *( - async_get_component_strings(self.hass, lang, components) + _async_get_component_strings(self.hass, lang, components, integrations) for lang in languages ) ): From 906d3977363be1c46933e0ddb6dbb290effe229f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Feb 2023 02:25:47 -0600 Subject: [PATCH 0284/2810] Update homekit to use async_get_integrations (#88550) --- .../components/homekit/config_flow.py | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index e1d107065a..dddce5eae3 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -1,7 +1,6 @@ """Config flow for HomeKit integration.""" from __future__ import annotations -import asyncio from collections.abc import Iterable from copy import deepcopy import random @@ -37,7 +36,7 @@ from homeassistant.helpers.entityfilter import ( CONF_INCLUDE_DOMAINS, CONF_INCLUDE_ENTITIES, ) -from homeassistant.loader import async_get_integration +from homeassistant.loader import async_get_integrations from .const import ( CONF_ENTITY_CONFIG, @@ -163,17 +162,14 @@ def _async_cameras_from_entities(entities: list[str]) -> dict[str, str]: async def _async_name_to_type_map(hass: HomeAssistant) -> dict[str, str]: """Create a mapping of types of devices/entities HomeKit can support.""" - integrations = await asyncio.gather( - *(async_get_integration(hass, domain) for domain in SUPPORTED_DOMAINS), - return_exceptions=True, - ) - name_to_type_map = { - domain: domain - if isinstance(integrations[idx], Exception) - else integrations[idx].name - for idx, domain in enumerate(SUPPORTED_DOMAINS) + integrations = await async_get_integrations(hass, SUPPORTED_DOMAINS) + return { + domain: integration_or_exception.name + if (integration_or_exception := integrations[domain]) + and not isinstance(integration_or_exception, Exception) + else domain + for domain in SUPPORTED_DOMAINS } - return name_to_type_map class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): From aa20c902db302b250bd7ae884bed870b8774c39e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 22 Feb 2023 10:09:28 +0100 Subject: [PATCH 0285/2810] Add typed helpers and improve type hints in util/json (#88534) * Add type hints to load_json * Adjust ios * Adjust nest * Add use of load_json_array * Add tests * Adjust test patch * Add test_load_json_os_error --- homeassistant/components/ios/__init__.py | 16 ++-- homeassistant/components/nest/config_flow.py | 6 +- .../components/shopping_list/__init__.py | 14 ++-- homeassistant/util/json.py | 48 +++++++++++- tests/components/ios/test_init.py | 2 +- .../nest/test_config_flow_legacy.py | 2 +- tests/util/test_json.py | 76 ++++++++++++++++++- 7 files changed, 137 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/ios/__init__.py b/homeassistant/components/ios/__init__.py index e28c1a218e..052ed9f94a 100644 --- a/homeassistant/components/ios/__init__.py +++ b/homeassistant/components/ios/__init__.py @@ -1,7 +1,6 @@ """Native Home Assistant iOS app component.""" import datetime from http import HTTPStatus -from typing import TYPE_CHECKING import voluptuous as vol @@ -14,7 +13,7 @@ from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType -from homeassistant.util.json import load_json +from homeassistant.util.json import load_json_object from .const import ( CONF_ACTION_BACKGROUND_COLOR, @@ -252,22 +251,19 @@ def device_name_for_push_id(hass, push_id): async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the iOS component.""" - conf = config.get(DOMAIN) + conf: ConfigType | None = config.get(DOMAIN) ios_config = await hass.async_add_executor_job( - load_json, hass.config.path(CONFIGURATION_FILE) + load_json_object, hass.config.path(CONFIGURATION_FILE) ) - if TYPE_CHECKING: - assert isinstance(ios_config, dict) - if ios_config == {}: ios_config[ATTR_DEVICES] = {} - ios_config[CONF_USER] = conf or {} + if CONF_PUSH not in (conf_user := conf or {}): + conf_user[CONF_PUSH] = {} - if CONF_PUSH not in ios_config[CONF_USER]: - ios_config[CONF_USER][CONF_PUSH] = {} + ios_config[CONF_USER] = conf_user hass.data[DOMAIN] = ios_config diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py index df6bb9b333..a4642c38e8 100644 --- a/homeassistant/components/nest/config_flow.py +++ b/homeassistant/components/nest/config_flow.py @@ -33,7 +33,7 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.util import get_random_string -from homeassistant.util.json import load_json +from homeassistant.util.json import JsonObjectType, load_json_object from . import api from .const import ( @@ -570,7 +570,7 @@ class NestFlowHandler( return await self.async_step_link() flow = self.hass.data[DATA_FLOW_IMPL][DOMAIN] - tokens = await self.hass.async_add_executor_job(load_json, config_path) + tokens = await self.hass.async_add_executor_job(load_json_object, config_path) return self._entry_from_tokens( "Nest (import from configuration.yaml)", flow, tokens @@ -578,7 +578,7 @@ class NestFlowHandler( @callback def _entry_from_tokens( - self, title: str, flow: dict[str, Any], tokens: list[Any] | dict[Any, Any] + self, title: str, flow: dict[str, Any], tokens: JsonObjectType ) -> FlowResult: """Create an entry from tokens.""" return self.async_create_entry( diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index 5bbb211891..bd08e19c4e 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType -from homeassistant.util.json import load_json +from homeassistant.util.json import JsonArrayType, load_json_array from .const import ( DOMAIN, @@ -174,10 +174,10 @@ class NoMatchingShoppingListItem(Exception): class ShoppingData: """Class to hold shopping list data.""" - def __init__(self, hass): + def __init__(self, hass: HomeAssistant) -> None: """Initialize the shopping list.""" self.hass = hass - self.items = [] + self.items: JsonArrayType = [] async def async_add(self, name, context=None): """Add a shopping list item.""" @@ -277,16 +277,16 @@ class ShoppingData: context=context, ) - async def async_load(self): + async def async_load(self) -> None: """Load items.""" - def load(): + def load() -> JsonArrayType: """Load the items synchronously.""" - return load_json(self.hass.config.path(PERSISTENCE), default=[]) + return load_json_array(self.hass.config.path(PERSISTENCE)) self.items = await self.hass.async_add_executor_job(load) - def save(self): + def save(self) -> None: """Save the items.""" save_json(self.hass.config.path(PERSISTENCE), self.items) diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index bd75852a2a..724c3ebf8d 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable import json import logging +from os import PathLike from typing import Any import orjson @@ -12,6 +13,7 @@ from homeassistant.exceptions import HomeAssistantError from .file import WriteError # pylint: disable=unused-import # noqa: F401 +_SENTINEL = object() _LOGGER = logging.getLogger(__name__) JsonValueType = ( @@ -54,8 +56,10 @@ def json_loads_object(__obj: bytes | bytearray | memoryview | str) -> JsonObject raise ValueError(f"Expected JSON to be parsed as a dict got {type(value)}") -def load_json(filename: str, default: list | dict | None = None) -> list | dict: - """Load JSON data from a file and return as dict or list. +def load_json( + filename: str | PathLike, default: JsonValueType = _SENTINEL # type: ignore[assignment] +) -> JsonValueType: + """Load JSON data from a file. Defaults to returning empty dict if file is not found. """ @@ -71,7 +75,45 @@ def load_json(filename: str, default: list | dict | None = None) -> list | dict: except OSError as error: _LOGGER.exception("JSON file reading failed: %s", filename) raise HomeAssistantError(error) from error - return {} if default is None else default + return {} if default is _SENTINEL else default + + +def load_json_array( + filename: str | PathLike, default: JsonArrayType = _SENTINEL # type: ignore[assignment] +) -> JsonArrayType: + """Load JSON data from a file and return as list. + + Defaults to returning empty list if file is not found. + """ + if default is _SENTINEL: + default = [] + value: JsonValueType = load_json(filename, default=default) + # Avoid isinstance overhead as we are not interested in list subclasses + if type(value) is list: # pylint: disable=unidiomatic-typecheck + return value + _LOGGER.exception( + "Expected JSON to be parsed as a list got %s in: %s", {type(value)}, filename + ) + raise HomeAssistantError(f"Expected JSON to be parsed as a list got {type(value)}") + + +def load_json_object( + filename: str | PathLike, default: JsonObjectType = _SENTINEL # type: ignore[assignment] +) -> JsonObjectType: + """Load JSON data from a file and return as dict. + + Defaults to returning empty dict if file is not found. + """ + if default is _SENTINEL: + default = {} + value: JsonValueType = load_json(filename, default=default) + # Avoid isinstance overhead as we are not interested in dict subclasses + if type(value) is dict: # pylint: disable=unidiomatic-typecheck + return value + _LOGGER.exception( + "Expected JSON to be parsed as a dict got %s in: %s", {type(value)}, filename + ) + raise HomeAssistantError(f"Expected JSON to be parsed as a dict got {type(value)}") def save_json( diff --git a/tests/components/ios/test_init.py b/tests/components/ios/test_init.py index f47cb16f1f..67c8bbde2c 100644 --- a/tests/components/ios/test_init.py +++ b/tests/components/ios/test_init.py @@ -13,7 +13,7 @@ from tests.common import mock_component, mock_coro @pytest.fixture(autouse=True) def mock_load_json(): """Mock load_json.""" - with patch("homeassistant.components.ios.load_json", return_value={}): + with patch("homeassistant.components.ios.load_json_object", return_value={}): yield diff --git a/tests/components/nest/test_config_flow_legacy.py b/tests/components/nest/test_config_flow_legacy.py index df2575c130..897961d9f9 100644 --- a/tests/components/nest/test_config_flow_legacy.py +++ b/tests/components/nest/test_config_flow_legacy.py @@ -228,7 +228,7 @@ async def test_step_import(hass: HomeAssistant) -> None: async def test_step_import_with_token_cache(hass: HomeAssistant) -> None: """Test that we import existing token cache.""" with patch("os.path.isfile", return_value=True), patch( - "homeassistant.components.nest.config_flow.load_json", + "homeassistant.components.nest.config_flow.load_json_object", return_value={"access_token": "yo"}, ), patch( "homeassistant.components.nest.async_setup_legacy_entry", return_value=True diff --git a/tests/util/test_json.py b/tests/util/test_json.py index c845e8c49a..b3bccf71b5 100644 --- a/tests/util/test_json.py +++ b/tests/util/test_json.py @@ -4,7 +4,13 @@ from pathlib import Path import pytest from homeassistant.exceptions import HomeAssistantError -from homeassistant.util.json import json_loads_array, json_loads_object, load_json +from homeassistant.util.json import ( + json_loads_array, + json_loads_object, + load_json, + load_json_array, + load_json_object, +) # Test data that can be saved as JSON TEST_JSON_A = {"a": 1, "B": "two"} @@ -17,8 +23,74 @@ def test_load_bad_data(tmp_path: Path) -> None: fname = tmp_path / "test5.json" with open(fname, "w") as fh: fh.write(TEST_BAD_SERIALIED) - with pytest.raises(HomeAssistantError): + with pytest.raises(HomeAssistantError) as err: load_json(fname) + assert isinstance(err.value.__cause__, ValueError) + + +def test_load_json_os_error() -> None: + """Test trying to load JSON data from a directory.""" + fname = "/" + with pytest.raises(HomeAssistantError) as err: + load_json(fname) + assert isinstance(err.value.__cause__, OSError) + + +def test_load_json_file_not_found_error() -> None: + """Test trying to load object data from inexistent JSON file.""" + fname = "invalid_file.json" + + assert load_json(fname) == {} + assert load_json(fname, default="") == "" + assert load_json_object(fname) == {} + assert load_json_object(fname, default={"Hi": "Peter"}) == {"Hi": "Peter"} + assert load_json_array(fname) == [] + assert load_json_array(fname, default=["Hi"]) == ["Hi"] + + +def test_load_json_value_data(tmp_path: Path) -> None: + """Test trying to load object data from JSON file.""" + fname = tmp_path / "test5.json" + with open(fname, "w", encoding="utf8") as handle: + handle.write('"two"') + + assert load_json(fname) == "two" + with pytest.raises( + HomeAssistantError, match="Expected JSON to be parsed as a dict" + ): + load_json_object(fname) + with pytest.raises( + HomeAssistantError, match="Expected JSON to be parsed as a list" + ): + load_json_array(fname) + + +def test_load_json_object_data(tmp_path: Path) -> None: + """Test trying to load object data from JSON file.""" + fname = tmp_path / "test5.json" + with open(fname, "w", encoding="utf8") as handle: + handle.write('{"a": 1, "B": "two"}') + + assert load_json(fname) == {"a": 1, "B": "two"} + assert load_json_object(fname) == {"a": 1, "B": "two"} + with pytest.raises( + HomeAssistantError, match="Expected JSON to be parsed as a list" + ): + load_json_array(fname) + + +def test_load_json_array_data(tmp_path: Path) -> None: + """Test trying to load array data from JSON file.""" + fname = tmp_path / "test5.json" + with open(fname, "w", encoding="utf8") as handle: + handle.write('[{"a": 1, "B": "two"}]') + + assert load_json(fname) == [{"a": 1, "B": "two"}] + assert load_json_array(fname) == [{"a": 1, "B": "two"}] + with pytest.raises( + HomeAssistantError, match="Expected JSON to be parsed as a dict" + ): + load_json_object(fname) def test_json_loads_array() -> None: From 93c681ae58e3da4ba9929ee05fe755a1bd84f337 Mon Sep 17 00:00:00 2001 From: Chris Xiao <30990835+chrisx8@users.noreply.github.com> Date: Wed, 22 Feb 2023 04:18:58 -0500 Subject: [PATCH 0286/2810] Add options flow for met (#88525) * implement options flow for met * add tests for met options flow * fix met options flow tests * fix met options flow when tracking home --- homeassistant/components/met/__init__.py | 7 ++ homeassistant/components/met/config_flow.py | 98 +++++++++++++++------ homeassistant/components/met/strings.json | 13 +++ tests/components/met/test_config_flow.py | 45 +++++++++- 4 files changed, 134 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/met/__init__.py b/homeassistant/components/met/__init__.py index 2ccd456936..c95c3abe05 100644 --- a/homeassistant/components/met/__init__.py +++ b/homeassistant/components/met/__init__.py @@ -68,6 +68,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][config_entry.entry_id] = coordinator + config_entry.async_on_unload(config_entry.add_update_listener(async_update_entry)) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True @@ -85,6 +87,11 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return unload_ok +async def async_update_entry(hass: HomeAssistant, config_entry: ConfigEntry): + """Reload Met component when options changed.""" + await hass.config_entries.async_reload(config_entry.entry_id) + + class CannotConnect(HomeAssistantError): """Unable to connect to the web site.""" diff --git a/homeassistant/components/met/config_flow.py b/homeassistant/components/met/config_flow.py index baf7269a81..453c0a9cee 100644 --- a/homeassistant/components/met/config_flow.py +++ b/homeassistant/components/met/config_flow.py @@ -34,13 +34,46 @@ def configured_instances(hass: HomeAssistant) -> set[str]: return set(entries) -class MetFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): +def _get_data_schema( + hass: HomeAssistant, config_entry: config_entries.ConfigEntry | None = None +) -> vol.Schema: + """Get a schema with default values.""" + # If tracking home or no config entry is passed in, default value come from Home location + if config_entry is None or config_entry.data.get(CONF_TRACK_HOME, False): + return vol.Schema( + { + vol.Required(CONF_NAME, default=HOME_LOCATION_NAME): str, + vol.Required(CONF_LATITUDE, default=hass.config.latitude): cv.latitude, + vol.Required( + CONF_LONGITUDE, default=hass.config.longitude + ): cv.longitude, + vol.Required(CONF_ELEVATION, default=hass.config.elevation): int, + } + ) + # Not tracking home, default values come from config entry + return vol.Schema( + { + vol.Required(CONF_NAME, default=config_entry.data.get(CONF_NAME)): str, + vol.Required( + CONF_LATITUDE, default=config_entry.data.get(CONF_LATITUDE) + ): cv.latitude, + vol.Required( + CONF_LONGITUDE, default=config_entry.data.get(CONF_LONGITUDE) + ): cv.longitude, + vol.Required( + CONF_ELEVATION, default=config_entry.data.get(CONF_ELEVATION) + ): int, + } + ) + + +class MetConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for Met component.""" VERSION = 1 def __init__(self) -> None: - """Init MetFlowHandler.""" + """Init MetConfigFlowHandler.""" self._errors: dict[str, Any] = {} async def async_step_user( @@ -59,31 +92,9 @@ class MetFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) self._errors[CONF_NAME] = "already_configured" - return await self._show_config_form( - name=HOME_LOCATION_NAME, - latitude=self.hass.config.latitude, - longitude=self.hass.config.longitude, - elevation=self.hass.config.elevation, - ) - - async def _show_config_form( - self, - name: str | None = None, - latitude: float | None = None, - longitude: float | None = None, - elevation: int | None = None, - ) -> FlowResult: - """Show the configuration form to edit location data.""" return self.async_show_form( step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_NAME, default=name): str, - vol.Required(CONF_LATITUDE, default=latitude): cv.latitude, - vol.Required(CONF_LONGITUDE, default=longitude): cv.longitude, - vol.Required(CONF_ELEVATION, default=elevation): int, - } - ), + data_schema=_get_data_schema(self.hass), errors=self._errors, ) @@ -102,3 +113,40 @@ class MetFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry( title=HOME_LOCATION_NAME, data={CONF_TRACK_HOME: True} ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + """Get the options flow for Met.""" + return MetOptionsFlowHandler(config_entry) + + +class MetOptionsFlowHandler(config_entries.OptionsFlow): + """Options flow for Met component.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize the Met OptionsFlow.""" + self._config_entry = config_entry + self._errors: dict[str, Any] = {} + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Configure options for Met.""" + + if user_input is not None: + # Update config entry with data from user input + self.hass.config_entries.async_update_entry( + self._config_entry, data=user_input + ) + return self.async_create_entry( + title=self._config_entry.title, data=user_input + ) + + return self.async_show_form( + step_id="init", + data_schema=_get_data_schema(self.hass, config_entry=self._config_entry), + errors=self._errors, + ) diff --git a/homeassistant/components/met/strings.json b/homeassistant/components/met/strings.json index b9d251e21d..4fa9c58e4b 100644 --- a/homeassistant/components/met/strings.json +++ b/homeassistant/components/met/strings.json @@ -18,5 +18,18 @@ "abort": { "no_home": "No home coordinates are set in the Home Assistant configuration" } + }, + "options": { + "step": { + "init": { + "title": "[%key:common::config_flow::data::location%]", + "data": { + "name": "[%key:common::config_flow::data::name%]", + "latitude": "[%key:common::config_flow::data::latitude%]", + "longitude": "[%key:common::config_flow::data::longitude%]", + "elevation": "[%key:common::config_flow::data::elevation%]" + } + } + } } } diff --git a/tests/components/met/test_config_flow.py b/tests/components/met/test_config_flow.py index 102a9a8b4e..59ffff14f1 100644 --- a/tests/components/met/test_config_flow.py +++ b/tests/components/met/test_config_flow.py @@ -1,22 +1,27 @@ """Tests for Met.no config flow.""" -from unittest.mock import patch +from unittest.mock import ANY, patch import pytest from homeassistant import config_entries from homeassistant.components.met.const import DOMAIN, HOME_LOCATION_NAME from homeassistant.config import async_process_ha_core_config -from homeassistant.const import CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.const import CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant +from . import init_integration + from tests.common import MockConfigEntry @pytest.fixture(name="met_setup", autouse=True) -def met_setup_fixture(): +def met_setup_fixture(request): """Patch met setup entry.""" - with patch("homeassistant.components.met.async_setup_entry", return_value=True): + if "disable_autouse_fixture" in request.keywords: yield + else: + with patch("homeassistant.components.met.async_setup_entry", return_value=True): + yield async def test_show_config_form(hass: HomeAssistant) -> None: @@ -130,3 +135,35 @@ async def test_onboarding_step_abort_no_home( assert result["type"] == "abort" assert result["reason"] == "no_home" + + +@pytest.mark.disable_autouse_fixture +async def test_options_flow(hass: HomeAssistant) -> None: + """Test show options form.""" + update_data = { + CONF_NAME: "test", + CONF_LATITUDE: 12, + CONF_LONGITUDE: 23, + CONF_ELEVATION: 456, + } + + entry = await init_integration(hass) + await hass.async_block_till_done() + + # Test show Options form + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == "form" + assert result["step_id"] == "init" + + # Test Options flow updated config entry + with patch("homeassistant.components.met.metno.MetWeatherData") as weatherdatamock: + result = await hass.config_entries.options.async_init( + entry.entry_id, data=update_data + ) + await hass.async_block_till_done() + assert result["type"] == "create_entry" + assert result["title"] == "Mock Title" + assert result["data"] == update_data + weatherdatamock.assert_called_with( + {"lat": "12", "lon": "23", "msl": "456"}, ANY, api_url=ANY + ) From 33b16d20b1258cc63ee940626f29112c8c8c0a60 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 22 Feb 2023 10:53:48 +0100 Subject: [PATCH 0287/2810] Clean up tradfri import flow (#88588) --- .../components/tradfri/config_flow.py | 24 ------------------- tests/components/tradfri/test_config_flow.py | 14 ----------- 2 files changed, 38 deletions(-) diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py index 20dc2ed951..1e9b63bb32 100644 --- a/homeassistant/components/tradfri/config_flow.py +++ b/homeassistant/components/tradfri/config_flow.py @@ -108,30 +108,6 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._host = host return await self.async_step_auth() - async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: - """Import a config entry.""" - self._async_abort_entries_match({CONF_HOST: user_input["host"]}) - - # Happens if user has host directly in configuration.yaml - if "key" not in user_input: - self._host = user_input["host"] - return await self.async_step_auth() - - try: - data = await get_gateway_info( - self.hass, - user_input["host"], - # Old config format had a fixed identity - user_input.get("identity", "homeassistant"), - user_input["key"], - ) - - return await self._entry_from_data(data) - except AuthError: - # If we fail to connect, just pass it on to discovery - self._host = user_input["host"] - return await self.async_step_auth() - async def _entry_from_data(self, data: dict[str, Any]) -> FlowResult: """Create an entry from data.""" host = data[CONF_HOST] diff --git a/tests/components/tradfri/test_config_flow.py b/tests/components/tradfri/test_config_flow.py index 15ca17e003..9eff733582 100644 --- a/tests/components/tradfri/test_config_flow.py +++ b/tests/components/tradfri/test_config_flow.py @@ -164,20 +164,6 @@ async def test_discovery_duplicate_aborted(hass: HomeAssistant) -> None: assert entry.data["host"] == "new-host" -async def test_import_duplicate_aborted(hass: HomeAssistant) -> None: - """Test a duplicate import host is ignored.""" - MockConfigEntry(domain="tradfri", data={"host": "some-host"}).add_to_hass(hass) - - flow = await hass.config_entries.flow.async_init( - "tradfri", - context={"source": config_entries.SOURCE_IMPORT}, - data={"host": "some-host"}, - ) - - assert flow["type"] == data_entry_flow.FlowResultType.ABORT - assert flow["reason"] == "already_configured" - - async def test_duplicate_discovery( hass: HomeAssistant, mock_auth, mock_entry_setup ) -> None: From 6d9411b8a19f9a5f049329c9288c065eab42700e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 22 Feb 2023 11:59:53 +0100 Subject: [PATCH 0288/2810] Improve trigger platform typing (#88511) * Improve trigger platform typing * Tweak docstring * Revert "Tweak docstring" This reverts commit c31f790fc3c1e66a5d802f759f07dfe4049cf529. * Tweak docstring --- .../components/device_automation/trigger.py | 27 +++++----------- homeassistant/helpers/trigger.py | 32 +++++++++++++++---- 2 files changed, 33 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/device_automation/trigger.py b/homeassistant/components/device_automation/trigger.py index 80e96ddaba..6bbbd6febc 100644 --- a/homeassistant/components/device_automation/trigger.py +++ b/homeassistant/components/device_automation/trigger.py @@ -7,7 +7,11 @@ import voluptuous as vol from homeassistant.const import CONF_DOMAIN from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.trigger import ( + TriggerActionType, + TriggerInfo, + TriggerProtocol, +) from homeassistant.helpers.typing import ConfigType from . import ( @@ -20,28 +24,13 @@ from .helpers import async_validate_device_automation_config TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA) -class DeviceAutomationTriggerProtocol(Protocol): +class DeviceAutomationTriggerProtocol(TriggerProtocol, Protocol): """Define the format of device_trigger modules. - Each module must define either TRIGGER_SCHEMA or async_validate_trigger_config. + Each module must define either TRIGGER_SCHEMA or async_validate_trigger_config + from TriggerProtocol. """ - TRIGGER_SCHEMA: vol.Schema - - async def async_validate_trigger_config( - self, hass: HomeAssistant, config: ConfigType - ) -> ConfigType: - """Validate config.""" - - async def async_attach_trigger( - self, - hass: HomeAssistant, - config: ConfigType, - action: TriggerActionType, - trigger_info: TriggerInfo, - ) -> CALLBACK_TYPE: - """Attach a trigger.""" - async def async_get_trigger_capabilities( self, hass: HomeAssistant, config: ConfigType ) -> dict[str, vol.Schema]: diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 52c5ab0e15..314c0e8939 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -7,7 +7,7 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass, field import functools import logging -from typing import TYPE_CHECKING, Any, Protocol, TypedDict, cast +from typing import Any, Protocol, TypedDict, cast import voluptuous as vol @@ -31,11 +31,6 @@ from homeassistant.loader import IntegrationNotFound, async_get_integration from .typing import ConfigType, TemplateVarsType -if TYPE_CHECKING: - from homeassistant.components.device_automation.trigger import ( - DeviceAutomationTriggerProtocol, - ) - _PLATFORM_ALIASES = { "device": "device_automation", "event": "homeassistant", @@ -48,6 +43,29 @@ _PLATFORM_ALIASES = { DATA_PLUGGABLE_ACTIONS = "pluggable_actions" +class TriggerProtocol(Protocol): + """Define the format of trigger modules. + + Each module must define either TRIGGER_SCHEMA or async_validate_trigger_config. + """ + + TRIGGER_SCHEMA: vol.Schema + + async def async_validate_trigger_config( + self, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + + async def async_attach_trigger( + self, + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, + ) -> CALLBACK_TYPE: + """Attach a trigger.""" + + class TriggerActionType(Protocol): """Protocol type for trigger action callback.""" @@ -195,7 +213,7 @@ class PluggableAction: async def _async_get_trigger_platform( hass: HomeAssistant, config: ConfigType -) -> DeviceAutomationTriggerProtocol: +) -> TriggerProtocol: platform_and_sub_type = config[CONF_PLATFORM].split(".") platform = platform_and_sub_type[0] platform = _PLATFORM_ALIASES.get(platform, platform) From 3c1e62aeefd05f969274c34840dfb801d619bc63 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 22 Feb 2023 13:39:28 +0100 Subject: [PATCH 0289/2810] Improve type hint in zwave_js trigger (#88597) Imrpove type hint in zwave_js trigger --- homeassistant/components/zwave_js/trigger.py | 26 ++++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/zwave_js/trigger.py b/homeassistant/components/zwave_js/trigger.py index d1751dc4f4..f747c25c71 100644 --- a/homeassistant/components/zwave_js/trigger.py +++ b/homeassistant/components/zwave_js/trigger.py @@ -1,12 +1,15 @@ """Z-Wave JS trigger dispatcher.""" from __future__ import annotations -from types import ModuleType from typing import cast from homeassistant.const import CONF_PLATFORM from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.trigger import ( + TriggerActionType, + TriggerInfo, + TriggerProtocol, +) from homeassistant.helpers.typing import ConfigType from .triggers import event, value_updated @@ -17,7 +20,7 @@ TRIGGERS = { } -def _get_trigger_platform(config: ConfigType) -> ModuleType: +def _get_trigger_platform(config: ConfigType) -> TriggerProtocol: """Return trigger platform.""" platform_split = config[CONF_PLATFORM].split(".", maxsplit=1) if len(platform_split) < 2 or platform_split[1] not in TRIGGERS: @@ -31,12 +34,9 @@ async def async_validate_trigger_config( """Validate config.""" platform = _get_trigger_platform(config) if hasattr(platform, "async_validate_trigger_config"): - return cast( - ConfigType, - await getattr(platform, "async_validate_trigger_config")(hass, config), - ) - assert hasattr(platform, "TRIGGER_SCHEMA") - return cast(ConfigType, getattr(platform, "TRIGGER_SCHEMA")(config)) + return await platform.async_validate_trigger_config(hass, config) + + return cast(ConfigType, platform.TRIGGER_SCHEMA(config)) async def async_attach_trigger( @@ -47,10 +47,4 @@ async def async_attach_trigger( ) -> CALLBACK_TYPE: """Attach trigger of specified platform.""" platform = _get_trigger_platform(config) - assert hasattr(platform, "async_attach_trigger") - return cast( - CALLBACK_TYPE, - await getattr(platform, "async_attach_trigger")( - hass, config, action, trigger_info - ), - ) + return await platform.async_attach_trigger(hass, config, action, trigger_info) From 79adfbc862aed0bd1d7e173b32e011aff7253829 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 22 Feb 2023 13:53:00 +0100 Subject: [PATCH 0290/2810] Use load_json_object in tellduslive (#88591) --- homeassistant/components/tellduslive/config_flow.py | 4 ++-- tests/components/tellduslive/test_config_flow.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tellduslive/config_flow.py b/homeassistant/components/tellduslive/config_flow.py index a181bce11f..c87b3998a2 100644 --- a/homeassistant/components/tellduslive/config_flow.py +++ b/homeassistant/components/tellduslive/config_flow.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_HOST -from homeassistant.util.json import load_json +from homeassistant.util.json import load_json_object from .const import ( APPLICATION_NAME, @@ -137,7 +137,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_user() conf = await self.hass.async_add_executor_job( - load_json, self.hass.config.path(TELLDUS_CONFIG_FILE) + load_json_object, self.hass.config.path(TELLDUS_CONFIG_FILE) ) host = next(iter(conf)) diff --git a/tests/components/tellduslive/test_config_flow.py b/tests/components/tellduslive/test_config_flow.py index bffcb60b6d..0eaadae493 100644 --- a/tests/components/tellduslive/test_config_flow.py +++ b/tests/components/tellduslive/test_config_flow.py @@ -141,7 +141,7 @@ async def test_step_import_load_json_matching_host( flow = init_config_flow(hass) with patch( - "homeassistant.components.tellduslive.config_flow.load_json", + "homeassistant.components.tellduslive.config_flow.load_json_object", return_value={"tellduslive": {}}, ), patch("os.path.isfile"): result = await flow.async_step_import( @@ -156,7 +156,7 @@ async def test_step_import_load_json(hass: HomeAssistant, mock_tellduslive) -> N flow = init_config_flow(hass) with patch( - "homeassistant.components.tellduslive.config_flow.load_json", + "homeassistant.components.tellduslive.config_flow.load_json_object", return_value={"localhost": {}}, ), patch("os.path.isfile"): result = await flow.async_step_import( From 1278fe1f81321bb37f163a171004bb0bf91611fb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 22 Feb 2023 13:54:02 +0100 Subject: [PATCH 0291/2810] Use load_json_object in nanoleaf (#88592) * Use load_json_object in nanoleaf * pretty * prettier --- .../components/nanoleaf/config_flow.py | 36 +++++++++---------- tests/components/nanoleaf/test_config_flow.py | 6 ++-- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/nanoleaf/config_flow.py b/homeassistant/components/nanoleaf/config_flow.py index be16db310b..87239f5fd8 100644 --- a/homeassistant/components/nanoleaf/config_flow.py +++ b/homeassistant/components/nanoleaf/config_flow.py @@ -15,7 +15,7 @@ from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.json import save_json -from homeassistant.util.json import load_json +from homeassistant.util.json import JsonObjectType, JsonValueType, load_json_object from .const import DOMAIN @@ -36,16 +36,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): reauth_entry: config_entries.ConfigEntry | None = None + nanoleaf: Nanoleaf + + # For discovery integration import + discovery_conf: JsonObjectType + device_id: str + VERSION = 1 - def __init__(self) -> None: - """Initialize a Nanoleaf flow.""" - self.nanoleaf: Nanoleaf - - # For discovery integration import - self.discovery_conf: dict - self.device_id: str - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -134,19 +132,19 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # Import from discovery integration self.device_id = device_id - self.discovery_conf = cast( - dict, - await self.hass.async_add_executor_job( - load_json, self.hass.config.path(CONFIG_FILE) - ), - ) - auth_token: str | None = self.discovery_conf.get(self.device_id, {}).get( - "token", # >= 2021.4 - self.discovery_conf.get(host, {}).get("token"), # < 2021.4 + self.discovery_conf = await self.hass.async_add_executor_job( + load_json_object, self.hass.config.path(CONFIG_FILE) ) + + auth_token: JsonValueType = None + if device_conf := self.discovery_conf.get(self.device_id): # >= 2021.4 + auth_token = cast(JsonObjectType, device_conf).get("token") + if not auth_token and (host_conf := self.discovery_conf.get(host)): # < 2021.4 + auth_token = cast(JsonObjectType, host_conf).get("token") + if auth_token is not None: self.nanoleaf = Nanoleaf( - async_get_clientsession(self.hass), host, auth_token + async_get_clientsession(self.hass), host, cast(str, auth_token) ) _LOGGER.warning( "Importing Nanoleaf %s from the discovery integration", name diff --git a/tests/components/nanoleaf/test_config_flow.py b/tests/components/nanoleaf/test_config_flow.py index 6880ae7671..200e2c2154 100644 --- a/tests/components/nanoleaf/test_config_flow.py +++ b/tests/components/nanoleaf/test_config_flow.py @@ -230,7 +230,7 @@ async def test_discovery_link_unavailable( with patch( "homeassistant.components.nanoleaf.config_flow.Nanoleaf.get_info", ), patch( - "homeassistant.components.nanoleaf.config_flow.load_json", + "homeassistant.components.nanoleaf.config_flow.load_json_object", return_value={}, ): result = await hass.config_entries.flow.async_init( @@ -353,7 +353,7 @@ async def test_import_discovery_integration( Test updating the .nanoleaf_conf file if it was not the only device in the file. """ with patch( - "homeassistant.components.nanoleaf.config_flow.load_json", + "homeassistant.components.nanoleaf.config_flow.load_json_object", return_value=dict(nanoleaf_conf_file), ), patch( "homeassistant.components.nanoleaf.config_flow.Nanoleaf", @@ -402,7 +402,7 @@ async def test_import_discovery_integration( async def test_ssdp_discovery(hass: HomeAssistant) -> None: """Test SSDP discovery.""" with patch( - "homeassistant.components.nanoleaf.config_flow.load_json", + "homeassistant.components.nanoleaf.config_flow.load_json_object", return_value={}, ), patch( "homeassistant.components.nanoleaf.config_flow.Nanoleaf", From 42d90538e49a4ea437c48203fbd2126b309ea1d7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 22 Feb 2023 13:55:44 +0100 Subject: [PATCH 0292/2810] Use load_json_object in ps4 (#88590) --- homeassistant/components/ps4/__init__.py | 10 ++--- tests/components/ps4/conftest.py | 20 +++++++--- tests/components/ps4/test_init.py | 46 +++++++---------------- tests/components/ps4/test_media_player.py | 22 +++++++---- 4 files changed, 45 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/ps4/__init__.py b/homeassistant/components/ps4/__init__.py index 28ebd8ff07..d9c5f2f6dd 100644 --- a/homeassistant/components/ps4/__init__.py +++ b/homeassistant/components/ps4/__init__.py @@ -28,7 +28,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType from homeassistant.util import location -from homeassistant.util.json import load_json +from homeassistant.util.json import JsonObjectType, load_json_object from .config_flow import PlayStation4FlowHandler # noqa: F401 from .const import ( @@ -165,19 +165,15 @@ def format_unique_id(creds, mac_address): return f"{mac_address}_{suffix}" -def load_games(hass: HomeAssistant, unique_id: str) -> dict: +def load_games(hass: HomeAssistant, unique_id: str) -> JsonObjectType: """Load games for sources.""" g_file = hass.config.path(GAMES_FILE.format(unique_id)) try: - games = load_json(g_file) + games = load_json_object(g_file) except HomeAssistantError as error: games = {} _LOGGER.error("Failed to load games file: %s", error) - if not isinstance(games, dict): - _LOGGER.error("Games file was not parsed correctly") - games = {} - # If file exists if os.path.isfile(g_file): games = _reformat_data(hass, games, unique_id) diff --git a/tests/components/ps4/conftest.py b/tests/components/ps4/conftest.py index 5bb27012b1..13b6a6e997 100644 --- a/tests/components/ps4/conftest.py +++ b/tests/components/ps4/conftest.py @@ -1,4 +1,5 @@ """Test configuration for PS4.""" +from collections.abc import Generator from unittest.mock import MagicMock, patch from pyps4_2ndscreen.ddp import DEFAULT_UDP_PORT, DDPProtocol @@ -6,28 +7,30 @@ import pytest @pytest.fixture -def patch_load_json(): +def patch_load_json_object() -> Generator[MagicMock, None, None]: """Prevent load JSON being used.""" - with patch("homeassistant.components.ps4.load_json", return_value={}) as mock_load: + with patch( + "homeassistant.components.ps4.load_json_object", return_value={} + ) as mock_load: yield mock_load @pytest.fixture -def patch_save_json(): +def patch_save_json() -> Generator[MagicMock, None, None]: """Prevent save JSON being used.""" with patch("homeassistant.components.ps4.save_json") as mock_save: yield mock_save @pytest.fixture -def patch_get_status(): +def patch_get_status() -> Generator[MagicMock, None, None]: """Prevent save JSON being used.""" with patch("pyps4_2ndscreen.ps4.get_status", return_value=None) as mock_get_status: yield mock_get_status @pytest.fixture -def mock_ddp_endpoint(): +def mock_ddp_endpoint() -> Generator[None, None, None]: """Mock pyps4_2ndscreen.ddp.async_create_ddp_endpoint.""" protocol = DDPProtocol() protocol._local_port = DEFAULT_UDP_PORT @@ -40,5 +43,10 @@ def mock_ddp_endpoint(): @pytest.fixture(autouse=True) -def patch_io(patch_load_json, patch_save_json, patch_get_status, mock_ddp_endpoint): +def patch_io( + patch_load_json_object: MagicMock, + patch_save_json: MagicMock, + patch_get_status: MagicMock, + mock_ddp_endpoint: None, +) -> None: """Prevent PS4 doing I/O.""" diff --git a/tests/components/ps4/test_init.py b/tests/components/ps4/test_init.py index 5d17421a4b..3b8dc5e1e2 100644 --- a/tests/components/ps4/test_init.py +++ b/tests/components/ps4/test_init.py @@ -198,14 +198,14 @@ async def setup_mock_component(hass): await hass.async_block_till_done() -def test_games_reformat_to_dict(hass: HomeAssistant) -> None: +def test_games_reformat_to_dict( + hass: HomeAssistant, patch_load_json_object: MagicMock +) -> None: """Test old data format is converted to new format.""" + patch_load_json_object.return_value = MOCK_GAMES_DATA_OLD_STR_FORMAT with patch( - "homeassistant.components.ps4.load_json", - return_value=MOCK_GAMES_DATA_OLD_STR_FORMAT, - ), patch("homeassistant.components.ps4.save_json", side_effect=MagicMock()), patch( - "os.path.isfile", return_value=True - ): + "homeassistant.components.ps4.save_json", side_effect=MagicMock() + ), patch("os.path.isfile", return_value=True): mock_games = ps4.load_games(hass, MOCK_ENTRY_ID) # New format is a nested dict. @@ -221,13 +221,12 @@ def test_games_reformat_to_dict(hass: HomeAssistant) -> None: assert mock_data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.GAME -def test_load_games(hass: HomeAssistant) -> None: +def test_load_games(hass: HomeAssistant, patch_load_json_object: MagicMock) -> None: """Test that games are loaded correctly.""" + patch_load_json_object.return_value = MOCK_GAMES with patch( - "homeassistant.components.ps4.load_json", return_value=MOCK_GAMES - ), patch("homeassistant.components.ps4.save_json", side_effect=MagicMock()), patch( - "os.path.isfile", return_value=True - ): + "homeassistant.components.ps4.save_json", side_effect=MagicMock() + ), patch("os.path.isfile", return_value=True): mock_games = ps4.load_games(hass, MOCK_ENTRY_ID) assert isinstance(mock_games, dict) @@ -240,29 +239,12 @@ def test_load_games(hass: HomeAssistant) -> None: assert mock_data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.GAME -def test_loading_games_returns_dict(hass: HomeAssistant) -> None: +def test_loading_games_returns_dict( + hass: HomeAssistant, patch_load_json_object: MagicMock +) -> None: """Test that loading games always returns a dict.""" + patch_load_json_object.side_effect = HomeAssistantError with patch( - "homeassistant.components.ps4.load_json", side_effect=HomeAssistantError - ), patch("homeassistant.components.ps4.save_json", side_effect=MagicMock()), patch( - "os.path.isfile", return_value=True - ): - mock_games = ps4.load_games(hass, MOCK_ENTRY_ID) - - assert isinstance(mock_games, dict) - assert not mock_games - - with patch( - "homeassistant.components.ps4.load_json", return_value="Some String" - ), patch("homeassistant.components.ps4.save_json", side_effect=MagicMock()), patch( - "os.path.isfile", return_value=True - ): - mock_games = ps4.load_games(hass, MOCK_ENTRY_ID) - - assert isinstance(mock_games, dict) - assert not mock_games - - with patch("homeassistant.components.ps4.load_json", return_value=[]), patch( "homeassistant.components.ps4.save_json", side_effect=MagicMock() ), patch("os.path.isfile", return_value=True): mock_games = ps4.load_games(hass, MOCK_ENTRY_ID) diff --git a/tests/components/ps4/test_media_player.py b/tests/components/ps4/test_media_player.py index 2c5f3d1a83..acb84186c0 100644 --- a/tests/components/ps4/test_media_player.py +++ b/tests/components/ps4/test_media_player.py @@ -263,11 +263,11 @@ async def test_media_attributes_are_fetched(hass: HomeAssistant) -> None: async def test_media_attributes_are_loaded( - hass: HomeAssistant, patch_load_json + hass: HomeAssistant, patch_load_json_object: MagicMock ) -> None: """Test that media attributes are loaded.""" mock_entity_id = await setup_mock_component(hass) - patch_load_json.return_value = {MOCK_TITLE_ID: MOCK_GAMES_DATA_LOCKED} + patch_load_json_object.return_value = {MOCK_TITLE_ID: MOCK_GAMES_DATA_LOCKED} with patch( "homeassistant.components.ps4.media_player." @@ -451,9 +451,11 @@ async def test_media_stop(hass: HomeAssistant) -> None: assert len(mock_call.mock_calls) == 1 -async def test_select_source(hass: HomeAssistant, patch_load_json) -> None: +async def test_select_source( + hass: HomeAssistant, patch_load_json_object: MagicMock +) -> None: """Test that select source service calls function with title.""" - patch_load_json.return_value = {MOCK_TITLE_ID: MOCK_GAMES_DATA} + patch_load_json_object.return_value = {MOCK_TITLE_ID: MOCK_GAMES_DATA} with patch("pyps4_2ndscreen.ps4.get_status", return_value=MOCK_STATUS_IDLE): mock_entity_id = await setup_mock_component(hass) @@ -471,9 +473,11 @@ async def test_select_source(hass: HomeAssistant, patch_load_json) -> None: assert len(mock_call.mock_calls) == 1 -async def test_select_source_caps(hass: HomeAssistant, patch_load_json) -> None: +async def test_select_source_caps( + hass: HomeAssistant, patch_load_json_object: MagicMock +) -> None: """Test that select source service calls function with upper case title.""" - patch_load_json.return_value = {MOCK_TITLE_ID: MOCK_GAMES_DATA} + patch_load_json_object.return_value = {MOCK_TITLE_ID: MOCK_GAMES_DATA} with patch("pyps4_2ndscreen.ps4.get_status", return_value=MOCK_STATUS_IDLE): mock_entity_id = await setup_mock_component(hass) @@ -494,9 +498,11 @@ async def test_select_source_caps(hass: HomeAssistant, patch_load_json) -> None: assert len(mock_call.mock_calls) == 1 -async def test_select_source_id(hass: HomeAssistant, patch_load_json) -> None: +async def test_select_source_id( + hass: HomeAssistant, patch_load_json_object: MagicMock +) -> None: """Test that select source service calls function with Title ID.""" - patch_load_json.return_value = {MOCK_TITLE_ID: MOCK_GAMES_DATA} + patch_load_json_object.return_value = {MOCK_TITLE_ID: MOCK_GAMES_DATA} with patch("pyps4_2ndscreen.ps4.get_status", return_value=MOCK_STATUS_IDLE): mock_entity_id = await setup_mock_component(hass) From 10bb0ab6b4af4853167e9b9ea195b7926d6c6992 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 22 Feb 2023 13:56:11 +0100 Subject: [PATCH 0293/2810] Use load_json_object in matrix (#88587) --- homeassistant/components/matrix/__init__.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/matrix/__init__.py b/homeassistant/components/matrix/__init__.py index 84fafd3f06..febafc367f 100644 --- a/homeassistant/components/matrix/__init__.py +++ b/homeassistant/components/matrix/__init__.py @@ -21,7 +21,7 @@ from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType -from homeassistant.util.json import load_json +from homeassistant.util.json import JsonObjectType, load_json_object from .const import DOMAIN, FORMAT_HTML, FORMAT_TEXT, SERVICE_SEND_MESSAGE @@ -276,15 +276,13 @@ class MatrixBot: except MatrixRequestError as ex: _LOGGER.error("Could not join room %s: %s", room_id, ex) - def _get_auth_tokens(self): + def _get_auth_tokens(self) -> JsonObjectType: """Read sorted authentication tokens from disk. Returns the auth_tokens dictionary. """ try: - auth_tokens = load_json(self._session_filepath) - - return auth_tokens + return load_json_object(self._session_filepath) except HomeAssistantError as ex: _LOGGER.warning( "Loading authentication tokens from file '%s' failed: %s", From 209278d423b3ee8cccf0bd83a40bb0ad732de08c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 22 Feb 2023 13:56:30 +0100 Subject: [PATCH 0294/2810] Improve type hint in homeassistant trigger (#88596) Imrpove type hint in homeassistant trigger --- homeassistant/components/homeassistant/trigger.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/homeassistant/trigger.py b/homeassistant/components/homeassistant/trigger.py index 588b671300..3160af5807 100644 --- a/homeassistant/components/homeassistant/trigger.py +++ b/homeassistant/components/homeassistant/trigger.py @@ -1,16 +1,17 @@ """Home Assistant trigger dispatcher.""" import importlib -from homeassistant.components.device_automation.trigger import ( - DeviceAutomationTriggerProtocol, -) from homeassistant.const import CONF_PLATFORM from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.trigger import ( + TriggerActionType, + TriggerInfo, + TriggerProtocol, +) from homeassistant.helpers.typing import ConfigType -def _get_trigger_platform(config: ConfigType) -> DeviceAutomationTriggerProtocol: +def _get_trigger_platform(config: ConfigType) -> TriggerProtocol: return importlib.import_module(f"..triggers.{config[CONF_PLATFORM]}", __name__) @@ -20,7 +21,7 @@ async def async_validate_trigger_config( """Validate config.""" platform = _get_trigger_platform(config) if hasattr(platform, "async_validate_trigger_config"): - return await getattr(platform, "async_validate_trigger_config")(hass, config) + return await platform.async_validate_trigger_config(hass, config) return platform.TRIGGER_SCHEMA(config) From db6dba87e59de7f215c8f83f7a54c4c99f10ca63 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 22 Feb 2023 13:57:32 +0100 Subject: [PATCH 0295/2810] Use load_json_object in html5 (#88586) --- homeassistant/components/html5/notify.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py index cea362e4ba..931d446b2a 100644 --- a/homeassistant/components/html5/notify.py +++ b/homeassistant/components/html5/notify.py @@ -35,7 +35,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import ensure_unique_string -from homeassistant.util.json import load_json +from homeassistant.util.json import JsonObjectType, load_json_object from .const import DOMAIN, SERVICE_DISMISS @@ -175,9 +175,6 @@ def get_service( registrations = _load_config(json_path) - if registrations is None: - return None - vapid_pub_key = config[ATTR_VAPID_PUB_KEY] vapid_prv_key = config[ATTR_VAPID_PRV_KEY] vapid_email = config[ATTR_VAPID_EMAIL] @@ -197,10 +194,10 @@ def get_service( ) -def _load_config(filename): +def _load_config(filename: str) -> JsonObjectType: """Load configuration.""" with suppress(HomeAssistantError): - return load_json(filename) + return load_json_object(filename) return {} From 3f11fbfda866109307b3a8cd62bd5e7621a78f78 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 22 Feb 2023 13:58:07 +0100 Subject: [PATCH 0296/2810] Use load_json_object in ecobee (#88584) * Use load_json_object in ecobee * Adjust tests --- homeassistant/components/ecobee/config_flow.py | 4 ++-- tests/components/ecobee/test_config_flow.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ecobee/config_flow.py b/homeassistant/components/ecobee/config_flow.py index cce39a9ce9..0bd7306e54 100644 --- a/homeassistant/components/ecobee/config_flow.py +++ b/homeassistant/components/ecobee/config_flow.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_API_KEY from homeassistant.exceptions import HomeAssistantError -from homeassistant.util.json import load_json +from homeassistant.util.json import load_json_object from .const import _LOGGER, CONF_REFRESH_TOKEN, DATA_ECOBEE_CONFIG, DOMAIN @@ -85,7 +85,7 @@ class EcobeeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """ try: legacy_config = await self.hass.async_add_executor_job( - load_json, self.hass.config.path(ECOBEE_CONFIG_FILENAME) + load_json_object, self.hass.config.path(ECOBEE_CONFIG_FILENAME) ) config = { ECOBEE_API_KEY: legacy_config[ECOBEE_API_KEY], diff --git a/tests/components/ecobee/test_config_flow.py b/tests/components/ecobee/test_config_flow.py index e593795332..a4185313f5 100644 --- a/tests/components/ecobee/test_config_flow.py +++ b/tests/components/ecobee/test_config_flow.py @@ -144,7 +144,7 @@ async def test_import_flow_triggered_with_ecobee_conf_and_valid_data_and_valid_t MOCK_ECOBEE_CONF = {ECOBEE_API_KEY: None, ECOBEE_REFRESH_TOKEN: None} with patch( - "homeassistant.components.ecobee.config_flow.load_json", + "homeassistant.components.ecobee.config_flow.load_json_object", return_value=MOCK_ECOBEE_CONF, ), patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee: mock_ecobee = mock_ecobee.return_value @@ -173,7 +173,7 @@ async def test_import_flow_triggered_with_ecobee_conf_and_invalid_data( MOCK_ECOBEE_CONF = {} with patch( - "homeassistant.components.ecobee.config_flow.load_json", + "homeassistant.components.ecobee.config_flow.load_json_object", return_value=MOCK_ECOBEE_CONF, ), patch.object( flow, "async_step_user", return_value=mock_coro() @@ -196,7 +196,7 @@ async def test_import_flow_triggered_with_ecobee_conf_and_valid_data_and_stale_t MOCK_ECOBEE_CONF = {ECOBEE_API_KEY: None, ECOBEE_REFRESH_TOKEN: None} with patch( - "homeassistant.components.ecobee.config_flow.load_json", + "homeassistant.components.ecobee.config_flow.load_json_object", return_value=MOCK_ECOBEE_CONF, ), patch( "homeassistant.components.ecobee.config_flow.Ecobee" From 0f212478cf86a8ff92681dc18eb856679e326537 Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Wed, 22 Feb 2023 08:41:46 -0500 Subject: [PATCH 0297/2810] Bump Insteon dependencies (#88514) --- homeassistant/components/insteon/manifest.json | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index 57a26b4821..35d7f624c5 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.1", - "insteon-frontend-home-assistant==0.3.1" + "pyinsteon==1.3.2", + "insteon-frontend-home-assistant==0.3.2" ], "usb": [ { diff --git a/requirements_all.txt b/requirements_all.txt index 798abda576..396c0de28a 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.1 +insteon-frontend-home-assistant==0.3.2 # 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.1 +pyinsteon==1.3.2 # homeassistant.components.intesishome pyintesishome==1.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 01469edb83..a233d1f8e7 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.1 +insteon-frontend-home-assistant==0.3.2 # 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.1 +pyinsteon==1.3.2 # homeassistant.components.ipma pyipma==3.0.6 From 5bffbafae4bf432122d25cf09ee237c31394e52e Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 22 Feb 2023 14:47:43 +0100 Subject: [PATCH 0298/2810] Add ZHA "consumer connected" binary sensor for Xiaomi EU plugs (#88194) * Initialize consumer_connected attribute * Add binary sensor for consumer_connected --- homeassistant/components/zha/binary_sensor.py | 10 ++++++++++ .../zha/core/channels/manufacturerspecific.py | 1 + 2 files changed, 11 insertions(+) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index af7ce6b5bf..dc5a5eebba 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -203,3 +203,13 @@ class AqaraPetFeederErrorDetected(BinarySensor, id_suffix="error_detected"): SENSOR_ATTR = "error_detected" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PROBLEM _attr_name: str = "Error detected" + + +@MULTI_MATCH( + channel_names="opple_cluster", models={"lumi.plug.mmeu01", "lumi.plug.maeu01"} +) +class XiaomiPlugConsumerConnected(BinarySensor, id_suffix="consumer_connected"): + """ZHA Xiaomi plug consumer connected binary sensor.""" + + SENSOR_ATTR = "consumer_connected" + _attr_name: str = "Consumer connected" diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/channels/manufacturerspecific.py index e6b88a6c9a..85a478b0d4 100644 --- a/homeassistant/components/zha/core/channels/manufacturerspecific.py +++ b/homeassistant/components/zha/core/channels/manufacturerspecific.py @@ -125,6 +125,7 @@ class OppleRemote(ZigbeeChannel): elif self.cluster.endpoint.model in ("lumi.plug.mmeu01", "lumi.plug.maeu01"): self.ZCL_INIT_ATTRS = { "power_outage_memory": True, + "consumer_connected": True, } elif self.cluster.endpoint.model == "aqara.feeder.acn001": self.ZCL_INIT_ATTRS = { From 594b7b7e2c3626ba3d57313f103634121b0a24eb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 22 Feb 2023 15:06:43 +0100 Subject: [PATCH 0299/2810] Add clarifying comment about unit of elevation (#88489) * Add clarifying comment about unit of elevation * Make it a docstring --- homeassistant/core.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/core.py b/homeassistant/core.py index b4a86a4655..a3f8711c8e 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1817,7 +1817,10 @@ class Config: self.latitude: float = 0 self.longitude: float = 0 + self.elevation: int = 0 + """Elevation (always in meters regardless of the unit system).""" + self.location_name: str = "Home" self.time_zone: str = "UTC" self.units: UnitSystem = METRIC_SYSTEM From f40b7126642a93a33e810485d9115c05771bdfc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Albin=20M=C3=A9doc?= Date: Wed, 22 Feb 2023 15:33:43 +0100 Subject: [PATCH 0300/2810] Fix 500 error when getting calendar events (#88276) * Fix 500 error when getting calendar event * Add test for calendar dates in wrong order * Update calendar tests to use f strings --- homeassistant/components/calendar/__init__.py | 2 ++ tests/components/calendar/test_init.py | 23 ++++++++++++++----- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 876b90eac9..390e14d168 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -386,6 +386,8 @@ class CalendarEventView(http.HomeAssistantView): return web.Response(status=HTTPStatus.BAD_REQUEST) if start_date is None or end_date is None: return web.Response(status=HTTPStatus.BAD_REQUEST) + if start_date > end_date: + return web.Response(status=HTTPStatus.BAD_REQUEST) try: calendar_event_list = await entity.async_get_events( diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index b5135ccc78..806410c983 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -28,9 +28,7 @@ async def test_events_http_api( start = dt_util.now() end = start + timedelta(days=1) response = await client.get( - "/api/calendars/calendar.calendar_1?start={}&end={}".format( - start.isoformat(), end.isoformat() - ) + f"/api/calendars/calendar.calendar_1?start={start.isoformat()}&end={end.isoformat()}" ) assert response.status == HTTPStatus.OK events = await response.json() @@ -63,14 +61,27 @@ async def test_events_http_api_error( side_effect=HomeAssistantError("Failure"), ): response = await client.get( - "/api/calendars/calendar.calendar_1?start={}&end={}".format( - start.isoformat(), end.isoformat() - ) + f"/api/calendars/calendar.calendar_1?start={start.isoformat()}&end={end.isoformat()}" ) assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR assert await response.json() == {"message": "Error reading events: Failure"} +async def test_events_http_api_dates_wrong_order( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test the calendar demo view.""" + await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) + await hass.async_block_till_done() + client = await hass_client() + start = dt_util.now() + end = start + timedelta(days=-1) + response = await client.get( + f"/api/calendars/calendar.calendar_1?start={start.isoformat()}&end={end.isoformat()}" + ) + assert response.status == HTTPStatus.BAD_REQUEST + + async def test_calendars_http_api( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: From 2314d15761e751dcc5fe8b590e71f720bfba4a7c Mon Sep 17 00:00:00 2001 From: Yann Le Moigne Date: Wed, 22 Feb 2023 15:54:18 +0100 Subject: [PATCH 0301/2810] Fix cover template: optimistic mode is ignored (#87925) * Fix cover template: optimistic mode is ignored (#84334) * Fix invalid check in is_closed (make test pass) * Add test for non optimistic cover * Update homeassistant/components/template/cover.py Co-authored-by: Erik Montnemery * Update test_cover.py * Fix format * Fix tests --------- Co-authored-by: Erik Montnemery --- homeassistant/components/template/cover.py | 7 +++-- tests/components/template/test_cover.py | 36 ++++++++++++++++++---- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index e36ca75ed0..9aafc719f1 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -172,7 +172,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): self._tilt_script = Script(hass, tilt_action, friendly_name, DOMAIN) optimistic = config.get(CONF_OPTIMISTIC) self._optimistic = optimistic or ( - not self._template and not self._position_template + optimistic is None and not self._template and not self._position_template ) tilt_optimistic = config.get(CONF_TILT_OPTIMISTIC) self._tilt_optimistic = tilt_optimistic or not self._tilt_template @@ -270,8 +270,11 @@ class CoverTemplate(TemplateEntity, CoverEntity): self._tilt_value = state @property - def is_closed(self) -> bool: + def is_closed(self) -> bool | None: """Return if the cover is closed.""" + if self._position is None: + return None + return self._position == 0 @property diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index 8520fe9cc8..e89773d998 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -21,6 +21,7 @@ from homeassistant.const import ( STATE_OPEN, STATE_OPENING, STATE_UNAVAILABLE, + STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant @@ -80,7 +81,7 @@ OPEN_CLOSE_COVER_CONFIG = { ( "cover.test_state", "cat", - STATE_OPEN, + STATE_UNKNOWN, {}, -1, "Received invalid cover is_on state: cat", @@ -89,7 +90,7 @@ OPEN_CLOSE_COVER_CONFIG = { ( "cover.test_state", "bear", - STATE_OPEN, + STATE_UNKNOWN, {}, -1, "Received invalid cover is_on state: bear", @@ -112,8 +113,8 @@ OPEN_CLOSE_COVER_CONFIG = { } }, [ - ("cover.test_state", STATE_OPEN, STATE_OPEN, {}, -1, ""), - ("cover.test_state", STATE_CLOSED, STATE_OPEN, {}, -1, ""), + ("cover.test_state", STATE_OPEN, STATE_UNKNOWN, {}, -1, ""), + ("cover.test_state", STATE_CLOSED, STATE_UNKNOWN, {}, -1, ""), ("cover.test_state", STATE_OPENING, STATE_OPENING, {}, -1, ""), ("cover.test_state", STATE_CLOSING, STATE_CLOSING, {}, -1, ""), ("cover.test", STATE_CLOSED, STATE_CLOSING, {"position": 0}, 0, ""), @@ -136,7 +137,7 @@ async def test_template_state_text( ) -> None: """Test the state text of a template.""" state = hass.states.get("cover.test_template_cover") - assert state.state == STATE_OPEN + assert state.state == STATE_UNKNOWN for entity, set_state, test_state, attr, pos, text in states: hass.states.async_set(entity, set_state, attributes=attr) @@ -207,6 +208,29 @@ async def test_template_position(hass: HomeAssistant, start_ha) -> None: assert state.state == test_state +@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + DOMAIN: { + "platform": "template", + "covers": { + "test_template_cover": { + **OPEN_CLOSE_COVER_CONFIG, + "optimistic": False, + } + }, + } + }, + ], +) +async def test_template_not_optimistic(hass, start_ha): + """Test the is_closed attribute.""" + state = hass.states.get("cover.test_template_cover") + assert state.state == STATE_UNKNOWN + + @pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) @pytest.mark.parametrize( "config", @@ -428,7 +452,7 @@ async def test_set_position(hass: HomeAssistant, start_ha, calls) -> None: state = hass.states.async_set("input_number.test", 42) await hass.async_block_till_done() state = hass.states.get("cover.test_template_cover") - assert state.state == STATE_OPEN + assert state.state == STATE_UNKNOWN await hass.services.async_call( DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True From f97b50f76218a65e9318b9149be2cbd4c348aad4 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 22 Feb 2023 16:26:12 +0100 Subject: [PATCH 0302/2810] Add Reolink update entity (#87865) --- .coveragerc | 1 + homeassistant/components/reolink/__init__.py | 35 +++++++-- .../components/reolink/binary_sensor.py | 1 - homeassistant/components/reolink/camera.py | 1 - homeassistant/components/reolink/entity.py | 78 +++++++++++++------ homeassistant/components/reolink/number.py | 1 - homeassistant/components/reolink/update.py | 78 +++++++++++++++++++ tests/components/reolink/test_config_flow.py | 1 + 8 files changed, 162 insertions(+), 34 deletions(-) create mode 100644 homeassistant/components/reolink/update.py diff --git a/.coveragerc b/.coveragerc index 871f798a65..7429966011 100644 --- a/.coveragerc +++ b/.coveragerc @@ -972,6 +972,7 @@ omit = homeassistant/components/reolink/entity.py homeassistant/components/reolink/host.py homeassistant/components/reolink/number.py + homeassistant/components/reolink/update.py homeassistant/components/repetier/__init__.py homeassistant/components/repetier/sensor.py homeassistant/components/rest/notify.py diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 0ff0861f65..2d3fc52eb3 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -23,8 +23,9 @@ from .host import ReolinkHost _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.CAMERA, Platform.NUMBER] -DEVICE_UPDATE_INTERVAL = 60 +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CAMERA, Platform.NUMBER, Platform.UPDATE] +DEVICE_UPDATE_INTERVAL = timedelta(seconds=60) +FIRMWARE_UPDATE_INTERVAL = timedelta(hours=12) @dataclass @@ -33,6 +34,7 @@ class ReolinkData: host: ReolinkHost device_coordinator: DataUpdateCoordinator + firmware_coordinator: DataUpdateCoordinator async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: @@ -75,23 +77,44 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async with async_timeout.timeout(host.api.timeout): await host.renew() - coordinator_device_config_update = DataUpdateCoordinator( + async def async_check_firmware_update(): + """Check for firmware updates.""" + async with async_timeout.timeout(host.api.timeout): + try: + return await host.api.check_new_firmware() + except ReolinkError as err: + raise UpdateFailed( + f"Error checking Reolink firmware update {host.api.nvr_name}" + ) from err + + device_coordinator = DataUpdateCoordinator( hass, _LOGGER, name=f"reolink.{host.api.nvr_name}", update_method=async_device_config_update, - update_interval=timedelta(seconds=DEVICE_UPDATE_INTERVAL), + update_interval=DEVICE_UPDATE_INTERVAL, + ) + firmware_coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"reolink.{host.api.nvr_name}.firmware", + update_method=async_check_firmware_update, + update_interval=FIRMWARE_UPDATE_INTERVAL, ) # Fetch initial data so we have data when entities subscribe try: - await coordinator_device_config_update.async_config_entry_first_refresh() + await asyncio.gather( + device_coordinator.async_config_entry_first_refresh(), + firmware_coordinator.async_config_entry_first_refresh(), + ) except ConfigEntryNotReady: await host.stop() raise hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = ReolinkData( host=host, - device_coordinator=coordinator_device_config_update, + device_coordinator=device_coordinator, + firmware_coordinator=firmware_coordinator, ) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 5e7718f418..541ad9ec99 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -116,7 +116,6 @@ async def async_setup_entry( class ReolinkBinarySensorEntity(ReolinkCoordinatorEntity, BinarySensorEntity): """Base binary-sensor class for Reolink IP camera motion sensors.""" - _attr_has_entity_name = True entity_description: ReolinkBinarySensorEntityDescription def __init__( diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index 5ccada7269..d14906a578 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -43,7 +43,6 @@ class ReolinkCamera(ReolinkCoordinatorEntity, Camera): """An implementation of a Reolink IP camera.""" _attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM - _attr_has_entity_name = True def __init__( self, diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index bcf39814c9..5f983ab349 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -3,25 +3,69 @@ from __future__ import annotations from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) from . import ReolinkData from .const import DOMAIN -class ReolinkCoordinatorEntity(CoordinatorEntity): - """Parent class for Reolink hardware camera entities.""" +class ReolinkBaseCoordinatorEntity(CoordinatorEntity): + """Parent class for entities that control the Reolink NVR itself, without a channel. - def __init__(self, reolink_data: ReolinkData, channel: int) -> None: - """Initialize ReolinkCoordinatorEntity for a hardware camera.""" - coordinator = reolink_data.device_coordinator + A camera connected directly to HomeAssistant without using a NVR is in the reolink API + basically a NVR with a single channel that has the camera connected to that channel. + """ + + _attr_has_entity_name = True + + def __init__( + self, + reolink_data: ReolinkData, + coordinator: DataUpdateCoordinator | None = None, + ) -> None: + """Initialize ReolinkBaseCoordinatorEntity for a NVR entity without a channel.""" + if coordinator is None: + coordinator = reolink_data.device_coordinator super().__init__(coordinator) self._host = reolink_data.host - self._channel = channel http_s = "https" if self._host.api.use_https else "http" - conf_url = f"{http_s}://{self._host.api.host}:{self._host.api.port}" + self._conf_url = f"{http_s}://{self._host.api.host}:{self._host.api.port}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._host.unique_id)}, + connections={(CONNECTION_NETWORK_MAC, self._host.api.mac_address)}, + name=self._host.api.nvr_name, + model=self._host.api.model, + manufacturer=self._host.api.manufacturer, + hw_version=self._host.api.hardware_version, + sw_version=self._host.api.sw_version, + configuration_url=self._conf_url, + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._host.api.session_active and super().available + + +class ReolinkCoordinatorEntity(ReolinkBaseCoordinatorEntity): + """Parent class for Reolink hardware camera entities connected to a channel of the NVR.""" + + def __init__( + self, + reolink_data: ReolinkData, + channel: int, + coordinator: DataUpdateCoordinator | None = None, + ) -> None: + """Initialize ReolinkCoordinatorEntity for a hardware camera connected to a channel of the NVR.""" + super().__init__(reolink_data, coordinator) + + self._channel = channel + if self._host.api.is_nvr: self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{self._host.unique_id}_ch{self._channel}")}, @@ -29,21 +73,5 @@ class ReolinkCoordinatorEntity(CoordinatorEntity): name=self._host.api.camera_name(self._channel), model=self._host.api.camera_model(self._channel), manufacturer=self._host.api.manufacturer, - configuration_url=conf_url, + configuration_url=self._conf_url, ) - else: - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._host.unique_id)}, - connections={(CONNECTION_NETWORK_MAC, self._host.api.mac_address)}, - name=self._host.api.nvr_name, - model=self._host.api.model, - manufacturer=self._host.api.manufacturer, - hw_version=self._host.api.hardware_version, - sw_version=self._host.api.sw_version, - configuration_url=conf_url, - ) - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._host.api.session_active and super().available diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 3f8860876a..e9b692fffe 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -88,7 +88,6 @@ async def async_setup_entry( class ReolinkNumberEntity(ReolinkCoordinatorEntity, NumberEntity): """Base number entity class for Reolink IP cameras.""" - _attr_has_entity_name = True entity_description: ReolinkNumberEntityDescription def __init__( diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py new file mode 100644 index 0000000000..71ca16ca68 --- /dev/null +++ b/homeassistant/components/reolink/update.py @@ -0,0 +1,78 @@ +"""Update entities for Reolink devices.""" +from __future__ import annotations + +import logging +from typing import Any + +from reolink_aio.exceptions import ReolinkError + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ReolinkData +from .const import DOMAIN +from .entity import ReolinkBaseCoordinatorEntity + +LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up update entities for Reolink component.""" + reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities([ReolinkUpdateEntity(reolink_data)]) + + +class ReolinkUpdateEntity(ReolinkBaseCoordinatorEntity, UpdateEntity): + """Update entity for a Netgear device.""" + + _attr_device_class = UpdateDeviceClass.FIRMWARE + _attr_supported_features = UpdateEntityFeature.INSTALL + _attr_release_url = "https://reolink.com/download-center/" + _attr_name = "Update" + + def __init__( + self, + reolink_data: ReolinkData, + ) -> None: + """Initialize a Netgear device.""" + super().__init__(reolink_data, reolink_data.firmware_coordinator) + + self._attr_unique_id = f"{self._host.unique_id}_update" + + @property + def installed_version(self) -> str | None: + """Version currently in use.""" + return self._host.api.sw_version + + @property + def latest_version(self) -> str | None: + """Latest version available for install.""" + if self.coordinator.data is None: + return None + + if not self.coordinator.data: + return self.installed_version + + return self.coordinator.data + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install the latest firmware version.""" + try: + await self._host.api.update_firmware() + except ReolinkError as err: + raise HomeAssistantError( + f"Error trying to update Reolink firmware: {err}" + ) from err diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 10391c3206..a5de5d5acb 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -36,6 +36,7 @@ def get_mock_info(error=None, user_level="admin"): host_mock.get_host_data = AsyncMock(return_value=None) else: host_mock.get_host_data = AsyncMock(side_effect=error) + host_mock.check_new_firmware = AsyncMock(return_value=False) host_mock.unsubscribe = AsyncMock(return_value=True) host_mock.logout = AsyncMock(return_value=True) host_mock.mac_address = TEST_MAC From 59f2b2ad516d1c4901027df50fa64496af40e6b6 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 22 Feb 2023 09:36:42 -0600 Subject: [PATCH 0303/2810] Bump intents package version; hassil==1.0.5; home-assistant-intents==2023.2.22 (#88605) * Bump hassil and intents versions * Adjust tests to handle abbreviated responses --- .../components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- tests/components/conversation/test_init.py | 14 +++++++------- tests/helpers/test_intent.py | 8 ++++---- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index d33e2dc5f5..5e4e2e8902 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==0.2.6", "home-assistant-intents==2023.1.31"] + "requirements": ["hassil==1.0.5", "home-assistant-intents==2023.2.22"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e45a5cc177..d88ac10643 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==0.2.6 +hassil==1.0.5 home-assistant-bluetooth==1.9.3 home-assistant-frontend==20230202.0 -home-assistant-intents==2023.1.31 +home-assistant-intents==2023.2.22 httpx==0.23.3 ifaddr==0.1.7 janus==1.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index 396c0de28a..609b7c2a9d 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==0.2.6 +hassil==1.0.5 # homeassistant.components.tasmota hatasmota==0.6.4 @@ -910,7 +910,7 @@ holidays==0.18.0 home-assistant-frontend==20230202.0 # homeassistant.components.conversation -home-assistant-intents==2023.1.31 +home-assistant-intents==2023.2.22 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a233d1f8e7..540bfc9f12 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==0.2.6 +hassil==1.0.5 # homeassistant.components.tasmota hatasmota==0.6.4 @@ -693,7 +693,7 @@ holidays==0.18.0 home-assistant-frontend==20230202.0 # homeassistant.components.conversation -home-assistant-intents==2023.1.31 +home-assistant-intents==2023.2.22 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 23914115c0..f0f5698705 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -79,7 +79,7 @@ async def test_http_processing_intent( "speech": { "plain": { "extra_data": None, - "speech": "Turned on my cool light", + "speech": "Turned on light", } }, "language": hass.config.language, @@ -127,7 +127,7 @@ async def test_http_processing_intent_target_ha_agent( "speech": { "plain": { "extra_data": None, - "speech": "Turned on my cool light", + "speech": "Turned on light", } }, "language": hass.config.language, @@ -176,7 +176,7 @@ async def test_http_processing_intent_entity_added( "speech": { "plain": { "extra_data": None, - "speech": "Turned on my cool light", + "speech": "Turned on light", } }, "language": hass.config.language, @@ -210,7 +210,7 @@ async def test_http_processing_intent_entity_added( "speech": { "plain": { "extra_data": None, - "speech": "Turned on friendly light", + "speech": "Turned on light", } }, "language": hass.config.language, @@ -243,7 +243,7 @@ async def test_http_processing_intent_entity_added( "speech": { "plain": { "extra_data": None, - "speech": "Turned on late added light", + "speech": "Turned on light", } }, "language": hass.config.language, @@ -278,7 +278,7 @@ async def test_http_processing_intent_entity_added( "speech": { "plain": { "extra_data": None, - "speech": "Sorry, I couldn't understand " "that", + "speech": "Sorry, I couldn't understand that", } }, }, @@ -783,7 +783,7 @@ async def test_non_default_response(hass: HomeAssistant, init_components) -> Non ) ) assert len(calls) == 1 - assert result.response.speech["plain"]["speech"] == "Opened front door" + assert result.response.speech["plain"]["speech"] == "Opened" async def test_turn_on_area(hass: HomeAssistant, init_components) -> None: diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py index 17c513fabb..9ea95231b2 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -157,19 +157,19 @@ def test_async_validate_slots() -> None: ) -async def test_cant_turn_on_sensor(hass: HomeAssistant) -> None: +async def test_cant_turn_on_lock(hass: HomeAssistant) -> None: """Test that we can't turn on entities that don't support it.""" assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "conversation", {}) assert await async_setup_component(hass, "intent", {}) - assert await async_setup_component(hass, "sensor", {}) + assert await async_setup_component(hass, "lock", {}) hass.states.async_set( - "sensor.test", "123", attributes={ATTR_FRIENDLY_NAME: "Test Sensor"} + "lock.test", "123", attributes={ATTR_FRIENDLY_NAME: "Test Lock"} ) result = await conversation.async_converse( - hass, "turn on test sensor", None, Context(), None + hass, "turn on test lock", None, Context(), None ) assert result.response.response_type == intent.IntentResponseType.ERROR From 68d4cbb2101f1056e6562f9d398b34a3e484fc71 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 22 Feb 2023 16:54:13 +0100 Subject: [PATCH 0304/2810] Bump reolink-aio to 0.5.0 (#88594) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 836b9a8524..afba72fbf1 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.4.2"] + "requirements": ["reolink-aio==0.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 609b7c2a9d..2a505752bb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2234,7 +2234,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.4.2 +reolink-aio==0.5.0 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 540bfc9f12..f741edcdb7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1582,7 +1582,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.4.2 +reolink-aio==0.5.0 # homeassistant.components.python_script restrictedpython==6.0 From 3afc39dbbfd29f5946abdd7206c0432c3509e7fb Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 22 Feb 2023 17:18:11 +0100 Subject: [PATCH 0305/2810] Set default for `hass_config_yaml` fixture to "" (#88608) Set default for hass_config_yaml fixture to "" --- pylint/plugins/hass_enforce_type_hints.py | 4 ++-- tests/conftest.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 41c718704b..3e390e2810 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -110,8 +110,8 @@ _TEST_FIXTURES: dict[str, list[str] | str] = { "hass_client": "ClientSessionGenerator", "hass_client_no_auth": "ClientSessionGenerator", "hass_config": "ConfigType", - "hass_config_yaml": "str | None", - "hass_config_yaml_files": "dict[str, str] | None", + "hass_config_yaml": "str", + "hass_config_yaml_files": "dict[str, str]", "hass_owner_user": "MockUser", "hass_read_only_access_token": "str", "hass_read_only_user": "MockUser", diff --git a/tests/conftest.py b/tests/conftest.py index 148b8b8e47..9e7e6f1079 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -943,18 +943,18 @@ def mock_hass_config( @pytest.fixture -def hass_config_yaml() -> str | None: +def hass_config_yaml() -> str: """Fixture to parametrize the content of configuration.yaml file. To set yaml content, tests can be marked with: @pytest.mark.parametrize("hass_config_yaml", ["..."]) Add the `mock_hass_config_yaml: None` fixture to the test. """ - return None + return "" @pytest.fixture -def hass_config_yaml_files(hass_config_yaml: str | None) -> dict[str, str] | None: +def hass_config_yaml_files(hass_config_yaml: str) -> dict[str, str]: """Fixture to parametrize multiple yaml configuration files. To set the YAML files to patch, tests can be marked with: @@ -963,12 +963,12 @@ def hass_config_yaml_files(hass_config_yaml: str | None) -> dict[str, str] | Non ) Add the `mock_hass_config_yaml: None` fixture to the test. """ - return None if hass_config_yaml is None else {YAML_CONFIG_FILE: hass_config_yaml} + return {YAML_CONFIG_FILE: hass_config_yaml} @pytest.fixture def mock_hass_config_yaml( - hass: HomeAssistant, hass_config_yaml_files: dict[str, str] | None + hass: HomeAssistant, hass_config_yaml_files: dict[str, str] ) -> Generator[None, None, None]: """Fixture to mock the content of the yaml configuration files. From f7bfdfefde374f6ff1501b52848b1d8db5274df4 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Wed, 22 Feb 2023 16:24:21 +0000 Subject: [PATCH 0306/2810] Diagnostics report for Thread networks (#88541) * Initial diagnostics * Include MLP of local OTBR * Add dep on pyroute2 * Move pyroute2 onto executor * More comments * Read thread data direct from zeroconf cache * Get neighbour cache for known BR's * isort * mypy * Add diagnostic test * rel import * Fix pylint * Restore coverage in discovery.py --- .../components/thread/diagnostics.py | 208 ++++++++++++++++ homeassistant/components/thread/discovery.py | 90 +++++-- homeassistant/components/thread/manifest.json | 2 +- requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/thread/conftest.py | 3 +- .../thread/snapshots/test_diagnostics.ambr | 76 ++++++ tests/components/thread/test_diagnostics.py | 224 ++++++++++++++++++ tests/components/thread/test_discovery.py | 6 + tests/components/thread/test_websocket_api.py | 4 + 10 files changed, 593 insertions(+), 26 deletions(-) create mode 100644 homeassistant/components/thread/diagnostics.py create mode 100644 tests/components/thread/snapshots/test_diagnostics.ambr create mode 100644 tests/components/thread/test_diagnostics.py diff --git a/homeassistant/components/thread/diagnostics.py b/homeassistant/components/thread/diagnostics.py new file mode 100644 index 0000000000..b945f818d0 --- /dev/null +++ b/homeassistant/components/thread/diagnostics.py @@ -0,0 +1,208 @@ +"""Diagnostics support for Thread networks. + +When triaging Matter and HomeKit issues you often need to check for problems with the Thread network. + +This report helps spot and rule out: + +* Is the users border router visible at all? +* Is the border router actually announcing any routes? The user could have a network boundary like + VLANs or WiFi isolation that is blocking the RA packets. +* Alternatively, if user isn't on HAOS they could have accept_ra_rt_info_max_plen set incorrectly. +* Are there any bogus routes that could be interfering. If routes don't expire they can build up. + When you have 10 routes and only 2 border routers something has gone wrong. + +This does not do any connectivity checks. So user could have all their border routers visible, but +some of their thread accessories can't be pinged, but it's still a thread problem. +""" + +from __future__ import annotations + +from typing import 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 +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .dataset_store import async_get_store +from .discovery import async_read_zeroconf_cache + + +class Neighbour(TypedDict): + """A neighbour cache entry (ip neigh).""" + + lladdr: str + state: int + probes: int + + +class Route(TypedDict): + """A route table entry (ip -6 route).""" + + metrics: int + priority: int + is_nexthop: bool + + +class Router(TypedDict): + """A border router.""" + + server: str | None + addresses: list[str] + neighbours: dict[str, Neighbour] + thread_version: str | None + model: str | None + vendor: str | None + routes: dict[str, Route] + + +class Network(TypedDict): + """A thread network.""" + + name: str | None + routers: dict[str, Router] + prefixes: set[str] + unexpected_routers: set[str] + + +def _get_possible_thread_routes() -> ( + 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) + 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, + } + + return 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 + store = await async_get_store(hass) + for record in store.datasets.values(): + if not record.extended_pan_id: + continue + network = networks.setdefault( + record.extended_pan_id, + { + "name": record.network_name, + "routers": {}, + "prefixes": set(), + "unexpected_routers": set(), + }, + ) + if mlp := record.dataset.get(MeshcopTLVType.MESHLOCALPREFIX): + network["prefixes"].add(f"{mlp[0:4]}:{mlp[4:8]}:{mlp[8:12]}:{mlp[12:16]}") + + # 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 + ) + + # 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: + continue + + network = networks.setdefault( + data.extended_pan_id, + { + "name": data.network_name, + "routers": {}, + "prefixes": set(), + "unexpected_routers": set(), + }, + ) + + if not data.server: + continue + + router = network["routers"][data.server] = { + "server": data.server, + "addresses": data.addresses or [], + "neighbours": {}, + "thread_version": data.thread_version, + "model": data.model_name, + "vendor": data.vendor_name, + "routes": {}, + } + + # For every address this border router hass, see if we have seen + # it in the route table as a via - these are the routes its + # announcing via RA + if data.addresses: + for address in data.addresses: + if address in routes: + router["routes"].update(routes[address]) + + if address in neighbours: + router["neighbours"][address] = neighbours[address] + + network["prefixes"].update(router["routes"].keys()) + + # Find unexpected via's. + # Collect all router addresses and then for each prefix, find via's that aren't + # a known router for that prefix. + for network in networks.values(): + routers = set() + + for router in network["routers"].values(): + routers.update(router["addresses"]) + + for prefix in network["prefixes"]: + if prefix not in reverse_routes: + continue + if ghosts := reverse_routes[prefix] - routers: + network["unexpected_routers"] = ghosts + + return { + "networks": networks, + } diff --git a/homeassistant/components/thread/discovery.py b/homeassistant/components/thread/discovery.py index 2001626ca1..d78c546cce 100644 --- a/homeassistant/components/thread/discovery.py +++ b/homeassistant/components/thread/discovery.py @@ -4,9 +4,10 @@ from __future__ import annotations from collections.abc import Callable import dataclasses import logging +from typing import cast -from zeroconf import ServiceListener, Zeroconf -from zeroconf.asyncio import AsyncZeroconf +from zeroconf import BadTypeInNameException, DNSPointer, ServiceListener, Zeroconf +from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf from homeassistant.components import zeroconf from homeassistant.core import HomeAssistant @@ -19,6 +20,8 @@ KNOWN_BRANDS: dict[str | None, str] = { "HomeAssistant": "homeassistant", } THREAD_TYPE = "_meshcop._udp.local." +CLASS_IN = 1 +TYPE_PTR = 12 @dataclasses.dataclass @@ -31,6 +34,65 @@ class ThreadRouterDiscoveryData: network_name: str | None server: str | None vendor_name: str | None + addresses: list[str] | None + thread_version: str | None + + +def async_discovery_data_from_service( + service: AsyncServiceInfo, +) -> ThreadRouterDiscoveryData: + """Get a ThreadRouterDiscoveryData from an AsyncServiceInfo.""" + + def try_decode(value: bytes | None) -> str | None: + """Try decoding UTF-8.""" + if value is None: + return None + try: + return value.decode() + except UnicodeDecodeError: + return None + + ext_pan_id = service.properties.get(b"xp") + network_name = try_decode(service.properties.get(b"nn")) + model_name = try_decode(service.properties.get(b"mn")) + server = service.server + vendor_name = try_decode(service.properties.get(b"vn")) + thread_version = try_decode(service.properties.get(b"tv")) + return ThreadRouterDiscoveryData( + brand=KNOWN_BRANDS.get(vendor_name), + extended_pan_id=ext_pan_id.hex() if ext_pan_id is not None else None, + model_name=model_name, + network_name=network_name, + server=server, + vendor_name=vendor_name, + addresses=service.parsed_addresses(), + thread_version=thread_version, + ) + + +def async_read_zeroconf_cache(aiozc: AsyncZeroconf) -> list[ThreadRouterDiscoveryData]: + """Return all meshcop records already in the zeroconf cache.""" + results = [] + + records = aiozc.zeroconf.cache.async_all_by_details(THREAD_TYPE, TYPE_PTR, CLASS_IN) + for record in records: + record = cast(DNSPointer, record) + + try: + info = AsyncServiceInfo(THREAD_TYPE, record.alias) + except BadTypeInNameException as ex: + _LOGGER.debug( + "Ignoring record with bad type in name: %s: %s", record.alias, ex + ) + continue + + if not info.load_from_cache(aiozc.zeroconf): + # data is not fully in the cache, so ignore for now + continue + + results.append(async_discovery_data_from_service(info)) + + return results class ThreadRouterDiscovery: @@ -83,15 +145,6 @@ class ThreadRouterDiscovery: _LOGGER.debug("_add_update_service failed to add %s, %s", type_, name) return - def try_decode(value: bytes | None) -> str | None: - """Try decoding UTF-8.""" - if value is None: - return None - try: - return value.decode() - except UnicodeDecodeError: - return None - _LOGGER.debug("_add_update_service %s %s", name, service) # We use the extended mac address as key, bail out if it's missing try: @@ -99,19 +152,8 @@ class ThreadRouterDiscovery: except (KeyError, UnicodeDecodeError) as err: _LOGGER.debug("_add_update_service failed to parse service %s", err) return - ext_pan_id = service.properties.get(b"xp") - network_name = try_decode(service.properties.get(b"nn")) - model_name = try_decode(service.properties.get(b"mn")) - server = service.server - vendor_name = try_decode(service.properties.get(b"vn")) - data = ThreadRouterDiscoveryData( - brand=KNOWN_BRANDS.get(vendor_name), - extended_pan_id=ext_pan_id.hex() if ext_pan_id is not None else None, - model_name=model_name, - network_name=network_name, - server=server, - vendor_name=vendor_name, - ) + + data = async_discovery_data_from_service(service) if name in self._known_routers and self._known_routers[name] == ( extended_mac_address, data, diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index 144a10b878..89b5aa3baa 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"], + "requirements": ["python-otbr-api==1.0.3", "pyroute2==0.7.5"], "zeroconf": ["_meshcop._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 2a505752bb..25042506a1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1916,6 +1916,9 @@ pyrisco==0.5.7 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.6 +# homeassistant.components.thread +pyroute2==0.7.5 + # homeassistant.components.ruckus_unleashed pyruckus==0.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f741edcdb7..ffa1135173 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1384,6 +1384,9 @@ pyrisco==0.5.7 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.6 +# homeassistant.components.thread +pyroute2==0.7.5 + # homeassistant.components.ruckus_unleashed pyruckus==0.16 diff --git a/tests/components/thread/conftest.py b/tests/components/thread/conftest.py index a02b18e9ea..2b0f00a097 100644 --- a/tests/components/thread/conftest.py +++ b/tests/components/thread/conftest.py @@ -3,6 +3,7 @@ import pytest from homeassistant.components import thread +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -10,7 +11,7 @@ CONFIG_ENTRY_DATA = {} @pytest.fixture(name="thread_config_entry") -async def thread_config_entry_fixture(hass): +async def thread_config_entry_fixture(hass: HomeAssistant): """Mock Thread config entry.""" config_entry = MockConfigEntry( data=CONFIG_ENTRY_DATA, diff --git a/tests/components/thread/snapshots/test_diagnostics.ambr b/tests/components/thread/snapshots/test_diagnostics.ambr new file mode 100644 index 0000000000..8f3e922561 --- /dev/null +++ b/tests/components/thread/snapshots/test_diagnostics.ambr @@ -0,0 +1,76 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'networks': dict({ + '1111111122222222': dict({ + 'name': 'OpenThreadDemo', + 'prefixes': list([ + 'fdad:70bf:e5aa:15dd', + ]), + 'routers': dict({ + }), + 'unexpected_routers': list([ + ]), + }), + 'c3a60fc387c381c286212cc3a5': dict({ + 'name': 'OpenThread HC', + 'prefixes': list([ + 'fd59:86c6:e5a5::', + ]), + 'routers': dict({ + 'HomeAssistant OpenThreadBorderRouter #0BBF._meshcop._udp.local.': dict({ + 'addresses': list([ + '127.0.0.1', + 'fe80::10ed:6406:4ee9:85e5', + ]), + 'model': 'OpenThreadBorderRouter', + 'neighbours': dict({ + 'fe80::10ed:6406:4ee9:85e5': dict({ + 'lladdr': '00:00:00:00:00:00', + 'probes': 64, + 'state': 64, + }), + }), + 'routes': dict({ + 'fd59:86c6:e5a5::': dict({ + 'is_nexthop': False, + 'metrics': 100, + 'priority': 100, + }), + }), + 'server': 'HomeAssistant OpenThreadBorderRouter #0BBF._meshcop._udp.local.', + 'thread_version': '1.3.0', + 'vendor': 'HomeAssistant', + }), + 'HomePod._meshcop._udp.local.': dict({ + 'addresses': list([ + '127.0.0.1', + 'fe80::10ed:6406:4ee9:85e4', + ]), + 'model': None, + 'neighbours': dict({ + 'fe80::10ed:6406:4ee9:85e4': dict({ + 'lladdr': '00:00:00:00:00:00', + 'probes': 64, + 'state': 64, + }), + }), + 'routes': dict({ + 'fd59:86c6:e5a5::': dict({ + 'is_nexthop': True, + 'metrics': 100, + 'priority': 100, + }), + }), + 'server': 'HomePod._meshcop._udp.local.', + 'thread_version': '1.2.0', + 'vendor': 'Apple', + }), + }), + 'unexpected_routers': list([ + 'fe80::10ed:6406:4ee9:85e3', + ]), + }), + }), + }) +# --- diff --git a/tests/components/thread/test_diagnostics.py b/tests/components/thread/test_diagnostics.py new file mode 100644 index 0000000000..1006fa374c --- /dev/null +++ b/tests/components/thread/test_diagnostics.py @@ -0,0 +1,224 @@ +"""Test the thread websocket API.""" + +import dataclasses +import time +from unittest.mock import Mock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from zeroconf import DNSCache, ServiceInfo + +from homeassistant.components.thread import dataset_store +from homeassistant.components.thread.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import DATASET_1 + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + +TEST_ZEROCONF_RECORD_1 = ServiceInfo( + type_="_meshcop._udp.local.", + name="HomeAssistant OpenThreadBorderRouter #0BBF._meshcop._udp.local.", + addresses=["127.0.0.1", "fe80::10ed:6406:4ee9:85e5"], + port=8080, + properties={ + "rv": "1", + "vn": "HomeAssistant", + "mn": "OpenThreadBorderRouter", + "nn": "OpenThread HC", + "xp": "\xe6\x0f\xc7\xc1\x86!,\xe5", + "tv": "1.3.0", + "xa": "\xae\xeb/YKW\x0b\xbf", + "sb": "\x00\x00\x01\xb1", + "at": "\x00\x00\x00\x00\x00\x01\x00\x00", + "pt": "\x8f\x06Q~", + "sq": "3", + "bb": "\xf0\xbf", + "dn": "DefaultDomain", + }, +) + +TEST_ZEROCONF_RECORD_2 = ServiceInfo( + type_="_meshcop._udp.local.", + name="HomePod._meshcop._udp.local.", + addresses=["127.0.0.1", "fe80::10ed:6406:4ee9:85e4"], + port=8080, + properties={ + "rv": "1", + "vn": "Apple", + "nn": "OpenThread HC", + "xp": "\xe6\x0f\xc7\xc1\x86!,\xe5", + "tv": "1.2.0", + "xa": "\xae\xeb/YKW\x0b\xbf", + "sb": "\x00\x00\x01\xb1", + "at": "\x00\x00\x00\x00\x00\x01\x00\x00", + "pt": "\x8f\x06Q~", + "sq": "3", + "bb": "\xf0\xbf", + "dn": "DefaultDomain", + }, +) + + +TEST_ZEROCONF_RECORD_3 = ServiceInfo( + type_="_meshcop._udp.local.", + name="office._meshcop._udp.local.", + addresses=["127.0.0.1", "fe80::10ed:6406:4ee9:85e0"], + port=8080, + properties={ + "rv": "1", + "vn": "Apple", + "nn": "OpenThread HC", + "xp": "\xe6\x0f\xc7\xc1\x86!,\xe5", + "tv": "1.2.0", + "xa": "\xae\xeb/YKW\x0b\xbf", + "sb": "\x00\x00\x01\xb1", + "at": "\x00\x00\x00\x00\x00\x01\x00\x00", + "pt": "\x8f\x06Q~", + "sq": "3", + "bb": "\xf0\xbf", + "dn": "DefaultDomain", + }, +) + +TEST_ZEROCONF_RECORD_4 = ServiceInfo( + type_="_meshcop._udp.local.", + name="office._meshcop._udp.local.", + addresses=["127.0.0.1", "fe80::10ed:6406:4ee9:85e0"], + port=8080, + properties={ + "rv": "1", + "vn": "Apple", + "nn": "OpenThread HC", + "xp": "\xe6\x0f\xc7\xc1\x86!,\xe5", + "tv": "1.2.0", + "xa": "\xae\xeb/YKW\x0b\xbf", + "sb": "\x00\x00\x01\xb1", + "at": "\x00\x00\x00\x00\x00\x01\x00\x00", + "pt": "\x8f\x06Q~", + "sq": "3", + "bb": "\xf0\xbf", + "dn": "DefaultDomain", + }, +) +# Make sure this generates an invalid DNSPointer +TEST_ZEROCONF_RECORD_4.name = "office._meshcop._udp.lo\x00cal." + + +@dataclasses.dataclass +class MockRoute: + """A mock iproute2 route table entry.""" + + dst: str + gateway: str | None = None + nh_gateway: str | None = None + metrics: int = 100 + priority: int = 100 + family: int = 10 + dst_len: int = 64 + + +@dataclasses.dataclass +class MockNeighbour: + """A mock iproute2 neighbour cache entry.""" + + dst: str + lladdr: str = "00:00:00:00:00:00" + state: int = 64 + probes: int = 64 + + +@pytest.fixture +def ndb() -> Mock: + """Prevent NDB poking the OS route tables.""" + with patch( + "homeassistant.components.thread.diagnostics.NDB" + ) as ndb, ndb() as instance: + instance.neighbours = [] + instance.routes = [] + yield instance + + +async def test_diagnostics( + hass: HomeAssistant, + mock_async_zeroconf: None, + ndb: Mock, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for thread routers.""" + cache = mock_async_zeroconf.zeroconf.cache = DNSCache() + + now = time.monotonic() * 1000 + cache.async_add_records( + [ + *TEST_ZEROCONF_RECORD_1.dns_addresses(created=now), + TEST_ZEROCONF_RECORD_1.dns_service(created=now), + TEST_ZEROCONF_RECORD_1.dns_text(created=now), + TEST_ZEROCONF_RECORD_1.dns_pointer(created=now), + ] + ) + cache.async_add_records( + [ + *TEST_ZEROCONF_RECORD_2.dns_addresses(created=now), + TEST_ZEROCONF_RECORD_2.dns_service(created=now), + TEST_ZEROCONF_RECORD_2.dns_text(created=now), + TEST_ZEROCONF_RECORD_2.dns_pointer(created=now), + ] + ) + # Test for invalid cache + cache.async_add_records([TEST_ZEROCONF_RECORD_3.dns_pointer(created=now)]) + # Test for invalid record + cache.async_add_records( + [ + *TEST_ZEROCONF_RECORD_4.dns_addresses(created=now), + TEST_ZEROCONF_RECORD_4.dns_service(created=now), + TEST_ZEROCONF_RECORD_4.dns_text(created=now), + TEST_ZEROCONF_RECORD_4.dns_pointer(created=now), + ] + ) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + + await dataset_store.async_add_dataset(hass, "source", DATASET_1) + + ndb.neighbours.append( + MockNeighbour( + dst="fe80::10ed:6406:4ee9:85e5", + ) + ) + ndb.neighbours.append( + MockNeighbour( + dst="fe80::10ed:6406:4ee9:85e4", + ) + ) + + ndb.routes.append( + MockRoute( + dst="fd59:86c6:e5a5::", + gateway="fe80::10ed:6406:4ee9:85e5", + ) + ) + + ndb.routes.append( + MockRoute( + dst="fd59:86c6:e5a5::", + nh_gateway="fe80::10ed:6406:4ee9:85e4", + ) + ) + + # Add a "ghost" route - we don't know a border router on 85e3 + ndb.routes.append( + MockRoute( + dst="fd59:86c6:e5a5::", + nh_gateway="fe80::10ed:6406:4ee9:85e3", + ) + ) + + diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + assert diag == snapshot diff --git a/tests/components/thread/test_discovery.py b/tests/components/thread/test_discovery.py index 4996a40f58..fc19b3f10a 100644 --- a/tests/components/thread/test_discovery.py +++ b/tests/components/thread/test_discovery.py @@ -73,6 +73,8 @@ async def test_discover_routers(hass: HomeAssistant, mock_async_zeroconf: None) network_name="OpenThread HC", server="core-silabs-multiprotocol.local.", vendor_name="HomeAssistant", + thread_version="1.3.0", + addresses=["192.168.0.115"], ), ) @@ -95,6 +97,8 @@ async def test_discover_routers(hass: HomeAssistant, mock_async_zeroconf: None) network_name="NEST-PAN-E1AF", server="2d99f293-cd8e-2770-8dd2-6675de9fa000.local.", vendor_name="Google Inc.", + thread_version="1.3.0", + addresses=["192.168.0.124"], ), ) @@ -163,6 +167,8 @@ async def test_discover_routers_bad_data( network_name="OpenThread HC", server="core-silabs-multiprotocol.local.", vendor_name=None, + thread_version="1.3.0", + addresses=["192.168.0.115"], ), ) diff --git a/tests/components/thread/test_websocket_api.py b/tests/components/thread/test_websocket_api.py index 20471d1dcf..2ebeef92c5 100644 --- a/tests/components/thread/test_websocket_api.py +++ b/tests/components/thread/test_websocket_api.py @@ -240,6 +240,8 @@ async def test_discover_routers( "network_name": "OpenThread HC", "server": "core-silabs-multiprotocol.local.", "vendor_name": "HomeAssistant", + "addresses": ["192.168.0.115"], + "thread_version": "1.3.0", }, "key": "aeeb2f594b570bbf", "type": "router_discovered", @@ -265,6 +267,8 @@ async def test_discover_routers( "network_name": "NEST-PAN-E1AF", "server": "2d99f293-cd8e-2770-8dd2-6675de9fa000.local.", "vendor_name": "Google Inc.", + "thread_version": "1.3.0", + "addresses": ["192.168.0.124"], }, "key": "f6a99b425a67abed", "type": "router_discovered", From 5683d21931ecb3568cd36b98586c19f5e8bb8507 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 22 Feb 2023 11:51:40 -0500 Subject: [PATCH 0307/2810] Add new zwave_js WS command to parse DSK from QR code (#87237) * Add new zwave_js WS command to parse DSK from QR code * remove minimum character check since it is not needed in this case --- homeassistant/components/zwave_js/api.py | 34 +++++++++++- tests/components/zwave_js/test_api.py | 69 ++++++++++++++++++++++-- 2 files changed, 99 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 2f23d06b38..defb7ab004 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -43,7 +43,10 @@ from zwave_js_server.model.node.firmware import ( NodeFirmwareUpdateProgress, NodeFirmwareUpdateResult, ) -from zwave_js_server.model.utils import async_parse_qr_code_string +from zwave_js_server.model.utils import ( + async_parse_qr_code_string, + async_try_parse_dsk_from_qr_code_string, +) from zwave_js_server.util.node import async_set_config_parameter from homeassistant.components import websocket_api @@ -396,6 +399,9 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_unprovision_smart_start_node) websocket_api.async_register_command(hass, websocket_get_provisioning_entries) websocket_api.async_register_command(hass, websocket_parse_qr_code_string) + websocket_api.async_register_command( + hass, websocket_try_parse_dsk_from_qr_code_string + ) websocket_api.async_register_command(hass, websocket_supports_feature) websocket_api.async_register_command(hass, websocket_stop_inclusion) websocket_api.async_register_command(hass, websocket_stop_exclusion) @@ -980,6 +986,32 @@ async def websocket_parse_qr_code_string( connection.send_result(msg[ID], dataclasses.asdict(qr_provisioning_information)) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/try_parse_dsk_from_qr_code_string", + vol.Required(ENTRY_ID): str, + vol.Required(QR_CODE_STRING): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_entry +async def websocket_try_parse_dsk_from_qr_code_string( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + entry: ConfigEntry, + client: Client, + driver: Driver, +) -> None: + """Try to parse a DSK string from a QR code.""" + connection.send_result( + msg[ID], + await async_try_parse_dsk_from_qr_code_string(client, msg[QR_CODE_STRING]), + ) + + @websocket_api.require_admin @websocket_api.websocket_command( { diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 8b34b3548f..8003b4524e 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -1431,9 +1431,72 @@ async def test_parse_qr_code_string( assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_supports_feature( - hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator -) -> None: +async def test_try_parse_dsk_from_qr_code_string( + hass, integration, client, hass_ws_client +): + """Test try_parse_dsk_from_qr_code_string websocket command.""" + entry = integration + ws_client = await hass_ws_client(hass) + + client.async_send_command.return_value = {"dsk": "a"} + + await ws_client.send_json( + { + ID: 1, + TYPE: "zwave_js/try_parse_dsk_from_qr_code_string", + ENTRY_ID: entry.entry_id, + QR_CODE_STRING: "90testtesttesttesttesttesttesttesttesttesttesttesttest", + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + assert msg["result"] == "a" + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "utils.try_parse_dsk_from_qr_code_string", + "qr": "90testtesttesttesttesttesttesttesttesttesttesttesttest", + } + + # Test FailedZWaveCommand is caught + with patch( + "homeassistant.components.zwave_js.api.async_try_parse_dsk_from_qr_code_string", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 6, + TYPE: "zwave_js/try_parse_dsk_from_qr_code_string", + ENTRY_ID: entry.entry_id, + QR_CODE_STRING: "90testtesttesttesttesttesttesttesttesttesttesttesttest", + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "Z-Wave error 1: error message" + + # Test sending command with not loaded entry fails + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json( + { + ID: 7, + TYPE: "zwave_js/try_parse_dsk_from_qr_code_string", + ENTRY_ID: entry.entry_id, + QR_CODE_STRING: "90testtesttesttesttesttesttesttesttesttesttesttesttest", + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED + + +async def test_supports_feature(hass, integration, client, hass_ws_client): """Test supports_feature websocket command.""" entry = integration ws_client = await hass_ws_client(hass) From 1f9f6ab1f022e83c0d17d84562955d7388a850f0 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 22 Feb 2023 11:52:00 -0500 Subject: [PATCH 0308/2810] Update zwave_js FirmwareUploadView to support controller updates (#87239) * Update zwave_js FirmwareUploadView to support controller updates * Add coverage * Change None check to assertion --- homeassistant/components/zwave_js/api.py | 46 +++++++++++++------ tests/components/zwave_js/test_api.py | 58 ++++++++++++++++++++++-- 2 files changed, 85 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index defb7ab004..c3358b6cde 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -27,13 +27,14 @@ from zwave_js_server.exceptions import ( NotFoundError, SetValueFailed, ) -from zwave_js_server.firmware import update_firmware +from zwave_js_server.firmware import controller_firmware_update_otw, update_firmware from zwave_js_server.model.controller import ( ControllerStatistics, InclusionGrant, ProvisioningEntry, QRProvisioningInformation, ) +from zwave_js_server.model.controller.firmware import ControllerFirmwareUpdateData from zwave_js_server.model.driver import Driver from zwave_js_server.model.log_config import LogConfig from zwave_js_server.model.log_message import LogMessage @@ -445,7 +446,7 @@ def async_register_api(hass: HomeAssistant) -> None: hass, websocket_subscribe_controller_statistics ) websocket_api.async_register_command(hass, websocket_subscribe_node_statistics) - hass.http.register_view(FirmwareUploadView()) + hass.http.register_view(FirmwareUploadView(dr.async_get(hass))) @websocket_api.require_admin @@ -2071,10 +2072,10 @@ class FirmwareUploadView(HomeAssistantView): url = r"/api/zwave_js/firmware/upload/{device_id}" name = "api:zwave_js:firmware:upload" - def __init__(self) -> None: + def __init__(self, dev_reg: dr.DeviceRegistry) -> None: """Initialize view.""" super().__init__() - self._dev_reg: dr.DeviceRegistry | None = None + self._dev_reg = dev_reg async def post(self, request: web.Request, device_id: str) -> web.Response: """Handle upload.""" @@ -2083,12 +2084,16 @@ class FirmwareUploadView(HomeAssistantView): hass = request.app["hass"] try: - node = async_get_node_from_device_id(hass, device_id) + node = async_get_node_from_device_id(hass, device_id, self._dev_reg) except ValueError as err: if "not loaded" in err.args[0]: raise web_exceptions.HTTPBadRequest raise web_exceptions.HTTPNotFound + # If this was not true, we wouldn't have been able to get the node from the + # device ID above + assert node.client.driver + # Increase max payload request._client_max_size = 1024 * 1024 * 10 # pylint: disable=protected-access @@ -2100,18 +2105,29 @@ class FirmwareUploadView(HomeAssistantView): uploaded_file: web_request.FileField = data["file"] try: - await update_firmware( - node.client.ws_server_url, - node, - [ - NodeFirmwareUpdateData( + if node.client.driver.controller.own_node == node: + await controller_firmware_update_otw( + node.client.ws_server_url, + ControllerFirmwareUpdateData( uploaded_file.filename, await hass.async_add_executor_job(uploaded_file.file.read), - ) - ], - async_get_clientsession(hass), - additional_user_agent_components=USER_AGENT, - ) + ), + async_get_clientsession(hass), + additional_user_agent_components=USER_AGENT, + ) + else: + await update_firmware( + node.client.ws_server_url, + node, + [ + NodeFirmwareUpdateData( + uploaded_file.filename, + await hass.async_add_executor_job(uploaded_file.file.read), + ) + ], + async_get_clientsession(hass), + additional_user_agent_components=USER_AGENT, + ) except BaseZwaveJSServerError as err: raise web_exceptions.HTTPBadRequest(reason=str(err)) from err diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 8003b4524e..be8161ff65 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -28,6 +28,7 @@ from zwave_js_server.model.controller import ( ProvisioningEntry, QRProvisioningInformation, ) +from zwave_js_server.model.controller.firmware import ControllerFirmwareUpdateData from zwave_js_server.model.node import Node from zwave_js_server.model.node.firmware import NodeFirmwareUpdateData @@ -84,7 +85,7 @@ from tests.common import MockUser from tests.typing import ClientSessionGenerator, WebSocketGenerator -def get_device(hass, node): +def get_device(hass: HomeAssistant, node): """Get device ID for a node.""" dev_reg = dr.async_get(hass) device_id = get_device_id(node.client.driver, node) @@ -2968,7 +2969,9 @@ async def test_firmware_upload_view( device = get_device(hass, multisensor_6) with patch( "homeassistant.components.zwave_js.api.update_firmware", - ) as mock_cmd, patch.dict( + ) as mock_node_cmd, patch( + "homeassistant.components.zwave_js.api.controller_firmware_update_otw", + ) as mock_controller_cmd, patch.dict( "homeassistant.components.zwave_js.api.USER_AGENT", {"HomeAssistant": "0.0.0"}, ): @@ -2976,11 +2979,40 @@ async def test_firmware_upload_view( f"/api/zwave_js/firmware/upload/{device.id}", data={"file": firmware_file}, ) - assert mock_cmd.call_args[0][1:3] == ( + mock_controller_cmd.assert_not_called() + assert mock_node_cmd.call_args[0][1:3] == ( multisensor_6, [NodeFirmwareUpdateData("file", bytes(10))], ) - assert mock_cmd.call_args[1] == { + assert mock_node_cmd.call_args[1] == { + "additional_user_agent_components": {"HomeAssistant": "0.0.0"}, + } + assert json.loads(await resp.text()) is None + + +async def test_firmware_upload_view_controller( + hass, client, integration, hass_client: ClientSessionGenerator, firmware_file +) -> None: + """Test the HTTP firmware upload view for a controller.""" + hass_client = await hass_client() + device = get_device(hass, client.driver.controller.nodes[1]) + with patch( + "homeassistant.components.zwave_js.api.update_firmware", + ) as mock_node_cmd, patch( + "homeassistant.components.zwave_js.api.controller_firmware_update_otw", + ) as mock_controller_cmd, patch.dict( + "homeassistant.components.zwave_js.api.USER_AGENT", + {"HomeAssistant": "0.0.0"}, + ): + resp = await hass_client.post( + f"/api/zwave_js/firmware/upload/{device.id}", + data={"file": firmware_file}, + ) + mock_node_cmd.assert_not_called() + assert mock_controller_cmd.call_args[0][1:2] == ( + ControllerFirmwareUpdateData("file", bytes(10)), + ) + assert mock_controller_cmd.call_args[1] == { "additional_user_agent_components": {"HomeAssistant": "0.0.0"}, } assert json.loads(await resp.text()) is None @@ -3020,6 +3052,24 @@ async def test_firmware_upload_view_invalid_payload( assert resp.status == HTTPStatus.BAD_REQUEST +async def test_firmware_upload_view_no_driver( + hass: HomeAssistant, + client, + multisensor_6, + integration, + hass_client: ClientSessionGenerator, +) -> None: + """Test the HTTP firmware upload view when the driver doesn't exist.""" + device = get_device(hass, multisensor_6) + client.driver = None + aiohttp_client = await hass_client() + resp = await aiohttp_client.post( + f"/api/zwave_js/firmware/upload/{device.id}", + data={"wrong_key": bytes(10)}, + ) + assert resp.status == HTTPStatus.NOT_FOUND + + @pytest.mark.parametrize( ("method", "url"), [("post", "/api/zwave_js/firmware/upload/{}")], From cab8a59be48a027e2da9ed96aa782f7018d91b59 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 22 Feb 2023 11:53:22 -0500 Subject: [PATCH 0309/2810] Add dsk option to zwave_js/add_node WS command (#87823) * Add dsk option to zwave_js/add_node WS command * Bump zwave-js-server-python to 0.46.0 (#88520) * fix tests --- homeassistant/components/zwave_js/api.py | 3 ++ tests/components/zwave_js/test_api.py | 36 ++++++++++++++++++++---- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index c3358b6cde..4051c1f8f7 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -654,6 +654,7 @@ async def websocket_node_comments( QR_PROVISIONING_INFORMATION, "options" ): QR_PROVISIONING_INFORMATION_SCHEMA, vol.Exclusive(QR_CODE_STRING, "options"): QR_CODE_STRING_SCHEMA, + vol.Exclusive(DSK, "options"): str, } ) @websocket_api.async_response @@ -676,6 +677,7 @@ async def websocket_add_node( or msg.get(QR_PROVISIONING_INFORMATION) or msg.get(QR_CODE_STRING) ) + dsk = msg.get(DSK) @callback def async_cleanup() -> None: @@ -772,6 +774,7 @@ async def websocket_add_node( INCLUSION_STRATEGY_NOT_SMART_START[inclusion_strategy.value], force_security=force_security, provisioning=provisioning, + dsk=dsk, ) except ValueError as err: connection.send_error( diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index be8161ff65..9de99be4c0 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -780,13 +780,39 @@ async def test_add_node( client.async_send_command.reset_mock() client.async_send_command.return_value = {"success": True} - # Test Smart Start QR provisioning information with S2 inclusion strategy fails + # Test S2 DSK string string await ws_client.send_json( { ID: 6, TYPE: "zwave_js/add_node", ENTRY_ID: entry.entry_id, INCLUSION_STRATEGY: InclusionStrategy.SECURITY_S2.value, + DSK: "test_dsk", + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "controller.begin_inclusion", + "options": { + "strategy": InclusionStrategy.SECURITY_S2, + "dsk": "test_dsk", + }, + } + + client.async_send_command.reset_mock() + client.async_send_command.return_value = {"success": True} + + # Test Smart Start QR provisioning information with S2 inclusion strategy fails + await ws_client.send_json( + { + ID: 7, + TYPE: "zwave_js/add_node", + ENTRY_ID: entry.entry_id, + INCLUSION_STRATEGY: InclusionStrategy.SECURITY_S2.value, QR_PROVISIONING_INFORMATION: { VERSION: 1, SECURITY_CLASSES: [0], @@ -813,7 +839,7 @@ async def test_add_node( # Test QR provisioning information with S0 inclusion strategy fails await ws_client.send_json( { - ID: 7, + ID: 8, TYPE: "zwave_js/add_node", ENTRY_ID: entry.entry_id, INCLUSION_STRATEGY: InclusionStrategy.SECURITY_S0, @@ -843,7 +869,7 @@ async def test_add_node( # Test ValueError is caught as failure await ws_client.send_json( { - ID: 8, + ID: 9, TYPE: "zwave_js/add_node", ENTRY_ID: entry.entry_id, INCLUSION_STRATEGY: InclusionStrategy.DEFAULT.value, @@ -863,7 +889,7 @@ async def test_add_node( ): await ws_client.send_json( { - ID: 9, + ID: 10, TYPE: "zwave_js/add_node", ENTRY_ID: entry.entry_id, } @@ -879,7 +905,7 @@ async def test_add_node( await hass.async_block_till_done() await ws_client.send_json( - {ID: 10, TYPE: "zwave_js/add_node", ENTRY_ID: entry.entry_id} + {ID: 11, TYPE: "zwave_js/add_node", ENTRY_ID: entry.entry_id} ) msg = await ws_client.receive_json() From 1f34fa63ab7481b7b93d9e7f14d1c7dd7c90a7ae Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 22 Feb 2023 17:59:52 +0100 Subject: [PATCH 0310/2810] Always include platform in `config/entity_registry/list_for_display` (#88601) Always include platform in config/entity_registry/list_for_display --- homeassistant/helpers/entity_registry.py | 8 +------- tests/components/config/test_entity_registry.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 78ccdcd12f..4c192d916c 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -173,12 +173,10 @@ class RegistryEntry: This version only includes what's needed for display. Returns None if there's no data needed for display. """ - display_dict: dict[str, Any] = {} + display_dict: dict[str, Any] = {"ei": self.entity_id, "pl": self.platform} for key, attr_name in DISLAY_DICT_OPTIONAL: if (attr_val := getattr(self, attr_name)) is not None: display_dict[key] = attr_val - if "tk" in display_dict: - display_dict["pl"] = self.platform if (category := self.entity_category) is not None: display_dict["ec"] = ENTITY_CATEGORY_VALUE_TO_INDEX[category] if self.hidden_by is not None: @@ -192,10 +190,6 @@ class RegistryEntry: precision := sensor_options.get("suggested_display_precision") ) is not None: display_dict["dp"] = precision - if not display_dict: - # We didn't gather any data needed for display - return None - display_dict["ei"] = self.entity_id return display_dict @property diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py index 66f1567ddb..9cad68c9c9 100644 --- a/tests/components/config/test_entity_registry.py +++ b/tests/components/config/test_entity_registry.py @@ -239,23 +239,32 @@ async def test_list_entities_for_display( "di": "device123", "ei": "test_domain.nameless", "en": None, + "pl": "test_platform", }, { "ai": "area52", "di": "device123", "ei": "test_domain.renamed", + "pl": "test_platform", + }, + { + "ei": "test_domain.boring", + "pl": "test_platform", }, { "ei": "test_domain.hidden", "hb": True, + "pl": "test_platform", }, { "dp": 0, "ei": "sensor.default_precision", + "pl": "test_platform", }, { "dp": 0, "ei": "sensor.user_precision", + "pl": "test_platform", }, ], } @@ -296,6 +305,7 @@ async def test_list_entities_for_display( "di": "device123", "ei": "test_domain.test", "en": "Hello World", + "pl": "test_platform", }, ], } From 1cf5b7c9f758eadb0a5266538b2b0d184999834d Mon Sep 17 00:00:00 2001 From: Quentame Date: Wed, 22 Feb 2023 18:02:34 +0100 Subject: [PATCH 0311/2810] Bump Freebox to 1.1.0 (#88609) --- homeassistant/components/freebox/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/freebox/manifest.json b/homeassistant/components/freebox/manifest.json index 35760e7cbf..637f7050bf 100644 --- a/homeassistant/components/freebox/manifest.json +++ b/homeassistant/components/freebox/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/freebox", "iot_class": "local_polling", "loggers": ["freebox_api"], - "requirements": ["freebox-api==1.0.1"], + "requirements": ["freebox-api==1.1.0"], "zeroconf": ["_fbx-api._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 25042506a1..c8e9c08e18 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -741,7 +741,7 @@ forecast_solar==2.2.0 fortiosapi==1.0.5 # homeassistant.components.freebox -freebox-api==1.0.1 +freebox-api==1.1.0 # homeassistant.components.free_mobile freesms==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ffa1135173..323f57da42 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -563,7 +563,7 @@ foobot_async==1.0.0 forecast_solar==2.2.0 # homeassistant.components.freebox -freebox-api==1.0.1 +freebox-api==1.1.0 # homeassistant.components.fritz # homeassistant.components.fritzbox_callmonitor From 05a7df5629f93dd21dff26b5a85ec3628d5cbcd8 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 22 Feb 2023 12:08:57 -0500 Subject: [PATCH 0312/2810] Add controller support to `zwave_js/subscribe_firmware_update_status` (#87348) --- homeassistant/components/zwave_js/api.py | 93 +++++++++++++++--- tests/components/zwave_js/test_api.py | 115 +++++++++++++++++++++++ 2 files changed, 195 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 4051c1f8f7..2612d2d4f6 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -34,7 +34,11 @@ from zwave_js_server.model.controller import ( ProvisioningEntry, QRProvisioningInformation, ) -from zwave_js_server.model.controller.firmware import ControllerFirmwareUpdateData +from zwave_js_server.model.controller.firmware import ( + ControllerFirmwareUpdateData, + ControllerFirmwareUpdateProgress, + ControllerFirmwareUpdateResult, +) from zwave_js_server.model.driver import Driver from zwave_js_server.model.log_config import LogConfig from zwave_js_server.model.log_message import LogMessage @@ -1939,10 +1943,10 @@ async def websocket_is_node_firmware_update_in_progress( connection.send_result(msg[ID], await node.async_is_firmware_update_in_progress()) -def _get_firmware_update_progress_dict( +def _get_node_firmware_update_progress_dict( progress: NodeFirmwareUpdateProgress, ) -> dict[str, int | float]: - """Get a dictionary of firmware update progress.""" + """Get a dictionary of a node's firmware update progress.""" return { "current_file": progress.current_file, "total_files": progress.total_files, @@ -1952,6 +1956,19 @@ def _get_firmware_update_progress_dict( } +def _get_controller_firmware_update_progress_dict( + progress: ControllerFirmwareUpdateProgress, +) -> dict[str, int | float]: + """Get a dictionary of a controller's firmware update progress.""" + return { + "current_file": 1, + "total_files": 1, + "sent_fragments": progress.sent_fragments, + "total_fragments": progress.total_fragments, + "progress": progress.progress, + } + + @websocket_api.require_admin @websocket_api.websocket_command( { @@ -1968,6 +1985,8 @@ async def websocket_subscribe_firmware_update_status( node: Node, ) -> None: """Subscribe to the status of a firmware update.""" + assert node.client.driver + controller = node.client.driver.controller @callback def async_cleanup() -> None: @@ -1976,20 +1995,20 @@ async def websocket_subscribe_firmware_update_status( unsub() @callback - def forward_progress(event: dict) -> None: + def forward_node_progress(event: dict) -> None: progress: NodeFirmwareUpdateProgress = event["firmware_update_progress"] connection.send_message( websocket_api.event_message( msg[ID], { "event": event["event"], - **_get_firmware_update_progress_dict(progress), + **_get_node_firmware_update_progress_dict(progress), }, ) ) @callback - def forward_finished(event: dict) -> None: + def forward_node_finished(event: dict) -> None: finished: NodeFirmwareUpdateResult = event["firmware_update_finished"] connection.send_message( websocket_api.event_message( @@ -2004,21 +2023,69 @@ async def websocket_subscribe_firmware_update_status( ) ) - msg[DATA_UNSUBSCRIBE] = unsubs = [ - node.on("firmware update progress", forward_progress), - node.on("firmware update finished", forward_finished), - ] + @callback + def forward_controller_progress(event: dict) -> None: + progress: ControllerFirmwareUpdateProgress = event["firmware_update_progress"] + connection.send_message( + websocket_api.event_message( + msg[ID], + { + "event": event["event"], + **_get_controller_firmware_update_progress_dict(progress), + }, + ) + ) + + @callback + def forward_controller_finished(event: dict) -> None: + finished: ControllerFirmwareUpdateResult = event["firmware_update_finished"] + connection.send_message( + websocket_api.event_message( + msg[ID], + { + "event": event["event"], + "status": finished.status, + "success": finished.success, + }, + ) + ) + + if controller.own_node == node: + msg[DATA_UNSUBSCRIBE] = unsubs = [ + controller.on("firmware update progress", forward_controller_progress), + controller.on("firmware update finished", forward_controller_finished), + ] + else: + msg[DATA_UNSUBSCRIBE] = unsubs = [ + node.on("firmware update progress", forward_node_progress), + node.on("firmware update finished", forward_node_finished), + ] connection.subscriptions[msg["id"]] = async_cleanup - progress = node.firmware_update_progress connection.send_result(msg[ID]) - if progress: + if node.is_controller_node and ( + controller_progress := controller.firmware_update_progress + ): connection.send_message( websocket_api.event_message( msg[ID], { "event": "firmware update progress", - **_get_firmware_update_progress_dict(progress), + **_get_controller_firmware_update_progress_dict( + controller_progress + ), + }, + ) + ) + elif controller.own_node != node and ( + node_progress := node.firmware_update_progress + ): + connection.send_message( + websocket_api.event_message( + msg[ID], + { + "event": "firmware update progress", + **_get_node_firmware_update_progress_dict(node_progress), }, ) ) diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 9de99be4c0..f988e72e70 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -3872,6 +3872,121 @@ async def test_subscribe_firmware_update_status_initial_value( } +async def test_subscribe_controller_firmware_update_status( + hass, integration, client, hass_ws_client +): + """Test the subscribe_firmware_update_status websocket command for a node.""" + ws_client = await hass_ws_client(hass) + device = get_device(hass, client.driver.controller.nodes[1]) + + client.async_send_command_no_wait.return_value = {} + + await ws_client.send_json( + { + ID: 1, + TYPE: "zwave_js/subscribe_firmware_update_status", + DEVICE_ID: device.id, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + assert msg["result"] is None + + event = Event( + type="firmware update progress", + data={ + "source": "controller", + "event": "firmware update progress", + "progress": { + "sentFragments": 1, + "totalFragments": 10, + "progress": 10.0, + }, + }, + ) + client.driver.controller.receive_event(event) + + msg = await ws_client.receive_json() + assert msg["event"] == { + "event": "firmware update progress", + "current_file": 1, + "total_files": 1, + "sent_fragments": 1, + "total_fragments": 10, + "progress": 10.0, + } + + event = Event( + type="firmware update finished", + data={ + "source": "controller", + "event": "firmware update finished", + "result": { + "status": 255, + "success": True, + }, + }, + ) + client.driver.controller.receive_event(event) + + msg = await ws_client.receive_json() + assert msg["event"] == { + "event": "firmware update finished", + "status": 255, + "success": True, + } + + +async def test_subscribe_controller_firmware_update_status_initial_value( + hass, client, integration, hass_ws_client +): + """Test subscribe_firmware_update_status cmd with in progress update for node.""" + ws_client = await hass_ws_client(hass) + device = get_device(hass, client.driver.controller.nodes[1]) + + assert client.driver.controller.firmware_update_progress is None + + # Send a firmware update progress event before the WS command + event = Event( + type="firmware update progress", + data={ + "source": "controller", + "event": "firmware update progress", + "progress": { + "sentFragments": 1, + "totalFragments": 10, + "progress": 10.0, + }, + }, + ) + client.driver.controller.receive_event(event) + + client.async_send_command_no_wait.return_value = {} + + await ws_client.send_json( + { + ID: 1, + TYPE: "zwave_js/subscribe_firmware_update_status", + DEVICE_ID: device.id, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + assert msg["result"] is None + + msg = await ws_client.receive_json() + assert msg["event"] == { + "event": "firmware update progress", + "current_file": 1, + "total_files": 1, + "sent_fragments": 1, + "total_fragments": 10, + "progress": 10.0, + } + + async def test_subscribe_firmware_update_status_failures( hass: HomeAssistant, multisensor_6, From 0c03862a7082704ca967a532939076079c96b7b5 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 22 Feb 2023 18:53:46 +0100 Subject: [PATCH 0313/2810] Update frontend to 20230222.0 (#88615) --- 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 af58a15363..17cbbb72ef 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==20230202.0"] + "requirements": ["home-assistant-frontend==20230222.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d88ac10643..5193492a07 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==20230202.0 +home-assistant-frontend==20230222.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 c8e9c08e18..02943afdad 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==20230202.0 +home-assistant-frontend==20230222.0 # homeassistant.components.conversation home-assistant-intents==2023.2.22 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 323f57da42..557e9646de 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==20230202.0 +home-assistant-frontend==20230222.0 # homeassistant.components.conversation home-assistant-intents==2023.2.22 From d41facf750c7c40a681ac868cf6d70c98205402a Mon Sep 17 00:00:00 2001 From: Jon Caruana Date: Wed, 22 Feb 2023 10:08:11 -0800 Subject: [PATCH 0314/2810] Add devices to LiteJet (#88406) --- homeassistant/components/litejet/light.py | 15 +++++++--- homeassistant/components/litejet/scene.py | 23 ++++++++------- homeassistant/components/litejet/switch.py | 33 ++++++++++++---------- tests/components/litejet/__init__.py | 2 +- tests/components/litejet/test_scene.py | 4 +-- 5 files changed, 45 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/litejet/light.py b/homeassistant/components/litejet/light.py index 09855a4d0d..9b771bdc03 100644 --- a/homeassistant/components/litejet/light.py +++ b/homeassistant/components/litejet/light.py @@ -15,6 +15,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import CONF_DEFAULT_TRANSITION, DOMAIN @@ -46,19 +47,25 @@ class LiteJetLight(LightEntity): _attr_should_poll = False _attr_supported_color_modes = {ColorMode.BRIGHTNESS} _attr_supported_features = LightEntityFeature.TRANSITION + _attr_has_entity_name = True + _attr_name = None def __init__( - self, config_entry: ConfigEntry, litejet: LiteJet, index: int, name: str + self, config_entry: ConfigEntry, system: LiteJet, index: int, name: str ) -> None: """Initialize a LiteJet light.""" self._config_entry = config_entry - self._lj = litejet + self._lj = system self._index = index self._attr_brightness = 0 self._attr_is_on = False - self._attr_name = name self._attr_unique_id = f"{config_entry.entry_id}_{index}" self._attr_extra_state_attributes = {ATTR_NUMBER: self._index} + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{config_entry.entry_id}_light_{index}")}, + name=name, + via_device=(DOMAIN, f"{config_entry.entry_id}_mcp"), + ) async def async_added_to_hass(self) -> None: """Run when this Entity has been added to HA.""" @@ -71,7 +78,7 @@ class LiteJetLight(LightEntity): self._lj.unsubscribe(self._on_load_changed) self._lj.unsubscribe(self._on_connected_changed) - def _on_load_changed(self, level) -> None: + def _on_load_changed(self, level: int | None) -> None: """Handle state changes.""" self.schedule_update_ha_state(True) diff --git a/homeassistant/components/litejet/scene.py b/homeassistant/components/litejet/scene.py index c6d4817cca..83eb2cc5f0 100644 --- a/homeassistant/components/litejet/scene.py +++ b/homeassistant/components/litejet/scene.py @@ -8,6 +8,7 @@ from homeassistant.components.scene import Scene from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -37,14 +38,21 @@ async def async_setup_entry( class LiteJetScene(Scene): """Representation of a single LiteJet scene.""" - def __init__( - self, entry_id, lj: LiteJet, i, name # pylint: disable=invalid-name - ) -> None: + _attr_has_entity_name = True + _attr_entity_registry_enabled_default = False + + def __init__(self, entry_id: str, system: LiteJet, i: int, name: str) -> None: """Initialize the scene.""" - self._lj = lj + self._lj = system self._index = i self._attr_unique_id = f"{entry_id}_{i}" self._attr_name = name + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{entry_id}_mcp")}, + name="LiteJet", + manufacturer="Centralite", + model="CL24", + ) async def async_added_to_hass(self) -> None: """Run when this Entity has been added to HA.""" @@ -59,7 +67,7 @@ class LiteJetScene(Scene): self.async_write_ha_state() @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the device-specific state attributes.""" return {ATTR_NUMBER: self._index} @@ -69,8 +77,3 @@ class LiteJetScene(Scene): await self._lj.activate_scene(self._index) except LiteJetError as exc: raise HomeAssistantError() from exc - - @property - def entity_registry_enabled_default(self) -> bool: - """Scenes are only enabled by explicit user choice.""" - return False diff --git a/homeassistant/components/litejet/switch.py b/homeassistant/components/litejet/switch.py index c2dcebd326..97a5122342 100644 --- a/homeassistant/components/litejet/switch.py +++ b/homeassistant/components/litejet/switch.py @@ -3,10 +3,11 @@ from typing import Any from pylitejet import LiteJet, LiteJetError -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -35,15 +36,27 @@ class LiteJetSwitch(SwitchEntity): """Representation of a single LiteJet switch.""" _attr_should_poll = False + _attr_has_entity_name = True + _attr_entity_registry_enabled_default = False + _attr_device_class = SwitchDeviceClass.SWITCH - def __init__(self, entry_id, lj, i, name): # pylint: disable=invalid-name + def __init__(self, entry_id: str, system: LiteJet, i: int, name: str) -> None: """Initialize a LiteJet switch.""" - self._entry_id = entry_id - self._lj = lj + self._lj = system self._index = i self._attr_is_on = False + self._attr_unique_id = f"{entry_id}_{i}" self._attr_name = name + # Keypad #1 has switches 1-6, #2 has 7-12, ... + keypad_number = int((i - 1) / 6) + 1 + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{entry_id}_keypad_{keypad_number}")}, + name=f"Keypad #{keypad_number}", + manufacturer="Centralite", + via_device=(DOMAIN, f"{entry_id}_mcp"), + ) + async def async_added_to_hass(self) -> None: """Run when this Entity has been added to HA.""" self._lj.on_switch_pressed(self._index, self._on_switch_pressed) @@ -69,12 +82,7 @@ class LiteJetSwitch(SwitchEntity): self.async_write_ha_state() @property - def unique_id(self): - """Return a unique identifier for this switch.""" - return f"{self._entry_id}_{self._index}" - - @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the device-specific state attributes.""" return {ATTR_NUMBER: self._index} @@ -91,8 +99,3 @@ class LiteJetSwitch(SwitchEntity): await self._lj.release_switch(self._index) except LiteJetError as exc: raise HomeAssistantError() from exc - - @property - def entity_registry_enabled_default(self) -> bool: - """Switches are only enabled by explicit user choice.""" - return False diff --git a/tests/components/litejet/__init__.py b/tests/components/litejet/__init__.py index e0304c2161..1ba39e46bf 100644 --- a/tests/components/litejet/__init__.py +++ b/tests/components/litejet/__init__.py @@ -41,7 +41,7 @@ async def async_init_integration( scene.DOMAIN, DOMAIN, f"{entry.entry_id}_1", - suggested_object_id="mock_scene_1", + suggested_object_id="litejet_mock_scene_1", disabled_by=None, ) diff --git a/tests/components/litejet/test_scene.py b/tests/components/litejet/test_scene.py index a430fc6387..d1316d81bb 100644 --- a/tests/components/litejet/test_scene.py +++ b/tests/components/litejet/test_scene.py @@ -11,9 +11,9 @@ from homeassistant.helpers import entity_registry as er from . import async_init_integration -ENTITY_SCENE = "scene.mock_scene_1" +ENTITY_SCENE = "scene.litejet_mock_scene_1" ENTITY_SCENE_NUMBER = 1 -ENTITY_OTHER_SCENE = "scene.mock_scene_2" +ENTITY_OTHER_SCENE = "scene.litejet_mock_scene_2" ENTITY_OTHER_SCENE_NUMBER = 2 From 9c7adfc4b28d61561702b1923bc0c71dce507718 Mon Sep 17 00:00:00 2001 From: Quentame Date: Wed, 22 Feb 2023 20:16:22 +0100 Subject: [PATCH 0315/2810] =?UTF-8?q?Bump=20M=C3=A9t=C3=A9o-France=20to=20?= =?UTF-8?q?1.2.0=20(#88617)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/meteo_france/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/meteo_france/manifest.json b/homeassistant/components/meteo_france/manifest.json index 9b5a1e4212..3b6bb9c351 100644 --- a/homeassistant/components/meteo_france/manifest.json +++ b/homeassistant/components/meteo_france/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/meteo_france", "iot_class": "cloud_polling", "loggers": ["meteofrance_api"], - "requirements": ["meteofrance-api==1.1.0"] + "requirements": ["meteofrance-api==1.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 02943afdad..dbee2a5873 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1120,7 +1120,7 @@ messagebird==1.2.0 meteoalertapi==0.3.0 # homeassistant.components.meteo_france -meteofrance-api==1.1.0 +meteofrance-api==1.2.0 # homeassistant.components.mfi mficlient==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 557e9646de..89ac8473c4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -825,7 +825,7 @@ meater-python==0.0.8 melnor-bluetooth==0.0.20 # homeassistant.components.meteo_france -meteofrance-api==1.1.0 +meteofrance-api==1.2.0 # homeassistant.components.mfi mficlient==0.3.0 From 881e85d74fab09fa563a587df95b3ca5475f9731 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 22 Feb 2023 20:24:30 +0100 Subject: [PATCH 0316/2810] Bump python-matter-server to 3.0.0 (#88607) * Bump python-matter-server to 3.0.0 Include all fixes for the changed api schema of the library * fix test fixtures * remove invalid data from fixtures * fix some of the tests * fix binary sensor bug * fix sensor bug * fix switch test * fix tests * adjust bugs and typos --- homeassistant/components/matter/__init__.py | 35 +- homeassistant/components/matter/adapter.py | 19 +- homeassistant/components/matter/api.py | 10 +- .../components/matter/binary_sensor.py | 15 +- .../components/matter/device_platform.py | 2 +- .../components/matter/diagnostics.py | 23 +- homeassistant/components/matter/entity.py | 70 +- homeassistant/components/matter/helpers.py | 10 +- homeassistant/components/matter/light.py | 49 +- homeassistant/components/matter/manifest.json | 2 +- homeassistant/components/matter/sensor.py | 32 +- homeassistant/components/matter/switch.py | 11 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/matter/common.py | 18 +- tests/components/matter/conftest.py | 4 +- .../fixtures/config_entry_diagnostics.json | 4616 ++-------------- .../config_entry_diagnostics_redacted.json | 4672 ++--------------- .../nodes/color-temperature-light.json | 2381 ++------- .../matter/fixtures/nodes/contact-sensor.json | 727 +-- .../fixtures/nodes/device_diagnostics.json | 4664 ++-------------- .../matter/fixtures/nodes/dimmable-light.json | 4599 ++-------------- .../fixtures/nodes/extended-color-light.json | 2381 ++------- .../matter/fixtures/nodes/flow-sensor.json | 689 +-- .../fixtures/nodes/humidity-sensor.json | 677 +-- .../matter/fixtures/nodes/light-sensor.json | 773 +-- .../fixtures/nodes/occupancy-sensor.json | 819 +-- .../fixtures/nodes/on-off-plugin-unit.json | 1286 +---- .../matter/fixtures/nodes/onoff-light.json | 4595 ++-------------- .../fixtures/nodes/pressure-sensor.json | 677 +-- .../fixtures/nodes/temperature-sensor.json | 750 +-- tests/components/matter/test_adapter.py | 12 +- tests/components/matter/test_api.py | 26 +- tests/components/matter/test_binary_sensor.py | 10 +- tests/components/matter/test_diagnostics.py | 11 +- tests/components/matter/test_init.py | 13 +- tests/components/matter/test_light.py | 18 +- tests/components/matter/test_sensor.py | 2 +- tests/components/matter/test_switch.py | 6 +- 39 files changed, 3687 insertions(+), 31021 deletions(-) diff --git a/homeassistant/components/matter/__init__.py b/homeassistant/components/matter/__init__.py index 6a11e5ded4..111e7c0ea9 100644 --- a/homeassistant/components/matter/__init__.py +++ b/homeassistant/components/matter/__init__.py @@ -5,12 +5,8 @@ import asyncio import async_timeout from matter_server.client import MatterClient -from matter_server.client.exceptions import ( - CannotConnect, - FailedCommand, - InvalidServerVersion, -) -from matter_server.common.models.error import MatterError +from matter_server.client.exceptions import CannotConnect, InvalidServerVersion +from matter_server.common.errors import MatterError, NodeCommissionFailed import voluptuous as vol from homeassistant.components.hassio import AddonError, AddonManager, AddonState @@ -211,31 +207,10 @@ def _async_init_services(hass: HomeAssistant) -> None: """Get node id from ha device id.""" dev_reg = dr.async_get(hass) device = dev_reg.async_get(ha_device_id) - if device is None: return None - - matter_id = next( - ( - identifier - for identifier in device.identifiers - if identifier[0] == DOMAIN - ), - None, - ) - - if not matter_id: - return None - - unique_id = matter_id[1] - - matter_client = get_matter(hass).matter_client - - # This could be more efficient - for node in await matter_client.get_nodes(): - if node.unique_id == unique_id: - return node.node_id - + if node := await get_node_from_device_entry(hass, device): + return node.node_id return None async def open_commissioning_window(call: ServiceCall) -> None: @@ -251,7 +226,7 @@ def _async_init_services(hass: HomeAssistant) -> None: try: await matter_client.open_commissioning_window(node_id) - except FailedCommand as err: + except NodeCommissionFailed as err: raise HomeAssistantError(str(err)) from err async_register_admin_service( diff --git a/homeassistant/components/matter/adapter.py b/homeassistant/components/matter/adapter.py index 9f8d1fe6c5..5bcec4b433 100644 --- a/homeassistant/components/matter/adapter.py +++ b/homeassistant/components/matter/adapter.py @@ -4,12 +4,11 @@ from __future__ import annotations from typing import TYPE_CHECKING, cast from chip.clusters import Objects as all_clusters -from matter_server.common.models.events import EventType -from matter_server.common.models.node_device import ( +from matter_server.client.models.node_device import ( AbstractMatterNodeDevice, MatterBridgedNodeDevice, ) -from matter_server.common.models.server_information import ServerInfo +from matter_server.common.models import EventType, ServerInfoMessage from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -23,7 +22,7 @@ from .helpers import get_device_id if TYPE_CHECKING: from matter_server.client import MatterClient - from matter_server.common.models.node import MatterNode + from matter_server.client.models.node import MatterNode class MatterAdapter: @@ -70,15 +69,19 @@ class MatterAdapter: bridge_unique_id: str | None = None - if node.aggregator_device_type_instance is not None and ( - node.root_device_type_instance.get_cluster(all_clusters.BasicInformation) + 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(ServerInfo, self.matter_client.server_info) + 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: @@ -90,7 +93,7 @@ class MatterAdapter: bridge_unique_id: str | None = None, ) -> None: """Create a device registry entry.""" - server_info = cast(ServerInfo, self.matter_client.server_info) + server_info = cast(ServerInfoMessage, self.matter_client.server_info) basic_info = node_device.device_info() device_type_instances = node_device.device_type_instances() diff --git a/homeassistant/components/matter/api.py b/homeassistant/components/matter/api.py index b1c2d9effb..7b4b7d35b7 100644 --- a/homeassistant/components/matter/api.py +++ b/homeassistant/components/matter/api.py @@ -5,7 +5,7 @@ from collections.abc import Callable from functools import wraps from typing import Any -from matter_server.client.exceptions import FailedCommand +from matter_server.common.errors import MatterError import voluptuous as vol from homeassistant.components import websocket_api @@ -44,7 +44,7 @@ def async_get_matter_adapter(func: Callable) -> Callable: def async_handle_failed_command(func: Callable) -> Callable: - """Decorate function to handle FailedCommand and send relevant error.""" + """Decorate function to handle MatterError and send relevant error.""" @wraps(func) async def async_handle_failed_command_func( @@ -54,11 +54,11 @@ def async_handle_failed_command(func: Callable) -> Callable: *args: Any, **kwargs: Any, ) -> None: - """Handle FailedCommand within function and send relevant error.""" + """Handle MatterError within function and send relevant error.""" try: await func(hass, connection, msg, *args, **kwargs) - except FailedCommand as err: - connection.send_error(msg[ID], err.error_code, err.args[0]) + except MatterError as err: + connection.send_error(msg[ID], str(err.error_code), err.args[0]) return async_handle_failed_command_func diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index 15ad13d25a..ce5b7a1091 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from functools import partial from chip.clusters import Objects as clusters -from matter_server.common.models import device_types +from matter_server.client.models import device_types from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -39,8 +39,10 @@ class MatterBinarySensor(MatterEntity, BinarySensorEntity): @callback def _update_from_device(self) -> None: """Update from device.""" - cluster = self._device_type_instance.get_cluster(clusters.BooleanState) - self._attr_is_on = cluster.stateValue if cluster else None + self._attr_is_on = self.get_matter_attribute_value( + # We always subscribe to a single value + self.entity_description.subscribe_attributes[0], + ) class MatterOccupancySensor(MatterBinarySensor): @@ -51,9 +53,12 @@ class MatterOccupancySensor(MatterBinarySensor): @callback def _update_from_device(self) -> None: """Update from device.""" - cluster = self._device_type_instance.get_cluster(clusters.OccupancySensing) + value = self.get_matter_attribute_value( + # We always subscribe to a single value + self.entity_description.subscribe_attributes[0], + ) # The first bit = if occupied - self._attr_is_on = cluster.occupancy & 1 == 1 if cluster else None + self._attr_is_on = (value & 1 == 1) if value is not None else None @dataclass diff --git a/homeassistant/components/matter/device_platform.py b/homeassistant/components/matter/device_platform.py index 24e7f8b5dc..35b5d40b6d 100644 --- a/homeassistant/components/matter/device_platform.py +++ b/homeassistant/components/matter/device_platform.py @@ -11,7 +11,7 @@ from .sensor import DEVICE_ENTITY as SENSOR_DEVICE_ENTITY from .switch import DEVICE_ENTITY as SWITCH_DEVICE_ENTITY if TYPE_CHECKING: - from matter_server.common.models.device_types import DeviceType + from matter_server.client.models.device_types import DeviceType from .entity import MatterEntityDescriptionBaseClass diff --git a/homeassistant/components/matter/diagnostics.py b/homeassistant/components/matter/diagnostics.py index 571523f7f0..bcb41cc046 100644 --- a/homeassistant/components/matter/diagnostics.py +++ b/homeassistant/components/matter/diagnostics.py @@ -4,7 +4,8 @@ from __future__ import annotations from copy import deepcopy from typing import Any -from matter_server.common.helpers.util import dataclass_to_dict +from chip.clusters import Objects +from matter_server.common.helpers.util import dataclass_to_dict, parse_attribute_path from homeassistant.components.diagnostics import REDACTED from homeassistant.config_entries import ConfigEntry @@ -13,16 +14,20 @@ from homeassistant.helpers import device_registry as dr from .helpers import get_matter, get_node_from_device_entry -ATTRIBUTES_TO_REDACT = {"chip.clusters.Objects.BasicInformation.Attributes.Location"} +ATTRIBUTES_TO_REDACT = {Objects.BasicInformation.Attributes.Location} def redact_matter_attributes(node_data: dict[str, Any]) -> dict[str, Any]: """Redact Matter cluster attribute.""" redacted = deepcopy(node_data) for attribute_to_redact in ATTRIBUTES_TO_REDACT: - for value in redacted["attributes"].values(): - if value["attribute_type"] == attribute_to_redact: - value["value"] = REDACTED + for attribute_path, _value in redacted["attributes"].items(): + _, cluster_id, attribute_id = parse_attribute_path(attribute_path) + if cluster_id != attribute_to_redact.cluster_id: + continue + if attribute_id != attribute_to_redact.attribute_id: + continue + redacted["attributes"][attribute_path] = REDACTED return redacted @@ -40,7 +45,7 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" matter = get_matter(hass) server_diagnostics = await matter.matter_client.get_diagnostics() - data = remove_serialization_type(dataclass_to_dict(server_diagnostics)) + data = dataclass_to_dict(server_diagnostics) nodes = [redact_matter_attributes(node_data) for node_data in data["nodes"]] data["nodes"] = nodes @@ -56,10 +61,8 @@ async def async_get_device_diagnostics( node = await get_node_from_device_entry(hass, device) return { - "server_info": remove_serialization_type( - dataclass_to_dict(server_diagnostics.info) - ), + "server_info": dataclass_to_dict(server_diagnostics.info), "node": redact_matter_attributes( - remove_serialization_type(dataclass_to_dict(node) if node else {}) + remove_serialization_type(dataclass_to_dict(node.node_data) if node else {}) ), } diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 820d0f7284..4a0c8f6a60 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -7,10 +7,11 @@ from dataclasses import dataclass import logging from typing import TYPE_CHECKING, Any, cast -from matter_server.common.models.device_type_instance import MatterDeviceTypeInstance -from matter_server.common.models.events import EventType -from matter_server.common.models.node_device import AbstractMatterNodeDevice -from matter_server.common.models.server_information import ServerInfo +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 @@ -20,7 +21,6 @@ from .helpers import get_device_id, get_operational_instance_id if TYPE_CHECKING: from matter_server.client import MatterClient - from matter_server.common.models.node import MatterAttribute LOGGER = logging.getLogger(__name__) @@ -59,19 +59,20 @@ class MatterEntity(Entity): self.entity_description = entity_description self._unsubscribes: list[Callable] = [] # for fast lookups we create a mapping to the attribute paths - # The server info is set when the client connects to the server. self._attributes_map: dict[type, str] = {} - server_info = cast(ServerInfo, self.matter_client.server_info) + # 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 self._attr_unique_id = ( f"{get_operational_instance_id(server_info, self._node_device.node())}-" - f"{device_type_instance.endpoint}-" + f"{device_type_instance.endpoint.endpoint_id}-" f"{device_type_instance.device_type.device_type}" ) 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 async def async_added_to_hass(self) -> None: """Handle being added to Home Assistant.""" @@ -79,19 +80,24 @@ class MatterEntity(Entity): # Subscribe to attribute updates. for attr_cls in self.entity_description.subscribe_attributes: - if matter_attr := self.get_matter_attribute(attr_cls): - self._attributes_map[attr_cls] = matter_attr.path - self._unsubscribes.append( - self.matter_client.subscribe( - self._on_matter_event, - EventType.ATTRIBUTE_UPDATED, - self._device_type_instance.node.node_id, - matter_attr.path, - ) + 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, + attr_path_filter=attr_path, ) - continue - # not sure if this can happen, but just in case log it. - LOGGER.warning("Attribute not found on device: %s", attr_cls) + ) + # subscribe to node (availability changes) + self._unsubscribes.append( + self.matter_client.subscribe( + callback=self._on_matter_event, + event_filter=EventType.NODE_UPDATED, + node_filter=self._device_type_instance.node.node_id, + ) + ) # make sure to update the attributes once self._update_from_device() @@ -104,6 +110,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._update_from_device() self.async_write_ha_state() @@ -113,13 +120,18 @@ class MatterEntity(Entity): """Update data from Matter device.""" @callback - def get_matter_attribute(self, attribute: type) -> MatterAttribute | None: - """Lookup MatterAttribute on device by providing the attribute class.""" - return next( - ( - x - for x in self._device_type_instance.attributes - if x.attribute_type == attribute - ), - None, + def get_matter_attribute_value( + self, attribute: type[ClusterAttributeDescriptor] + ) -> Any: + """Get current value for given attribute.""" + return self._device_type_instance.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 ) diff --git a/homeassistant/components/matter/helpers.py b/homeassistant/components/matter/helpers.py index ef42f9354c..994ab0ff80 100644 --- a/homeassistant/components/matter/helpers.py +++ b/homeassistant/components/matter/helpers.py @@ -11,9 +11,9 @@ from homeassistant.helpers import device_registry as dr from .const import DOMAIN, ID_TYPE_DEVICE_ID if TYPE_CHECKING: - from matter_server.common.models.node import MatterNode - from matter_server.common.models.node_device import AbstractMatterNodeDevice - from matter_server.common.models.server_information import ServerInfo + from matter_server.client.models.node import MatterNode + from matter_server.client.models.node_device import AbstractMatterNodeDevice + from matter_server.common.models import ServerInfoMessage from .adapter import MatterAdapter @@ -37,7 +37,7 @@ def get_matter(hass: HomeAssistant) -> MatterAdapter: def get_operational_instance_id( - server_info: ServerInfo, + server_info: ServerInfoMessage, node: MatterNode, ) -> str: """Return `Operational Instance Name` for given MatterNode.""" @@ -49,7 +49,7 @@ def get_operational_instance_id( def get_device_id( - server_info: ServerInfo, + server_info: ServerInfoMessage, node_device: AbstractMatterNodeDevice, ) -> str: """Return HA device_id for the given MatterNodeDevice.""" diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index 5761d57125..a891870bbe 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -7,7 +7,7 @@ from functools import partial from typing import Any from chip.clusters import Objects as clusters -from matter_server.common.models import device_types +from matter_server.client.models import device_types from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -85,15 +85,13 @@ class MatterLight(MatterEntity, LightEntity): def _supports_color_mode(self, color_feature: MatterColorControlFeatures) -> bool: """Return if device supports given color mode.""" - feature_map = self._device_type_instance.node.get_attribute( - self._device_type_instance.endpoint, - clusters.ColorControl, + feature_map = self.get_matter_attribute_value( clusters.ColorControl.Attributes.FeatureMap, ) - assert isinstance(feature_map.value, int) + assert isinstance(feature_map, int) - return self._supports_feature(feature_map.value, color_feature) + return self._supports_feature(feature_map, color_feature) def _supports_hs_color(self) -> bool: """Return if device supports hs color.""" @@ -176,7 +174,7 @@ class MatterLight(MatterEntity, LightEntity): assert level_control is not None - level = round( + level = round( # type: ignore[unreachable] renormalize( brightness, (0, 255), @@ -195,13 +193,17 @@ class MatterLight(MatterEntity, LightEntity): def _get_xy_color(self) -> tuple[float, float]: """Get xy color from matter.""" - x_color = self.get_matter_attribute(clusters.ColorControl.Attributes.CurrentX) - y_color = self.get_matter_attribute(clusters.ColorControl.Attributes.CurrentY) + x_color = self.get_matter_attribute_value( + clusters.ColorControl.Attributes.CurrentX + ) + y_color = self.get_matter_attribute_value( + clusters.ColorControl.Attributes.CurrentY + ) assert x_color is not None assert y_color is not None - xy_color = convert_to_hass_xy((x_color.value, y_color.value)) + xy_color = convert_to_hass_xy((x_color, y_color)) LOGGER.debug( "Got xy color %s for %s", xy_color, @@ -213,16 +215,18 @@ class MatterLight(MatterEntity, LightEntity): def _get_hs_color(self) -> tuple[float, float]: """Get hs color from matter.""" - hue = self.get_matter_attribute(clusters.ColorControl.Attributes.CurrentHue) + hue = self.get_matter_attribute_value( + clusters.ColorControl.Attributes.CurrentHue + ) - saturation = self.get_matter_attribute( + saturation = self.get_matter_attribute_value( clusters.ColorControl.Attributes.CurrentSaturation ) assert hue is not None assert saturation is not None - hs_color = convert_to_hass_hs((hue.value, saturation.value)) + hs_color = convert_to_hass_hs((hue, saturation)) LOGGER.debug( "Got hs color %s for %s", @@ -235,7 +239,7 @@ class MatterLight(MatterEntity, LightEntity): def _get_color_temperature(self) -> int: """Get color temperature from matter.""" - color_temp = self.get_matter_attribute( + color_temp = self.get_matter_attribute_value( clusters.ColorControl.Attributes.ColorTemperatureMireds ) @@ -243,11 +247,11 @@ class MatterLight(MatterEntity, LightEntity): LOGGER.debug( "Got color temperature %s for %s", - color_temp.value, + color_temp, self._device_type_instance, ) - return int(color_temp.value) + return int(color_temp) def _get_brightness(self) -> int: """Get brightness from matter.""" @@ -257,7 +261,7 @@ class MatterLight(MatterEntity, LightEntity): # We should not get here if brightness is not supported. assert level_control is not None - LOGGER.debug( + LOGGER.debug( # type: ignore[unreachable] "Got brightness %s for %s", level_control.currentLevel, self._device_type_instance, @@ -274,13 +278,13 @@ class MatterLight(MatterEntity, LightEntity): def _get_color_mode(self) -> ColorMode: """Get color mode from matter.""" - color_mode = self.get_matter_attribute( + color_mode = self.get_matter_attribute_value( clusters.ColorControl.Attributes.ColorMode ) assert color_mode is not None - ha_color_mode = COLOR_MODE_MAP[MatterColorMode(color_mode.value)] + ha_color_mode = COLOR_MODE_MAP[MatterColorMode(color_mode)] LOGGER.debug( "Got color mode (%s) for %s", ha_color_mode, self._device_type_instance @@ -292,7 +296,7 @@ class MatterLight(MatterEntity, LightEntity): """Send device command.""" await self.matter_client.send_device_command( node_id=self._device_type_instance.node.node_id, - endpoint=self._device_type_instance.endpoint, + endpoint_id=self._device_type_instance.endpoint_id, command=command, ) @@ -369,8 +373,9 @@ class MatterLight(MatterEntity, LightEntity): if supports_color_temperature: self._attr_color_temp = self._get_color_temperature() - if attr := self.get_matter_attribute(clusters.OnOff.Attributes.OnOff): - self._attr_is_on = attr.value + self._attr_is_on = self.get_matter_attribute_value( + clusters.OnOff.Attributes.OnOff + ) if supports_brightness: self._attr_brightness = self._get_brightness() diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index bf31b7eb68..73863de5bd 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==2.1.1"] + "requirements": ["python-matter-server==3.0.0"] } diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 38a701e779..d60d473b0b 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -4,12 +4,10 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from functools import partial -from typing import Any from chip.clusters import Objects as clusters from chip.clusters.Types import Nullable, NullValue -from matter_server.common.models import device_types -from matter_server.common.models.device_type_instance import MatterDeviceTypeInstance +from matter_server.client.models import device_types from homeassistant.components.sensor import ( SensorDeviceClass, @@ -53,13 +51,12 @@ class MatterSensor(MatterEntity, SensorEntity): def _update_from_device(self) -> None: """Update from device.""" measurement: Nullable | float | None - measurement = _get_attribute_value( - self._device_type_instance, + measurement = self.get_matter_attribute_value( # We always subscribe to a single value self.entity_description.subscribe_attributes[0], ) - if measurement is NullValue or measurement is None: + if measurement == NullValue or measurement is None: measurement = None else: measurement = self.entity_description.measurement_to_ha(measurement) @@ -67,29 +64,6 @@ class MatterSensor(MatterEntity, SensorEntity): self._attr_native_value = measurement -def _get_attribute_value( - device_type_instance: MatterDeviceTypeInstance, - attribute: clusters.ClusterAttributeDescriptor, -) -> Any: - """Return the value of an attribute.""" - # Find the cluster for this attribute. We don't have a lookup table yet. - cluster_cls: clusters.Cluster = next( - cluster - for cluster in device_type_instance.device_type.clusters - if cluster.id == attribute.cluster_id - ) - - # Find the attribute descriptor so we know the instance variable to fetch - attribute_descriptor: clusters.ClusterObjectFieldDescriptor = next( - descriptor - for descriptor in cluster_cls.descriptor.Fields - if descriptor.Tag == attribute.attribute_id - ) - - cluster_data = device_type_instance.get_cluster(cluster_cls) - return getattr(cluster_data, attribute_descriptor.Label) - - @dataclass class MatterSensorEntityDescriptionMixin: """Required fields for sensor device mapping.""" diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index f86a7cbb02..53ae25f889 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -6,7 +6,7 @@ from functools import partial from typing import Any from chip.clusters import Objects as clusters -from matter_server.common.models import device_types +from matter_server.client.models import device_types from homeassistant.components.switch import ( SwitchDeviceClass, @@ -41,7 +41,7 @@ class MatterSwitch(MatterEntity, SwitchEntity): """Turn switch on.""" await self.matter_client.send_device_command( node_id=self._device_type_instance.node.node_id, - endpoint=self._device_type_instance.endpoint, + endpoint_id=self._device_type_instance.endpoint_id, command=clusters.OnOff.Commands.On(), ) @@ -49,15 +49,16 @@ class MatterSwitch(MatterEntity, SwitchEntity): """Turn switch off.""" await self.matter_client.send_device_command( node_id=self._device_type_instance.node.node_id, - endpoint=self._device_type_instance.endpoint, + endpoint_id=self._device_type_instance.endpoint_id, command=clusters.OnOff.Commands.Off(), ) @callback def _update_from_device(self) -> None: """Update from device.""" - cluster = self._device_type_instance.get_cluster(clusters.OnOff) - self._attr_is_on = cluster.onOff if cluster else None + self._attr_is_on = self.get_matter_attribute_value( + clusters.OnOff.Attributes.OnOff + ) @dataclass diff --git a/requirements_all.txt b/requirements_all.txt index dbee2a5873..146c699d4a 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==2.1.1 +python-matter-server==3.0.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 89ac8473c4..7cbaa57720 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==2.1.1 +python-matter-server==3.0.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/tests/components/matter/common.py b/tests/components/matter/common.py index 92a4cd4c8f..7582b9c415 100644 --- a/tests/components/matter/common.py +++ b/tests/components/matter/common.py @@ -6,9 +6,9 @@ import json from typing import Any from unittest.mock import MagicMock +from matter_server.client.models.node import MatterNode from matter_server.common.helpers.util import dataclass_from_dict -from matter_server.common.models.events import EventType -from matter_server.common.models.node import MatterNode +from matter_server.common.models import EventType, MatterNodeData from homeassistant.core import HomeAssistant @@ -33,9 +33,11 @@ async def setup_integration_with_node_fixture( ) -> MatterNode: """Set up Matter integration with fixture as node.""" node_data = load_and_parse_node_fixture(node_fixture) - node = dataclass_from_dict( - MatterNode, - node_data, + node = MatterNode( + dataclass_from_dict( + MatterNodeData, + node_data, + ) ) client.get_nodes.return_value = [node] client.get_node.return_value = node @@ -58,8 +60,8 @@ def set_node_attribute( value: Any, ) -> None: """Set a node attribute.""" - attribute = node.attributes[f"{endpoint}/{cluster_id}/{attribute_id}"] - attribute.value = value + attribute_path = f"{endpoint}/{cluster_id}/{attribute_id}" + node.endpoints[endpoint].set_attribute_value(attribute_path, value) async def trigger_subscription_callback( @@ -69,6 +71,6 @@ async def trigger_subscription_callback( data: Any = None, ) -> None: """Trigger a subscription callback.""" - callback = client.subscribe.call_args[0][0] + callback = client.subscribe.call_args.kwargs["callback"] callback(event, data) await hass.async_block_till_done() diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index 7ecf9c1e12..e4af252fcc 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -6,7 +6,7 @@ from collections.abc import AsyncGenerator, Generator from unittest.mock import AsyncMock, MagicMock, patch from matter_server.common.const import SCHEMA_VERSION -from matter_server.common.models.server_information import ServerInfo +from matter_server.common.models import ServerInfoMessage import pytest from homeassistant.core import HomeAssistant @@ -39,7 +39,7 @@ async def matter_client_fixture() -> AsyncGenerator[MagicMock, None]: client.connect = AsyncMock(side_effect=connect) client.start_listening = AsyncMock(side_effect=listen) - client.server_info = ServerInfo( + client.server_info = ServerInfoMessage( fabric_id=MOCK_FABRIC_ID, compressed_fabric_id=MOCK_COMPR_FABRIC_ID, schema_version=1, diff --git a/tests/components/matter/fixtures/config_entry_diagnostics.json b/tests/components/matter/fixtures/config_entry_diagnostics.json index 8c7b0960fc..95ef0b6a85 100644 --- a/tests/components/matter/fixtures/config_entry_diagnostics.json +++ b/tests/components/matter/fixtures/config_entry_diagnostics.json @@ -13,4229 +13,663 @@ "node_id": 5, "date_commissioned": "2023-01-16T21:07:57.508440", "last_interview": "2023-01-16T21:07:57.508448", - "interview_version": 1, + "interview_version": 2, "attributes": { - "0/4/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.NameSupport", - "attribute_name": "NameSupport", - "value": 128 - }, - "0/4/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 1 - }, - "0/4/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 4 - }, - "0/4/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [0, 1, 2, 3] - }, - "0/4/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 1, 2, 3, 4, 5] - }, - "0/4/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 65528, 65529, 65531, 65532, 65533] - }, - "0/29/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.DeviceTypeList", - "attribute_name": "DeviceTypeList", - "value": [ - { - "type": 22, - "revision": 1 - } - ] - }, - "0/29/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ServerList", - "attribute_name": "ServerList", - "value": [ - 4, 29, 31, 40, 42, 43, 44, 48, 49, 50, 51, 52, 53, 54, 55, 59, 60, - 62, 63, 64, 65 - ] - }, - "0/29/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ClientList", - "attribute_name": "ClientList", - "value": [41] - }, - "0/29/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.PartsList", - "attribute_name": "PartsList", - "value": [1] - }, - "0/29/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/29/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/29/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/29/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/29/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] - }, - "0/31/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.Acl", - "attribute_name": "Acl", - "value": [ - { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 - } - ] - }, - "0/31/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.Extension", - "attribute_name": "Extension", - "value": [] - }, - "0/31/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.SubjectsPerAccessControlEntry", - "attribute_name": "SubjectsPerAccessControlEntry", - "value": 4 - }, - "0/31/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.TargetsPerAccessControlEntry", - "attribute_name": "TargetsPerAccessControlEntry", - "value": 3 - }, - "0/31/4": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.AccessControlEntriesPerFabric", - "attribute_name": "AccessControlEntriesPerFabric", - "value": 3 - }, - "0/31/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/31/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/31/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/31/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/31/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533] - }, - "0/40/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.DataModelRevision", - "attribute_name": "DataModelRevision", - "value": 1 - }, - "0/40/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.VendorName", - "attribute_name": "VendorName", - "value": "Nabu Casa" - }, - "0/40/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.VendorID", - "attribute_name": "VendorID", - "value": 65521 - }, - "0/40/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.ProductName", - "attribute_name": "ProductName", - "value": "M5STAMP Lighting App" - }, - "0/40/4": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.ProductID", - "attribute_name": "ProductID", - "value": 32768 - }, - "0/40/5": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.NodeLabel", - "attribute_name": "NodeLabel", - "value": "" - }, - "0/40/6": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 6, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.Location", - "attribute_name": "Location", - "value": "XX" - }, - "0/40/7": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 7, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.HardwareVersion", - "attribute_name": "HardwareVersion", - "value": 0 - }, - "0/40/8": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 8, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.HardwareVersionString", - "attribute_name": "HardwareVersionString", - "value": "v1.0" - }, - "0/40/9": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 9, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.SoftwareVersion", - "attribute_name": "SoftwareVersion", - "value": 1 - }, - "0/40/10": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 10, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.SoftwareVersionString", - "attribute_name": "SoftwareVersionString", - "value": "v1.0" - }, - "0/40/11": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 11, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.ManufacturingDate", - "attribute_name": "ManufacturingDate", - "value": "20200101" - }, - "0/40/12": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 12, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.PartNumber", - "attribute_name": "PartNumber", - "value": "" - }, - "0/40/13": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 13, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.ProductURL", - "attribute_name": "ProductURL", - "value": "" - }, - "0/40/14": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 14, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.ProductLabel", - "attribute_name": "ProductLabel", - "value": "" - }, - "0/40/15": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 15, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.SerialNumber", - "attribute_name": "SerialNumber", - "value": "TEST_SN" - }, - "0/40/16": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 16, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.LocalConfigDisabled", - "attribute_name": "LocalConfigDisabled", - "value": false - }, - "0/40/17": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 17, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.Reachable", - "attribute_name": "Reachable", - "value": true - }, - "0/40/18": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 18, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.UniqueID", - "attribute_name": "UniqueID", - "value": "869D5F986B588B29" - }, + "0/4/0": 128, + "0/4/65532": 1, + "0/4/65533": 4, + "0/4/65528": [0, 1, 2, 3], + "0/4/65529": [0, 1, 2, 3, 4, 5], + "0/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/29/0": [ + { + "type": 22, + "revision": 1 + } + ], + "0/29/1": [ + 4, 29, 31, 40, 42, 43, 44, 48, 49, 50, 51, 52, 53, 54, 55, 59, 60, 62, + 63, 64, 65 + ], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "privilege": 5, + "authMode": 2, + "subjects": [112233], + "targets": null, + "fabricIndex": 1 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 3, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "Nabu Casa", + "0/40/2": 65521, + "0/40/3": "M5STAMP Lighting App", + "0/40/4": 32768, + "0/40/5": "", + "0/40/6": "XX", + "0/40/7": 0, + "0/40/8": "v1.0", + "0/40/9": 1, + "0/40/10": "v1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "TEST_SN", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "869D5F986B588B29", "0/40/19": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 19, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.CapabilityMinima", - "attribute_name": "CapabilityMinima", - "value": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 - } - }, - "0/40/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/40/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/40/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/40/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/40/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, - 19, 65528, 65529, 65531, 65532, 65533 - ] - }, - "0/42/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.DefaultOtaProviders", - "attribute_name": "DefaultOtaProviders", - "value": [] - }, - "0/42/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.UpdatePossible", - "attribute_name": "UpdatePossible", - "value": true - }, - "0/42/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.UpdateState", - "attribute_name": "UpdateState", - "value": 0 - }, - "0/42/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.UpdateStateProgress", - "attribute_name": "UpdateStateProgress", - "value": 0 - }, - "0/42/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/42/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/42/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/42/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0] - }, - "0/42/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] - }, - "0/43/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 43, - "cluster_type": "chip.clusters.Objects.LocalizationConfiguration", - "cluster_name": "LocalizationConfiguration", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.LocalizationConfiguration.Attributes.ActiveLocale", - "attribute_name": "ActiveLocale", - "value": "en-US" - }, - "0/43/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 43, - "cluster_type": "chip.clusters.Objects.LocalizationConfiguration", - "cluster_name": "LocalizationConfiguration", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.LocalizationConfiguration.Attributes.SupportedLocales", - "attribute_name": "SupportedLocales", - "value": [ - "en-US", - "de-DE", - "fr-FR", - "en-GB", - "es-ES", - "zh-CN", - "it-IT", - "ja-JP" - ] - }, - "0/43/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 43, - "cluster_type": "chip.clusters.Objects.LocalizationConfiguration", - "cluster_name": "LocalizationConfiguration", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.LocalizationConfiguration.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/43/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 43, - "cluster_type": "chip.clusters.Objects.LocalizationConfiguration", - "cluster_name": "LocalizationConfiguration", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.LocalizationConfiguration.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/43/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 43, - "cluster_type": "chip.clusters.Objects.LocalizationConfiguration", - "cluster_name": "LocalizationConfiguration", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.LocalizationConfiguration.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/43/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 43, - "cluster_type": "chip.clusters.Objects.LocalizationConfiguration", - "cluster_name": "LocalizationConfiguration", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.LocalizationConfiguration.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/43/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 43, - "cluster_type": "chip.clusters.Objects.LocalizationConfiguration", - "cluster_name": "LocalizationConfiguration", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.LocalizationConfiguration.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 65528, 65529, 65531, 65532, 65533] - }, - "0/44/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 44, - "cluster_type": "chip.clusters.Objects.TimeFormatLocalization", - "cluster_name": "TimeFormatLocalization", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.TimeFormatLocalization.Attributes.HourFormat", - "attribute_name": "HourFormat", - "value": 0 - }, - "0/44/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 44, - "cluster_type": "chip.clusters.Objects.TimeFormatLocalization", - "cluster_name": "TimeFormatLocalization", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.TimeFormatLocalization.Attributes.ActiveCalendarType", - "attribute_name": "ActiveCalendarType", - "value": 0 - }, - "0/44/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 44, - "cluster_type": "chip.clusters.Objects.TimeFormatLocalization", - "cluster_name": "TimeFormatLocalization", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.TimeFormatLocalization.Attributes.SupportedCalendarTypes", - "attribute_name": "SupportedCalendarTypes", - "value": [0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 7] - }, - "0/44/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 44, - "cluster_type": "chip.clusters.Objects.TimeFormatLocalization", - "cluster_name": "TimeFormatLocalization", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.TimeFormatLocalization.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/44/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 44, - "cluster_type": "chip.clusters.Objects.TimeFormatLocalization", - "cluster_name": "TimeFormatLocalization", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.TimeFormatLocalization.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/44/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 44, - "cluster_type": "chip.clusters.Objects.TimeFormatLocalization", - "cluster_name": "TimeFormatLocalization", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.TimeFormatLocalization.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/44/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 44, - "cluster_type": "chip.clusters.Objects.TimeFormatLocalization", - "cluster_name": "TimeFormatLocalization", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.TimeFormatLocalization.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/44/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 44, - "cluster_type": "chip.clusters.Objects.TimeFormatLocalization", - "cluster_name": "TimeFormatLocalization", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.TimeFormatLocalization.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 65528, 65529, 65531, 65532, 65533] - }, - "0/48/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.Breadcrumb", - "attribute_name": "Breadcrumb", - "value": 0 + "caseSessionsPerFabric": 3, + "subscriptionsPerFabric": 3 }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 65528, 65529, 65531, 65532, 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 0, + "0/42/3": 0, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/43/0": "en-US", + "0/43/1": [ + "en-US", + "de-DE", + "fr-FR", + "en-GB", + "es-ES", + "zh-CN", + "it-IT", + "ja-JP" + ], + "0/43/65532": 0, + "0/43/65533": 1, + "0/43/65528": [], + "0/43/65529": [], + "0/43/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "0/44/0": 0, + "0/44/1": 0, + "0/44/2": [0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 7], + "0/44/65532": 0, + "0/44/65533": 1, + "0/44/65528": [], + "0/44/65529": [], + "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, "0/48/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.BasicCommissioningInfo", - "attribute_name": "BasicCommissioningInfo", - "value": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "failSafeExpiryLengthSeconds": 60, + "maxCumulativeFailsafeSeconds": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [], + "0/49/2": 10, + "0/49/3": 30, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "bVdMQU4yLjQ=", + "0/49/7": null, + "0/49/65532": 1, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 2, 4, 6, 8], + "0/49/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533 + ], + "0/50/65532": 0, + "0/50/65533": 1, + "0/50/65528": [1], + "0/50/65529": [0], + "0/50/65531": [65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "name": "WIFI_STA_DEF", + "isOperational": true, + "offPremiseServicesReachableIPv4": null, + "offPremiseServicesReachableIPv6": null, + "hardwareAddress": "YFX5V0js", + "IPv4Addresses": ["wKgBIw=="], + "IPv6Addresses": ["/oAAAAAAAABiVfn//ldI7A=="], + "type": 1 } - }, - "0/48/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.RegulatoryConfig", - "attribute_name": "RegulatoryConfig", - "value": 0 - }, - "0/48/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.LocationCapability", - "attribute_name": "LocationCapability", - "value": 0 - }, - "0/48/4": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.SupportsConcurrentConnection", - "attribute_name": "SupportsConcurrentConnection", - "value": true - }, - "0/48/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/48/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/48/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [1, 3, 5] - }, - "0/48/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 2, 4] - }, - "0/48/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533] - }, - "0/49/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.MaxNetworks", - "attribute_name": "MaxNetworks", - "value": 1 - }, - "0/49/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.Networks", - "attribute_name": "Networks", - "value": [] - }, - "0/49/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.ScanMaxTimeSeconds", - "attribute_name": "ScanMaxTimeSeconds", - "value": 10 - }, - "0/49/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.ConnectMaxTimeSeconds", - "attribute_name": "ConnectMaxTimeSeconds", - "value": 30 - }, - "0/49/4": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.InterfaceEnabled", - "attribute_name": "InterfaceEnabled", - "value": true - }, - "0/49/5": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.LastNetworkingStatus", - "attribute_name": "LastNetworkingStatus", - "value": 0 - }, - "0/49/6": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 6, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.LastNetworkID", - "attribute_name": "LastNetworkID", - "value": "bVdMQU4yLjQ=" - }, - "0/49/7": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 7, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.LastConnectErrorValue", - "attribute_name": "LastConnectErrorValue", - "value": null - }, - "0/49/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 1 - }, - "0/49/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/49/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [1, 5, 7] - }, - "0/49/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 2, 4, 6, 8] - }, - "0/49/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533] - }, - "0/50/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 50, - "cluster_type": "chip.clusters.Objects.DiagnosticLogs", - "cluster_name": "DiagnosticLogs", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.DiagnosticLogs.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/50/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 50, - "cluster_type": "chip.clusters.Objects.DiagnosticLogs", - "cluster_name": "DiagnosticLogs", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.DiagnosticLogs.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/50/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 50, - "cluster_type": "chip.clusters.Objects.DiagnosticLogs", - "cluster_name": "DiagnosticLogs", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.DiagnosticLogs.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [1] - }, - "0/50/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 50, - "cluster_type": "chip.clusters.Objects.DiagnosticLogs", - "cluster_name": "DiagnosticLogs", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.DiagnosticLogs.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0] - }, - "0/50/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 50, - "cluster_type": "chip.clusters.Objects.DiagnosticLogs", - "cluster_name": "DiagnosticLogs", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.DiagnosticLogs.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [65528, 65529, 65531, 65532, 65533] - }, - "0/51/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.NetworkInterfaces", - "attribute_name": "NetworkInterfaces", - "value": [ - { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "YFX5V0js", - "IPv4Addresses": ["wKgBIw=="], - "IPv6Addresses": ["/oAAAAAAAABiVfn//ldI7A=="], - "type": 1 - } - ] - }, - "0/51/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.RebootCount", - "attribute_name": "RebootCount", - "value": 3 - }, - "0/51/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.UpTime", - "attribute_name": "UpTime", - "value": 213 - }, - "0/51/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.TotalOperationalHours", - "attribute_name": "TotalOperationalHours", - "value": 0 - }, - "0/51/4": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.BootReasons", - "attribute_name": "BootReasons", - "value": 1 - }, - "0/51/5": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.ActiveHardwareFaults", - "attribute_name": "ActiveHardwareFaults", - "value": [] - }, - "0/51/6": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 6, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.ActiveRadioFaults", - "attribute_name": "ActiveRadioFaults", - "value": [] - }, - "0/51/7": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 7, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.ActiveNetworkFaults", - "attribute_name": "ActiveNetworkFaults", - "value": [] - }, - "0/51/8": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 8, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.TestEventTriggersEnabled", - "attribute_name": "TestEventTriggersEnabled", - "value": false - }, - "0/51/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/51/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/51/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/51/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0] - }, - "0/51/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 - ] - }, - "0/52/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.ThreadMetrics", - "attribute_name": "ThreadMetrics", - "value": [] - }, - "0/52/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.CurrentHeapFree", - "attribute_name": "CurrentHeapFree", - "value": 166660 - }, - "0/52/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.CurrentHeapUsed", - "attribute_name": "CurrentHeapUsed", - "value": 86332 - }, - "0/52/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.CurrentHeapHighWatermark", - "attribute_name": "CurrentHeapHighWatermark", - "value": 99208 - }, - "0/52/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/52/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/52/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/52/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/52/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] - }, + ], + "0/51/1": 3, + "0/51/2": 213, + "0/51/3": 0, + "0/51/4": 1, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/52/0": [], + "0/52/1": 166660, + "0/52/2": 86332, + "0/52/3": 99208, + "0/52/65532": 0, + "0/52/65533": 1, + "0/52/65528": [], + "0/52/65529": [], + "0/52/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/53/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.Channel", - "attribute_name": "Channel", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RoutingRole", - "attribute_name": "RoutingRole", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.NetworkName", - "attribute_name": "NetworkName", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.PanId", - "attribute_name": "PanId", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/4": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.ExtendedPanId", - "attribute_name": "ExtendedPanId", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/5": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.MeshLocalPrefix", - "attribute_name": "MeshLocalPrefix", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/6": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 6, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.OverrunCount", - "attribute_name": "OverrunCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/7": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 7, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.NeighborTableList", - "attribute_name": "NeighborTableList", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/8": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 8, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RouteTableList", - "attribute_name": "RouteTableList", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/9": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 9, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.PartitionId", - "attribute_name": "PartitionId", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/10": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 10, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.Weighting", - "attribute_name": "Weighting", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/11": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 11, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.DataVersion", - "attribute_name": "DataVersion", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/12": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 12, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.StableDataVersion", - "attribute_name": "StableDataVersion", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/13": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 13, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.LeaderRouterId", - "attribute_name": "LeaderRouterId", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/14": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 14, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.DetachedRoleCount", - "attribute_name": "DetachedRoleCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/15": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 15, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.ChildRoleCount", - "attribute_name": "ChildRoleCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/16": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 16, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RouterRoleCount", - "attribute_name": "RouterRoleCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/17": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 17, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.LeaderRoleCount", - "attribute_name": "LeaderRoleCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/18": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 18, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.AttachAttemptCount", - "attribute_name": "AttachAttemptCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/19": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 19, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.PartitionIdChangeCount", - "attribute_name": "PartitionIdChangeCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/20": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 20, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.BetterPartitionAttachAttemptCount", - "attribute_name": "BetterPartitionAttachAttemptCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/21": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 21, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.ParentChangeCount", - "attribute_name": "ParentChangeCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/22": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 22, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxTotalCount", - "attribute_name": "TxTotalCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/23": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 23, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxUnicastCount", - "attribute_name": "TxUnicastCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/24": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 24, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxBroadcastCount", - "attribute_name": "TxBroadcastCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/25": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 25, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxAckRequestedCount", - "attribute_name": "TxAckRequestedCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/26": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 26, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxAckedCount", - "attribute_name": "TxAckedCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/27": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 27, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxNoAckRequestedCount", - "attribute_name": "TxNoAckRequestedCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/28": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 28, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxDataCount", - "attribute_name": "TxDataCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/29": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 29, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxDataPollCount", - "attribute_name": "TxDataPollCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/30": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 30, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxBeaconCount", - "attribute_name": "TxBeaconCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/31": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 31, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxBeaconRequestCount", - "attribute_name": "TxBeaconRequestCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/32": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 32, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxOtherCount", - "attribute_name": "TxOtherCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/33": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 33, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxRetryCount", - "attribute_name": "TxRetryCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/34": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 34, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxDirectMaxRetryExpiryCount", - "attribute_name": "TxDirectMaxRetryExpiryCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/35": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 35, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxIndirectMaxRetryExpiryCount", - "attribute_name": "TxIndirectMaxRetryExpiryCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/36": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 36, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxErrCcaCount", - "attribute_name": "TxErrCcaCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/37": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 37, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxErrAbortCount", - "attribute_name": "TxErrAbortCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/38": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 38, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxErrBusyChannelCount", - "attribute_name": "TxErrBusyChannelCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/39": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 39, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxTotalCount", - "attribute_name": "RxTotalCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/40": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 40, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxUnicastCount", - "attribute_name": "RxUnicastCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/41": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 41, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxBroadcastCount", - "attribute_name": "RxBroadcastCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/42": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 42, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxDataCount", - "attribute_name": "RxDataCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/43": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 43, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxDataPollCount", - "attribute_name": "RxDataPollCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/44": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 44, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxBeaconCount", - "attribute_name": "RxBeaconCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/45": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 45, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxBeaconRequestCount", - "attribute_name": "RxBeaconRequestCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/46": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 46, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxOtherCount", - "attribute_name": "RxOtherCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/47": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 47, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxAddressFilteredCount", - "attribute_name": "RxAddressFilteredCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/48": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 48, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxDestAddrFilteredCount", - "attribute_name": "RxDestAddrFilteredCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/49": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 49, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxDuplicatedCount", - "attribute_name": "RxDuplicatedCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/50": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 50, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxErrNoFrameCount", - "attribute_name": "RxErrNoFrameCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/51": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 51, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxErrUnknownNeighborCount", - "attribute_name": "RxErrUnknownNeighborCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/52": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 52, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxErrInvalidSrcAddrCount", - "attribute_name": "RxErrInvalidSrcAddrCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/53": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 53, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxErrSecCount", - "attribute_name": "RxErrSecCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/54": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 54, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxErrFcsCount", - "attribute_name": "RxErrFcsCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/55": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 55, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxErrOtherCount", - "attribute_name": "RxErrOtherCount", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/56": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 56, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.ActiveTimestamp", - "attribute_name": "ActiveTimestamp", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/57": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 57, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.PendingTimestamp", - "attribute_name": "PendingTimestamp", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/58": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 58, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.Delay", - "attribute_name": "Delay", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/59": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 59, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.SecurityPolicy", - "attribute_name": "SecurityPolicy", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/60": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 60, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.ChannelPage0Mask", - "attribute_name": "ChannelPage0Mask", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/61": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 61, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.OperationalDatasetComponents", - "attribute_name": "OperationalDatasetComponents", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "0/53/62": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 62, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.ActiveNetworkFaultsList", - "attribute_name": "ActiveNetworkFaultsList", - "value": { - "TLVValue": null, - "Reason": null + "TLVValue": null, + "Reason": null + }, + "0/53/65532": 15, + "0/53/65533": 1, + "0/53/65528": [], + "0/53/65529": [0], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, + 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, + 54, 55, 56, 57, 58, 59, 60, 61, 62, 65528, 65529, 65531, 65532, 65533 + ], + "0/54/0": "BKFR27h1", + "0/54/1": 4, + "0/54/2": 3, + "0/54/3": 3, + "0/54/4": -56, + "0/54/5": null, + "0/54/6": null, + "0/54/7": null, + "0/54/8": null, + "0/54/9": null, + "0/54/10": null, + "0/54/11": null, + "0/54/12": null, + "0/54/65532": 3, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [0], + "0/54/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 65528, 65529, 65531, 65532, + 65533 + ], + "0/55/0": null, + "0/55/1": null, + "0/55/2": 0, + "0/55/3": 0, + "0/55/4": 0, + "0/55/5": 0, + "0/55/6": 0, + "0/55/7": null, + "0/55/8": 0, + "0/55/65532": 3, + "0/55/65533": 1, + "0/55/65528": [], + "0/55/65529": [0], + "0/55/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/59/65532": 0, + "0/59/65533": 1, + "0/59/65528": [], + "0/59/65529": [], + "0/59/65531": [65528, 65529, 65531, 65532, 65533], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", + "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", + "fabricIndex": 1 } - }, - "0/53/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 15 - }, - "0/53/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/53/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/53/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0] - }, - "0/53/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, - 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, - 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, - 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 65528, 65529, 65531, 65532, - 65533 - ] - }, - "0/54/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.Bssid", - "attribute_name": "Bssid", - "value": "BKFR27h1" - }, - "0/54/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.SecurityType", - "attribute_name": "SecurityType", - "value": 4 - }, - "0/54/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.WiFiVersion", - "attribute_name": "WiFiVersion", - "value": 3 - }, - "0/54/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.ChannelNumber", - "attribute_name": "ChannelNumber", - "value": 3 - }, - "0/54/4": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.Rssi", - "attribute_name": "Rssi", - "value": -56 - }, - "0/54/5": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.BeaconLostCount", - "attribute_name": "BeaconLostCount", - "value": null - }, - "0/54/6": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 6, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.BeaconRxCount", - "attribute_name": "BeaconRxCount", - "value": null - }, - "0/54/7": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 7, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.PacketMulticastRxCount", - "attribute_name": "PacketMulticastRxCount", - "value": null - }, - "0/54/8": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 8, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.PacketMulticastTxCount", - "attribute_name": "PacketMulticastTxCount", - "value": null - }, - "0/54/9": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 9, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.PacketUnicastRxCount", - "attribute_name": "PacketUnicastRxCount", - "value": null - }, - "0/54/10": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 10, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.PacketUnicastTxCount", - "attribute_name": "PacketUnicastTxCount", - "value": null - }, - "0/54/11": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 11, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.CurrentMaxRate", - "attribute_name": "CurrentMaxRate", - "value": null - }, - "0/54/12": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 12, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.OverrunCount", - "attribute_name": "OverrunCount", - "value": null - }, - "0/54/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 3 - }, - "0/54/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/54/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/54/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0] - }, - "0/54/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 65528, 65529, 65531, - 65532, 65533 - ] - }, - "0/55/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.PHYRate", - "attribute_name": "PHYRate", - "value": null - }, - "0/55/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.FullDuplex", - "attribute_name": "FullDuplex", - "value": null - }, - "0/55/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.PacketRxCount", - "attribute_name": "PacketRxCount", - "value": 0 - }, - "0/55/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.PacketTxCount", - "attribute_name": "PacketTxCount", - "value": 0 - }, - "0/55/4": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.TxErrCount", - "attribute_name": "TxErrCount", - "value": 0 - }, - "0/55/5": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.CollisionCount", - "attribute_name": "CollisionCount", - "value": 0 - }, - "0/55/6": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 6, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.OverrunCount", - "attribute_name": "OverrunCount", - "value": 0 - }, - "0/55/7": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 7, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.CarrierDetect", - "attribute_name": "CarrierDetect", - "value": null - }, - "0/55/8": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 8, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.TimeSinceReset", - "attribute_name": "TimeSinceReset", - "value": 0 - }, - "0/55/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 3 - }, - "0/55/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/55/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/55/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0] - }, - "0/55/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 - ] - }, - "0/59/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 59, - "cluster_type": "chip.clusters.Objects.Switch", - "cluster_name": "Switch", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.Switch.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/59/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 59, - "cluster_type": "chip.clusters.Objects.Switch", - "cluster_name": "Switch", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.Switch.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/59/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 59, - "cluster_type": "chip.clusters.Objects.Switch", - "cluster_name": "Switch", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.Switch.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/59/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 59, - "cluster_type": "chip.clusters.Objects.Switch", - "cluster_name": "Switch", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.Switch.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/59/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 59, - "cluster_type": "chip.clusters.Objects.Switch", - "cluster_name": "Switch", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.Switch.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [65528, 65529, 65531, 65532, 65533] - }, - "0/60/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.WindowStatus", - "attribute_name": "WindowStatus", - "value": 0 - }, - "0/60/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.AdminFabricIndex", - "attribute_name": "AdminFabricIndex", - "value": null - }, - "0/60/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.AdminVendorId", - "attribute_name": "AdminVendorId", - "value": null - }, - "0/60/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/60/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/60/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/60/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 1, 2] - }, - "0/60/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 65528, 65529, 65531, 65532, 65533] - }, - "0/62/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.NOCs", - "attribute_name": "NOCs", - "value": [ - { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", - "fabricIndex": 1 - } - ] - }, - "0/62/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.Fabrics", - "attribute_name": "Fabrics", - "value": [ - { - "rootPublicKey": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 5, - "label": "", - "fabricIndex": 1 - } - ] - }, - "0/62/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.SupportedFabrics", - "attribute_name": "SupportedFabrics", - "value": 5 - }, - "0/62/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.CommissionedFabrics", - "attribute_name": "CommissionedFabrics", - "value": 1 - }, - "0/62/4": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.TrustedRootCertificates", - "attribute_name": "TrustedRootCertificates", - "value": [ - "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEAs0LOfZc6nU2vCzNP4s4thP60zvr4+hvwAg4WX37RRYWwunhkdRqKdlkwlAgAfG/f2ytVRhbT6dpwVyMdPM0fDcKNQEpARgkAmAwBBSOgri06KQhYgWHhrlHD/AJOSaN4DAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQGxeigcXo8H7pmRHCOma3uT688xoreaDwV8JfFUMUnHUvqg+2GNzFtvfD6MkDaYVPghsXjITZLv5qsHhrUaIO7QY" - ] - }, - "0/62/5": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.CurrentFabricIndex", - "attribute_name": "CurrentFabricIndex", - "value": 1 - }, - "0/62/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/62/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/62/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [1, 3, 5, 8] - }, - "0/62/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 2, 4, 6, 7, 9, 10, 11] - }, - "0/62/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533] - }, - "0/63/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.GroupKeyMap", - "attribute_name": "GroupKeyMap", - "value": [] - }, - "0/63/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.GroupTable", - "attribute_name": "GroupTable", - "value": [] - }, - "0/63/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.MaxGroupsPerFabric", - "attribute_name": "MaxGroupsPerFabric", - "value": 3 - }, - "0/63/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.MaxGroupKeysPerFabric", - "attribute_name": "MaxGroupKeysPerFabric", - "value": 3 - }, - "0/63/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/63/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/63/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [2, 5] - }, - "0/63/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 1, 3, 4] - }, - "0/63/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] - }, - "0/64/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 64, - "cluster_type": "chip.clusters.Objects.FixedLabel", - "cluster_name": "FixedLabel", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.FixedLabel.Attributes.LabelList", - "attribute_name": "LabelList", - "value": [ - { - "label": "room", - "value": "bedroom 2" - }, - { - "label": "orientation", - "value": "North" - }, - { - "label": "floor", - "value": "2" - }, - { - "label": "direction", - "value": "up" - } - ] - }, - "0/64/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 64, - "cluster_type": "chip.clusters.Objects.FixedLabel", - "cluster_name": "FixedLabel", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.FixedLabel.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/64/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 64, - "cluster_type": "chip.clusters.Objects.FixedLabel", - "cluster_name": "FixedLabel", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.FixedLabel.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/64/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 64, - "cluster_type": "chip.clusters.Objects.FixedLabel", - "cluster_name": "FixedLabel", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.FixedLabel.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/64/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 64, - "cluster_type": "chip.clusters.Objects.FixedLabel", - "cluster_name": "FixedLabel", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.FixedLabel.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/64/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 64, - "cluster_type": "chip.clusters.Objects.FixedLabel", - "cluster_name": "FixedLabel", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.FixedLabel.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 65528, 65529, 65531, 65532, 65533] - }, - "0/65/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 65, - "cluster_type": "chip.clusters.Objects.UserLabel", - "cluster_name": "UserLabel", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.UserLabel.Attributes.LabelList", - "attribute_name": "LabelList", - "value": [] - }, - "0/65/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 65, - "cluster_type": "chip.clusters.Objects.UserLabel", - "cluster_name": "UserLabel", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.UserLabel.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/65/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 65, - "cluster_type": "chip.clusters.Objects.UserLabel", - "cluster_name": "UserLabel", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.UserLabel.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/65/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 65, - "cluster_type": "chip.clusters.Objects.UserLabel", - "cluster_name": "UserLabel", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.UserLabel.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/65/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 65, - "cluster_type": "chip.clusters.Objects.UserLabel", - "cluster_name": "UserLabel", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.UserLabel.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/65/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 65, - "cluster_type": "chip.clusters.Objects.UserLabel", - "cluster_name": "UserLabel", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.UserLabel.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 65528, 65529, 65531, 65532, 65533] - }, - "1/3/0": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.IdentifyTime", - "attribute_name": "IdentifyTime", - "value": 0 - }, - "1/3/1": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.IdentifyType", - "attribute_name": "IdentifyType", - "value": 0 - }, - "1/3/65532": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "1/3/65533": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 4 - }, - "1/3/65528": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "1/3/65529": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 64] - }, - "1/3/65531": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 65528, 65529, 65531, 65532, 65533] - }, - "1/4/0": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.NameSupport", - "attribute_name": "NameSupport", - "value": 128 - }, - "1/4/65532": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 1 - }, - "1/4/65533": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 4 - }, - "1/4/65528": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [0, 1, 2, 3] - }, - "1/4/65529": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 1, 2, 3, 4, 5] - }, - "1/4/65531": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 65528, 65529, 65531, 65532, 65533] - }, - "1/6/0": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.OnOff", - "attribute_name": "OnOff", - "value": false - }, - "1/6/16384": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 16384, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.GlobalSceneControl", - "attribute_name": "GlobalSceneControl", - "value": true - }, - "1/6/16385": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 16385, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.OnTime", - "attribute_name": "OnTime", - "value": 0 - }, - "1/6/16386": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 16386, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.OffWaitTime", - "attribute_name": "OffWaitTime", - "value": 0 - }, - "1/6/16387": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 16387, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.StartUpOnOff", - "attribute_name": "StartUpOnOff", - "value": null - }, - "1/6/65532": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 1 - }, - "1/6/65533": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 4 - }, - "1/6/65528": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "1/6/65529": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 1, 2, 64, 65, 66] - }, - "1/6/65531": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [ - 0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533 - ] - }, - "1/8/0": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.CurrentLevel", - "attribute_name": "CurrentLevel", - "value": 254 - }, - "1/8/1": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.RemainingTime", - "attribute_name": "RemainingTime", - "value": 0 - }, - "1/8/2": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.MinLevel", - "attribute_name": "MinLevel", - "value": 1 - }, - "1/8/3": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.MaxLevel", - "attribute_name": "MaxLevel", - "value": 254 - }, - "1/8/4": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.CurrentFrequency", - "attribute_name": "CurrentFrequency", - "value": 0 - }, - "1/8/5": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.MinFrequency", - "attribute_name": "MinFrequency", - "value": 0 - }, - "1/8/6": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 6, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.MaxFrequency", - "attribute_name": "MaxFrequency", - "value": 0 - }, - "1/8/15": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 15, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.Options", - "attribute_name": "Options", - "value": 0 - }, - "1/8/16": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 16, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.OnOffTransitionTime", - "attribute_name": "OnOffTransitionTime", - "value": 0 - }, - "1/8/17": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 17, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.OnLevel", - "attribute_name": "OnLevel", - "value": null - }, - "1/8/18": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 18, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.OnTransitionTime", - "attribute_name": "OnTransitionTime", - "value": 0 - }, - "1/8/19": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 19, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.OffTransitionTime", - "attribute_name": "OffTransitionTime", - "value": 0 - }, - "1/8/20": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 20, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.DefaultMoveRate", - "attribute_name": "DefaultMoveRate", - "value": 50 - }, - "1/8/16384": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 16384, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.StartUpCurrentLevel", - "attribute_name": "StartUpCurrentLevel", - "value": null - }, - "1/8/65532": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 3 - }, - "1/8/65533": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 5 - }, - "1/8/65528": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "1/8/65529": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 1, 2, 3, 4, 5, 6, 7] - }, - "1/8/65531": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [ - 0, 1, 2, 3, 4, 5, 6, 15, 16, 17, 18, 19, 20, 16384, 65528, 65529, - 65531, 65532, 65533 - ] - }, - "1/29/0": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.DeviceTypeList", - "attribute_name": "DeviceTypeList", - "value": [ - { - "type": 257, - "revision": 1 - } - ] - }, - "1/29/1": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ServerList", - "attribute_name": "ServerList", - "value": [3, 4, 6, 8, 29, 768, 1030] - }, - "1/29/2": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ClientList", - "attribute_name": "ClientList", - "value": [] - }, - "1/29/3": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.PartsList", - "attribute_name": "PartsList", - "value": [] - }, - "1/29/65532": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "1/29/65533": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "1/29/65528": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "1/29/65529": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "1/29/65531": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] - }, - "1/768/0": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.CurrentHue", - "attribute_name": "CurrentHue", - "value": 0 - }, - "1/768/1": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.CurrentSaturation", - "attribute_name": "CurrentSaturation", - "value": 0 - }, - "1/768/2": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.RemainingTime", - "attribute_name": "RemainingTime", - "value": 0 - }, - "1/768/3": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.CurrentX", - "attribute_name": "CurrentX", - "value": 24939 - }, - "1/768/4": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.CurrentY", - "attribute_name": "CurrentY", - "value": 24701 - }, - "1/768/7": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 7, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorTemperatureMireds", - "attribute_name": "ColorTemperatureMireds", - "value": 0 - }, - "1/768/8": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 8, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorMode", - "attribute_name": "ColorMode", - "value": 2 - }, - "1/768/15": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 15, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.Options", - "attribute_name": "Options", - "value": 0 - }, - "1/768/16": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.NumberOfPrimaries", - "attribute_name": "NumberOfPrimaries", - "value": 0 - }, - "1/768/16384": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16384, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.EnhancedCurrentHue", - "attribute_name": "EnhancedCurrentHue", - "value": 0 - }, - "1/768/16385": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16385, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.EnhancedColorMode", - "attribute_name": "EnhancedColorMode", - "value": 2 - }, - "1/768/16386": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16386, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorLoopActive", - "attribute_name": "ColorLoopActive", - "value": 0 - }, - "1/768/16387": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16387, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorLoopDirection", - "attribute_name": "ColorLoopDirection", - "value": 0 - }, - "1/768/16388": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16388, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorLoopTime", - "attribute_name": "ColorLoopTime", - "value": 25 - }, - "1/768/16389": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16389, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorLoopStartEnhancedHue", - "attribute_name": "ColorLoopStartEnhancedHue", - "value": 8960 - }, - "1/768/16390": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16390, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorLoopStoredEnhancedHue", - "attribute_name": "ColorLoopStoredEnhancedHue", - "value": 0 - }, - "1/768/16394": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16394, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorCapabilities", - "attribute_name": "ColorCapabilities", - "value": 31 - }, - "1/768/16395": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16395, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorTempPhysicalMinMireds", - "attribute_name": "ColorTempPhysicalMinMireds", - "value": 0 - }, - "1/768/16396": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16396, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorTempPhysicalMaxMireds", - "attribute_name": "ColorTempPhysicalMaxMireds", - "value": 65279 - }, - "1/768/16397": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16397, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.CoupleColorTempToLevelMinMireds", - "attribute_name": "CoupleColorTempToLevelMinMireds", - "value": 0 - }, - "1/768/16400": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16400, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.StartUpColorTemperatureMireds", - "attribute_name": "StartUpColorTemperatureMireds", - "value": 0 - }, - "1/768/65532": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 31 - }, - "1/768/65533": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 5 - }, - "1/768/65528": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "1/768/65529": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 64, 65, 66, 67, 68, 71, 75, 76 - ] - }, - "1/768/65531": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [ - 0, 1, 2, 3, 4, 7, 8, 15, 16, 16384, 16385, 16386, 16387, 16388, - 16389, 16390, 16394, 16395, 16396, 16397, 16400, 65528, 65529, - 65531, 65532, 65533 - ] - }, - "1/1030/0": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 1030, - "cluster_type": "chip.clusters.Objects.OccupancySensing", - "cluster_name": "OccupancySensing", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.OccupancySensing.Attributes.Occupancy", - "attribute_name": "Occupancy", - "value": 0 - }, - "1/1030/1": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 1030, - "cluster_type": "chip.clusters.Objects.OccupancySensing", - "cluster_name": "OccupancySensing", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.OccupancySensing.Attributes.OccupancySensorType", - "attribute_name": "OccupancySensorType", - "value": 0 - }, - "1/1030/2": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 1030, - "cluster_type": "chip.clusters.Objects.OccupancySensing", - "cluster_name": "OccupancySensing", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.OccupancySensing.Attributes.OccupancySensorTypeBitmap", - "attribute_name": "OccupancySensorTypeBitmap", - "value": 1 - }, - "1/1030/65532": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 1030, - "cluster_type": "chip.clusters.Objects.OccupancySensing", - "cluster_name": "OccupancySensing", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.OccupancySensing.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "1/1030/65533": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 1030, - "cluster_type": "chip.clusters.Objects.OccupancySensing", - "cluster_name": "OccupancySensing", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.OccupancySensing.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 3 - }, - "1/1030/65528": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 1030, - "cluster_type": "chip.clusters.Objects.OccupancySensing", - "cluster_name": "OccupancySensing", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.OccupancySensing.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "1/1030/65529": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 1030, - "cluster_type": "chip.clusters.Objects.OccupancySensing", - "cluster_name": "OccupancySensing", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.OccupancySensing.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "1/1030/65531": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 1030, - "cluster_type": "chip.clusters.Objects.OccupancySensing", - "cluster_name": "OccupancySensing", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.OccupancySensing.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 65528, 65529, 65531, 65532, 65533] - } + ], + "0/62/1": [ + { + "rootPublicKey": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", + "vendorId": 65521, + "fabricId": 1, + "nodeId": 5, + "label": "", + "fabricIndex": 1 + } + ], + "0/62/2": 5, + "0/62/3": 1, + "0/62/4": [ + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEAs0LOfZc6nU2vCzNP4s4thP60zvr4+hvwAg4WX37RRYWwunhkdRqKdlkwlAgAfG/f2ytVRhbT6dpwVyMdPM0fDcKNQEpARgkAmAwBBSOgri06KQhYgWHhrlHD/AJOSaN4DAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQGxeigcXo8H7pmRHCOma3uT688xoreaDwV8JfFUMUnHUvqg+2GNzFtvfD6MkDaYVPghsXjITZLv5qsHhrUaIO7QY" + ], + "0/62/5": 1, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 3, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/64/0": [ + { + "label": "room", + "value": "bedroom 2" + }, + { + "label": "orientation", + "value": "North" + }, + { + "label": "floor", + "value": "2" + }, + { + "label": "direction", + "value": "up" + } + ], + "0/64/65532": 0, + "0/64/65533": 1, + "0/64/65528": [], + "0/64/65529": [], + "0/64/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/65/0": [], + "0/65/65532": 0, + "0/65/65533": 1, + "0/65/65528": [], + "0/65/65529": [], + "0/65/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/6/0": false, + "1/6/16384": true, + "1/6/16385": 0, + "1/6/16386": 0, + "1/6/16387": null, + "1/6/65532": 1, + "1/6/65533": 4, + "1/6/65528": [], + "1/6/65529": [0, 1, 2, 64, 65, 66], + "1/6/65531": [ + 0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533 + ], + "1/8/0": 254, + "1/8/1": 0, + "1/8/2": 1, + "1/8/3": 254, + "1/8/4": 0, + "1/8/5": 0, + "1/8/6": 0, + "1/8/15": 0, + "1/8/16": 0, + "1/8/17": null, + "1/8/18": 0, + "1/8/19": 0, + "1/8/20": 50, + "1/8/16384": null, + "1/8/65532": 3, + "1/8/65533": 5, + "1/8/65528": [], + "1/8/65529": [0, 1, 2, 3, 4, 5, 6, 7], + "1/8/65531": [ + 0, 1, 2, 3, 4, 5, 6, 15, 16, 17, 18, 19, 20, 16384, 65528, 65529, + 65531, 65532, 65533 + ], + "1/29/0": [ + { + "type": 257, + "revision": 1 + } + ], + "1/29/1": [3, 4, 6, 8, 29, 768, 1030], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/768/0": 0, + "1/768/1": 0, + "1/768/2": 0, + "1/768/3": 24939, + "1/768/4": 24701, + "1/768/7": 0, + "1/768/8": 2, + "1/768/15": 0, + "1/768/16": 0, + "1/768/16384": 0, + "1/768/16385": 2, + "1/768/16386": 0, + "1/768/16387": 0, + "1/768/16388": 25, + "1/768/16389": 8960, + "1/768/16390": 0, + "1/768/16394": 31, + "1/768/16395": 0, + "1/768/16396": 65279, + "1/768/16397": 0, + "1/768/16400": 0, + "1/768/65532": 31, + "1/768/65533": 5, + "1/768/65528": [], + "1/768/65529": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 64, 65, 66, 67, 68, 71, 75, 76 + ], + "1/768/65531": [ + 0, 1, 2, 3, 4, 7, 8, 15, 16, 16384, 16385, 16386, 16387, 16388, 16389, + 16390, 16394, 16395, 16396, 16397, 16400, 65528, 65529, 65531, 65532, + 65533 + ], + "1/1030/0": 0, + "1/1030/1": 0, + "1/1030/2": 1, + "1/1030/65532": 0, + "1/1030/65533": 3, + "1/1030/65528": [], + "1/1030/65529": [], + "1/1030/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533] }, - "endpoints": [0, 1], - "root_device_type_instance": { - "__type": "", - "repr": "" - }, - "aggregator_device_type_instance": null, - "device_type_instances": [ - { - "__type": "", - "repr": "" - } - ], - "node_devices": [ - { - "__type": "", - "repr": "" - } - ] + "available": true } ], "events": [] diff --git a/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json b/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json index bbe7cf1f2c..794980a8fd 100644 --- a/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json +++ b/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json @@ -14,4229 +14,475 @@ "node_id": 5, "date_commissioned": "2023-01-16T21:07:57.508440", "last_interview": "2023-01-16T21:07:57.508448", - "interview_version": 1, + "interview_version": 2, "attributes": { - "0/4/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.NameSupport", - "attribute_name": "NameSupport", - "value": 128 - }, - "0/4/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 1 - }, - "0/4/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 4 - }, - "0/4/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [0, 1, 2, 3] - }, - "0/4/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 1, 2, 3, 4, 5] - }, - "0/4/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 65528, 65529, 65531, 65532, 65533] - }, - "0/29/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.DeviceTypeList", - "attribute_name": "DeviceTypeList", - "value": [ - { - "type": 22, - "revision": 1 - } - ] - }, - "0/29/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ServerList", - "attribute_name": "ServerList", - "value": [ - 4, 29, 31, 40, 42, 43, 44, 48, 49, 50, 51, 52, 53, 54, 55, 59, 60, - 62, 63, 64, 65 - ] - }, - "0/29/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ClientList", - "attribute_name": "ClientList", - "value": [41] - }, - "0/29/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.PartsList", - "attribute_name": "PartsList", - "value": [1] - }, - "0/29/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/29/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/29/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/29/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/29/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] - }, - "0/31/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.Acl", - "attribute_name": "Acl", - "value": [ - { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 - } - ] - }, - "0/31/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.Extension", - "attribute_name": "Extension", - "value": [] - }, - "0/31/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.SubjectsPerAccessControlEntry", - "attribute_name": "SubjectsPerAccessControlEntry", - "value": 4 - }, - "0/31/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.TargetsPerAccessControlEntry", - "attribute_name": "TargetsPerAccessControlEntry", - "value": 3 - }, - "0/31/4": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.AccessControlEntriesPerFabric", - "attribute_name": "AccessControlEntriesPerFabric", - "value": 3 - }, - "0/31/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/31/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/31/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/31/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/31/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533] - }, - "0/40/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.DataModelRevision", - "attribute_name": "DataModelRevision", - "value": 1 - }, - "0/40/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.VendorName", - "attribute_name": "VendorName", - "value": "Nabu Casa" - }, - "0/40/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.VendorID", - "attribute_name": "VendorID", - "value": 65521 - }, - "0/40/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.ProductName", - "attribute_name": "ProductName", - "value": "M5STAMP Lighting App" - }, - "0/40/4": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.ProductID", - "attribute_name": "ProductID", - "value": 32768 - }, - "0/40/5": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.NodeLabel", - "attribute_name": "NodeLabel", - "value": "" - }, - "0/40/6": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 6, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.Location", - "attribute_name": "Location", - "value": "**REDACTED**" - }, - "0/40/7": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 7, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.HardwareVersion", - "attribute_name": "HardwareVersion", - "value": 0 - }, - "0/40/8": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 8, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.HardwareVersionString", - "attribute_name": "HardwareVersionString", - "value": "v1.0" - }, - "0/40/9": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 9, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.SoftwareVersion", - "attribute_name": "SoftwareVersion", - "value": 1 - }, - "0/40/10": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 10, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.SoftwareVersionString", - "attribute_name": "SoftwareVersionString", - "value": "v1.0" - }, - "0/40/11": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 11, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.ManufacturingDate", - "attribute_name": "ManufacturingDate", - "value": "20200101" - }, - "0/40/12": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 12, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.PartNumber", - "attribute_name": "PartNumber", - "value": "" - }, - "0/40/13": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 13, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.ProductURL", - "attribute_name": "ProductURL", - "value": "" - }, - "0/40/14": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 14, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.ProductLabel", - "attribute_name": "ProductLabel", - "value": "" - }, - "0/40/15": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 15, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.SerialNumber", - "attribute_name": "SerialNumber", - "value": "TEST_SN" - }, - "0/40/16": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 16, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.LocalConfigDisabled", - "attribute_name": "LocalConfigDisabled", - "value": false - }, - "0/40/17": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 17, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.Reachable", - "attribute_name": "Reachable", - "value": true - }, - "0/40/18": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 18, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.UniqueID", - "attribute_name": "UniqueID", - "value": "869D5F986B588B29" - }, + "0/4/0": 128, + "0/4/65532": 1, + "0/4/65533": 4, + "0/4/65528": [0, 1, 2, 3], + "0/4/65529": [0, 1, 2, 3, 4, 5], + "0/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/29/0": [ + { + "type": 22, + "revision": 1 + } + ], + "0/29/1": [ + 4, 29, 31, 40, 42, 43, 44, 48, 49, 50, 51, 52, 53, 54, 55, 59, 60, + 62, 63, 64, 65 + ], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "privilege": 5, + "authMode": 2, + "subjects": [112233], + "targets": null, + "fabricIndex": 1 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 3, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "Nabu Casa", + "0/40/2": 65521, + "0/40/3": "M5STAMP Lighting App", + "0/40/4": 32768, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 0, + "0/40/8": "v1.0", + "0/40/9": 1, + "0/40/10": "v1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "TEST_SN", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "869D5F986B588B29", "0/40/19": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 19, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.CapabilityMinima", - "attribute_name": "CapabilityMinima", - "value": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 - } - }, - "0/40/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/40/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/40/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/40/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/40/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, - 19, 65528, 65529, 65531, 65532, 65533 - ] - }, - "0/42/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.DefaultOtaProviders", - "attribute_name": "DefaultOtaProviders", - "value": [] - }, - "0/42/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.UpdatePossible", - "attribute_name": "UpdatePossible", - "value": true - }, - "0/42/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.UpdateState", - "attribute_name": "UpdateState", - "value": 0 - }, - "0/42/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.UpdateStateProgress", - "attribute_name": "UpdateStateProgress", - "value": 0 - }, - "0/42/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/42/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/42/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/42/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0] - }, - "0/42/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] - }, - "0/43/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 43, - "cluster_type": "chip.clusters.Objects.LocalizationConfiguration", - "cluster_name": "LocalizationConfiguration", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.LocalizationConfiguration.Attributes.ActiveLocale", - "attribute_name": "ActiveLocale", - "value": "en-US" - }, - "0/43/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 43, - "cluster_type": "chip.clusters.Objects.LocalizationConfiguration", - "cluster_name": "LocalizationConfiguration", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.LocalizationConfiguration.Attributes.SupportedLocales", - "attribute_name": "SupportedLocales", - "value": [ - "en-US", - "de-DE", - "fr-FR", - "en-GB", - "es-ES", - "zh-CN", - "it-IT", - "ja-JP" - ] - }, - "0/43/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 43, - "cluster_type": "chip.clusters.Objects.LocalizationConfiguration", - "cluster_name": "LocalizationConfiguration", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.LocalizationConfiguration.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/43/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 43, - "cluster_type": "chip.clusters.Objects.LocalizationConfiguration", - "cluster_name": "LocalizationConfiguration", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.LocalizationConfiguration.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/43/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 43, - "cluster_type": "chip.clusters.Objects.LocalizationConfiguration", - "cluster_name": "LocalizationConfiguration", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.LocalizationConfiguration.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/43/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 43, - "cluster_type": "chip.clusters.Objects.LocalizationConfiguration", - "cluster_name": "LocalizationConfiguration", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.LocalizationConfiguration.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/43/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 43, - "cluster_type": "chip.clusters.Objects.LocalizationConfiguration", - "cluster_name": "LocalizationConfiguration", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.LocalizationConfiguration.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 65528, 65529, 65531, 65532, 65533] - }, - "0/44/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 44, - "cluster_type": "chip.clusters.Objects.TimeFormatLocalization", - "cluster_name": "TimeFormatLocalization", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.TimeFormatLocalization.Attributes.HourFormat", - "attribute_name": "HourFormat", - "value": 0 - }, - "0/44/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 44, - "cluster_type": "chip.clusters.Objects.TimeFormatLocalization", - "cluster_name": "TimeFormatLocalization", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.TimeFormatLocalization.Attributes.ActiveCalendarType", - "attribute_name": "ActiveCalendarType", - "value": 0 - }, - "0/44/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 44, - "cluster_type": "chip.clusters.Objects.TimeFormatLocalization", - "cluster_name": "TimeFormatLocalization", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.TimeFormatLocalization.Attributes.SupportedCalendarTypes", - "attribute_name": "SupportedCalendarTypes", - "value": [0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 7] - }, - "0/44/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 44, - "cluster_type": "chip.clusters.Objects.TimeFormatLocalization", - "cluster_name": "TimeFormatLocalization", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.TimeFormatLocalization.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/44/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 44, - "cluster_type": "chip.clusters.Objects.TimeFormatLocalization", - "cluster_name": "TimeFormatLocalization", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.TimeFormatLocalization.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/44/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 44, - "cluster_type": "chip.clusters.Objects.TimeFormatLocalization", - "cluster_name": "TimeFormatLocalization", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.TimeFormatLocalization.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/44/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 44, - "cluster_type": "chip.clusters.Objects.TimeFormatLocalization", - "cluster_name": "TimeFormatLocalization", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.TimeFormatLocalization.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/44/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 44, - "cluster_type": "chip.clusters.Objects.TimeFormatLocalization", - "cluster_name": "TimeFormatLocalization", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.TimeFormatLocalization.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 65528, 65529, 65531, 65532, 65533] - }, - "0/48/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.Breadcrumb", - "attribute_name": "Breadcrumb", - "value": 0 + "caseSessionsPerFabric": 3, + "subscriptionsPerFabric": 3 }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, + 19, 65528, 65529, 65531, 65532, 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 0, + "0/42/3": 0, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/43/0": "en-US", + "0/43/1": [ + "en-US", + "de-DE", + "fr-FR", + "en-GB", + "es-ES", + "zh-CN", + "it-IT", + "ja-JP" + ], + "0/43/65532": 0, + "0/43/65533": 1, + "0/43/65528": [], + "0/43/65529": [], + "0/43/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "0/44/0": 0, + "0/44/1": 0, + "0/44/2": [0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 7], + "0/44/65532": 0, + "0/44/65533": 1, + "0/44/65528": [], + "0/44/65529": [], + "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, "0/48/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.BasicCommissioningInfo", - "attribute_name": "BasicCommissioningInfo", - "value": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "failSafeExpiryLengthSeconds": 60, + "maxCumulativeFailsafeSeconds": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [], + "0/49/2": 10, + "0/49/3": 30, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "bVdMQU4yLjQ=", + "0/49/7": null, + "0/49/65532": 1, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 2, 4, 6, 8], + "0/49/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533 + ], + "0/50/65532": 0, + "0/50/65533": 1, + "0/50/65528": [1], + "0/50/65529": [0], + "0/50/65531": [65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "name": "WIFI_STA_DEF", + "isOperational": true, + "offPremiseServicesReachableIPv4": null, + "offPremiseServicesReachableIPv6": null, + "hardwareAddress": "YFX5V0js", + "IPv4Addresses": ["wKgBIw=="], + "IPv6Addresses": ["/oAAAAAAAABiVfn//ldI7A=="], + "type": 1 } - }, - "0/48/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.RegulatoryConfig", - "attribute_name": "RegulatoryConfig", - "value": 0 - }, - "0/48/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.LocationCapability", - "attribute_name": "LocationCapability", - "value": 0 - }, - "0/48/4": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.SupportsConcurrentConnection", - "attribute_name": "SupportsConcurrentConnection", - "value": true - }, - "0/48/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/48/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/48/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [1, 3, 5] - }, - "0/48/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 2, 4] - }, - "0/48/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533] - }, - "0/49/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.MaxNetworks", - "attribute_name": "MaxNetworks", - "value": 1 - }, - "0/49/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.Networks", - "attribute_name": "Networks", - "value": [] - }, - "0/49/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.ScanMaxTimeSeconds", - "attribute_name": "ScanMaxTimeSeconds", - "value": 10 - }, - "0/49/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.ConnectMaxTimeSeconds", - "attribute_name": "ConnectMaxTimeSeconds", - "value": 30 - }, - "0/49/4": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.InterfaceEnabled", - "attribute_name": "InterfaceEnabled", - "value": true - }, - "0/49/5": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.LastNetworkingStatus", - "attribute_name": "LastNetworkingStatus", - "value": 0 - }, - "0/49/6": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 6, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.LastNetworkID", - "attribute_name": "LastNetworkID", - "value": "bVdMQU4yLjQ=" - }, - "0/49/7": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 7, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.LastConnectErrorValue", - "attribute_name": "LastConnectErrorValue", - "value": null - }, - "0/49/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 1 - }, - "0/49/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/49/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [1, 5, 7] - }, - "0/49/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 2, 4, 6, 8] - }, - "0/49/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533] - }, - "0/50/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 50, - "cluster_type": "chip.clusters.Objects.DiagnosticLogs", - "cluster_name": "DiagnosticLogs", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.DiagnosticLogs.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/50/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 50, - "cluster_type": "chip.clusters.Objects.DiagnosticLogs", - "cluster_name": "DiagnosticLogs", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.DiagnosticLogs.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/50/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 50, - "cluster_type": "chip.clusters.Objects.DiagnosticLogs", - "cluster_name": "DiagnosticLogs", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.DiagnosticLogs.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [1] - }, - "0/50/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 50, - "cluster_type": "chip.clusters.Objects.DiagnosticLogs", - "cluster_name": "DiagnosticLogs", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.DiagnosticLogs.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0] - }, - "0/50/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 50, - "cluster_type": "chip.clusters.Objects.DiagnosticLogs", - "cluster_name": "DiagnosticLogs", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.DiagnosticLogs.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [65528, 65529, 65531, 65532, 65533] - }, - "0/51/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.NetworkInterfaces", - "attribute_name": "NetworkInterfaces", - "value": [ - { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "YFX5V0js", - "IPv4Addresses": ["wKgBIw=="], - "IPv6Addresses": ["/oAAAAAAAABiVfn//ldI7A=="], - "type": 1 - } - ] - }, - "0/51/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.RebootCount", - "attribute_name": "RebootCount", - "value": 3 - }, - "0/51/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.UpTime", - "attribute_name": "UpTime", - "value": 213 - }, - "0/51/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.TotalOperationalHours", - "attribute_name": "TotalOperationalHours", - "value": 0 - }, - "0/51/4": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.BootReasons", - "attribute_name": "BootReasons", - "value": 1 - }, - "0/51/5": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.ActiveHardwareFaults", - "attribute_name": "ActiveHardwareFaults", - "value": [] - }, - "0/51/6": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 6, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.ActiveRadioFaults", - "attribute_name": "ActiveRadioFaults", - "value": [] - }, - "0/51/7": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 7, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.ActiveNetworkFaults", - "attribute_name": "ActiveNetworkFaults", - "value": [] - }, - "0/51/8": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 8, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.TestEventTriggersEnabled", - "attribute_name": "TestEventTriggersEnabled", - "value": false - }, - "0/51/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/51/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/51/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/51/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0] - }, - "0/51/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 - ] - }, - "0/52/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.ThreadMetrics", - "attribute_name": "ThreadMetrics", - "value": [] - }, - "0/52/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.CurrentHeapFree", - "attribute_name": "CurrentHeapFree", - "value": 166660 - }, - "0/52/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.CurrentHeapUsed", - "attribute_name": "CurrentHeapUsed", - "value": 86332 - }, - "0/52/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.CurrentHeapHighWatermark", - "attribute_name": "CurrentHeapHighWatermark", - "value": 99208 - }, - "0/52/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/52/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/52/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/52/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/52/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] - }, - "0/53/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.Channel", - "attribute_name": "Channel", - "value": { - "TLVValue": null, - "Reason": null + ], + "0/51/1": 3, + "0/51/2": 213, + "0/51/3": 0, + "0/51/4": 1, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/52/0": [], + "0/52/1": 166660, + "0/52/2": 86332, + "0/52/3": 99208, + "0/52/65532": 0, + "0/52/65533": 1, + "0/52/65528": [], + "0/52/65529": [], + "0/52/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/53/0": null, + "0/53/1": null, + "0/53/2": null, + "0/53/3": null, + "0/53/4": null, + "0/53/5": null, + "0/53/6": null, + "0/53/7": null, + "0/53/8": null, + "0/53/9": null, + "0/53/10": null, + "0/53/11": null, + "0/53/12": null, + "0/53/13": null, + "0/53/14": null, + "0/53/15": null, + "0/53/16": null, + "0/53/17": null, + "0/53/18": null, + "0/53/19": null, + "0/53/20": null, + "0/53/21": null, + "0/53/22": null, + "0/53/23": null, + "0/53/24": null, + "0/53/25": null, + "0/53/26": null, + "0/53/27": null, + "0/53/28": null, + "0/53/29": null, + "0/53/30": null, + "0/53/31": null, + "0/53/32": null, + "0/53/33": null, + "0/53/34": null, + "0/53/35": null, + "0/53/36": null, + "0/53/37": null, + "0/53/38": null, + "0/53/39": null, + "0/53/40": null, + "0/53/41": null, + "0/53/42": null, + "0/53/43": null, + "0/53/44": null, + "0/53/45": null, + "0/53/46": null, + "0/53/47": null, + "0/53/48": null, + "0/53/49": null, + "0/53/50": null, + "0/53/51": null, + "0/53/52": null, + "0/53/53": null, + "0/53/54": null, + "0/53/55": null, + "0/53/56": null, + "0/53/57": null, + "0/53/58": null, + "0/53/59": null, + "0/53/60": null, + "0/53/61": null, + "0/53/62": null, + "0/53/65532": 15, + "0/53/65533": 1, + "0/53/65528": [], + "0/53/65529": [0], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, + 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, + 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, + 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 65528, 65529, 65531, 65532, + 65533 + ], + "0/54/0": "BKFR27h1", + "0/54/1": 4, + "0/54/2": 3, + "0/54/3": 3, + "0/54/4": -56, + "0/54/5": null, + "0/54/6": null, + "0/54/7": null, + "0/54/8": null, + "0/54/9": null, + "0/54/10": null, + "0/54/11": null, + "0/54/12": null, + "0/54/65532": 3, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [0], + "0/54/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 65528, 65529, 65531, + 65532, 65533 + ], + "0/55/0": null, + "0/55/1": null, + "0/55/2": 0, + "0/55/3": 0, + "0/55/4": 0, + "0/55/5": 0, + "0/55/6": 0, + "0/55/7": null, + "0/55/8": 0, + "0/55/65532": 3, + "0/55/65533": 1, + "0/55/65528": [], + "0/55/65529": [0], + "0/55/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/59/65532": 0, + "0/59/65533": 1, + "0/59/65528": [], + "0/59/65529": [], + "0/59/65531": [65528, 65529, 65531, 65532, 65533], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", + "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", + "fabricIndex": 1 } - }, - "0/53/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RoutingRole", - "attribute_name": "RoutingRole", - "value": { - "TLVValue": null, - "Reason": null + ], + "0/62/1": [ + { + "rootPublicKey": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", + "vendorId": 65521, + "fabricId": 1, + "nodeId": 5, + "label": "", + "fabricIndex": 1 } - }, - "0/53/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.NetworkName", - "attribute_name": "NetworkName", - "value": { - "TLVValue": null, - "Reason": null + ], + "0/62/2": 5, + "0/62/3": 1, + "0/62/4": [ + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEAs0LOfZc6nU2vCzNP4s4thP60zvr4+hvwAg4WX37RRYWwunhkdRqKdlkwlAgAfG/f2ytVRhbT6dpwVyMdPM0fDcKNQEpARgkAmAwBBSOgri06KQhYgWHhrlHD/AJOSaN4DAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQGxeigcXo8H7pmRHCOma3uT688xoreaDwV8JfFUMUnHUvqg+2GNzFtvfD6MkDaYVPghsXjITZLv5qsHhrUaIO7QY" + ], + "0/62/5": 1, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 3, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/64/0": [ + { + "label": "room", + "value": "bedroom 2" + }, + { + "label": "orientation", + "value": "North" + }, + { + "label": "floor", + "value": "2" + }, + { + "label": "direction", + "value": "up" } - }, - "0/53/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.PanId", - "attribute_name": "PanId", - "value": { - "TLVValue": null, - "Reason": null + ], + "0/64/65532": 0, + "0/64/65533": 1, + "0/64/65528": [], + "0/64/65529": [], + "0/64/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/65/0": [], + "0/65/65532": 0, + "0/65/65533": 1, + "0/65/65528": [], + "0/65/65529": [], + "0/65/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/6/0": false, + "1/6/16384": true, + "1/6/16385": 0, + "1/6/16386": 0, + "1/6/16387": null, + "1/6/65532": 1, + "1/6/65533": 4, + "1/6/65528": [], + "1/6/65529": [0, 1, 2, 64, 65, 66], + "1/6/65531": [ + 0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533 + ], + "1/8/0": 254, + "1/8/1": 0, + "1/8/2": 1, + "1/8/3": 254, + "1/8/4": 0, + "1/8/5": 0, + "1/8/6": 0, + "1/8/15": 0, + "1/8/16": 0, + "1/8/17": null, + "1/8/18": 0, + "1/8/19": 0, + "1/8/20": 50, + "1/8/16384": null, + "1/8/65532": 3, + "1/8/65533": 5, + "1/8/65528": [], + "1/8/65529": [0, 1, 2, 3, 4, 5, 6, 7], + "1/8/65531": [ + 0, 1, 2, 3, 4, 5, 6, 15, 16, 17, 18, 19, 20, 16384, 65528, 65529, + 65531, 65532, 65533 + ], + "1/29/0": [ + { + "type": 257, + "revision": 1 } - }, - "0/53/4": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.ExtendedPanId", - "attribute_name": "ExtendedPanId", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/5": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.MeshLocalPrefix", - "attribute_name": "MeshLocalPrefix", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/6": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 6, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.OverrunCount", - "attribute_name": "OverrunCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/7": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 7, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.NeighborTableList", - "attribute_name": "NeighborTableList", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/8": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 8, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RouteTableList", - "attribute_name": "RouteTableList", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/9": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 9, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.PartitionId", - "attribute_name": "PartitionId", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/10": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 10, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.Weighting", - "attribute_name": "Weighting", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/11": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 11, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.DataVersion", - "attribute_name": "DataVersion", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/12": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 12, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.StableDataVersion", - "attribute_name": "StableDataVersion", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/13": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 13, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.LeaderRouterId", - "attribute_name": "LeaderRouterId", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/14": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 14, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.DetachedRoleCount", - "attribute_name": "DetachedRoleCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/15": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 15, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.ChildRoleCount", - "attribute_name": "ChildRoleCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/16": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 16, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RouterRoleCount", - "attribute_name": "RouterRoleCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/17": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 17, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.LeaderRoleCount", - "attribute_name": "LeaderRoleCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/18": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 18, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.AttachAttemptCount", - "attribute_name": "AttachAttemptCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/19": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 19, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.PartitionIdChangeCount", - "attribute_name": "PartitionIdChangeCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/20": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 20, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.BetterPartitionAttachAttemptCount", - "attribute_name": "BetterPartitionAttachAttemptCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/21": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 21, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.ParentChangeCount", - "attribute_name": "ParentChangeCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/22": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 22, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxTotalCount", - "attribute_name": "TxTotalCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/23": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 23, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxUnicastCount", - "attribute_name": "TxUnicastCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/24": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 24, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxBroadcastCount", - "attribute_name": "TxBroadcastCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/25": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 25, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxAckRequestedCount", - "attribute_name": "TxAckRequestedCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/26": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 26, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxAckedCount", - "attribute_name": "TxAckedCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/27": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 27, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxNoAckRequestedCount", - "attribute_name": "TxNoAckRequestedCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/28": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 28, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxDataCount", - "attribute_name": "TxDataCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/29": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 29, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxDataPollCount", - "attribute_name": "TxDataPollCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/30": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 30, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxBeaconCount", - "attribute_name": "TxBeaconCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/31": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 31, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxBeaconRequestCount", - "attribute_name": "TxBeaconRequestCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/32": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 32, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxOtherCount", - "attribute_name": "TxOtherCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/33": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 33, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxRetryCount", - "attribute_name": "TxRetryCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/34": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 34, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxDirectMaxRetryExpiryCount", - "attribute_name": "TxDirectMaxRetryExpiryCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/35": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 35, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxIndirectMaxRetryExpiryCount", - "attribute_name": "TxIndirectMaxRetryExpiryCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/36": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 36, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxErrCcaCount", - "attribute_name": "TxErrCcaCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/37": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 37, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxErrAbortCount", - "attribute_name": "TxErrAbortCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/38": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 38, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxErrBusyChannelCount", - "attribute_name": "TxErrBusyChannelCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/39": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 39, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxTotalCount", - "attribute_name": "RxTotalCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/40": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 40, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxUnicastCount", - "attribute_name": "RxUnicastCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/41": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 41, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxBroadcastCount", - "attribute_name": "RxBroadcastCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/42": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 42, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxDataCount", - "attribute_name": "RxDataCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/43": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 43, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxDataPollCount", - "attribute_name": "RxDataPollCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/44": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 44, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxBeaconCount", - "attribute_name": "RxBeaconCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/45": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 45, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxBeaconRequestCount", - "attribute_name": "RxBeaconRequestCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/46": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 46, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxOtherCount", - "attribute_name": "RxOtherCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/47": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 47, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxAddressFilteredCount", - "attribute_name": "RxAddressFilteredCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/48": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 48, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxDestAddrFilteredCount", - "attribute_name": "RxDestAddrFilteredCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/49": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 49, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxDuplicatedCount", - "attribute_name": "RxDuplicatedCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/50": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 50, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxErrNoFrameCount", - "attribute_name": "RxErrNoFrameCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/51": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 51, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxErrUnknownNeighborCount", - "attribute_name": "RxErrUnknownNeighborCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/52": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 52, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxErrInvalidSrcAddrCount", - "attribute_name": "RxErrInvalidSrcAddrCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/53": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 53, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxErrSecCount", - "attribute_name": "RxErrSecCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/54": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 54, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxErrFcsCount", - "attribute_name": "RxErrFcsCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/55": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 55, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxErrOtherCount", - "attribute_name": "RxErrOtherCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/56": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 56, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.ActiveTimestamp", - "attribute_name": "ActiveTimestamp", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/57": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 57, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.PendingTimestamp", - "attribute_name": "PendingTimestamp", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/58": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 58, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.Delay", - "attribute_name": "Delay", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/59": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 59, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.SecurityPolicy", - "attribute_name": "SecurityPolicy", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/60": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 60, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.ChannelPage0Mask", - "attribute_name": "ChannelPage0Mask", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/61": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 61, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.OperationalDatasetComponents", - "attribute_name": "OperationalDatasetComponents", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/62": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 62, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.ActiveNetworkFaultsList", - "attribute_name": "ActiveNetworkFaultsList", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 15 - }, - "0/53/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/53/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/53/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0] - }, - "0/53/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, - 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, - 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, - 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 65528, 65529, - 65531, 65532, 65533 - ] - }, - "0/54/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.Bssid", - "attribute_name": "Bssid", - "value": "BKFR27h1" - }, - "0/54/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.SecurityType", - "attribute_name": "SecurityType", - "value": 4 - }, - "0/54/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.WiFiVersion", - "attribute_name": "WiFiVersion", - "value": 3 - }, - "0/54/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.ChannelNumber", - "attribute_name": "ChannelNumber", - "value": 3 - }, - "0/54/4": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.Rssi", - "attribute_name": "Rssi", - "value": -56 - }, - "0/54/5": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.BeaconLostCount", - "attribute_name": "BeaconLostCount", - "value": null - }, - "0/54/6": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 6, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.BeaconRxCount", - "attribute_name": "BeaconRxCount", - "value": null - }, - "0/54/7": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 7, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.PacketMulticastRxCount", - "attribute_name": "PacketMulticastRxCount", - "value": null - }, - "0/54/8": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 8, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.PacketMulticastTxCount", - "attribute_name": "PacketMulticastTxCount", - "value": null - }, - "0/54/9": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 9, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.PacketUnicastRxCount", - "attribute_name": "PacketUnicastRxCount", - "value": null - }, - "0/54/10": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 10, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.PacketUnicastTxCount", - "attribute_name": "PacketUnicastTxCount", - "value": null - }, - "0/54/11": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 11, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.CurrentMaxRate", - "attribute_name": "CurrentMaxRate", - "value": null - }, - "0/54/12": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 12, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.OverrunCount", - "attribute_name": "OverrunCount", - "value": null - }, - "0/54/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 3 - }, - "0/54/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/54/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/54/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0] - }, - "0/54/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 65528, 65529, 65531, - 65532, 65533 - ] - }, - "0/55/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.PHYRate", - "attribute_name": "PHYRate", - "value": null - }, - "0/55/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.FullDuplex", - "attribute_name": "FullDuplex", - "value": null - }, - "0/55/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.PacketRxCount", - "attribute_name": "PacketRxCount", - "value": 0 - }, - "0/55/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.PacketTxCount", - "attribute_name": "PacketTxCount", - "value": 0 - }, - "0/55/4": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.TxErrCount", - "attribute_name": "TxErrCount", - "value": 0 - }, - "0/55/5": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.CollisionCount", - "attribute_name": "CollisionCount", - "value": 0 - }, - "0/55/6": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 6, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.OverrunCount", - "attribute_name": "OverrunCount", - "value": 0 - }, - "0/55/7": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 7, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.CarrierDetect", - "attribute_name": "CarrierDetect", - "value": null - }, - "0/55/8": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 8, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.TimeSinceReset", - "attribute_name": "TimeSinceReset", - "value": 0 - }, - "0/55/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 3 - }, - "0/55/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/55/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/55/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0] - }, - "0/55/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 - ] - }, - "0/59/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 59, - "cluster_type": "chip.clusters.Objects.Switch", - "cluster_name": "Switch", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.Switch.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/59/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 59, - "cluster_type": "chip.clusters.Objects.Switch", - "cluster_name": "Switch", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.Switch.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/59/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 59, - "cluster_type": "chip.clusters.Objects.Switch", - "cluster_name": "Switch", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.Switch.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/59/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 59, - "cluster_type": "chip.clusters.Objects.Switch", - "cluster_name": "Switch", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.Switch.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/59/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 59, - "cluster_type": "chip.clusters.Objects.Switch", - "cluster_name": "Switch", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.Switch.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [65528, 65529, 65531, 65532, 65533] - }, - "0/60/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.WindowStatus", - "attribute_name": "WindowStatus", - "value": 0 - }, - "0/60/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.AdminFabricIndex", - "attribute_name": "AdminFabricIndex", - "value": null - }, - "0/60/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.AdminVendorId", - "attribute_name": "AdminVendorId", - "value": null - }, - "0/60/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/60/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/60/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/60/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 1, 2] - }, - "0/60/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 65528, 65529, 65531, 65532, 65533] - }, - "0/62/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.NOCs", - "attribute_name": "NOCs", - "value": [ - { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", - "fabricIndex": 1 - } - ] - }, - "0/62/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.Fabrics", - "attribute_name": "Fabrics", - "value": [ - { - "rootPublicKey": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 5, - "label": "", - "fabricIndex": 1 - } - ] - }, - "0/62/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.SupportedFabrics", - "attribute_name": "SupportedFabrics", - "value": 5 - }, - "0/62/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.CommissionedFabrics", - "attribute_name": "CommissionedFabrics", - "value": 1 - }, - "0/62/4": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.TrustedRootCertificates", - "attribute_name": "TrustedRootCertificates", - "value": [ - "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEAs0LOfZc6nU2vCzNP4s4thP60zvr4+hvwAg4WX37RRYWwunhkdRqKdlkwlAgAfG/f2ytVRhbT6dpwVyMdPM0fDcKNQEpARgkAmAwBBSOgri06KQhYgWHhrlHD/AJOSaN4DAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQGxeigcXo8H7pmRHCOma3uT688xoreaDwV8JfFUMUnHUvqg+2GNzFtvfD6MkDaYVPghsXjITZLv5qsHhrUaIO7QY" - ] - }, - "0/62/5": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.CurrentFabricIndex", - "attribute_name": "CurrentFabricIndex", - "value": 1 - }, - "0/62/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/62/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/62/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [1, 3, 5, 8] - }, - "0/62/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 2, 4, 6, 7, 9, 10, 11] - }, - "0/62/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533] - }, - "0/63/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.GroupKeyMap", - "attribute_name": "GroupKeyMap", - "value": [] - }, - "0/63/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.GroupTable", - "attribute_name": "GroupTable", - "value": [] - }, - "0/63/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.MaxGroupsPerFabric", - "attribute_name": "MaxGroupsPerFabric", - "value": 3 - }, - "0/63/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.MaxGroupKeysPerFabric", - "attribute_name": "MaxGroupKeysPerFabric", - "value": 3 - }, - "0/63/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/63/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/63/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [2, 5] - }, - "0/63/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 1, 3, 4] - }, - "0/63/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] - }, - "0/64/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 64, - "cluster_type": "chip.clusters.Objects.FixedLabel", - "cluster_name": "FixedLabel", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.FixedLabel.Attributes.LabelList", - "attribute_name": "LabelList", - "value": [ - { - "label": "room", - "value": "bedroom 2" - }, - { - "label": "orientation", - "value": "North" - }, - { - "label": "floor", - "value": "2" - }, - { - "label": "direction", - "value": "up" - } - ] - }, - "0/64/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 64, - "cluster_type": "chip.clusters.Objects.FixedLabel", - "cluster_name": "FixedLabel", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.FixedLabel.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/64/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 64, - "cluster_type": "chip.clusters.Objects.FixedLabel", - "cluster_name": "FixedLabel", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.FixedLabel.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/64/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 64, - "cluster_type": "chip.clusters.Objects.FixedLabel", - "cluster_name": "FixedLabel", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.FixedLabel.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/64/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 64, - "cluster_type": "chip.clusters.Objects.FixedLabel", - "cluster_name": "FixedLabel", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.FixedLabel.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/64/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 64, - "cluster_type": "chip.clusters.Objects.FixedLabel", - "cluster_name": "FixedLabel", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.FixedLabel.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 65528, 65529, 65531, 65532, 65533] - }, - "0/65/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 65, - "cluster_type": "chip.clusters.Objects.UserLabel", - "cluster_name": "UserLabel", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.UserLabel.Attributes.LabelList", - "attribute_name": "LabelList", - "value": [] - }, - "0/65/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 65, - "cluster_type": "chip.clusters.Objects.UserLabel", - "cluster_name": "UserLabel", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.UserLabel.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/65/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 65, - "cluster_type": "chip.clusters.Objects.UserLabel", - "cluster_name": "UserLabel", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.UserLabel.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/65/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 65, - "cluster_type": "chip.clusters.Objects.UserLabel", - "cluster_name": "UserLabel", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.UserLabel.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/65/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 65, - "cluster_type": "chip.clusters.Objects.UserLabel", - "cluster_name": "UserLabel", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.UserLabel.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/65/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 65, - "cluster_type": "chip.clusters.Objects.UserLabel", - "cluster_name": "UserLabel", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.UserLabel.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 65528, 65529, 65531, 65532, 65533] - }, - "1/3/0": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.IdentifyTime", - "attribute_name": "IdentifyTime", - "value": 0 - }, - "1/3/1": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.IdentifyType", - "attribute_name": "IdentifyType", - "value": 0 - }, - "1/3/65532": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "1/3/65533": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 4 - }, - "1/3/65528": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "1/3/65529": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 64] - }, - "1/3/65531": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 65528, 65529, 65531, 65532, 65533] - }, - "1/4/0": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.NameSupport", - "attribute_name": "NameSupport", - "value": 128 - }, - "1/4/65532": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 1 - }, - "1/4/65533": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 4 - }, - "1/4/65528": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [0, 1, 2, 3] - }, - "1/4/65529": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 1, 2, 3, 4, 5] - }, - "1/4/65531": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 65528, 65529, 65531, 65532, 65533] - }, - "1/6/0": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.OnOff", - "attribute_name": "OnOff", - "value": false - }, - "1/6/16384": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 16384, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.GlobalSceneControl", - "attribute_name": "GlobalSceneControl", - "value": true - }, - "1/6/16385": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 16385, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.OnTime", - "attribute_name": "OnTime", - "value": 0 - }, - "1/6/16386": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 16386, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.OffWaitTime", - "attribute_name": "OffWaitTime", - "value": 0 - }, - "1/6/16387": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 16387, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.StartUpOnOff", - "attribute_name": "StartUpOnOff", - "value": null - }, - "1/6/65532": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 1 - }, - "1/6/65533": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 4 - }, - "1/6/65528": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "1/6/65529": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 1, 2, 64, 65, 66] - }, - "1/6/65531": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [ - 0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533 - ] - }, - "1/8/0": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.CurrentLevel", - "attribute_name": "CurrentLevel", - "value": 254 - }, - "1/8/1": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.RemainingTime", - "attribute_name": "RemainingTime", - "value": 0 - }, - "1/8/2": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.MinLevel", - "attribute_name": "MinLevel", - "value": 1 - }, - "1/8/3": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.MaxLevel", - "attribute_name": "MaxLevel", - "value": 254 - }, - "1/8/4": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.CurrentFrequency", - "attribute_name": "CurrentFrequency", - "value": 0 - }, - "1/8/5": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.MinFrequency", - "attribute_name": "MinFrequency", - "value": 0 - }, - "1/8/6": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 6, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.MaxFrequency", - "attribute_name": "MaxFrequency", - "value": 0 - }, - "1/8/15": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 15, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.Options", - "attribute_name": "Options", - "value": 0 - }, - "1/8/16": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 16, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.OnOffTransitionTime", - "attribute_name": "OnOffTransitionTime", - "value": 0 - }, - "1/8/17": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 17, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.OnLevel", - "attribute_name": "OnLevel", - "value": null - }, - "1/8/18": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 18, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.OnTransitionTime", - "attribute_name": "OnTransitionTime", - "value": 0 - }, - "1/8/19": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 19, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.OffTransitionTime", - "attribute_name": "OffTransitionTime", - "value": 0 - }, - "1/8/20": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 20, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.DefaultMoveRate", - "attribute_name": "DefaultMoveRate", - "value": 50 - }, - "1/8/16384": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 16384, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.StartUpCurrentLevel", - "attribute_name": "StartUpCurrentLevel", - "value": null - }, - "1/8/65532": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 3 - }, - "1/8/65533": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 5 - }, - "1/8/65528": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "1/8/65529": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 1, 2, 3, 4, 5, 6, 7] - }, - "1/8/65531": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [ - 0, 1, 2, 3, 4, 5, 6, 15, 16, 17, 18, 19, 20, 16384, 65528, 65529, - 65531, 65532, 65533 - ] - }, - "1/29/0": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.DeviceTypeList", - "attribute_name": "DeviceTypeList", - "value": [ - { - "type": 257, - "revision": 1 - } - ] - }, - "1/29/1": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ServerList", - "attribute_name": "ServerList", - "value": [3, 4, 6, 8, 29, 768, 1030] - }, - "1/29/2": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ClientList", - "attribute_name": "ClientList", - "value": [] - }, - "1/29/3": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.PartsList", - "attribute_name": "PartsList", - "value": [] - }, - "1/29/65532": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "1/29/65533": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "1/29/65528": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "1/29/65529": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "1/29/65531": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] - }, - "1/768/0": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.CurrentHue", - "attribute_name": "CurrentHue", - "value": 0 - }, - "1/768/1": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.CurrentSaturation", - "attribute_name": "CurrentSaturation", - "value": 0 - }, - "1/768/2": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.RemainingTime", - "attribute_name": "RemainingTime", - "value": 0 - }, - "1/768/3": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.CurrentX", - "attribute_name": "CurrentX", - "value": 24939 - }, - "1/768/4": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.CurrentY", - "attribute_name": "CurrentY", - "value": 24701 - }, - "1/768/7": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 7, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorTemperatureMireds", - "attribute_name": "ColorTemperatureMireds", - "value": 0 - }, - "1/768/8": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 8, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorMode", - "attribute_name": "ColorMode", - "value": 2 - }, - "1/768/15": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 15, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.Options", - "attribute_name": "Options", - "value": 0 - }, - "1/768/16": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.NumberOfPrimaries", - "attribute_name": "NumberOfPrimaries", - "value": 0 - }, - "1/768/16384": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16384, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.EnhancedCurrentHue", - "attribute_name": "EnhancedCurrentHue", - "value": 0 - }, - "1/768/16385": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16385, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.EnhancedColorMode", - "attribute_name": "EnhancedColorMode", - "value": 2 - }, - "1/768/16386": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16386, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorLoopActive", - "attribute_name": "ColorLoopActive", - "value": 0 - }, - "1/768/16387": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16387, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorLoopDirection", - "attribute_name": "ColorLoopDirection", - "value": 0 - }, - "1/768/16388": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16388, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorLoopTime", - "attribute_name": "ColorLoopTime", - "value": 25 - }, - "1/768/16389": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16389, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorLoopStartEnhancedHue", - "attribute_name": "ColorLoopStartEnhancedHue", - "value": 8960 - }, - "1/768/16390": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16390, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorLoopStoredEnhancedHue", - "attribute_name": "ColorLoopStoredEnhancedHue", - "value": 0 - }, - "1/768/16394": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16394, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorCapabilities", - "attribute_name": "ColorCapabilities", - "value": 31 - }, - "1/768/16395": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16395, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorTempPhysicalMinMireds", - "attribute_name": "ColorTempPhysicalMinMireds", - "value": 0 - }, - "1/768/16396": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16396, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorTempPhysicalMaxMireds", - "attribute_name": "ColorTempPhysicalMaxMireds", - "value": 65279 - }, - "1/768/16397": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16397, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.CoupleColorTempToLevelMinMireds", - "attribute_name": "CoupleColorTempToLevelMinMireds", - "value": 0 - }, - "1/768/16400": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16400, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.StartUpColorTemperatureMireds", - "attribute_name": "StartUpColorTemperatureMireds", - "value": 0 - }, - "1/768/65532": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 31 - }, - "1/768/65533": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 5 - }, - "1/768/65528": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "1/768/65529": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 64, 65, 66, 67, 68, 71, 75, 76 - ] - }, - "1/768/65531": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [ - 0, 1, 2, 3, 4, 7, 8, 15, 16, 16384, 16385, 16386, 16387, 16388, - 16389, 16390, 16394, 16395, 16396, 16397, 16400, 65528, 65529, - 65531, 65532, 65533 - ] - }, - "1/1030/0": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 1030, - "cluster_type": "chip.clusters.Objects.OccupancySensing", - "cluster_name": "OccupancySensing", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.OccupancySensing.Attributes.Occupancy", - "attribute_name": "Occupancy", - "value": 0 - }, - "1/1030/1": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 1030, - "cluster_type": "chip.clusters.Objects.OccupancySensing", - "cluster_name": "OccupancySensing", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.OccupancySensing.Attributes.OccupancySensorType", - "attribute_name": "OccupancySensorType", - "value": 0 - }, - "1/1030/2": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 1030, - "cluster_type": "chip.clusters.Objects.OccupancySensing", - "cluster_name": "OccupancySensing", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.OccupancySensing.Attributes.OccupancySensorTypeBitmap", - "attribute_name": "OccupancySensorTypeBitmap", - "value": 1 - }, - "1/1030/65532": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 1030, - "cluster_type": "chip.clusters.Objects.OccupancySensing", - "cluster_name": "OccupancySensing", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.OccupancySensing.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "1/1030/65533": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 1030, - "cluster_type": "chip.clusters.Objects.OccupancySensing", - "cluster_name": "OccupancySensing", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.OccupancySensing.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 3 - }, - "1/1030/65528": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 1030, - "cluster_type": "chip.clusters.Objects.OccupancySensing", - "cluster_name": "OccupancySensing", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.OccupancySensing.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "1/1030/65529": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 1030, - "cluster_type": "chip.clusters.Objects.OccupancySensing", - "cluster_name": "OccupancySensing", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.OccupancySensing.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "1/1030/65531": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 1030, - "cluster_type": "chip.clusters.Objects.OccupancySensing", - "cluster_name": "OccupancySensing", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.OccupancySensing.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 65528, 65529, 65531, 65532, 65533] - } + ], + "1/29/1": [3, 4, 6, 8, 29, 768, 1030], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/768/0": 0, + "1/768/1": 0, + "1/768/2": 0, + "1/768/3": 24939, + "1/768/4": 24701, + "1/768/7": 0, + "1/768/8": 2, + "1/768/15": 0, + "1/768/16": 0, + "1/768/16384": 0, + "1/768/16385": 2, + "1/768/16386": 0, + "1/768/16387": 0, + "1/768/16388": 25, + "1/768/16389": 8960, + "1/768/16390": 0, + "1/768/16394": 31, + "1/768/16395": 0, + "1/768/16396": 65279, + "1/768/16397": 0, + "1/768/16400": 0, + "1/768/65532": 31, + "1/768/65533": 5, + "1/768/65528": [], + "1/768/65529": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 64, 65, 66, 67, 68, 71, 75, 76 + ], + "1/768/65531": [ + 0, 1, 2, 3, 4, 7, 8, 15, 16, 16384, 16385, 16386, 16387, 16388, + 16389, 16390, 16394, 16395, 16396, 16397, 16400, 65528, 65529, + 65531, 65532, 65533 + ], + "1/1030/0": 0, + "1/1030/1": 0, + "1/1030/2": 1, + "1/1030/65532": 0, + "1/1030/65533": 3, + "1/1030/65528": [], + "1/1030/65529": [], + "1/1030/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533] }, - "endpoints": [0, 1], - "root_device_type_instance": { - "__type": "", - "repr": "" - }, - "aggregator_device_type_instance": null, - "device_type_instances": [ - { - "__type": "", - "repr": "" - } - ], - "node_devices": [ - { - "__type": "", - "repr": "" - } - ] + "available": true } ], "events": [] diff --git a/tests/components/matter/fixtures/nodes/color-temperature-light.json b/tests/components/matter/fixtures/nodes/color-temperature-light.json index c0a5a0d1d2..2155f20fe3 100644 --- a/tests/components/matter/fixtures/nodes/color-temperature-light.json +++ b/tests/components/matter/fixtures/nodes/color-temperature-light.json @@ -2,2109 +2,316 @@ "node_id": 1, "date_commissioned": "2023-01-31T03:59:47.454727", "last_interview": "2023-01-31T03:59:47.454728", - "interview_version": 1, + "interview_version": 2, "attributes": { - "0/29/0": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.DeviceTypeList", - "attribute_name": "DeviceTypeList", - "value": [ - { - "type": 22, - "revision": 1 - } - ] - }, - "0/29/1": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ServerList", - "attribute_name": "ServerList", - "value": [29, 31, 40, 48, 49, 51, 60, 62, 63] - }, - "0/29/2": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ClientList", - "attribute_name": "ClientList", - "value": [] - }, - "0/29/3": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.PartsList", - "attribute_name": "PartsList", - "value": [1, 2, 3, 4, 5, 6, 7] - }, - "0/29/65532": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/29/65533": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/29/65528": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/29/65529": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/29/65531": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] - }, - "0/31/0": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.Acl", - "attribute_name": "Acl", - "value": [ - { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 52 - } - ] - }, - "0/31/1": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.Extension", - "attribute_name": "Extension", - "value": [] - }, - "0/31/2": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.SubjectsPerAccessControlEntry", - "attribute_name": "SubjectsPerAccessControlEntry", - "value": 4 - }, - "0/31/3": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.TargetsPerAccessControlEntry", - "attribute_name": "TargetsPerAccessControlEntry", - "value": 3 - }, - "0/31/4": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.AccessControlEntriesPerFabric", - "attribute_name": "AccessControlEntriesPerFabric", - "value": 3 - }, - "0/31/65532": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/31/65533": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/31/65528": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/31/65529": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/31/65531": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533] - }, - "0/40/0": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "BasicInformation", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.DataModelRevision", - "attribute_name": "DataModelRevision", - "value": 1 - }, - "0/40/1": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "BasicInformation", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.VendorName", - "attribute_name": "VendorName", - "value": "Signify" - }, - "0/40/2": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "BasicInformation", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.VendorID", - "attribute_name": "VendorID", - "value": 4107 - }, - "0/40/3": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "BasicInformation", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.ProductName", - "attribute_name": "ProductName", - "value": "Mock Extended Color Light" - }, - "0/40/4": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "BasicInformation", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.ProductID", - "attribute_name": "ProductID", - "value": 2 - }, - "0/40/5": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "BasicInformation", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.NodeLabel", - "attribute_name": "NodeLabel", - "value": "Mock Color Temperature Light" - }, - "0/40/6": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "BasicInformation", - "attribute_id": 6, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.Location", - "attribute_name": "Location", - "value": "**REDACTED**" - }, - "0/40/7": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "BasicInformation", - "attribute_id": 7, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.HardwareVersion", - "attribute_name": "HardwareVersion", - "value": 1 - }, - "0/40/8": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "BasicInformation", - "attribute_id": 8, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.HardwareVersionString", - "attribute_name": "HardwareVersionString", - "value": "1" - }, - "0/40/9": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "BasicInformation", - "attribute_id": 9, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.SoftwareVersion", - "attribute_name": "SoftwareVersion", - "value": 65536 - }, - "0/40/10": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "BasicInformation", - "attribute_id": 10, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.SoftwareVersionString", - "attribute_name": "SoftwareVersionString", - "value": "1.0.0" - }, - "0/40/17": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "BasicInformation", - "attribute_id": 17, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.Reachable", - "attribute_name": "Reachable", - "value": true - }, - "0/40/18": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "BasicInformation", - "attribute_id": 18, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.UniqueID", - "attribute_name": "UniqueID", - "value": "mock-color-temperature-light" - }, + "0/29/0": [ + { + "type": 22, + "revision": 1 + } + ], + "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63], + "0/29/2": [], + "0/29/3": [1, 2, 3, 4, 5, 6, 7], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "privilege": 5, + "authMode": 2, + "subjects": [112233], + "targets": null, + "fabricIndex": 52 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 3, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "Signify", + "0/40/2": 4107, + "0/40/3": "Mock Extended Color Light", + "0/40/4": 2, + "0/40/5": "Mock Color Temperature Light", + "0/40/6": "**REDACTED**", + "0/40/7": 1, + "0/40/8": "1", + "0/40/9": 65536, + "0/40/10": "1.0.0", + "0/40/17": true, + "0/40/18": "mock-color-temperature-light", "0/40/19": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "BasicInformation", - "attribute_id": 19, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.CapabilityMinima", - "attribute_name": "CapabilityMinima", - "value": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 65535 - } - }, - "0/40/65532": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "BasicInformation", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/40/65533": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "BasicInformation", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/40/65528": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "BasicInformation", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/40/65529": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "BasicInformation", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/40/65531": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "BasicInformation", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 17, 18, 19, 65528, 65529, 65531, - 65532, 65533 - ] - }, - "0/48/0": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.Breadcrumb", - "attribute_name": "Breadcrumb", - "value": 0 + "caseSessionsPerFabric": 3, + "subscriptionsPerFabric": 65535 }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 17, 18, 19, 65528, 65529, 65531, 65532, + 65533 + ], + "0/48/0": 0, "0/48/1": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.BasicCommissioningInfo", - "attribute_name": "BasicCommissioningInfo", - "value": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "failSafeExpiryLengthSeconds": 60, + "maxCumulativeFailsafeSeconds": 900 + }, + "0/48/2": 2, + "0/48/3": 2, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "networkID": "ZXRoMA==", + "connected": true } - }, - "0/48/2": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.RegulatoryConfig", - "attribute_name": "RegulatoryConfig", - "value": 2 - }, - "0/48/3": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.LocationCapability", - "attribute_name": "LocationCapability", - "value": 2 - }, - "0/48/4": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.SupportsConcurrentConnection", - "attribute_name": "SupportsConcurrentConnection", - "value": true - }, - "0/48/65532": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/48/65533": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/48/65528": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [1, 3, 5] - }, - "0/48/65529": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 2, 4] - }, - "0/48/65531": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533] - }, - "0/49/0": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.MaxNetworks", - "attribute_name": "MaxNetworks", - "value": 1 - }, - "0/49/1": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.Networks", - "attribute_name": "Networks", - "value": [ - { - "networkID": "ZXRoMA==", - "connected": true - } - ] - }, - "0/49/4": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.InterfaceEnabled", - "attribute_name": "InterfaceEnabled", - "value": true - }, - "0/49/5": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.LastNetworkingStatus", - "attribute_name": "LastNetworkingStatus", - "value": null - }, - "0/49/6": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 6, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.LastNetworkID", - "attribute_name": "LastNetworkID", - "value": null - }, - "0/49/7": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 7, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.LastConnectErrorValue", - "attribute_name": "LastConnectErrorValue", - "value": null - }, - "0/49/65532": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 4 - }, - "0/49/65533": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/49/65528": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/49/65529": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/49/65531": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533] - }, - "0/51/0": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.NetworkInterfaces", - "attribute_name": "NetworkInterfaces", - "value": [ - { - "name": "eth1", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "ABeILIy4", - "IPv4Addresses": ["CjwBuw=="], - "IPv6Addresses": [ - "/VqgxiAxQiYCF4j//iyMuA==", - "IAEEcLs7AAYCF4j//iyMuA==", - "/oAAAAAAAAACF4j//iyMuA==" - ], - "type": 0 - }, - { - "name": "eth0", - "isOperational": false, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAN/ESDO", - "IPv4Addresses": [], - "IPv6Addresses": [], - "type": 2 - }, - { - "name": "lo", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAAAAAAA", - "IPv4Addresses": ["fwAAAQ=="], - "IPv6Addresses": ["AAAAAAAAAAAAAAAAAAAAAQ=="], - "type": 0 - } - ] - }, - "0/51/1": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.RebootCount", - "attribute_name": "RebootCount", - "value": 4 - }, - "0/51/2": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.UpTime", - "attribute_name": "UpTime", - "value": 834681 - }, - "0/51/3": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.TotalOperationalHours", - "attribute_name": "TotalOperationalHours", - "value": 231 - }, - "0/51/4": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.BootReasons", - "attribute_name": "BootReasons", - "value": 0 - }, - "0/51/5": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.ActiveHardwareFaults", - "attribute_name": "ActiveHardwareFaults", - "value": [] - }, - "0/51/6": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 6, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.ActiveRadioFaults", - "attribute_name": "ActiveRadioFaults", - "value": [] - }, - "0/51/7": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 7, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.ActiveNetworkFaults", - "attribute_name": "ActiveNetworkFaults", - "value": [] - }, - "0/51/8": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 8, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.TestEventTriggersEnabled", - "attribute_name": "TestEventTriggersEnabled", - "value": false - }, - "0/51/65532": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/51/65533": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/51/65528": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/51/65529": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0] - }, - "0/51/65531": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533] - }, - "0/60/0": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.WindowStatus", - "attribute_name": "WindowStatus", - "value": 0 - }, - "0/60/1": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.AdminFabricIndex", - "attribute_name": "AdminFabricIndex", - "value": null - }, - "0/60/2": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.AdminVendorId", - "attribute_name": "AdminVendorId", - "value": null - }, - "0/60/65532": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/60/65533": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/60/65528": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/60/65529": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 1, 2] - }, - "0/60/65531": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 65528, 65529, 65531, 65532, 65533] - }, - "0/62/0": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.NOCs", - "attribute_name": "NOCs", - "value": [ - { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEEYGMAhVV+Adasucgyi++1D7eyBIfHs9xLKJPVJqJdMAqt0S8lQs+6v/NAyAVXsN8jdGlNgZQENRnfqC2gXv3COzcKNQEoARgkAgE2AwQCBAEYMAQUTK/GvAzp9yCT0ihFRaEyW8KuO0IwBRQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtBgwC0CaO1hqAR9PQJUkSx4MQyHEDQND/3j7m6EPRImPCA53dKI7e4w7xZEQEW95oMhuUobdy3WbMcggAMTX46ninwqUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEqboEMvSYpJHvrznp5AQ1fHW0AVUrTajBHZ/2uba7+FTyPb+fqgf6K1zbuMqTxTOA/FwjzAL7hQTwG+HNnmLwNTcKNQEpARgkAmAwBBQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtDAFFG02YRl97W++GsAiEiBzIhO0hzA6GDALQBl+ZyFbSXu3oXVJGBjtDcpwOCRC30OaVjDhUT7NbohDLaKuwxMhAgE+uHtSLKRZPGlQGSzYdnDGj/dWolGE+n4Y", - "fabricIndex": 52 - } - ] - }, - "0/62/1": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.Fabrics", - "attribute_name": "Fabrics", - "value": [ - { - "rootPublicKey": "BOI8+YJvCUh78+5WD4aHD7t1HQJS3WMrCEknk6n+5HXP2VRMB3SvK6+EEa8rR6UkHnCryIREeOmS0XYozzHjTQg=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 52 - } - ] - }, - "0/62/2": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.SupportedFabrics", - "attribute_name": "SupportedFabrics", - "value": 16 - }, - "0/62/3": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.CommissionedFabrics", - "attribute_name": "CommissionedFabrics", - "value": 4 - }, - "0/62/4": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.TrustedRootCertificates", - "attribute_name": "TrustedRootCertificates", - "value": [ - "FTABAQAkAgE3AycUF+1s7yUxl34kFQEYJgRAwC8rJgXA8xAtNwYnFBftbO8lMZd+JBUBGCQHASQIATAJQQRVFfH8a8Q6Ao7WLf+UWfO6Qs7yilIhKqD59s89ORO1UFw4ge7hwKMSFn8ixUw/SolOQBSsCF4L0mSEatBpsDFnNwo1ASkBGCQCYDAEFPrNGEmgHq6sOVn/hmBwy/Vue7r0MAUU+s0YSaAerqw5Wf+GYHDL9W57uvQYMAtAZthavHRZnzQlRYALrcnL/6oo5LlVEot5CBGjqdoD/j6tvJcsTbqGqtv2H5GL4DC5KRNAE5ZmyqaQDMp3hZMrMxg=", - "FTABAQAkAgE3AycUfHqQXNhPx0gkFQIYJgRtwC8rJgXt8xAtNwYnFHx6kFzYT8dIJBUCGCQHASQIATAJQQQNBDXlacZCe3oZcAfbwCvm06XcQzAC287XzRvF/RLV/fkpY0xOgTn6WYEYvThhqRF2B1WApMsQ59+WAo0X9igONwo1ASkBGCQCYDAEFEabr60b38Ymu5LyDvqUx3o0SI7xMAUURpuvrRvfxia7kvIO+pTHejRIjvEYMAtAhtqVBW7tg1C0F3w0a3mQ5uirN8UFAqgTYDh45HoqZv+NuAkF3tUI8bcIZZ/QxLu5C6GcK7KYlT3peRpNIyBCOBg=", - "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEE4jz5gm8JSHvz7lYPhocPu3UdAlLdYysISSeTqf7kdc/ZVEwHdK8rr4QRrytHpSQecKvIhER46ZLRdijPMeNNCDcKNQEpARgkAmAwBBRtNmEZfe1vvhrAIhIgcyITtIcwOjAFFG02YRl97W++GsAiEiBzIhO0hzA6GDALQPowt7QmngLyiuBmOVcu/kv3QR/+HhfRPxN5W8E811lBhPX4qEpvsBdEzJS0SpiQh+Fql+ARHzUGuIKP6SqjCnMY", - "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEOXNb0qzX1XBSquJ+bK0cuBsQl9aY/aBJ5njrCywf1qBz9W+54CQc4Lz5FtNzAhV7B6C3NppGozNUiSoAsL55ATcKNQEpARgkAmAwBBSWJBXzLVqg+IBcPntxiV91YIS0yDAFFJYkFfMtWqD4gFw+e3GJX3VghLTIGDALQIx8UTwGL2Ios2ym0C36ZrBPU7x7dw9zxCMKiuiDeEtdqcrKE7W/aaQ8/Mg2jszd7mqe/qq5y3yq3V6a8tQjrD0Y" - ] - }, - "0/62/5": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.CurrentFabricIndex", - "attribute_name": "CurrentFabricIndex", - "value": 52 - }, - "0/62/65532": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/62/65533": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/62/65528": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [1, 3, 5, 8] - }, - "0/62/65529": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 2, 4, 6, 7, 9, 10, 11] - }, - "0/62/65531": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533] - }, - "0/63/0": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.GroupKeyMap", - "attribute_name": "GroupKeyMap", - "value": [] - }, - "0/63/1": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.GroupTable", - "attribute_name": "GroupTable", - "value": [] - }, - "0/63/2": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.MaxGroupsPerFabric", - "attribute_name": "MaxGroupsPerFabric", - "value": 3 - }, - "0/63/3": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.MaxGroupKeysPerFabric", - "attribute_name": "MaxGroupKeysPerFabric", - "value": 3 - }, - "0/63/65533": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/63/65528": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [2, 5] - }, - "0/63/65529": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 1, 3, 4] - }, - "0/63/65531": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 65528, 65529, 65531, 65533] - }, - "1/6/0": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.OnOff", - "attribute_name": "OnOff", - "value": true - }, - "1/6/16384": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 16384, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.GlobalSceneControl", - "attribute_name": "GlobalSceneControl", - "value": true - }, - "1/6/16385": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 16385, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.OnTime", - "attribute_name": "OnTime", - "value": 0 - }, - "1/6/16386": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 16386, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.OffWaitTime", - "attribute_name": "OffWaitTime", - "value": 0 - }, - "1/6/16387": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 16387, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.StartUpOnOff", - "attribute_name": "StartUpOnOff", - "value": null - }, - "1/6/65532": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 1 - }, - "1/6/65533": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 4 - }, - "1/6/65528": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "1/6/65529": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 1, 2, 64, 65, 66] - }, - "1/6/65531": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [ - 0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533 - ] - }, - "1/29/0": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.DeviceTypeList", - "attribute_name": "DeviceTypeList", - "value": [ - { - "type": 268, - "revision": 1 - } - ] - }, - "1/29/1": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ServerList", - "attribute_name": "ServerList", - "value": [6, 29, 57, 768, 8, 80, 3, 4] - }, - "1/29/2": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ClientList", - "attribute_name": "ClientList", - "value": [] - }, - "1/29/3": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.PartsList", - "attribute_name": "PartsList", - "value": [] - }, - "1/29/65533": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "1/29/65528": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "1/29/65529": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "1/29/65531": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 65528, 65529, 65531, 65533] - }, - "1/768/0": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.CurrentHue", - "attribute_name": "CurrentHue", - "value": 36 - }, - "1/768/3": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.CurrentX", - "attribute_name": "CurrentX", - "value": 26515 - }, - "1/768/4": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.CurrentY", - "attribute_name": "CurrentY", - "value": 25657 - }, - "1/768/7": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 7, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorTemperatureMireds", - "attribute_name": "ColorTemperatureMireds", - "value": 284 - }, - "1/768/1": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.CurrentSaturation", - "attribute_name": "CurrentSaturation", - "value": 51 - }, - "1/768/16384": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16384, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.EnhancedCurrentHue", - "attribute_name": "EnhancedCurrentHue", - "value": 9305 - }, - "1/768/8": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 8, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorMode", - "attribute_name": "ColorMode", - "value": 0 - }, - "1/768/15": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 15, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.Options", - "attribute_name": "Options", - "value": 0 - }, - "1/768/16385": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16385, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.EnhancedColorMode", - "attribute_name": "EnhancedColorMode", - "value": 0 - }, + ], + "0/49/4": true, + "0/49/5": null, + "0/49/6": null, + "0/49/7": null, + "0/49/65532": 4, + "0/49/65533": 1, + "0/49/65528": [], + "0/49/65529": [], + "0/49/65531": [0, 1, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "name": "eth1", + "isOperational": true, + "offPremiseServicesReachableIPv4": null, + "offPremiseServicesReachableIPv6": null, + "hardwareAddress": "ABeILIy4", + "IPv4Addresses": ["CjwBuw=="], + "IPv6Addresses": [ + "/VqgxiAxQiYCF4j//iyMuA==", + "IAEEcLs7AAYCF4j//iyMuA==", + "/oAAAAAAAAACF4j//iyMuA==" + ], + "type": 0 + }, + { + "name": "eth0", + "isOperational": false, + "offPremiseServicesReachableIPv4": null, + "offPremiseServicesReachableIPv6": null, + "hardwareAddress": "AAN/ESDO", + "IPv4Addresses": [], + "IPv6Addresses": [], + "type": 2 + }, + { + "name": "lo", + "isOperational": true, + "offPremiseServicesReachableIPv4": null, + "offPremiseServicesReachableIPv6": null, + "hardwareAddress": "AAAAAAAA", + "IPv4Addresses": ["fwAAAQ=="], + "IPv6Addresses": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "type": 0 + } + ], + "0/51/1": 4, + "0/51/2": 834681, + "0/51/3": 231, + "0/51/4": 0, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEEYGMAhVV+Adasucgyi++1D7eyBIfHs9xLKJPVJqJdMAqt0S8lQs+6v/NAyAVXsN8jdGlNgZQENRnfqC2gXv3COzcKNQEoARgkAgE2AwQCBAEYMAQUTK/GvAzp9yCT0ihFRaEyW8KuO0IwBRQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtBgwC0CaO1hqAR9PQJUkSx4MQyHEDQND/3j7m6EPRImPCA53dKI7e4w7xZEQEW95oMhuUobdy3WbMcggAMTX46ninwqUGA==", + "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEqboEMvSYpJHvrznp5AQ1fHW0AVUrTajBHZ/2uba7+FTyPb+fqgf6K1zbuMqTxTOA/FwjzAL7hQTwG+HNnmLwNTcKNQEpARgkAmAwBBQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtDAFFG02YRl97W++GsAiEiBzIhO0hzA6GDALQBl+ZyFbSXu3oXVJGBjtDcpwOCRC30OaVjDhUT7NbohDLaKuwxMhAgE+uHtSLKRZPGlQGSzYdnDGj/dWolGE+n4Y", + "fabricIndex": 52 + } + ], + "0/62/1": [ + { + "rootPublicKey": "BOI8+YJvCUh78+5WD4aHD7t1HQJS3WMrCEknk6n+5HXP2VRMB3SvK6+EEa8rR6UkHnCryIREeOmS0XYozzHjTQg=", + "vendorId": 65521, + "fabricId": 1, + "nodeId": 1, + "label": "", + "fabricIndex": 52 + } + ], + "0/62/2": 16, + "0/62/3": 4, + "0/62/4": [ + "FTABAQAkAgE3AycUF+1s7yUxl34kFQEYJgRAwC8rJgXA8xAtNwYnFBftbO8lMZd+JBUBGCQHASQIATAJQQRVFfH8a8Q6Ao7WLf+UWfO6Qs7yilIhKqD59s89ORO1UFw4ge7hwKMSFn8ixUw/SolOQBSsCF4L0mSEatBpsDFnNwo1ASkBGCQCYDAEFPrNGEmgHq6sOVn/hmBwy/Vue7r0MAUU+s0YSaAerqw5Wf+GYHDL9W57uvQYMAtAZthavHRZnzQlRYALrcnL/6oo5LlVEot5CBGjqdoD/j6tvJcsTbqGqtv2H5GL4DC5KRNAE5ZmyqaQDMp3hZMrMxg=", + "FTABAQAkAgE3AycUfHqQXNhPx0gkFQIYJgRtwC8rJgXt8xAtNwYnFHx6kFzYT8dIJBUCGCQHASQIATAJQQQNBDXlacZCe3oZcAfbwCvm06XcQzAC287XzRvF/RLV/fkpY0xOgTn6WYEYvThhqRF2B1WApMsQ59+WAo0X9igONwo1ASkBGCQCYDAEFEabr60b38Ymu5LyDvqUx3o0SI7xMAUURpuvrRvfxia7kvIO+pTHejRIjvEYMAtAhtqVBW7tg1C0F3w0a3mQ5uirN8UFAqgTYDh45HoqZv+NuAkF3tUI8bcIZZ/QxLu5C6GcK7KYlT3peRpNIyBCOBg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEE4jz5gm8JSHvz7lYPhocPu3UdAlLdYysISSeTqf7kdc/ZVEwHdK8rr4QRrytHpSQecKvIhER46ZLRdijPMeNNCDcKNQEpARgkAmAwBBRtNmEZfe1vvhrAIhIgcyITtIcwOjAFFG02YRl97W++GsAiEiBzIhO0hzA6GDALQPowt7QmngLyiuBmOVcu/kv3QR/+HhfRPxN5W8E811lBhPX4qEpvsBdEzJS0SpiQh+Fql+ARHzUGuIKP6SqjCnMY", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEOXNb0qzX1XBSquJ+bK0cuBsQl9aY/aBJ5njrCywf1qBz9W+54CQc4Lz5FtNzAhV7B6C3NppGozNUiSoAsL55ATcKNQEpARgkAmAwBBSWJBXzLVqg+IBcPntxiV91YIS0yDAFFJYkFfMtWqD4gFw+e3GJX3VghLTIGDALQIx8UTwGL2Ios2ym0C36ZrBPU7x7dw9zxCMKiuiDeEtdqcrKE7W/aaQ8/Mg2jszd7mqe/qq5y3yq3V6a8tQjrD0Y" + ], + "0/62/5": 52, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 3, + "0/63/3": 3, + "0/63/65533": 1, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65533], + "1/6/0": true, + "1/6/16384": true, + "1/6/16385": 0, + "1/6/16386": 0, + "1/6/16387": null, + "1/6/65532": 1, + "1/6/65533": 4, + "1/6/65528": [], + "1/6/65529": [0, 1, 2, 64, 65, 66], + "1/6/65531": [ + 0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533 + ], + "1/29/0": [ + { + "type": 268, + "revision": 1 + } + ], + "1/29/1": [6, 29, 57, 768, 8, 80, 3, 4], + "1/29/2": [], + "1/29/3": [], + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65533], + "1/768/0": 36, + "1/768/3": 26515, + "1/768/4": 25657, + "1/768/7": 284, + "1/768/1": 51, + "1/768/16384": 9305, + "1/768/8": 0, + "1/768/15": 0, + "1/768/16385": 0, "1/768/16386": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16386, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorLoopActive", - "attribute_name": "ColorLoopActive", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "1/768/16387": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16387, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorLoopDirection", - "attribute_name": "ColorLoopDirection", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "1/768/16388": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16388, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorLoopTime", - "attribute_name": "ColorLoopTime", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "1/768/16389": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16389, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorLoopStartEnhancedHue", - "attribute_name": "ColorLoopStartEnhancedHue", - "value": { - "TLVValue": null, - "Reason": null - } + "TLVValue": null, + "Reason": null }, "1/768/16390": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16390, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorLoopStoredEnhancedHue", - "attribute_name": "ColorLoopStoredEnhancedHue", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "1/768/16394": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16394, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorCapabilities", - "attribute_name": "ColorCapabilities", - "value": 25 + "TLVValue": null, + "Reason": null }, + "1/768/16394": 25, "1/768/16": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.NumberOfPrimaries", - "attribute_name": "NumberOfPrimaries", - "value": { - "TLVValue": null, - "Reason": null + "TLVValue": null, + "Reason": null + }, + "1/768/16395": 153, + "1/768/16396": 500, + "1/768/16397": 250, + "1/768/16400": 65535, + "1/768/2": 0, + "1/768/65532": 16, + "1/768/65533": 5, + "1/768/65528": [], + "1/768/65529": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 71, 75, 76], + "1/768/65531": [ + 0, 3, 4, 7, 1, 16384, 8, 15, 16385, 16386, 16387, 16388, 16389, 16390, + 16394, 16, 16395, 16396, 16397, 16400, 2, 65528, 65529, 65531, 65532, + 65533 + ], + "1/8/0": 128, + "1/8/2": 1, + "1/8/3": 254, + "1/8/1": 0, + "1/8/17": null, + "1/8/15": 0, + "1/8/16384": 1, + "1/8/65532": 3, + "1/8/65533": 5, + "1/8/65528": [], + "1/8/65529": [0, 1, 2, 3, 4, 5, 6, 7], + "1/8/65531": [0, 2, 3, 1, 17, 15, 16384, 65528, 65529, 65531, 65532, 65533], + "1/80/0": "Lighting", + "1/80/1": 0, + "1/80/2": [ + { + "label": "Dark", + "mode": 0, + "semanticTags": [] + }, + { + "label": "Medium", + "mode": 1, + "semanticTags": [] + }, + { + "label": "Light", + "mode": 2, + "semanticTags": [] } - }, - "1/768/16395": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16395, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorTempPhysicalMinMireds", - "attribute_name": "ColorTempPhysicalMinMireds", - "value": 153 - }, - "1/768/16396": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16396, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorTempPhysicalMaxMireds", - "attribute_name": "ColorTempPhysicalMaxMireds", - "value": 500 - }, - "1/768/16397": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16397, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.CoupleColorTempToLevelMinMireds", - "attribute_name": "CoupleColorTempToLevelMinMireds", - "value": 250 - }, - "1/768/16400": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16400, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.StartUpColorTemperatureMireds", - "attribute_name": "StartUpColorTemperatureMireds", - "value": 65535 - }, - "1/768/2": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.RemainingTime", - "attribute_name": "RemainingTime", - "value": 0 - }, - "1/768/65532": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 16 - }, - "1/768/65533": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 5 - }, - "1/768/65528": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "1/768/65529": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 71, 75, 76] - }, - "1/768/65531": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [ - 0, 3, 4, 7, 1, 16384, 8, 15, 16385, 16386, 16387, 16388, 16389, 16390, - 16394, 16, 16395, 16396, 16397, 16400, 2, 65528, 65529, 65531, 65532, - 65533 - ] - }, - "1/8/0": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.CurrentLevel", - "attribute_name": "CurrentLevel", - "value": 128 - }, - "1/8/2": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.MinLevel", - "attribute_name": "MinLevel", - "value": 1 - }, - "1/8/3": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.MaxLevel", - "attribute_name": "MaxLevel", - "value": 254 - }, - "1/8/1": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.RemainingTime", - "attribute_name": "RemainingTime", - "value": 0 - }, - "1/8/17": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 17, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.OnLevel", - "attribute_name": "OnLevel", - "value": null - }, - "1/8/15": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 15, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.Options", - "attribute_name": "Options", - "value": 0 - }, - "1/8/16384": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 16384, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.StartUpCurrentLevel", - "attribute_name": "StartUpCurrentLevel", - "value": 1 - }, - "1/8/65532": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 3 - }, - "1/8/65533": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 5 - }, - "1/8/65528": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "1/8/65529": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 1, 2, 3, 4, 5, 6, 7] - }, - "1/8/65531": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 2, 3, 1, 17, 15, 16384, 65528, 65529, 65531, 65532, 65533] - }, - "1/80/0": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 80, - "cluster_type": "chip.clusters.Objects.ModeSelect", - "cluster_name": "ModeSelect", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.ModeSelect.Attributes.Description", - "attribute_name": "Description", - "value": "Lighting" - }, - "1/80/1": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 80, - "cluster_type": "chip.clusters.Objects.ModeSelect", - "cluster_name": "ModeSelect", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.ModeSelect.Attributes.StandardNamespace", - "attribute_name": "StandardNamespace", - "value": 0 - }, - "1/80/2": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 80, - "cluster_type": "chip.clusters.Objects.ModeSelect", - "cluster_name": "ModeSelect", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.ModeSelect.Attributes.SupportedModes", - "attribute_name": "SupportedModes", - "value": [ - { - "label": "Dark", - "mode": 0, - "semanticTags": [] - }, - { - "label": "Medium", - "mode": 1, - "semanticTags": [] - }, - { - "label": "Light", - "mode": 2, - "semanticTags": [] - } - ] - }, - "1/80/3": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 80, - "cluster_type": "chip.clusters.Objects.ModeSelect", - "cluster_name": "ModeSelect", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.ModeSelect.Attributes.CurrentMode", - "attribute_name": "CurrentMode", - "value": 0 - }, - "1/80/5": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 80, - "cluster_type": "chip.clusters.Objects.ModeSelect", - "cluster_name": "ModeSelect", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.ModeSelect.Attributes.OnMode", - "attribute_name": "OnMode", - "value": 0 - }, - "1/80/65532": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 80, - "cluster_type": "chip.clusters.Objects.ModeSelect", - "cluster_name": "ModeSelect", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.ModeSelect.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 1 - }, - "1/80/65533": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 80, - "cluster_type": "chip.clusters.Objects.ModeSelect", - "cluster_name": "ModeSelect", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.ModeSelect.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "1/80/65528": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 80, - "cluster_type": "chip.clusters.Objects.ModeSelect", - "cluster_name": "ModeSelect", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.ModeSelect.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "1/80/65529": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 80, - "cluster_type": "chip.clusters.Objects.ModeSelect", - "cluster_name": "ModeSelect", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.ModeSelect.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0] - }, - "1/80/65531": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 80, - "cluster_type": "chip.clusters.Objects.ModeSelect", - "cluster_name": "ModeSelect", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.ModeSelect.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 5, 65528, 65529, 65531, 65532, 65533] - }, - "1/3/0": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.IdentifyTime", - "attribute_name": "IdentifyTime", - "value": 0 - }, - "1/3/1": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.IdentifyType", - "attribute_name": "IdentifyType", - "value": 1 - }, - "1/3/65532": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "1/3/65533": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 4 - }, - "1/3/65528": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "1/3/65529": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 64] - }, - "1/3/65531": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 65528, 65529, 65531, 65532, 65533] - }, - "1/4/0": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.NameSupport", - "attribute_name": "NameSupport", - "value": 0 - }, - "1/4/65532": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "1/4/65533": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 4 - }, - "1/4/65528": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [0, 1, 2, 3] - }, - "1/4/65529": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 5, 2, 4, 3, 1] - }, - "1/4/65531": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 65528, 65529, 65531, 65532, 65533] - } + ], + "1/80/3": 0, + "1/80/5": 0, + "1/80/65532": 1, + "1/80/65533": 1, + "1/80/65528": [], + "1/80/65529": [0], + "1/80/65531": [0, 1, 2, 3, 5, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 1, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/4/0": 0, + "1/4/65532": 0, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 5, 2, 4, 3, 1], + "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533] }, - "endpoints": [0, 1], - "root_device_type_instance": null, - "aggregator_device_type_instance": null, - "device_type_instances": [null], - "node_devices": [null], - "_type": "matter_server.common.models.node.MatterNode" + "available": true } diff --git a/tests/components/matter/fixtures/nodes/contact-sensor.json b/tests/components/matter/fixtures/nodes/contact-sensor.json index 5e2ca4e937..fa90ecff1d 100644 --- a/tests/components/matter/fixtures/nodes/contact-sensor.json +++ b/tests/components/matter/fixtures/nodes/contact-sensor.json @@ -2,655 +2,88 @@ "node_id": 1, "date_commissioned": "2022-11-29T21:23:48.485051", "last_interview": "2022-11-29T21:23:48.485057", - "interview_version": 1, + "interview_version": 2, "attributes": { - "0/29/0": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.DeviceTypeList", - "attribute_name": "DeviceTypeList", - "value": [ - { - "type": 22, - "revision": 1 - } - ] - }, - "0/29/1": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ServerList", - "attribute_name": "ServerList", - "value": [ - 4, 29, 31, 40, 42, 43, 44, 48, 49, 50, 51, 52, 53, 54, 55, 59, 60, 62, - 63, 64, 65 - ] - }, - "0/29/2": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ClientList", - "attribute_name": "ClientList", - "value": [41] - }, - "0/29/3": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.PartsList", - "attribute_name": "PartsList", - "value": [1] - }, - "0/29/65532": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/29/65533": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/29/65528": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/29/65529": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/29/65531": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] - }, - "0/40/0": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.DataModelRevision", - "attribute_name": "DataModelRevision", - "value": 1 - }, - "0/40/1": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.VendorName", - "attribute_name": "VendorName", - "value": "Nabu Casa" - }, - "0/40/2": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.VendorID", - "attribute_name": "VendorID", - "value": 65521 - }, - "0/40/3": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.ProductName", - "attribute_name": "ProductName", - "value": "Mock ContactSensor" - }, - "0/40/4": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.ProductID", - "attribute_name": "ProductID", - "value": 32768 - }, - "0/40/5": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.NodeLabel", - "attribute_name": "NodeLabel", - "value": "Mock Contact sensor" - }, - "0/40/6": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 6, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.Location", - "attribute_name": "Location", - "value": "XX" - }, - "0/40/7": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 7, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.HardwareVersion", - "attribute_name": "HardwareVersion", - "value": 0 - }, - "0/40/8": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 8, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.HardwareVersionString", - "attribute_name": "HardwareVersionString", - "value": "v1.0" - }, - "0/40/9": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 9, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.SoftwareVersion", - "attribute_name": "SoftwareVersion", - "value": 1 - }, - "0/40/10": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 10, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.SoftwareVersionString", - "attribute_name": "SoftwareVersionString", - "value": "v1.0" - }, - "0/40/11": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 11, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.ManufacturingDate", - "attribute_name": "ManufacturingDate", - "value": "20221206" - }, - "0/40/12": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 12, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.PartNumber", - "attribute_name": "PartNumber", - "value": "" - }, - "0/40/13": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 13, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.ProductURL", - "attribute_name": "ProductURL", - "value": "" - }, - "0/40/14": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 14, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.ProductLabel", - "attribute_name": "ProductLabel", - "value": "" - }, - "0/40/15": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 15, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.SerialNumber", - "attribute_name": "SerialNumber", - "value": "TEST_SN" - }, - "0/40/16": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 16, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.LocalConfigDisabled", - "attribute_name": "LocalConfigDisabled", - "value": false - }, - "0/40/17": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 17, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.Reachable", - "attribute_name": "Reachable", - "value": true - }, - "0/40/18": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 18, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.UniqueID", - "attribute_name": "UniqueID", - "value": "mock-contact-sensor" - }, - "0/40/19": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 19, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.CapabilityMinima", - "attribute_name": "CapabilityMinima", - "value": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0/29/0": [ + { + "type": 22, + "revision": 1 } + ], + "0/29/1": [ + 4, 29, 31, 40, 42, 43, 44, 48, 49, 50, 51, 52, 53, 54, 55, 59, 60, 62, 63, + 64, 65 + ], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "Nabu Casa", + "0/40/2": 65521, + "0/40/3": "Mock ContactSensor", + "0/40/4": 32768, + "0/40/5": "Mock Contact sensor", + "0/40/6": "XX", + "0/40/7": 0, + "0/40/8": "v1.0", + "0/40/9": 1, + "0/40/10": "v1.0", + "0/40/11": "20221206", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "TEST_SN", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "mock-contact-sensor", + "0/40/19": { + "caseSessionsPerFabric": 3, + "subscriptionsPerFabric": 3 }, - "0/40/65532": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/40/65533": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/40/65528": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/40/65529": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/40/65531": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, - 65528, 65529, 65531, 65532, 65533 - ] - }, - - "1/3/0": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.IdentifyTime", - "attribute_name": "IdentifyTime", - "value": 0 - }, - "1/3/1": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.IdentifyType", - "attribute_name": "IdentifyType", - "value": 2 - }, - "1/3/65532": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "1/3/65533": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 4 - }, - "1/3/65528": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "1/3/65529": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 64] - }, - "1/3/65531": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 65528, 65529, 65531, 65532, 65533] - }, - - "1/29/0": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.DeviceTypeList", - "attribute_name": "DeviceTypeList", - "value": [ - { - "type": 21, - "revision": 1 - } - ] - }, - "1/29/1": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ServerList", - "attribute_name": "ServerList", - "value": [ - 3, 4, 5, 6, 7, 8, 15, 29, 30, 37, 47, 59, 64, 65, 69, 80, 257, 258, 259, - 512, 513, 514, 516, 768, 1024, 1026, 1027, 1028, 1029, 1030, 1283, 1284, - 1285, 1286, 1287, 1288, 1289, 1290, 1291, 1292, 1293, 1294, 2820, - 4294048773 - ] - }, - "1/29/2": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ClientList", - "attribute_name": "ClientList", - "value": [] - }, - "1/29/3": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.PartsList", - "attribute_name": "PartsList", - "value": [] - }, - "1/29/65532": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "1/29/65533": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "1/29/65528": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "1/29/65529": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "1/29/65531": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] - }, - "1/69/0": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 69, - "cluster_type": "chip.clusters.Objects.BooleanState", - "cluster_name": "BooleanState", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.BooleanState.Attributes.StateValue", - "attribute_name": "StateValue", - "value": true - }, - "1/69/65532": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 69, - "cluster_type": "chip.clusters.Objects.BooleanState", - "cluster_name": "BooleanState", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.BooleanState.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "1/69/65533": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 69, - "cluster_type": "chip.clusters.Objects.BooleanState", - "cluster_name": "BooleanState", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.BooleanState.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "1/69/65528": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 69, - "cluster_type": "chip.clusters.Objects.BooleanState", - "cluster_name": "BooleanState", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.BooleanState.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "1/69/65529": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 69, - "cluster_type": "chip.clusters.Objects.BooleanState", - "cluster_name": "BooleanState", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.BooleanState.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "1/69/65531": { - "node_id": 1, - "endpoint": 1, - "cluster_id": 69, - "cluster_type": "chip.clusters.Objects.BooleanState", - "cluster_name": "BooleanState", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.BooleanState.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 65528, 65529, 65531, 65532, 65533] - } + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 65528, 65529, 65531, 65532, 65533 + ], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "type": 21, + "revision": 1 + } + ], + "1/29/1": [ + 3, 4, 5, 6, 7, 8, 15, 29, 30, 37, 47, 59, 64, 65, 69, 80, 257, 258, 259, + 512, 513, 514, 516, 768, 1024, 1026, 1027, 1028, 1029, 1030, 1283, 1284, + 1285, 1286, 1287, 1288, 1289, 1290, 1291, 1292, 1293, 1294, 2820, + 4294048773 + ], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/69/0": true, + "1/69/65532": 0, + "1/69/65533": 1, + "1/69/65528": [], + "1/69/65529": [], + "1/69/65531": [0, 65528, 65529, 65531, 65532, 65533] }, - "endpoints": [0, 1], - "root_device_type_instance": null, - "aggregator_device_type_instance": null, - "device_type_instances": [null], - "node_devices": [null], - "_type": "matter_server.common.models.node.MatterNode" + "available": true } diff --git a/tests/components/matter/fixtures/nodes/device_diagnostics.json b/tests/components/matter/fixtures/nodes/device_diagnostics.json index 377d72c735..2950a61622 100644 --- a/tests/components/matter/fixtures/nodes/device_diagnostics.json +++ b/tests/components/matter/fixtures/nodes/device_diagnostics.json @@ -2,4222 +2,470 @@ "node_id": 5, "date_commissioned": "2023-01-16T21:07:57.508440", "last_interview": "2023-01-16T21:07:57.508448", - "interview_version": 1, + "interview_version": 2, "attributes": { - "0/4/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.NameSupport", - "attribute_name": "NameSupport", - "value": 128 - }, - "0/4/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 1 - }, - "0/4/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 4 - }, - "0/4/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [0, 1, 2, 3] - }, - "0/4/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 1, 2, 3, 4, 5] - }, - "0/4/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 65528, 65529, 65531, 65532, 65533] - }, - "0/29/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.DeviceTypeList", - "attribute_name": "DeviceTypeList", - "value": [ - { - "type": 22, - "revision": 1 - } - ] - }, - "0/29/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ServerList", - "attribute_name": "ServerList", - "value": [ - 4, 29, 31, 40, 42, 43, 44, 48, 49, 50, 51, 52, 53, 54, 55, 59, 60, 62, - 63, 64, 65 - ] - }, - "0/29/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ClientList", - "attribute_name": "ClientList", - "value": [41] - }, - "0/29/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.PartsList", - "attribute_name": "PartsList", - "value": [1] - }, - "0/29/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/29/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/29/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/29/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/29/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] - }, - "0/31/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.Acl", - "attribute_name": "Acl", - "value": [ - { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 - } - ] - }, - "0/31/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.Extension", - "attribute_name": "Extension", - "value": [] - }, - "0/31/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.SubjectsPerAccessControlEntry", - "attribute_name": "SubjectsPerAccessControlEntry", - "value": 4 - }, - "0/31/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.TargetsPerAccessControlEntry", - "attribute_name": "TargetsPerAccessControlEntry", - "value": 3 - }, - "0/31/4": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.AccessControlEntriesPerFabric", - "attribute_name": "AccessControlEntriesPerFabric", - "value": 3 - }, - "0/31/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/31/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/31/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/31/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/31/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533] - }, - "0/40/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.DataModelRevision", - "attribute_name": "DataModelRevision", - "value": 1 - }, - "0/40/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.VendorName", - "attribute_name": "VendorName", - "value": "Nabu Casa" - }, - "0/40/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.VendorID", - "attribute_name": "VendorID", - "value": 65521 - }, - "0/40/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.ProductName", - "attribute_name": "ProductName", - "value": "M5STAMP Lighting App" - }, - "0/40/4": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.ProductID", - "attribute_name": "ProductID", - "value": 32768 - }, - "0/40/5": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.NodeLabel", - "attribute_name": "NodeLabel", - "value": "" - }, - "0/40/6": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 6, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.Location", - "attribute_name": "Location", - "value": "XX" - }, - "0/40/7": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 7, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.HardwareVersion", - "attribute_name": "HardwareVersion", - "value": 0 - }, - "0/40/8": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 8, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.HardwareVersionString", - "attribute_name": "HardwareVersionString", - "value": "v1.0" - }, - "0/40/9": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 9, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.SoftwareVersion", - "attribute_name": "SoftwareVersion", - "value": 1 - }, - "0/40/10": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 10, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.SoftwareVersionString", - "attribute_name": "SoftwareVersionString", - "value": "v1.0" - }, - "0/40/11": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 11, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.ManufacturingDate", - "attribute_name": "ManufacturingDate", - "value": "20200101" - }, - "0/40/12": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 12, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.PartNumber", - "attribute_name": "PartNumber", - "value": "" - }, - "0/40/13": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 13, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.ProductURL", - "attribute_name": "ProductURL", - "value": "" - }, - "0/40/14": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 14, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.ProductLabel", - "attribute_name": "ProductLabel", - "value": "" - }, - "0/40/15": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 15, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.SerialNumber", - "attribute_name": "SerialNumber", - "value": "TEST_SN" - }, - "0/40/16": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 16, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.LocalConfigDisabled", - "attribute_name": "LocalConfigDisabled", - "value": false - }, - "0/40/17": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 17, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.Reachable", - "attribute_name": "Reachable", - "value": true - }, - "0/40/18": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 18, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.UniqueID", - "attribute_name": "UniqueID", - "value": "869D5F986B588B29" - }, + "0/4/0": 128, + "0/4/65532": 1, + "0/4/65533": 4, + "0/4/65528": [0, 1, 2, 3], + "0/4/65529": [0, 1, 2, 3, 4, 5], + "0/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/29/0": [ + { + "type": 22, + "revision": 1 + } + ], + "0/29/1": [ + 4, 29, 31, 40, 42, 43, 44, 48, 49, 50, 51, 52, 53, 54, 55, 59, 60, 62, 63, + 64, 65 + ], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "privilege": 5, + "authMode": 2, + "subjects": [112233], + "targets": null, + "fabricIndex": 1 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 3, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "Nabu Casa", + "0/40/2": 65521, + "0/40/3": "M5STAMP Lighting App", + "0/40/4": 32768, + "0/40/5": "", + "0/40/6": "XX", + "0/40/7": 0, + "0/40/8": "v1.0", + "0/40/9": 1, + "0/40/10": "v1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "TEST_SN", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "869D5F986B588B29", "0/40/19": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 19, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.CapabilityMinima", - "attribute_name": "CapabilityMinima", - "value": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 - } - }, - "0/40/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/40/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/40/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/40/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/40/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, - 65528, 65529, 65531, 65532, 65533 - ] - }, - "0/42/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.DefaultOtaProviders", - "attribute_name": "DefaultOtaProviders", - "value": [] - }, - "0/42/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.UpdatePossible", - "attribute_name": "UpdatePossible", - "value": true - }, - "0/42/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.UpdateState", - "attribute_name": "UpdateState", - "value": 0 - }, - "0/42/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.UpdateStateProgress", - "attribute_name": "UpdateStateProgress", - "value": 0 - }, - "0/42/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/42/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/42/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/42/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0] - }, - "0/42/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] - }, - "0/43/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 43, - "cluster_type": "chip.clusters.Objects.LocalizationConfiguration", - "cluster_name": "LocalizationConfiguration", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.LocalizationConfiguration.Attributes.ActiveLocale", - "attribute_name": "ActiveLocale", - "value": "en-US" - }, - "0/43/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 43, - "cluster_type": "chip.clusters.Objects.LocalizationConfiguration", - "cluster_name": "LocalizationConfiguration", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.LocalizationConfiguration.Attributes.SupportedLocales", - "attribute_name": "SupportedLocales", - "value": [ - "en-US", - "de-DE", - "fr-FR", - "en-GB", - "es-ES", - "zh-CN", - "it-IT", - "ja-JP" - ] - }, - "0/43/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 43, - "cluster_type": "chip.clusters.Objects.LocalizationConfiguration", - "cluster_name": "LocalizationConfiguration", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.LocalizationConfiguration.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/43/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 43, - "cluster_type": "chip.clusters.Objects.LocalizationConfiguration", - "cluster_name": "LocalizationConfiguration", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.LocalizationConfiguration.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/43/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 43, - "cluster_type": "chip.clusters.Objects.LocalizationConfiguration", - "cluster_name": "LocalizationConfiguration", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.LocalizationConfiguration.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/43/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 43, - "cluster_type": "chip.clusters.Objects.LocalizationConfiguration", - "cluster_name": "LocalizationConfiguration", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.LocalizationConfiguration.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/43/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 43, - "cluster_type": "chip.clusters.Objects.LocalizationConfiguration", - "cluster_name": "LocalizationConfiguration", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.LocalizationConfiguration.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 65528, 65529, 65531, 65532, 65533] - }, - "0/44/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 44, - "cluster_type": "chip.clusters.Objects.TimeFormatLocalization", - "cluster_name": "TimeFormatLocalization", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.TimeFormatLocalization.Attributes.HourFormat", - "attribute_name": "HourFormat", - "value": 0 - }, - "0/44/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 44, - "cluster_type": "chip.clusters.Objects.TimeFormatLocalization", - "cluster_name": "TimeFormatLocalization", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.TimeFormatLocalization.Attributes.ActiveCalendarType", - "attribute_name": "ActiveCalendarType", - "value": 0 - }, - "0/44/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 44, - "cluster_type": "chip.clusters.Objects.TimeFormatLocalization", - "cluster_name": "TimeFormatLocalization", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.TimeFormatLocalization.Attributes.SupportedCalendarTypes", - "attribute_name": "SupportedCalendarTypes", - "value": [0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 7] - }, - "0/44/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 44, - "cluster_type": "chip.clusters.Objects.TimeFormatLocalization", - "cluster_name": "TimeFormatLocalization", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.TimeFormatLocalization.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/44/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 44, - "cluster_type": "chip.clusters.Objects.TimeFormatLocalization", - "cluster_name": "TimeFormatLocalization", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.TimeFormatLocalization.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/44/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 44, - "cluster_type": "chip.clusters.Objects.TimeFormatLocalization", - "cluster_name": "TimeFormatLocalization", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.TimeFormatLocalization.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/44/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 44, - "cluster_type": "chip.clusters.Objects.TimeFormatLocalization", - "cluster_name": "TimeFormatLocalization", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.TimeFormatLocalization.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/44/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 44, - "cluster_type": "chip.clusters.Objects.TimeFormatLocalization", - "cluster_name": "TimeFormatLocalization", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.TimeFormatLocalization.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 65528, 65529, 65531, 65532, 65533] - }, - "0/48/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.Breadcrumb", - "attribute_name": "Breadcrumb", - "value": 0 + "caseSessionsPerFabric": 3, + "subscriptionsPerFabric": 3 }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 65528, 65529, 65531, 65532, 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 0, + "0/42/3": 0, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/43/0": "en-US", + "0/43/1": [ + "en-US", + "de-DE", + "fr-FR", + "en-GB", + "es-ES", + "zh-CN", + "it-IT", + "ja-JP" + ], + "0/43/65532": 0, + "0/43/65533": 1, + "0/43/65528": [], + "0/43/65529": [], + "0/43/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "0/44/0": 0, + "0/44/1": 0, + "0/44/2": [0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 7], + "0/44/65532": 0, + "0/44/65533": 1, + "0/44/65528": [], + "0/44/65529": [], + "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, "0/48/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.BasicCommissioningInfo", - "attribute_name": "BasicCommissioningInfo", - "value": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "failSafeExpiryLengthSeconds": 60, + "maxCumulativeFailsafeSeconds": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [], + "0/49/2": 10, + "0/49/3": 30, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "bVdMQU4yLjQ=", + "0/49/7": null, + "0/49/65532": 1, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 2, 4, 6, 8], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/50/65532": 0, + "0/50/65533": 1, + "0/50/65528": [1], + "0/50/65529": [0], + "0/50/65531": [65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "name": "WIFI_STA_DEF", + "isOperational": true, + "offPremiseServicesReachableIPv4": null, + "offPremiseServicesReachableIPv6": null, + "hardwareAddress": "YFX5V0js", + "IPv4Addresses": ["wKgBIw=="], + "IPv6Addresses": ["/oAAAAAAAABiVfn//ldI7A=="], + "type": 1 } - }, - "0/48/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.RegulatoryConfig", - "attribute_name": "RegulatoryConfig", - "value": 0 - }, - "0/48/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.LocationCapability", - "attribute_name": "LocationCapability", - "value": 0 - }, - "0/48/4": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.SupportsConcurrentConnection", - "attribute_name": "SupportsConcurrentConnection", - "value": true - }, - "0/48/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/48/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/48/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [1, 3, 5] - }, - "0/48/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 2, 4] - }, - "0/48/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533] - }, - "0/49/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.MaxNetworks", - "attribute_name": "MaxNetworks", - "value": 1 - }, - "0/49/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.Networks", - "attribute_name": "Networks", - "value": [] - }, - "0/49/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.ScanMaxTimeSeconds", - "attribute_name": "ScanMaxTimeSeconds", - "value": 10 - }, - "0/49/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.ConnectMaxTimeSeconds", - "attribute_name": "ConnectMaxTimeSeconds", - "value": 30 - }, - "0/49/4": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.InterfaceEnabled", - "attribute_name": "InterfaceEnabled", - "value": true - }, - "0/49/5": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.LastNetworkingStatus", - "attribute_name": "LastNetworkingStatus", - "value": 0 - }, - "0/49/6": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 6, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.LastNetworkID", - "attribute_name": "LastNetworkID", - "value": "bVdMQU4yLjQ=" - }, - "0/49/7": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 7, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.LastConnectErrorValue", - "attribute_name": "LastConnectErrorValue", - "value": null - }, - "0/49/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 1 - }, - "0/49/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/49/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [1, 5, 7] - }, - "0/49/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 2, 4, 6, 8] - }, - "0/49/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533] - }, - "0/50/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 50, - "cluster_type": "chip.clusters.Objects.DiagnosticLogs", - "cluster_name": "DiagnosticLogs", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.DiagnosticLogs.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/50/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 50, - "cluster_type": "chip.clusters.Objects.DiagnosticLogs", - "cluster_name": "DiagnosticLogs", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.DiagnosticLogs.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/50/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 50, - "cluster_type": "chip.clusters.Objects.DiagnosticLogs", - "cluster_name": "DiagnosticLogs", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.DiagnosticLogs.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [1] - }, - "0/50/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 50, - "cluster_type": "chip.clusters.Objects.DiagnosticLogs", - "cluster_name": "DiagnosticLogs", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.DiagnosticLogs.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0] - }, - "0/50/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 50, - "cluster_type": "chip.clusters.Objects.DiagnosticLogs", - "cluster_name": "DiagnosticLogs", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.DiagnosticLogs.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [65528, 65529, 65531, 65532, 65533] - }, - "0/51/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.NetworkInterfaces", - "attribute_name": "NetworkInterfaces", - "value": [ - { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "YFX5V0js", - "IPv4Addresses": ["wKgBIw=="], - "IPv6Addresses": ["/oAAAAAAAABiVfn//ldI7A=="], - "type": 1 - } - ] - }, - "0/51/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.RebootCount", - "attribute_name": "RebootCount", - "value": 3 - }, - "0/51/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.UpTime", - "attribute_name": "UpTime", - "value": 213 - }, - "0/51/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.TotalOperationalHours", - "attribute_name": "TotalOperationalHours", - "value": 0 - }, - "0/51/4": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.BootReasons", - "attribute_name": "BootReasons", - "value": 1 - }, - "0/51/5": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.ActiveHardwareFaults", - "attribute_name": "ActiveHardwareFaults", - "value": [] - }, - "0/51/6": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 6, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.ActiveRadioFaults", - "attribute_name": "ActiveRadioFaults", - "value": [] - }, - "0/51/7": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 7, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.ActiveNetworkFaults", - "attribute_name": "ActiveNetworkFaults", - "value": [] - }, - "0/51/8": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 8, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.TestEventTriggersEnabled", - "attribute_name": "TestEventTriggersEnabled", - "value": false - }, - "0/51/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/51/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/51/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/51/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0] - }, - "0/51/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533] - }, - "0/52/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.ThreadMetrics", - "attribute_name": "ThreadMetrics", - "value": [] - }, - "0/52/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.CurrentHeapFree", - "attribute_name": "CurrentHeapFree", - "value": 166660 - }, - "0/52/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.CurrentHeapUsed", - "attribute_name": "CurrentHeapUsed", - "value": 86332 - }, - "0/52/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.CurrentHeapHighWatermark", - "attribute_name": "CurrentHeapHighWatermark", - "value": 99208 - }, - "0/52/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/52/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/52/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/52/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/52/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] - }, - "0/53/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.Channel", - "attribute_name": "Channel", - "value": { - "TLVValue": null, - "Reason": null + ], + "0/51/1": 3, + "0/51/2": 213, + "0/51/3": 0, + "0/51/4": 1, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/52/0": [], + "0/52/1": 166660, + "0/52/2": 86332, + "0/52/3": 99208, + "0/52/65532": 0, + "0/52/65533": 1, + "0/52/65528": [], + "0/52/65529": [], + "0/52/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/53/0": null, + "0/53/1": null, + "0/53/2": null, + "0/53/3": null, + "0/53/4": null, + "0/53/5": null, + "0/53/6": null, + "0/53/7": null, + "0/53/8": null, + "0/53/9": null, + "0/53/10": null, + "0/53/11": null, + "0/53/12": null, + "0/53/13": null, + "0/53/14": null, + "0/53/15": null, + "0/53/16": null, + "0/53/17": null, + "0/53/18": null, + "0/53/19": null, + "0/53/20": null, + "0/53/21": null, + "0/53/22": null, + "0/53/23": null, + "0/53/24": null, + "0/53/25": null, + "0/53/26": null, + "0/53/27": null, + "0/53/28": null, + "0/53/29": null, + "0/53/30": null, + "0/53/31": null, + "0/53/32": null, + "0/53/33": null, + "0/53/34": null, + "0/53/35": null, + "0/53/36": null, + "0/53/37": null, + "0/53/38": null, + "0/53/39": null, + "0/53/40": null, + "0/53/41": null, + "0/53/42": null, + "0/53/43": null, + "0/53/44": null, + "0/53/45": null, + "0/53/46": null, + "0/53/47": null, + "0/53/48": null, + "0/53/49": null, + "0/53/50": null, + "0/53/51": null, + "0/53/52": null, + "0/53/53": null, + "0/53/54": null, + "0/53/55": null, + "0/53/56": null, + "0/53/57": null, + "0/53/58": null, + "0/53/59": null, + "0/53/60": null, + "0/53/61": null, + "0/53/62": null, + "0/53/65532": 15, + "0/53/65533": 1, + "0/53/65528": [], + "0/53/65529": [0], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, + 57, 58, 59, 60, 61, 62, 65528, 65529, 65531, 65532, 65533 + ], + "0/54/0": "BKFR27h1", + "0/54/1": 4, + "0/54/2": 3, + "0/54/3": 3, + "0/54/4": -56, + "0/54/5": null, + "0/54/6": null, + "0/54/7": null, + "0/54/8": null, + "0/54/9": null, + "0/54/10": null, + "0/54/11": null, + "0/54/12": null, + "0/54/65532": 3, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [0], + "0/54/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 65528, 65529, 65531, 65532, + 65533 + ], + "0/55/0": null, + "0/55/1": null, + "0/55/2": 0, + "0/55/3": 0, + "0/55/4": 0, + "0/55/5": 0, + "0/55/6": 0, + "0/55/7": null, + "0/55/8": 0, + "0/55/65532": 3, + "0/55/65533": 1, + "0/55/65528": [], + "0/55/65529": [0], + "0/55/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/59/65532": 0, + "0/59/65533": 1, + "0/59/65528": [], + "0/59/65529": [], + "0/59/65531": [65528, 65529, 65531, 65532, 65533], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", + "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", + "fabricIndex": 1 } - }, - "0/53/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RoutingRole", - "attribute_name": "RoutingRole", - "value": { - "TLVValue": null, - "Reason": null + ], + "0/62/1": [ + { + "rootPublicKey": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", + "vendorId": 65521, + "fabricId": 1, + "nodeId": 5, + "label": "", + "fabricIndex": 1 } - }, - "0/53/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.NetworkName", - "attribute_name": "NetworkName", - "value": { - "TLVValue": null, - "Reason": null + ], + "0/62/2": 5, + "0/62/3": 1, + "0/62/4": [ + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEAs0LOfZc6nU2vCzNP4s4thP60zvr4+hvwAg4WX37RRYWwunhkdRqKdlkwlAgAfG/f2ytVRhbT6dpwVyMdPM0fDcKNQEpARgkAmAwBBSOgri06KQhYgWHhrlHD/AJOSaN4DAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQGxeigcXo8H7pmRHCOma3uT688xoreaDwV8JfFUMUnHUvqg+2GNzFtvfD6MkDaYVPghsXjITZLv5qsHhrUaIO7QY" + ], + "0/62/5": 1, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 3, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/64/0": [ + { + "label": "room", + "value": "bedroom 2" + }, + { + "label": "orientation", + "value": "North" + }, + { + "label": "floor", + "value": "2" + }, + { + "label": "direction", + "value": "up" } - }, - "0/53/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.PanId", - "attribute_name": "PanId", - "value": { - "TLVValue": null, - "Reason": null + ], + "0/64/65532": 0, + "0/64/65533": 1, + "0/64/65528": [], + "0/64/65529": [], + "0/64/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/65/0": [], + "0/65/65532": 0, + "0/65/65533": 1, + "0/65/65528": [], + "0/65/65529": [], + "0/65/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/6/0": false, + "1/6/16384": true, + "1/6/16385": 0, + "1/6/16386": 0, + "1/6/16387": null, + "1/6/65532": 1, + "1/6/65533": 4, + "1/6/65528": [], + "1/6/65529": [0, 1, 2, 64, 65, 66], + "1/6/65531": [ + 0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533 + ], + "1/8/0": 254, + "1/8/1": 0, + "1/8/2": 1, + "1/8/3": 254, + "1/8/4": 0, + "1/8/5": 0, + "1/8/6": 0, + "1/8/15": 0, + "1/8/16": 0, + "1/8/17": null, + "1/8/18": 0, + "1/8/19": 0, + "1/8/20": 50, + "1/8/16384": null, + "1/8/65532": 3, + "1/8/65533": 5, + "1/8/65528": [], + "1/8/65529": [0, 1, 2, 3, 4, 5, 6, 7], + "1/8/65531": [ + 0, 1, 2, 3, 4, 5, 6, 15, 16, 17, 18, 19, 20, 16384, 65528, 65529, 65531, + 65532, 65533 + ], + "1/29/0": [ + { + "type": 257, + "revision": 1 } - }, - "0/53/4": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.ExtendedPanId", - "attribute_name": "ExtendedPanId", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/5": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.MeshLocalPrefix", - "attribute_name": "MeshLocalPrefix", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/6": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 6, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.OverrunCount", - "attribute_name": "OverrunCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/7": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 7, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.NeighborTableList", - "attribute_name": "NeighborTableList", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/8": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 8, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RouteTableList", - "attribute_name": "RouteTableList", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/9": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 9, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.PartitionId", - "attribute_name": "PartitionId", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/10": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 10, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.Weighting", - "attribute_name": "Weighting", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/11": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 11, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.DataVersion", - "attribute_name": "DataVersion", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/12": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 12, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.StableDataVersion", - "attribute_name": "StableDataVersion", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/13": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 13, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.LeaderRouterId", - "attribute_name": "LeaderRouterId", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/14": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 14, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.DetachedRoleCount", - "attribute_name": "DetachedRoleCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/15": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 15, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.ChildRoleCount", - "attribute_name": "ChildRoleCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/16": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 16, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RouterRoleCount", - "attribute_name": "RouterRoleCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/17": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 17, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.LeaderRoleCount", - "attribute_name": "LeaderRoleCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/18": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 18, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.AttachAttemptCount", - "attribute_name": "AttachAttemptCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/19": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 19, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.PartitionIdChangeCount", - "attribute_name": "PartitionIdChangeCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/20": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 20, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.BetterPartitionAttachAttemptCount", - "attribute_name": "BetterPartitionAttachAttemptCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/21": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 21, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.ParentChangeCount", - "attribute_name": "ParentChangeCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/22": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 22, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxTotalCount", - "attribute_name": "TxTotalCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/23": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 23, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxUnicastCount", - "attribute_name": "TxUnicastCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/24": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 24, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxBroadcastCount", - "attribute_name": "TxBroadcastCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/25": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 25, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxAckRequestedCount", - "attribute_name": "TxAckRequestedCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/26": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 26, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxAckedCount", - "attribute_name": "TxAckedCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/27": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 27, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxNoAckRequestedCount", - "attribute_name": "TxNoAckRequestedCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/28": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 28, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxDataCount", - "attribute_name": "TxDataCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/29": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 29, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxDataPollCount", - "attribute_name": "TxDataPollCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/30": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 30, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxBeaconCount", - "attribute_name": "TxBeaconCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/31": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 31, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxBeaconRequestCount", - "attribute_name": "TxBeaconRequestCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/32": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 32, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxOtherCount", - "attribute_name": "TxOtherCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/33": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 33, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxRetryCount", - "attribute_name": "TxRetryCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/34": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 34, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxDirectMaxRetryExpiryCount", - "attribute_name": "TxDirectMaxRetryExpiryCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/35": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 35, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxIndirectMaxRetryExpiryCount", - "attribute_name": "TxIndirectMaxRetryExpiryCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/36": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 36, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxErrCcaCount", - "attribute_name": "TxErrCcaCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/37": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 37, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxErrAbortCount", - "attribute_name": "TxErrAbortCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/38": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 38, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxErrBusyChannelCount", - "attribute_name": "TxErrBusyChannelCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/39": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 39, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxTotalCount", - "attribute_name": "RxTotalCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/40": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 40, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxUnicastCount", - "attribute_name": "RxUnicastCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/41": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 41, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxBroadcastCount", - "attribute_name": "RxBroadcastCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/42": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 42, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxDataCount", - "attribute_name": "RxDataCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/43": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 43, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxDataPollCount", - "attribute_name": "RxDataPollCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/44": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 44, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxBeaconCount", - "attribute_name": "RxBeaconCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/45": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 45, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxBeaconRequestCount", - "attribute_name": "RxBeaconRequestCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/46": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 46, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxOtherCount", - "attribute_name": "RxOtherCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/47": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 47, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxAddressFilteredCount", - "attribute_name": "RxAddressFilteredCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/48": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 48, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxDestAddrFilteredCount", - "attribute_name": "RxDestAddrFilteredCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/49": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 49, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxDuplicatedCount", - "attribute_name": "RxDuplicatedCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/50": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 50, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxErrNoFrameCount", - "attribute_name": "RxErrNoFrameCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/51": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 51, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxErrUnknownNeighborCount", - "attribute_name": "RxErrUnknownNeighborCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/52": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 52, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxErrInvalidSrcAddrCount", - "attribute_name": "RxErrInvalidSrcAddrCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/53": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 53, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxErrSecCount", - "attribute_name": "RxErrSecCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/54": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 54, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxErrFcsCount", - "attribute_name": "RxErrFcsCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/55": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 55, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxErrOtherCount", - "attribute_name": "RxErrOtherCount", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/56": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 56, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.ActiveTimestamp", - "attribute_name": "ActiveTimestamp", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/57": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 57, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.PendingTimestamp", - "attribute_name": "PendingTimestamp", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/58": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 58, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.Delay", - "attribute_name": "Delay", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/59": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 59, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.SecurityPolicy", - "attribute_name": "SecurityPolicy", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/60": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 60, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.ChannelPage0Mask", - "attribute_name": "ChannelPage0Mask", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/61": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 61, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.OperationalDatasetComponents", - "attribute_name": "OperationalDatasetComponents", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/62": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 62, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.ActiveNetworkFaultsList", - "attribute_name": "ActiveNetworkFaultsList", - "value": { - "TLVValue": null, - "Reason": null - } - }, - "0/53/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 15 - }, - "0/53/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/53/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/53/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0] - }, - "0/53/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, - 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, - 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, - 56, 57, 58, 59, 60, 61, 62, 65528, 65529, 65531, 65532, 65533 - ] - }, - "0/54/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.Bssid", - "attribute_name": "Bssid", - "value": "BKFR27h1" - }, - "0/54/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.SecurityType", - "attribute_name": "SecurityType", - "value": 4 - }, - "0/54/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.WiFiVersion", - "attribute_name": "WiFiVersion", - "value": 3 - }, - "0/54/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.ChannelNumber", - "attribute_name": "ChannelNumber", - "value": 3 - }, - "0/54/4": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.Rssi", - "attribute_name": "Rssi", - "value": -56 - }, - "0/54/5": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.BeaconLostCount", - "attribute_name": "BeaconLostCount", - "value": null - }, - "0/54/6": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 6, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.BeaconRxCount", - "attribute_name": "BeaconRxCount", - "value": null - }, - "0/54/7": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 7, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.PacketMulticastRxCount", - "attribute_name": "PacketMulticastRxCount", - "value": null - }, - "0/54/8": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 8, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.PacketMulticastTxCount", - "attribute_name": "PacketMulticastTxCount", - "value": null - }, - "0/54/9": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 9, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.PacketUnicastRxCount", - "attribute_name": "PacketUnicastRxCount", - "value": null - }, - "0/54/10": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 10, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.PacketUnicastTxCount", - "attribute_name": "PacketUnicastTxCount", - "value": null - }, - "0/54/11": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 11, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.CurrentMaxRate", - "attribute_name": "CurrentMaxRate", - "value": null - }, - "0/54/12": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 12, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.OverrunCount", - "attribute_name": "OverrunCount", - "value": null - }, - "0/54/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 3 - }, - "0/54/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/54/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/54/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0] - }, - "0/54/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 65528, 65529, 65531, 65532, - 65533 - ] - }, - "0/55/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.PHYRate", - "attribute_name": "PHYRate", - "value": null - }, - "0/55/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.FullDuplex", - "attribute_name": "FullDuplex", - "value": null - }, - "0/55/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.PacketRxCount", - "attribute_name": "PacketRxCount", - "value": 0 - }, - "0/55/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.PacketTxCount", - "attribute_name": "PacketTxCount", - "value": 0 - }, - "0/55/4": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.TxErrCount", - "attribute_name": "TxErrCount", - "value": 0 - }, - "0/55/5": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.CollisionCount", - "attribute_name": "CollisionCount", - "value": 0 - }, - "0/55/6": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 6, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.OverrunCount", - "attribute_name": "OverrunCount", - "value": 0 - }, - "0/55/7": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 7, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.CarrierDetect", - "attribute_name": "CarrierDetect", - "value": null - }, - "0/55/8": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 8, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.TimeSinceReset", - "attribute_name": "TimeSinceReset", - "value": 0 - }, - "0/55/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 3 - }, - "0/55/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/55/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/55/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0] - }, - "0/55/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533] - }, - "0/59/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 59, - "cluster_type": "chip.clusters.Objects.Switch", - "cluster_name": "Switch", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.Switch.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/59/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 59, - "cluster_type": "chip.clusters.Objects.Switch", - "cluster_name": "Switch", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.Switch.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/59/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 59, - "cluster_type": "chip.clusters.Objects.Switch", - "cluster_name": "Switch", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.Switch.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/59/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 59, - "cluster_type": "chip.clusters.Objects.Switch", - "cluster_name": "Switch", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.Switch.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/59/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 59, - "cluster_type": "chip.clusters.Objects.Switch", - "cluster_name": "Switch", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.Switch.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [65528, 65529, 65531, 65532, 65533] - }, - "0/60/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.WindowStatus", - "attribute_name": "WindowStatus", - "value": 0 - }, - "0/60/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.AdminFabricIndex", - "attribute_name": "AdminFabricIndex", - "value": null - }, - "0/60/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.AdminVendorId", - "attribute_name": "AdminVendorId", - "value": null - }, - "0/60/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/60/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/60/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/60/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 1, 2] - }, - "0/60/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 65528, 65529, 65531, 65532, 65533] - }, - "0/62/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.NOCs", - "attribute_name": "NOCs", - "value": [ - { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", - "fabricIndex": 1 - } - ] - }, - "0/62/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.Fabrics", - "attribute_name": "Fabrics", - "value": [ - { - "rootPublicKey": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 5, - "label": "", - "fabricIndex": 1 - } - ] - }, - "0/62/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.SupportedFabrics", - "attribute_name": "SupportedFabrics", - "value": 5 - }, - "0/62/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.CommissionedFabrics", - "attribute_name": "CommissionedFabrics", - "value": 1 - }, - "0/62/4": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.TrustedRootCertificates", - "attribute_name": "TrustedRootCertificates", - "value": [ - "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEAs0LOfZc6nU2vCzNP4s4thP60zvr4+hvwAg4WX37RRYWwunhkdRqKdlkwlAgAfG/f2ytVRhbT6dpwVyMdPM0fDcKNQEpARgkAmAwBBSOgri06KQhYgWHhrlHD/AJOSaN4DAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQGxeigcXo8H7pmRHCOma3uT688xoreaDwV8JfFUMUnHUvqg+2GNzFtvfD6MkDaYVPghsXjITZLv5qsHhrUaIO7QY" - ] - }, - "0/62/5": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.CurrentFabricIndex", - "attribute_name": "CurrentFabricIndex", - "value": 1 - }, - "0/62/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/62/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/62/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [1, 3, 5, 8] - }, - "0/62/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 2, 4, 6, 7, 9, 10, 11] - }, - "0/62/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533] - }, - "0/63/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.GroupKeyMap", - "attribute_name": "GroupKeyMap", - "value": [] - }, - "0/63/1": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.GroupTable", - "attribute_name": "GroupTable", - "value": [] - }, - "0/63/2": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.MaxGroupsPerFabric", - "attribute_name": "MaxGroupsPerFabric", - "value": 3 - }, - "0/63/3": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.MaxGroupKeysPerFabric", - "attribute_name": "MaxGroupKeysPerFabric", - "value": 3 - }, - "0/63/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/63/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/63/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [2, 5] - }, - "0/63/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 1, 3, 4] - }, - "0/63/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 63, - "cluster_type": "chip.clusters.Objects.GroupKeyManagement", - "cluster_name": "GroupKeyManagement", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.GroupKeyManagement.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] - }, - "0/64/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 64, - "cluster_type": "chip.clusters.Objects.FixedLabel", - "cluster_name": "FixedLabel", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.FixedLabel.Attributes.LabelList", - "attribute_name": "LabelList", - "value": [ - { - "label": "room", - "value": "bedroom 2" - }, - { - "label": "orientation", - "value": "North" - }, - { - "label": "floor", - "value": "2" - }, - { - "label": "direction", - "value": "up" - } - ] - }, - "0/64/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 64, - "cluster_type": "chip.clusters.Objects.FixedLabel", - "cluster_name": "FixedLabel", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.FixedLabel.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/64/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 64, - "cluster_type": "chip.clusters.Objects.FixedLabel", - "cluster_name": "FixedLabel", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.FixedLabel.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/64/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 64, - "cluster_type": "chip.clusters.Objects.FixedLabel", - "cluster_name": "FixedLabel", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.FixedLabel.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/64/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 64, - "cluster_type": "chip.clusters.Objects.FixedLabel", - "cluster_name": "FixedLabel", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.FixedLabel.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/64/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 64, - "cluster_type": "chip.clusters.Objects.FixedLabel", - "cluster_name": "FixedLabel", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.FixedLabel.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 65528, 65529, 65531, 65532, 65533] - }, - "0/65/0": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 65, - "cluster_type": "chip.clusters.Objects.UserLabel", - "cluster_name": "UserLabel", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.UserLabel.Attributes.LabelList", - "attribute_name": "LabelList", - "value": [] - }, - "0/65/65532": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 65, - "cluster_type": "chip.clusters.Objects.UserLabel", - "cluster_name": "UserLabel", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.UserLabel.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/65/65533": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 65, - "cluster_type": "chip.clusters.Objects.UserLabel", - "cluster_name": "UserLabel", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.UserLabel.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/65/65528": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 65, - "cluster_type": "chip.clusters.Objects.UserLabel", - "cluster_name": "UserLabel", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.UserLabel.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/65/65529": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 65, - "cluster_type": "chip.clusters.Objects.UserLabel", - "cluster_name": "UserLabel", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.UserLabel.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/65/65531": { - "node_id": 5, - "endpoint": 0, - "cluster_id": 65, - "cluster_type": "chip.clusters.Objects.UserLabel", - "cluster_name": "UserLabel", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.UserLabel.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 65528, 65529, 65531, 65532, 65533] - }, - "1/3/0": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.IdentifyTime", - "attribute_name": "IdentifyTime", - "value": 0 - }, - "1/3/1": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.IdentifyType", - "attribute_name": "IdentifyType", - "value": 0 - }, - "1/3/65532": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "1/3/65533": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 4 - }, - "1/3/65528": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "1/3/65529": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 64] - }, - "1/3/65531": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 3, - "cluster_type": "chip.clusters.Objects.Identify", - "cluster_name": "Identify", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.Identify.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 65528, 65529, 65531, 65532, 65533] - }, - "1/4/0": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.NameSupport", - "attribute_name": "NameSupport", - "value": 128 - }, - "1/4/65532": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 1 - }, - "1/4/65533": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 4 - }, - "1/4/65528": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [0, 1, 2, 3] - }, - "1/4/65529": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 1, 2, 3, 4, 5] - }, - "1/4/65531": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 65528, 65529, 65531, 65532, 65533] - }, - "1/6/0": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.OnOff", - "attribute_name": "OnOff", - "value": false - }, - "1/6/16384": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 16384, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.GlobalSceneControl", - "attribute_name": "GlobalSceneControl", - "value": true - }, - "1/6/16385": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 16385, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.OnTime", - "attribute_name": "OnTime", - "value": 0 - }, - "1/6/16386": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 16386, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.OffWaitTime", - "attribute_name": "OffWaitTime", - "value": 0 - }, - "1/6/16387": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 16387, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.StartUpOnOff", - "attribute_name": "StartUpOnOff", - "value": null - }, - "1/6/65532": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 1 - }, - "1/6/65533": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 4 - }, - "1/6/65528": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "1/6/65529": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 1, 2, 64, 65, 66] - }, - "1/6/65531": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 6, - "cluster_type": "chip.clusters.Objects.OnOff", - "cluster_name": "OnOff", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.OnOff.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [ - 0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533 - ] - }, - "1/8/0": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.CurrentLevel", - "attribute_name": "CurrentLevel", - "value": 254 - }, - "1/8/1": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.RemainingTime", - "attribute_name": "RemainingTime", - "value": 0 - }, - "1/8/2": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.MinLevel", - "attribute_name": "MinLevel", - "value": 1 - }, - "1/8/3": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.MaxLevel", - "attribute_name": "MaxLevel", - "value": 254 - }, - "1/8/4": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.CurrentFrequency", - "attribute_name": "CurrentFrequency", - "value": 0 - }, - "1/8/5": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.MinFrequency", - "attribute_name": "MinFrequency", - "value": 0 - }, - "1/8/6": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 6, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.MaxFrequency", - "attribute_name": "MaxFrequency", - "value": 0 - }, - "1/8/15": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 15, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.Options", - "attribute_name": "Options", - "value": 0 - }, - "1/8/16": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 16, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.OnOffTransitionTime", - "attribute_name": "OnOffTransitionTime", - "value": 0 - }, - "1/8/17": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 17, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.OnLevel", - "attribute_name": "OnLevel", - "value": null - }, - "1/8/18": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 18, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.OnTransitionTime", - "attribute_name": "OnTransitionTime", - "value": 0 - }, - "1/8/19": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 19, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.OffTransitionTime", - "attribute_name": "OffTransitionTime", - "value": 0 - }, - "1/8/20": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 20, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.DefaultMoveRate", - "attribute_name": "DefaultMoveRate", - "value": 50 - }, - "1/8/16384": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 16384, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.StartUpCurrentLevel", - "attribute_name": "StartUpCurrentLevel", - "value": null - }, - "1/8/65532": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 3 - }, - "1/8/65533": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 5 - }, - "1/8/65528": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "1/8/65529": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 1, 2, 3, 4, 5, 6, 7] - }, - "1/8/65531": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 8, - "cluster_type": "chip.clusters.Objects.LevelControl", - "cluster_name": "LevelControl", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.LevelControl.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [ - 0, 1, 2, 3, 4, 5, 6, 15, 16, 17, 18, 19, 20, 16384, 65528, 65529, 65531, - 65532, 65533 - ] - }, - "1/29/0": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.DeviceTypeList", - "attribute_name": "DeviceTypeList", - "value": [ - { - "type": 257, - "revision": 1 - } - ] - }, - "1/29/1": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ServerList", - "attribute_name": "ServerList", - "value": [3, 4, 6, 8, 29, 768, 1030] - }, - "1/29/2": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ClientList", - "attribute_name": "ClientList", - "value": [] - }, - "1/29/3": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.PartsList", - "attribute_name": "PartsList", - "value": [] - }, - "1/29/65532": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "1/29/65533": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "1/29/65528": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "1/29/65529": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "1/29/65531": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] - }, - "1/768/0": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.CurrentHue", - "attribute_name": "CurrentHue", - "value": 0 - }, - "1/768/1": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.CurrentSaturation", - "attribute_name": "CurrentSaturation", - "value": 0 - }, - "1/768/2": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.RemainingTime", - "attribute_name": "RemainingTime", - "value": 0 - }, - "1/768/3": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.CurrentX", - "attribute_name": "CurrentX", - "value": 24939 - }, - "1/768/4": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.CurrentY", - "attribute_name": "CurrentY", - "value": 24701 - }, - "1/768/7": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 7, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorTemperatureMireds", - "attribute_name": "ColorTemperatureMireds", - "value": 0 - }, - "1/768/8": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 8, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorMode", - "attribute_name": "ColorMode", - "value": 2 - }, - "1/768/15": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 15, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.Options", - "attribute_name": "Options", - "value": 0 - }, - "1/768/16": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.NumberOfPrimaries", - "attribute_name": "NumberOfPrimaries", - "value": 0 - }, - "1/768/16384": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16384, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.EnhancedCurrentHue", - "attribute_name": "EnhancedCurrentHue", - "value": 0 - }, - "1/768/16385": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16385, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.EnhancedColorMode", - "attribute_name": "EnhancedColorMode", - "value": 2 - }, - "1/768/16386": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16386, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorLoopActive", - "attribute_name": "ColorLoopActive", - "value": 0 - }, - "1/768/16387": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16387, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorLoopDirection", - "attribute_name": "ColorLoopDirection", - "value": 0 - }, - "1/768/16388": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16388, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorLoopTime", - "attribute_name": "ColorLoopTime", - "value": 25 - }, - "1/768/16389": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16389, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorLoopStartEnhancedHue", - "attribute_name": "ColorLoopStartEnhancedHue", - "value": 8960 - }, - "1/768/16390": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16390, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorLoopStoredEnhancedHue", - "attribute_name": "ColorLoopStoredEnhancedHue", - "value": 0 - }, - "1/768/16394": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16394, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorCapabilities", - "attribute_name": "ColorCapabilities", - "value": 31 - }, - "1/768/16395": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16395, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorTempPhysicalMinMireds", - "attribute_name": "ColorTempPhysicalMinMireds", - "value": 0 - }, - "1/768/16396": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16396, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ColorTempPhysicalMaxMireds", - "attribute_name": "ColorTempPhysicalMaxMireds", - "value": 65279 - }, - "1/768/16397": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16397, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.CoupleColorTempToLevelMinMireds", - "attribute_name": "CoupleColorTempToLevelMinMireds", - "value": 0 - }, - "1/768/16400": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 16400, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.StartUpColorTemperatureMireds", - "attribute_name": "StartUpColorTemperatureMireds", - "value": 0 - }, - "1/768/65532": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 31 - }, - "1/768/65533": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 5 - }, - "1/768/65528": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "1/768/65529": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 64, 65, 66, 67, 68, 71, 75, 76 - ] - }, - "1/768/65531": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 768, - "cluster_type": "chip.clusters.Objects.ColorControl", - "cluster_name": "ColorControl", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.ColorControl.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [ - 0, 1, 2, 3, 4, 7, 8, 15, 16, 16384, 16385, 16386, 16387, 16388, 16389, - 16390, 16394, 16395, 16396, 16397, 16400, 65528, 65529, 65531, 65532, - 65533 - ] - }, - "1/1030/0": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 1030, - "cluster_type": "chip.clusters.Objects.OccupancySensing", - "cluster_name": "OccupancySensing", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.OccupancySensing.Attributes.Occupancy", - "attribute_name": "Occupancy", - "value": 0 - }, - "1/1030/1": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 1030, - "cluster_type": "chip.clusters.Objects.OccupancySensing", - "cluster_name": "OccupancySensing", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.OccupancySensing.Attributes.OccupancySensorType", - "attribute_name": "OccupancySensorType", - "value": 0 - }, - "1/1030/2": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 1030, - "cluster_type": "chip.clusters.Objects.OccupancySensing", - "cluster_name": "OccupancySensing", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.OccupancySensing.Attributes.OccupancySensorTypeBitmap", - "attribute_name": "OccupancySensorTypeBitmap", - "value": 1 - }, - "1/1030/65532": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 1030, - "cluster_type": "chip.clusters.Objects.OccupancySensing", - "cluster_name": "OccupancySensing", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.OccupancySensing.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "1/1030/65533": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 1030, - "cluster_type": "chip.clusters.Objects.OccupancySensing", - "cluster_name": "OccupancySensing", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.OccupancySensing.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 3 - }, - "1/1030/65528": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 1030, - "cluster_type": "chip.clusters.Objects.OccupancySensing", - "cluster_name": "OccupancySensing", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.OccupancySensing.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "1/1030/65529": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 1030, - "cluster_type": "chip.clusters.Objects.OccupancySensing", - "cluster_name": "OccupancySensing", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.OccupancySensing.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "1/1030/65531": { - "node_id": 5, - "endpoint": 1, - "cluster_id": 1030, - "cluster_type": "chip.clusters.Objects.OccupancySensing", - "cluster_name": "OccupancySensing", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.OccupancySensing.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 65528, 65529, 65531, 65532, 65533] - } + ], + "1/29/1": [3, 4, 6, 8, 29, 768, 1030], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/768/0": 0, + "1/768/1": 0, + "1/768/2": 0, + "1/768/3": 24939, + "1/768/4": 24701, + "1/768/7": 0, + "1/768/8": 2, + "1/768/15": 0, + "1/768/16": 0, + "1/768/16384": 0, + "1/768/16385": 2, + "1/768/16386": 0, + "1/768/16387": 0, + "1/768/16388": 25, + "1/768/16389": 8960, + "1/768/16390": 0, + "1/768/16394": 31, + "1/768/16395": 0, + "1/768/16396": 65279, + "1/768/16397": 0, + "1/768/16400": 0, + "1/768/65532": 31, + "1/768/65533": 5, + "1/768/65528": [], + "1/768/65529": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 64, 65, 66, 67, 68, 71, 75, 76 + ], + "1/768/65531": [ + 0, 1, 2, 3, 4, 7, 8, 15, 16, 16384, 16385, 16386, 16387, 16388, 16389, + 16390, 16394, 16395, 16396, 16397, 16400, 65528, 65529, 65531, 65532, + 65533 + ], + "1/1030/0": 0, + "1/1030/1": 0, + "1/1030/2": 1, + "1/1030/65532": 0, + "1/1030/65533": 3, + "1/1030/65528": [], + "1/1030/65529": [], + "1/1030/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533] }, - "endpoints": [0, 1], - "root_device_type_instance": { - "__type": "", - "repr": "" - }, - "aggregator_device_type_instance": null, - "device_type_instances": [ - { - "__type": "", - "repr": "" - } - ], - "node_devices": [ - { - "__type": "", - "repr": "" - } - ] + "available": true } diff --git a/tests/components/matter/fixtures/nodes/dimmable-light.json b/tests/components/matter/fixtures/nodes/dimmable-light.json index 7449c600b3..f295e3bf15 100644 --- a/tests/components/matter/fixtures/nodes/dimmable-light.json +++ b/tests/components/matter/fixtures/nodes/dimmable-light.json @@ -2,4219 +2,410 @@ "node_id": 1, "date_commissioned": "2022-11-29T21:23:48.485051", "last_interview": "2022-11-29T21:23:48.485057", - "interview_version": 1, + "interview_version": 2, "attributes": { - "0/4/0": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.NameSupport", - "attribute_name": "NameSupport", - "value": 128 - }, - "0/4/65532": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 1 - }, - "0/4/65533": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 4 - }, - "0/4/65528": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [0, 1, 2, 3] - }, - "0/4/65529": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 1, 2, 3, 4, 5] - }, - "0/4/65531": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 4, - "cluster_type": "chip.clusters.Objects.Groups", - "cluster_name": "Groups", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.Groups.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 65528, 65529, 65531, 65532, 65533] - }, - "0/29/0": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.DeviceTypeList", - "attribute_name": "DeviceTypeList", - "value": [ - { - "type": 22, - "revision": 1 - } - ] - }, - "0/29/1": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ServerList", - "attribute_name": "ServerList", - "value": [ - 4, 29, 31, 40, 42, 43, 44, 48, 49, 50, 51, 52, 53, 54, 55, 59, 60, 62, - 63, 64, 65 - ] - }, - "0/29/2": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ClientList", - "attribute_name": "ClientList", - "value": [41] - }, - "0/29/3": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.PartsList", - "attribute_name": "PartsList", - "value": [1] - }, - "0/29/65532": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/29/65533": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/29/65528": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/29/65529": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/29/65531": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 29, - "cluster_type": "chip.clusters.Objects.Descriptor", - "cluster_name": "Descriptor", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.Descriptor.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] - }, - "0/31/0": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.Acl", - "attribute_name": "Acl", - "value": [ - { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 - } - ] - }, - "0/31/1": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.Extension", - "attribute_name": "Extension", - "value": [] - }, - "0/31/2": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.SubjectsPerAccessControlEntry", - "attribute_name": "SubjectsPerAccessControlEntry", - "value": 4 - }, - "0/31/3": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.TargetsPerAccessControlEntry", - "attribute_name": "TargetsPerAccessControlEntry", - "value": 3 - }, - "0/31/4": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.AccessControlEntriesPerFabric", - "attribute_name": "AccessControlEntriesPerFabric", - "value": 3 - }, - "0/31/65532": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/31/65533": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/31/65528": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/31/65529": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/31/65531": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 31, - "cluster_type": "chip.clusters.Objects.AccessControl", - "cluster_name": "AccessControl", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.AccessControl.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533] - }, - "0/40/0": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.DataModelRevision", - "attribute_name": "DataModelRevision", - "value": 1 - }, - "0/40/1": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.VendorName", - "attribute_name": "VendorName", - "value": "Nabu Casa" - }, - "0/40/2": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.VendorID", - "attribute_name": "VendorID", - "value": 65521 - }, - "0/40/3": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.ProductName", - "attribute_name": "ProductName", - "value": "Mock Dimmable Light" - }, - "0/40/4": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.ProductID", - "attribute_name": "ProductID", - "value": 32768 - }, - "0/40/5": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.NodeLabel", - "attribute_name": "NodeLabel", - "value": "Mock Dimmable Light" - }, - "0/40/6": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 6, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.Location", - "attribute_name": "Location", - "value": "XX" - }, - "0/40/7": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 7, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.HardwareVersion", - "attribute_name": "HardwareVersion", - "value": 0 - }, - "0/40/8": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 8, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.HardwareVersionString", - "attribute_name": "HardwareVersionString", - "value": "v1.0" - }, - "0/40/9": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 9, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.SoftwareVersion", - "attribute_name": "SoftwareVersion", - "value": 1 - }, - "0/40/10": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 10, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.SoftwareVersionString", - "attribute_name": "SoftwareVersionString", - "value": "v1.0" - }, - "0/40/11": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 11, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.ManufacturingDate", - "attribute_name": "ManufacturingDate", - "value": "20200101" - }, - "0/40/12": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 12, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.PartNumber", - "attribute_name": "PartNumber", - "value": "" - }, - "0/40/13": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 13, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.ProductURL", - "attribute_name": "ProductURL", - "value": "" - }, - "0/40/14": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 14, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.ProductLabel", - "attribute_name": "ProductLabel", - "value": "" - }, - "0/40/15": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 15, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.SerialNumber", - "attribute_name": "SerialNumber", - "value": "TEST_SN" - }, - "0/40/16": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 16, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.LocalConfigDisabled", - "attribute_name": "LocalConfigDisabled", - "value": false - }, - "0/40/17": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 17, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.Reachable", - "attribute_name": "Reachable", - "value": true - }, - "0/40/18": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 18, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.UniqueID", - "attribute_name": "UniqueID", - "value": "mock-dimmable-light" - }, + "0/4/0": 128, + "0/4/65532": 1, + "0/4/65533": 4, + "0/4/65528": [0, 1, 2, 3], + "0/4/65529": [0, 1, 2, 3, 4, 5], + "0/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/29/0": [ + { + "type": 22, + "revision": 1 + } + ], + "0/29/1": [ + 4, 29, 31, 40, 42, 43, 44, 48, 49, 50, 51, 52, 53, 54, 55, 59, 60, 62, 63, + 64, 65 + ], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "privilege": 5, + "authMode": 2, + "subjects": [112233], + "targets": null, + "fabricIndex": 1 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 3, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "Nabu Casa", + "0/40/2": 65521, + "0/40/3": "Mock Dimmable Light", + "0/40/4": 32768, + "0/40/5": "Mock Dimmable Light", + "0/40/6": "XX", + "0/40/7": 0, + "0/40/8": "v1.0", + "0/40/9": 1, + "0/40/10": "v1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "TEST_SN", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "mock-dimmable-light", "0/40/19": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 19, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.CapabilityMinima", - "attribute_name": "CapabilityMinima", - "value": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 - } - }, - "0/40/65532": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/40/65533": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/40/65528": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/40/65529": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/40/65531": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 40, - "cluster_type": "chip.clusters.Objects.BasicInformation", - "cluster_name": "Basic", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.BasicInformation.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, - 65528, 65529, 65531, 65532, 65533 - ] - }, - "0/42/0": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.DefaultOtaProviders", - "attribute_name": "DefaultOtaProviders", - "value": [] - }, - "0/42/1": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.UpdatePossible", - "attribute_name": "UpdatePossible", - "value": true - }, - "0/42/2": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.UpdateState", - "attribute_name": "UpdateState", - "value": 0 - }, - "0/42/3": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.UpdateStateProgress", - "attribute_name": "UpdateStateProgress", - "value": 0 - }, - "0/42/65532": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/42/65533": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/42/65528": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/42/65529": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0] - }, - "0/42/65531": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 42, - "cluster_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor", - "cluster_name": "OtaSoftwareUpdateRequestor", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.OtaSoftwareUpdateRequestor.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] - }, - "0/43/0": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 43, - "cluster_type": "chip.clusters.Objects.LocalizationConfiguration", - "cluster_name": "LocalizationConfiguration", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.LocalizationConfiguration.Attributes.ActiveLocale", - "attribute_name": "ActiveLocale", - "value": "en-US" - }, - "0/43/1": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 43, - "cluster_type": "chip.clusters.Objects.LocalizationConfiguration", - "cluster_name": "LocalizationConfiguration", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.LocalizationConfiguration.Attributes.SupportedLocales", - "attribute_name": "SupportedLocales", - "value": [ - "en-US", - "de-DE", - "fr-FR", - "en-GB", - "es-ES", - "zh-CN", - "it-IT", - "ja-JP" - ] - }, - "0/43/65532": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 43, - "cluster_type": "chip.clusters.Objects.LocalizationConfiguration", - "cluster_name": "LocalizationConfiguration", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.LocalizationConfiguration.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/43/65533": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 43, - "cluster_type": "chip.clusters.Objects.LocalizationConfiguration", - "cluster_name": "LocalizationConfiguration", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.LocalizationConfiguration.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/43/65528": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 43, - "cluster_type": "chip.clusters.Objects.LocalizationConfiguration", - "cluster_name": "LocalizationConfiguration", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.LocalizationConfiguration.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/43/65529": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 43, - "cluster_type": "chip.clusters.Objects.LocalizationConfiguration", - "cluster_name": "LocalizationConfiguration", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.LocalizationConfiguration.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/43/65531": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 43, - "cluster_type": "chip.clusters.Objects.LocalizationConfiguration", - "cluster_name": "LocalizationConfiguration", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.LocalizationConfiguration.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 65528, 65529, 65531, 65532, 65533] - }, - "0/44/0": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 44, - "cluster_type": "chip.clusters.Objects.TimeFormatLocalization", - "cluster_name": "TimeFormatLocalization", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.TimeFormatLocalization.Attributes.HourFormat", - "attribute_name": "HourFormat", - "value": 0 - }, - "0/44/1": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 44, - "cluster_type": "chip.clusters.Objects.TimeFormatLocalization", - "cluster_name": "TimeFormatLocalization", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.TimeFormatLocalization.Attributes.ActiveCalendarType", - "attribute_name": "ActiveCalendarType", - "value": 0 - }, - "0/44/2": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 44, - "cluster_type": "chip.clusters.Objects.TimeFormatLocalization", - "cluster_name": "TimeFormatLocalization", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.TimeFormatLocalization.Attributes.SupportedCalendarTypes", - "attribute_name": "SupportedCalendarTypes", - "value": [0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 7] - }, - "0/44/65532": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 44, - "cluster_type": "chip.clusters.Objects.TimeFormatLocalization", - "cluster_name": "TimeFormatLocalization", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.TimeFormatLocalization.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/44/65533": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 44, - "cluster_type": "chip.clusters.Objects.TimeFormatLocalization", - "cluster_name": "TimeFormatLocalization", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.TimeFormatLocalization.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/44/65528": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 44, - "cluster_type": "chip.clusters.Objects.TimeFormatLocalization", - "cluster_name": "TimeFormatLocalization", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.TimeFormatLocalization.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/44/65529": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 44, - "cluster_type": "chip.clusters.Objects.TimeFormatLocalization", - "cluster_name": "TimeFormatLocalization", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.TimeFormatLocalization.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/44/65531": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 44, - "cluster_type": "chip.clusters.Objects.TimeFormatLocalization", - "cluster_name": "TimeFormatLocalization", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.TimeFormatLocalization.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 65528, 65529, 65531, 65532, 65533] - }, - "0/48/0": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.Breadcrumb", - "attribute_name": "Breadcrumb", - "value": 0 + "caseSessionsPerFabric": 3, + "subscriptionsPerFabric": 3 }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 65528, 65529, 65531, 65532, 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 0, + "0/42/3": 0, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/43/0": "en-US", + "0/43/1": [ + "en-US", + "de-DE", + "fr-FR", + "en-GB", + "es-ES", + "zh-CN", + "it-IT", + "ja-JP" + ], + "0/43/65532": 0, + "0/43/65533": 1, + "0/43/65528": [], + "0/43/65529": [], + "0/43/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "0/44/0": 0, + "0/44/1": 0, + "0/44/2": [0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 7], + "0/44/65532": 0, + "0/44/65533": 1, + "0/44/65528": [], + "0/44/65529": [], + "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, "0/48/1": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.BasicCommissioningInfo", - "attribute_name": "BasicCommissioningInfo", - "value": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "failSafeExpiryLengthSeconds": 60, + "maxCumulativeFailsafeSeconds": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "networkID": "", + "connected": true } - }, - "0/48/2": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.RegulatoryConfig", - "attribute_name": "RegulatoryConfig", - "value": 0 - }, - "0/48/3": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.LocationCapability", - "attribute_name": "LocationCapability", - "value": 0 - }, - "0/48/4": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.SupportsConcurrentConnection", - "attribute_name": "SupportsConcurrentConnection", - "value": true - }, - "0/48/65532": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/48/65533": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/48/65528": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [1, 3, 5] - }, - "0/48/65529": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 2, 4] - }, - "0/48/65531": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 48, - "cluster_type": "chip.clusters.Objects.GeneralCommissioning", - "cluster_name": "GeneralCommissioning", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.GeneralCommissioning.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533] - }, - "0/49/0": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.MaxNetworks", - "attribute_name": "MaxNetworks", - "value": 1 - }, - "0/49/1": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.Networks", - "attribute_name": "Networks", - "value": [ - { - "networkID": "b'wifi-sm'", - "connected": true - } - ] - }, - "0/49/2": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.ScanMaxTimeSeconds", - "attribute_name": "ScanMaxTimeSeconds", - "value": 10 - }, - "0/49/3": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.ConnectMaxTimeSeconds", - "attribute_name": "ConnectMaxTimeSeconds", - "value": 30 - }, - "0/49/4": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.InterfaceEnabled", - "attribute_name": "InterfaceEnabled", - "value": true - }, - "0/49/5": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.LastNetworkingStatus", - "attribute_name": "LastNetworkingStatus", - "value": 0 - }, - "0/49/6": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 6, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.LastNetworkID", - "attribute_name": "LastNetworkID", - "value": "b'wifi-sm'" - }, - "0/49/7": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 7, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.LastConnectErrorValue", - "attribute_name": "LastConnectErrorValue", - "value": null - }, - "0/49/65532": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 1 - }, - "0/49/65533": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/49/65528": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [1, 5, 7] - }, - "0/49/65529": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 2, 4, 6, 8] - }, - "0/49/65531": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 49, - "cluster_type": "chip.clusters.Objects.NetworkCommissioning", - "cluster_name": "NetworkCommissioning", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.NetworkCommissioning.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533] - }, - "0/50/65532": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 50, - "cluster_type": "chip.clusters.Objects.DiagnosticLogs", - "cluster_name": "DiagnosticLogs", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.DiagnosticLogs.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/50/65533": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 50, - "cluster_type": "chip.clusters.Objects.DiagnosticLogs", - "cluster_name": "DiagnosticLogs", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.DiagnosticLogs.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/50/65528": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 50, - "cluster_type": "chip.clusters.Objects.DiagnosticLogs", - "cluster_name": "DiagnosticLogs", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.DiagnosticLogs.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [1] - }, - "0/50/65529": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 50, - "cluster_type": "chip.clusters.Objects.DiagnosticLogs", - "cluster_name": "DiagnosticLogs", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.DiagnosticLogs.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0] - }, - "0/50/65531": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 50, - "cluster_type": "chip.clusters.Objects.DiagnosticLogs", - "cluster_name": "DiagnosticLogs", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.DiagnosticLogs.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [65528, 65529, 65531, 65532, 65533] - }, - "0/51/0": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.NetworkInterfaces", - "attribute_name": "NetworkInterfaces", - "value": [ - { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "b\"\\x84\\xf7\\x03'\\xb5,\"", - "IPv4Addresses": ["b'\\xc0\\xa8\\x01\\x84'"], - "IPv6Addresses": [ - "b\"\\xfe\\x80\\x00\\x00\\x00\\x00\\x00\\x00\\x86\\xf7\\x03\\xff\\xfe'\\xb5,\"", - "b\"*\\x00\\xbb\\xa0\\x12\\x12\\x8f\\x00\\x86\\xf7\\x03\\xff\\xfe'\\xb5,\"", - "b\"\\xfd\\xf5\\nn\\xf0cO\\xed\\x86\\xf7\\x03\\xff\\xfe'\\xb5,\"" - ], - "type": 1 - } - ] - }, - "0/51/1": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.RebootCount", - "attribute_name": "RebootCount", - "value": 6 - }, - "0/51/2": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.UpTime", - "attribute_name": "UpTime", - "value": 31279 - }, - "0/51/3": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.TotalOperationalHours", - "attribute_name": "TotalOperationalHours", - "value": 8 - }, - "0/51/4": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.BootReasons", - "attribute_name": "BootReasons", - "value": 1 - }, - "0/51/5": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.ActiveHardwareFaults", - "attribute_name": "ActiveHardwareFaults", - "value": [] - }, - "0/51/6": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 6, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.ActiveRadioFaults", - "attribute_name": "ActiveRadioFaults", - "value": [] - }, - "0/51/7": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 7, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.ActiveNetworkFaults", - "attribute_name": "ActiveNetworkFaults", - "value": [] - }, - "0/51/8": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 8, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.TestEventTriggersEnabled", - "attribute_name": "TestEventTriggersEnabled", - "value": false - }, - "0/51/65532": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/51/65533": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/51/65528": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/51/65529": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0] - }, - "0/51/65531": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 51, - "cluster_type": "chip.clusters.Objects.GeneralDiagnostics", - "cluster_name": "GeneralDiagnostics", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.GeneralDiagnostics.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533] - }, - "0/52/0": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.ThreadMetrics", - "attribute_name": "ThreadMetrics", - "value": [] - }, - "0/52/1": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.CurrentHeapFree", - "attribute_name": "CurrentHeapFree", - "value": 166480 - }, - "0/52/2": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.CurrentHeapUsed", - "attribute_name": "CurrentHeapUsed", - "value": 86512 - }, - "0/52/3": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.CurrentHeapHighWatermark", - "attribute_name": "CurrentHeapHighWatermark", - "value": 157052 - }, - "0/52/65532": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/52/65533": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/52/65528": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/52/65529": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/52/65531": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 52, - "cluster_type": "chip.clusters.Objects.SoftwareDiagnostics", - "cluster_name": "SoftwareDiagnostics", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.SoftwareDiagnostics.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] - }, - "0/53/0": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.Channel", - "attribute_name": "Channel", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" + ], + "0/49/2": 10, + "0/49/3": 30, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "", + "0/49/7": null, + "0/49/65532": 1, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 2, 4, 6, 8], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/50/65532": 0, + "0/50/65533": 1, + "0/50/65528": [1], + "0/50/65529": [0], + "0/50/65531": [65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "name": "WIFI_STA_DEF", + "isOperational": true, + "offPremiseServicesReachableIPv4": null, + "offPremiseServicesReachableIPv6": null, + "hardwareAddress": "", + "IPv4Addresses": [], + "IPv6Addresses": [], + "type": 1 } - }, - "0/53/1": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RoutingRole", - "attribute_name": "RoutingRole", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" + ], + "0/51/1": 6, + "0/51/2": 31279, + "0/51/3": 8, + "0/51/4": 1, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/52/0": [], + "0/52/1": 166480, + "0/52/2": 86512, + "0/52/3": 157052, + "0/52/65532": 0, + "0/52/65533": 1, + "0/52/65528": [], + "0/52/65529": [], + "0/52/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/53/65532": 15, + "0/53/65533": 1, + "0/53/65528": [], + "0/53/65529": [0], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, + 57, 58, 59, 60, 61, 62, 65528, 65529, 65531, 65532, 65533 + ], + "0/54/0": "", + "0/54/1": 4, + "0/54/2": 3, + "0/54/3": 6, + "0/54/4": -61, + "0/54/5": null, + "0/54/6": null, + "0/54/7": null, + "0/54/8": null, + "0/54/9": null, + "0/54/10": null, + "0/54/11": null, + "0/54/12": null, + "0/54/65532": 3, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [0], + "0/54/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 65528, 65529, 65531, 65532, + 65533 + ], + "0/55/0": null, + "0/55/1": null, + "0/55/2": 0, + "0/55/3": 0, + "0/55/4": 0, + "0/55/5": 0, + "0/55/6": 0, + "0/55/7": null, + "0/55/8": 0, + "0/55/65532": 3, + "0/55/65533": 1, + "0/55/65528": [], + "0/55/65529": [0], + "0/55/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/59/65532": 0, + "0/59/65533": 1, + "0/59/65528": [], + "0/59/65529": [], + "0/59/65531": [65528, 65529, 65531, 65532, 65533], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "noc": "", + "icac": "", + "fabricIndex": 1 } - }, - "0/53/2": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.NetworkName", - "attribute_name": "NetworkName", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" + ], + "0/62/1": [ + { + "rootPublicKey": "", + "vendorId": 65521, + "fabricId": 1, + "nodeId": 1, + "label": "", + "fabricIndex": 1 } - }, - "0/53/3": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.PanId", - "attribute_name": "PanId", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" + ], + "0/62/2": 5, + "0/62/3": 1, + "0/62/4": [""], + "0/62/5": 1, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 3, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/64/0": [ + { + "label": "room", + "value": "bedroom 2" + }, + { + "label": "orientation", + "value": "North" + }, + { + "label": "floor", + "value": "2" + }, + { + "label": "direction", + "value": "up" } - }, - "0/53/4": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.ExtendedPanId", - "attribute_name": "ExtendedPanId", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" + ], + "0/64/65532": 0, + "0/64/65533": 1, + "0/64/65528": [], + "0/64/65529": [], + "0/64/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/65/0": [], + "0/65/65532": 0, + "0/65/65533": 1, + "0/65/65528": [], + "0/65/65529": [], + "0/65/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/6/0": true, + "1/6/16384": true, + "1/6/16385": 0, + "1/6/16386": 0, + "1/6/16387": null, + "1/6/65532": 1, + "1/6/65533": 4, + "1/6/65528": [], + "1/6/65529": [0, 1, 2, 64, 65, 66], + "1/6/65531": [ + 0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533 + ], + "1/8/0": 52, + "1/8/1": 0, + "1/8/2": 1, + "1/8/3": 254, + "1/8/4": 0, + "1/8/5": 0, + "1/8/6": 0, + "1/8/15": 0, + "1/8/16": 0, + "1/8/17": null, + "1/8/18": 0, + "1/8/19": 0, + "1/8/20": 50, + "1/8/16384": null, + "1/8/65532": 3, + "1/8/65533": 5, + "1/8/65528": [], + "1/8/65529": [0, 1, 2, 3, 4, 5, 6, 7], + "1/8/65531": [ + 0, 1, 2, 3, 4, 5, 6, 15, 16, 17, 18, 19, 20, 16384, 65528, 65529, 65531, + 65532, 65533 + ], + "1/29/0": [ + { + "type": 257, + "revision": 1 } - }, - "0/53/5": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.MeshLocalPrefix", - "attribute_name": "MeshLocalPrefix", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/6": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 6, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.OverrunCount", - "attribute_name": "OverrunCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/7": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 7, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.NeighborTableList", - "attribute_name": "NeighborTableList", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/8": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 8, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RouteTableList", - "attribute_name": "RouteTableList", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/9": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 9, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.PartitionId", - "attribute_name": "PartitionId", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/10": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 10, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.Weighting", - "attribute_name": "Weighting", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/11": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 11, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.DataVersion", - "attribute_name": "DataVersion", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/12": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 12, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.StableDataVersion", - "attribute_name": "StableDataVersion", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/13": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 13, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.LeaderRouterId", - "attribute_name": "LeaderRouterId", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/14": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 14, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.DetachedRoleCount", - "attribute_name": "DetachedRoleCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/15": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 15, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.ChildRoleCount", - "attribute_name": "ChildRoleCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/16": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 16, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RouterRoleCount", - "attribute_name": "RouterRoleCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/17": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 17, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.LeaderRoleCount", - "attribute_name": "LeaderRoleCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/18": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 18, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.AttachAttemptCount", - "attribute_name": "AttachAttemptCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/19": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 19, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.PartitionIdChangeCount", - "attribute_name": "PartitionIdChangeCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/20": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 20, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.BetterPartitionAttachAttemptCount", - "attribute_name": "BetterPartitionAttachAttemptCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/21": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 21, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.ParentChangeCount", - "attribute_name": "ParentChangeCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/22": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 22, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxTotalCount", - "attribute_name": "TxTotalCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/23": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 23, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxUnicastCount", - "attribute_name": "TxUnicastCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/24": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 24, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxBroadcastCount", - "attribute_name": "TxBroadcastCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/25": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 25, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxAckRequestedCount", - "attribute_name": "TxAckRequestedCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/26": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 26, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxAckedCount", - "attribute_name": "TxAckedCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/27": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 27, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxNoAckRequestedCount", - "attribute_name": "TxNoAckRequestedCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/28": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 28, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxDataCount", - "attribute_name": "TxDataCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/29": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 29, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxDataPollCount", - "attribute_name": "TxDataPollCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/30": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 30, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxBeaconCount", - "attribute_name": "TxBeaconCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/31": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 31, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxBeaconRequestCount", - "attribute_name": "TxBeaconRequestCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/32": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 32, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxOtherCount", - "attribute_name": "TxOtherCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/33": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 33, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxRetryCount", - "attribute_name": "TxRetryCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/34": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 34, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxDirectMaxRetryExpiryCount", - "attribute_name": "TxDirectMaxRetryExpiryCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/35": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 35, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxIndirectMaxRetryExpiryCount", - "attribute_name": "TxIndirectMaxRetryExpiryCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/36": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 36, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxErrCcaCount", - "attribute_name": "TxErrCcaCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/37": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 37, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxErrAbortCount", - "attribute_name": "TxErrAbortCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/38": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 38, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.TxErrBusyChannelCount", - "attribute_name": "TxErrBusyChannelCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/39": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 39, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxTotalCount", - "attribute_name": "RxTotalCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/40": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 40, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxUnicastCount", - "attribute_name": "RxUnicastCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/41": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 41, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxBroadcastCount", - "attribute_name": "RxBroadcastCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/42": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 42, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxDataCount", - "attribute_name": "RxDataCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/43": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 43, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxDataPollCount", - "attribute_name": "RxDataPollCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/44": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 44, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxBeaconCount", - "attribute_name": "RxBeaconCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/45": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 45, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxBeaconRequestCount", - "attribute_name": "RxBeaconRequestCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/46": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 46, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxOtherCount", - "attribute_name": "RxOtherCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/47": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 47, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxAddressFilteredCount", - "attribute_name": "RxAddressFilteredCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/48": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 48, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxDestAddrFilteredCount", - "attribute_name": "RxDestAddrFilteredCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/49": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 49, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxDuplicatedCount", - "attribute_name": "RxDuplicatedCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/50": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 50, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxErrNoFrameCount", - "attribute_name": "RxErrNoFrameCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/51": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 51, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxErrUnknownNeighborCount", - "attribute_name": "RxErrUnknownNeighborCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/52": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 52, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxErrInvalidSrcAddrCount", - "attribute_name": "RxErrInvalidSrcAddrCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/53": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 53, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxErrSecCount", - "attribute_name": "RxErrSecCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/54": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 54, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxErrFcsCount", - "attribute_name": "RxErrFcsCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/55": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 55, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.RxErrOtherCount", - "attribute_name": "RxErrOtherCount", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/56": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 56, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.ActiveTimestamp", - "attribute_name": "ActiveTimestamp", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/57": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 57, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.PendingTimestamp", - "attribute_name": "PendingTimestamp", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/58": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 58, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.Delay", - "attribute_name": "Delay", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/59": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 59, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.SecurityPolicy", - "attribute_name": "SecurityPolicy", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/60": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 60, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.ChannelPage0Mask", - "attribute_name": "ChannelPage0Mask", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/61": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 61, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.OperationalDatasetComponents", - "attribute_name": "OperationalDatasetComponents", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/62": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 62, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.ActiveNetworkFaultsList", - "attribute_name": "ActiveNetworkFaultsList", - "value": { - "TLVValue": null, - "Reason": "InteractionModelError: Failure (0x1)" - } - }, - "0/53/65532": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 15 - }, - "0/53/65533": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/53/65528": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/53/65529": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0] - }, - "0/53/65531": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 53, - "cluster_type": "chip.clusters.Objects.ThreadNetworkDiagnostics", - "cluster_name": "ThreadNetworkDiagnostics", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.ThreadNetworkDiagnostics.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, - 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, - 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, - 56, 57, 58, 59, 60, 61, 62, 65528, 65529, 65531, 65532, 65533 - ] - }, - "0/54/0": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.Bssid", - "attribute_name": "Bssid", - "value": "b'r\\xa7A\\x91\\xf1!'" - }, - "0/54/1": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.SecurityType", - "attribute_name": "SecurityType", - "value": 4 - }, - "0/54/2": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.WiFiVersion", - "attribute_name": "WiFiVersion", - "value": 3 - }, - "0/54/3": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.ChannelNumber", - "attribute_name": "ChannelNumber", - "value": 6 - }, - "0/54/4": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.Rssi", - "attribute_name": "Rssi", - "value": -61 - }, - "0/54/5": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.BeaconLostCount", - "attribute_name": "BeaconLostCount", - "value": null - }, - "0/54/6": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 6, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.BeaconRxCount", - "attribute_name": "BeaconRxCount", - "value": null - }, - "0/54/7": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 7, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.PacketMulticastRxCount", - "attribute_name": "PacketMulticastRxCount", - "value": null - }, - "0/54/8": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 8, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.PacketMulticastTxCount", - "attribute_name": "PacketMulticastTxCount", - "value": null - }, - "0/54/9": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 9, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.PacketUnicastRxCount", - "attribute_name": "PacketUnicastRxCount", - "value": null - }, - "0/54/10": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 10, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.PacketUnicastTxCount", - "attribute_name": "PacketUnicastTxCount", - "value": null - }, - "0/54/11": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 11, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.CurrentMaxRate", - "attribute_name": "CurrentMaxRate", - "value": null - }, - "0/54/12": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 12, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.OverrunCount", - "attribute_name": "OverrunCount", - "value": null - }, - "0/54/65532": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 3 - }, - "0/54/65533": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/54/65528": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/54/65529": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0] - }, - "0/54/65531": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 54, - "cluster_type": "chip.clusters.Objects.WiFiNetworkDiagnostics", - "cluster_name": "WiFiNetworkDiagnostics", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.WiFiNetworkDiagnostics.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 65528, 65529, 65531, 65532, - 65533 - ] - }, - "0/55/0": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.PHYRate", - "attribute_name": "PHYRate", - "value": null - }, - "0/55/1": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.FullDuplex", - "attribute_name": "FullDuplex", - "value": null - }, - "0/55/2": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.PacketRxCount", - "attribute_name": "PacketRxCount", - "value": 0 - }, - "0/55/3": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 3, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.PacketTxCount", - "attribute_name": "PacketTxCount", - "value": 0 - }, - "0/55/4": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 4, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.TxErrCount", - "attribute_name": "TxErrCount", - "value": 0 - }, - "0/55/5": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 5, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.CollisionCount", - "attribute_name": "CollisionCount", - "value": 0 - }, - "0/55/6": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 6, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.OverrunCount", - "attribute_name": "OverrunCount", - "value": 0 - }, - "0/55/7": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 7, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.CarrierDetect", - "attribute_name": "CarrierDetect", - "value": null - }, - "0/55/8": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 8, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.TimeSinceReset", - "attribute_name": "TimeSinceReset", - "value": 0 - }, - "0/55/65532": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 3 - }, - "0/55/65533": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/55/65528": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/55/65529": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0] - }, - "0/55/65531": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 55, - "cluster_type": "chip.clusters.Objects.EthernetNetworkDiagnostics", - "cluster_name": "EthernetNetworkDiagnostics", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.EthernetNetworkDiagnostics.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533] - }, - "0/59/65532": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 59, - "cluster_type": "chip.clusters.Objects.Switch", - "cluster_name": "Switch", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.Switch.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/59/65533": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 59, - "cluster_type": "chip.clusters.Objects.Switch", - "cluster_name": "Switch", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.Switch.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/59/65528": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 59, - "cluster_type": "chip.clusters.Objects.Switch", - "cluster_name": "Switch", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.Switch.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/59/65529": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 59, - "cluster_type": "chip.clusters.Objects.Switch", - "cluster_name": "Switch", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.Switch.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [] - }, - "0/59/65531": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 59, - "cluster_type": "chip.clusters.Objects.Switch", - "cluster_name": "Switch", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.Switch.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [65528, 65529, 65531, 65532, 65533] - }, - "0/60/0": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.WindowStatus", - "attribute_name": "WindowStatus", - "value": 0 - }, - "0/60/1": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 1, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.AdminFabricIndex", - "attribute_name": "AdminFabricIndex", - "value": null - }, - "0/60/2": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 2, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.AdminVendorId", - "attribute_name": "AdminVendorId", - "value": null - }, - "0/60/65532": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 65532, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.FeatureMap", - "attribute_name": "FeatureMap", - "value": 0 - }, - "0/60/65533": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 65533, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.ClusterRevision", - "attribute_name": "ClusterRevision", - "value": 1 - }, - "0/60/65528": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 65528, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.GeneratedCommandList", - "attribute_name": "GeneratedCommandList", - "value": [] - }, - "0/60/65529": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 65529, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.AcceptedCommandList", - "attribute_name": "AcceptedCommandList", - "value": [0, 1, 2] - }, - "0/60/65531": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 60, - "cluster_type": "chip.clusters.Objects.AdministratorCommissioning", - "cluster_name": "AdministratorCommissioning", - "attribute_id": 65531, - "attribute_type": "chip.clusters.Objects.AdministratorCommissioning.Attributes.AttributeList", - "attribute_name": "AttributeList", - "value": [0, 1, 2, 65528, 65529, 65531, 65532, 65533] - }, - "0/62/0": { - "node_id": 1, - "endpoint": 0, - "cluster_id": 62, - "cluster_type": "chip.clusters.Objects.OperationalCredentials", - "cluster_name": "OperationalCredentials", - "attribute_id": 0, - "attribute_type": "chip.clusters.Objects.OperationalCredentials.Attributes.NOCs", - "attribute_name": "NOCs", - "value": [ - { - "noc": "b'\\x150\\x01\\x01\\x01$\\x02\\x017\\x03$\\x13\\x02\\x18&\\x04\\x80\"\\x81\\'&\\x05\\x80%M:7\\x06$\\x15\\x01$\\x11\\x01\\x18$\\x07\\x01$\\x08\\x010\\tA\\x04Z\\xec\\xd9\\x11h\\'J[%&\\xe1\\xd8\\xa0\\xb15\\x01\\x9c\\x83\\xf8\\x1a\\x9bg\\xbb\\x81e\\x18\\x8b\\xc8\\x9dr\\xed\\xe7\\xc3\\\\\\xd21\\x99\\xc1\\xdb\\xfe\\xd0}\\xf5)\\xaeMq\\xd5\\xeeRg\\x16\\xd5%\\x9c\\xfcD\\t\\xcbQ\\xc8\\x83*\\x997\\n5\\x01(\\x01\\x18$\\x02\\x016\\x03\\x04\\x02\\x04\\x01\\x180\\x04\\x14\\xca\\xe2\\x07\\xdb\\x89 dict[str, Any]: async def test_matter_attribute_redact(device_diagnostics: dict[str, Any]) -> None: """Test the matter attribute redact helper.""" - assert device_diagnostics["attributes"]["0/40/6"]["value"] == "XX" + assert device_diagnostics["attributes"]["0/40/6"] == "XX" redacted_device_diagnostics = redact_matter_attributes(device_diagnostics) # Check that the correct attribute value is redacted. - assert ( - redacted_device_diagnostics["attributes"]["0/40/6"]["value"] == "**REDACTED**" - ) + assert redacted_device_diagnostics["attributes"]["0/40/6"] == "**REDACTED**" # Check that the other attribute values are not redacted. - redacted_device_diagnostics["attributes"]["0/40/6"]["value"] = "XX" + redacted_device_diagnostics["attributes"]["0/40/6"] = "XX" assert redacted_device_diagnostics == device_diagnostics @@ -107,5 +105,4 @@ async def test_device_diagnostics( diagnostics = await get_diagnostics_for_device( hass, hass_client, config_entry, device ) - assert diagnostics == device_diagnostics_redacted diff --git a/tests/components/matter/test_init.py b/tests/components/matter/test_init.py index d8b2038d13..fac5653f86 100644 --- a/tests/components/matter/test_init.py +++ b/tests/components/matter/test_init.py @@ -7,9 +7,10 @@ from unittest.mock import AsyncMock, MagicMock, call, patch from aiohttp import ClientWebSocketResponse from matter_server.client.exceptions import CannotConnect, InvalidServerVersion +from matter_server.client.models.node import MatterNode +from matter_server.common.errors import MatterError from matter_server.common.helpers.util import dataclass_from_dict -from matter_server.common.models.error import MatterError -from matter_server.common.models.node import MatterNode +from matter_server.common.models import MatterNodeData import pytest from homeassistant.components.hassio import HassioAPIError @@ -51,9 +52,11 @@ async def test_entry_setup_unload( ) -> None: """Test the integration set up and unload.""" node_data = load_and_parse_node_fixture("onoff-light") - node = dataclass_from_dict( - MatterNode, - node_data, + node = MatterNode( + dataclass_from_dict( + MatterNodeData, + node_data, + ) ) matter_client.get_nodes.return_value = [node] matter_client.get_node.return_value = node diff --git a/tests/components/matter/test_light.py b/tests/components/matter/test_light.py index ad8dfebeec..a5a858b0b1 100644 --- a/tests/components/matter/test_light.py +++ b/tests/components/matter/test_light.py @@ -66,7 +66,7 @@ async def test_on_off_light( assert matter_client.send_device_command.call_count == 1 assert matter_client.send_device_command.call_args == call( node_id=light_node.node_id, - endpoint=1, + endpoint_id=1, command=clusters.OnOff.Commands.Off(), ) matter_client.send_device_command.reset_mock() @@ -84,7 +84,7 @@ async def test_on_off_light( assert matter_client.send_device_command.call_count == 1 assert matter_client.send_device_command.call_args == call( node_id=light_node.node_id, - endpoint=1, + endpoint_id=1, command=clusters.OnOff.Commands.On(), ) matter_client.send_device_command.reset_mock() @@ -135,7 +135,7 @@ async def test_dimmable_light( assert matter_client.send_device_command.call_count == 1 assert matter_client.send_device_command.call_args == call( node_id=light_node.node_id, - endpoint=1, + endpoint_id=1, command=clusters.LevelControl.Commands.MoveToLevelWithOnOff( level=128, transitionTime=0, @@ -192,7 +192,7 @@ async def test_color_temperature_light( [ call( node_id=light_node.node_id, - endpoint=1, + endpoint_id=1, command=clusters.ColorControl.Commands.MoveToColorTemperature( colorTemperature=3003, transitionTime=0, @@ -200,7 +200,7 @@ async def test_color_temperature_light( ), call( node_id=light_node.node_id, - endpoint=1, + endpoint_id=1, command=clusters.OnOff.Commands.On(), ), ] @@ -268,14 +268,14 @@ async def test_extended_color_light( [ call( node_id=light_node.node_id, - endpoint=1, + endpoint_id=1, command=clusters.ColorControl.Commands.MoveToColor( colorX=0.5 * 65536, colorY=0.5 * 65536, transitionTime=0 ), ), call( node_id=light_node.node_id, - endpoint=1, + endpoint_id=1, command=clusters.OnOff.Commands.On(), ), ] @@ -298,14 +298,14 @@ async def test_extended_color_light( [ call( node_id=light_node.node_id, - endpoint=1, + endpoint_id=1, command=clusters.ColorControl.Commands.MoveToHueAndSaturation( hue=0, saturation=0, transitionTime=0 ), ), call( node_id=light_node.node_id, - endpoint=1, + endpoint_id=1, command=clusters.OnOff.Commands.On(), ), ] diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index ccdf09b8dc..deaaf62c97 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -1,7 +1,7 @@ """Test Matter sensors.""" from unittest.mock import MagicMock -from matter_server.common.models.node import MatterNode +from matter_server.client.models.node import MatterNode import pytest from homeassistant.core import HomeAssistant diff --git a/tests/components/matter/test_switch.py b/tests/components/matter/test_switch.py index 9fe225b1b1..524ea54815 100644 --- a/tests/components/matter/test_switch.py +++ b/tests/components/matter/test_switch.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters -from matter_server.common.models.node import MatterNode +from matter_server.client.models.node import MatterNode import pytest from homeassistant.core import HomeAssistant @@ -46,7 +46,7 @@ async def test_turn_on( assert matter_client.send_device_command.call_count == 1 assert matter_client.send_device_command.call_args == call( node_id=switch_node.node_id, - endpoint=1, + endpoint_id=1, command=clusters.OnOff.Commands.On(), ) @@ -80,6 +80,6 @@ async def test_turn_off( assert matter_client.send_device_command.call_count == 1 assert matter_client.send_device_command.call_args == call( node_id=switch_node.node_id, - endpoint=1, + endpoint_id=1, command=clusters.OnOff.Commands.Off(), ) From 205c5ccbf3e5b7935939ec7db30df854adb7cacb Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Wed, 22 Feb 2023 14:27:36 -0500 Subject: [PATCH 0317/2810] Bump ZHA quirks to 0.0.93 (#88621) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index f0049a79fa..090e171835 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -23,7 +23,7 @@ "bellows==0.34.7", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.92", + "zha-quirks==0.0.93", "zigpy-deconz==0.19.2", "zigpy==0.53.0", "zigpy-xbee==0.16.2", diff --git a/requirements_all.txt b/requirements_all.txt index 146c699d4a..cad22977a9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2706,7 +2706,7 @@ zeroconf==0.47.3 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.92 +zha-quirks==0.0.93 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7cbaa57720..29efeeffd6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1922,7 +1922,7 @@ zeroconf==0.47.3 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.92 +zha-quirks==0.0.93 # homeassistant.components.zha zigpy-deconz==0.19.2 From 64e39c9c81eab4414a55c2b12c4b8a752dbf898c Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 22 Feb 2023 20:28:58 +0100 Subject: [PATCH 0318/2810] Bump brother to 2.2.0 (#88618) --- homeassistant/components/brother/__init__.py | 4 ++-- homeassistant/components/brother/config_flow.py | 6 +++--- homeassistant/components/brother/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/brother/test_config_flow.py | 4 ++-- tests/components/brother/test_diagnostics.py | 2 +- tests/components/brother/test_sensor.py | 2 +- 8 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/brother/__init__.py b/homeassistant/components/brother/__init__.py index e0d0a0ca44..5f05caf0fc 100644 --- a/homeassistant/components/brother/__init__.py +++ b/homeassistant/components/brother/__init__.py @@ -5,7 +5,7 @@ from datetime import timedelta import logging import async_timeout -from brother import Brother, BrotherSensors, SnmpError, UnsupportedModel +from brother import Brother, BrotherSensors, SnmpError, UnsupportedModelError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_TYPE, Platform @@ -81,6 +81,6 @@ class BrotherDataUpdateCoordinator(DataUpdateCoordinator[BrotherSensors]): try: async with async_timeout.timeout(20): data = await self.brother.async_update() - except (ConnectionError, SnmpError, UnsupportedModel) as error: + except (ConnectionError, SnmpError, UnsupportedModelError) as error: raise UpdateFailed(error) from error return data diff --git a/homeassistant/components/brother/config_flow.py b/homeassistant/components/brother/config_flow.py index 48c73d0c4d..55d47bb0c2 100644 --- a/homeassistant/components/brother/config_flow.py +++ b/homeassistant/components/brother/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from brother import Brother, SnmpError, UnsupportedModel +from brother import Brother, SnmpError, UnsupportedModelError import voluptuous as vol from homeassistant import config_entries, exceptions @@ -62,7 +62,7 @@ class BrotherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except SnmpError: errors["base"] = "snmp_error" - except UnsupportedModel: + except UnsupportedModelError: return self.async_abort(reason="unsupported_model") return self.async_show_form( @@ -86,7 +86,7 @@ class BrotherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.host, snmp_engine=snmp_engine, model=model ) await self.brother.async_update() - except UnsupportedModel: + except UnsupportedModelError: return self.async_abort(reason="unsupported_model") except (ConnectionError, SnmpError): return self.async_abort(reason="cannot_connect") diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json index db8a432e1f..bd5d877b4f 100644 --- a/homeassistant/components/brother/manifest.json +++ b/homeassistant/components/brother/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["brother", "pyasn1", "pysmi", "pysnmp"], "quality_scale": "platinum", - "requirements": ["brother==2.1.1"], + "requirements": ["brother==2.2.0"], "zeroconf": [ { "type": "_printer._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index cad22977a9..7d0e175bde 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -480,7 +480,7 @@ boto3==1.20.24 broadlink==0.18.3 # homeassistant.components.brother -brother==2.1.1 +brother==2.2.0 # homeassistant.components.brottsplatskartan brottsplatskartan==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 29efeeffd6..425c87c7a8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -393,7 +393,7 @@ boschshcpy==0.2.35 broadlink==0.18.3 # homeassistant.components.brother -brother==2.1.1 +brother==2.2.0 # homeassistant.components.brunt brunt==1.2.0 diff --git a/tests/components/brother/test_config_flow.py b/tests/components/brother/test_config_flow.py index 832e874a46..9dc871bb4c 100644 --- a/tests/components/brother/test_config_flow.py +++ b/tests/components/brother/test_config_flow.py @@ -2,7 +2,7 @@ import json from unittest.mock import patch -from brother import SnmpError, UnsupportedModel +from brother import SnmpError, UnsupportedModelError from homeassistant import data_entry_flow from homeassistant.components import zeroconf @@ -116,7 +116,7 @@ async def test_snmp_error(hass: HomeAssistant) -> None: async def test_unsupported_model_error(hass: HomeAssistant) -> None: """Test unsupported printer model error.""" with patch("brother.Brother.initialize"), patch( - "brother.Brother._get_data", side_effect=UnsupportedModel("error") + "brother.Brother._get_data", side_effect=UnsupportedModelError("error") ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=CONFIG diff --git a/tests/components/brother/test_diagnostics.py b/tests/components/brother/test_diagnostics.py index 2a5fa93617..ce09fe13d1 100644 --- a/tests/components/brother/test_diagnostics.py +++ b/tests/components/brother/test_diagnostics.py @@ -22,7 +22,7 @@ async def test_entry_diagnostics( diagnostics_data = json.loads(load_fixture("diagnostics_data.json", "brother")) test_time = datetime(2019, 11, 11, 9, 10, 32, tzinfo=UTC) with patch("brother.Brother.initialize"), patch( - "brother.datetime", utcnow=Mock(return_value=test_time) + "brother.datetime", now=Mock(return_value=test_time) ), patch( "brother.Brother._get_data", return_value=json.loads(load_fixture("printer_data.json", "brother")), diff --git a/tests/components/brother/test_sensor.py b/tests/components/brother/test_sensor.py index f95322b01a..6769d21940 100644 --- a/tests/components/brother/test_sensor.py +++ b/tests/components/brother/test_sensor.py @@ -48,7 +48,7 @@ async def test_sensors(hass: HomeAssistant) -> None: ) test_time = datetime(2019, 11, 11, 9, 10, 32, tzinfo=UTC) with patch("brother.Brother.initialize"), patch( - "brother.datetime", utcnow=Mock(return_value=test_time) + "brother.datetime", now=Mock(return_value=test_time) ), patch( "brother.Brother._get_data", return_value=json.loads(load_fixture("printer_data.json", "brother")), From 3ebb2fc3a9bf78dbe6ffb5542685e7e5ee10b52f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Feb 2023 14:33:27 -0500 Subject: [PATCH 0319/2810] Fix handling of HomeKit sources with unsafe characters (#88280) fixes #87049 --- .../components/homekit/type_media_players.py | 4 +- .../components/homekit/type_remotes.py | 29 ++++---- .../homekit/test_type_media_players.py | 67 +++++++++++++++++++ 3 files changed, 85 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py index 55519fdf6f..eae7ed2742 100644 --- a/homeassistant/components/homekit/type_media_players.py +++ b/homeassistant/components/homekit/type_media_players.py @@ -305,8 +305,8 @@ class TelevisionMediaPlayer(RemoteInputSelectAccessory): def set_input_source(self, value): """Send input set value if call came from HomeKit.""" _LOGGER.debug("%s: Set current input to %s", self.entity_id, value) - source = self.sources[value] - params = {ATTR_ENTITY_ID: self.entity_id, ATTR_INPUT_SOURCE: source} + source_name = self._mapped_sources[self.sources[value]] + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_INPUT_SOURCE: source_name} self.async_call_service(DOMAIN, SERVICE_SELECT_SOURCE, params) def set_remote_key(self, value): diff --git a/homeassistant/components/homekit/type_remotes.py b/homeassistant/components/homekit/type_remotes.py index 1dfcb0f91a..69441b5ebe 100644 --- a/homeassistant/components/homekit/type_remotes.py +++ b/homeassistant/components/homekit/type_remotes.py @@ -91,6 +91,8 @@ class RemoteInputSelectAccessory(HomeAccessory, ABC): state = self.hass.states.get(self.entity_id) features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + self._mapped_sources_list = [] + self._mapped_sources = {} self.source_key = source_key self.source_list_key = source_list_key self.sources = [] @@ -103,9 +105,7 @@ class RemoteInputSelectAccessory(HomeAccessory, ABC): self.entity_id, MAXIMUM_SOURCES, ) - self.sources = [ - cleanup_name_for_homekit(source) for source in sources[:MAXIMUM_SOURCES] - ] + self.sources = sources[:MAXIMUM_SOURCES] if self.sources: self.support_select_source = True @@ -143,6 +143,15 @@ class RemoteInputSelectAccessory(HomeAccessory, ABC): serv_input.configure_char(CHAR_CURRENT_VISIBILITY_STATE, value=False) _LOGGER.debug("%s: Added source %s", self.entity_id, source) + def _get_mapped_sources(self, state: State) -> dict[str, str]: + """Return a dict of sources mapped to their homekit safe name.""" + source_list = state.attributes.get(self.source_list_key, []) + if self._mapped_sources_list != source_list: + self._mapped_sources = { + cleanup_name_for_homekit(source): source for source in source_list + } + return self._mapped_sources + def _get_ordered_source_list_from_state(self, state: State) -> list[str]: """Return ordered source list while preserving order with duplicates removed. @@ -150,13 +159,7 @@ class RemoteInputSelectAccessory(HomeAccessory, ABC): which will make the source list conflict as HomeKit requires unique source names. """ - seen = set() - sources: list[str] = [] - for source in state.attributes.get(self.source_list_key, []): - if source not in seen: - sources.append(source) - seen.add(source) - return sources + return list(self._get_mapped_sources(state)) @abstractmethod def set_on_off(self, value): @@ -185,8 +188,8 @@ class RemoteInputSelectAccessory(HomeAccessory, ABC): return possible_sources = self._get_ordered_source_list_from_state(new_state) - if source in possible_sources: - index = possible_sources.index(source) + if source_name in possible_sources: + index = possible_sources.index(source_name) if index >= MAXIMUM_SOURCES: _LOGGER.debug( "%s: Source %s and above are not supported", @@ -235,7 +238,7 @@ class ActivityRemote(RemoteInputSelectAccessory): def set_input_source(self, value): """Send input set value if call came from HomeKit.""" _LOGGER.debug("%s: Set current input to %s", self.entity_id, value) - source = self.sources[value] + source = self._mapped_sources[self.sources[value]] params = {ATTR_ENTITY_ID: self.entity_id, ATTR_ACTIVITY: source} self.async_call_service(REMOTE_DOMAIN, SERVICE_TURN_ON, params) diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py index b9a2f82980..f68adc2407 100644 --- a/tests/components/homekit/test_type_media_players.py +++ b/tests/components/homekit/test_type_media_players.py @@ -562,3 +562,70 @@ async def test_media_player_television_duplicate_sources( ) await hass.async_block_till_done() assert acc.char_input_source.value == 0 + + +async def test_media_player_television_unsafe_chars( + hass: HomeAssistant, hk_driver, events, caplog: pytest.LogCaptureFixture +) -> None: + """Test if television accessory with unsafe characters.""" + entity_id = "media_player.television" + sources = ["MUSIC", "HDMI 3/ARC", "SCREEN MIRRORING", "HDMI 2/MHL", "HDMI", "MUSIC"] + hass.states.async_set( + entity_id, + None, + { + ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.TV, + ATTR_SUPPORTED_FEATURES: 3469, + ATTR_MEDIA_VOLUME_MUTED: False, + ATTR_INPUT_SOURCE: "HDMI 2/MHL", + ATTR_INPUT_SOURCE_LIST: sources, + }, + ) + await hass.async_block_till_done() + acc = TelevisionMediaPlayer(hass, hk_driver, "MediaPlayer", entity_id, 2, None) + await acc.run() + await hass.async_block_till_done() + + assert acc.aid == 2 + assert acc.category == 31 # Television + + assert acc.char_active.value == 0 + assert acc.char_remote_key.value == 0 + assert acc.char_input_source.value == 3 + assert acc.char_mute.value is False + + hass.states.async_set( + entity_id, + None, + { + ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.TV, + ATTR_SUPPORTED_FEATURES: 3469, + ATTR_MEDIA_VOLUME_MUTED: False, + ATTR_INPUT_SOURCE: "HDMI 3/ARC", + ATTR_INPUT_SOURCE_LIST: sources, + }, + ) + await hass.async_block_till_done() + assert acc.char_input_source.value == 1 + + call_select_source = async_mock_service(hass, DOMAIN, "select_source") + + acc.char_input_source.client_update_value(3) + await hass.async_block_till_done() + assert call_select_source + assert call_select_source[0].data[ATTR_ENTITY_ID] == entity_id + assert call_select_source[0].data[ATTR_INPUT_SOURCE] == "HDMI 2/MHL" + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] is None + + assert acc.char_input_source.value == 3 + + acc.char_input_source.client_update_value(4) + await hass.async_block_till_done() + assert call_select_source + assert call_select_source[1].data[ATTR_ENTITY_ID] == entity_id + assert call_select_source[1].data[ATTR_INPUT_SOURCE] == "HDMI" + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] is None + + assert acc.char_input_source.value == 4 From cebc6dd096b192d0d75a2b6263903a200ecfc9f6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 22 Feb 2023 20:44:37 +0100 Subject: [PATCH 0320/2810] 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 52cb0b5fa0..f1670bf3d3 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 d913ad0daa..7102fe5d92 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 c6ff79aa0e4d58503cbbecae8635da7382d883cf Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 22 Feb 2023 14:58:11 -0500 Subject: [PATCH 0321/2810] 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 19eaa55f00..ebe2ab0025 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 1d54084969..00aae5b8a0 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 918d150465..a3cdefe2b7 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 10affab078..7818d736e0 100644 --- a/tests/components/otbr/test_init.py +++ b/tests/components/otbr/test_init.py @@ -1,9 +1,11 @@ """Test the Open Thread Border Router integration.""" - +import asyncio from http import HTTPStatus from unittest.mock import patch +import aiohttp import pytest +import python_otbr_api from homeassistant.components import otbr from homeassistant.core import HomeAssistant @@ -35,9 +37,15 @@ async def test_import_dataset(hass: HomeAssistant) -> None: mock_add.assert_called_once_with(config_entry.title, DATASET.hex()) -async def test_config_entry_not_ready( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@pytest.mark.parametrize( + "error", + [ + asyncio.TimeoutError, + python_otbr_api.OTBRError, + aiohttp.ClientError, + ], +) +async def test_config_entry_not_ready(hass: HomeAssistant, error) -> None: """Test raising ConfigEntryNotReady .""" config_entry = MockConfigEntry( @@ -47,8 +55,8 @@ async def test_config_entry_not_ready( title="My OTBR", ) config_entry.add_to_hass(hass) - aioclient_mock.get(f"{BASE_URL}/node/dataset/active", status=HTTPStatus.CREATED) - assert not await hass.config_entries.async_setup(config_entry.entry_id) + with patch("python_otbr_api.OTBR.get_active_dataset_tlvs", side_effect=error): + assert not await hass.config_entries.async_setup(config_entry.entry_id) async def test_remove_entry( From 2a819f23c1fe2a374845e992082e3c130fec8b78 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 22 Feb 2023 15:12:55 -0500 Subject: [PATCH 0322/2810] 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 8a773213a5..4c10a2328a 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 128e3b145f..2f1b22e0ea 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 b96acb29b1..adff43d377 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -287,3 +287,39 @@ async def test_gateway_initialize_failure_transient( # Initialization immediately stops and is retried after TransientConnectionError assert mock_new.call_count == 2 + + +@patch( + "homeassistant.components.zha.core.gateway.ZHAGateway.async_load_devices", + MagicMock(), +) +@patch( + "homeassistant.components.zha.core.gateway.ZHAGateway.async_load_groups", + MagicMock(), +) +@pytest.mark.parametrize( + ("device_path", "thread_state", "config_override"), + [ + ("/dev/ttyUSB0", True, {}), + ("socket://192.168.1.123:9999", False, {}), + ("socket://192.168.1.123:9999", True, {"use_thread": True}), + ], +) +async def test_gateway_initialize_bellows_thread( + device_path, thread_state, config_override, hass, coordinator +): + """Test ZHA disabling the UART thread when connecting to a TCP coordinator.""" + zha_gateway = get_zha_gateway(hass) + assert zha_gateway is not None + + zha_gateway.config_entry.data = dict(zha_gateway.config_entry.data) + zha_gateway.config_entry.data["device"]["path"] = device_path + zha_gateway._config.setdefault("zigpy_config", {}).update(config_override) + + with patch( + "bellows.zigbee.application.ControllerApplication.new", + new=AsyncMock(), + ) as mock_new: + await zha_gateway.async_initialize() + + assert mock_new.mock_calls[0].args[0]["use_thread"] is thread_state From aa3657e0712081c835f81cf0c711854a54b90beb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 22 Feb 2023 21:29:49 +0100 Subject: [PATCH 0323/2810] Bump version to 2023.4.0dev0 (#88630) --- .github/workflows/ci.yaml | 2 +- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0c6e0c173a..57c7425932 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -31,7 +31,7 @@ env: CACHE_VERSION: 5 PIP_CACHE_VERSION: 4 MYPY_CACHE_VERSION: 4 - HA_SHORT_VERSION: 2023.3 + HA_SHORT_VERSION: 2023.4 DEFAULT_PYTHON: "3.10" ALL_PYTHON_VERSIONS: "['3.10', '3.11']" # 10.3 is the oldest supported version diff --git a/homeassistant/const.py b/homeassistant/const.py index 52cb0b5fa0..1559560f11 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 -MINOR_VERSION: Final = 3 +MINOR_VERSION: Final = 4 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" diff --git a/pyproject.toml b/pyproject.toml index d913ad0daa..a3d6d2f244 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.3.0.dev0" +version = "2023.4.0.dev0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 473db48943cc6668cc11f77a8626a6f60bd369a2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 22 Feb 2023 21:31:02 +0100 Subject: [PATCH 0324/2810] 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 7abf716cec..24fb89f214 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 89b5aa3baa..16fadd9b06 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 7d0e175bde..0de24f6b3c 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 425c87c7a8..b1d1176f16 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 a3cdefe2b7..e27cfb219c 100644 --- a/tests/components/otbr/test_config_flow.py +++ b/tests/components/otbr/test_config_flow.py @@ -98,7 +98,7 @@ async def test_user_flow_router_not_setup( assert aioclient_mock.mock_calls[-1][0] == "POST" assert aioclient_mock.mock_calls[-1][1].path == "/node/state" - assert aioclient_mock.mock_calls[-1][2] == "enabled" + assert aioclient_mock.mock_calls[-1][2] == "enable" expected_data = { "url": "http://custom_url:1234", @@ -230,7 +230,7 @@ async def test_hassio_discovery_flow_router_not_setup( assert aioclient_mock.mock_calls[-1][0] == "POST" assert aioclient_mock.mock_calls[-1][1].path == "/node/state" - assert aioclient_mock.mock_calls[-1][2] == "enabled" + assert aioclient_mock.mock_calls[-1][2] == "enable" expected_data = { "url": f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port']}", @@ -279,7 +279,7 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred( assert aioclient_mock.mock_calls[-1][0] == "POST" assert aioclient_mock.mock_calls[-1][1].path == "/node/state" - assert aioclient_mock.mock_calls[-1][2] == "enabled" + assert aioclient_mock.mock_calls[-1][2] == "enable" expected_data = { "url": f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port']}", From 87dc692a2074849cd314bf5ee7199b7b14efccf3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 22 Feb 2023 22:01:32 +0100 Subject: [PATCH 0325/2810] Use json_loads_object in alexa (#88610) --- homeassistant/components/alexa/state_report.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index 783397ca04..a189c364c0 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -5,6 +5,7 @@ import asyncio from http import HTTPStatus import json import logging +from typing import cast import aiohttp import async_timeout @@ -15,6 +16,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.significant_change import create_checker import homeassistant.util.dt as dt_util +from homeassistant.util.json import JsonObjectType, json_loads_object from .const import API_CHANGE, DATE_FORMAT, DOMAIN, Cause from .entities import ENTITY_ADAPTERS, AlexaEntity, generate_alexa_id @@ -162,9 +164,10 @@ async def async_send_changereport_message( if response.status == HTTPStatus.ACCEPTED: return - response_json = json.loads(response_text) + response_json = json_loads_object(response_text) + response_payload = cast(JsonObjectType, response_json["payload"]) - if response_json["payload"]["code"] == "INVALID_ACCESS_TOKEN_EXCEPTION": + if response_payload["code"] == "INVALID_ACCESS_TOKEN_EXCEPTION": if invalidate_access_token: # Invalidate the access token and try again config.async_invalidate_access_token() @@ -180,8 +183,8 @@ async def async_send_changereport_message( _LOGGER.error( "Error when sending ChangeReport for %s to Alexa: %s: %s", alexa_entity.entity_id, - response_json["payload"]["code"], - response_json["payload"]["description"], + response_payload["code"], + response_payload["description"], ) @@ -299,11 +302,12 @@ async def async_send_doorbell_event_message(hass, config, alexa_entity): if response.status == HTTPStatus.ACCEPTED: return - response_json = json.loads(response_text) + response_json = json_loads_object(response_text) + response_payload = cast(JsonObjectType, response_json["payload"]) _LOGGER.error( "Error when sending DoorbellPress event for %s to Alexa: %s: %s", alexa_entity.entity_id, - response_json["payload"]["code"], - response_json["payload"]["description"], + response_payload["code"], + response_payload["description"], ) From 23b52025f987e3973e107d35dfdc60273cf57cae Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 23 Feb 2023 08:13:03 +0100 Subject: [PATCH 0326/2810] 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 2d3fc52eb3..6633f5c02f 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 afba72fbf1..62b2b5a038 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 71ca16ca68..51a9697717 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 0de24f6b3c..80c7bedb6d 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 b1d1176f16..4583dbae11 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1585,7 +1585,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.0 +reolink-aio==0.5.1 # homeassistant.components.python_script restrictedpython==6.0 From 27ebee1501bbb591265c54068a3f07d6fa55ab47 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Feb 2023 02:18:55 -0500 Subject: [PATCH 0327/2810] 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 64d24504ef..718a6b571a 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 f3d086afed..4a93758573 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 1a817c8a52..b8d9ad3d16 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 80c7bedb6d..5f857958a7 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 4583dbae11..be5f3bb7f3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1895,13 +1895,13 @@ xmltodict==0.13.0 yalesmartalarmclient==0.3.9 # homeassistant.components.yalexs_ble -yalexs-ble==2.0.2 +yalexs-ble==2.0.3 # homeassistant.components.august yalexs==1.2.7 # homeassistant.components.august -yalexs_ble==2.0.2 +yalexs_ble==2.0.3 # homeassistant.components.yeelight yeelight==0.7.10 From 6474297d1f66e9c9bc8fcb1df55b484195b71816 Mon Sep 17 00:00:00 2001 From: Artem Draft Date: Thu, 23 Feb 2023 11:17:46 +0300 Subject: [PATCH 0328/2810] Browse media support in universal media player (#85668) Allow forward and override browse media in universal media player --- .../components/universal/media_player.py | 56 ++++---- .../components/universal/test_media_player.py | 121 ++++++++++++++---- 2 files changed, 128 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py index c7cc0dd098..2cbe7aa6fb 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -45,6 +45,7 @@ from homeassistant.components.media_player import ( MediaPlayerEntityFeature, MediaPlayerState, ) +from homeassistant.components.media_player.browse_media import BrowseMedia from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, @@ -78,6 +79,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( TrackTemplate, @@ -93,6 +95,7 @@ ATTR_ACTIVE_CHILD = "active_child" CONF_ATTRS = "attributes" CONF_CHILDREN = "children" CONF_COMMANDS = "commands" +CONF_BROWSE_MEDIA_ENTITY = "browse_media_entity" STATES_ORDER = [ STATE_UNKNOWN, @@ -119,6 +122,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_ATTRS, default={}): vol.Or( cv.ensure_list(ATTRS_SCHEMA), ATTRS_SCHEMA ), + vol.Optional(CONF_BROWSE_MEDIA_ENTITY): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_STATE_TEMPLATE): cv.template, @@ -136,17 +140,7 @@ async def async_setup_platform( """Set up the universal media players.""" await async_setup_reload_service(hass, "universal", ["media_player"]) - player = UniversalMediaPlayer( - hass, - config.get(CONF_NAME), - config.get(CONF_CHILDREN), - config.get(CONF_COMMANDS), - config.get(CONF_ATTRS), - config.get(CONF_UNIQUE_ID), - config.get(CONF_DEVICE_CLASS), - config.get(CONF_STATE_TEMPLATE), - ) - + player = UniversalMediaPlayer(hass, config) async_add_entities([player]) @@ -158,30 +152,25 @@ class UniversalMediaPlayer(MediaPlayerEntity): def __init__( self, hass, - name, - children, - commands, - attributes, - unique_id=None, - device_class=None, - state_template=None, + config, ): """Initialize the Universal media device.""" self.hass = hass - self._name = name - self._children = children - self._cmds = commands + self._name = config.get(CONF_NAME) + self._children = config.get(CONF_CHILDREN) + self._cmds = config.get(CONF_COMMANDS) self._attrs = {} - for key, val in attributes.items(): + for key, val in config.get(CONF_ATTRS).items(): attr = list(map(str.strip, val.split("|", 1))) if len(attr) == 1: attr.append(None) self._attrs[key] = attr self._child_state = None self._state_template_result = None - self._state_template = state_template - self._device_class = device_class - self._attr_unique_id = unique_id + self._state_template = config.get(CONF_STATE_TEMPLATE) + self._device_class = config.get(CONF_DEVICE_CLASS) + self._attr_unique_id = config.get(CONF_UNIQUE_ID) + self._browse_media_entity = config.get(CONF_BROWSE_MEDIA_ENTITY) async def async_added_to_hass(self) -> None: """Subscribe to children and template state changes.""" @@ -497,6 +486,9 @@ class UniversalMediaPlayer(MediaPlayerEntity): if SERVICE_PLAY_MEDIA in self._cmds: flags |= MediaPlayerEntityFeature.PLAY_MEDIA + if self._browse_media_entity: + flags |= MediaPlayerEntityFeature.BROWSE_MEDIA + if SERVICE_CLEAR_PLAYLIST in self._cmds: flags |= MediaPlayerEntityFeature.CLEAR_PLAYLIST @@ -628,6 +620,20 @@ class UniversalMediaPlayer(MediaPlayerEntity): # Delegate to turn_on or turn_off by default await super().async_toggle() + async def async_browse_media( + self, + media_content_type: str | None = None, + media_content_id: str | None = None, + ) -> BrowseMedia: + """Return a BrowseMedia instance.""" + entity_id = self._browse_media_entity + if not entity_id and self._child_state: + entity_id = self._child_state.entity_id + component: EntityComponent[MediaPlayerEntity] = self.hass.data[DOMAIN] + if entity_id and (entity := component.get_entity(entity_id)): + return await entity.async_browse_media(media_content_type, media_content_id) + raise NotImplementedError() + async def async_update(self) -> None: """Update state in HA.""" self._child_state = None diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index 78fd072218..81204fe21c 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -9,7 +9,8 @@ from homeassistant import config as hass_config import homeassistant.components.input_number as input_number import homeassistant.components.input_select as input_select import homeassistant.components.media_player as media_player -from homeassistant.components.media_player import MediaPlayerEntityFeature +from homeassistant.components.media_player import MediaClass, MediaPlayerEntityFeature +from homeassistant.components.media_player.browse_media import BrowseMedia import homeassistant.components.switch as switch import homeassistant.components.universal.media_player as universal from homeassistant.const import ( @@ -36,6 +37,15 @@ CONFIG_CHILDREN_ONLY = { ], } +MOCK_BROWSE_MEDIA = BrowseMedia( + media_class=MediaClass.APP, + media_content_id="mock-id", + media_content_type="mock-type", + title="Mock Title", + can_play=False, + can_expand=True, +) + def validate_config(config): """Use the platform schema to validate configuration.""" @@ -376,7 +386,7 @@ async def test_master_state(hass: HomeAssistant) -> None: """Test master state property.""" config = validate_config(CONFIG_CHILDREN_ONLY) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) assert ump.master_state is None @@ -387,7 +397,7 @@ async def test_master_state_with_attrs( """Test master state property.""" config = validate_config(config_children_and_attr) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) assert ump.master_state == STATE_OFF hass.states.async_set(mock_states.mock_state_switch_id, STATE_ON) @@ -402,7 +412,7 @@ async def test_master_state_with_bad_attrs( config["attributes"]["state"] = "bad.entity_id" config = validate_config(config) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) assert ump.master_state == STATE_OFF @@ -411,7 +421,7 @@ async def test_active_child_state(hass: HomeAssistant, mock_states) -> None: """Test active child state property.""" config = validate_config(CONFIG_CHILDREN_ONLY) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -452,7 +462,7 @@ async def test_name(hass: HomeAssistant) -> None: """Test name property.""" config = validate_config(CONFIG_CHILDREN_ONLY) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) assert config["name"] == ump.name @@ -461,7 +471,7 @@ async def test_polling(hass: HomeAssistant) -> None: """Test should_poll property.""" config = validate_config(CONFIG_CHILDREN_ONLY) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) assert ump.should_poll is False @@ -470,7 +480,7 @@ async def test_state_children_only(hass: HomeAssistant, mock_states) -> None: """Test media player state with only children.""" config = validate_config(CONFIG_CHILDREN_ONLY) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -489,7 +499,7 @@ async def test_state_with_children_and_attrs( """Test media player with children and master state.""" config = validate_config(config_children_and_attr) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -514,7 +524,7 @@ async def test_volume_level(hass: HomeAssistant, mock_states) -> None: """Test volume level property.""" config = validate_config(CONFIG_CHILDREN_ONLY) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -538,7 +548,7 @@ async def test_media_image_url(hass: HomeAssistant, mock_states) -> None: test_url = "test_url" config = validate_config(CONFIG_CHILDREN_ONLY) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -558,7 +568,7 @@ async def test_is_volume_muted_children_only(hass: HomeAssistant, mock_states) - """Test is volume muted property w/ children only.""" config = validate_config(CONFIG_CHILDREN_ONLY) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -583,7 +593,7 @@ async def test_sound_mode_list_children_and_attr( """Test sound mode list property w/ children and attrs.""" config = validate_config(config_children_and_attr) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) assert ump.sound_mode_list == "['music', 'movie']" @@ -599,7 +609,7 @@ async def test_source_list_children_and_attr( """Test source list property w/ children and attrs.""" config = validate_config(config_children_and_attr) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) assert ump.source_list == "['dvd', 'htpc']" @@ -613,7 +623,7 @@ async def test_sound_mode_children_and_attr( """Test sound modeproperty w/ children and attrs.""" config = validate_config(config_children_and_attr) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) assert ump.sound_mode == "music" @@ -627,7 +637,7 @@ async def test_source_children_and_attr( """Test source property w/ children and attrs.""" config = validate_config(config_children_and_attr) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) assert ump.source == "dvd" @@ -641,7 +651,7 @@ async def test_volume_level_children_and_attr( """Test volume level property w/ children and attrs.""" config = validate_config(config_children_and_attr) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) assert ump.volume_level == 0 @@ -655,7 +665,7 @@ async def test_is_volume_muted_children_and_attr( """Test is volume muted property w/ children and attrs.""" config = validate_config(config_children_and_attr) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) assert not ump.is_volume_muted @@ -669,7 +679,7 @@ async def test_supported_features_children_only( """Test supported media commands with only children.""" config = validate_config(CONFIG_CHILDREN_ONLY) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -709,9 +719,10 @@ async def test_supported_features_children_and_cmds( "play_media": excmd, "clear_playlist": excmd, } + config["browse_media_entity"] = "media_player.test" config = validate_config(config) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -737,6 +748,7 @@ async def test_supported_features_children_and_cmds( | MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.CLEAR_PLAYLIST + | MediaPlayerEntityFeature.BROWSE_MEDIA ) assert check_flags == ump.supported_features @@ -926,7 +938,7 @@ async def test_supported_features_play_pause( config["commands"] = {"media_play_pause": excmd} config = validate_config(config) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -946,7 +958,7 @@ async def test_service_call_no_active_child( """Test a service call to children with no active child.""" config = validate_config(config_children_and_attr) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -966,7 +978,7 @@ async def test_service_call_to_child(hass: HomeAssistant, mock_states) -> None: """Test service calls that should be routed to a child.""" config = validate_config(CONFIG_CHILDREN_ONLY) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -1045,7 +1057,7 @@ async def test_service_call_to_command(hass: HomeAssistant, mock_states) -> None service = async_mock_service(hass, "test", "turn_off") - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -1084,6 +1096,67 @@ async def test_state_template(hass: HomeAssistant) -> None: assert hass.states.get("media_player.tv").state == STATE_OFF +async def test_browse_media(hass: HomeAssistant): + """Test browse media.""" + await async_setup_component( + hass, "media_player", {"media_player": {"platform": "demo"}} + ) + await hass.async_block_till_done() + + config = { + "name": "test", + "platform": "universal", + "children": [ + "media_player.bedroom", + ], + } + config = validate_config(config) + ump = universal.UniversalMediaPlayer(hass, config) + ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) + await ump.async_update() + + with patch( + "homeassistant.components.demo.media_player.MediaPlayerEntity.supported_features", + MediaPlayerEntityFeature.BROWSE_MEDIA, + ), patch( + "homeassistant.components.demo.media_player.MediaPlayerEntity.async_browse_media", + return_value=MOCK_BROWSE_MEDIA, + ): + result = await ump.async_browse_media() + assert result == MOCK_BROWSE_MEDIA + + +async def test_browse_media_override(hass: HomeAssistant): + """Test browse media override.""" + await async_setup_component( + hass, "media_player", {"media_player": {"platform": "demo"}} + ) + await hass.async_block_till_done() + + config = { + "name": "test", + "platform": "universal", + "children": [ + "media_player.mock1", + ], + "browse_media_entity": "media_player.bedroom", + } + config = validate_config(config) + ump = universal.UniversalMediaPlayer(hass, config) + ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) + await ump.async_update() + + with patch( + "homeassistant.components.demo.media_player.MediaPlayerEntity.supported_features", + MediaPlayerEntityFeature.BROWSE_MEDIA, + ), patch( + "homeassistant.components.demo.media_player.MediaPlayerEntity.async_browse_media", + return_value=MOCK_BROWSE_MEDIA, + ): + result = await ump.async_browse_media() + assert result == MOCK_BROWSE_MEDIA + + async def test_device_class(hass: HomeAssistant) -> None: """Test device_class property.""" hass.states.async_set("sensor.test_sensor", "on") From 6511b3f35590b84d4067f0d1761f33c8cc48acdb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 23 Feb 2023 10:59:47 +0100 Subject: [PATCH 0329/2810] Update pre-commit to 3.1.0 (#88657) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 49b6450883..75153ea6a0 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -13,7 +13,7 @@ coverage==7.1.0 freezegun==1.2.2 mock-open==1.4.0 mypy==1.0.1 -pre-commit==3.0.0 +pre-commit==3.1.0 pydantic==1.10.5 pylint==2.16.0 pylint-per-file-ignores==1.1.0 From dac3c7179fb7c972beb5f38d24e8db938ca86351 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 23 Feb 2023 16:22:39 +0100 Subject: [PATCH 0330/2810] 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 7d9e8b9a4f..0f59c6279f 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 7b5a76c9ac..ddc7d3ce85 100644 --- a/tests/components/openuv/test_config_flow.py +++ b/tests/components/openuv/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, patch from pyopenuv.errors import InvalidApiKeyError +import pytest import voluptuous as vol from homeassistant import data_entry_flow @@ -17,6 +18,8 @@ from homeassistant.core import HomeAssistant from .conftest import TEST_API_KEY, TEST_ELEVATION, TEST_LATITUDE, TEST_LONGITUDE +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + async def test_create_entry(hass: HomeAssistant, client, config, mock_pyopenuv) -> None: """Test creating an entry.""" From f8314fe0078402f034adab71d836f8f01f2d9461 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 23 Feb 2023 16:23:03 +0100 Subject: [PATCH 0331/2810] Update apprise to 1.3.0 (#88658) --- homeassistant/components/apprise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json index a462d433c7..453fc77351 100644 --- a/homeassistant/components/apprise/manifest.json +++ b/homeassistant/components/apprise/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/apprise", "iot_class": "cloud_push", "loggers": ["apprise"], - "requirements": ["apprise==1.2.1"] + "requirements": ["apprise==1.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5f857958a7..ec177cc02e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -342,7 +342,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.apprise -apprise==1.2.1 +apprise==1.3.0 # homeassistant.components.aprs aprslib==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index be5f3bb7f3..02005f89cc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -311,7 +311,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.apprise -apprise==1.2.1 +apprise==1.3.0 # homeassistant.components.aprs aprslib==0.7.0 From 6112793b195af9ef4ac50bb7ecb3421c695eace4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 23 Feb 2023 16:26:17 +0100 Subject: [PATCH 0332/2810] Modernize Twentemilieu tests (#88640) --- tests/components/twentemilieu/conftest.py | 18 +- .../twentemilieu/snapshots/test_calendar.ambr | 103 +++++ .../snapshots/test_config_flow.ambr | 85 +++++ .../snapshots/test_diagnostics.ambr | 20 + .../twentemilieu/snapshots/test_sensor.ambr | 356 ++++++++++++++++++ .../components/twentemilieu/test_calendar.py | 64 +--- .../twentemilieu/test_config_flow.py | 42 +-- .../twentemilieu/test_diagnostics.py | 15 +- tests/components/twentemilieu/test_init.py | 8 +- tests/components/twentemilieu/test_sensor.py | 120 ++---- 10 files changed, 639 insertions(+), 192 deletions(-) create mode 100644 tests/components/twentemilieu/snapshots/test_calendar.ambr create mode 100644 tests/components/twentemilieu/snapshots/test_config_flow.ambr create mode 100644 tests/components/twentemilieu/snapshots/test_diagnostics.ambr create mode 100644 tests/components/twentemilieu/snapshots/test_sensor.ambr diff --git a/tests/components/twentemilieu/conftest.py b/tests/components/twentemilieu/conftest.py index 6e58ba2db4..c42e3a9eb5 100644 --- a/tests/components/twentemilieu/conftest.py +++ b/tests/components/twentemilieu/conftest.py @@ -46,22 +46,14 @@ def mock_setup_entry() -> Generator[None, None, None]: @pytest.fixture -def mock_twentemilieu_config_flow() -> Generator[None, MagicMock, None]: - """Return a mocked Twente Milieu client.""" - with patch( - "homeassistant.components.twentemilieu.config_flow.TwenteMilieu", autospec=True - ) as twentemilieu_mock: - twentemilieu = twentemilieu_mock.return_value - twentemilieu.unique_id.return_value = 12345 - yield twentemilieu - - -@pytest.fixture -def mock_twentemilieu() -> Generator[None, MagicMock, None]: +def mock_twentemilieu() -> Generator[MagicMock, None, None]: """Return a mocked Twente Milieu client.""" with patch( "homeassistant.components.twentemilieu.TwenteMilieu", autospec=True - ) as twentemilieu_mock: + ) as twentemilieu_mock, patch( + "homeassistant.components.twentemilieu.config_flow.TwenteMilieu", + new=twentemilieu_mock, + ): twentemilieu = twentemilieu_mock.return_value twentemilieu.unique_id.return_value = 12345 twentemilieu.update.return_value = { diff --git a/tests/components/twentemilieu/snapshots/test_calendar.ambr b/tests/components/twentemilieu/snapshots/test_calendar.ambr new file mode 100644 index 0000000000..04965b342b --- /dev/null +++ b/tests/components/twentemilieu/snapshots/test_calendar.ambr @@ -0,0 +1,103 @@ +# serializer version: 1 +# name: test_api_calendar + list([ + dict({ + 'entity_id': 'calendar.twente_milieu', + 'name': 'Twente Milieu', + }), + ]) +# --- +# name: test_api_events + list([ + dict({ + 'description': None, + 'end': dict({ + 'date': '2022-01-06', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'date': '2022-01-06', + }), + 'summary': 'Christmas tree pickup', + 'uid': None, + }), + ]) +# --- +# name: test_waste_pickup_calendar + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'all_day': True, + 'description': '', + 'end_time': '2022-01-06 00:00:00', + 'friendly_name': 'Twente Milieu', + 'icon': 'mdi:delete-empty', + 'location': '', + 'message': 'Christmas tree pickup', + 'start_time': '2022-01-06 00:00:00', + }), + 'context': , + 'entity_id': 'calendar.twente_milieu', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_waste_pickup_calendar.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'calendar', + 'entity_category': None, + 'entity_id': 'calendar.twente_milieu', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:delete-empty', + 'original_name': None, + 'platform': 'twentemilieu', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345', + 'unit_of_measurement': None, + }) +# --- +# name: test_waste_pickup_calendar.2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://www.twentemilieu.nl', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'twentemilieu', + '12345', + ), + }), + 'is_new': False, + 'manufacturer': 'Twente Milieu', + 'model': None, + 'name': 'Twente Milieu', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/twentemilieu/snapshots/test_config_flow.ambr b/tests/components/twentemilieu/snapshots/test_config_flow.ambr new file mode 100644 index 0000000000..7acb466d99 --- /dev/null +++ b/tests/components/twentemilieu/snapshots/test_config_flow.ambr @@ -0,0 +1,85 @@ +# serializer version: 1 +# name: test_full_user_flow + FlowResultSnapshot({ + 'context': dict({ + 'source': 'user', + 'unique_id': '12345', + }), + 'data': dict({ + 'house_letter': 'A', + 'house_number': '1', + 'id': 12345, + 'post_code': '1234AB', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'twentemilieu', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'house_letter': 'A', + 'house_number': '1', + 'id': 12345, + 'post_code': '1234AB', + }), + 'disabled_by': None, + 'domain': 'twentemilieu', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': '12345', + 'unique_id': '12345', + 'version': 1, + }), + 'title': '12345', + 'type': , + 'version': 1, + }) +# --- +# name: test_invalid_address + FlowResultSnapshot({ + 'context': dict({ + 'source': 'user', + 'unique_id': '12345', + }), + 'data': dict({ + 'house_letter': None, + 'house_number': '1', + 'id': 12345, + 'post_code': '1234AB', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'twentemilieu', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'house_letter': None, + 'house_number': '1', + 'id': 12345, + 'post_code': '1234AB', + }), + 'disabled_by': None, + 'domain': 'twentemilieu', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': '12345', + 'unique_id': '12345', + 'version': 1, + }), + 'title': '12345', + 'type': , + 'version': 1, + }) +# --- diff --git a/tests/components/twentemilieu/snapshots/test_diagnostics.ambr b/tests/components/twentemilieu/snapshots/test_diagnostics.ambr new file mode 100644 index 0000000000..d7c786fe5b --- /dev/null +++ b/tests/components/twentemilieu/snapshots/test_diagnostics.ambr @@ -0,0 +1,20 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'WasteType.NON_RECYCLABLE': list([ + '2021-11-01', + '2021-12-01', + ]), + 'WasteType.ORGANIC': list([ + '2021-11-02', + ]), + 'WasteType.PACKAGES': list([ + '2021-11-03', + ]), + 'WasteType.PAPER': list([ + ]), + 'WasteType.TREE': list([ + '2022-01-06', + ]), + }) +# --- diff --git a/tests/components/twentemilieu/snapshots/test_sensor.ambr b/tests/components/twentemilieu/snapshots/test_sensor.ambr new file mode 100644 index 0000000000..46b21ebab3 --- /dev/null +++ b/tests/components/twentemilieu/snapshots/test_sensor.ambr @@ -0,0 +1,356 @@ +# serializer version: 1 +# name: test_sensors[sensor.twente_milieu_christmas_tree_pickup] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'date', + 'friendly_name': 'Twente Milieu Christmas tree pickup', + 'icon': 'mdi:pine-tree', + }), + 'context': , + 'entity_id': 'sensor.twente_milieu_christmas_tree_pickup', + 'last_changed': , + 'last_updated': , + 'state': '2022-01-06', + }) +# --- +# name: test_sensors[sensor.twente_milieu_christmas_tree_pickup].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.twente_milieu_christmas_tree_pickup', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:pine-tree', + 'original_name': 'Christmas tree pickup', + 'platform': 'twentemilieu', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'twentemilieu_12345_tree', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.twente_milieu_christmas_tree_pickup].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://www.twentemilieu.nl', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'twentemilieu', + '12345', + ), + }), + 'is_new': False, + 'manufacturer': 'Twente Milieu', + 'model': None, + 'name': 'Twente Milieu', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_sensors[sensor.twente_milieu_non_recyclable_waste_pickup] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'date', + 'friendly_name': 'Twente Milieu Non-recyclable waste pickup', + 'icon': 'mdi:delete-empty', + }), + 'context': , + 'entity_id': 'sensor.twente_milieu_non_recyclable_waste_pickup', + 'last_changed': , + 'last_updated': , + 'state': '2021-11-01', + }) +# --- +# name: test_sensors[sensor.twente_milieu_non_recyclable_waste_pickup].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.twente_milieu_non_recyclable_waste_pickup', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:delete-empty', + 'original_name': 'Non-recyclable waste pickup', + 'platform': 'twentemilieu', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'twentemilieu_12345_Non-recyclable', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.twente_milieu_non_recyclable_waste_pickup].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://www.twentemilieu.nl', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'twentemilieu', + '12345', + ), + }), + 'is_new': False, + 'manufacturer': 'Twente Milieu', + 'model': None, + 'name': 'Twente Milieu', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_sensors[sensor.twente_milieu_organic_waste_pickup] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'date', + 'friendly_name': 'Twente Milieu Organic waste pickup', + 'icon': 'mdi:delete-empty', + }), + 'context': , + 'entity_id': 'sensor.twente_milieu_organic_waste_pickup', + 'last_changed': , + 'last_updated': , + 'state': '2021-11-02', + }) +# --- +# name: test_sensors[sensor.twente_milieu_organic_waste_pickup].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.twente_milieu_organic_waste_pickup', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:delete-empty', + 'original_name': 'Organic waste pickup', + 'platform': 'twentemilieu', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'twentemilieu_12345_Organic', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.twente_milieu_organic_waste_pickup].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://www.twentemilieu.nl', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'twentemilieu', + '12345', + ), + }), + 'is_new': False, + 'manufacturer': 'Twente Milieu', + 'model': None, + 'name': 'Twente Milieu', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_sensors[sensor.twente_milieu_packages_waste_pickup] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'date', + 'friendly_name': 'Twente Milieu Packages waste pickup', + 'icon': 'mdi:delete-empty', + }), + 'context': , + 'entity_id': 'sensor.twente_milieu_packages_waste_pickup', + 'last_changed': , + 'last_updated': , + 'state': '2021-11-03', + }) +# --- +# name: test_sensors[sensor.twente_milieu_packages_waste_pickup].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.twente_milieu_packages_waste_pickup', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:delete-empty', + 'original_name': 'Packages waste pickup', + 'platform': 'twentemilieu', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'twentemilieu_12345_Plastic', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.twente_milieu_packages_waste_pickup].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://www.twentemilieu.nl', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'twentemilieu', + '12345', + ), + }), + 'is_new': False, + 'manufacturer': 'Twente Milieu', + 'model': None, + 'name': 'Twente Milieu', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_sensors[sensor.twente_milieu_paper_waste_pickup] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'date', + 'friendly_name': 'Twente Milieu Paper waste pickup', + 'icon': 'mdi:delete-empty', + }), + 'context': , + 'entity_id': 'sensor.twente_milieu_paper_waste_pickup', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.twente_milieu_paper_waste_pickup].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.twente_milieu_paper_waste_pickup', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:delete-empty', + 'original_name': 'Paper waste pickup', + 'platform': 'twentemilieu', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'twentemilieu_12345_Paper', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.twente_milieu_paper_waste_pickup].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://www.twentemilieu.nl', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'twentemilieu', + '12345', + ), + }), + 'is_new': False, + 'manufacturer': 'Twente Milieu', + 'model': None, + 'name': 'Twente Milieu', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/twentemilieu/test_calendar.py b/tests/components/twentemilieu/test_calendar.py index 2c6c201276..7610b8b003 100644 --- a/tests/components/twentemilieu/test_calendar.py +++ b/tests/components/twentemilieu/test_calendar.py @@ -2,71 +2,50 @@ from http import HTTPStatus import pytest +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.twentemilieu.const import DOMAIN -from homeassistant.const import ATTR_ICON, STATE_OFF from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator +pytestmark = pytest.mark.usefixtures("init_integration") + @pytest.mark.freeze_time("2022-01-05 00:00:00+00:00") async def test_waste_pickup_calendar( hass: HomeAssistant, - init_integration: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test the Twente Milieu waste pickup calendar.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) + assert (state := hass.states.get("calendar.twente_milieu")) + assert state == snapshot - state = hass.states.get("calendar.twente_milieu") - entry = entity_registry.async_get("calendar.twente_milieu") - assert entry - assert state - assert entry.unique_id == "12345" - assert state.attributes[ATTR_ICON] == "mdi:delete-empty" - assert state.attributes["all_day"] is True - assert state.attributes["message"] == "Christmas tree pickup" - assert not state.attributes["location"] - assert not state.attributes["description"] - assert state.state == STATE_OFF + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert entity_entry == snapshot - assert entry.device_id - device_entry = device_registry.async_get(entry.device_id) - assert device_entry - assert device_entry.identifiers == {(DOMAIN, "12345")} - assert device_entry.manufacturer == "Twente Milieu" - assert device_entry.name == "Twente Milieu" - assert device_entry.entry_type is dr.DeviceEntryType.SERVICE - assert device_entry.configuration_url == "https://www.twentemilieu.nl" - assert not device_entry.model - assert not device_entry.sw_version + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry == snapshot async def test_api_calendar( - hass: HomeAssistant, - init_integration: MockConfigEntry, hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test the API returns the calendar.""" client = await hass_client() response = await client.get("/api/calendars") assert response.status == HTTPStatus.OK data = await response.json() - assert data == [ - { - "entity_id": "calendar.twente_milieu", - "name": "Twente Milieu", - } - ] + assert data == snapshot async def test_api_events( - hass: HomeAssistant, - init_integration: MockConfigEntry, hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test the Twente Milieu calendar view.""" client = await hass_client() @@ -76,13 +55,4 @@ async def test_api_events( assert response.status == HTTPStatus.OK events = await response.json() assert len(events) == 1 - assert events[0] == { - "start": {"date": "2022-01-06"}, - "end": {"date": "2022-01-06"}, - "summary": "Christmas tree pickup", - "description": None, - "location": None, - "uid": None, - "recurrence_id": None, - "rrule": None, - } + assert events == snapshot diff --git a/tests/components/twentemilieu/test_config_flow.py b/tests/components/twentemilieu/test_config_flow.py index 83e7011b88..d8c2c82f4e 100644 --- a/tests/components/twentemilieu/test_config_flow.py +++ b/tests/components/twentemilieu/test_config_flow.py @@ -1,6 +1,8 @@ """Tests for the Twente Milieu config flow.""" from unittest.mock import MagicMock +import pytest +from syrupy.assertion import SnapshotAssertion from twentemilieu import TwenteMilieuAddressError, TwenteMilieuConnectionError from homeassistant import config_entries @@ -12,18 +14,16 @@ from homeassistant.components.twentemilieu.const import ( DOMAIN, ) from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_full_user_flow( - hass: HomeAssistant, - mock_twentemilieu_config_flow: MagicMock, - mock_setup_entry: MagicMock, -) -> None: + +@pytest.mark.usefixtures("mock_twentemilieu") +async def test_full_user_flow(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: """Test registering an integration and finishing flow works.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -42,19 +42,13 @@ async def test_full_user_flow( ) assert result2.get("type") == FlowResultType.CREATE_ENTRY - assert result2.get("title") == "12345" - assert result2.get("data") == { - CONF_ID: 12345, - CONF_POST_CODE: "1234AB", - CONF_HOUSE_NUMBER: "1", - CONF_HOUSE_LETTER: "A", - } + assert result2 == snapshot async def test_invalid_address( hass: HomeAssistant, - mock_twentemilieu_config_flow: MagicMock, - mock_setup_entry: MagicMock, + mock_twentemilieu: MagicMock, + snapshot: SnapshotAssertion, ) -> None: """Test full user flow when the user enters an incorrect address. @@ -68,7 +62,7 @@ async def test_invalid_address( assert result.get("type") == FlowResultType.FORM assert result.get("step_id") == SOURCE_USER - mock_twentemilieu_config_flow.unique_id.side_effect = TwenteMilieuAddressError + mock_twentemilieu.unique_id.side_effect = TwenteMilieuAddressError result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ @@ -81,7 +75,7 @@ async def test_invalid_address( assert result2.get("step_id") == SOURCE_USER assert result2.get("errors") == {"base": "invalid_address"} - mock_twentemilieu_config_flow.unique_id.side_effect = None + mock_twentemilieu.unique_id.side_effect = None result3 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ @@ -91,21 +85,15 @@ async def test_invalid_address( ) assert result3.get("type") == FlowResultType.CREATE_ENTRY - assert result3.get("title") == "12345" - assert result3.get("data") == { - CONF_ID: 12345, - CONF_POST_CODE: "1234AB", - CONF_HOUSE_NUMBER: "1", - CONF_HOUSE_LETTER: None, - } + assert result3 == snapshot async def test_connection_error( hass: HomeAssistant, - mock_twentemilieu_config_flow: MagicMock, + mock_twentemilieu: MagicMock, ) -> None: """Test we show user form on Twente Milieu connection error.""" - mock_twentemilieu_config_flow.unique_id.side_effect = TwenteMilieuConnectionError + mock_twentemilieu.unique_id.side_effect = TwenteMilieuConnectionError result = await hass.config_entries.flow.async_init( DOMAIN, @@ -122,9 +110,9 @@ async def test_connection_error( assert result.get("errors") == {"base": "cannot_connect"} +@pytest.mark.usefixtures("mock_twentemilieu") async def test_address_already_set_up( hass: HomeAssistant, - mock_twentemilieu_config_flow: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test we abort if address has already been set up.""" diff --git a/tests/components/twentemilieu/test_diagnostics.py b/tests/components/twentemilieu/test_diagnostics.py index 36112d3d99..0828d35ec5 100644 --- a/tests/components/twentemilieu/test_diagnostics.py +++ b/tests/components/twentemilieu/test_diagnostics.py @@ -1,4 +1,5 @@ """Tests for the diagnostics data provided by the TwenteMilieu integration.""" +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant @@ -11,14 +12,10 @@ async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - assert await get_diagnostics_for_config_entry( - hass, hass_client, init_integration - ) == { - "WasteType.NON_RECYCLABLE": ["2021-11-01", "2021-12-01"], - "WasteType.ORGANIC": ["2021-11-02"], - "WasteType.PAPER": [], - "WasteType.TREE": ["2022-01-06"], - "WasteType.PACKAGES": ["2021-11-03"], - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) diff --git a/tests/components/twentemilieu/test_init.py b/tests/components/twentemilieu/test_init.py index d5fd108b67..b97578588b 100644 --- a/tests/components/twentemilieu/test_init.py +++ b/tests/components/twentemilieu/test_init.py @@ -1,5 +1,7 @@ """Tests for the Twente Milieu integration.""" -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch + +import pytest from homeassistant.components.twentemilieu.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -8,10 +10,10 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +@pytest.mark.usefixtures("mock_twentemilieu") async def test_load_unload_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_twentemilieu: AsyncMock, ) -> None: """Test the Twente Milieu configuration entry loading/unloading.""" mock_config_entry.add_to_hass(hass) @@ -45,10 +47,10 @@ async def test_config_entry_not_ready( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +@pytest.mark.usefixtures("mock_twentemilieu") async def test_update_config_entry_unique_id( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_twentemilieu: AsyncMock, ) -> None: """Test the we update old config entries with an unique ID.""" mock_config_entry.unique_id = None diff --git a/tests/components/twentemilieu/test_sensor.py b/tests/components/twentemilieu/test_sensor.py index 6e20fd4d14..6fd39e38d4 100644 --- a/tests/components/twentemilieu/test_sensor.py +++ b/tests/components/twentemilieu/test_sensor.py @@ -1,104 +1,38 @@ """Tests for the Twente Milieu sensors.""" -from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.components.twentemilieu.const import DOMAIN -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_FRIENDLY_NAME, - ATTR_ICON, - ATTR_UNIT_OF_MEASUREMENT, - STATE_UNKNOWN, -) + +import pytest +from syrupy.assertion import SnapshotAssertion + from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("init_integration") -async def test_waste_pickup_sensors( +@pytest.mark.parametrize( + "entity_id", + [ + "sensor.twente_milieu_christmas_tree_pickup", + "sensor.twente_milieu_non_recyclable_waste_pickup", + "sensor.twente_milieu_organic_waste_pickup", + "sensor.twente_milieu_packages_waste_pickup", + "sensor.twente_milieu_paper_waste_pickup", + ], +) +async def test_sensors( hass: HomeAssistant, - init_integration: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + entity_id: str, ) -> None: """Test the Twente Milieu waste pickup sensors.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) + assert (state := hass.states.get(entity_id)) + assert state == snapshot - state = hass.states.get("sensor.twente_milieu_christmas_tree_pickup") - entry = entity_registry.async_get("sensor.twente_milieu_christmas_tree_pickup") - assert entry - assert state - assert entry.unique_id == "twentemilieu_12345_tree" - assert state.state == "2022-01-06" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Twente Milieu Christmas tree pickup" - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DATE - assert state.attributes.get(ATTR_ICON) == "mdi:pine-tree" - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert entity_entry == snapshot - state = hass.states.get("sensor.twente_milieu_non_recyclable_waste_pickup") - entry = entity_registry.async_get( - "sensor.twente_milieu_non_recyclable_waste_pickup" - ) - assert entry - assert state - assert entry.unique_id == "twentemilieu_12345_Non-recyclable" - assert state.state == "2021-11-01" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Twente Milieu Non-recyclable waste pickup" - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DATE - assert state.attributes.get(ATTR_ICON) == "mdi:delete-empty" - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - - state = hass.states.get("sensor.twente_milieu_organic_waste_pickup") - entry = entity_registry.async_get("sensor.twente_milieu_organic_waste_pickup") - assert entry - assert state - assert entry.unique_id == "twentemilieu_12345_Organic" - assert state.state == "2021-11-02" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) == "Twente Milieu Organic waste pickup" - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DATE - assert state.attributes.get(ATTR_ICON) == "mdi:delete-empty" - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - - state = hass.states.get("sensor.twente_milieu_packages_waste_pickup") - entry = entity_registry.async_get("sensor.twente_milieu_packages_waste_pickup") - assert entry - assert state - assert entry.unique_id == "twentemilieu_12345_Plastic" - assert state.state == "2021-11-03" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Twente Milieu Packages waste pickup" - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DATE - assert state.attributes.get(ATTR_ICON) == "mdi:delete-empty" - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - - state = hass.states.get("sensor.twente_milieu_paper_waste_pickup") - entry = entity_registry.async_get("sensor.twente_milieu_paper_waste_pickup") - assert entry - assert state - assert entry.unique_id == "twentemilieu_12345_Paper" - assert state.state == STATE_UNKNOWN - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) == "Twente Milieu Paper waste pickup" - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DATE - assert state.attributes.get(ATTR_ICON) == "mdi:delete-empty" - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - - assert entry.device_id - device_entry = device_registry.async_get(entry.device_id) - assert device_entry - assert device_entry.identifiers == {(DOMAIN, "12345")} - assert device_entry.manufacturer == "Twente Milieu" - assert device_entry.name == "Twente Milieu" - assert device_entry.entry_type is dr.DeviceEntryType.SERVICE - assert device_entry.configuration_url == "https://www.twentemilieu.nl" - assert not device_entry.model - assert not device_entry.sw_version + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry == snapshot From 57397828773eb1c7f5dde0512e26ab283151885a Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 23 Feb 2023 13:24:55 -0500 Subject: [PATCH 0333/2810] 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 2612d2d4f6..091de1949e 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 f988e72e70..4e99d19261 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -2,6 +2,7 @@ from copy import deepcopy from http import HTTPStatus import json +from typing import Any from unittest.mock import patch import pytest @@ -2983,12 +2984,18 @@ async def test_get_config_parameters( assert msg["error"]["code"] == ERR_NOT_LOADED +@pytest.mark.parametrize( + ("firmware_data", "expected_data"), + [({"target": "1"}, {"firmware_target": 1}), ({}, {})], +) async def test_firmware_upload_view( hass: HomeAssistant, multisensor_6, integration, hass_client: ClientSessionGenerator, firmware_file, + firmware_data: dict[str, Any], + expected_data: dict[str, Any], ) -> None: """Test the HTTP firmware upload view.""" client = await hass_client() @@ -3001,15 +3008,19 @@ async def test_firmware_upload_view( "homeassistant.components.zwave_js.api.USER_AGENT", {"HomeAssistant": "0.0.0"}, ): + data = {"file": firmware_file} + data.update(firmware_data) + resp = await client.post( - f"/api/zwave_js/firmware/upload/{device.id}", - data={"file": firmware_file}, + f"/api/zwave_js/firmware/upload/{device.id}", data=data ) + + update_data = NodeFirmwareUpdateData("file", bytes(10)) + for attr, value in expected_data.items(): + setattr(update_data, attr, value) + mock_controller_cmd.assert_not_called() - assert mock_node_cmd.call_args[0][1:3] == ( - multisensor_6, - [NodeFirmwareUpdateData("file", bytes(10))], - ) + assert mock_node_cmd.call_args[0][1:3] == (multisensor_6, [update_data]) assert mock_node_cmd.call_args[1] == { "additional_user_agent_components": {"HomeAssistant": "0.0.0"}, } @@ -3017,7 +3028,11 @@ async def test_firmware_upload_view( async def test_firmware_upload_view_controller( - hass, client, integration, hass_client: ClientSessionGenerator, firmware_file + hass: HomeAssistant, + client, + integration, + hass_client: ClientSessionGenerator, + firmware_file, ) -> None: """Test the HTTP firmware upload view for a controller.""" hass_client = await hass_client() From e1e0400b162055bc361d381683ce774cceb9c857 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 23 Feb 2023 10:37:15 -0800 Subject: [PATCH 0334/2810] 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 390e14d168..c77d6c9c67 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 be6fb4a17b..88737150c0 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 806410c983..5c90a1cfc2 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 c7eea20920..f432fe3f97 100644 --- a/tests/components/local_calendar/test_calendar.py +++ b/tests/components/local_calendar/test_calendar.py @@ -48,8 +48,12 @@ class FakeStore(LocalCalendarStore): def mock_store() -> None: """Test cleanup, remove any media storage persisted during the test.""" + stores: dict[Path, FakeStore] = {} + def new_store(hass: HomeAssistant, path: Path) -> FakeStore: - return FakeStore(hass, path) + if path not in stores: + stores[path] = FakeStore(hass, path) + return stores[path] with patch( "homeassistant.components.local_calendar.LocalCalendarStore", new=new_store @@ -961,8 +965,20 @@ async def test_update_invalid_event_id( assert resp.get("error").get("code") == "failed" +@pytest.mark.parametrize( + ("start_date_time", "end_date_time"), + [ + ("1997-07-14T17:00:00+00:00", "1997-07-15T04:00:00+00:00"), + ("1997-07-14T11:00:00-06:00", "1997-07-14T22:00:00-06:00"), + ], +) async def test_create_event_service( - hass: HomeAssistant, setup_integration: None, get_events: GetEventsFn + hass: HomeAssistant, + setup_integration: None, + get_events: GetEventsFn, + start_date_time: str, + end_date_time: str, + config_entry: MockConfigEntry, ) -> None: """Test creating an event using the create_event service.""" @@ -970,13 +986,15 @@ async def test_create_event_service( "calendar", "create_event", { - "start_date_time": "1997-07-14T17:00:00+00:00", - "end_date_time": "1997-07-15T04:00:00+00:00", + "start_date_time": start_date_time, + "end_date_time": end_date_time, "summary": "Bastille Day Party", }, target={"entity_id": TEST_ENTITY}, blocking=True, ) + # Ensure data is written to disk + await hass.async_block_till_done() events = await get_events("1997-07-14T00:00:00Z", "1997-07-16T00:00:00Z") assert list(map(event_fields, events)) == [ @@ -995,3 +1013,17 @@ async def test_create_event_service( "end": {"dateTime": "1997-07-14T22:00:00-06:00"}, } ] + + # Reload the config entry, which reloads the content from the store and + # verifies that the persisted data can be parsed correctly. + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + events = await get_events("1997-07-13T00:00:00Z", "1997-07-14T18:00:00Z") + assert list(map(event_fields, events)) == [ + { + "summary": "Bastille Day Party", + "start": {"dateTime": "1997-07-14T11:00:00-06:00"}, + "end": {"dateTime": "1997-07-14T22:00:00-06:00"}, + } + ] From e0601530a01b26021f5478694a6b0fd88f01b1a2 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 23 Feb 2023 19:38:07 +0100 Subject: [PATCH 0335/2810] 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 17cbbb72ef..a5930177b9 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 5193492a07..4675a2ae92 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 ec177cc02e..1befa3fee9 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 02005f89cc..b643171a7f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -690,7 +690,7 @@ hole==0.8.0 holidays==0.18.0 # homeassistant.components.frontend -home-assistant-frontend==20230222.0 +home-assistant-frontend==20230223.0 # homeassistant.components.conversation home-assistant-intents==2023.2.22 From 301144993cb39d9dc8c4fd752d7d50c3fc9f218d Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 23 Feb 2023 20:58:37 +0100 Subject: [PATCH 0336/2810] 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 111e7c0ea9..e86e5c0ca4 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 5bcec4b433..fbc027091b 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 ce5b7a1091..b4d1b867e7 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 35b5d40b6d..0000000000 --- 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 0000000000..3fb8481dc9 --- /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 4a0c8f6a60..a1d67158ab 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 994ab0ff80..4b60995025 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 a891870bbe..da0739cd41 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 73863de5bd..b81ac2c62b 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 0000000000..3ce5f18467 --- /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 d60d473b0b..34760fbbf1 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 53ae25f889..e5c9861043 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 1befa3fee9..25b58cad48 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 b643171a7f..d2de02e3df 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 4f45862c5c..172290125b 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 8f849c8594..2ccb818b33 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 a5a858b0b1..cab1f59f83 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 deaaf62c97..24b6662108 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 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 0337/2810] 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 8a773213a5..4c10a2328a 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 128e3b145f..2f1b22e0ea 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 b96acb29b1..adff43d377 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 0338/2810] 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 2612d2d4f6..091de1949e 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 f988e72e70..4e99d19261 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 0339/2810] 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 7abf716cec..24fb89f214 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 89b5aa3baa..16fadd9b06 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 7d0e175bde..0de24f6b3c 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 425c87c7a8..b1d1176f16 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 918d150465..b0bd8956c2 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 0340/2810] 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 19eaa55f00..ebe2ab0025 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 1d54084969..00aae5b8a0 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 b0bd8956c2..e27cfb219c 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 10affab078..7818d736e0 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 0341/2810] 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 2d3fc52eb3..6633f5c02f 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 afba72fbf1..62b2b5a038 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 71ca16ca68..51a9697717 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 0de24f6b3c..80c7bedb6d 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 b1d1176f16..4583dbae11 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 0342/2810] 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 64d24504ef..718a6b571a 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 f3d086afed..4a93758573 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 1a817c8a52..b8d9ad3d16 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 80c7bedb6d..5f857958a7 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 4583dbae11..be5f3bb7f3 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 0343/2810] 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 390e14d168..c77d6c9c67 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 be6fb4a17b..88737150c0 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 806410c983..5c90a1cfc2 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 c7eea20920..f432fe3f97 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 0344/2810] 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 7d9e8b9a4f..0f59c6279f 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 7b5a76c9ac..ddc7d3ce85 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 0345/2810] 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 111e7c0ea9..e86e5c0ca4 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 5bcec4b433..fbc027091b 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 ce5b7a1091..b4d1b867e7 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 35b5d40b6d..0000000000 --- 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 0000000000..3fb8481dc9 --- /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 4a0c8f6a60..a1d67158ab 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 994ab0ff80..4b60995025 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 a891870bbe..da0739cd41 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 73863de5bd..b81ac2c62b 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 0000000000..3ce5f18467 --- /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 d60d473b0b..34760fbbf1 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 53ae25f889..e5c9861043 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 5f857958a7..dea4022c98 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 be5f3bb7f3..00bba24ec3 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 4f45862c5c..172290125b 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 8f849c8594..2ccb818b33 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 a5a858b0b1..cab1f59f83 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 deaaf62c97..24b6662108 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 0346/2810] 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 17cbbb72ef..a5930177b9 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 5193492a07..4675a2ae92 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 dea4022c98..9b25fbc482 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 00bba24ec3..a48a0bb301 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 0347/2810] 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 f1670bf3d3..8c80e72519 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 7102fe5d92..15a62106b4 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 d5f171349809ce93df1fe23565c2090ebd82af42 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 23 Feb 2023 18:40:10 -0600 Subject: [PATCH 0348/2810] Include binary_sensor in default Assist exposed domains (#88682) --- homeassistant/components/conversation/const.py | 3 +++ .../components/conversation/default_agent.py | 11 ++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/conversation/const.py b/homeassistant/components/conversation/const.py index b79a557698..1cae975c95 100644 --- a/homeassistant/components/conversation/const.py +++ b/homeassistant/components/conversation/const.py @@ -3,6 +3,7 @@ DOMAIN = "conversation" DEFAULT_EXPOSED_DOMAINS = { + "binary_sensor", "climate", "cover", "fan", @@ -16,3 +17,5 @@ DEFAULT_EXPOSED_DOMAINS = { "vacuum", "water_heater", } + +DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"} diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 2c531fea14..3db7b013bd 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -28,7 +28,7 @@ from homeassistant.helpers import ( from homeassistant.util.json import JsonObjectType, json_loads_object from .agent import AbstractConversationAgent, ConversationInput, ConversationResult -from .const import DEFAULT_EXPOSED_DOMAINS, DOMAIN +from .const import DEFAULT_EXPOSED_ATTRIBUTES, DEFAULT_EXPOSED_DOMAINS, DOMAIN _LOGGER = logging.getLogger(__name__) _DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that" @@ -467,6 +467,12 @@ class DefaultAgent(AbstractConversationAgent): for state in states: # Checked against "requires_context" and "excludes_context" in hassil context = {"domain": state.domain} + if state.attributes: + # Include some attributes + for attr_key, attr_value in state.attributes.items(): + if attr_key not in DEFAULT_EXPOSED_ATTRIBUTES: + continue + context[attr_key] = attr_value entity = entities.async_get(state.entity_id) if entity is not None: @@ -506,6 +512,9 @@ class DefaultAgent(AbstractConversationAgent): for alias in area.aliases: area_names.append((alias, area.id)) + _LOGGER.debug("Exposed areas: %s", area_names) + _LOGGER.debug("Exposed entities: %s", entity_names) + self._slot_lists = { "area": TextSlotList.from_tuples(area_names, allow_template=False), "name": TextSlotList.from_tuples(entity_names, allow_template=False), From a71487a42bd2483278a14d30d2520900dc712f4d Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 23 Feb 2023 19:50:23 -0600 Subject: [PATCH 0349/2810] 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 3db7b013bd..3be3f8cfc6 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -227,7 +227,21 @@ class DefaultAgent(AbstractConversationAgent): intent_response: intent.IntentResponse, recognize_result: RecognizeResult, ) -> str: - all_states = intent_response.matched_states + intent_response.unmatched_states + # Make copies of the states here so we can add translated names for responses. + matched: list[core.State] = [] + + for state in intent_response.matched_states: + state_copy = core.State.from_dict(state.as_dict()) + if state_copy is not None: + matched.append(state_copy) + + unmatched: list[core.State] = [] + for state in intent_response.unmatched_states: + state_copy = core.State.from_dict(state.as_dict()) + if state_copy is not None: + unmatched.append(state_copy) + + all_states = matched + unmatched domains = {state.domain for state in all_states} translations = await translation.async_get_translations( self.hass, language, "state", domains @@ -262,13 +276,11 @@ class DefaultAgent(AbstractConversationAgent): "query": { # Entity states that matched the query (e.g, "on") "matched": [ - template.TemplateState(self.hass, state) - for state in intent_response.matched_states + template.TemplateState(self.hass, state) for state in matched ], # Entity states that did not match the query "unmatched": [ - template.TemplateState(self.hass, state) - for state in intent_response.unmatched_states + template.TemplateState(self.hass, state) for state in unmatched ], }, } From f0b029c363ee12a9f05ba19b7be693ab7a722fa9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Feb 2023 19:52:31 -0600 Subject: [PATCH 0350/2810] 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 f4b82be0ac..7110419215 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 25b58cad48..b9106e5f3e 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 d2de02e3df..5dc78de279 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 389400cc51..3446b1dc66 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 7e2a81d310..626aa44efd 100644 --- a/tests/components/mopeka/test_sensor.py +++ b/tests/components/mopeka/test_sensor.py @@ -10,14 +10,52 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from . import PRO_GOOD_SIGNAL_SERVICE_INFO, PRO_SERVICE_INFO +from . import ( + PRO_GOOD_SIGNAL_SERVICE_INFO, + PRO_SERVICE_INFO, + PRO_UNUSABLE_SIGNAL_SERVICE_INFO, +) from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info -async def test_sensors_bad_signal(hass: HomeAssistant) -> None: - """Test setting up creates the sensors when there is bad signal.""" +async def test_sensors_unusable_signal(hass: HomeAssistant) -> None: + """Test setting up creates the sensors when there is unusable signal.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="aa:bb:cc:dd:ee:ff", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 0 + inject_bluetooth_service_info(hass, PRO_UNUSABLE_SIGNAL_SERVICE_INFO) + await hass.async_block_till_done() + assert len(hass.states.async_all("sensor")) == 4 + + temp_sensor = hass.states.get("sensor.pro_plus_eeff_temperature") + temp_sensor_attrs = temp_sensor.attributes + assert temp_sensor.state == "30" + assert temp_sensor_attrs[ATTR_FRIENDLY_NAME] == "Pro Plus EEFF Temperature" + assert temp_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS + assert temp_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + tank_sensor = hass.states.get("sensor.pro_plus_eeff_tank_level") + tank_sensor_attrs = tank_sensor.attributes + assert tank_sensor.state == STATE_UNKNOWN + assert tank_sensor_attrs[ATTR_FRIENDLY_NAME] == "Pro Plus EEFF Tank Level" + assert tank_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == UnitOfLength.MILLIMETERS + assert tank_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_sensors_poor_signal(hass: HomeAssistant) -> None: + """Test setting up creates the sensors when there is poor signal.""" entry = MockConfigEntry( domain=DOMAIN, unique_id="aa:bb:cc:dd:ee:ff", @@ -41,7 +79,7 @@ async def test_sensors_bad_signal(hass: HomeAssistant) -> None: tank_sensor = hass.states.get("sensor.pro_plus_eeff_tank_level") tank_sensor_attrs = tank_sensor.attributes - assert tank_sensor.state == STATE_UNKNOWN + assert tank_sensor.state == "0" assert tank_sensor_attrs[ATTR_FRIENDLY_NAME] == "Pro Plus EEFF Tank Level" assert tank_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == UnitOfLength.MILLIMETERS assert tank_sensor_attrs[ATTR_STATE_CLASS] == "measurement" From 9575cd91614feef9cbb4a046c893569bd0e434c8 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 23 Feb 2023 20:52:53 -0500 Subject: [PATCH 0351/2810] 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 554a94b845..05dc67314e 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 090e171835..c3aefe5987 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 0f7363bb01..d955645199 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -15,6 +15,7 @@ import zigpy.types from homeassistant import config_entries from homeassistant.components import ssdp, usb, zeroconf +from homeassistant.components.hassio import AddonState from homeassistant.components.ssdp import ATTR_UPNP_MANUFACTURER_URL, ATTR_UPNP_SERIAL from homeassistant.components.zha import config_flow, radio_manager from homeassistant.components.zha.core.const import ( @@ -1840,3 +1841,46 @@ async def test_options_flow_migration_reset_old_adapter( user_input={}, ) assert result4["step_id"] == "choose_serial_port" + + +async def test_config_flow_port_yellow_port_name(hass: HomeAssistant) -> None: + """Test config flow serial port name for Yellow Zigbee radio.""" + port = com_port(device="/dev/ttyAMA1") + port.serial_number = None + port.manufacturer = None + port.description = None + + with patch( + "homeassistant.components.zha.config_flow.yellow_hardware.async_info" + ), patch("serial.tools.list_ports.comports", MagicMock(return_value=[port])): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + + assert ( + result["data_schema"].schema["path"].container[0] + == "/dev/ttyAMA1 - Yellow Zigbee module - Nabu Casa" + ) + + +async def test_config_flow_port_multiprotocol_port_name(hass: HomeAssistant) -> None: + """Test config flow serial port name for multiprotocol add-on.""" + + with patch( + "homeassistant.components.hassio.addon_manager.AddonManager.async_get_addon_info" + ) as async_get_addon_info, patch( + "serial.tools.list_ports.comports", MagicMock(return_value=[]) + ): + async_get_addon_info.return_value.state = AddonState.RUNNING + async_get_addon_info.return_value.hostname = "core-silabs-multiprotocol" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + + assert ( + result["data_schema"].schema["path"].container[0] + == "socket://core-silabs-multiprotocol:9999 - Multiprotocol add-on - Nabu Casa" + ) From af49b98475a11c23b8c5c6dd6fcb3159c234a6e0 Mon Sep 17 00:00:00 2001 From: David Poll Date: Thu, 23 Feb 2023 19:14:28 -0800 Subject: [PATCH 0352/2810] Enable jinja loop controls (break/continue) (#88625) Enables jinja loop controls (break/continue) --- homeassistant/helpers/template.py | 1 + tests/helpers/test_template.py | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 9aafe53925..2e112706fb 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -2063,6 +2063,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.template_cache: weakref.WeakValueDictionary[ str | jinja2.nodes.Template, CodeType | str | None ] = weakref.WeakValueDictionary() + self.add_extension("jinja2.ext.loopcontrols") self.filters["round"] = forgiving_round self.filters["multiply"] = multiply self.filters["log"] = logarithm diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index ec6714bafe..2434b2aed1 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -243,6 +243,26 @@ def test_iterating_domain_states(hass: HomeAssistant) -> None: ) +def test_loop_controls(hass: HomeAssistant) -> None: + """Test that loop controls are enabled.""" + assert ( + template.Template( + """ + {%- for v in range(10) %} + {%- if v == 1 -%} + {%- continue -%} + {%- elif v == 3 -%} + {%- break -%} + {%- endif -%} + {{ v }} + {%- endfor -%} + """, + hass, + ).async_render() + == "02" + ) + + def test_float_function(hass: HomeAssistant) -> None: """Test float function.""" hass.states.async_set("sensor.temperature", "12") From 2f826a6f86add77c43b3b738daafe7a46e551093 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 24 Feb 2023 04:15:20 +0100 Subject: [PATCH 0353/2810] Modernize uptime tests (#88636) * Modernize uptime tests * Fix tests --- .../uptime/snapshots/test_config_flow.ambr | 34 ++++++++ .../uptime/snapshots/test_sensor.ambr | 85 +++++++++++++++++++ tests/components/uptime/test_config_flow.py | 10 ++- tests/components/uptime/test_sensor.py | 31 ++++--- 4 files changed, 140 insertions(+), 20 deletions(-) create mode 100644 tests/components/uptime/snapshots/test_config_flow.ambr create mode 100644 tests/components/uptime/snapshots/test_sensor.ambr diff --git a/tests/components/uptime/snapshots/test_config_flow.ambr b/tests/components/uptime/snapshots/test_config_flow.ambr new file mode 100644 index 0000000000..ac4b739683 --- /dev/null +++ b/tests/components/uptime/snapshots/test_config_flow.ambr @@ -0,0 +1,34 @@ +# serializer version: 1 +# name: test_full_user_flow + FlowResultSnapshot({ + 'context': dict({ + 'source': 'user', + }), + 'data': dict({ + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'uptime', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + }), + 'disabled_by': None, + 'domain': 'uptime', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Uptime', + 'unique_id': None, + 'version': 1, + }), + 'title': 'Uptime', + 'type': , + 'version': 1, + }) +# --- diff --git a/tests/components/uptime/snapshots/test_sensor.ambr b/tests/components/uptime/snapshots/test_sensor.ambr new file mode 100644 index 0000000000..539ba640d8 --- /dev/null +++ b/tests/components/uptime/snapshots/test_sensor.ambr @@ -0,0 +1,85 @@ +# serializer version: 1 +# name: test_uptime_sensor + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Uptime', + }), + 'context': , + 'entity_id': 'sensor.uptime', + 'last_changed': , + 'last_updated': , + 'state': '2022-03-01T00:00:00+00:00', + }) +# --- +# name: test_uptime_sensor.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'uptime', + 'supported_features': 0, + 'translation_key': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_uptime_sensor.2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'is_new': False, + 'manufacturer': None, + 'model': None, + 'name': 'Uptime', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_uptime_sensor.3 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'is_new': False, + 'manufacturer': None, + 'model': None, + 'name': 'Uptime', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/uptime/test_config_flow.py b/tests/components/uptime/test_config_flow.py index 4a7bb11b83..9f1c3931d1 100644 --- a/tests/components/uptime/test_config_flow.py +++ b/tests/components/uptime/test_config_flow.py @@ -1,5 +1,7 @@ """Tests for the Uptime config flow.""" -from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.uptime.const import DOMAIN from homeassistant.config_entries import SOURCE_USER @@ -9,9 +11,10 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry +@pytest.mark.usefixtures("mock_setup_entry") async def test_full_user_flow( hass: HomeAssistant, - mock_setup_entry: MagicMock, + snapshot: SnapshotAssertion, ) -> None: """Test the full user configuration flow.""" result = await hass.config_entries.flow.async_init( @@ -27,8 +30,7 @@ async def test_full_user_flow( ) assert result2.get("type") == FlowResultType.CREATE_ENTRY - assert result2.get("title") == "Uptime" - assert result2.get("data") == {} + assert result2 == snapshot async def test_single_instance_allowed( diff --git a/tests/components/uptime/test_sensor.py b/tests/components/uptime/test_sensor.py index 053224c3b4..41c097badd 100644 --- a/tests/components/uptime/test_sensor.py +++ b/tests/components/uptime/test_sensor.py @@ -1,36 +1,35 @@ """The tests for the uptime sensor platform.""" import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props -from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.uptime.const import DOMAIN -from homeassistant.const import ATTR_DEVICE_CLASS from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry +@pytest.mark.usefixtures("init_integration") @pytest.mark.freeze_time("2022-03-01 00:00:00+00:00") async def test_uptime_sensor( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test Uptime sensor.""" - state = hass.states.get("sensor.uptime") - assert state + + assert (state := hass.states.get("sensor.uptime")) assert state.state == "2022-03-01T00:00:00+00:00" - assert state.attributes["friendly_name"] == "Uptime" - assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TIMESTAMP + assert state == snapshot - entity_registry = er.async_get(hass) - entry = entity_registry.async_get("sensor.uptime") - assert entry - assert entry.unique_id == init_integration.entry_id + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert entity_entry == snapshot(exclude=props("unique_id")) + assert entity_entry.unique_id == init_integration.entry_id - device_registry = dr.async_get(hass) - assert entry.device_id - device_entry = device_registry.async_get(entry.device_id) - assert device_entry + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry == snapshot(exclude=props("identifiers")) assert device_entry.identifiers == {(DOMAIN, init_integration.entry_id)} - assert device_entry.name == init_integration.title - assert device_entry.entry_type == dr.DeviceEntryType.SERVICE From d90ee8511878cb63d3b345940724147a74e28168 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 24 Feb 2023 04:30:51 +0100 Subject: [PATCH 0354/2810] Allow conditions to be implemented in platforms (#88509) * Allow conditions to be implemented in platforms * Update tests * Tweak typing * Rebase fixes --- .../components/device_automation/condition.py | 20 +--- homeassistant/helpers/condition.py | 93 ++++++++++++++----- tests/helpers/test_script.py | 16 +--- 3 files changed, 77 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/device_automation/condition.py b/homeassistant/components/device_automation/condition.py index 3856458c3d..f819668f09 100644 --- a/homeassistant/components/device_automation/condition.py +++ b/homeassistant/components/device_automation/condition.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant.const import CONF_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.condition import ConditionProtocol, trace_condition_function from homeassistant.helpers.typing import ConfigType from . import DeviceAutomationType, async_get_device_automation_platform @@ -17,24 +18,13 @@ if TYPE_CHECKING: from homeassistant.helpers import condition -class DeviceAutomationConditionProtocol(Protocol): +class DeviceAutomationConditionProtocol(ConditionProtocol, Protocol): """Define the format of device_condition modules. - Each module must define either CONDITION_SCHEMA or async_validate_condition_config. + Each module must define either CONDITION_SCHEMA or async_validate_condition_config + from ConditionProtocol. """ - CONDITION_SCHEMA: vol.Schema - - async def async_validate_condition_config( - self, hass: HomeAssistant, config: ConfigType - ) -> ConfigType: - """Validate config.""" - - def async_condition_from_config( - self, hass: HomeAssistant, config: ConfigType - ) -> condition.ConditionCheckerType: - """Evaluate state based on configuration.""" - async def async_get_condition_capabilities( self, hass: HomeAssistant, config: ConfigType ) -> dict[str, vol.Schema]: @@ -62,4 +52,4 @@ async def async_condition_from_config( platform = await async_get_device_automation_platform( hass, config[CONF_DOMAIN], DeviceAutomationType.CONDITION ) - return platform.async_condition_from_config(hass, config) + return trace_condition_function(platform.async_condition_from_config(hass, config)) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 7513e2b008..0029a9c906 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -7,15 +7,13 @@ from collections.abc import Callable, Container, Generator from contextlib import contextmanager from datetime import datetime, time as dt_time, timedelta import functools as ft -import logging import re import sys -from typing import Any, cast +from typing import Any, Protocol, cast import voluptuous as vol from homeassistant.components import zone as zone_cmp -from homeassistant.components.device_automation import condition as device_condition from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -55,6 +53,7 @@ from homeassistant.exceptions import ( HomeAssistantError, TemplateError, ) +from homeassistant.loader import IntegrationNotFound, async_get_integration from homeassistant.util.async_ import run_callback_threadsafe import homeassistant.util.dt as dt_util @@ -77,12 +76,44 @@ ASYNC_FROM_CONFIG_FORMAT = "async_{}_from_config" FROM_CONFIG_FORMAT = "{}_from_config" VALIDATE_CONFIG_FORMAT = "{}_validate_config" -_LOGGER = logging.getLogger(__name__) +_PLATFORM_ALIASES = { + "and": None, + "device": "device_automation", + "not": None, + "numeric_state": None, + "or": None, + "state": None, + "sun": None, + "template": None, + "time": None, + "trigger": None, + "zone": None, +} INPUT_ENTITY_ID = re.compile( r"^input_(?:select|text|number|boolean|datetime)\.(?!.+__)(?!_)[\da-z_]+(? ConfigType: + """Validate config.""" + + def async_condition_from_config( + self, hass: HomeAssistant, config: ConfigType + ) -> ConditionCheckerType: + """Evaluate state based on configuration.""" + + ConditionCheckerType = Callable[[HomeAssistant, TemplateVarsType], bool | None] @@ -152,6 +183,27 @@ def trace_condition_function(condition: ConditionCheckerType) -> ConditionChecke return wrapper +async def _async_get_condition_platform( + hass: HomeAssistant, config: ConfigType +) -> ConditionProtocol | None: + platform = config[CONF_CONDITION] + platform = _PLATFORM_ALIASES.get(platform, platform) + if platform is None: + return None + try: + integration = await async_get_integration(hass, platform) + except IntegrationNotFound: + raise HomeAssistantError( + f'Invalid condition "{platform}" specified {config}' + ) from None + try: + return integration.get_platform("condition") + except ImportError: + raise HomeAssistantError( + f"Integration '{platform}' does not provide condition support" + ) from None + + async def async_from_config( hass: HomeAssistant, config: ConfigType, @@ -160,15 +212,18 @@ async def async_from_config( Should be run on the event loop. """ - condition = config.get(CONF_CONDITION) - for fmt in (ASYNC_FROM_CONFIG_FORMAT, FROM_CONFIG_FORMAT): - factory = getattr(sys.modules[__name__], fmt.format(condition), None) + factory: Any = None + platform = await _async_get_condition_platform(hass, config) - if factory: - break + if platform is None: + condition = config.get(CONF_CONDITION) + for fmt in (ASYNC_FROM_CONFIG_FORMAT, FROM_CONFIG_FORMAT): + factory = getattr(sys.modules[__name__], fmt.format(condition), None) - if factory is None: - raise HomeAssistantError(f'Invalid condition "{condition}" specified {config}') + if factory: + break + else: + factory = platform.async_condition_from_config # Check if condition is not enabled if not config.get(CONF_ENABLED, True): @@ -928,14 +983,6 @@ def zone_from_config(config: ConfigType) -> ConditionCheckerType: return if_in_zone -async def async_device_from_config( - hass: HomeAssistant, config: ConfigType -) -> ConditionCheckerType: - """Test a device condition.""" - checker = await device_condition.async_condition_from_config(hass, config) - return trace_condition_function(checker) - - async def async_trigger_from_config( hass: HomeAssistant, config: ConfigType ) -> ConditionCheckerType: @@ -991,10 +1038,10 @@ async def async_validate_condition_config( config["conditions"] = conditions return config - if condition == "device": - return await device_condition.async_validate_condition_config(hass, config) - - if condition in ("numeric_state", "state"): + platform = await _async_get_condition_platform(hass, config) + if platform is not None and hasattr(platform, "async_validate_condition_config"): + return await platform.async_validate_condition_config(hass, config) + if platform is None and condition in ("numeric_state", "state"): validator = cast( Callable[[HomeAssistant, ConfigType], ConfigType], getattr(sys.modules[__name__], VALIDATE_CONFIG_FORMAT.format(condition)), diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 5328a1d38e..0521bc722c 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -2406,13 +2406,7 @@ async def test_repeat_var_in_condition(hass: HomeAssistant, condition) -> None: script_obj = script.Script( hass, cv.SCRIPT_SCHEMA(sequence), "Test Name", "test_domain" ) - - with mock.patch( - "homeassistant.helpers.condition._LOGGER.error", - side_effect=AssertionError("Template Error"), - ): - await script_obj.async_run(context=Context()) - + await script_obj.async_run(context=Context()) assert len(events) == 2 if condition == "while": @@ -2545,13 +2539,7 @@ async def test_repeat_nested( ] ) script_obj = script.Script(hass, sequence, "Test Name", "test_domain") - - with mock.patch( - "homeassistant.helpers.condition._LOGGER.error", - side_effect=AssertionError("Template Error"), - ): - await script_obj.async_run(variables, Context()) - + await script_obj.async_run(variables, Context()) assert len(events) == 10 assert events[0].data == first_last assert events[-1].data == first_last From 0ae2fdc08b82ff424fbee606cd3c158e6273c66a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Feb 2023 22:00:08 -0600 Subject: [PATCH 0355/2810] Switch samsungtv to use async_timeout to avoid task creation (#88679) wait_for creates a task, async_timeout does the same work and avoids the task creation --- homeassistant/components/samsungtv/media_player.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 3e544b181f..59b4131af1 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -6,6 +6,7 @@ from collections.abc import Coroutine, Sequence from datetime import datetime, timedelta from typing import Any +import async_timeout from async_upnp_client.aiohttp import AiohttpNotifyServer, AiohttpSessionRequester from async_upnp_client.client import UpnpDevice, UpnpService, UpnpStateVariable from async_upnp_client.client_factory import UpnpFactory @@ -250,7 +251,8 @@ class SamsungTVDevice(MediaPlayerEntity): # enter it unless we have to (Python 3.11 will have zero cost try) return try: - await asyncio.wait_for(self._app_list_event.wait(), APP_LIST_DELAY) + async with async_timeout.timeout(APP_LIST_DELAY): + await self._app_list_event.wait() except asyncio.TimeoutError as err: # No need to try again self._app_list_event.set() From 84823d2fcfa7f5d7e56d9259144b46061c051d69 Mon Sep 17 00:00:00 2001 From: stickpin <630000+stickpin@users.noreply.github.com> Date: Fri, 24 Feb 2023 07:32:59 +0100 Subject: [PATCH 0356/2810] 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 5008325d5e..e44251ed7c 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 b9106e5f3e..56dca59e96 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 5dc78de279..e734c5f110 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -405,7 +405,7 @@ bthome-ble==2.5.2 buienradar==1.0.5 # homeassistant.components.caldav -caldav==1.1.1 +caldav==1.1.3 # homeassistant.components.co2signal co2signal==0.4.2 From ee8f7468083dc3d08341cb1a892b7ecb7dbeb85e Mon Sep 17 00:00:00 2001 From: Thomas Dietrich Date: Fri, 24 Feb 2023 14:11:40 +0100 Subject: [PATCH 0357/2810] Change statistics component ownership (#88692) --- CODEOWNERS | 4 ++-- homeassistant/components/statistics/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index cb559a7d7b..94360a4f45 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1138,8 +1138,8 @@ build.json @home-assistant/supervisor /tests/components/starline/ @anonym-tsk /homeassistant/components/starlink/ @boswelja /tests/components/starlink/ @boswelja -/homeassistant/components/statistics/ @fabaff @ThomDietrich -/tests/components/statistics/ @fabaff @ThomDietrich +/homeassistant/components/statistics/ @ThomDietrich +/tests/components/statistics/ @ThomDietrich /homeassistant/components/steam_online/ @tkdrob /tests/components/steam_online/ @tkdrob /homeassistant/components/steamist/ @bdraco diff --git a/homeassistant/components/statistics/manifest.json b/homeassistant/components/statistics/manifest.json index 6a41dec447..04b5277ecf 100644 --- a/homeassistant/components/statistics/manifest.json +++ b/homeassistant/components/statistics/manifest.json @@ -2,7 +2,7 @@ "domain": "statistics", "name": "Statistics", "after_dependencies": ["recorder"], - "codeowners": ["@fabaff", "@ThomDietrich"], + "codeowners": ["@ThomDietrich"], "documentation": "https://www.home-assistant.io/integrations/statistics", "iot_class": "local_polling", "quality_scale": "internal" From 753c790a250013fddf3d23978dab3638fb216447 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 24 Feb 2023 14:13:03 +0100 Subject: [PATCH 0358/2810] Use async_timeout in integrations (#88697) --- homeassistant/components/hlk_sw16/config_flow.py | 4 +++- homeassistant/components/opentherm_gw/__init__.py | 7 +++---- homeassistant/components/opentherm_gw/config_flow.py | 7 +++---- homeassistant/components/ping/binary_sensor.py | 6 +++--- homeassistant/components/squeezebox/config_flow.py | 4 +++- homeassistant/components/upnp/__init__.py | 4 +++- 6 files changed, 18 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/hlk_sw16/config_flow.py b/homeassistant/components/hlk_sw16/config_flow.py index 8338947260..4920e1542d 100644 --- a/homeassistant/components/hlk_sw16/config_flow.py +++ b/homeassistant/components/hlk_sw16/config_flow.py @@ -1,6 +1,7 @@ """Config flow for HLK-SW16.""" import asyncio +import async_timeout from hlk_sw16 import create_hlk_sw16_connection import voluptuous as vol @@ -35,7 +36,8 @@ async def connect_client(hass, user_input): reconnect_interval=DEFAULT_RECONNECT_INTERVAL, keep_alive_interval=DEFAULT_KEEP_ALIVE_INTERVAL, ) - return await asyncio.wait_for(client_aw, timeout=CONNECTION_TIMEOUT) + async with async_timeout.timeout(CONNECTION_TIMEOUT): + return await client_aw async def validate_input(hass: HomeAssistant, user_input): diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index 51071c9a0a..aebf1e26c3 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -3,6 +3,7 @@ import asyncio from datetime import date, datetime import logging +import async_timeout import pyotgw import pyotgw.vars as gw_vars from serial import SerialException @@ -112,10 +113,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b config_entry.add_update_listener(options_updated) try: - await asyncio.wait_for( - gateway.connect_and_subscribe(), - timeout=CONNECTION_TIMEOUT, - ) + async with async_timeout.timeout(CONNECTION_TIMEOUT): + await gateway.connect_and_subscribe() except (asyncio.TimeoutError, ConnectionError, SerialException) as ex: await gateway.cleanup() raise ConfigEntryNotReady( diff --git a/homeassistant/components/opentherm_gw/config_flow.py b/homeassistant/components/opentherm_gw/config_flow.py index ed9b62ff49..87a5102165 100644 --- a/homeassistant/components/opentherm_gw/config_flow.py +++ b/homeassistant/components/opentherm_gw/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import async_timeout import pyotgw from pyotgw import vars as gw_vars from serial import SerialException @@ -68,10 +69,8 @@ class OpenThermGwConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return status[gw_vars.OTGW].get(gw_vars.OTGW_ABOUT) try: - await asyncio.wait_for( - test_connection(), - timeout=CONNECTION_TIMEOUT, - ) + async with async_timeout.timeout(CONNECTION_TIMEOUT): + await test_connection() except asyncio.TimeoutError: return self._show_form({"base": "timeout_connect"}) except (ConnectionError, SerialException): diff --git a/homeassistant/components/ping/binary_sensor.py b/homeassistant/components/ping/binary_sensor.py index 7500d9988a..c8b4ce5a20 100644 --- a/homeassistant/components/ping/binary_sensor.py +++ b/homeassistant/components/ping/binary_sensor.py @@ -8,6 +8,7 @@ import logging import re from typing import Any +import async_timeout from icmplib import NameLookupError, async_ping import voluptuous as vol @@ -230,9 +231,8 @@ class PingDataSubProcess(PingData): close_fds=False, # required for posix_spawn ) try: - out_data, out_error = await asyncio.wait_for( - pinger.communicate(), self._count + PING_TIMEOUT - ) + async with async_timeout.timeout(self._count + PING_TIMEOUT): + out_data, out_error = await pinger.communicate() if out_data: _LOGGER.debug( diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py index 1411b8bc78..bb175ee00b 100644 --- a/homeassistant/components/squeezebox/config_flow.py +++ b/homeassistant/components/squeezebox/config_flow.py @@ -4,6 +4,7 @@ from http import HTTPStatus import logging from typing import TYPE_CHECKING +import async_timeout from pysqueezebox import Server, async_discover import voluptuous as vol @@ -130,7 +131,8 @@ class SqueezeboxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # no host specified, see if we can discover an unconfigured LMS server try: - await asyncio.wait_for(self._discover(), timeout=TIMEOUT) + async with async_timeout.timeout(TIMEOUT): + await self._discover() return await self.async_step_edit() except asyncio.TimeoutError: errors["base"] = "no_server_found" diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index ac9fe19f4e..7ddec4e3fb 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from datetime import timedelta +import async_timeout from async_upnp_client.exceptions import UpnpConnectionError from homeassistant.components import ssdp @@ -70,7 +71,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) try: - await asyncio.wait_for(device_discovered_event.wait(), timeout=10) + async with async_timeout.timeout(10): + await device_discovered_event.wait() except asyncio.TimeoutError as err: raise ConfigEntryNotReady(f"Device not discovered: {usn}") from err finally: From ba929dfc79b32f4bd7aad1711224eb4385ad7745 Mon Sep 17 00:00:00 2001 From: StefanIacobLivisi <109964424+StefanIacobLivisi@users.noreply.github.com> Date: Fri, 24 Feb 2023 16:22:30 +0200 Subject: [PATCH 0359/2810] Bump aiolivisi to 0.0.16 (#88700) Increment aiolivisi library version --- homeassistant/components/livisi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/livisi/manifest.json b/homeassistant/components/livisi/manifest.json index 849cfdad5c..6cdebeb307 100644 --- a/homeassistant/components/livisi/manifest.json +++ b/homeassistant/components/livisi/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/livisi", "iot_class": "local_polling", - "requirements": ["aiolivisi==0.0.15"] + "requirements": ["aiolivisi==0.0.16"] } diff --git a/requirements_all.txt b/requirements_all.txt index 56dca59e96..ea58bc384d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -202,7 +202,7 @@ aiolifx_effects==0.3.1 aiolifx_themes==0.4.0 # homeassistant.components.livisi -aiolivisi==0.0.15 +aiolivisi==0.0.16 # homeassistant.components.lookin aiolookin==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e734c5f110..6ced08f7fc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -183,7 +183,7 @@ aiolifx_effects==0.3.1 aiolifx_themes==0.4.0 # homeassistant.components.livisi -aiolivisi==0.0.15 +aiolivisi==0.0.16 # homeassistant.components.lookin aiolookin==1.0.0 From fdc06c2fc2741232418be9454c66ad0e3094d4bb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 24 Feb 2023 16:54:02 +0100 Subject: [PATCH 0360/2810] Improve type hint in webostv trigger (#88599) Improve type hint in webostv trigger --- homeassistant/components/webostv/trigger.py | 20 +++++++++---------- .../components/webostv/triggers/__init__.py | 11 ---------- 2 files changed, 9 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/webostv/trigger.py b/homeassistant/components/webostv/trigger.py index 5441917cc3..4d237993f9 100644 --- a/homeassistant/components/webostv/trigger.py +++ b/homeassistant/components/webostv/trigger.py @@ -5,24 +5,28 @@ from typing import cast from homeassistant.const import CONF_PLATFORM from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.trigger import ( + TriggerActionType, + TriggerInfo, + TriggerProtocol, +) from homeassistant.helpers.typing import ConfigType -from .triggers import TriggersPlatformModule, turn_on +from .triggers import turn_on TRIGGERS = { "turn_on": turn_on, } -def _get_trigger_platform(config: ConfigType) -> TriggersPlatformModule: +def _get_trigger_platform(config: ConfigType) -> TriggerProtocol: """Return trigger platform.""" platform_split = config[CONF_PLATFORM].split(".", maxsplit=1) if len(platform_split) < 2 or platform_split[1] not in TRIGGERS: raise ValueError( f"Unknown webOS Smart TV trigger platform {config[CONF_PLATFORM]}" ) - return cast(TriggersPlatformModule, TRIGGERS[platform_split[1]]) + return cast(TriggerProtocol, TRIGGERS[platform_split[1]]) async def async_validate_trigger_config( @@ -41,10 +45,4 @@ async def async_attach_trigger( ) -> CALLBACK_TYPE: """Attach trigger of specified platform.""" platform = _get_trigger_platform(config) - assert hasattr(platform, "async_attach_trigger") - return cast( - CALLBACK_TYPE, - await getattr(platform, "async_attach_trigger")( - hass, config, action, trigger_info - ), - ) + return await platform.async_attach_trigger(hass, config, action, trigger_info) diff --git a/homeassistant/components/webostv/triggers/__init__.py b/homeassistant/components/webostv/triggers/__init__.py index 710caffef7..d8c5a28ef3 100644 --- a/homeassistant/components/webostv/triggers/__init__.py +++ b/homeassistant/components/webostv/triggers/__init__.py @@ -1,12 +1 @@ """webOS Smart TV triggers.""" -from __future__ import annotations - -from typing import Protocol - -import voluptuous as vol - - -class TriggersPlatformModule(Protocol): - """Protocol type for the triggers platform.""" - - TRIGGER_SCHEMA: vol.Schema From ee7dfdae30e055ff728fbb8621bd3ff98853cf4d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Feb 2023 10:09:12 -0600 Subject: [PATCH 0361/2810] 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 94b111ab42..fde8c26ba5 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 ea58bc384d..032305ac46 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 6ced08f7fc..867d89c6b7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -143,7 +143,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.4.0 +aioesphomeapi==13.4.1 # homeassistant.components.flo aioflo==2021.11.0 From e69091c6db561d84b6b8d9b45dace7e8fee25141 Mon Sep 17 00:00:00 2001 From: Jon Caruana Date: Fri, 24 Feb 2023 08:51:48 -0800 Subject: [PATCH 0362/2810] Use strict typing for LiteJet integration (#88629) * Strict typing for LiteJet. * Add test for new check. * PR feedback. * PR feedback. --- .strict-typing | 1 + homeassistant/components/litejet/__init__.py | 4 ++-- homeassistant/components/litejet/config_flow.py | 2 +- homeassistant/components/litejet/trigger.py | 16 ++++++++++------ mypy.ini | 10 ++++++++++ tests/components/litejet/test_trigger.py | 11 +++++++++++ 6 files changed, 35 insertions(+), 9 deletions(-) diff --git a/.strict-typing b/.strict-typing index 13fd49391e..b33eab5bd6 100644 --- a/.strict-typing +++ b/.strict-typing @@ -186,6 +186,7 @@ homeassistant.components.ld2410_ble.* homeassistant.components.lidarr.* homeassistant.components.lifx.* homeassistant.components.light.* +homeassistant.components.litejet.* homeassistant.components.litterrobot.* homeassistant.components.local_ip.* homeassistant.components.lock.* diff --git a/homeassistant/components/litejet/__init__.py b/homeassistant/components/litejet/__init__.py index 040b8688a4..291333d0b7 100644 --- a/homeassistant/components/litejet/__init__.py +++ b/homeassistant/components/litejet/__init__.py @@ -6,7 +6,7 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -63,7 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: system.on_connected_changed(handle_connected_changed) - async def handle_stop(event) -> None: + async def handle_stop(event: Event) -> None: await system.close() entry.async_on_unload( diff --git a/homeassistant/components/litejet/config_flow.py b/homeassistant/components/litejet/config_flow.py index 25d454071c..c469d480ca 100644 --- a/homeassistant/components/litejet/config_flow.py +++ b/homeassistant/components/litejet/config_flow.py @@ -76,7 +76,7 @@ class LiteJetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_import(self, import_data): + async def async_step_import(self, import_data: dict[str, Any]) -> FlowResult: """Import litejet config from configuration.yaml.""" return self.async_create_entry(title=import_data[CONF_PORT], data=import_data) diff --git a/homeassistant/components/litejet/trigger.py b/homeassistant/components/litejet/trigger.py index 926ed69637..df5ffac9b9 100644 --- a/homeassistant/components/litejet/trigger.py +++ b/homeassistant/components/litejet/trigger.py @@ -2,6 +2,8 @@ from __future__ import annotations from collections.abc import Callable +from datetime import datetime +from typing import cast from pylitejet import LiteJet import voluptuous as vol @@ -42,7 +44,7 @@ async def async_attach_trigger( ) -> CALLBACK_TYPE: """Listen for events based on configuration.""" trigger_data = trigger_info["trigger_data"] - number = config.get(CONF_NUMBER) + number = cast(int, config[CONF_NUMBER]) held_more_than = config.get(CONF_HELD_MORE_THAN) held_less_than = config.get(CONF_HELD_LESS_THAN) pressed_time = None @@ -50,7 +52,7 @@ async def async_attach_trigger( job = HassJob(action) @callback - def call_action(): + def call_action() -> None: """Call action with right context.""" hass.async_run_hass_job( job, @@ -72,11 +74,11 @@ async def async_attach_trigger( # neither: trigger on pressed @callback - def pressed_more_than_satisfied(now): + def pressed_more_than_satisfied(now: datetime) -> None: """Handle the LiteJet's switch's button pressed >= held_more_than.""" call_action() - def pressed(): + def pressed() -> None: """Handle the press of the LiteJet switch's button.""" nonlocal cancel_pressed_more_than, pressed_time nonlocal held_less_than, held_more_than @@ -88,10 +90,12 @@ async def async_attach_trigger( hass, pressed_more_than_satisfied, dt_util.utcnow() + held_more_than ) - def released(): + def released() -> None: """Handle the release of the LiteJet switch's button.""" nonlocal cancel_pressed_more_than, pressed_time nonlocal held_less_than, held_more_than + if pressed_time is None: + return if cancel_pressed_more_than is not None: cancel_pressed_more_than() cancel_pressed_more_than = None @@ -110,7 +114,7 @@ async def async_attach_trigger( system.on_switch_released(number, released) @callback - def async_remove(): + def async_remove() -> None: """Remove all subscriptions used for this trigger.""" system.unsubscribe(pressed) system.unsubscribe(released) diff --git a/mypy.ini b/mypy.ini index db5df3a5b4..6d32c16b96 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1622,6 +1622,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.litejet.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.litterrobot.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/litejet/test_trigger.py b/tests/components/litejet/test_trigger.py index 41c4af23c9..40511c3f45 100644 --- a/tests/components/litejet/test_trigger.py +++ b/tests/components/litejet/test_trigger.py @@ -107,6 +107,17 @@ async def test_simple(hass: HomeAssistant, calls, mock_litejet) -> None: assert calls[0].data["id"] == 0 +async def test_only_release(hass: HomeAssistant, calls, mock_litejet) -> None: + """Test the simplest form of a LiteJet trigger.""" + await setup_automation( + hass, {"platform": "litejet", "number": ENTITY_OTHER_SWITCH_NUMBER} + ) + + await simulate_release(hass, mock_litejet, ENTITY_OTHER_SWITCH_NUMBER) + + assert len(calls) == 0 + + async def test_held_more_than_short(hass: HomeAssistant, calls, mock_litejet) -> None: """Test a too short hold.""" await setup_automation( From 69a3738bdb984a0d564d3846e0ec319074408032 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Feb 2023 11:41:44 -0600 Subject: [PATCH 0363/2810] 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 a3a609a1b6..431bc78ba8 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 5cda3d283d..3ff6b62b21 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -125,7 +125,7 @@ def session_scope( need_rollback = True session.commit() except Exception as err: # pylint: disable=broad-except - _LOGGER.error("Error executing query: %s", err) + _LOGGER.error("Error executing query: %s", err, exc_info=True) if need_rollback: session.rollback() if not exception_filter or not exception_filter(err): From 7b2e743a6b0ce8dce5595eb54d089c3142cc6d94 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Feb 2023 13:33:25 -0600 Subject: [PATCH 0364/2810] 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 85598815c2..ef48a5988a 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 08768e8bac..ce911183df 100644 --- a/tests/components/purpleair/test_config_flow.py +++ b/tests/components/purpleair/test_config_flow.py @@ -123,7 +123,7 @@ async def test_duplicate_error( ) async def test_reauth( hass: HomeAssistant, - api, + mock_aiopurpleair, check_api_key_errors, check_api_key_mock, config_entry, @@ -143,7 +143,7 @@ async def test_reauth( assert result["step_id"] == "reauth_confirm" # Test errors that can arise when checking the API key: - with patch.object(api, "async_check_api_key", check_api_key_mock): + with patch.object(mock_aiopurpleair, "async_check_api_key", check_api_key_mock): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"api_key": "new_api_key"} ) @@ -157,6 +157,9 @@ async def test_reauth( assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "reauth_successful" assert len(hass.config_entries.async_entries()) == 1 + # Unload to make sure the update does not run after the + # mock is removed. + await hass.config_entries.async_unload(config_entry.entry_id) @pytest.mark.parametrize( @@ -169,7 +172,7 @@ async def test_reauth( ) async def test_options_add_sensor( hass: HomeAssistant, - api, + mock_aiopurpleair, config_entry, get_nearby_sensors_errors, get_nearby_sensors_mock, @@ -187,7 +190,9 @@ async def test_options_add_sensor( assert result["step_id"] == "add_sensor" # Test errors that can arise when searching for nearby sensors: - with patch.object(api.sensors, "async_get_nearby_sensors", get_nearby_sensors_mock): + with patch.object( + mock_aiopurpleair.sensors, "async_get_nearby_sensors", get_nearby_sensors_mock + ): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ @@ -225,6 +230,9 @@ async def test_options_add_sensor( TEST_SENSOR_INDEX1, TEST_SENSOR_INDEX2, ] + # Unload to make sure the update does not run after the + # mock is removed. + await hass.config_entries.async_unload(config_entry.entry_id) async def test_options_add_sensor_duplicate( @@ -260,6 +268,9 @@ async def test_options_add_sensor_duplicate( ) assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "already_configured" + # Unload to make sure the update does not run after the + # mock is removed. + await hass.config_entries.async_unload(config_entry.entry_id) async def test_options_remove_sensor( @@ -288,3 +299,6 @@ async def test_options_remove_sensor( } assert config_entry.options["sensor_indices"] == [] + # Unload to make sure the update does not run after the + # mock is removed. + await hass.config_entries.async_unload(config_entry.entry_id) From 0223058d25815abf634b20b9f95a8b66044227a7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Feb 2023 20:37:36 -0600 Subject: [PATCH 0365/2810] 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 38afa37838..c15436ed2c 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -83,38 +83,28 @@ def json_bytes(data: Any) -> bytes: ) +def _strip_null(obj: Any) -> Any: + """Strip NUL from an object.""" + if isinstance(obj, str): + return obj.split("\0", 1)[0] + if isinstance(obj, dict): + return {key: _strip_null(o) for key, o in obj.items()} + if isinstance(obj, list): + return [_strip_null(o) for o in obj] + return obj + + def json_bytes_strip_null(data: Any) -> bytes: """Dump json bytes after terminating strings at the first NUL.""" - - def process_dict(_dict: dict[Any, Any]) -> dict[Any, Any]: - """Strip NUL from items in a dict.""" - return {key: strip_null(o) for key, o in _dict.items()} - - def process_list(_list: list[Any]) -> list[Any]: - """Strip NUL from items in a list.""" - return [strip_null(o) for o in _list] - - def strip_null(obj: Any) -> Any: - """Strip NUL from an object.""" - if isinstance(obj, str): - return obj.split("\0", 1)[0] - if isinstance(obj, dict): - return process_dict(obj) - if isinstance(obj, list): - return process_list(obj) - return obj - # We expect null-characters to be very rare, hence try encoding first and look # for an escaped null-character in the output. result = json_bytes(data) - if b"\\u0000" in result: - # We work on the processed result so we don't need to worry about - # Home Assistant extensions which allows encoding sets, tuples, etc. - data_processed = orjson.loads(result) - data_processed = strip_null(data_processed) - result = json_bytes(data_processed) + if b"\\u0000" not in result: + return result - return result + # We work on the processed result so we don't need to worry about + # Home Assistant extensions which allows encoding sets, tuples, etc. + return json_bytes(_strip_null(orjson.loads(result))) def json_dumps(data: Any) -> str: From a60fd18386936ca72434a90679f20528c0a0b0dd Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Sat, 25 Feb 2023 03:39:59 +0100 Subject: [PATCH 0366/2810] 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 a5930177b9..1daffd4307 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 4675a2ae92..08cccaf7b5 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 032305ac46..a3f23fa171 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 867d89c6b7..454ae7bbd9 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 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 0367/2810] 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 554a94b845..05dc67314e 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 090e171835..c3aefe5987 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 0f7363bb01..d955645199 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 0368/2810] 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 f4b82be0ac..7110419215 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 9b25fbc482..1922d45bf3 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 a48a0bb301..826325f184 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 389400cc51..3446b1dc66 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 7e2a81d310..626aa44efd 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 0369/2810] 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 2c531fea14..114c71f53c 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 0370/2810] 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 94b111ab42..fde8c26ba5 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 1922d45bf3..db304e946e 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 826325f184..dac4e5f060 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 0371/2810] 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 a3a609a1b6..431bc78ba8 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 5cda3d283d..3ff6b62b21 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 0372/2810] 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 85598815c2..ef48a5988a 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 08768e8bac..ce911183df 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 0373/2810] 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 38afa37838..c15436ed2c 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 0374/2810] 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 a5930177b9..1daffd4307 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 4675a2ae92..08cccaf7b5 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 db304e946e..c7c2bf6f14 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 dac4e5f060..92e58a0a82 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 0375/2810] 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 8c80e72519..60abfd52a8 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 15a62106b4..125b8eaafe 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 5a365788b5b118c98003265de95192ad1304e3d7 Mon Sep 17 00:00:00 2001 From: mkmer Date: Fri, 24 Feb 2023 21:49:49 -0500 Subject: [PATCH 0376/2810] 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 87f3e02591..73986920b8 100644 --- a/homeassistant/components/honeywell/strings.json +++ b/homeassistant/components/honeywell/strings.json @@ -7,6 +7,13 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Honeywell integration needs to re-authenticate your account", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { From 1edef73c9aa996fe3b45ac0a4ac3723f8c9893ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Sat, 25 Feb 2023 04:10:00 +0100 Subject: [PATCH 0377/2810] 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 e2f188541d..1782511049 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 a3f23fa171..1a8ecd655f 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 454ae7bbd9..35415529dd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -227,7 +227,7 @@ aiopvpc==4.0.1 aiopyarr==22.11.0 # homeassistant.components.qnap_qsw -aioqsw==0.3.1 +aioqsw==0.3.2 # homeassistant.components.recollect_waste aiorecollect==1.0.8 From f52a5f696500d384b33987fae46ccd35885f2913 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Feb 2023 22:11:48 -0600 Subject: [PATCH 0378/2810] 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 9fd643a775..c7035d5a0d 100644 --- a/homeassistant/helpers/signal.py +++ b/homeassistant/helpers/signal.py @@ -1,4 +1,5 @@ """Signal handling related helpers.""" +import asyncio import logging import signal @@ -23,7 +24,9 @@ def async_register_signal_handling(hass: HomeAssistant) -> None: """ hass.loop.remove_signal_handler(signal.SIGTERM) hass.loop.remove_signal_handler(signal.SIGINT) - hass.async_create_task(hass.async_stop(exit_code)) + hass.data["homeassistant_stop"] = asyncio.create_task( + hass.async_stop(exit_code) + ) try: hass.loop.add_signal_handler(signal.SIGTERM, async_signal_handle, 0) From f18c0bf6268119bbd580175a6b377658c739121c Mon Sep 17 00:00:00 2001 From: Artem Draft Date: Sat, 25 Feb 2023 09:43:58 +0300 Subject: [PATCH 0379/2810] Pass `assumed_state` property in universal media player (#87846) Pass assumed_state property in universal media player --- homeassistant/components/universal/media_player.py | 6 ++++++ tests/components/universal/test_media_player.py | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py index 2cbe7aa6fb..9f6c9db416 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -47,6 +47,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.components.media_player.browse_media import BrowseMedia from homeassistant.const import ( + ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, ATTR_SUPPORTED_FEATURES, @@ -291,6 +292,11 @@ class UniversalMediaPlayer(MediaPlayerEntity): """Return the name of universal player.""" return self._name + @property + def assumed_state(self) -> bool: + """Return True if unable to access real state of the entity.""" + return self._child_attr(ATTR_ASSUMED_STATE) + @property def state(self): """Return the current state of media player. diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index 81204fe21c..fd8c572685 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -492,6 +492,13 @@ async def test_state_children_only(hass: HomeAssistant, mock_states) -> None: await ump.async_update() assert ump.state == STATE_PLAYING + mock_states.mock_mp_1._state = STATE_ON + mock_states.mock_mp_1._attr_assumed_state = True + mock_states.mock_mp_1.async_schedule_update_ha_state() + await hass.async_block_till_done() + await ump.async_update() + assert ump.assumed_state is True + async def test_state_with_children_and_attrs( hass: HomeAssistant, config_children_and_attr, mock_states From 3499d60401a429b2bef148ff59c300ef9757fc73 Mon Sep 17 00:00:00 2001 From: Austin Mroczek Date: Sat, 25 Feb 2023 00:20:17 -0800 Subject: [PATCH 0380/2810] 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 a820a7a034..8e0d58b7b7 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 1a8ecd655f..9fae25122a 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 35415529dd..22c4b7e4df 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1773,7 +1773,7 @@ tololib==0.1.0b4 toonapi==0.2.1 # homeassistant.components.totalconnect -total_connect_client==2023.1 +total_connect_client==2023.2 # homeassistant.components.tplink_omada tplink-omada-client==1.1.0 From 091305fc57067f4641c83ceeb2f570453cde72a1 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Sat, 25 Feb 2023 11:05:51 +0200 Subject: [PATCH 0381/2810] Use DataUpdateCoordinator for islamic_prayer_times (#73893) * use DataUpdateCoordinator for islamic_prayer_times Add suggested type hints remove uneccassry options setup * Use entity_description for sensors * move coordinator into separate file, sensor_descptions to sensor.py * add strict typing * revert strict typing * fix test coverage * revert unrelated file changes * fix sorting * Update code based on review * add missing type hint * more missing type hints * Update homeassistant/components/islamic_prayer_times/coordinator.py Co-authored-by: Martin Hjelmare * remove config_entry parameter --------- Co-authored-by: Martin Hjelmare --- .../islamic_prayer_times/__init__.py | 179 +++--------------- .../islamic_prayer_times/config_flow.py | 11 +- .../components/islamic_prayer_times/const.py | 23 +-- .../islamic_prayer_times/coordinator.py | 121 ++++++++++++ .../components/islamic_prayer_times/sensor.py | 103 ++++++---- .../islamic_prayer_times/__init__.py | 28 +-- .../islamic_prayer_times/test_config_flow.py | 32 ++-- .../islamic_prayer_times/test_init.py | 20 +- .../islamic_prayer_times/test_sensor.py | 18 +- 9 files changed, 272 insertions(+), 263 deletions(-) create mode 100644 homeassistant/components/islamic_prayer_times/coordinator.py diff --git a/homeassistant/components/islamic_prayer_times/__init__.py b/homeassistant/components/islamic_prayer_times/__init__.py index 7fd5ed4129..95a7db632b 100644 --- a/homeassistant/components/islamic_prayer_times/__init__.py +++ b/homeassistant/components/islamic_prayer_times/__init__.py @@ -1,22 +1,13 @@ """The islamic_prayer_times component.""" -from datetime import timedelta -import logging - -from prayer_times_calculator import PrayerTimesCalculator, exceptions -from requests.exceptions import ConnectionError as ConnError +from __future__ import annotations from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.event import async_call_later, async_track_point_in_time -import homeassistant.util.dt as dt_util -from .const import CONF_CALC_METHOD, DATA_UPDATED, DEFAULT_CALC_METHOD, DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN +from .coordinator import IslamicPrayerDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -25,154 +16,32 @@ CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up the Islamic Prayer Component.""" - client = IslamicPrayerClient(hass, config_entry) - hass.data[DOMAIN] = client - await client.async_setup() + coordinator = IslamicPrayerDataUpdateCoordinator(hass) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, coordinator) + config_entry.async_on_unload( + config_entry.add_update_listener(async_options_updated) + ) + hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload Islamic Prayer entry from config_entry.""" - if hass.data[DOMAIN].event_unsub: - hass.data[DOMAIN].event_unsub() - hass.data.pop(DOMAIN) - return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + if unload_ok := await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ): + coordinator: IslamicPrayerDataUpdateCoordinator = hass.data.pop(DOMAIN) + if coordinator.event_unsub: + coordinator.event_unsub() + return unload_ok -class IslamicPrayerClient: - """Islamic Prayer Client Object.""" - - def __init__(self, hass, config_entry): - """Initialize the Islamic Prayer client.""" - self.hass = hass - self.config_entry = config_entry - self.prayer_times_info = {} - self.available = True - self.event_unsub = None - - @property - def calc_method(self): - """Return the calculation method.""" - return self.config_entry.options[CONF_CALC_METHOD] - - def get_new_prayer_times(self): - """Fetch prayer times for today.""" - calc = PrayerTimesCalculator( - latitude=self.hass.config.latitude, - longitude=self.hass.config.longitude, - calculation_method=self.calc_method, - date=str(dt_util.now().date()), - ) - return calc.fetch_prayer_times() - - async def async_schedule_future_update(self): - """Schedule future update for sensors. - - Midnight is a calculated time. The specifics of the calculation - depends on the method of the prayer time calculation. This calculated - midnight is the time at which the time to pray the Isha prayers have - expired. - - Calculated Midnight: The Islamic midnight. - Traditional Midnight: 12:00AM - - Update logic for prayer times: - - If the Calculated Midnight is before the traditional midnight then wait - until the traditional midnight to run the update. This way the day - will have changed over and we don't need to do any fancy calculations. - - If the Calculated Midnight is after the traditional midnight, then wait - until after the calculated Midnight. We don't want to update the prayer - times too early or else the timings might be incorrect. - - Example: - calculated midnight = 11:23PM (before traditional midnight) - Update time: 12:00AM - - calculated midnight = 1:35AM (after traditional midnight) - update time: 1:36AM. - - """ - _LOGGER.debug("Scheduling next update for Islamic prayer times") - - now = dt_util.utcnow() - - midnight_dt = self.prayer_times_info["Midnight"] - - if now > dt_util.as_utc(midnight_dt): - next_update_at = midnight_dt + timedelta(days=1, minutes=1) - _LOGGER.debug( - "Midnight is after day the changes so schedule update for after" - " Midnight the next day" - ) - else: - _LOGGER.debug( - "Midnight is before the day changes so schedule update for the next" - " start of day" - ) - next_update_at = dt_util.start_of_local_day(now + timedelta(days=1)) - - _LOGGER.info("Next update scheduled for: %s", next_update_at) - - self.event_unsub = async_track_point_in_time( - self.hass, self.async_update, next_update_at - ) - - async def async_update(self, *_): - """Update sensors with new prayer times.""" - try: - prayer_times = await self.hass.async_add_executor_job( - self.get_new_prayer_times - ) - self.available = True - except (exceptions.InvalidResponseError, ConnError): - self.available = False - _LOGGER.debug("Error retrieving prayer times") - async_call_later(self.hass, 60, self.async_update) - return - - for prayer, time in prayer_times.items(): - self.prayer_times_info[prayer] = dt_util.parse_datetime( - f"{dt_util.now().date()} {time}" - ) - await self.async_schedule_future_update() - - _LOGGER.debug("New prayer times retrieved. Updating sensors") - async_dispatcher_send(self.hass, DATA_UPDATED) - - async def async_setup(self): - """Set up the Islamic prayer client.""" - await self.async_add_options() - - try: - await self.hass.async_add_executor_job(self.get_new_prayer_times) - except (exceptions.InvalidResponseError, ConnError) as err: - raise ConfigEntryNotReady from err - - await self.async_update() - self.config_entry.add_update_listener(self.async_options_updated) - - await self.hass.config_entries.async_forward_entry_setups( - self.config_entry, PLATFORMS - ) - - return True - - async def async_add_options(self): - """Add options for entry.""" - if not self.config_entry.options: - data = dict(self.config_entry.data) - calc_method = data.pop(CONF_CALC_METHOD, DEFAULT_CALC_METHOD) - - self.hass.config_entries.async_update_entry( - self.config_entry, data=data, options={CONF_CALC_METHOD: calc_method} - ) - - @staticmethod - async def async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Triggered by config entry options updates.""" - if hass.data[DOMAIN].event_unsub: - hass.data[DOMAIN].event_unsub() - await hass.data[DOMAIN].async_update() +async def async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Triggered by config entry options updates.""" + coordinator: IslamicPrayerDataUpdateCoordinator = hass.data[DOMAIN] + if coordinator.event_unsub: + coordinator.event_unsub() + await coordinator.async_request_refresh() diff --git a/homeassistant/components/islamic_prayer_times/config_flow.py b/homeassistant/components/islamic_prayer_times/config_flow.py index 5278750d36..d0d314fe67 100644 --- a/homeassistant/components/islamic_prayer_times/config_flow.py +++ b/homeassistant/components/islamic_prayer_times/config_flow.py @@ -1,10 +1,13 @@ """Config flow for Islamic Prayer Times integration.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant import config_entries from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from .const import CALC_METHODS, CONF_CALC_METHOD, DEFAULT_CALC_METHOD, DOMAIN, NAME @@ -22,7 +25,9 @@ class IslamicPrayerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return IslamicPrayerOptionsFlowHandler(config_entry) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") @@ -40,7 +45,9 @@ class IslamicPrayerOptionsFlowHandler(config_entries.OptionsFlow): """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/islamic_prayer_times/const.py b/homeassistant/components/islamic_prayer_times/const.py index e037f486aa..2a73a33bef 100644 --- a/homeassistant/components/islamic_prayer_times/const.py +++ b/homeassistant/components/islamic_prayer_times/const.py @@ -1,23 +1,12 @@ """Constants for the Islamic Prayer component.""" +from typing import Final + from prayer_times_calculator import PrayerTimesCalculator -DOMAIN = "islamic_prayer_times" -NAME = "Islamic Prayer Times" -PRAYER_TIMES_ICON = "mdi:calendar-clock" +DOMAIN: Final = "islamic_prayer_times" +NAME: Final = "Islamic Prayer Times" -SENSOR_TYPES = { - "Fajr": "prayer", - "Sunrise": "time", - "Dhuhr": "prayer", - "Asr": "prayer", - "Maghrib": "prayer", - "Isha": "prayer", - "Midnight": "time", -} - -CONF_CALC_METHOD = "calculation_method" +CONF_CALC_METHOD: Final = "calculation_method" CALC_METHODS: list[str] = list(PrayerTimesCalculator.CALCULATION_METHODS) -DEFAULT_CALC_METHOD = "isna" - -DATA_UPDATED = "Islamic_prayer_data_updated" +DEFAULT_CALC_METHOD: Final = "isna" diff --git a/homeassistant/components/islamic_prayer_times/coordinator.py b/homeassistant/components/islamic_prayer_times/coordinator.py new file mode 100644 index 0000000000..1a8b0bf703 --- /dev/null +++ b/homeassistant/components/islamic_prayer_times/coordinator.py @@ -0,0 +1,121 @@ +"""Coordinator for the Islamic prayer times integration.""" +from __future__ import annotations + +from datetime import datetime, timedelta +import logging + +from prayer_times_calculator import PrayerTimesCalculator, exceptions +from requests.exceptions import ConnectionError as ConnError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.event import async_call_later, async_track_point_in_time +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +import homeassistant.util.dt as dt_util + +from .const import CONF_CALC_METHOD, DEFAULT_CALC_METHOD, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetime]]): + """Islamic Prayer Client Object.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the Islamic Prayer client.""" + self.event_unsub: CALLBACK_TYPE | None = None + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + ) + + @property + def calc_method(self) -> str: + """Return the calculation method.""" + return self.config_entry.options.get(CONF_CALC_METHOD, DEFAULT_CALC_METHOD) + + def get_new_prayer_times(self) -> dict[str, str]: + """Fetch prayer times for today.""" + calc = PrayerTimesCalculator( + latitude=self.hass.config.latitude, + longitude=self.hass.config.longitude, + calculation_method=self.calc_method, + date=str(dt_util.now().date()), + ) + return calc.fetch_prayer_times() + + @callback + def async_schedule_future_update(self, midnight_dt: datetime) -> None: + """Schedule future update for sensors. + + Midnight is a calculated time. The specifics of the calculation + depends on the method of the prayer time calculation. This calculated + midnight is the time at which the time to pray the Isha prayers have + expired. + + Calculated Midnight: The Islamic midnight. + Traditional Midnight: 12:00AM + + Update logic for prayer times: + + If the Calculated Midnight is before the traditional midnight then wait + until the traditional midnight to run the update. This way the day + will have changed over and we don't need to do any fancy calculations. + + If the Calculated Midnight is after the traditional midnight, then wait + until after the calculated Midnight. We don't want to update the prayer + times too early or else the timings might be incorrect. + + Example: + calculated midnight = 11:23PM (before traditional midnight) + Update time: 12:00AM + + calculated midnight = 1:35AM (after traditional midnight) + update time: 1:36AM. + + """ + _LOGGER.debug("Scheduling next update for Islamic prayer times") + + now = dt_util.utcnow() + + if now > midnight_dt: + next_update_at = midnight_dt + timedelta(days=1, minutes=1) + _LOGGER.debug( + "Midnight is after the day changes so schedule update for after Midnight the next day" + ) + else: + _LOGGER.debug( + "Midnight is before the day changes so schedule update for the next start of day" + ) + next_update_at = dt_util.start_of_local_day(now + timedelta(days=1)) + + _LOGGER.debug("Next update scheduled for: %s", next_update_at) + + self.event_unsub = async_track_point_in_time( + self.hass, self.async_request_update, next_update_at + ) + + async def async_request_update(self, *_) -> None: + """Request update from coordinator.""" + await self.async_request_refresh() + + async def _async_update_data(self) -> dict[str, datetime]: + """Update sensors with new prayer times.""" + try: + prayer_times = await self.hass.async_add_executor_job( + self.get_new_prayer_times + ) + except (exceptions.InvalidResponseError, ConnError) as err: + async_call_later(self.hass, 60, self.async_request_update) + raise UpdateFailed from err + + prayer_times_info: dict[str, datetime] = {} + for prayer, time in prayer_times.items(): + if prayer_time := dt_util.parse_datetime(f"{dt_util.now().date()} {time}"): + prayer_times_info[prayer] = dt_util.as_utc(prayer_time) + + self.async_schedule_future_update(prayer_times_info["Midnight"]) + return prayer_times_info diff --git a/homeassistant/components/islamic_prayer_times/sensor.py b/homeassistant/components/islamic_prayer_times/sensor.py index a90a2c53c5..abaefec408 100644 --- a/homeassistant/components/islamic_prayer_times/sensor.py +++ b/homeassistant/components/islamic_prayer_times/sensor.py @@ -1,12 +1,51 @@ """Platform to retrieve Islamic prayer times information for Home Assistant.""" -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from datetime import datetime + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DATA_UPDATED, DOMAIN, PRAYER_TIMES_ICON, SENSOR_TYPES +from . import IslamicPrayerDataUpdateCoordinator +from .const import DOMAIN, NAME + +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="Fajr", + name="Fajr prayer", + ), + SensorEntityDescription( + key="Sunrise", + name="Sunrise time", + ), + SensorEntityDescription( + key="Dhuhr", + name="Dhuhr prayer", + ), + SensorEntityDescription( + key="Asr", + name="Asr prayer", + ), + SensorEntityDescription( + key="Maghrib", + name="Maghrib prayer", + ), + SensorEntityDescription( + key="Isha", + name="Isha prayer", + ), + SensorEntityDescription( + key="Midnight", + name="Midnight time", + ), +) async def async_setup_entry( @@ -16,46 +55,38 @@ async def async_setup_entry( ) -> None: """Set up the Islamic prayer times sensor platform.""" - client = hass.data[DOMAIN] + coordinator: IslamicPrayerDataUpdateCoordinator = hass.data[DOMAIN] - entities = [] - for sensor_type in SENSOR_TYPES: - entities.append(IslamicPrayerTimeSensor(sensor_type, client)) - - async_add_entities(entities, True) + async_add_entities( + IslamicPrayerTimeSensor(coordinator, description) + for description in SENSOR_TYPES + ) -class IslamicPrayerTimeSensor(SensorEntity): +class IslamicPrayerTimeSensor( + CoordinatorEntity[IslamicPrayerDataUpdateCoordinator], SensorEntity +): """Representation of an Islamic prayer time sensor.""" _attr_device_class = SensorDeviceClass.TIMESTAMP - _attr_icon = PRAYER_TIMES_ICON - _attr_should_poll = False + _attr_has_entity_name = True - def __init__(self, sensor_type, client): + def __init__( + self, + coordinator: IslamicPrayerDataUpdateCoordinator, + description: SensorEntityDescription, + ) -> None: """Initialize the Islamic prayer time sensor.""" - self.sensor_type = sensor_type - self.client = client + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = description.key + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + name=NAME, + entry_type=DeviceEntryType.SERVICE, + ) @property - def name(self): - """Return the name of the sensor.""" - return f"{self.sensor_type} {SENSOR_TYPES[self.sensor_type]}" - - @property - def unique_id(self): - """Return the unique id of the entity.""" - return self.sensor_type - - @property - def native_value(self): + def native_value(self) -> datetime: """Return the state of the sensor.""" - return self.client.prayer_times_info.get(self.sensor_type).astimezone( - dt_util.UTC - ) - - async def async_added_to_hass(self) -> None: - """Handle entity which will be added.""" - self.async_on_remove( - async_dispatcher_connect(self.hass, DATA_UPDATED, self.async_write_ha_state) - ) + return self.coordinator.data[self.entity_description.key] diff --git a/tests/components/islamic_prayer_times/__init__.py b/tests/components/islamic_prayer_times/__init__.py index 3afc2207ff..386d20ab98 100644 --- a/tests/components/islamic_prayer_times/__init__.py +++ b/tests/components/islamic_prayer_times/__init__.py @@ -15,13 +15,13 @@ PRAYER_TIMES = { } PRAYER_TIMES_TIMESTAMPS = { - "Fajr": datetime(2020, 1, 1, 6, 10, 0), - "Sunrise": datetime(2020, 1, 1, 7, 25, 0), - "Dhuhr": datetime(2020, 1, 1, 12, 30, 0), - "Asr": datetime(2020, 1, 1, 15, 32, 0), - "Maghrib": datetime(2020, 1, 1, 17, 35, 0), - "Isha": datetime(2020, 1, 1, 18, 53, 0), - "Midnight": datetime(2020, 1, 1, 00, 45, 0), + "Fajr": datetime(2020, 1, 1, 6, 10, 0, tzinfo=dt_util.UTC), + "Sunrise": datetime(2020, 1, 1, 7, 25, 0, tzinfo=dt_util.UTC), + "Dhuhr": datetime(2020, 1, 1, 12, 30, 0, tzinfo=dt_util.UTC), + "Asr": datetime(2020, 1, 1, 15, 32, 0, tzinfo=dt_util.UTC), + "Maghrib": datetime(2020, 1, 1, 17, 35, 0, tzinfo=dt_util.UTC), + "Isha": datetime(2020, 1, 1, 18, 53, 0, tzinfo=dt_util.UTC), + "Midnight": datetime(2020, 1, 1, 00, 45, 0, tzinfo=dt_util.UTC), } NEW_PRAYER_TIMES = { @@ -35,13 +35,13 @@ NEW_PRAYER_TIMES = { } NEW_PRAYER_TIMES_TIMESTAMPS = { - "Fajr": datetime(2020, 1, 1, 6, 00, 0), - "Sunrise": datetime(2020, 1, 1, 7, 25, 0), - "Dhuhr": datetime(2020, 1, 1, 12, 30, 0), - "Asr": datetime(2020, 1, 1, 15, 32, 0), - "Maghrib": datetime(2020, 1, 1, 17, 45, 0), - "Isha": datetime(2020, 1, 1, 18, 53, 0), - "Midnight": datetime(2020, 1, 1, 00, 43, 0), + "Fajr": datetime(2020, 1, 1, 6, 00, 0, tzinfo=dt_util.UTC), + "Sunrise": datetime(2020, 1, 1, 7, 25, 0, tzinfo=dt_util.UTC), + "Dhuhr": datetime(2020, 1, 1, 12, 30, 0, tzinfo=dt_util.UTC), + "Asr": datetime(2020, 1, 1, 15, 32, 0, tzinfo=dt_util.UTC), + "Maghrib": datetime(2020, 1, 1, 17, 45, 0, tzinfo=dt_util.UTC), + "Isha": datetime(2020, 1, 1, 18, 53, 0, tzinfo=dt_util.UTC), + "Midnight": datetime(2020, 1, 1, 00, 43, 0, tzinfo=dt_util.UTC), } NOW = datetime(2020, 1, 1, 00, 00, 0, tzinfo=dt_util.UTC) diff --git a/tests/components/islamic_prayer_times/test_config_flow.py b/tests/components/islamic_prayer_times/test_config_flow.py index 947e5002ea..6643093873 100644 --- a/tests/components/islamic_prayer_times/test_config_flow.py +++ b/tests/components/islamic_prayer_times/test_config_flow.py @@ -1,27 +1,16 @@ """Tests for Islamic Prayer Times config flow.""" from unittest.mock import patch -import pytest - from homeassistant import config_entries, data_entry_flow from homeassistant.components import islamic_prayer_times -from homeassistant.components.islamic_prayer_times import config_flow # noqa: F401 from homeassistant.components.islamic_prayer_times.const import CONF_CALC_METHOD, DOMAIN from homeassistant.core import HomeAssistant +from . import PRAYER_TIMES + from tests.common import MockConfigEntry -@pytest.fixture(name="mock_setup", autouse=True) -def mock_setup(): - """Mock entry setup.""" - with patch( - "homeassistant.components.islamic_prayer_times.async_setup_entry", - return_value=True, - ): - yield - - async def test_flow_works(hass: HomeAssistant) -> None: """Test user config.""" result = await hass.config_entries.flow.async_init( @@ -30,9 +19,13 @@ async def test_flow_works(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) + with patch( + "homeassistant.components.islamic_prayer_times.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result["title"] == "Islamic Prayer Times" @@ -47,6 +40,13 @@ async def test_options(hass: HomeAssistant) -> None: ) entry.add_to_hass(hass) + with patch( + "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", + return_value=PRAYER_TIMES, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] == data_entry_flow.FlowResultType.FORM diff --git a/tests/components/islamic_prayer_times/test_init.py b/tests/components/islamic_prayer_times/test_init.py index b46f2740c3..d641a22590 100644 --- a/tests/components/islamic_prayer_times/test_init.py +++ b/tests/components/islamic_prayer_times/test_init.py @@ -22,7 +22,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture(autouse=True) -def set_utc(hass): +def set_utc(hass: HomeAssistant) -> None: """Set timezone to UTC.""" hass.config.set_time_zone("UTC") @@ -44,9 +44,6 @@ async def test_successful_config_entry(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert entry.state is config_entries.ConfigEntryState.LOADED - assert entry.options == { - islamic_prayer_times.CONF_CALC_METHOD: islamic_prayer_times.DEFAULT_CALC_METHOD - } async def test_setup_failed(hass: HomeAssistant) -> None: @@ -100,10 +97,7 @@ async def test_islamic_prayer_times_timestamp_format(hass: HomeAssistant) -> Non await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert ( - hass.data[islamic_prayer_times.DOMAIN].prayer_times_info - == PRAYER_TIMES_TIMESTAMPS - ) + assert hass.data[islamic_prayer_times.DOMAIN].data == PRAYER_TIMES_TIMESTAMPS async def test_update(hass: HomeAssistant) -> None: @@ -115,7 +109,6 @@ async def test_update(hass: HomeAssistant) -> None: "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times" ) as FetchPrayerTimes, freeze_time(NOW): FetchPrayerTimes.side_effect = [ - PRAYER_TIMES, PRAYER_TIMES, NEW_PRAYER_TIMES, ] @@ -124,13 +117,10 @@ async def test_update(hass: HomeAssistant) -> None: await hass.async_block_till_done() pt_data = hass.data[islamic_prayer_times.DOMAIN] - assert pt_data.prayer_times_info == PRAYER_TIMES_TIMESTAMPS + assert pt_data.data == PRAYER_TIMES_TIMESTAMPS - future = pt_data.prayer_times_info["Midnight"] + timedelta(days=1, minutes=1) + future = pt_data.data["Midnight"] + timedelta(days=1, minutes=1) async_fire_time_changed(hass, future) await hass.async_block_till_done() - assert ( - hass.data[islamic_prayer_times.DOMAIN].prayer_times_info - == NEW_PRAYER_TIMES_TIMESTAMPS - ) + assert pt_data.data == NEW_PRAYER_TIMES_TIMESTAMPS diff --git a/tests/components/islamic_prayer_times/test_sensor.py b/tests/components/islamic_prayer_times/test_sensor.py index 56dd0e3805..3b291c9973 100644 --- a/tests/components/islamic_prayer_times/test_sensor.py +++ b/tests/components/islamic_prayer_times/test_sensor.py @@ -4,8 +4,10 @@ from unittest.mock import patch from freezegun import freeze_time import pytest -from homeassistant.components import islamic_prayer_times +from homeassistant.components.islamic_prayer_times.const import DOMAIN +from homeassistant.components.islamic_prayer_times.sensor import SENSOR_TYPES from homeassistant.core import HomeAssistant +from homeassistant.util import slugify import homeassistant.util.dt as dt_util from . import NOW, PRAYER_TIMES, PRAYER_TIMES_TIMESTAMPS @@ -14,14 +16,14 @@ from tests.common import MockConfigEntry @pytest.fixture(autouse=True) -def set_utc(hass): +def set_utc(hass: HomeAssistant) -> None: """Set timezone to UTC.""" hass.config.set_time_zone("UTC") async def test_islamic_prayer_times_sensors(hass: HomeAssistant) -> None: """Test minimum Islamic prayer times configuration.""" - entry = MockConfigEntry(domain=islamic_prayer_times.DOMAIN, data={}) + entry = MockConfigEntry(domain=DOMAIN, data={}) entry.add_to_hass(hass) with patch( @@ -31,10 +33,10 @@ async def test_islamic_prayer_times_sensors(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - for prayer in PRAYER_TIMES: + for prayer in SENSOR_TYPES: assert ( - hass.states.get( - f"sensor.{prayer}_{islamic_prayer_times.const.SENSOR_TYPES[prayer]}" - ).state - == PRAYER_TIMES_TIMESTAMPS[prayer].astimezone(dt_util.UTC).isoformat() + hass.states.get(f"sensor.{DOMAIN}_{slugify(prayer.name)}").state + == PRAYER_TIMES_TIMESTAMPS[prayer.key] + .astimezone(dt_util.UTC) + .isoformat() ) From 0a3a8c4b3c823a753fb2db82efcb6c4b1b55b71a Mon Sep 17 00:00:00 2001 From: Arturo Date: Sat, 25 Feb 2023 03:25:04 -0600 Subject: [PATCH 0382/2810] 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 da0739cd41..080cc472f2 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 cab1f59f83..226b22670e 100644 --- a/tests/components/matter/test_light.py +++ b/tests/components/matter/test_light.py @@ -288,7 +288,7 @@ async def test_extended_color_light( "turn_on", { "entity_id": entity_id, - "hs_color": (0, 0), + "hs_color": (236.69291338582678, 100.0), }, blocking=True, ) @@ -299,9 +299,9 @@ async def test_extended_color_light( call( node_id=1, endpoint_id=1, - command=clusters.ColorControl.Commands.MoveToColor( - colorX=21168, - colorY=21561, + command=clusters.ColorControl.Commands.MoveToHueAndSaturation( + hue=167, + saturation=254, transitionTime=0, optionsMask=0, optionsOverride=0, From 0f204d650253d92f18efcb69330913fd010b6228 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sat, 25 Feb 2023 12:01:01 +0100 Subject: [PATCH 0383/2810] 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 c3aefe5987..a36373c862 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 cadb007e12..9f8398d493 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -146,6 +146,8 @@ IGNORE_VIOLATIONS = { ("demo", "openalpr_local"), # This would be a circular dep ("http", "network"), + # This would be a circular dep + ("zha", "homeassistant_hardware"), # This should become a helper method that integrations can submit data to ("websocket_api", "lovelace"), ("websocket_api", "shopping_list"), From 7b61d3763b4a33b1fe9127ab657085840b764208 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Feb 2023 05:01:30 -0600 Subject: [PATCH 0384/2810] 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 a3f8711c8e..7268b7d8f2 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -730,6 +730,7 @@ class HomeAssistant: "Timed out waiting for shutdown stage 1 to complete, the shutdown will" " continue" ) + self._async_log_running_tasks(1) # stage 2 self.state = CoreState.final_write @@ -742,6 +743,7 @@ class HomeAssistant: "Timed out waiting for shutdown stage 2 to complete, the shutdown will" " continue" ) + self._async_log_running_tasks(2) # stage 3 self.state = CoreState.not_running @@ -762,11 +764,18 @@ class HomeAssistant: "Timed out waiting for shutdown stage 3 to complete, the shutdown will" " continue" ) + self._async_log_running_tasks(3) + self.state = CoreState.stopped if self._stopped is not None: self._stopped.set() + def _async_log_running_tasks(self, stage: int) -> None: + """Log all running tasks.""" + for task in self._tasks: + _LOGGER.warning("Shutdown stage %s: still running: %s", stage, task) + class Context: """The context that triggered something.""" From 57360a75289f1a3631bf92ad1257974e85619907 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Feb 2023 05:02:07 -0600 Subject: [PATCH 0385/2810] 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 2bfccf4696..f7e78e82fb 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 3b20782f5b..9f1d8dfcbc 100644 --- a/tests/helpers/test_discovery_flow.py +++ b/tests/helpers/test_discovery_flow.py @@ -96,3 +96,20 @@ async def test_async_create_flow_checks_existing_flows_before_startup( data={"properties": {"id": "aa:bb:cc:dd:ee:ff"}}, ) ] + + +async def test_async_create_flow_does_nothing_after_stop( + hass: HomeAssistant, mock_flow_init +) -> None: + """Test we no longer create flows when hass is stopping.""" + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + hass.state = CoreState.stopping + mock_flow_init.reset_mock() + discovery_flow.async_create_flow( + hass, + "hue", + {"source": config_entries.SOURCE_HOMEKIT}, + {"properties": {"id": "aa:bb:cc:dd:ee:ff"}}, + ) + assert len(mock_flow_init.mock_calls) == 0 From 1519a7856747c54abd233dae3ad97d593d1d4036 Mon Sep 17 00:00:00 2001 From: avee87 <6134677+avee87@users.noreply.github.com> Date: Sat, 25 Feb 2023 11:05:24 +0000 Subject: [PATCH 0386/2810] 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 7f88945997..0d2dc06b83 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 2405908cdda9485e46554bcb1855884372382984 Mon Sep 17 00:00:00 2001 From: Arturo Date: Sat, 25 Feb 2023 03:25:04 -0600 Subject: [PATCH 0387/2810] 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 da0739cd41..080cc472f2 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 cab1f59f83..226b22670e 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 0388/2810] 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 e2f188541d..1782511049 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 c7c2bf6f14..bb0fdc5c56 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 92e58a0a82..dbad01d921 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 0389/2810] 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 7f88945997..0d2dc06b83 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 0390/2810] 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 a820a7a034..8e0d58b7b7 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 bb0fdc5c56..ae13c59367 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 dbad01d921..bc01351902 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 0391/2810] 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 87f3e02591..73986920b8 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 0392/2810] 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 a3f8711c8e..7268b7d8f2 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 0393/2810] 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 9fd643a775..c7035d5a0d 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 0394/2810] 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 2bfccf4696..f7e78e82fb 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 3b20782f5b..9f1d8dfcbc 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 0395/2810] 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 c3aefe5987..a36373c862 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 cadb007e12..9f8398d493 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 0396/2810] 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 60abfd52a8..9f0249e992 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 125b8eaafe..37d3b7f021 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 b4a3a663cf7f986a4d386a9b8b4809a272085077 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 25 Feb 2023 17:18:49 +0100 Subject: [PATCH 0397/2810] Simplify adding unifi entities (#88571) --- homeassistant/components/unifi/controller.py | 32 ++++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 2721e254de..31d0727892 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -198,26 +198,26 @@ class UniFiController: @callback def async_load_entities(description: UnifiEntityDescription) -> None: """Load and subscribe to UniFi endpoints.""" - entities: list[UnifiEntity] = [] api_handler = description.api_handler_fn(self.api) + @callback + def async_add_unifi_entity(obj_ids: list[str]) -> None: + """Add UniFi entity.""" + async_add_entities( + [ + unifi_platform_entity(obj_id, self, description) + for obj_id in obj_ids + if description.allowed_fn(self, obj_id) + if description.supported_fn(self, obj_id) + ] + ) + + async_add_unifi_entity(list(api_handler)) + @callback def async_create_entity(event: ItemEvent, obj_id: str) -> None: - """Create UniFi entity.""" - if not description.allowed_fn( - self, obj_id - ) or not description.supported_fn(self, obj_id): - return - - entity = unifi_platform_entity(obj_id, self, description) - if event == ItemEvent.ADDED: - async_add_entities([entity]) - return - entities.append(entity) - - for obj_id in api_handler: - async_create_entity(ItemEvent.CHANGED, obj_id) - async_add_entities(entities) + """Create new UniFi entity on event.""" + async_add_unifi_entity([obj_id]) api_handler.subscribe(async_create_entity, ItemEvent.ADDED) From 327edabb646b187924d0397885662f25e77e2129 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Feb 2023 21:47:18 -0600 Subject: [PATCH 0398/2810] 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 c2c84bf855..45ceb471fd 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -39,7 +39,7 @@ def is_installed(package: str) -> bool: try: pkg_resources.get_distribution(package) return True - except (pkg_resources.ResolutionError, pkg_resources.ExtractionError): + except (IndexError, pkg_resources.ResolutionError, pkg_resources.ExtractionError): req = pkg_resources.Requirement.parse(package) except ValueError: # This is a zip file. We no longer use this in Home Assistant, From 490a0908d44393c65801584ef85f6c61f931ec1c Mon Sep 17 00:00:00 2001 From: Yuxin Wang Date: Sun, 26 Feb 2023 01:57:31 -0500 Subject: [PATCH 0399/2810] Avoiding testing implementation details in apcupsd tests (#88772) Fix apcupsd tests. --- tests/components/apcupsd/test_init.py | 31 +++++++++++---------------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/tests/components/apcupsd/test_init.py b/tests/components/apcupsd/test_init.py index 93cd817130..eae5df9b0c 100644 --- a/tests/components/apcupsd/test_init.py +++ b/tests/components/apcupsd/test_init.py @@ -4,7 +4,7 @@ from unittest.mock import patch import pytest -from homeassistant.components.apcupsd import DOMAIN, APCUPSdData +from homeassistant.components.apcupsd import DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -31,20 +31,20 @@ async def test_async_setup_entry(hass: HomeAssistant, status: OrderedDict) -> No async def test_multiple_integrations(hass: HomeAssistant) -> None: """Test successful setup for multiple entries.""" # Load two integrations from two mock hosts. + status1 = MOCK_STATUS | {"LOADPCT": "15.0 Percent", "SERIALNO": "XXXXX1"} + status2 = MOCK_STATUS | {"LOADPCT": "16.0 Percent", "SERIALNO": "XXXXX2"} entries = ( - await init_integration(hass, host="test1", status=MOCK_STATUS), - await init_integration(hass, host="test2", status=MOCK_MINIMAL_STATUS), + await init_integration(hass, host="test1", status=status1), + await init_integration(hass, host="test2", status=status2), ) - # Data dict should contain different API objects. - assert len(hass.data[DOMAIN]) == len(entries) - for entry in entries: - assert entry.entry_id in hass.data[DOMAIN] - assert isinstance(hass.data[DOMAIN][entry.entry_id], APCUPSdData) + assert len(hass.config_entries.async_entries(DOMAIN)) == 2 + assert all(entry.state is ConfigEntryState.LOADED for entry in entries) - assert ( - hass.data[DOMAIN][entries[0].entry_id] != hass.data[DOMAIN][entries[1].entry_id] - ) + state1 = hass.states.get("sensor.ups_load") + state2 = hass.states.get("sensor.ups_load_2") + assert state1 is not None and state2 is not None + assert state1.state != state2.state async def test_connection_error(hass: HomeAssistant) -> None: @@ -83,19 +83,14 @@ async def test_unload_remove(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert entries[0].state is ConfigEntryState.NOT_LOADED assert entries[1].state is ConfigEntryState.LOADED - assert len(hass.data[DOMAIN]) == 1 # Unload the second entry. assert await hass.config_entries.async_unload(entries[1].entry_id) await hass.async_block_till_done() assert all(entry.state is ConfigEntryState.NOT_LOADED for entry in entries) - # We should never leave any garbage in the data dict. - assert len(hass.data[DOMAIN]) == 0 - # Remove both entries. for entry in entries: await hass.config_entries.async_remove(entry.entry_id) - await hass.async_block_till_done() - state = hass.states.get(entry.entry_id) - assert state is None + await hass.async_block_till_done() + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 From 7c23de469ebd4cd7b81d5a8a2db5ac8412135ffb Mon Sep 17 00:00:00 2001 From: shbatm Date: Sun, 26 Feb 2023 01:12:00 -0600 Subject: [PATCH 0400/2810] Add ISY994 services to set and delete lock codes (#88754) --- homeassistant/components/isy994/lock.py | 56 ++++++++++++++++--- homeassistant/components/isy994/manifest.json | 2 +- homeassistant/components/isy994/services.py | 13 +++++ homeassistant/components/isy994/services.yaml | 46 +++++++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 111 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/isy994/lock.py b/homeassistant/components/isy994/lock.py index c5372135bb..9bf487def0 100644 --- a/homeassistant/components/isy994/lock.py +++ b/homeassistant/components/isy994/lock.py @@ -8,16 +8,43 @@ from pyisy.constants import ISY_VALUE_UNKNOWN from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) -from .const import _LOGGER, DOMAIN +from .const import DOMAIN from .entity import ISYNodeEntity, ISYProgramEntity +from .services import ( + SERVICE_DELETE_USER_CODE_SCHEMA, + SERVICE_DELETE_ZWAVE_LOCK_USER_CODE, + SERVICE_SET_USER_CODE_SCHEMA, + SERVICE_SET_ZWAVE_LOCK_USER_CODE, +) VALUE_TO_STATE = {0: False, 100: True} +@callback +def async_setup_lock_services(hass: HomeAssistant) -> None: + """Create lock-specific services for the ISY Integration.""" + platform = async_get_current_platform() + + platform.async_register_entity_service( + SERVICE_SET_ZWAVE_LOCK_USER_CODE, + SERVICE_SET_USER_CODE_SCHEMA, + "async_set_zwave_lock_user_code", + ) + platform.async_register_entity_service( + SERVICE_DELETE_ZWAVE_LOCK_USER_CODE, + SERVICE_DELETE_USER_CODE_SCHEMA, + "async_delete_zwave_lock_user_code", + ) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -32,6 +59,7 @@ async def async_setup_entry( entities.append(ISYLockProgramEntity(name, status, actions)) async_add_entities(entities) + async_setup_lock_services(hass) class ISYLockEntity(ISYNodeEntity, LockEntity): @@ -47,12 +75,26 @@ class ISYLockEntity(ISYNodeEntity, LockEntity): async def async_lock(self, **kwargs: Any) -> None: """Send the lock command to the ISY device.""" if not await self._node.secure_lock(): - _LOGGER.error("Unable to lock device") + raise HomeAssistantError(f"Unable to lock device {self._node.address}") async def async_unlock(self, **kwargs: Any) -> None: """Send the unlock command to the ISY device.""" if not await self._node.secure_unlock(): - _LOGGER.error("Unable to lock device") + raise HomeAssistantError(f"Unable to unlock device {self._node.address}") + + async def async_set_zwave_lock_user_code(self, user_num: int, code: int) -> None: + """Set a user lock code for a Z-Wave Lock.""" + if not await self._node.set_zwave_lock_code(user_num, code): + raise HomeAssistantError( + f"Could not set user code {user_num} for {self._node.address}" + ) + + async def async_delete_zwave_lock_user_code(self, user_num: int) -> None: + """Delete a user lock code for a Z-Wave Lock.""" + if not await self._node.delete_zwave_lock_code(user_num): + raise HomeAssistantError( + f"Could not delete user code {user_num} for {self._node.address}" + ) class ISYLockProgramEntity(ISYProgramEntity, LockEntity): @@ -66,9 +108,9 @@ class ISYLockProgramEntity(ISYProgramEntity, LockEntity): async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" if not await self._actions.run_then(): - _LOGGER.error("Unable to lock device") + raise HomeAssistantError(f"Unable to lock device {self._node.address}") async def async_unlock(self, **kwargs: Any) -> None: """Unlock the device.""" if not await self._actions.run_else(): - _LOGGER.error("Unable to unlock device") + raise HomeAssistantError(f"Unable to unlock device {self._node.address}") diff --git a/homeassistant/components/isy994/manifest.json b/homeassistant/components/isy994/manifest.json index 991b79e7be..3aa81027b4 100644 --- a/homeassistant/components/isy994/manifest.json +++ b/homeassistant/components/isy994/manifest.json @@ -24,7 +24,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyisy"], - "requirements": ["pyisy==3.1.13"], + "requirements": ["pyisy==3.1.14"], "ssdp": [ { "manufacturer": "Universal Devices Inc.", diff --git a/homeassistant/components/isy994/services.py b/homeassistant/components/isy994/services.py index 05e0425c3f..ea66bc9013 100644 --- a/homeassistant/components/isy994/services.py +++ b/homeassistant/components/isy994/services.py @@ -52,8 +52,14 @@ SERVICE_RENAME_NODE = "rename_node" SERVICE_SET_ON_LEVEL = "set_on_level" SERVICE_SET_RAMP_RATE = "set_ramp_rate" +# Services valid only for Z-Wave Locks +SERVICE_SET_ZWAVE_LOCK_USER_CODE = "set_zwave_lock_user_code" +SERVICE_DELETE_ZWAVE_LOCK_USER_CODE = "delete_zwave_lock_user_code" + CONF_PARAMETER = "parameter" CONF_PARAMETERS = "parameters" +CONF_USER_NUM = "user_num" +CONF_CODE = "code" CONF_VALUE = "value" CONF_INIT = "init" CONF_ISY = "isy" @@ -129,6 +135,13 @@ SERVICE_SET_ZWAVE_PARAMETER_SCHEMA = { vol.Required(CONF_SIZE): vol.All(vol.Coerce(int), vol.In(VALID_PARAMETER_SIZES)), } +SERVICE_SET_USER_CODE_SCHEMA = { + vol.Required(CONF_USER_NUM): vol.Coerce(int), + vol.Required(CONF_CODE): vol.Coerce(int), +} + +SERVICE_DELETE_USER_CODE_SCHEMA = {vol.Required(CONF_USER_NUM): vol.Coerce(int)} + SERVICE_SET_VARIABLE_SCHEMA = vol.All( cv.has_at_least_one_key(CONF_ADDRESS, CONF_TYPE, CONF_NAME), vol.Schema( diff --git a/homeassistant/components/isy994/services.yaml b/homeassistant/components/isy994/services.yaml index e336eaa574..89b6c4d33d 100644 --- a/homeassistant/components/isy994/services.yaml +++ b/homeassistant/components/isy994/services.yaml @@ -118,6 +118,52 @@ set_zwave_parameter: - "1" - "2" - "4" +set_zwave_lock_user_code: + name: Set Z-Wave Lock User Code + description: >- + Set a Z-Wave Lock User Code via the ISY. + target: + entity: + integration: isy994 + domain: lock + fields: + user_num: + name: User Number + description: The user slot number on the lock + required: true + example: 8 + selector: + number: + min: 1 + max: 255 + code: + name: Code + description: The code to set for the user. + required: true + example: 33491663 + selector: + number: + min: 1 + max: 99999999 + mode: box +delete_zwave_lock_user_code: + name: Delete Z-Wave Lock User Code + description: >- + Delete a Z-Wave Lock User Code via the ISY. + target: + entity: + integration: isy994 + domain: lock + fields: + user_num: + name: User Number + description: The user slot number on the lock + required: true + example: 8 + selector: + number: + min: 1 + max: 255 rename_node: name: Rename Node on ISY description: >- diff --git a/requirements_all.txt b/requirements_all.txt index 9fae25122a..4d903ffaae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1708,7 +1708,7 @@ pyirishrail==0.0.2 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.1.13 +pyisy==3.1.14 # homeassistant.components.itach pyitachip2ir==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 22c4b7e4df..ecda31f4c3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1227,7 +1227,7 @@ pyiqvia==2022.04.0 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.1.13 +pyisy==3.1.14 # homeassistant.components.kaleidescape pykaleidescape==1.0.1 From e00ff548690723f0ac0a8bbb511b911c708022b2 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sun, 26 Feb 2023 11:05:31 +0100 Subject: [PATCH 0401/2810] 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 57c5f680e0..fd77b5e234 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 89c993cafa..263fd41b30 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 6050010b20..434a9a50ea 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 d9a2bd365e..5114cc222e 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 579b8e7915..7907881188 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 d554eaf4ff..e255ff3650 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 94f3704048..8aabad2c9f 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 23634e77c5..95d96de976 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 4d903ffaae..10dfef7810 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 ecda31f4c3..3b67a58872 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 0ae9c73e6b..b75c49b2b7 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 0ced1799b4..e4f90a59f6 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 9263919214..3360c82577 100644 --- a/tests/components/nibe_heatpump/test_config_flow.py +++ b/tests/components/nibe_heatpump/test_config_flow.py @@ -5,9 +5,9 @@ from nibe.coil import Coil from nibe.exceptions import ( AddressInUseException, CoilNotFoundException, - CoilReadException, - CoilReadSendException, - CoilWriteException, + ReadException, + ReadSendException, + WriteException, ) import pytest @@ -169,7 +169,7 @@ async def test_read_timeout( """Test we handle cannot connect error.""" result = await _get_connection_form(hass, connection_type) - mock_connection.verify_connectivity.side_effect = CoilReadException() + mock_connection.verify_connectivity.side_effect = ReadException() result2 = await hass.config_entries.flow.async_configure(result["flow_id"], data) @@ -190,7 +190,7 @@ async def test_write_timeout( """Test we handle cannot connect error.""" result = await _get_connection_form(hass, connection_type) - mock_connection.verify_connectivity.side_effect = CoilWriteException() + mock_connection.verify_connectivity.side_effect = WriteException() result2 = await hass.config_entries.flow.async_configure(result["flow_id"], data) @@ -232,7 +232,7 @@ async def test_nibegw_invalid_host( """Test we handle cannot connect error.""" result = await _get_connection_form(hass, connection_type) - mock_connection.verify_connectivity.side_effect = CoilReadSendException() + mock_connection.verify_connectivity.side_effect = ReadSendException() result2 = await hass.config_entries.flow.async_configure(result["flow_id"], data) From c9dfa15ed611340ed30045486e207ee93247b890 Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Sun, 26 Feb 2023 18:49:25 +0100 Subject: [PATCH 0402/2810] Add device HmIP-DLD (#83380) * Add HmIP-DLD * Remove commented code * Fix errors * Format using black * Fix device count * Add missing tests * Apply changes by reviewer * Change setup entry code * Remove jammed state * Add error messages * Update homeassistant/components/homematicip_cloud/helpers.py Co-authored-by: Aaron Bach * Add decorator * Add error log output * Update test_device.py --------- Co-authored-by: Aaron Bach --- .../components/homematicip_cloud/const.py | 1 + .../components/homematicip_cloud/helpers.py | 39 ++++ .../components/homematicip_cloud/lock.py | 95 ++++++++++ .../homematicip_cloud/test_device.py | 2 +- .../homematicip_cloud/test_helpers.py | 18 ++ .../components/homematicip_cloud/test_lock.py | 127 +++++++++++++ tests/fixtures/homematicip_cloud.json | 177 +++++++++++++++++- 7 files changed, 457 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/homematicip_cloud/helpers.py create mode 100644 homeassistant/components/homematicip_cloud/lock.py create mode 100644 tests/components/homematicip_cloud/test_helpers.py create mode 100644 tests/components/homematicip_cloud/test_lock.py diff --git a/homeassistant/components/homematicip_cloud/const.py b/homeassistant/components/homematicip_cloud/const.py index 055db90a68..4ea1a2fc7e 100644 --- a/homeassistant/components/homematicip_cloud/const.py +++ b/homeassistant/components/homematicip_cloud/const.py @@ -14,6 +14,7 @@ PLATFORMS = [ Platform.CLIMATE, Platform.COVER, Platform.LIGHT, + Platform.LOCK, Platform.SENSOR, Platform.SWITCH, Platform.WEATHER, diff --git a/homeassistant/components/homematicip_cloud/helpers.py b/homeassistant/components/homematicip_cloud/helpers.py new file mode 100644 index 0000000000..1680904bbc --- /dev/null +++ b/homeassistant/components/homematicip_cloud/helpers.py @@ -0,0 +1,39 @@ +"""Helper functions for Homematicip Cloud Integration.""" + +from functools import wraps +import json +import logging + +from homeassistant.exceptions import HomeAssistantError + +from . import HomematicipGenericEntity + +_LOGGER = logging.getLogger(__name__) + + +def is_error_response(response) -> bool: + """Response from async call contains errors or not.""" + if isinstance(response, dict): + return response.get("errorCode") not in ("", None) + + return False + + +def handle_errors(func): + """Handle async errors.""" + + @wraps(func) + async def inner(self: HomematicipGenericEntity) -> None: + """Handle errors from async call.""" + result = await func(self) + if is_error_response(result): + _LOGGER.error( + "Error while execute function %s: %s", + __name__, + json.dumps(result), + ) + raise HomeAssistantError( + f"Error while execute function {func.__name__}: {result.get('errorCode')}. See log for more information." + ) + + return inner diff --git a/homeassistant/components/homematicip_cloud/lock.py b/homeassistant/components/homematicip_cloud/lock.py new file mode 100644 index 0000000000..563f010306 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/lock.py @@ -0,0 +1,95 @@ +"""Support for HomematicIP Cloud lock devices.""" +from __future__ import annotations + +import logging +from typing import Any + +from homematicip.aio.device import AsyncDoorLockDrive +from homematicip.base.enums import LockState, MotorState + +from homeassistant.components.lock import LockEntity, LockEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import DOMAIN as HMIPC_DOMAIN, HomematicipGenericEntity +from .helpers import handle_errors + +_LOGGER = logging.getLogger(__name__) + +ATTR_AUTO_RELOCK_DELAY = "auto_relock_delay" +ATTR_DOOR_HANDLE_TYPE = "door_handle_type" +ATTR_DOOR_LOCK_DIRECTION = "door_lock_direction" +ATTR_DOOR_LOCK_NEUTRAL_POSITION = "door_lock_neutral_position" +ATTR_DOOR_LOCK_TURNS = "door_lock_turns" + +DEVICE_DLD_ATTRIBUTES = { + "autoRelockDelay": ATTR_AUTO_RELOCK_DELAY, + "doorHandleType": ATTR_DOOR_HANDLE_TYPE, + "doorLockDirection": ATTR_DOOR_LOCK_DIRECTION, + "doorLockNeutralPosition": ATTR_DOOR_LOCK_NEUTRAL_POSITION, + "doorLockTurns": ATTR_DOOR_LOCK_TURNS, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the HomematicIP locks from a config entry.""" + hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] + + async_add_entities( + HomematicipDoorLockDrive(hap, device) + for device in hap.home.devices + if isinstance(device, AsyncDoorLockDrive) + ) + + +class HomematicipDoorLockDrive(HomematicipGenericEntity, LockEntity): + """Representation of the HomematicIP DoorLockDrive.""" + + _attr_supported_features = LockEntityFeature.OPEN + + @property + def is_locked(self) -> bool | None: + """Return true if device is locked.""" + return ( + self._device.lockState == LockState.LOCKED + and self._device.motorState == MotorState.STOPPED + ) + + @property + def is_locking(self) -> bool: + """Return true if device is locking.""" + return self._device.motorState == MotorState.CLOSING + + @property + def is_unlocking(self) -> bool: + """Return true if device is unlocking.""" + return self._device.motorState == MotorState.OPENING + + @handle_errors + async def async_lock(self, **kwargs: Any) -> None: + """Lock the device.""" + return await self._device.set_lock_state(LockState.LOCKED) + + @handle_errors + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock the device.""" + return await self._device.set_lock_state(LockState.UNLOCKED) + + @handle_errors + async def async_open(self, **kwargs: Any) -> None: + """Open the door latch.""" + return await self._device.set_lock_state(LockState.OPEN) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes of the device.""" + return super().extra_state_attributes | { + attr_key: attr_value + for attr, attr_key in DEVICE_DLD_ATTRIBUTES.items() + if (attr_value := getattr(self._device, attr, None)) is not None + } diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index 60d8c4d655..d84fe690df 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -25,7 +25,7 @@ async def test_hmip_load_all_supported_devices( test_devices=None, test_groups=None ) - assert len(mock_hap.hmip_device_by_entity_id) == 270 + assert len(mock_hap.hmip_device_by_entity_id) == 272 async def test_hmip_remove_device( diff --git a/tests/components/homematicip_cloud/test_helpers.py b/tests/components/homematicip_cloud/test_helpers.py new file mode 100644 index 0000000000..85c16255d7 --- /dev/null +++ b/tests/components/homematicip_cloud/test_helpers.py @@ -0,0 +1,18 @@ +"""Test HomematicIP Cloud helper functions.""" + +import json + +from homeassistant.components.homematicip_cloud.helpers import is_error_response + + +async def test_is_error_response(): + """Test, if an response is a normal result or an error.""" + assert not is_error_response("True") + assert not is_error_response(True) + assert not is_error_response("") + assert is_error_response( + json.loads( + '{"errorCode": "INVALID_NUMBER_PARAMETER_VALUE", "minValue": 0.0, "maxValue": 1.01}' + ) + ) + assert not is_error_response(json.loads('{"errorCode": ""}')) diff --git a/tests/components/homematicip_cloud/test_lock.py b/tests/components/homematicip_cloud/test_lock.py new file mode 100644 index 0000000000..48ae02738a --- /dev/null +++ b/tests/components/homematicip_cloud/test_lock.py @@ -0,0 +1,127 @@ +"""Tests for HomematicIP Cloud locks.""" +from unittest.mock import patch + +from homematicip.base.enums import LockState, MotorState +import pytest + +from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN +from homeassistant.components.lock import ( + DOMAIN, + STATE_LOCKING, + STATE_UNLOCKING, + LockEntityFeature, +) +from homeassistant.const import ATTR_SUPPORTED_FEATURES +from homeassistant.exceptions import HomeAssistantError +from homeassistant.setup import async_setup_component + +from .helper import async_manipulate_test_data, get_and_check_entity_basics + + +async def test_manually_configured_platform(hass): + """Test that we do not set up an access point.""" + assert await async_setup_component( + hass, DOMAIN, {DOMAIN: {"platform": HMIPC_DOMAIN}} + ) + assert not hass.data.get(HMIPC_DOMAIN) + + +async def test_hmip_doorlockdrive(hass, default_mock_hap_factory): + """Test HomematicipDoorLockDrive.""" + entity_id = "lock.haustuer" + entity_name = "Haustuer" + device_model = "HmIP-DLD" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=[entity_name] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + assert ha_state.attributes[ATTR_SUPPORTED_FEATURES] == LockEntityFeature.OPEN + + await hass.services.async_call( + "lock", + "open", + {"entity_id": entity_id}, + blocking=True, + ) + assert hmip_device.mock_calls[-1][0] == "set_lock_state" + assert hmip_device.mock_calls[-1][1] == (LockState.OPEN,) + + await hass.services.async_call( + "lock", + "lock", + {"entity_id": entity_id}, + blocking=True, + ) + assert hmip_device.mock_calls[-1][0] == "set_lock_state" + assert hmip_device.mock_calls[-1][1] == (LockState.LOCKED,) + + await hass.services.async_call( + "lock", + "unlock", + {"entity_id": entity_id}, + blocking=True, + ) + + assert hmip_device.mock_calls[-1][0] == "set_lock_state" + assert hmip_device.mock_calls[-1][1] == (LockState.UNLOCKED,) + + await async_manipulate_test_data( + hass, hmip_device, "motorState", MotorState.CLOSING + ) + ha_state = hass.states.get(entity_id) + assert ha_state.state == STATE_LOCKING + + await async_manipulate_test_data( + hass, hmip_device, "motorState", MotorState.OPENING + ) + ha_state = hass.states.get(entity_id) + assert ha_state.state == STATE_UNLOCKING + + +async def test_hmip_doorlockdrive_handle_errors(hass, default_mock_hap_factory): + """Test HomematicipDoorLockDrive.""" + entity_id = "lock.haustuer" + entity_name = "Haustuer" + device_model = "HmIP-DLD" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=[entity_name] + ) + with patch( + "homematicip.aio.device.AsyncDoorLockDrive.set_lock_state", + return_value={ + "errorCode": "INVALID_NUMBER_PARAMETER_VALUE", + "minValue": 0.0, + "maxValue": 1.01, + }, + ): + get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "lock", + "open", + {"entity_id": entity_id}, + blocking=True, + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "lock", + "lock", + {"entity_id": entity_id}, + blocking=True, + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "lock", + "unlock", + {"entity_id": entity_id}, + blocking=True, + ) diff --git a/tests/fixtures/homematicip_cloud.json b/tests/fixtures/homematicip_cloud.json index c54327069a..d050300971 100644 --- a/tests/fixtures/homematicip_cloud.json +++ b/tests/fixtures/homematicip_cloud.json @@ -6978,7 +6978,7 @@ "supported": true, "type": "EXTERNAL" }, - "3014F711A000DIN_RAIL_DIMMER3": { +"3014F711A000DIN_RAIL_DIMMER3": { "availableFirmwareVersion": "1.2.0", "connectionType": "HMIP_RF", "firmwareVersion": "1.2.0", @@ -7119,6 +7119,181 @@ "serializedGlobalTradeItemNumber": "3014F711A000DIN_RAIL_DIMMER3", "type": "DIN_RAIL_DIMMER_3", "updateState": "UP_TO_DATE" + }, + "3014F7110000000000000DLD": { + "availableFirmwareVersion": "1.2.0", + "connectionType": "HMIP_RF", + "firmwareVersion": "1.2.0", + "firmwareVersionInteger": 66048, + "functionalChannels": { + "0": { + "busConfigMismatch": null, + "coProFaulty": false, + "coProRestartNeeded": false, + "coProUpdateFailure": false, + "configPending": false, + "deviceId": "3014F7110000000000000DLD", + "deviceOverheated": false, + "deviceOverloaded": false, + "devicePowerFailureDetected": false, + "deviceUndervoltage": false, + "dutyCycle": false, + "functionalChannelType": "DEVICE_OPERATIONLOCK", + "groupIndex": 0, + "groups": ["00000000-0000-0000-0000-000000000025"], + "index": 0, + "label": "", + "lowBat": false, + "mountingOrientation": null, + "multicastRoutingEnabled": false, + "operationLockActive": false, + "particulateMatterSensorCommunicationError": null, + "particulateMatterSensorError": null, + "powerShortCircuit": null, + "profilePeriodLimitReached": false, + "routerModuleEnabled": false, + "routerModuleSupported": false, + "rssiDeviceValue": -63, + "rssiPeerValue": -64, + "shortCircuitDataLine": null, + "supportedOptionalFeatures": { + "IFeatureBusConfigMismatch": false, + "IFeatureDeviceCoProError": false, + "IFeatureDeviceCoProRestart": false, + "IFeatureDeviceCoProUpdate": false, + "IFeatureDeviceIdentify": false, + "IFeatureDeviceOverheated": false, + "IFeatureDeviceOverloaded": false, + "IFeatureDeviceParticulateMatterSensorCommunicationError": false, + "IFeatureDeviceParticulateMatterSensorError": false, + "IFeatureDevicePowerFailure": false, + "IFeatureDeviceTemperatureHumiditySensorCommunicationError": false, + "IFeatureDeviceTemperatureHumiditySensorError": false, + "IFeatureDeviceTemperatureOutOfRange": false, + "IFeatureDeviceUndervoltage": false, + "IFeatureMulticastRouter": false, + "IFeaturePowerShortCircuit": false, + "IFeatureProfilePeriodLimit": true, + "IFeatureRssiValue": true, + "IFeatureShortCircuitDataLine": false, + "IOptionalFeatureDutyCycle": true, + "IOptionalFeatureLowBat": true, + "IOptionalFeatureMountingOrientation": false + }, + "temperatureHumiditySensorCommunicationError": null, + "temperatureHumiditySensorError": null, + "temperatureOutOfRange": false, + "unreach": false + }, + "1": { + "autoRelockDelay": 300.0, + "autoRelockEnabled": false, + "deviceId": "3014F7110000000000000DLD", + "doorHandleType": "LEVER_HANDLE", + "doorLockDirection": "RIGHT", + "doorLockNeutralPosition": "VERTICAL", + "doorLockTurns": 2, + "functionalChannelType": "DOOR_LOCK_CHANNEL", + "groupIndex": 1, + "groups": [ + "00000000-0000-0000-0000-000000000026", + "00000000-0000-0000-0000-000000000027", + "00000000-0000-0000-0000-000000000028" + ], + "index": 1, + "label": "", + "lockState": "LOCKED", + "motorState": "STOPPED" + }, + "2": { + "authorized": true, + "deviceId": "3014F7110000000000000DLD", + "functionalChannelType": "ACCESS_AUTHORIZATION_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000033"], + "index": 2, + "label": "" + }, + "3": { + "authorized": true, + "deviceId": "3014F7110000000000000DLD", + "functionalChannelType": "ACCESS_AUTHORIZATION_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000033"], + "index": 3, + "label": "" + }, + "4": { + "authorized": true, + "deviceId": "3014F7110000000000000DLD", + "functionalChannelType": "ACCESS_AUTHORIZATION_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000033"], + "index": 4, + "label": "" + }, + "5": { + "authorized": true, + "deviceId": "3014F7110000000000000DLD", + "functionalChannelType": "ACCESS_AUTHORIZATION_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000033"], + "index": 5, + "label": "" + }, + "6": { + "authorized": true, + "deviceId": "3014F7110000000000000DLD", + "functionalChannelType": "ACCESS_AUTHORIZATION_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000033"], + "index": 6, + "label": "" + }, + "7": { + "authorized": true, + "deviceId": "3014F7110000000000000DLD", + "functionalChannelType": "ACCESS_AUTHORIZATION_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000033"], + "index": 7, + "label": "" + }, + "8": { + "authorized": true, + "deviceId": "3014F7110000000000000DLD", + "functionalChannelType": "ACCESS_AUTHORIZATION_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000033"], + "index": 8, + "label": "" + }, + "9": { + "authorized": true, + "deviceId": "3014F7110000000000000DLD", + "functionalChannelType": "ACCESS_AUTHORIZATION_CHANNEL", + "groupIndex": 1, + "groups": [ + "00000000-0000-0000-0000-000000000033", + "00000000-0000-0000-0000-000000000032" + ], + "index": 9, + "label": "" + } + }, + "homeId": "00000000-0000-0000-0000-000000000001", + "id": "3014F7110000000000000DLD", + "label": "Haustuer", + "lastStatusUpdate": 1618727020725, + "liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED", + "manufacturerCode": 1, + "modelId": 423, + "modelType": "HmIP-DLD", + "oem": "eQ-3", + "permanentlyReachable": true, + "serializedGlobalTradeItemNumber": "3014F7110000000000000DLD", + "type": "DOOR_LOCK_DRIVE", + "updateState": "UP_TO_DATE" } }, "groups": { From 0fb41bdffe728ba7877a5ec44f4cc1dbd941ce98 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sun, 26 Feb 2023 12:41:16 -0800 Subject: [PATCH 0403/2810] Unblock JSON CI by fixing improperly indented JSON in test fixture (#88803) --- tests/fixtures/homematicip_cloud.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fixtures/homematicip_cloud.json b/tests/fixtures/homematicip_cloud.json index d050300971..83b5f8993b 100644 --- a/tests/fixtures/homematicip_cloud.json +++ b/tests/fixtures/homematicip_cloud.json @@ -6978,7 +6978,7 @@ "supported": true, "type": "EXTERNAL" }, -"3014F711A000DIN_RAIL_DIMMER3": { + "3014F711A000DIN_RAIL_DIMMER3": { "availableFirmwareVersion": "1.2.0", "connectionType": "HMIP_RF", "firmwareVersion": "1.2.0", From 588b51bdfa00e6aaf52796f3493239750c008e0d Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 26 Feb 2023 21:45:14 +0100 Subject: [PATCH 0404/2810] 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 51a9697717..5752afc92a 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -49,7 +49,7 @@ class ReolinkUpdateEntity(ReolinkBaseCoordinatorEntity, UpdateEntity): """Initialize a Netgear device.""" super().__init__(reolink_data, reolink_data.firmware_coordinator) - self._attr_unique_id = f"{self._host.unique_id}_update" + self._attr_unique_id = f"{self._host.unique_id}" @property def installed_version(self) -> str | None: From 0f018665080c6eaf53153b5794fad5e8b9d4a046 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 26 Feb 2023 21:49:24 +0100 Subject: [PATCH 0405/2810] 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 6633f5c02f..2faa89232a 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -106,9 +106,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b ) # Fetch initial data so we have data when entities subscribe try: + # If camera WAN blocked, firmware check fails, do not prevent setup await asyncio.gather( device_coordinator.async_config_entry_first_refresh(), - firmware_coordinator.async_config_entry_first_refresh(), + firmware_coordinator.async_refresh(), ) except ConfigEntryNotReady: await host.stop() From bea81d3f631a743aec25eb61bf0eabb93d25eba2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Feb 2023 17:59:28 -0600 Subject: [PATCH 0406/2810] 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 86a63538a6..c68d99bfb2 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 3c3da10db7..9f6f65f1d2 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -513,6 +513,16 @@ async def async_get_all_descriptions( return descriptions +@callback +def remove_entity_service_fields(call: ServiceCall) -> dict[Any, Any]: + """Remove entity service fields.""" + return { + key: val + for key, val in call.data.items() + if key not in cv.ENTITY_SERVICE_FIELDS + } + + @callback @bind_hass def async_set_service_schema( @@ -567,11 +577,7 @@ async def entity_service_call( # noqa: C901 # If the service function is a string, we'll pass it the service call data if isinstance(func, str): - data: dict | ServiceCall = { - key: val - for key, val in call.data.items() - if key not in cv.ENTITY_SERVICE_FIELDS - } + data: dict | ServiceCall = remove_entity_service_fields(call) # If the service function is not a string, we pass the service call else: data = call From 9be3f86a4cff83b2ea5805a3f4bd10f201c0bc1b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 26 Feb 2023 20:25:29 -0500 Subject: [PATCH 0407/2810] 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 bbc50fe7a5..70fc024c00 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 a36373c862..1e0d8999d3 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 9f8398d493..8d2f179aef 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -2,6 +2,7 @@ from __future__ import annotations import ast +from collections import deque from pathlib import Path from homeassistant.const import Platform @@ -118,6 +119,7 @@ ALLOWED_USED_COMPONENTS = { "input_text", "media_source", "onboarding", + "panel_custom", "persistent_notification", "person", "script", @@ -138,22 +140,19 @@ IGNORE_VIOLATIONS = { # Has same requirement, gets defaults. ("sql", "recorder"), # Sharing a base class - ("openalpr_cloud", "openalpr_local"), ("lutron_caseta", "lutron"), ("ffmpeg_noise", "ffmpeg_motion"), # Demo ("demo", "manual"), - ("demo", "openalpr_local"), # This would be a circular dep ("http", "network"), # This would be a circular dep ("zha", "homeassistant_hardware"), + ("zha", "homeassistant_yellow"), # This should become a helper method that integrations can submit data to ("websocket_api", "lovelace"), ("websocket_api", "shopping_list"), "logbook", - # Migration wizard from zwave to zwave_js. - "zwave_js", } @@ -231,6 +230,7 @@ def find_non_referenced_integrations( def validate_dependencies( integrations: dict[str, Integration], integration: Integration, + check_dependencies: bool, ) -> None: """Validate all dependencies.""" # Some integrations are allowed to have violations. @@ -252,12 +252,60 @@ def validate_dependencies( "or 'after_dependencies'", ) + if check_dependencies: + _check_circular_deps( + integrations, integration.domain, integration, set(), deque() + ) + + +def _check_circular_deps( + integrations: dict[str, Integration], + start_domain: str, + integration: Integration, + checked: set[str], + checking: deque[str], +) -> None: + """Check for circular dependencies pointing at starting_domain.""" + if integration.domain in checked or integration.domain in checking: + return + + checking.append(integration.domain) + for domain in integration.manifest.get("dependencies", []): + if domain == start_domain: + integrations[start_domain].add_error( + "dependencies", + f"Found a circular dependency with {integration.domain} ({', '.join(checking)})", + ) + break + + _check_circular_deps( + integrations, start_domain, integrations[domain], checked, checking + ) + else: + for domain in integration.manifest.get("after_dependencies", []): + if domain == start_domain: + integrations[start_domain].add_error( + "dependencies", + f"Found a circular dependency with after dependencies of {integration.domain} ({', '.join(checking)})", + ) + break + + _check_circular_deps( + integrations, start_domain, integrations[domain], checked, checking + ) + checked.add(integration.domain) + checking.remove(integration.domain) + def validate(integrations: dict[str, Integration], config: Config) -> None: """Handle dependencies for integrations.""" # check for non-existing dependencies for integration in integrations.values(): - validate_dependencies(integrations, integration) + validate_dependencies( + integrations, + integration, + check_dependencies=not config.specific_integrations, + ) if config.specific_integrations: continue From c8fc2dc4402c9f1d79f5d55282bac0671ad442fc Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Mon, 27 Feb 2023 01:25:55 +0000 Subject: [PATCH 0408/2810] Add Camera platform to Prosegur (#76428) * add camera to prosegur * add tests * address review * better tests * clean * clean * fix tests * leftover from merge * sorting missing * Update homeassistant/components/prosegur/services.yaml Co-authored-by: Paulus Schoutsen --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/prosegur/__init__.py | 2 +- .../prosegur/alarm_control_panel.py | 9 ++ homeassistant/components/prosegur/camera.py | 97 +++++++++++++++++++ homeassistant/components/prosegur/const.py | 2 + .../components/prosegur/diagnostics.py | 29 ++++++ .../components/prosegur/manifest.json | 2 +- .../components/prosegur/services.yaml | 7 ++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/prosegur/common.py | 27 ------ tests/components/prosegur/conftest.py | 58 +++++++++++ .../prosegur/test_alarm_control_panel.py | 22 ++--- tests/components/prosegur/test_camera.py | 69 +++++++++++++ tests/components/prosegur/test_diagnostics.py | 21 ++++ tests/components/prosegur/test_init.py | 55 +++-------- 15 files changed, 317 insertions(+), 87 deletions(-) create mode 100644 homeassistant/components/prosegur/camera.py create mode 100644 homeassistant/components/prosegur/diagnostics.py create mode 100644 homeassistant/components/prosegur/services.yaml delete mode 100644 tests/components/prosegur/common.py create mode 100644 tests/components/prosegur/conftest.py create mode 100644 tests/components/prosegur/test_camera.py create mode 100644 tests/components/prosegur/test_diagnostics.py diff --git a/homeassistant/components/prosegur/__init__.py b/homeassistant/components/prosegur/__init__.py index 04f353e96b..9f594fc6da 100644 --- a/homeassistant/components/prosegur/__init__.py +++ b/homeassistant/components/prosegur/__init__.py @@ -11,7 +11,7 @@ from homeassistant.helpers import aiohttp_client from .const import CONF_COUNTRY, DOMAIN -PLATFORMS = [Platform.ALARM_CONTROL_PANEL] +PLATFORMS = [Platform.ALARM_CONTROL_PANEL, Platform.CAMERA] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/prosegur/alarm_control_panel.py b/homeassistant/components/prosegur/alarm_control_panel.py index 133c182e2c..cfcb07773f 100644 --- a/homeassistant/components/prosegur/alarm_control_panel.py +++ b/homeassistant/components/prosegur/alarm_control_panel.py @@ -15,6 +15,7 @@ from homeassistant.const import ( STATE_ALARM_DISARMED, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN @@ -59,6 +60,14 @@ class ProsegurAlarm(alarm.AlarmControlPanelEntity): self._attr_name = f"contract {self.contract}" self._attr_unique_id = self.contract + self._attr_device_info = DeviceInfo( + name="Prosegur Alarm", + manufacturer="Prosegur", + model="smart", + identifiers={(DOMAIN, self.contract)}, + configuration_url="https://smart.prosegur.com", + ) + async def async_update(self) -> None: """Update alarm status.""" diff --git a/homeassistant/components/prosegur/camera.py b/homeassistant/components/prosegur/camera.py new file mode 100644 index 0000000000..40f8e18fb6 --- /dev/null +++ b/homeassistant/components/prosegur/camera.py @@ -0,0 +1,97 @@ +"""Support for Prosegur cameras.""" +from __future__ import annotations + +import logging + +from pyprosegur.auth import Auth +from pyprosegur.exceptions import ProsegurException +from pyprosegur.installation import Camera as InstallationCamera, Installation + +from homeassistant.components.camera import Camera +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) + +from . import DOMAIN +from .const import SERVICE_REQUEST_IMAGE + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Prosegur camera platform.""" + + platform = async_get_current_platform() + platform.async_register_entity_service( + SERVICE_REQUEST_IMAGE, + {}, + "async_request_image", + ) + + _installation = await Installation.retrieve(hass.data[DOMAIN][entry.entry_id]) + + async_add_entities( + [ + ProsegurCamera(_installation, camera, hass.data[DOMAIN][entry.entry_id]) + for camera in _installation.cameras + ], + update_before_add=True, + ) + + +class ProsegurCamera(Camera): + """Representation of a Smart Prosegur Camera.""" + + def __init__( + self, installation: Installation, camera: InstallationCamera, auth: Auth + ) -> None: + """Initialize Prosegur Camera component.""" + Camera.__init__(self) + + self._installation = installation + self._camera = camera + self._auth = auth + self._attr_name = camera.description + self._attr_unique_id = f"{self._installation.contract} {camera.id}" + + self._attr_device_info = DeviceInfo( + name=self._camera.description, + manufacturer="Prosegur", + model="smart camera", + identifiers={(DOMAIN, self._installation.contract)}, + configuration_url="https://smart.prosegur.com", + ) + + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: + """Return bytes of camera image.""" + + try: + _LOGGER.debug("Get image for %s", self._camera.description) + return await self._installation.get_image(self._auth, self._camera.id) + + except ProsegurException as err: + _LOGGER.error("Image %s doesn't exist: %s", self._camera.description, err) + + return None + + async def async_request_image(self): + """Request new image from the camera.""" + + try: + _LOGGER.debug("Request image for %s", self._camera.description) + await self._installation.request_image(self._auth, self._camera.id) + + except ProsegurException as err: + _LOGGER.error( + "Could not request image from camera %s: %s", + self._camera.description, + err, + ) diff --git a/homeassistant/components/prosegur/const.py b/homeassistant/components/prosegur/const.py index b066b320a1..3f5b869197 100644 --- a/homeassistant/components/prosegur/const.py +++ b/homeassistant/components/prosegur/const.py @@ -3,3 +3,5 @@ DOMAIN = "prosegur" CONF_COUNTRY = "country" + +SERVICE_REQUEST_IMAGE = "request_image" diff --git a/homeassistant/components/prosegur/diagnostics.py b/homeassistant/components/prosegur/diagnostics.py new file mode 100644 index 0000000000..d244569834 --- /dev/null +++ b/homeassistant/components/prosegur/diagnostics.py @@ -0,0 +1,29 @@ +"""Diagnostics support for Prosegur.""" +from __future__ import annotations + +from typing import Any + +from pyprosegur.installation import Installation + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +TO_REDACT = {"description", "latitude", "longitude", "contractId", "address"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + installation = await Installation.retrieve(hass.data[DOMAIN][entry.entry_id]) + + activity = await installation.activity(hass.data[DOMAIN][entry.entry_id]) + + return { + "installation": async_redact_data(installation.data, TO_REDACT), + "activity": activity, + } diff --git a/homeassistant/components/prosegur/manifest.json b/homeassistant/components/prosegur/manifest.json index 1827939d09..d5081a82db 100644 --- a/homeassistant/components/prosegur/manifest.json +++ b/homeassistant/components/prosegur/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/prosegur", "iot_class": "cloud_polling", "loggers": ["pyprosegur"], - "requirements": ["pyprosegur==0.0.5"] + "requirements": ["pyprosegur==0.0.8"] } diff --git a/homeassistant/components/prosegur/services.yaml b/homeassistant/components/prosegur/services.yaml new file mode 100644 index 0000000000..0db63cb7ad --- /dev/null +++ b/homeassistant/components/prosegur/services.yaml @@ -0,0 +1,7 @@ +request_image: + name: Request Camera image + description: Request a new image from a Prosegur Camera + target: + entity: + domain: camera + integration: prosegur diff --git a/requirements_all.txt b/requirements_all.txt index 10dfef7810..6f8143b054 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1884,7 +1884,7 @@ pypoint==2.3.0 pyprof2calltree==1.4.5 # homeassistant.components.prosegur -pyprosegur==0.0.5 +pyprosegur==0.0.8 # homeassistant.components.prusalink pyprusalink==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3b67a58872..33aa5f39ca 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1364,7 +1364,7 @@ pypoint==2.3.0 pyprof2calltree==1.4.5 # homeassistant.components.prosegur -pyprosegur==0.0.5 +pyprosegur==0.0.8 # homeassistant.components.prusalink pyprusalink==1.1.0 diff --git a/tests/components/prosegur/common.py b/tests/components/prosegur/common.py deleted file mode 100644 index bed9d987ce..0000000000 --- a/tests/components/prosegur/common.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Common methods used across tests for Prosegur.""" -from homeassistant.components.prosegur import DOMAIN as PROSEGUR_DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.setup import async_setup_component - -from tests.common import MockConfigEntry - -CONTRACT = "1234abcd" - - -async def setup_platform(hass): - """Set up the Prosegur platform.""" - mock_entry = MockConfigEntry( - domain=PROSEGUR_DOMAIN, - data={ - "contract": "1234abcd", - CONF_USERNAME: "user@email.com", - CONF_PASSWORD: "password", - "country": "PT", - }, - ) - mock_entry.add_to_hass(hass) - - assert await async_setup_component(hass, PROSEGUR_DOMAIN, {}) - await hass.async_block_till_done() - - return mock_entry diff --git a/tests/components/prosegur/conftest.py b/tests/components/prosegur/conftest.py new file mode 100644 index 0000000000..ea906fdcbf --- /dev/null +++ b/tests/components/prosegur/conftest.py @@ -0,0 +1,58 @@ +"""Define test fixtures for Prosegur.""" +from unittest.mock import AsyncMock, patch + +from pyprosegur.installation import Camera +import pytest + +from homeassistant.components.prosegur import DOMAIN as PROSEGUR_DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +CONTRACT = "1234abcd" + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=PROSEGUR_DOMAIN, + data={ + "contract": CONTRACT, + CONF_USERNAME: "user@email.com", + CONF_PASSWORD: "password", + "country": "PT", + }, + ) + + +@pytest.fixture +def mock_install() -> AsyncMock: + """Return the mocked alarm install.""" + install = AsyncMock() + install.contract = CONTRACT + install.cameras = [Camera("1", "test_cam")] + install.get_image = AsyncMock(return_value=b"ABC") + install.request_image = AsyncMock() + + install.data = {"contract": CONTRACT} + install.activity = AsyncMock(return_value={"event": "armed"}) + + return install + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_install: AsyncMock +) -> MockConfigEntry: + """Set up the Prosegur integration for testing.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "pyprosegur.installation.Installation.retrieve", return_value=mock_install + ), patch("pyprosegur.auth.Auth.login", return_value=AsyncMock()): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/prosegur/test_alarm_control_panel.py b/tests/components/prosegur/test_alarm_control_panel.py index dce5e8d3c4..51086e74b0 100644 --- a/tests/components/prosegur/test_alarm_control_panel.py +++ b/tests/components/prosegur/test_alarm_control_panel.py @@ -20,7 +20,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_component, entity_registry as er -from .common import CONTRACT, setup_platform +from .conftest import CONTRACT PROSEGUR_ALARM_ENTITY = f"alarm_control_panel.contract_{CONTRACT}" @@ -38,17 +38,17 @@ def mock_status(request): """Mock the status of the alarm.""" install = AsyncMock() - install.contract = "123" - install.installationId = "1234abcd" + install.contract = CONTRACT install.status = request.param with patch("pyprosegur.installation.Installation.retrieve", return_value=install): yield -async def test_entity_registry(hass: HomeAssistant, mock_auth, mock_status) -> None: +async def test_entity_registry( + hass: HomeAssistant, init_integration, mock_auth, mock_status +) -> None: """Tests that the devices are registered in the entity registry.""" - await setup_platform(hass) entity_registry = er.async_get(hass) entry = entity_registry.async_get(PROSEGUR_ALARM_ENTITY) @@ -59,11 +59,13 @@ async def test_entity_registry(hass: HomeAssistant, mock_auth, mock_status) -> N state = hass.states.get(PROSEGUR_ALARM_ENTITY) - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "contract 1234abcd" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == f"contract {CONTRACT}" assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 3 -async def test_connection_error(hass: HomeAssistant, mock_auth) -> None: +async def test_connection_error( + hass: HomeAssistant, init_integration, mock_auth, mock_config_entry +) -> None: """Test the alarm control panel when connection can't be made to the cloud service.""" install = AsyncMock() @@ -73,8 +75,6 @@ async def test_connection_error(hass: HomeAssistant, mock_auth) -> None: install.status = Status.ARMED with patch("pyprosegur.installation.Installation.retrieve", return_value=install): - await setup_platform(hass) - await hass.async_block_till_done() with patch( @@ -95,7 +95,7 @@ async def test_connection_error(hass: HomeAssistant, mock_auth) -> None: ], ) async def test_arm( - hass: HomeAssistant, mock_auth, code, alarm_service, alarm_state + hass: HomeAssistant, init_integration, mock_auth, code, alarm_service, alarm_state ) -> None: """Test the alarm control panel can be set to away.""" @@ -106,8 +106,6 @@ async def test_arm( install.status = code with patch("pyprosegur.installation.Installation.retrieve", return_value=install): - await setup_platform(hass) - await hass.services.async_call( ALARM_DOMAIN, alarm_service, diff --git a/tests/components/prosegur/test_camera.py b/tests/components/prosegur/test_camera.py new file mode 100644 index 0000000000..75e4cbbc77 --- /dev/null +++ b/tests/components/prosegur/test_camera.py @@ -0,0 +1,69 @@ +"""The camera tests for the prosegur platform.""" +import logging +from unittest.mock import AsyncMock + +from pyprosegur.exceptions import ProsegurException +import pytest + +from homeassistant.components import camera +from homeassistant.components.camera import Image +from homeassistant.components.prosegur.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.exceptions import HomeAssistantError + + +async def test_camera(hass, init_integration): + """Test prosegur get_image.""" + + image = await camera.async_get_image(hass, "camera.test_cam") + + assert image == Image(content_type="image/jpeg", content=b"ABC") + + +async def test_camera_fail(hass, init_integration, mock_install, caplog): + """Test prosegur get_image fails.""" + + mock_install.get_image = AsyncMock( + return_value=b"ABC", side_effect=ProsegurException() + ) + + with caplog.at_level(logging.ERROR, logger="homeassistant.components.prosegur"): + try: + await camera.async_get_image(hass, "camera.test_cam") + except HomeAssistantError as exc: + assert str(exc) == "Unable to get image" + else: + assert pytest.fail() + + assert "Image test_cam doesn't exist" in caplog.text + + +async def test_request_image(hass, init_integration, mock_install): + """Test the camera request image service.""" + + await hass.services.async_call( + DOMAIN, + "request_image", + {ATTR_ENTITY_ID: "camera.test_cam"}, + ) + await hass.async_block_till_done() + + assert mock_install.request_image.called + + +async def test_request_image_fail(hass, init_integration, mock_install, caplog): + """Test the camera request image service fails.""" + + mock_install.request_image = AsyncMock(side_effect=ProsegurException()) + + with caplog.at_level(logging.ERROR, logger="homeassistant.components.prosegur"): + await hass.services.async_call( + DOMAIN, + "request_image", + {ATTR_ENTITY_ID: "camera.test_cam"}, + ) + await hass.async_block_till_done() + + assert mock_install.request_image.called + + assert "Could not request image from camera test_cam" in caplog.text diff --git a/tests/components/prosegur/test_diagnostics.py b/tests/components/prosegur/test_diagnostics.py new file mode 100644 index 0000000000..85377833a7 --- /dev/null +++ b/tests/components/prosegur/test_diagnostics.py @@ -0,0 +1,21 @@ +"""Test Prosegur diagnostics.""" + +from unittest.mock import patch + +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_diagnostics(hass, hass_client, init_integration, mock_install): + """Test generating diagnostics for a config entry.""" + + with patch( + "pyprosegur.installation.Installation.retrieve", return_value=mock_install + ): + diag = await get_diagnostics_for_config_entry( + hass, hass_client, init_integration + ) + + assert diag == { + "installation": {"contract": "1234abcd"}, + "activity": {"event": "armed"}, + } diff --git a/tests/components/prosegur/test_init.py b/tests/components/prosegur/test_init.py index 7f0373ea93..cdc7135cf1 100644 --- a/tests/components/prosegur/test_init.py +++ b/tests/components/prosegur/test_init.py @@ -1,12 +1,10 @@ """Tests prosegur setup.""" -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest -from homeassistant.components.prosegur import DOMAIN from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -17,59 +15,28 @@ from tests.test_util.aiohttp import AiohttpClientMocker ConnectionError, ], ) -async def test_setup_entry_fail_retrieve(hass: HomeAssistant, error) -> None: +async def test_setup_entry_fail_retrieve( + hass: HomeAssistant, mock_config_entry, error +) -> None: """Test loading the Prosegur entry.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - "username": "test-username", - "password": "test-password", - "country": "PT", - "contract": "xpto", - }, - ) - config_entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) with patch( "pyprosegur.auth.Auth.login", side_effect=error, ): - assert not await hass.config_entries.async_setup(config_entry.entry_id) + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() async def test_unload_entry( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + init_integration, + mock_config_entry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test unloading the Prosegur entry.""" - aioclient_mock.post( - "https://smart.prosegur.com/smart-server/ws/access/login", - json={"data": {"token": "123456789"}}, - ) - - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - "username": "test-username", - "password": "test-password", - "country": "PT", - "contract": "xpto", - }, - ) - config_entry.add_to_hass(hass) - - install = MagicMock() - install.contract = "123" - - with patch( - "homeassistant.components.prosegur.config_flow.Installation.retrieve", - return_value=install, - ): - assert await hass.config_entries.async_setup(config_entry.entry_id) - - await hass.async_block_till_done() - - assert await hass.config_entries.async_unload(config_entry.entry_id) + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) From d219e7c8b1a2d75cfd2f956b4afccacbe5cd6245 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Feb 2023 20:06:27 -0600 Subject: [PATCH 0409/2810] 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 718a6b571a..dedfc9127a 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 b8d9ad3d16..e34ace05e1 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 6f8143b054..7d8fa102c8 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 33aa5f39ca..51736e7e4c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1895,13 +1895,13 @@ xmltodict==0.13.0 yalesmartalarmclient==0.3.9 # homeassistant.components.yalexs_ble -yalexs-ble==2.0.3 +yalexs-ble==2.0.4 # homeassistant.components.august yalexs==1.2.7 # homeassistant.components.august -yalexs_ble==2.0.3 +yalexs_ble==2.0.4 # homeassistant.components.yeelight yeelight==0.7.10 From 480a495239f1f2e038fd75822d05f4b48331228b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Feb 2023 20:08:20 -0600 Subject: [PATCH 0410/2810] 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 4e659d39cc..96d31872d0 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 1828687c0d..ea3730fa3e 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 1c348fc008..854109bee6 100644 --- a/tests/components/unifiprotect/test_config_flow.py +++ b/tests/components/unifiprotect/test_config_flow.py @@ -71,7 +71,10 @@ async def test_form(hass: HomeAssistant, nvr: NVR) -> None: ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, - ) as mock_setup_entry: + ) as mock_setup_entry, patch( + "homeassistant.components.unifiprotect.async_setup", + return_value=True, + ) as mock_setup: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -93,6 +96,7 @@ async def test_form(hass: HomeAssistant, nvr: NVR) -> None: "verify_ssl": False, } assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_setup.mock_calls) == 1 async def test_form_version_too_old(hass: HomeAssistant, old_nvr: NVR) -> None: @@ -214,7 +218,10 @@ async def test_form_reauth_auth(hass: HomeAssistant, nvr: NVR) -> None: with patch( "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr", return_value=nvr, - ): + ), patch( + "homeassistant.components.unifiprotect.async_setup", + return_value=True, + ) as mock_setup: result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], { @@ -225,6 +232,7 @@ async def test_form_reauth_auth(hass: HomeAssistant, nvr: NVR) -> None: assert result3["type"] == FlowResultType.ABORT assert result3["reason"] == "reauth_successful" + assert len(mock_setup.mock_calls) == 1 async def test_form_options(hass: HomeAssistant, ufp_client: ProtectApiClient) -> None: @@ -332,7 +340,10 @@ async def test_discovered_by_unifi_discovery_direct_connect( ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, - ) as mock_setup_entry: + ) as mock_setup_entry, patch( + "homeassistant.components.unifiprotect.async_setup", + return_value=True, + ) as mock_setup: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -353,6 +364,7 @@ async def test_discovered_by_unifi_discovery_direct_connect( "verify_ssl": True, } assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_setup.mock_calls) == 1 async def test_discovered_by_unifi_discovery_direct_connect_updated( @@ -515,7 +527,10 @@ async def test_discovered_by_unifi_discovery(hass: HomeAssistant, nvr: NVR) -> N ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, - ) as mock_setup_entry: + ) as mock_setup_entry, patch( + "homeassistant.components.unifiprotect.async_setup", + return_value=True, + ) as mock_setup: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -536,6 +551,7 @@ async def test_discovered_by_unifi_discovery(hass: HomeAssistant, nvr: NVR) -> N "verify_ssl": False, } assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_setup.mock_calls) == 1 async def test_discovered_by_unifi_discovery_partial( @@ -567,7 +583,10 @@ async def test_discovered_by_unifi_discovery_partial( ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, - ) as mock_setup_entry: + ) as mock_setup_entry, patch( + "homeassistant.components.unifiprotect.async_setup", + return_value=True, + ) as mock_setup: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -588,6 +607,7 @@ async def test_discovered_by_unifi_discovery_partial( "verify_ssl": False, } assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_setup.mock_calls) == 1 async def test_discovered_by_unifi_discovery_direct_connect_on_different_interface( @@ -736,7 +756,10 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, - ) as mock_setup_entry: + ) as mock_setup_entry, patch( + "homeassistant.components.unifiprotect.async_setup", + return_value=True, + ) as mock_setup: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -757,6 +780,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa "verify_ssl": True, } assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_setup.mock_calls) == 1 async def test_discovered_by_unifi_discovery_direct_connect_on_different_interface_resolver_no_result( From 4898d2296041f81493f694a311ea41e74892e68a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Feb 2023 20:14:54 -0600 Subject: [PATCH 0411/2810] 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 7d7ef2d42b..86b73c762f 100644 --- a/homeassistant/components/flux_led/__init__.py +++ b/homeassistant/components/flux_led/__init__.py @@ -87,14 +87,23 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, STARTUP_SCAN_TIMEOUT ) + @callback + def _async_start_background_discovery(*_: Any) -> None: + """Run discovery in the background.""" + hass.async_create_background_task(_async_discovery(), "flux_led-discovery") + async def _async_discovery(*_: Any) -> None: async_trigger_discovery( hass, await async_discover_devices(hass, DISCOVER_SCAN_TIMEOUT) ) async_trigger_discovery(hass, domain_data[FLUX_LED_DISCOVERY]) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _async_discovery) - async_track_time_interval(hass, _async_discovery, DISCOVERY_INTERVAL) + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STARTED, _async_start_background_discovery + ) + async_track_time_interval( + hass, _async_start_background_discovery, DISCOVERY_INTERVAL + ) return True From f8934175cbe88df04070c7e6c020a322005e8126 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Feb 2023 21:01:02 -0600 Subject: [PATCH 0412/2810] 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 df4cb65152..bbea3f1d5f 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 aecdc79da9..12b77aded8 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -29,6 +29,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.setup import async_set_domains_to_be_loaded, async_setup_component from homeassistant.util import dt +import homeassistant.util.dt as dt_util from .common import ( MockConfigEntry, @@ -999,6 +1000,27 @@ async def test_setup_retrying_during_unload_before_started(hass: HomeAssistant) ) +async def test_setup_does_not_retry_during_shutdown(hass: HomeAssistant) -> None: + """Test we do not retry when HASS is shutting down.""" + entry = MockConfigEntry(domain="test") + + mock_setup_entry = AsyncMock(side_effect=ConfigEntryNotReady) + mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) + mock_entity_platform(hass, "config_flow.test", None) + + await entry.async_setup(hass) + + assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY + assert len(mock_setup_entry.mock_calls) == 1 + + hass.state = CoreState.stopping + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_create_entry_options(hass: HomeAssistant) -> None: """Test a config entry being created with options.""" From 1d1c553d9be847c0e2fe25b93b963d1d9480f1fc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Feb 2023 21:02:52 -0600 Subject: [PATCH 0413/2810] 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 78e713ce5e..d5cf65d872 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 b91ac2cbf4..aabc27ff14 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 26697219ae..2686138d72 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 a8dec3cca2..83ad809016 100644 --- a/tests/components/bluetooth/test_active_update_processor.py +++ b/tests/components/bluetooth/test_active_update_processor.py @@ -16,7 +16,7 @@ from homeassistant.components.bluetooth import ( from homeassistant.components.bluetooth.active_update_processor import ( ActiveBluetoothProcessorCoordinator, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo from homeassistant.setup import async_setup_component @@ -384,3 +384,65 @@ async def test_rate_limit( assert async_handle_update.mock_calls[-1] == call({"testdata": 1}) cancel() + + +async def test_no_polling_after_stop_event( + hass: HomeAssistant, + mock_bleak_scanner_start: MagicMock, + mock_bluetooth_adapters: None, +) -> None: + """Test we do not poll after the stop event.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + needs_poll_calls = 0 + + def _update_method(service_info: BluetoothServiceInfoBleak): + return {"testdata": 0} + + def _poll_needed(*args, **kwargs): + nonlocal needs_poll_calls + needs_poll_calls += 1 + return True + + async def _poll(*args, **kwargs): + return {"testdata": 1} + + coordinator = ActiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + address="aa:bb:cc:dd:ee:ff", + mode=BluetoothScanningMode.ACTIVE, + update_method=_update_method, + needs_poll_method=_poll_needed, + poll_method=_poll, + ) + assert coordinator.available is False # no data yet + + processor = MagicMock() + coordinator.async_register_processor(processor) + async_handle_update = processor.async_handle_update + + cancel = coordinator.async_start() + + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) + await hass.async_block_till_done() + assert needs_poll_calls == 1 + + assert coordinator.available is True + + # async_handle_update should have been called twice + # The first time, it was passed the data from parsing the advertisement + # The second time, it was passed the data from polling + assert len(async_handle_update.mock_calls) == 2 + assert async_handle_update.mock_calls[0] == call({"testdata": 0}) + assert async_handle_update.mock_calls[1] == call({"testdata": 1}) + + hass.state = CoreState.stopping + await hass.async_block_till_done() + assert needs_poll_calls == 1 + + # Should not generate a poll now that CoreState is stopping + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO_2) + await hass.async_block_till_done() + assert needs_poll_calls == 1 + + cancel() From b5223e1196b46f4aba6a1b6275388524b4aefaaf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Feb 2023 21:36:18 -0600 Subject: [PATCH 0414/2810] 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 7268b7d8f2..7003b87ce6 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 81c9fb8186..b07a17972f 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 4749daa0c0..eb81efae92 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 5b78e0c4ffe61f22fcbd75156a2cbc41b5a37cf2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Feb 2023 21:36:18 -0600 Subject: [PATCH 0415/2810] 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 7268b7d8f2..7003b87ce6 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 81c9fb8186..b07a17972f 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 4749daa0c0..eb81efae92 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 0416/2810] 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 c2c84bf855..45ceb471fd 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 0417/2810] 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 57c5f680e0..fd77b5e234 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 89c993cafa..263fd41b30 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 6050010b20..434a9a50ea 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 d9a2bd365e..5114cc222e 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 579b8e7915..7907881188 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 d554eaf4ff..e255ff3650 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 94f3704048..8aabad2c9f 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 23634e77c5..95d96de976 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 ae13c59367..7a24f87ef9 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 bc01351902..4264ae7956 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 0ae9c73e6b..b75c49b2b7 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 0ced1799b4..e4f90a59f6 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 9263919214..3360c82577 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 0418/2810] 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 bbc50fe7a5..70fc024c00 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 a36373c862..1e0d8999d3 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 9f8398d493..8d2f179aef 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 0419/2810] 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 51a9697717..5752afc92a 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 0420/2810] 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 6633f5c02f..2faa89232a 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 0421/2810] 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 718a6b571a..dedfc9127a 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 b8d9ad3d16..e34ace05e1 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 7a24f87ef9..358caaf853 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 4264ae7956..c20cbe6a59 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 0422/2810] 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 4e659d39cc..96d31872d0 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 1828687c0d..ea3730fa3e 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 1c348fc008..854109bee6 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 0423/2810] 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 86a63538a6..c68d99bfb2 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 3c3da10db7..9f6f65f1d2 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 0424/2810] 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 7d7ef2d42b..86b73c762f 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 0425/2810] 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 df4cb65152..bbea3f1d5f 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 aecdc79da9..12b77aded8 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 0426/2810] 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 78e713ce5e..d5cf65d872 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 b91ac2cbf4..aabc27ff14 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 26697219ae..2686138d72 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 a8dec3cca2..83ad809016 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 0427/2810] 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 9f0249e992..541b5e6a96 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 37d3b7f021..121f3726ca 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 0d25eef19ca68af4496bddb496834e3e5fb39fca Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 26 Feb 2023 22:42:17 -0500 Subject: [PATCH 0428/2810] Use a background task for LIFX discovery (#88820) --- homeassistant/components/lifx/__init__.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py index b2265d81da..1bdbc618fd 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -17,10 +17,9 @@ from homeassistant.const import ( CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STARTED, - EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_call_later, async_track_time_interval @@ -167,15 +166,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: We do not want the discovery task to block startup. """ - task = asyncio.create_task(discovery_manager.async_discovery()) - - @callback - def _async_stop(_: Event) -> None: - if not task.done(): - task.cancel() - - # Task must be shut down when home assistant is closing - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop) + hass.async_create_background_task( + discovery_manager.async_discovery(), "lifx-discovery" + ) # Let the system settle a bit before starting discovery # to reduce the risk we miss devices because the event From 33466cdddd164fb208a7e7bc3138f28b4844aab6 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 27 Feb 2023 07:45:53 +0100 Subject: [PATCH 0429/2810] Add climate state translations to Overkiz integration (#88809) Add climate translations --- .../atlantic_electrical_heater.py | 2 ++ ...er_with_adjustable_temperature_setpoint.py | 2 ++ .../atlantic_electrical_towel_dryer.py | 2 ++ .../atlantic_heat_recovery_ventilation.py | 2 ++ .../atlantic_pass_apc_heating_zone.py | 2 ++ .../climate_entities/somfy_thermostat.py | 3 ++ homeassistant/components/overkiz/strings.json | 28 +++++++++++++++++++ 7 files changed, 41 insertions(+) diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater.py b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater.py index bb09543605..46a330c97c 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater.py @@ -15,6 +15,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import UnitOfTemperature +from ..const import DOMAIN from ..entity import OverkizEntity PRESET_COMFORT1 = "comfort-1" @@ -47,6 +48,7 @@ class AtlanticElectricalHeater(OverkizEntity, ClimateEntity): _attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] _attr_supported_features = ClimateEntityFeature.PRESET_MODE _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_translation_key = DOMAIN @property def hvac_mode(self) -> HVACMode: diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py index 3b02523ec2..d79d2fca68 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py @@ -16,6 +16,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from ..const import DOMAIN from ..coordinator import OverkizDataUpdateCoordinator from ..entity import OverkizEntity @@ -70,6 +71,7 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint( _attr_supported_features = ( ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE ) + _attr_translation_key = DOMAIN def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py index c9885ada42..c8e4920a11 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py @@ -14,6 +14,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from ..const import DOMAIN from ..coordinator import OverkizDataUpdateCoordinator from ..entity import OverkizEntity @@ -43,6 +44,7 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity): _attr_hvac_modes = [*HVAC_MODE_TO_OVERKIZ] _attr_preset_modes = [*PRESET_MODE_TO_OVERKIZ] _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_translation_key = DOMAIN def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_heat_recovery_ventilation.py b/homeassistant/components/overkiz/climate_entities/atlantic_heat_recovery_ventilation.py index 7c469518f8..1da7c48f9e 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_heat_recovery_ventilation.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_heat_recovery_ventilation.py @@ -13,6 +13,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import UnitOfTemperature +from ..const import DOMAIN from ..coordinator import OverkizDataUpdateCoordinator from ..entity import OverkizEntity @@ -49,6 +50,7 @@ class AtlanticHeatRecoveryVentilation(OverkizEntity, ClimateEntity): _attr_supported_features = ( ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.FAN_MODE ) + _attr_translation_key = DOMAIN def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py index e90edad113..b6835d93eb 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py @@ -17,6 +17,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from ..const import DOMAIN from ..coordinator import OverkizDataUpdateCoordinator from ..entity import OverkizEntity @@ -78,6 +79,7 @@ class AtlanticPassAPCHeatingZone(OverkizEntity, ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_translation_key = DOMAIN def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator diff --git a/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py b/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py index c3fd7cd964..8242fdc857 100644 --- a/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py +++ b/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py @@ -15,6 +15,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from ..const import DOMAIN from ..coordinator import OverkizDataUpdateCoordinator from ..entity import OverkizEntity @@ -60,6 +61,8 @@ class SomfyThermostat(OverkizEntity, ClimateEntity): ) _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ] _attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] + _attr_translation_key = DOMAIN + # Both min and max temp values have been retrieved from the Somfy Application. _attr_min_temp = 15.0 _attr_max_temp = 26.0 diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json index 5f4f3a0464..4140578012 100644 --- a/homeassistant/components/overkiz/strings.json +++ b/homeassistant/components/overkiz/strings.json @@ -28,6 +28,34 @@ } }, "entity": { + "climate": { + "overkiz": { + "state_attributes": { + "preset_mode": { + "state": { + "auto": "Auto", + "comfort-1": "Comfort 1", + "comfort-2": "Comfort 2", + "drying": "Drying", + "external": "External", + "freeze": "Freeze", + "frost_protection": "Frost protection", + "manual": "Manual", + "night": "Night", + "prog": "Prog" + } + }, + "fan_mode": { + "state": { + "away": "Away", + "bypass_boost": "Bypass boost", + "home_boost": "Home boost", + "kitchen_boost": "Kitchen boost" + } + } + } + } + }, "select": { "open_closed_pedestrian": { "state": { From 4fd7ca503f1062edcda0078b603a4d667185b10e Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 27 Feb 2023 08:09:45 +0100 Subject: [PATCH 0430/2810] Bump pyoverkiz to 1.7.6 (#88808) --- homeassistant/components/overkiz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index 658f3f5c7f..6ba7db46dd 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.7.3"], + "requirements": ["pyoverkiz==1.7.6"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 7d8fa102c8..2d47939f70 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1857,7 +1857,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.7.3 +pyoverkiz==1.7.6 # homeassistant.components.openweathermap pyowm==3.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 51736e7e4c..35eb4e939e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1340,7 +1340,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.7.3 +pyoverkiz==1.7.6 # homeassistant.components.openweathermap pyowm==3.2.0 From 66b33e1090251d3a07bc5b14436c4be99fa91a14 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Feb 2023 08:41:23 +0100 Subject: [PATCH 0431/2810] Bump actions/checkout from 3.1.0 to 3.3.0 (#88824) Bumps [actions/checkout](https://github.com/actions/checkout) from 3.1.0 to 3.3.0. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v3.1.0...v3.3.0) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 57c7425932..b45ffcd2d8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1073,7 +1073,7 @@ jobs: ffmpeg \ postgresql-server-dev-14 - name: Check out code from GitHub - uses: actions/checkout@v3.1.0 + uses: actions/checkout@v3.3.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.3.0 From b7846de31122d559fdc5feb74ff149dd46e5abeb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Feb 2023 09:30:33 +0100 Subject: [PATCH 0432/2810] Bump actions/setup-python from 4.3.0 to 4.5.0 (#88823) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4.3.0 to 4.5.0. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v4.3.0...v4.5.0) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b45ffcd2d8..117b64022c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1076,7 +1076,7 @@ jobs: uses: actions/checkout@v3.3.0 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.3.0 + uses: actions/setup-python@v4.5.0 with: python-version: ${{ matrix.python-version }} check-latest: true From 10bf910f885a8d93a244a82768dc9e805d23155b Mon Sep 17 00:00:00 2001 From: StefanIacobLivisi <109964424+StefanIacobLivisi@users.noreply.github.com> Date: Mon, 27 Feb 2023 11:38:52 +0200 Subject: [PATCH 0433/2810] Add support for LIVISI climate devices (#86691) * Add support for LIVISI climate devices * Remove the reauthentication logic * Add support for LIVISI climate devices * Remove the reauthentication support * Code review follow-up * Update homeassistant/components/livisi/manifest.json Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/livisi/manifest.json Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Code review follow-up * Code Review Follow-up * Code Review Follow-up * Code review follow-up * Code review follow-up * Code review follow-up * Code review follow-up --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .coveragerc | 4 + homeassistant/components/livisi/__init__.py | 5 +- homeassistant/components/livisi/climate.py | 215 ++++++++++++++++++ homeassistant/components/livisi/const.py | 7 +- .../components/livisi/coordinator.py | 42 +++- 5 files changed, 266 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/livisi/climate.py diff --git a/.coveragerc b/.coveragerc index 7429966011..a06f4fa92d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -639,6 +639,10 @@ omit = homeassistant/components/linode/* homeassistant/components/linux_battery/sensor.py homeassistant/components/lirc/* + homeassistant/components/livisi/__init__.py + homeassistant/components/livisi/climate.py + homeassistant/components/livisi/coordinator.py + homeassistant/components/livisi/switch.py homeassistant/components/llamalab_automate/notify.py homeassistant/components/logi_circle/__init__.py homeassistant/components/logi_circle/camera.py diff --git a/homeassistant/components/livisi/__init__.py b/homeassistant/components/livisi/__init__.py index e71c6bca66..b8d8fdbfb0 100644 --- a/homeassistant/components/livisi/__init__.py +++ b/homeassistant/components/livisi/__init__.py @@ -8,14 +8,15 @@ from aiolivisi import AioLivisi from homeassistant import core from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, device_registry as dr -from .const import DOMAIN, SWITCH_PLATFORM +from .const import DOMAIN from .coordinator import LivisiDataUpdateCoordinator -PLATFORMS: Final = [SWITCH_PLATFORM] +PLATFORMS: Final = [Platform.CLIMATE, Platform.SWITCH] async def async_setup_entry(hass: core.HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/livisi/climate.py b/homeassistant/components/livisi/climate.py new file mode 100644 index 0000000000..d0bdbe64bf --- /dev/null +++ b/homeassistant/components/livisi/climate.py @@ -0,0 +1,215 @@ +"""Code to handle a Livisi Virtual Climate Control.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from aiolivisi.const import CAPABILITY_MAP + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + DOMAIN, + LIVISI_REACHABILITY_CHANGE, + LIVISI_STATE_CHANGE, + LOGGER, + MAX_TEMPERATURE, + MIN_TEMPERATURE, + VRCC_DEVICE_TYPE, +) +from .coordinator import LivisiDataUpdateCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up climate device.""" + coordinator: LivisiDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + @callback + def handle_coordinator_update() -> None: + """Add climate device.""" + shc_devices: list[dict[str, Any]] = coordinator.data + entities: list[ClimateEntity] = [] + for device in shc_devices: + if ( + device["type"] == VRCC_DEVICE_TYPE + and device["id"] not in coordinator.devices + ): + livisi_climate: ClimateEntity = create_entity( + config_entry, device, coordinator + ) + LOGGER.debug("Include device type: %s", device.get("type")) + coordinator.devices.add(device["id"]) + entities.append(livisi_climate) + async_add_entities(entities) + + config_entry.async_on_unload( + coordinator.async_add_listener(handle_coordinator_update) + ) + + +def create_entity( + config_entry: ConfigEntry, + device: dict[str, Any], + coordinator: LivisiDataUpdateCoordinator, +) -> ClimateEntity: + """Create Climate Entity.""" + capabilities: Mapping[str, Any] = device[CAPABILITY_MAP] + room_id: str = device["location"] + room_name: str = coordinator.rooms[room_id] + livisi_climate = LivisiClimate( + config_entry, + coordinator, + unique_id=device["id"], + manufacturer=device["manufacturer"], + device_type=device["type"], + target_temperature_capability=capabilities["RoomSetpoint"], + temperature_capability=capabilities["RoomTemperature"], + humidity_capability=capabilities["RoomHumidity"], + room=room_name, + ) + return livisi_climate + + +class LivisiClimate(CoordinatorEntity[LivisiDataUpdateCoordinator], ClimateEntity): + """Represents the Livisi Climate.""" + + _attr_hvac_modes = [HVACMode.HEAT] + _attr_hvac_mode = HVACMode.HEAT + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_target_temperature_high = MAX_TEMPERATURE + _attr_target_temperature_low = MIN_TEMPERATURE + + def __init__( + self, + config_entry: ConfigEntry, + coordinator: LivisiDataUpdateCoordinator, + unique_id: str, + manufacturer: str, + device_type: str, + target_temperature_capability: str, + temperature_capability: str, + humidity_capability: str, + room: str, + ) -> None: + """Initialize the Livisi Climate.""" + self.config_entry = config_entry + self._attr_unique_id = unique_id + self._target_temperature_capability = target_temperature_capability + self._temperature_capability = temperature_capability + self._humidity_capability = humidity_capability + self.aio_livisi = coordinator.aiolivisi + self._attr_available = False + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer=manufacturer, + model=device_type, + name=room, + suggested_area=room, + via_device=(DOMAIN, config_entry.entry_id), + ) + super().__init__(coordinator) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + response = await self.aio_livisi.async_vrcc_set_temperature( + self._target_temperature_capability, + kwargs.get(ATTR_TEMPERATURE), + self.coordinator.is_avatar, + ) + if response is None: + self._attr_available = False + raise HomeAssistantError(f"Failed to turn off {self._attr_name}") + + def set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Do nothing as LIVISI devices do not support changing the hvac mode.""" + raise HomeAssistantError( + "This feature is not supported with the LIVISI climate devices" + ) + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + target_temperature = await self.coordinator.async_get_vrcc_target_temperature( + self._target_temperature_capability + ) + temperature = await self.coordinator.async_get_vrcc_temperature( + self._temperature_capability + ) + humidity = await self.coordinator.async_get_vrcc_humidity( + self._humidity_capability + ) + if temperature is None: + self._attr_current_temperature = None + self._attr_available = False + else: + self._attr_target_temperature = target_temperature + self._attr_current_temperature = temperature + self._attr_current_humidity = humidity + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{LIVISI_STATE_CHANGE}_{self._target_temperature_capability}", + self.update_target_temperature, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{LIVISI_STATE_CHANGE}_{self._temperature_capability}", + self.update_temperature, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{LIVISI_STATE_CHANGE}_{self._humidity_capability}", + self.update_humidity, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{LIVISI_REACHABILITY_CHANGE}_{self.unique_id}", + self.update_reachability, + ) + ) + + @callback + def update_target_temperature(self, target_temperature: float) -> None: + """Update the target temperature of the climate device.""" + self._attr_target_temperature = target_temperature + self.async_write_ha_state() + + @callback + def update_temperature(self, current_temperature: float) -> None: + """Update the current temperature of the climate device.""" + self._attr_current_temperature = current_temperature + self.async_write_ha_state() + + @callback + def update_humidity(self, humidity: int) -> None: + """Update the humidity temperature of the climate device.""" + self._attr_current_humidity = humidity + self.async_write_ha_state() + + @callback + def update_reachability(self, is_reachable: bool) -> None: + """Update the reachability of the climate device.""" + self._attr_available = is_reachable + self.async_write_ha_state() diff --git a/homeassistant/components/livisi/const.py b/homeassistant/components/livisi/const.py index e6abc5118d..684510cf7e 100644 --- a/homeassistant/components/livisi/const.py +++ b/homeassistant/components/livisi/const.py @@ -7,12 +7,15 @@ DOMAIN = "livisi" CONF_HOST = "host" CONF_PASSWORD: Final = "password" +AVATAR = "Avatar" AVATAR_PORT: Final = 9090 CLASSIC_PORT: Final = 8080 DEVICE_POLLING_DELAY: Final = 60 LIVISI_STATE_CHANGE: Final = "livisi_state_change" LIVISI_REACHABILITY_CHANGE: Final = "livisi_reachability_change" -SWITCH_PLATFORM: Final = "switch" - PSS_DEVICE_TYPE: Final = "PSS" +VRCC_DEVICE_TYPE: Final = "VRCC" + +MAX_TEMPERATURE: Final = 30.0 +MIN_TEMPERATURE: Final = 6.0 diff --git a/homeassistant/components/livisi/coordinator.py b/homeassistant/components/livisi/coordinator.py index 47a612274a..e6c29f7151 100644 --- a/homeassistant/components/livisi/coordinator.py +++ b/homeassistant/components/livisi/coordinator.py @@ -13,6 +13,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( + AVATAR, AVATAR_PORT, CLASSIC_PORT, CONF_HOST, @@ -69,14 +70,14 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): livisi_connection_data=livisi_connection_data ) controller_data = await self.aiolivisi.async_get_controller() - if controller_data["controllerType"] == "Avatar": + if (controller_type := controller_data["controllerType"]) == AVATAR: self.port = AVATAR_PORT self.is_avatar = True else: self.port = CLASSIC_PORT self.is_avatar = False + self.controller_type = controller_type self.serial_number = controller_data["serialNumber"] - self.controller_type = controller_data["controllerType"] async def async_get_devices(self) -> list[dict[str, Any]]: """Set the discovered devices list.""" @@ -84,7 +85,7 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): async def async_get_pss_state(self, capability: str) -> bool | None: """Set the PSS state.""" - response: dict[str, Any] = await self.aiolivisi.async_get_device_state( + response: dict[str, Any] | None = await self.aiolivisi.async_get_device_state( capability[1:] ) if response is None: @@ -92,6 +93,35 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): on_state = response["onState"] return on_state["value"] + async def async_get_vrcc_target_temperature(self, capability: str) -> float | None: + """Get the target temperature of the climate device.""" + response: dict[str, Any] | None = await self.aiolivisi.async_get_device_state( + capability[1:] + ) + if response is None: + return None + if self.is_avatar: + return response["setpointTemperature"]["value"] + return response["pointTemperature"]["value"] + + async def async_get_vrcc_temperature(self, capability: str) -> float | None: + """Get the temperature of the climate device.""" + response: dict[str, Any] | None = await self.aiolivisi.async_get_device_state( + capability[1:] + ) + if response is None: + return None + return response["temperature"]["value"] + + async def async_get_vrcc_humidity(self, capability: str) -> int | None: + """Get the humidity of the climate device.""" + response: dict[str, Any] | None = await self.aiolivisi.async_get_device_state( + capability[1:] + ) + if response is None: + return None + return response["humidity"]["value"] + async def async_set_all_rooms(self) -> None: """Set the room list.""" response: list[dict[str, Any]] = await self.aiolivisi.async_get_all_rooms() @@ -108,6 +138,12 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): f"{LIVISI_STATE_CHANGE}_{event_data.source}", event_data.onState, ) + if event_data.vrccData is not None: + async_dispatcher_send( + self.hass, + f"{LIVISI_STATE_CHANGE}_{event_data.source}", + event_data.vrccData, + ) if event_data.isReachable is not None: async_dispatcher_send( self.hass, From ae3e8746f73a0b4ceb7bc97c5fcc16a356a1f119 Mon Sep 17 00:00:00 2001 From: Michael Davie Date: Mon, 27 Feb 2023 05:19:29 -0500 Subject: [PATCH 0434/2810] 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 5ea67d3a07..c2c2485d94 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 2d47939f70..f2435e78b1 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 35eb4e939e..7e0a14a6a3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -514,7 +514,7 @@ energyzero==0.3.1 enocean==0.50 # homeassistant.components.environment_canada -env_canada==0.5.28 +env_canada==0.5.29 # homeassistant.components.enphase_envoy envoy_reader==0.20.1 From 735000475a07604563c8ac51d7b037610e5b2923 Mon Sep 17 00:00:00 2001 From: stickpin <630000+stickpin@users.noreply.github.com> Date: Mon, 27 Feb 2023 11:29:46 +0100 Subject: [PATCH 0435/2810] 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 e44251ed7c..16624f2af5 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 f2435e78b1..7db4120aa0 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 7e0a14a6a3..d13208a56d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -405,7 +405,7 @@ bthome-ble==2.5.2 buienradar==1.0.5 # homeassistant.components.caldav -caldav==1.1.3 +caldav==1.2.0 # homeassistant.components.co2signal co2signal==0.4.2 From fe8f3602ff57fd79712dd3239a8b808e745bdc75 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Feb 2023 11:46:55 +0100 Subject: [PATCH 0436/2810] 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 75c37ab7b7..fd86024fbd 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 b3c3f9262d..7d96d51d5c 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -915,6 +915,7 @@ async def test_unit_conversion_priority( assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == automatic_unit # Assert the automatic unit conversion is stored in the registry entry = entity_registry.async_get(entity0.entity_id) + assert entry.unit_of_measurement == automatic_unit assert entry.options == { "sensor.private": {"suggested_unit_of_measurement": automatic_unit} } @@ -930,6 +931,7 @@ async def test_unit_conversion_priority( assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == suggested_unit # Assert the suggested unit is stored in the registry entry = entity_registry.async_get(entity2.entity_id) + assert entry.unit_of_measurement == suggested_unit assert entry.options == { "sensor.private": {"suggested_unit_of_measurement": suggested_unit} } @@ -1065,6 +1067,7 @@ async def test_unit_conversion_priority_precision( assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == automatic_unit # Assert the automatic unit conversion is stored in the registry entry = entity_registry.async_get(entity0.entity_id) + assert entry.unit_of_measurement == automatic_unit assert entry.options == { "sensor": {"suggested_display_precision": 2}, "sensor.private": {"suggested_unit_of_measurement": automatic_unit}, @@ -1081,6 +1084,7 @@ async def test_unit_conversion_priority_precision( assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == suggested_unit # Assert the suggested unit is stored in the registry entry = entity_registry.async_get(entity2.entity_id) + assert entry.unit_of_measurement == suggested_unit assert entry.options == { "sensor": {"suggested_display_precision": 2}, "sensor.private": {"suggested_unit_of_measurement": suggested_unit}, @@ -1154,13 +1158,17 @@ async def test_unit_conversion_priority_suggested_unit_change( platform.init(empty=True) # Pre-register entities - entry = entity_registry.async_get_or_create("sensor", "test", "very_unique") + entry = entity_registry.async_get_or_create( + "sensor", "test", "very_unique", unit_of_measurement=original_unit + ) entity_registry.async_update_entity_options( entry.entity_id, "sensor.private", {"suggested_unit_of_measurement": original_unit}, ) - entry = entity_registry.async_get_or_create("sensor", "test", "very_unique_2") + entry = entity_registry.async_get_or_create( + "sensor", "test", "very_unique_2", unit_of_measurement=original_unit + ) entity_registry.async_update_entity_options( entry.entity_id, "sensor.private", @@ -1193,11 +1201,124 @@ async def test_unit_conversion_priority_suggested_unit_change( state = hass.states.get(entity0.entity_id) assert float(state.state) == pytest.approx(float(original_value)) assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == original_unit + # Assert the suggested unit is stored in the registry + entry = entity_registry.async_get(entity0.entity_id) + assert entry.unit_of_measurement == original_unit + assert entry.options == { + "sensor.private": {"suggested_unit_of_measurement": original_unit}, + } # Registered entity -> Follow suggested unit the first time the entity was seen state = hass.states.get(entity1.entity_id) assert float(state.state) == pytest.approx(float(original_value)) assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == original_unit + # Assert the suggested unit is stored in the registry + entry = entity_registry.async_get(entity1.entity_id) + assert entry.unit_of_measurement == original_unit + assert entry.options == { + "sensor.private": {"suggested_unit_of_measurement": original_unit}, + } + + +@pytest.mark.parametrize( + ( + "native_unit_1", + "native_unit_2", + "suggested_unit", + "native_value", + "original_value", + "device_class", + ), + [ + # Distance + ( + UnitOfLength.KILOMETERS, + UnitOfLength.METERS, + UnitOfLength.KILOMETERS, + 1000000, + 1000, + SensorDeviceClass.DISTANCE, + ), + # Energy + ( + UnitOfEnergy.KILO_WATT_HOUR, + UnitOfEnergy.WATT_HOUR, + UnitOfEnergy.KILO_WATT_HOUR, + 1000000, + 1000, + SensorDeviceClass.ENERGY, + ), + ], +) +async def test_unit_conversion_priority_suggested_unit_change_2( + hass: HomeAssistant, + enable_custom_integrations: None, + native_unit_1, + native_unit_2, + suggested_unit, + native_value, + original_value, + device_class, +) -> None: + """Test priority of unit conversion.""" + + hass.config.units = METRIC_SYSTEM + + entity_registry = er.async_get(hass) + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + + # Pre-register entities + entity_registry.async_get_or_create( + "sensor", "test", "very_unique", unit_of_measurement=native_unit_1 + ) + entity_registry.async_get_or_create( + "sensor", "test", "very_unique_2", unit_of_measurement=native_unit_1 + ) + + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", + device_class=device_class, + native_unit_of_measurement=native_unit_2, + native_value=str(native_value), + unique_id="very_unique", + ) + entity0 = platform.ENTITIES["0"] + + platform.ENTITIES["1"] = platform.MockSensor( + name="Test", + device_class=device_class, + native_unit_of_measurement=native_unit_2, + native_value=str(native_value), + suggested_unit_of_measurement=suggested_unit, + unique_id="very_unique_2", + ) + entity1 = platform.ENTITIES["1"] + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + # Registered entity -> Follow unit in entity registry + state = hass.states.get(entity0.entity_id) + assert float(state.state) == pytest.approx(float(original_value)) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == native_unit_1 + # Assert the suggested unit is stored in the registry + entry = entity_registry.async_get(entity0.entity_id) + assert entry.unit_of_measurement == native_unit_1 + assert entry.options == { + "sensor.private": {"suggested_unit_of_measurement": native_unit_1}, + } + + # Registered entity -> Follow unit in entity registry + state = hass.states.get(entity1.entity_id) + assert float(state.state) == pytest.approx(float(original_value)) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == native_unit_1 + # Assert the suggested unit is stored in the registry + entry = entity_registry.async_get(entity0.entity_id) + assert entry.unit_of_measurement == native_unit_1 + assert entry.options == { + "sensor.private": {"suggested_unit_of_measurement": native_unit_1}, + } @pytest.mark.parametrize( From a8d587bc5379c0d3aad0174f20734b02fb39e78f Mon Sep 17 00:00:00 2001 From: landaisbenj <32144431+landaisbenj@users.noreply.github.com> Date: Mon, 27 Feb 2023 11:52:07 +0100 Subject: [PATCH 0437/2810] Add state_class to qbittorent sensors (#88829) Update Sensor.py on qbittorent integration Add stat class fonctionnality to sensor speed. --- homeassistant/components/qbittorrent/sensor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index 14bc0eb2ed..e7b75954d5 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.const import ( CONF_NAME, @@ -45,12 +46,14 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( name="Down Speed", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=SENSOR_TYPE_UPLOAD_SPEED, name="Up Speed", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, + state_class=SensorStateClass.MEASUREMENT, ), ) From b542f6b3acb4c59070c8485a045541f634d860af Mon Sep 17 00:00:00 2001 From: mkmer Date: Mon, 27 Feb 2023 07:48:23 -0500 Subject: [PATCH 0438/2810] 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 02bb95c38f..4b8e73e9fe 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 7db4120aa0..9bea267c26 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 d13208a56d..3e081f63a7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -254,7 +254,7 @@ aioskybell==22.7.0 aioslimproto==2.1.1 # homeassistant.components.honeywell -aiosomecomfort==0.0.8 +aiosomecomfort==0.0.10 # homeassistant.components.steamist aiosteamist==0.3.2 From b25f6e3ffc4f594d09999c9e78358e2feff27d1e Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 27 Feb 2023 13:54:56 +0100 Subject: [PATCH 0439/2810] Prepare for refactoring of MQTT related tests (#88557) * Update mqtt_mock * Tests manual_mqtt * Tests mqtt_json * Tests mqtt_room --- .../manual_mqtt/test_alarm_control_panel.py | 65 +++++++++---------- .../mqtt_json/test_device_tracker.py | 6 +- tests/components/mqtt_room/test_sensor.py | 8 +-- tests/conftest.py | 4 +- 4 files changed, 43 insertions(+), 40 deletions(-) diff --git a/tests/components/manual_mqtt/test_alarm_control_panel.py b/tests/components/manual_mqtt/test_alarm_control_panel.py index 7ec876dbce..8aaccad105 100644 --- a/tests/components/manual_mqtt/test_alarm_control_panel.py +++ b/tests/components/manual_mqtt/test_alarm_control_panel.py @@ -33,13 +33,13 @@ from tests.common import ( async_fire_time_changed, ) from tests.components.alarm_control_panel import common -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClient CODE = "HELLO_CODE" async def test_fail_setup_without_state_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test for failing with no state topic.""" with assert_setup_component(0, alarm_control_panel.DOMAIN) as config: @@ -57,7 +57,7 @@ async def test_fail_setup_without_state_topic( async def test_fail_setup_without_command_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test failing with no command topic.""" with assert_setup_component(0, alarm_control_panel.DOMAIN): @@ -87,7 +87,7 @@ async def test_no_pending( hass: HomeAssistant, service, expected_state, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock: MqttMockHAClient, ) -> None: """Test arm method.""" assert await async_setup_component( @@ -135,7 +135,7 @@ async def test_no_pending_when_code_not_req( hass: HomeAssistant, service, expected_state, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock: MqttMockHAClient, ) -> None: """Test arm method.""" assert await async_setup_component( @@ -184,7 +184,7 @@ async def test_with_pending( hass: HomeAssistant, service, expected_state, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock: MqttMockHAClient, ) -> None: """Test arm method.""" assert await async_setup_component( @@ -256,7 +256,7 @@ async def test_with_invalid_code( hass: HomeAssistant, service, expected_state, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock: MqttMockHAClient, ) -> None: """Attempt to arm without a valid code.""" assert await async_setup_component( @@ -304,7 +304,7 @@ async def test_with_template_code( hass: HomeAssistant, service, expected_state, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock: MqttMockHAClient, ) -> None: """Attempt to arm with a template-based code.""" assert await async_setup_component( @@ -353,7 +353,7 @@ async def test_with_specific_pending( hass: HomeAssistant, service, expected_state, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock: MqttMockHAClient, ) -> None: """Test arm method.""" assert await async_setup_component( @@ -395,7 +395,7 @@ async def test_with_specific_pending( async def test_trigger_no_pending( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test triggering when no pending submitted method.""" assert await async_setup_component( @@ -435,7 +435,7 @@ async def test_trigger_no_pending( async def test_trigger_with_delay( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test trigger method and switch from pending to triggered.""" assert await async_setup_component( @@ -483,7 +483,7 @@ async def test_trigger_with_delay( async def test_trigger_zero_trigger_time( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test disabled trigger.""" assert await async_setup_component( @@ -513,7 +513,7 @@ async def test_trigger_zero_trigger_time( async def test_trigger_zero_trigger_time_with_pending( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test disabled trigger.""" assert await async_setup_component( @@ -543,7 +543,7 @@ async def test_trigger_zero_trigger_time_with_pending( async def test_trigger_with_pending( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test arm home method.""" assert await async_setup_component( @@ -596,7 +596,7 @@ async def test_trigger_with_pending( async def test_trigger_with_disarm_after_trigger( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test disarm after trigger.""" assert await async_setup_component( @@ -636,7 +636,7 @@ async def test_trigger_with_disarm_after_trigger( async def test_trigger_with_zero_specific_trigger_time( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test trigger method.""" assert await async_setup_component( @@ -667,7 +667,7 @@ async def test_trigger_with_zero_specific_trigger_time( async def test_trigger_with_unused_zero_specific_trigger_time( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test disarm after trigger.""" assert await async_setup_component( @@ -708,7 +708,7 @@ async def test_trigger_with_unused_zero_specific_trigger_time( async def test_trigger_with_specific_trigger_time( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test disarm after trigger.""" assert await async_setup_component( @@ -748,7 +748,7 @@ async def test_trigger_with_specific_trigger_time( async def test_back_to_back_trigger_with_no_disarm_after_trigger( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test no disarm after back to back trigger.""" assert await async_setup_component( @@ -806,7 +806,7 @@ async def test_back_to_back_trigger_with_no_disarm_after_trigger( async def test_disarm_while_pending_trigger( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test disarming while pending state.""" assert await async_setup_component( @@ -849,7 +849,7 @@ async def test_disarm_while_pending_trigger( async def test_disarm_during_trigger_with_invalid_code( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test disarming while code is invalid.""" assert await async_setup_component( @@ -897,7 +897,7 @@ async def test_disarm_during_trigger_with_invalid_code( async def test_trigger_with_unused_specific_delay( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test trigger method and switch from pending to triggered.""" assert await async_setup_component( @@ -946,7 +946,7 @@ async def test_trigger_with_unused_specific_delay( async def test_trigger_with_specific_delay( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test trigger method and switch from pending to triggered.""" assert await async_setup_component( @@ -995,7 +995,7 @@ async def test_trigger_with_specific_delay( async def test_trigger_with_pending_and_delay( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test trigger method and switch from pending to triggered.""" assert await async_setup_component( @@ -1056,7 +1056,7 @@ async def test_trigger_with_pending_and_delay( async def test_trigger_with_pending_and_specific_delay( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test trigger method and switch from pending to triggered.""" assert await async_setup_component( @@ -1118,7 +1118,7 @@ async def test_trigger_with_pending_and_specific_delay( async def test_trigger_with_specific_pending( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test arm home method.""" assert await async_setup_component( @@ -1167,7 +1167,7 @@ async def test_trigger_with_specific_pending( async def test_trigger_with_no_disarm_after_trigger( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test disarm after trigger.""" assert await async_setup_component( @@ -1212,7 +1212,7 @@ async def test_trigger_with_no_disarm_after_trigger( async def test_arm_away_after_disabled_disarmed( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test pending state with and without zero trigger time.""" assert await async_setup_component( @@ -1278,7 +1278,7 @@ async def test_arm_away_after_disabled_disarmed( async def test_disarm_with_template_code( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Attempt to disarm with a valid or invalid template-based code.""" assert await async_setup_component( @@ -1332,7 +1332,7 @@ async def test_arm_via_command_topic( hass: HomeAssistant, config, expected_state, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock: MqttMockHAClient, ) -> None: """Test arming via command topic.""" command = config[8:].upper() @@ -1374,7 +1374,7 @@ async def test_arm_via_command_topic( async def test_disarm_pending_via_command_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test disarming pending alarm via command topic.""" assert await async_setup_component( @@ -1410,7 +1410,7 @@ async def test_disarm_pending_via_command_topic( async def test_state_changes_are_published_to_mqtt( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test publishing of MQTT messages when state changes.""" assert await async_setup_component( @@ -1431,7 +1431,6 @@ async def test_state_changes_are_published_to_mqtt( # Component should send disarmed alarm state on startup await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() mqtt_mock.async_publish.assert_called_once_with( "alarm/state", STATE_ALARM_DISARMED, 0, True ) diff --git a/tests/components/mqtt_json/test_device_tracker.py b/tests/components/mqtt_json/test_device_tracker.py index c2fb33d6ae..2cc5299061 100644 --- a/tests/components/mqtt_json/test_device_tracker.py +++ b/tests/components/mqtt_json/test_device_tracker.py @@ -1,4 +1,5 @@ """The tests for the JSON MQTT device tracker platform.""" +from collections.abc import Generator import json import logging import os @@ -15,6 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClient LOCATION_MESSAGE = { "longitude": 1.0, @@ -27,7 +29,9 @@ LOCATION_MESSAGE_INCOMPLETE = {"longitude": 2.0} @pytest.fixture(autouse=True) -async def setup_comp(hass, mqtt_mock_entry_with_yaml_config): +async def setup_comp( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> Generator[None, None, None]: """Initialize components.""" yaml_devices = hass.config.path(YAML_DEVICES) yield diff --git a/tests/components/mqtt_room/test_sensor.py b/tests/components/mqtt_room/test_sensor.py index 0dd786a768..999bcebd17 100644 --- a/tests/components/mqtt_room/test_sensor.py +++ b/tests/components/mqtt_room/test_sensor.py @@ -18,7 +18,7 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt from tests.common import async_fire_mqtt_message -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClient DEVICE_ID = "123TESTMAC" NAME = "test_device" @@ -56,9 +56,7 @@ async def assert_distance(hass, distance): assert state.attributes.get("distance") == distance -async def test_room_update( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: +async def test_room_update(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: """Test the updating between rooms.""" assert await async_setup_component( hass, @@ -96,7 +94,7 @@ async def test_room_update( async def test_unique_id_is_set( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test the updating between rooms.""" unique_name = "my_unique_name_0123456789" diff --git a/tests/conftest.py b/tests/conftest.py index 9e7e6f1079..22701ee0e8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -846,12 +846,14 @@ def mqtt_client_mock(hass: HomeAssistant) -> Generator[MqttMockPahoClient, None, @pytest.fixture async def mqtt_mock( hass: HomeAssistant, + mock_hass_config: None, mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data: dict[str, Any] | None, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> AsyncGenerator[MqttMockHAClient, None]: """Fixture to mock MQTT component.""" - return await mqtt_mock_entry_no_yaml_config() + with patch("homeassistant.components.mqtt.PLATFORMS", []): + return await mqtt_mock_entry_no_yaml_config() @asynccontextmanager From aeb6c4f07834dc9117127bca6f81f982cb840894 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Feb 2023 13:59:16 +0100 Subject: [PATCH 0440/2810] 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 44baf6a2d9..c071e760eb 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -2,9 +2,9 @@ from unittest.mock import patch import pytest +import python_otbr_api from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from . import BASE_URL @@ -82,8 +82,8 @@ async def test_get_info_fetch_fails( await async_setup_component(hass, "otbr", {}) with patch( - "homeassistant.components.otbr.OTBRData.get_active_dataset_tlvs", - side_effect=HomeAssistantError, + "python_otbr_api.OTBR.get_active_dataset_tlvs", + side_effect=python_otbr_api.OTBRError, ): await websocket_client.send_json( { From 76819fbb231359fef98a9c74e08a93e0fd7a504c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Feb 2023 14:01:09 +0100 Subject: [PATCH 0441/2810] Add missing mock in brunt config flow tests (#88834) --- tests/components/brunt/conftest.py | 14 ++++++++++++++ tests/components/brunt/test_config_flow.py | 11 +++++------ 2 files changed, 19 insertions(+), 6 deletions(-) create mode 100644 tests/components/brunt/conftest.py diff --git a/tests/components/brunt/conftest.py b/tests/components/brunt/conftest.py new file mode 100644 index 0000000000..8ae0bbaf31 --- /dev/null +++ b/tests/components/brunt/conftest.py @@ -0,0 +1,14 @@ +"""Configuration for brunt tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.brunt.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/brunt/test_config_flow.py b/tests/components/brunt/test_config_flow.py index 93fd06e30d..c8b0a3955c 100644 --- a/tests/components/brunt/test_config_flow.py +++ b/tests/components/brunt/test_config_flow.py @@ -1,5 +1,5 @@ """Test the Brunt config flow.""" -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch from aiohttp import ClientResponseError from aiohttp.client_exceptions import ServerDisconnectedError @@ -14,8 +14,10 @@ from tests.common import MockConfigEntry CONFIG = {CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"} +pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_form(hass: HomeAssistant) -> None: + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, data=None @@ -26,10 +28,7 @@ async def test_form(hass: HomeAssistant) -> None: with patch( "homeassistant.components.brunt.config_flow.BruntClientAsync.async_login", return_value=None, - ), patch( - "homeassistant.components.brunt.async_setup_entry", - return_value=True, - ) as mock_setup_entry: + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], CONFIG, From 5cc9e7feddcea2f3508e707c5296b239c17c530e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Feb 2023 14:01:53 +0100 Subject: [PATCH 0442/2810] Add missing mock in cert_expiry config flow tests (#88835) --- tests/components/cert_expiry/conftest.py | 14 ++++++++++++++ tests/components/cert_expiry/test_config_flow.py | 10 ++++------ 2 files changed, 18 insertions(+), 6 deletions(-) create mode 100644 tests/components/cert_expiry/conftest.py diff --git a/tests/components/cert_expiry/conftest.py b/tests/components/cert_expiry/conftest.py new file mode 100644 index 0000000000..0a3f5420f6 --- /dev/null +++ b/tests/components/cert_expiry/conftest.py @@ -0,0 +1,14 @@ +"""Configuration for cert_expiry tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.cert_expiry.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/cert_expiry/test_config_flow.py b/tests/components/cert_expiry/test_config_flow.py index ae493133d1..52985da001 100644 --- a/tests/components/cert_expiry/test_config_flow.py +++ b/tests/components/cert_expiry/test_config_flow.py @@ -3,6 +3,8 @@ import socket import ssl from unittest.mock import patch +import pytest + from homeassistant import config_entries, data_entry_flow from homeassistant.components.cert_expiry.const import DEFAULT_PORT, DOMAIN from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT @@ -13,6 +15,8 @@ from .helpers import future_timestamp from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + async def test_user(hass: HomeAssistant) -> None: """Test user config.""" @@ -34,9 +38,6 @@ async def test_user(hass: HomeAssistant) -> None: assert result["data"][CONF_PORT] == PORT assert result["result"].unique_id == f"{HOST}:{PORT}" - with patch("homeassistant.components.cert_expiry.sensor.async_setup_entry"): - await hass.async_block_till_done() - async def test_user_with_bad_cert(hass: HomeAssistant) -> None: """Test user config with bad certificate.""" @@ -60,9 +61,6 @@ async def test_user_with_bad_cert(hass: HomeAssistant) -> None: assert result["data"][CONF_PORT] == PORT assert result["result"].unique_id == f"{HOST}:{PORT}" - with patch("homeassistant.components.cert_expiry.sensor.async_setup_entry"): - await hass.async_block_till_done() - async def test_import_host_only(hass: HomeAssistant) -> None: """Test import with host only.""" From 198ebaff6eebd1ac1b2e1f7e9c4cfd27e192f595 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Feb 2023 14:03:51 +0100 Subject: [PATCH 0443/2810] Add missing mock in abode config flow tests (#88828) --- tests/components/abode/conftest.py | 12 ++++++++++++ tests/components/abode/test_config_flow.py | 3 +++ 2 files changed, 15 insertions(+) diff --git a/tests/components/abode/conftest.py b/tests/components/abode/conftest.py index 42b86f88e8..1f9ff37ecf 100644 --- a/tests/components/abode/conftest.py +++ b/tests/components/abode/conftest.py @@ -1,4 +1,7 @@ """Configuration for Abode tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + from jaraco.abode.helpers import urls as URL import pytest @@ -6,6 +9,15 @@ from tests.common import load_fixture from tests.components.light.conftest import mock_light_profiles # noqa: F401 +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.abode.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(autouse=True) def requests_mock_fixture(requests_mock) -> None: """Fixture to provide a requests mocker.""" diff --git a/tests/components/abode/test_config_flow.py b/tests/components/abode/test_config_flow.py index 5982a02c59..7619cf9325 100644 --- a/tests/components/abode/test_config_flow.py +++ b/tests/components/abode/test_config_flow.py @@ -6,6 +6,7 @@ from jaraco.abode.exceptions import ( AuthenticationException as AbodeAuthenticationException, ) from jaraco.abode.helpers.errors import MFA_CODE_REQUIRED +import pytest from requests.exceptions import ConnectTimeout from homeassistant import data_entry_flow @@ -17,6 +18,8 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + async def test_show_form(hass: HomeAssistant) -> None: """Test that the form is served with no input.""" From 2c2489284bd0d427c7e515add9278c7139c3733e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Feb 2023 15:29:14 +0100 Subject: [PATCH 0444/2810] 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 bbea3f1d5f..94f2bab75a 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 9740d338ef..2377f47d7e 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -264,7 +264,8 @@ async def _async_setup_component( SLOW_SETUP_MAX_WAIT, ) return False - except Exception: # pylint: disable=broad-except + # pylint: disable-next=broad-except + except (asyncio.CancelledError, SystemExit, Exception): _LOGGER.exception("Error during setup of component %s", domain) async_notify_setup_error(hass, domain, integration.documentation) return False From db1dd16ab04a4718af01040d947239f2707f9670 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Feb 2023 15:30:04 +0100 Subject: [PATCH 0445/2810] 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 c6f151b8e6..070378b342 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 5f19f233e3..a514760212 100644 --- a/tests/components/thread/test_config_flow.py +++ b/tests/components/thread/test_config_flow.py @@ -78,6 +78,29 @@ async def test_import_then_zeroconf(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 0 +async def test_user(hass: HomeAssistant) -> None: + """Test the user flow.""" + with patch( + "homeassistant.components.thread.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + thread.DOMAIN, context={"source": "user"} + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Thread" + assert result["data"] == {} + assert result["options"] == {} + assert len(mock_setup_entry.mock_calls) == 1 + + config_entry = hass.config_entries.async_entries(thread.DOMAIN)[0] + assert config_entry.data == {} + assert config_entry.options == {} + assert config_entry.title == "Thread" + assert config_entry.unique_id is None + + async def test_zeroconf(hass: HomeAssistant) -> None: """Test the zeroconf flow.""" with patch( From 2dcc2f88cc1061d7c2d2f825760c7978caf02243 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 27 Feb 2023 09:57:26 -0500 Subject: [PATCH 0446/2810] Use snapshots in blueprint import tests (#88843) --- .../blueprint/snapshots/test_importer.ambr | 210 ++++++++++++++++++ tests/components/blueprint/test_importer.py | 95 +------- 2 files changed, 216 insertions(+), 89 deletions(-) create mode 100644 tests/components/blueprint/snapshots/test_importer.ambr diff --git a/tests/components/blueprint/snapshots/test_importer.ambr b/tests/components/blueprint/snapshots/test_importer.ambr new file mode 100644 index 0000000000..1401a8f174 --- /dev/null +++ b/tests/components/blueprint/snapshots/test_importer.ambr @@ -0,0 +1,210 @@ +# serializer version: 1 +# name: test_extract_blueprint_from_community_topic + OrderedDict({ + 'remote': OrderedDict({ + 'name': 'Remote', + 'description': 'IKEA remote to use', + 'selector': dict({ + 'device': OrderedDict({ + 'integration': 'zha', + 'manufacturer': 'IKEA of Sweden', + 'model': 'TRADFRI remote control', + 'multiple': False, + }), + }), + }), + 'light': OrderedDict({ + 'name': 'Light(s)', + 'description': 'The light(s) to control', + 'selector': dict({ + 'target': OrderedDict({ + 'entity': OrderedDict({ + 'domain': 'light', + }), + }), + }), + }), + 'force_brightness': OrderedDict({ + 'name': 'Force turn on brightness', + 'description': ''' + Force the brightness to the set level below, when the "on" button on the remote is pushed and lights turn on. + + ''', + 'default': False, + 'selector': dict({ + 'boolean': dict({ + }), + }), + }), + 'brightness': OrderedDict({ + 'name': 'Brightness', + 'description': 'Brightness of the light(s) when turning on', + 'default': 50, + 'selector': dict({ + 'number': OrderedDict({ + 'min': 0.0, + 'max': 100.0, + 'mode': 'slider', + 'step': 1.0, + 'unit_of_measurement': '%', + }), + }), + }), + 'button_left_short': OrderedDict({ + 'name': 'Left button - short press', + 'description': 'Action to run on short left button press', + 'default': NodeListClass([ + ]), + 'selector': dict({ + 'action': dict({ + }), + }), + }), + 'button_left_long': OrderedDict({ + 'name': 'Left button - long press', + 'description': 'Action to run on long left button press', + 'default': NodeListClass([ + ]), + 'selector': dict({ + 'action': dict({ + }), + }), + }), + 'button_right_short': OrderedDict({ + 'name': 'Right button - short press', + 'description': 'Action to run on short right button press', + 'default': NodeListClass([ + ]), + 'selector': dict({ + 'action': dict({ + }), + }), + }), + 'button_right_long': OrderedDict({ + 'name': 'Right button - long press', + 'description': 'Action to run on long right button press', + 'default': NodeListClass([ + ]), + 'selector': dict({ + 'action': dict({ + }), + }), + }), + }) +# --- +# name: test_fetch_blueprint_from_community_url + OrderedDict({ + 'remote': OrderedDict({ + 'name': 'Remote', + 'description': 'IKEA remote to use', + 'selector': dict({ + 'device': OrderedDict({ + 'integration': 'zha', + 'manufacturer': 'IKEA of Sweden', + 'model': 'TRADFRI remote control', + 'multiple': False, + }), + }), + }), + 'light': OrderedDict({ + 'name': 'Light(s)', + 'description': 'The light(s) to control', + 'selector': dict({ + 'target': OrderedDict({ + 'entity': OrderedDict({ + 'domain': 'light', + }), + }), + }), + }), + 'force_brightness': OrderedDict({ + 'name': 'Force turn on brightness', + 'description': ''' + Force the brightness to the set level below, when the "on" button on the remote is pushed and lights turn on. + + ''', + 'default': False, + 'selector': dict({ + 'boolean': dict({ + }), + }), + }), + 'brightness': OrderedDict({ + 'name': 'Brightness', + 'description': 'Brightness of the light(s) when turning on', + 'default': 50, + 'selector': dict({ + 'number': OrderedDict({ + 'min': 0.0, + 'max': 100.0, + 'mode': 'slider', + 'step': 1.0, + 'unit_of_measurement': '%', + }), + }), + }), + 'button_left_short': OrderedDict({ + 'name': 'Left button - short press', + 'description': 'Action to run on short left button press', + 'default': NodeListClass([ + ]), + 'selector': dict({ + 'action': dict({ + }), + }), + }), + 'button_left_long': OrderedDict({ + 'name': 'Left button - long press', + 'description': 'Action to run on long left button press', + 'default': NodeListClass([ + ]), + 'selector': dict({ + 'action': dict({ + }), + }), + }), + 'button_right_short': OrderedDict({ + 'name': 'Right button - short press', + 'description': 'Action to run on short right button press', + 'default': NodeListClass([ + ]), + 'selector': dict({ + 'action': dict({ + }), + }), + }), + 'button_right_long': OrderedDict({ + 'name': 'Right button - long press', + 'description': 'Action to run on long right button press', + 'default': NodeListClass([ + ]), + 'selector': dict({ + 'action': dict({ + }), + }), + }), + }) +# --- +# name: test_fetch_blueprint_from_github_gist_url + OrderedDict({ + 'motion_entity': OrderedDict({ + 'name': 'Motion Sensor', + 'selector': dict({ + 'entity': OrderedDict({ + 'domain': 'binary_sensor', + 'device_class': 'motion', + 'multiple': False, + }), + }), + }), + 'light_entity': OrderedDict({ + 'name': 'Light', + 'selector': dict({ + 'entity': OrderedDict({ + 'domain': 'light', + 'multiple': False, + }), + }), + }), + }) +# --- diff --git a/tests/components/blueprint/test_importer.py b/tests/components/blueprint/test_importer.py index 8fd73db024..cdec562b99 100644 --- a/tests/components/blueprint/test_importer.py +++ b/tests/components/blueprint/test_importer.py @@ -18,74 +18,6 @@ def community_post(): return load_fixture("blueprint/community_post.json") -COMMUNITY_POST_INPUTS = { - "remote": { - "name": "Remote", - "description": "IKEA remote to use", - "selector": { - "device": { - "integration": "zha", - "manufacturer": "IKEA of Sweden", - "model": "TRADFRI remote control", - "multiple": False, - } - }, - }, - "light": { - "name": "Light(s)", - "description": "The light(s) to control", - "selector": {"target": {"entity": {"domain": "light"}}}, - }, - "force_brightness": { - "name": "Force turn on brightness", - "description": ( - 'Force the brightness to the set level below, when the "on" button on the' - " remote is pushed and lights turn on.\n" - ), - "default": False, - "selector": {"boolean": {}}, - }, - "brightness": { - "name": "Brightness", - "description": "Brightness of the light(s) when turning on", - "default": 50, - "selector": { - "number": { - "min": 0.0, - "max": 100.0, - "mode": "slider", - "step": 1.0, - "unit_of_measurement": "%", - } - }, - }, - "button_left_short": { - "name": "Left button - short press", - "description": "Action to run on short left button press", - "default": [], - "selector": {"action": {}}, - }, - "button_left_long": { - "name": "Left button - long press", - "description": "Action to run on long left button press", - "default": [], - "selector": {"action": {}}, - }, - "button_right_short": { - "name": "Right button - short press", - "description": "Action to run on short right button press", - "default": [], - "selector": {"action": {}}, - }, - "button_right_long": { - "name": "Right button - long press", - "description": "Action to run on long right button press", - "default": [], - "selector": {"action": {}}, - }, -} - - def test_get_community_post_import_url() -> None: """Test variations of generating import forum url.""" assert ( @@ -120,14 +52,14 @@ def test_get_github_import_url() -> None: ) -def test_extract_blueprint_from_community_topic(community_post) -> None: +def test_extract_blueprint_from_community_topic(community_post, snapshot) -> None: """Test extracting blueprint.""" imported_blueprint = importer._extract_blueprint_from_community_topic( "http://example.com", json.loads(community_post) ) assert imported_blueprint is not None assert imported_blueprint.blueprint.domain == "automation" - assert imported_blueprint.blueprint.inputs == COMMUNITY_POST_INPUTS + assert imported_blueprint.blueprint.inputs == snapshot def test_extract_blueprint_from_community_topic_invalid_yaml() -> None: @@ -161,7 +93,7 @@ def test_extract_blueprint_from_community_topic_wrong_lang() -> None: async def test_fetch_blueprint_from_community_url( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, community_post + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, community_post, snapshot ) -> None: """Test fetching blueprint from url.""" aioclient_mock.get( @@ -172,7 +104,7 @@ async def test_fetch_blueprint_from_community_url( ) assert isinstance(imported_blueprint, importer.ImportedBlueprint) assert imported_blueprint.blueprint.domain == "automation" - assert imported_blueprint.blueprint.inputs == COMMUNITY_POST_INPUTS + assert imported_blueprint.blueprint.inputs == snapshot assert ( imported_blueprint.suggested_filename == "frenck/zha-ikea-five-button-remote-for-lights" @@ -215,7 +147,7 @@ async def test_fetch_blueprint_from_github_url( async def test_fetch_blueprint_from_github_gist_url( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, snapshot ) -> None: """Test fetching blueprint from url.""" aioclient_mock.get( @@ -227,21 +159,6 @@ async def test_fetch_blueprint_from_github_gist_url( imported_blueprint = await importer.fetch_blueprint_from_url(hass, url) assert isinstance(imported_blueprint, importer.ImportedBlueprint) assert imported_blueprint.blueprint.domain == "automation" - assert imported_blueprint.blueprint.inputs == { - "motion_entity": { - "name": "Motion Sensor", - "selector": { - "entity": { - "domain": "binary_sensor", - "device_class": "motion", - "multiple": False, - } - }, - }, - "light_entity": { - "name": "Light", - "selector": {"entity": {"domain": "light", "multiple": False}}, - }, - } + assert imported_blueprint.blueprint.inputs == snapshot assert imported_blueprint.suggested_filename == "balloob/motion_light" assert imported_blueprint.blueprint.metadata["source_url"] == url From bdb9994b7e43d3c55e766082019e73067b77b640 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Mon, 27 Feb 2023 16:17:57 +0100 Subject: [PATCH 0447/2810] 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 cf83db4f9c..354656ecd9 100644 --- a/homeassistant/components/plugwise/sensor.py +++ b/homeassistant/components/plugwise/sensor.py @@ -17,6 +17,7 @@ from homeassistant.const import ( UnitOfPower, UnitOfPressure, UnitOfTemperature, + UnitOfTime, UnitOfVolume, ) from homeassistant.core import HomeAssistant @@ -303,9 +304,9 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="gas_consumed_interval", name="Gas consumed interval", - native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, - device_class=SensorDeviceClass.GAS, - state_class=SensorStateClass.TOTAL, + icon="mdi:meter-gas", + native_unit_of_measurement=f"{UnitOfVolume.CUBIC_METERS}/{UnitOfTime.HOURS}", + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="gas_consumed_cumulative", From ff4de8cd06377f964fa22deb50c6b1efbf4aec4c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Feb 2023 16:19:13 +0100 Subject: [PATCH 0448/2810] 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 ebe2ab0025..c202040228 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 a07819793b..d88581696c 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 c071e760eb..de01c6153e 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -96,3 +96,139 @@ async def test_get_info_fetch_fails( assert msg["id"] == 5 assert not msg["success"] assert msg["error"]["code"] == "get_dataset_failed" + + +async def test_create_network( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry, + websocket_client, +) -> None: + """Test create network.""" + + with patch( + "python_otbr_api.OTBR.create_active_dataset" + ) as create_dataset_mock, patch( + "python_otbr_api.OTBR.set_enabled" + ) as set_enabled_mock: + await websocket_client.send_json( + { + "id": 5, + "type": "otbr/create_network", + } + ) + + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + assert msg["success"] + assert msg["result"] is None + + create_dataset_mock.assert_called_once_with( + python_otbr_api.models.OperationalDataSet(network_name="home-assistant") + ) + assert len(set_enabled_mock.mock_calls) == 2 + assert set_enabled_mock.mock_calls[0][1][0] is False + assert set_enabled_mock.mock_calls[1][1][0] is True + + +async def test_create_network_no_entry( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test create network.""" + await async_setup_component(hass, "otbr", {}) + websocket_client = await hass_ws_client(hass) + await websocket_client.send_json( + { + "id": 5, + "type": "otbr/create_network", + } + ) + + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + assert not msg["success"] + assert msg["error"]["code"] == "not_loaded" + + +async def test_get_info_fetch_fails_1( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry, + websocket_client, +) -> None: + """Test create network.""" + await async_setup_component(hass, "otbr", {}) + + with patch( + "python_otbr_api.OTBR.set_enabled", + side_effect=python_otbr_api.OTBRError, + ): + await websocket_client.send_json( + { + "id": 5, + "type": "otbr/create_network", + } + ) + msg = await websocket_client.receive_json() + + assert msg["id"] == 5 + assert not msg["success"] + assert msg["error"]["code"] == "set_enabled_failed" + + +async def test_get_info_fetch_fails_2( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry, + websocket_client, +) -> None: + """Test create network.""" + await async_setup_component(hass, "otbr", {}) + + with patch( + "python_otbr_api.OTBR.set_enabled", + ), patch( + "python_otbr_api.OTBR.create_active_dataset", + side_effect=python_otbr_api.OTBRError, + ): + await websocket_client.send_json( + { + "id": 5, + "type": "otbr/create_network", + } + ) + msg = await websocket_client.receive_json() + + assert msg["id"] == 5 + assert not msg["success"] + assert msg["error"]["code"] == "create_active_dataset_failed" + + +async def test_get_info_fetch_fails_3( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry, + websocket_client, +) -> None: + """Test create network.""" + await async_setup_component(hass, "otbr", {}) + + with patch( + "python_otbr_api.OTBR.set_enabled", + side_effect=[None, python_otbr_api.OTBRError], + ), patch( + "python_otbr_api.OTBR.create_active_dataset", + ): + await websocket_client.send_json( + { + "id": 5, + "type": "otbr/create_network", + } + ) + msg = await websocket_client.receive_json() + + assert msg["id"] == 5 + assert not msg["success"] + assert msg["error"]["code"] == "set_enabled_failed" From 7419a92a1ba0682a1bb7cf9b12c00ce68879e482 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Feb 2023 16:20:01 +0100 Subject: [PATCH 0449/2810] Cleanup YAML import in aladdin_connect (#88694) --- .../components/aladdin_connect/config_flow.py | 9 ---- .../components/aladdin_connect/cover.py | 47 ++----------------- .../aladdin_connect/test_config_flow.py | 29 ------------ .../components/aladdin_connect/test_cover.py | 37 --------------- 4 files changed, 4 insertions(+), 118 deletions(-) diff --git a/homeassistant/components/aladdin_connect/config_flow.py b/homeassistant/components/aladdin_connect/config_flow.py index 89d3b0faf1..e5170e9b0a 100644 --- a/homeassistant/components/aladdin_connect/config_flow.py +++ b/homeassistant/components/aladdin_connect/config_flow.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio from collections.abc import Mapping -import logging from typing import Any from AIOAladdinConnect import AladdinConnectClient @@ -20,8 +19,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CLIENT_ID, DOMAIN -_LOGGER = logging.getLogger(__name__) - STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_USERNAME): str, @@ -134,12 +131,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) - async def async_step_import( - self, import_data: dict[str, Any] | None = None - ) -> FlowResult: - """Import Aladin Connect config from configuration.yaml.""" - return await self.async_step_user(import_data) - class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index 8815ccdbb9..5837920560 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -2,63 +2,24 @@ from __future__ import annotations from datetime import timedelta -import logging -from typing import Any, Final +from typing import Any from AIOAladdinConnect import AladdinConnectClient -import voluptuous as vol -from homeassistant.components.cover import ( - PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA, - CoverDeviceClass, - CoverEntity, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_PASSWORD, - CONF_USERNAME, - STATE_CLOSED, - STATE_CLOSING, - STATE_OPENING, -) +from homeassistant.components.cover import CoverDeviceClass, CoverEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPENING from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN, STATES_MAP, SUPPORTED_FEATURES from .model import DoorDevice -_LOGGER: Final = logging.getLogger(__name__) - -PLATFORM_SCHEMA: Final = BASE_PLATFORM_SCHEMA.extend( - {vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string} -) SCAN_INTERVAL = timedelta(seconds=300) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up Aladdin Connect devices yaml depreciated.""" - _LOGGER.warning( - "Configuring Aladdin Connect through yaml is deprecated. Please remove it from" - " your configuration as it has already been imported to a config entry" - ) - await hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, diff --git a/tests/components/aladdin_connect/test_config_flow.py b/tests/components/aladdin_connect/test_config_flow.py index c2fdd5589d..6f879994fb 100644 --- a/tests/components/aladdin_connect/test_config_flow.py +++ b/tests/components/aladdin_connect/test_config_flow.py @@ -131,35 +131,6 @@ async def test_form_already_configured( assert result2["reason"] == "already_configured" -async def test_import_flow_success( - hass: HomeAssistant, mock_aladdinconnect_api: MagicMock -) -> None: - """Test a successful import of yaml.""" - with patch( - "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ), patch( - "homeassistant.components.aladdin_connect.async_setup_entry", return_value=True - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_USERNAME: "test-user", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Aladdin Connect" - assert result2["data"] == { - CONF_USERNAME: "test-user", - CONF_PASSWORD: "test-password", - } - assert len(mock_setup_entry.mock_calls) == 1 - - async def test_reauth_flow( hass: HomeAssistant, mock_aladdinconnect_api: MagicMock ) -> None: diff --git a/tests/components/aladdin_connect/test_cover.py b/tests/components/aladdin_connect/test_cover.py index d42ee9a512..e63b50607c 100644 --- a/tests/components/aladdin_connect/test_cover.py +++ b/tests/components/aladdin_connect/test_cover.py @@ -1,16 +1,12 @@ """Test the Aladdin Connect Cover.""" from unittest.mock import AsyncMock, MagicMock, patch -import pytest - from homeassistant.components.aladdin_connect.const import DOMAIN from homeassistant.components.aladdin_connect.cover import SCAN_INTERVAL from homeassistant.components.cover import DOMAIN as COVER_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, - CONF_PASSWORD, - CONF_USERNAME, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, STATE_CLOSED, @@ -196,36 +192,3 @@ async def test_cover_operation( await hass.async_block_till_done() assert hass.states.get("cover.home").state == STATE_UNKNOWN - - -async def test_yaml_import( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - mock_aladdinconnect_api: MagicMock, -) -> None: - """Test setup YAML import.""" - assert COVER_DOMAIN not in hass.config.components - - with patch( - "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - await async_setup_component( - hass, - COVER_DOMAIN, - { - COVER_DOMAIN: { - "platform": DOMAIN, - "username": "test-user", - "password": "test-password", - } - }, - ) - await hass.async_block_till_done() - assert hass.config_entries.async_entries(DOMAIN) - assert "Configuring Aladdin Connect through yaml is deprecated" in caplog.text - - assert hass.config_entries.async_entries(DOMAIN) - config_data = hass.config_entries.async_entries(DOMAIN)[0].data - assert config_data[CONF_USERNAME] == "test-user" - assert config_data[CONF_PASSWORD] == "test-password" From ac70612ec59efa7523ae0d51857d6f66f60f2efa Mon Sep 17 00:00:00 2001 From: avee87 <6134677+avee87@users.noreply.github.com> Date: Mon, 27 Feb 2023 15:25:27 +0000 Subject: [PATCH 0450/2810] Improve helper integration scaffold (#88713) --- script/scaffold/generate.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/script/scaffold/generate.py b/script/scaffold/generate.py index b7e4c58d1a..e31df7ecf0 100644 --- a/script/scaffold/generate.py +++ b/script/scaffold/generate.py @@ -151,13 +151,13 @@ def _custom_tasks(template, info: Info) -> None: ) elif template == "config_flow_helper": - info.update_manifest(config_flow=True) + info.update_manifest(config_flow=True, integration_type="helper") info.update_strings( config={ "step": { "user": { "description": "New NEW_NAME Sensor", - "data": {"entity": "Input sensor", "name": "Name"}, + "data": {"entity_id": "Input sensor", "name": "Name"}, }, }, }, @@ -165,7 +165,7 @@ def _custom_tasks(template, info: Info) -> None: "step": { "init": { "data": { - "entity": "[%key:component::NEW_DOMAIN::config::step::user::description%]" + "entity_id": "[%key:component::NEW_DOMAIN::config::step::user::description%]" }, }, }, From e95944bf9fa4b74fc0eaebe2ae2af8839d3580da Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 27 Feb 2023 16:38:18 +0100 Subject: [PATCH 0451/2810] Add filter options to entity and device selectors (#87536) * Add support for multiple device classes * Add support for entity filter selector * Add support for device filter selector * Apply suggestions * Fix wrong property name * Update snapshot --------- Co-authored-by: Paulus Schoutsen --- homeassistant/helpers/selector.py | 69 ++++++++---- .../blueprint/snapshots/test_importer.ambr | 32 ++++-- tests/helpers/test_selector.py | 100 ++++++++++++++++++ 3 files changed, 170 insertions(+), 31 deletions(-) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 0ba5ee363e..fe4709a302 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -79,27 +79,27 @@ class Selector(Generic[_T]): return {"selector": {self.selector_type: self.config}} -SINGLE_ENTITY_SELECTOR_CONFIG_SCHEMA = vol.Schema( +ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( { # Integration that provided the entity vol.Optional("integration"): str, # Domain the entity belongs to - vol.Optional("domain"): vol.Any(str, [str]), + vol.Optional("domain"): vol.All(cv.ensure_list, [str]), # Device class of the entity - vol.Optional("device_class"): str, + vol.Optional("device_class"): vol.All(cv.ensure_list, [str]), } ) -class SingleEntitySelectorConfig(TypedDict, total=False): +class EntityFilterSelectorConfig(TypedDict, total=False): """Class to represent a single entity selector config.""" integration: str domain: str | list[str] - device_class: str + device_class: str | list[str] -SINGLE_DEVICE_SELECTOR_CONFIG_SCHEMA = vol.Schema( +DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( { # Integration linked to it with a config entry vol.Optional("integration"): str, @@ -108,18 +108,21 @@ SINGLE_DEVICE_SELECTOR_CONFIG_SCHEMA = vol.Schema( # Model of device vol.Optional("model"): str, # Device has to contain entities matching this selector - vol.Optional("entity"): SINGLE_ENTITY_SELECTOR_CONFIG_SCHEMA, + vol.Optional("entity"): vol.All( + cv.ensure_list, [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA] + ), } ) -class SingleDeviceSelectorConfig(TypedDict, total=False): +class DeviceFilterSelectorConfig(TypedDict, total=False): """Class to represent a single device selector config.""" integration: str manufacturer: str model: str - entity: SingleEntitySelectorConfig + entity: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] + filter: DeviceFilterSelectorConfig | list[DeviceFilterSelectorConfig] class ActionSelectorConfig(TypedDict): @@ -176,8 +179,8 @@ class AddonSelector(Selector[AddonSelectorConfig]): class AreaSelectorConfig(TypedDict, total=False): """Class to represent an area selector config.""" - entity: SingleEntitySelectorConfig - device: SingleDeviceSelectorConfig + entity: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] + device: DeviceFilterSelectorConfig | list[DeviceFilterSelectorConfig] multiple: bool @@ -189,8 +192,14 @@ class AreaSelector(Selector[AreaSelectorConfig]): CONFIG_SCHEMA = vol.Schema( { - vol.Optional("entity"): SINGLE_ENTITY_SELECTOR_CONFIG_SCHEMA, - vol.Optional("device"): SINGLE_DEVICE_SELECTOR_CONFIG_SCHEMA, + vol.Optional("entity"): vol.All( + cv.ensure_list, + [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA], + ), + vol.Optional("device"): vol.All( + cv.ensure_list, + [DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA], + ), vol.Optional("multiple", default=False): cv.boolean, } ) @@ -399,7 +408,7 @@ class DeviceSelectorConfig(TypedDict, total=False): integration: str manufacturer: str model: str - entity: SingleEntitySelectorConfig + entity: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] multiple: bool @@ -409,8 +418,14 @@ class DeviceSelector(Selector[DeviceSelectorConfig]): selector_type = "device" - CONFIG_SCHEMA = SINGLE_DEVICE_SELECTOR_CONFIG_SCHEMA.extend( - {vol.Optional("multiple", default=False): cv.boolean} + CONFIG_SCHEMA = DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA.extend( + { + vol.Optional("multiple", default=False): cv.boolean, + vol.Optional("filter"): vol.All( + cv.ensure_list, + [DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA], + ), + }, ) def __init__(self, config: DeviceSelectorConfig | None = None) -> None: @@ -457,7 +472,7 @@ class DurationSelector(Selector[DurationSelectorConfig]): return cast(dict[str, float], data) -class EntitySelectorConfig(SingleEntitySelectorConfig, total=False): +class EntitySelectorConfig(EntityFilterSelectorConfig, total=False): """Class to represent an entity selector config.""" exclude_entities: list[str] @@ -471,11 +486,15 @@ class EntitySelector(Selector[EntitySelectorConfig]): selector_type = "entity" - CONFIG_SCHEMA = SINGLE_ENTITY_SELECTOR_CONFIG_SCHEMA.extend( + CONFIG_SCHEMA = ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA.extend( { vol.Optional("exclude_entities"): [str], vol.Optional("include_entities"): [str], vol.Optional("multiple", default=False): cv.boolean, + vol.Optional("filter"): vol.All( + cv.ensure_list, + [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA], + ), } ) @@ -784,8 +803,8 @@ class SelectSelector(Selector[SelectSelectorConfig]): class TargetSelectorConfig(TypedDict, total=False): """Class to represent a target selector config.""" - entity: SingleEntitySelectorConfig - device: SingleDeviceSelectorConfig + entity: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] + device: DeviceFilterSelectorConfig | list[DeviceFilterSelectorConfig] class StateSelectorConfig(TypedDict, total=False): @@ -832,8 +851,14 @@ class TargetSelector(Selector[TargetSelectorConfig]): CONFIG_SCHEMA = vol.Schema( { - vol.Optional("entity"): SINGLE_ENTITY_SELECTOR_CONFIG_SCHEMA, - vol.Optional("device"): SINGLE_DEVICE_SELECTOR_CONFIG_SCHEMA, + vol.Optional("entity"): vol.All( + cv.ensure_list, + [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA], + ), + vol.Optional("device"): vol.All( + cv.ensure_list, + [DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA], + ), } ) diff --git a/tests/components/blueprint/snapshots/test_importer.ambr b/tests/components/blueprint/snapshots/test_importer.ambr index 1401a8f174..6e5648b54d 100644 --- a/tests/components/blueprint/snapshots/test_importer.ambr +++ b/tests/components/blueprint/snapshots/test_importer.ambr @@ -18,9 +18,13 @@ 'description': 'The light(s) to control', 'selector': dict({ 'target': OrderedDict({ - 'entity': OrderedDict({ - 'domain': 'light', - }), + 'entity': list([ + OrderedDict({ + 'domain': list([ + 'light', + ]), + }), + ]), }), }), }), @@ -111,9 +115,13 @@ 'description': 'The light(s) to control', 'selector': dict({ 'target': OrderedDict({ - 'entity': OrderedDict({ - 'domain': 'light', - }), + 'entity': list([ + OrderedDict({ + 'domain': list([ + 'light', + ]), + }), + ]), }), }), }), @@ -191,8 +199,12 @@ 'name': 'Motion Sensor', 'selector': dict({ 'entity': OrderedDict({ - 'domain': 'binary_sensor', - 'device_class': 'motion', + 'domain': list([ + 'binary_sensor', + ]), + 'device_class': list([ + 'motion', + ]), 'multiple': False, }), }), @@ -201,7 +213,9 @@ 'name': 'Light', 'selector': dict({ 'entity': OrderedDict({ - 'domain': 'light', + 'domain': list([ + 'light', + ]), 'multiple': False, }), }), diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 75e8a8dc54..a5fa5c7a50 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -92,6 +92,17 @@ def _test_selector( (None,), ), ({"entity": {"device_class": "motion"}}, ("abc123",), (None,)), + ({"entity": {"device_class": ["motion", "temperature"]}}, ("abc123",), (None,)), + ( + { + "entity": [ + {"domain": "light"}, + {"domain": "binary_sensor", "device_class": "motion"}, + ] + }, + ("abc123",), + (None,), + ), ( { "integration": "zha", @@ -107,6 +118,35 @@ def _test_selector( (["abc123", "def456"],), ("abc123", None, ["abc123", None]), ), + ( + { + "filter": { + "integration": "zha", + "manufacturer": "mock-manuf", + "model": "mock-model", + } + }, + ("abc123",), + (None,), + ), + ( + { + "filter": [ + { + "integration": "zha", + "manufacturer": "mock-manuf", + "model": "mock-model", + }, + { + "integration": "matter", + "manufacturer": "other-mock-manuf", + "model": "other-mock-model", + }, + ] + }, + ("abc123",), + (None,), + ), ), ) def test_device_selector_schema(schema, valid_selections, invalid_selections) -> None: @@ -126,6 +166,11 @@ def test_device_selector_schema(schema, valid_selections, invalid_selections) -> (None, "dog.abc123"), ), ({"device_class": "motion"}, ("sensor.abc123", FAKE_UUID), (None, "abc123")), + ( + {"device_class": ["motion", "temperature"]}, + ("sensor.abc123", FAKE_UUID), + (None, "abc123"), + ), ( {"integration": "zha", "domain": "light"}, ("light.abc123", FAKE_UUID), @@ -167,6 +212,21 @@ def test_device_selector_schema(schema, valid_selections, invalid_selections) -> ["sensor.abc123", "sensor.ghi789"], ), ), + ( + {"filter": {"domain": "light"}}, + ("light.abc123", FAKE_UUID), + (None,), + ), + ( + { + "filter": [ + {"domain": "light"}, + {"domain": "binary_sensor", "device_class": "motion"}, + ] + }, + ("light.abc123", "binary_sensor.abc123", FAKE_UUID), + (None,), + ), ), ) def test_entity_selector_schema(schema, valid_selections, invalid_selections) -> None: @@ -196,11 +256,31 @@ def test_entity_selector_schema(schema, valid_selections, invalid_selections) -> ("abc123",), (None,), ), + ( + { + "entity": [ + {"domain": "light"}, + {"domain": "binary_sensor", "device_class": "motion"}, + ] + }, + ("abc123",), + (None,), + ), ( {"device": {"integration": "demo", "model": "mock-model"}}, ("abc123",), (None,), ), + ( + { + "device": [ + {"integration": "demo", "model": "mock-model"}, + {"integration": "other-demo", "model": "other-mock-model"}, + ] + }, + ("abc123",), + (None,), + ), ( { "entity": {"domain": "binary_sensor", "device_class": "motion"}, @@ -345,6 +425,16 @@ def test_state_selector_schema(schema, valid_selections, invalid_selections) -> ({"entity": {}}, (), ()), ({"entity": {"domain": "light"}}, (), ()), ({"entity": {"domain": "binary_sensor", "device_class": "motion"}}, (), ()), + ( + { + "entity": [ + {"domain": "light"}, + {"domain": "binary_sensor", "device_class": "motion"}, + ] + }, + (), + (), + ), ( { "entity": { @@ -357,6 +447,16 @@ def test_state_selector_schema(schema, valid_selections, invalid_selections) -> (), ), ({"device": {"integration": "demo", "model": "mock-model"}}, (), ()), + ( + { + "device": [ + {"integration": "demo", "model": "mock-model"}, + {"integration": "other-demo", "model": "other-mock-model"}, + ], + }, + (), + (), + ), ( { "entity": {"domain": "binary_sensor", "device_class": "motion"}, From 00954dfc1f0e0eb3ebd6d58807e2a660705a839a Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Mon, 27 Feb 2023 16:40:08 +0100 Subject: [PATCH 0452/2810] Add gas sensor to BTHome (#88770) * Bump bthome * Add gas sensor --- homeassistant/components/bthome/manifest.json | 2 +- homeassistant/components/bthome/sensor.py | 10 ++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bthome/test_sensor.py | 17 +++++++++++++++++ 5 files changed, 30 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index 47f980c78f..f875074a9f 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", "iot_class": "local_push", - "requirements": ["bthome-ble==2.5.2"] + "requirements": ["bthome-ble==2.7.0"] } diff --git a/homeassistant/components/bthome/sensor.py b/homeassistant/components/bthome/sensor.py index 4b37818348..9816395730 100644 --- a/homeassistant/components/bthome/sensor.py +++ b/homeassistant/components/bthome/sensor.py @@ -119,6 +119,16 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), + # Gas (m3) + ( + BTHomeSensorDeviceClass.GAS, + Units.VOLUME_CUBIC_METERS, + ): SensorEntityDescription( + key=f"{BTHomeSensorDeviceClass.GAS}_{Units.VOLUME_CUBIC_METERS}", + device_class=SensorDeviceClass.GAS, + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + state_class=SensorStateClass.TOTAL_INCREASING, + ), # Humidity in (percent) (BTHomeSensorDeviceClass.HUMIDITY, Units.PERCENTAGE): SensorEntityDescription( key=f"{BTHomeSensorDeviceClass.HUMIDITY}_{Units.PERCENTAGE}", diff --git a/requirements_all.txt b/requirements_all.txt index 9bea267c26..680c4ed043 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -492,7 +492,7 @@ brunt==1.2.0 bt_proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==2.5.2 +bthome-ble==2.7.0 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3e081f63a7..7f42857853 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -399,7 +399,7 @@ brother==2.2.0 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==2.5.2 +bthome-ble==2.7.0 # homeassistant.components.buienradar buienradar==1.0.5 diff --git a/tests/components/bthome/test_sensor.py b/tests/components/bthome/test_sensor.py index 3b5c434c79..af01db0a6e 100644 --- a/tests/components/bthome/test_sensor.py +++ b/tests/components/bthome/test_sensor.py @@ -844,6 +844,23 @@ async def test_v1_sensors( }, ], ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x4b\x13\x8a\x14", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_gas", + "friendly_name": "Test Device 18B2 Gas", + "unit_of_measurement": "m³", + "state_class": "total_increasing", + "expected_state": "1346.067", + }, + ], + ), ( "A4:C1:38:8D:18:B2", make_bthome_v2_adv( From fd87748b99733c2a3ff6f8f4fc835f53bcc1f2ef Mon Sep 17 00:00:00 2001 From: StefanIacobLivisi <109964424+StefanIacobLivisi@users.noreply.github.com> Date: Mon, 27 Feb 2023 18:20:10 +0200 Subject: [PATCH 0453/2810] LIVISI climate device improvement (#88844) Code review follow-up --- homeassistant/components/livisi/climate.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/livisi/climate.py b/homeassistant/components/livisi/climate.py index d0bdbe64bf..58589b62e3 100644 --- a/homeassistant/components/livisi/climate.py +++ b/homeassistant/components/livisi/climate.py @@ -139,9 +139,6 @@ class LivisiClimate(CoordinatorEntity[LivisiDataUpdateCoordinator], ClimateEntit def set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Do nothing as LIVISI devices do not support changing the hvac mode.""" - raise HomeAssistantError( - "This feature is not supported with the LIVISI climate devices" - ) async def async_added_to_hass(self) -> None: """Register callbacks.""" From 0e8d28dab09b17f0c461452ee07ec01132af0be4 Mon Sep 17 00:00:00 2001 From: Emory Penney Date: Mon, 27 Feb 2023 08:22:15 -0800 Subject: [PATCH 0454/2810] Add Config Flow to Obihai (#88627) * Obihai: Config Flow Only * Remove reboot service * Update .coveragerc Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * PR Feedback * Use Issue Registry * Add config_flow tests * Another pass with pre-commit * Resolve cyclical import and move sensorClasses to sensor * Update homeassistant/components/obihai/config_flow.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/obihai/sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/obihai/sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/obihai/test_config_flow.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Another round of feedback * More PR feedback * Offline testing, already_configured is required * Update homeassistant/components/obihai/config_flow.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/obihai/config_flow.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/obihai/config_flow.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Cleanup * Update homeassistant/components/obihai/__init__.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * PR feedback * Backout today's changes: Fix mypy error * Update tests/components/obihai/test_config_flow.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/obihai/test_config_flow.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/obihai/test_config_flow.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/obihai/test_config_flow.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Don't plan ahead * PR feedback * Update homeassistant/components/obihai/config_flow.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Cleanup strings --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .coveragerc | 3 +- CODEOWNERS | 1 + homeassistant/components/obihai/__init__.py | 17 ++++ .../components/obihai/config_flow.py | 73 ++++++++++++++++ .../components/obihai/connectivity.py | 67 +++++++++++++++ homeassistant/components/obihai/const.py | 15 ++++ homeassistant/components/obihai/manifest.json | 1 + homeassistant/components/obihai/sensor.py | 85 +++++++++++-------- homeassistant/components/obihai/strings.json | 25 ++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/obihai/__init__.py | 10 +++ tests/components/obihai/conftest.py | 15 ++++ tests/components/obihai/test_config_flow.py | 73 ++++++++++++++++ 15 files changed, 353 insertions(+), 38 deletions(-) create mode 100644 homeassistant/components/obihai/config_flow.py create mode 100644 homeassistant/components/obihai/connectivity.py create mode 100644 homeassistant/components/obihai/const.py create mode 100644 homeassistant/components/obihai/strings.json create mode 100644 tests/components/obihai/__init__.py create mode 100644 tests/components/obihai/conftest.py create mode 100644 tests/components/obihai/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index a06f4fa92d..8a5b90b5d7 100644 --- a/.coveragerc +++ b/.coveragerc @@ -807,7 +807,8 @@ omit = homeassistant/components/nuki/sensor.py homeassistant/components/nx584/alarm_control_panel.py homeassistant/components/oasa_telematics/sensor.py - homeassistant/components/obihai/* + homeassistant/components/obihai/connectivity.py + homeassistant/components/obihai/sensor.py homeassistant/components/octoprint/__init__.py homeassistant/components/oem/climate.py homeassistant/components/ohmconnect/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 94360a4f45..24036150fe 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -826,6 +826,7 @@ build.json @home-assistant/supervisor /homeassistant/components/nzbget/ @chriscla /tests/components/nzbget/ @chriscla /homeassistant/components/obihai/ @dshokouhi +/tests/components/obihai/ @dshokouhi /homeassistant/components/octoprint/ @rfleming71 /tests/components/octoprint/ @rfleming71 /homeassistant/components/ohmconnect/ @robbiet480 diff --git a/homeassistant/components/obihai/__init__.py b/homeassistant/components/obihai/__init__.py index 8e65423b73..810b24dca2 100644 --- a/homeassistant/components/obihai/__init__.py +++ b/homeassistant/components/obihai/__init__.py @@ -1 +1,18 @@ """The Obihai integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import PLATFORMS + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up from a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/obihai/config_flow.py b/homeassistant/components/obihai/config_flow.py new file mode 100644 index 0000000000..dd2aa0db06 --- /dev/null +++ b/homeassistant/components/obihai/config_flow.py @@ -0,0 +1,73 @@ +"""Config flow to configure the Obihai integration.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult + +from .connectivity import validate_auth +from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, DOMAIN + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Optional( + CONF_USERNAME, + default=DEFAULT_USERNAME, + ): str, + vol.Optional( + CONF_PASSWORD, + default=DEFAULT_PASSWORD, + ): str, + } +) + + +class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN): + """Config flow for Obihai.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + + if user_input is not None: + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + if await self.hass.async_add_executor_job( + validate_auth, + user_input[CONF_HOST], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ): + return self.async_create_entry( + title=user_input[CONF_HOST], + data=user_input, + ) + errors["base"] = "cannot_connect" + + data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA, user_input) + return self.async_show_form( + step_id="user", + errors=errors, + data_schema=data_schema, + ) + + # DEPRECATED + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Handle a flow initialized by importing a config.""" + self._async_abort_entries_match({CONF_HOST: config[CONF_HOST]}) + return self.async_create_entry( + title=config.get(CONF_NAME, config[CONF_HOST]), + data={ + CONF_HOST: config[CONF_HOST], + CONF_PASSWORD: config[CONF_PASSWORD], + CONF_USERNAME: config[CONF_USERNAME], + }, + ) diff --git a/homeassistant/components/obihai/connectivity.py b/homeassistant/components/obihai/connectivity.py new file mode 100644 index 0000000000..4a5c25b210 --- /dev/null +++ b/homeassistant/components/obihai/connectivity.py @@ -0,0 +1,67 @@ +"""Support for Obihai Connectivity.""" +from __future__ import annotations + +from pyobihai import PyObihai + +from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, LOGGER + + +def get_pyobihai( + host: str, + username: str, + password: str, +) -> PyObihai: + """Retrieve an authenticated PyObihai.""" + return PyObihai(host, username, password) + + +def validate_auth( + host: str, + username: str, + password: str, +) -> bool: + """Test if the given setting works as expected.""" + obi = get_pyobihai(host, username, password) + + login = obi.check_account() + if not login: + LOGGER.debug("Invalid credentials") + return False + + return True + + +class ObihaiConnection: + """Contains a list of Obihai Sensors.""" + + def __init__( + self, + host: str, + username: str = DEFAULT_USERNAME, + password: str = DEFAULT_PASSWORD, + ) -> None: + """Store configuration.""" + self.sensors: list = [] + self.host = host + self.username = username + self.password = password + self.serial: list = [] + self.services: list = [] + self.line_services: list = [] + self.call_direction: list = [] + self.pyobihai: PyObihai = None + + def update(self) -> bool: + """Validate connection and retrieve a list of sensors.""" + if not self.pyobihai: + self.pyobihai = get_pyobihai(self.host, self.username, self.password) + + if not self.pyobihai.check_account(): + return False + + self.serial = self.pyobihai.get_device_serial() + self.services = self.pyobihai.get_state() + self.line_services = self.pyobihai.get_line_state() + self.call_direction = self.pyobihai.get_call_direction() + + return True diff --git a/homeassistant/components/obihai/const.py b/homeassistant/components/obihai/const.py new file mode 100644 index 0000000000..90bcd7736f --- /dev/null +++ b/homeassistant/components/obihai/const.py @@ -0,0 +1,15 @@ +"""Constants for the Obihai integration.""" + +import logging +from typing import Final + +from homeassistant.const import Platform + +DOMAIN: Final = "obihai" +DEFAULT_USERNAME = "admin" +DEFAULT_PASSWORD = "admin" +OBIHAI = "Obihai" + +LOGGER = logging.getLogger(__package__) + +PLATFORMS: Final = [Platform.SENSOR] diff --git a/homeassistant/components/obihai/manifest.json b/homeassistant/components/obihai/manifest.json index 867d7d875d..d5bb07805d 100644 --- a/homeassistant/components/obihai/manifest.json +++ b/homeassistant/components/obihai/manifest.json @@ -2,6 +2,7 @@ "domain": "obihai", "name": "Obihai", "codeowners": ["@dshokouhi"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/obihai", "iot_class": "local_polling", "loggers": ["pyobihai"], diff --git a/homeassistant/components/obihai/sensor.py b/homeassistant/components/obihai/sensor.py index cff4e6232e..953193a5ab 100644 --- a/homeassistant/components/obihai/sensor.py +++ b/homeassistant/components/obihai/sensor.py @@ -2,9 +2,7 @@ from __future__ import annotations from datetime import timedelta -import logging -from pyobihai import PyObihai import voluptuous as vol from homeassistant.components.sensor import ( @@ -12,20 +10,19 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -_LOGGER = logging.getLogger(__name__) +from .connectivity import ObihaiConnection +from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, DOMAIN, OBIHAI SCAN_INTERVAL = timedelta(seconds=5) -OBIHAI = "Obihai" -DEFAULT_USERNAME = "admin" -DEFAULT_PASSWORD = "admin" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, @@ -35,46 +32,58 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +# DEPRECATED +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Obihai sensor platform.""" + issue_registry.async_create_issue( + hass, + DOMAIN, + "manual_migration", + breaks_in_ha_version="2023.6.0", + is_fixable=False, + severity=issue_registry.IssueSeverity.ERROR, + translation_key="manual_migration", + ) - username = config[CONF_USERNAME] - password = config[CONF_PASSWORD] - host = config[CONF_HOST] + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + ) + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Obihai sensor entries.""" + + username = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + host = entry.data[CONF_HOST] + requester = ObihaiConnection(host, username, password) + + await hass.async_add_executor_job(requester.update) sensors = [] + for key in requester.services: + sensors.append(ObihaiServiceSensors(requester.pyobihai, requester.serial, key)) - pyobihai = PyObihai(host, username, password) + if requester.line_services is not None: + for key in requester.line_services: + sensors.append( + ObihaiServiceSensors(requester.pyobihai, requester.serial, key) + ) - login = pyobihai.check_account() - if not login: - _LOGGER.error("Invalid credentials") - return + for key in requester.call_direction: + sensors.append(ObihaiServiceSensors(requester.pyobihai, requester.serial, key)) - serial = pyobihai.get_device_serial() - - services = pyobihai.get_state() - - line_services = pyobihai.get_line_state() - - call_direction = pyobihai.get_call_direction() - - for key in services: - sensors.append(ObihaiServiceSensors(pyobihai, serial, key)) - - if line_services is not None: - for key in line_services: - sensors.append(ObihaiServiceSensors(pyobihai, serial, key)) - - for key in call_direction: - sensors.append(ObihaiServiceSensors(pyobihai, serial, key)) - - add_entities(sensors) + async_add_entities(sensors, update_before_add=True) class ObihaiServiceSensors(SensorEntity): @@ -148,6 +157,10 @@ class ObihaiServiceSensors(SensorEntity): def update(self) -> None: """Update the sensor.""" + if not self._pyobihai.check_account(): + self._state = None + return + services = self._pyobihai.get_state() if self._service_name in services: diff --git a/homeassistant/components/obihai/strings.json b/homeassistant/components/obihai/strings.json new file mode 100644 index 0000000000..053343b450 --- /dev/null +++ b/homeassistant/components/obihai/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "issues": { + "manual_migration": { + "title": "Manual migration required for Obihai", + "description": "Configuration of the Obihai platform in YAML is deprecated and will be removed in Home Assistant 2023.6; Your existing configuration has been imported into the UI automatically and can be safely removed from your configuration.yaml file." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 28ceb59384..5055547676 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -293,6 +293,7 @@ FLOWS = { "nut", "nws", "nzbget", + "obihai", "octoprint", "omnilogic", "oncue", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index cee5b2167a..0f5e0ff08e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3777,7 +3777,7 @@ "obihai": { "name": "Obihai", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "octoprint": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7f42857853..58a40dbd5a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1322,6 +1322,9 @@ pynx584==0.5 # homeassistant.components.nzbget pynzbgetapi==0.2.0 +# homeassistant.components.obihai +pyobihai==1.3.2 + # homeassistant.components.octoprint pyoctoprintapi==0.1.11 diff --git a/tests/components/obihai/__init__.py b/tests/components/obihai/__init__.py new file mode 100644 index 0000000000..36d0f58fe4 --- /dev/null +++ b/tests/components/obihai/__init__.py @@ -0,0 +1,10 @@ +"""Tests for the Obihai Integration.""" + + +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME + +USER_INPUT = { + CONF_HOST: "10.10.10.30", + CONF_PASSWORD: "admin", + CONF_USERNAME: "admin", +} diff --git a/tests/components/obihai/conftest.py b/tests/components/obihai/conftest.py new file mode 100644 index 0000000000..64e4d4b1a3 --- /dev/null +++ b/tests/components/obihai/conftest.py @@ -0,0 +1,15 @@ +"""Define test fixtures for Obihai.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.obihai.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/obihai/test_config_flow.py b/tests/components/obihai/test_config_flow.py new file mode 100644 index 0000000000..07d00f1577 --- /dev/null +++ b/tests/components/obihai/test_config_flow.py @@ -0,0 +1,73 @@ +"""Test the Obihai config flow.""" +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.obihai.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import USER_INPUT + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_user_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the user initiated form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch("pyobihai.PyObihai.check_account"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "10.10.10.30" + assert result["data"] == {**USER_INPUT} + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_auth_failure(hass: HomeAssistant) -> None: + """Test we get the authentication error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.obihai.config_flow.validate_auth", return_value=False + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == "cannot_connect" + + +async def test_yaml_import(hass: HomeAssistant) -> None: + """Test we get the YAML imported.""" + with patch( + "homeassistant.components.obihai.config_flow.validate_auth", return_value=True + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert "errors" not in result From 7cc8712a0c4067f63389963bfbbdb66da36deac7 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 27 Feb 2023 17:24:02 +0100 Subject: [PATCH 0455/2810] Change string to enum in SomfyThermostat (#88813) --- .../overkiz/climate_entities/somfy_thermostat.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py b/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py index 8242fdc857..aaae64e045 100644 --- a/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py +++ b/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py @@ -22,13 +22,10 @@ from ..entity import OverkizEntity PRESET_FREEZE = "freeze" PRESET_NIGHT = "night" -STATE_DEROGATION_ACTIVE = "active" -STATE_DEROGATION_INACTIVE = "inactive" - OVERKIZ_TO_HVAC_MODES: dict[str, HVACMode] = { - STATE_DEROGATION_ACTIVE: HVACMode.HEAT, - STATE_DEROGATION_INACTIVE: HVACMode.AUTO, + OverkizCommandParam.ACTIVE: HVACMode.HEAT, + OverkizCommandParam.INACTIVE: HVACMode.AUTO, } HVAC_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODES.items()} From 79f96fe900e0e8073a71d3f0e3d709d50185b89b Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 27 Feb 2023 17:25:02 +0100 Subject: [PATCH 0456/2810] Support ValveHeatingTemperatureInterface in Overkiz integration (#88804) * Add ValveHeatingTemperatureInterface support * Update presets * Bugfix * Bugfixes * Bugfix * Update manifest * Apply feedback * Sort alphabetically * Update homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../overkiz/climate_entities/__init__.py | 2 + .../valve_heating_temperature_interface.py | 137 ++++++++++++++++++ homeassistant/components/overkiz/const.py | 1 + 3 files changed, 140 insertions(+) create mode 100644 homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py diff --git a/homeassistant/components/overkiz/climate_entities/__init__.py b/homeassistant/components/overkiz/climate_entities/__init__.py index e70315e099..9d54c04422 100644 --- a/homeassistant/components/overkiz/climate_entities/__init__.py +++ b/homeassistant/components/overkiz/climate_entities/__init__.py @@ -10,6 +10,7 @@ from .atlantic_heat_recovery_ventilation import AtlanticHeatRecoveryVentilation from .atlantic_pass_apc_heating_zone import AtlanticPassAPCHeatingZone from .atlantic_pass_apc_zone_control import AtlanticPassAPCZoneControl from .somfy_thermostat import SomfyThermostat +from .valve_heating_temperature_interface import ValveHeatingTemperatureInterface WIDGET_TO_CLIMATE_ENTITY = { UIWidget.ATLANTIC_ELECTRICAL_HEATER: AtlanticElectricalHeater, @@ -21,4 +22,5 @@ WIDGET_TO_CLIMATE_ENTITY = { UIWidget.ATLANTIC_PASS_APC_HEATING_ZONE: AtlanticPassAPCHeatingZone, UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: AtlanticPassAPCZoneControl, UIWidget.SOMFY_THERMOSTAT: SomfyThermostat, + UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: ValveHeatingTemperatureInterface, } diff --git a/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py b/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py new file mode 100644 index 0000000000..fdaf0d61f1 --- /dev/null +++ b/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py @@ -0,0 +1,137 @@ +"""Support for ValveHeatingTemperatureInterface.""" +from __future__ import annotations + +from typing import Any, cast + +from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState + +from homeassistant.components.climate import ( + PRESET_AWAY, + PRESET_COMFORT, + PRESET_ECO, + PRESET_NONE, + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, + UnitOfTemperature, +) +from homeassistant.const import ATTR_TEMPERATURE + +from ..const import DOMAIN +from ..coordinator import OverkizDataUpdateCoordinator +from ..entity import OverkizEntity + +PRESET_MANUAL = "manual" +PRESET_FROST_PROTECTION = "frost_protection" + +OVERKIZ_TO_HVAC_ACTION: dict[str, HVACAction] = { + OverkizCommandParam.OPEN: HVACAction.HEATING, + OverkizCommandParam.CLOSED: HVACAction.IDLE, +} + +OVERKIZ_TO_PRESET_MODE: dict[str, str] = { + OverkizCommandParam.GEOFENCING_MODE: PRESET_NONE, + OverkizCommandParam.SUDDEN_DROP_MODE: PRESET_NONE, + OverkizCommandParam.AWAY: PRESET_AWAY, + OverkizCommandParam.COMFORT: PRESET_COMFORT, + OverkizCommandParam.ECO: PRESET_ECO, + OverkizCommandParam.FROSTPROTECTION: PRESET_FROST_PROTECTION, + OverkizCommandParam.MANUAL: PRESET_MANUAL, +} +PRESET_MODE_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_PRESET_MODE.items()} + +TEMPERATURE_SENSOR_DEVICE_INDEX = 2 + + +class ValveHeatingTemperatureInterface(OverkizEntity, ClimateEntity): + """Representation of Valve Heating Temperature Interface device.""" + + _attr_hvac_mode = HVACMode.HEAT + _attr_hvac_modes = [HVACMode.HEAT] + _attr_preset_modes = [*PRESET_MODE_TO_OVERKIZ] + _attr_supported_features = ( + ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ) + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_translation_key = DOMAIN + + def __init__( + self, device_url: str, coordinator: OverkizDataUpdateCoordinator + ) -> None: + """Init method.""" + super().__init__(device_url, coordinator) + self.temperature_device = self.executor.linked_device( + TEMPERATURE_SENSOR_DEVICE_INDEX + ) + + self._attr_min_temp = cast( + float, self.executor.select_state(OverkizState.CORE_MIN_SETPOINT) + ) + self._attr_max_temp = cast( + float, self.executor.select_state(OverkizState.CORE_MAX_SETPOINT) + ) + + @property + def hvac_action(self) -> str: + """Return the current running hvac operation.""" + return OVERKIZ_TO_HVAC_ACTION[ + cast(str, self.executor.select_state(OverkizState.CORE_OPEN_CLOSED_VALVE)) + ] + + @property + def target_temperature(self) -> float: + """Return the temperature.""" + return cast( + float, self.executor.select_state(OverkizState.CORE_TARGET_TEMPERATURE) + ) + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + if temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]: + return temperature.value_as_float + + return None + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new temperature.""" + temperature = kwargs[ATTR_TEMPERATURE] + + await self.executor.async_execute_command( + OverkizCommand.SET_DEROGATION, + float(temperature), + OverkizCommandParam.FURTHER_NOTICE, + ) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + return + + @property + def preset_mode(self) -> str: + """Return the current preset mode, e.g., home, away, temp.""" + return OVERKIZ_TO_PRESET_MODE[ + cast( + str, self.executor.select_state(OverkizState.IO_DEROGATION_HEATING_MODE) + ) + ] + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + + # If we want to switch to manual mode via a preset, we need to pass in a temperature + # Manual mode will be on automatically if an user sets a temperature + if preset_mode == PRESET_MANUAL: + if current_temperature := self.current_temperature: + await self.executor.async_execute_command( + OverkizCommand.SET_DEROGATION, + current_temperature, + OverkizCommandParam.FURTHER_NOTICE, + ) + else: + await self.executor.async_execute_command( + OverkizCommand.SET_DEROGATION, + PRESET_MODE_TO_OVERKIZ[preset_mode], + OverkizCommandParam.FURTHER_NOTICE, + ) diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py index d176a13754..806ba435c2 100644 --- a/homeassistant/components/overkiz/const.py +++ b/homeassistant/components/overkiz/const.py @@ -83,6 +83,7 @@ OVERKIZ_DEVICE_TO_PLATFORM: dict[UIClass | UIWidget, Platform | None] = { UIWidget.STATEFUL_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL, # widgetName, uiClass is Alarm (not supported) UIWidget.STATELESS_EXTERIOR_HEATING: Platform.SWITCH, # widgetName, uiClass is ExteriorHeatingSystem (not supported) UIWidget.TSKALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL, # widgetName, uiClass is Alarm (not supported) + UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) } # Map Overkiz camelCase to Home Assistant snake_case for translation From 73c7ee4326ba51a8ba92bd951993d6ab20f03402 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Mon, 27 Feb 2023 18:45:29 +0100 Subject: [PATCH 0457/2810] 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 9dd043a715..e2f068b961 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 680c4ed043..91b1fb85f0 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 58a40dbd5a..243c488d3f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -923,7 +923,7 @@ oauth2client==4.1.3 objgraph==3.5.0 # homeassistant.components.garages_amsterdam -odp-amsterdam==5.0.1 +odp-amsterdam==5.1.0 # homeassistant.components.omnilogic omnilogic==0.4.5 From 7a5a8826875a973b7b8d0f5dcf90f6aa2eab4125 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 27 Feb 2023 12:07:57 -0800 Subject: [PATCH 0458/2810] 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 8b025f6eec..2e0653b47e 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 1e0d8999d3..44f88aa733 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 91b1fb85f0..61824576d5 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 243c488d3f..379e0fd6c9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -352,7 +352,7 @@ base36==0.1.1 beautifulsoup4==4.11.1 # homeassistant.components.zha -bellows==0.34.7 +bellows==0.34.9 # homeassistant.components.bmw_connected_drive bimmer_connected==0.12.1 @@ -1937,10 +1937,10 @@ zigpy-xbee==0.16.2 zigpy-zigate==0.10.3 # homeassistant.components.zha -zigpy-znp==0.9.2 +zigpy-znp==0.9.3 # homeassistant.components.zha -zigpy==0.53.0 +zigpy==0.53.2 # homeassistant.components.zwave_js zwave-js-server-python==0.46.0 From 9fed4472f1d38cea66db36ebd6b6e388b1ecc66c Mon Sep 17 00:00:00 2001 From: Emory Penney Date: Mon, 27 Feb 2023 13:29:51 -0800 Subject: [PATCH 0459/2810] Adding Obihai codeowner (#88856) Obihai: Adding codeowner --- CODEOWNERS | 4 ++-- homeassistant/components/obihai/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 24036150fe..eb370cf77c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -825,8 +825,8 @@ build.json @home-assistant/supervisor /tests/components/nws/ @MatthewFlamm @kamiyo /homeassistant/components/nzbget/ @chriscla /tests/components/nzbget/ @chriscla -/homeassistant/components/obihai/ @dshokouhi -/tests/components/obihai/ @dshokouhi +/homeassistant/components/obihai/ @dshokouhi @ejpenney +/tests/components/obihai/ @dshokouhi @ejpenney /homeassistant/components/octoprint/ @rfleming71 /tests/components/octoprint/ @rfleming71 /homeassistant/components/ohmconnect/ @robbiet480 diff --git a/homeassistant/components/obihai/manifest.json b/homeassistant/components/obihai/manifest.json index d5bb07805d..939c170f98 100644 --- a/homeassistant/components/obihai/manifest.json +++ b/homeassistant/components/obihai/manifest.json @@ -1,7 +1,7 @@ { "domain": "obihai", "name": "Obihai", - "codeowners": ["@dshokouhi"], + "codeowners": ["@dshokouhi", "@ejpenney"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/obihai", "iot_class": "local_polling", From c096ef3fce8bba1528511cdeec0e78d5775dd5c6 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 28 Feb 2023 00:20:40 +0100 Subject: [PATCH 0460/2810] 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 1daffd4307..3d6fedb070 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 08cccaf7b5..16b1969f61 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 61824576d5..2104d8cd50 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 379e0fd6c9..9921b37b80 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -690,7 +690,7 @@ hole==0.8.0 holidays==0.18.0 # homeassistant.components.frontend -home-assistant-frontend==20230224.0 +home-assistant-frontend==20230227.0 # homeassistant.components.conversation home-assistant-intents==2023.2.22 From 7b3cab1bfe309f9c89913805aa7d82a7ac752217 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Tue, 28 Feb 2023 00:22:22 +0100 Subject: [PATCH 0461/2810] 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 9bf2731b3d..ce09032e1a 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 2104d8cd50..451f9c426a 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 9921b37b80..c9dfd63871 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1884,7 +1884,7 @@ xbox-webapi==2.0.11 xiaomi-ble==0.16.4 # homeassistant.components.knx -xknx==2.5.0 +xknx==2.6.0 # homeassistant.components.bluesound # homeassistant.components.fritz From 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 0462/2810] 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 cf83db4f9c..354656ecd9 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 0463/2810] 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 bbea3f1d5f..94f2bab75a 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 9740d338ef..2377f47d7e 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 0464/2810] 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 02bb95c38f..4b8e73e9fe 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 358caaf853..3308b8d942 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 c20cbe6a59..b9c4ff1b19 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 0465/2810] 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 5008325d5e..e44251ed7c 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 3308b8d942..dc2b91dc1e 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 b9c4ff1b19..075af80ed5 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 0466/2810] 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 e44251ed7c..16624f2af5 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 dc2b91dc1e..575b05d481 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 075af80ed5..cc895a45e9 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 0467/2810] 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 8b025f6eec..2e0653b47e 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 1e0d8999d3..44f88aa733 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 575b05d481..101a60ed54 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 cc895a45e9..a090ea6d72 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 0468/2810] 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 5ea67d3a07..c2c2485d94 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 101a60ed54..848e5d8367 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 a090ea6d72..e7b34100db 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 0469/2810] 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 75c37ab7b7..fd86024fbd 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 b3c3f9262d..7d96d51d5c 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 0470/2810] 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 c6f151b8e6..070378b342 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 5f19f233e3..a514760212 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 0471/2810] 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 9dd043a715..e2f068b961 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 848e5d8367..025e63c892 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 e7b34100db..05f9b3748f 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 0472/2810] 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 1daffd4307..3d6fedb070 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 08cccaf7b5..16b1969f61 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 025e63c892..cf86737e94 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 05f9b3748f..decf81c8d7 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 0473/2810] 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 9bf2731b3d..ce09032e1a 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 cf86737e94..109d88605b 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 decf81c8d7..8e4c697c7c 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 0474/2810] 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 541b5e6a96..f9a6d24e9e 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 121f3726ca..e5bcef1765 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 07c25b3dd83503dbb5129475f40dfe0f52a08360 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 28 Feb 2023 07:16:22 +0000 Subject: [PATCH 0475/2810] Prosegur late review comments (#88859) * address late comments on #76428 * adress review * extra tweaks --- homeassistant/components/prosegur/camera.py | 4 ++-- tests/components/prosegur/conftest.py | 9 ++++++--- tests/components/prosegur/test_camera.py | 13 ++++++------- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/prosegur/camera.py b/homeassistant/components/prosegur/camera.py index 40f8e18fb6..848b763903 100644 --- a/homeassistant/components/prosegur/camera.py +++ b/homeassistant/components/prosegur/camera.py @@ -73,8 +73,8 @@ class ProsegurCamera(Camera): ) -> bytes | None: """Return bytes of camera image.""" + _LOGGER.debug("Get image for %s", self._camera.description) try: - _LOGGER.debug("Get image for %s", self._camera.description) return await self._installation.get_image(self._auth, self._camera.id) except ProsegurException as err: @@ -85,8 +85,8 @@ class ProsegurCamera(Camera): async def async_request_image(self): """Request new image from the camera.""" + _LOGGER.debug("Request image for %s", self._camera.description) try: - _LOGGER.debug("Request image for %s", self._camera.description) await self._installation.request_image(self._auth, self._camera.id) except ProsegurException as err: diff --git a/tests/components/prosegur/conftest.py b/tests/components/prosegur/conftest.py index ea906fdcbf..bd2ce231e2 100644 --- a/tests/components/prosegur/conftest.py +++ b/tests/components/prosegur/conftest.py @@ -1,5 +1,5 @@ """Define test fixtures for Prosegur.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from pyprosegur.installation import Camera import pytest @@ -30,9 +30,12 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture def mock_install() -> AsyncMock: """Return the mocked alarm install.""" - install = AsyncMock() + install = MagicMock() install.contract = CONTRACT install.cameras = [Camera("1", "test_cam")] + install.arm = AsyncMock() + install.disarm = AsyncMock() + install.arm_partially = AsyncMock() install.get_image = AsyncMock(return_value=b"ABC") install.request_image = AsyncMock() @@ -51,7 +54,7 @@ async def init_integration( with patch( "pyprosegur.installation.Installation.retrieve", return_value=mock_install - ), patch("pyprosegur.auth.Auth.login", return_value=AsyncMock()): + ), patch("pyprosegur.auth.Auth.login"): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/prosegur/test_camera.py b/tests/components/prosegur/test_camera.py index 75e4cbbc77..40ab57e088 100644 --- a/tests/components/prosegur/test_camera.py +++ b/tests/components/prosegur/test_camera.py @@ -27,13 +27,12 @@ async def test_camera_fail(hass, init_integration, mock_install, caplog): return_value=b"ABC", side_effect=ProsegurException() ) - with caplog.at_level(logging.ERROR, logger="homeassistant.components.prosegur"): - try: - await camera.async_get_image(hass, "camera.test_cam") - except HomeAssistantError as exc: - assert str(exc) == "Unable to get image" - else: - assert pytest.fail() + with caplog.at_level( + logging.ERROR, logger="homeassistant.components.prosegur" + ), pytest.raises(HomeAssistantError) as exc: + await camera.async_get_image(hass, "camera.test_cam") + + assert "Unable to get image" in str(exc.value) assert "Image test_cam doesn't exist" in caplog.text From d2ea773e7f580c6019fef701c55dc91a4e3bd463 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Feb 2023 08:54:05 +0100 Subject: [PATCH 0476/2810] Adjust AddEntitiesCallback import (part 1) (#88870) Adjust AddEntitiesCallback import --- homeassistant/components/deluge/sensor.py | 6 ++---- homeassistant/components/deluge/switch.py | 6 ++---- homeassistant/components/efergy/sensor.py | 6 ++---- homeassistant/components/modem_callerid/button.py | 6 ++---- homeassistant/components/modem_callerid/sensor.py | 6 ++---- homeassistant/components/switchbot/humidifier.py | 6 ++---- homeassistant/components/switchbot/switch.py | 6 ++---- 7 files changed, 14 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/deluge/sensor.py b/homeassistant/components/deluge/sensor.py index 12b7ce0dd8..eed194640d 100644 --- a/homeassistant/components/deluge/sensor.py +++ b/homeassistant/components/deluge/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_IDLE, Platform, UnitOfDataRate from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from . import DelugeEntity @@ -71,9 +71,7 @@ SENSOR_TYPES: tuple[DelugeSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Deluge sensor.""" async_add_entities( diff --git a/homeassistant/components/deluge/switch.py b/homeassistant/components/deluge/switch.py index 5b3989384c..f9e89543d2 100644 --- a/homeassistant/components/deluge/switch.py +++ b/homeassistant/components/deluge/switch.py @@ -7,7 +7,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DelugeEntity from .const import DOMAIN @@ -15,9 +15,7 @@ from .coordinator import DelugeDataUpdateCoordinator async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Deluge switch.""" async_add_entities([DelugeSwitch(hass.data[DOMAIN][entry.entry_id])]) diff --git a/homeassistant/components/efergy/sensor.py b/homeassistant/components/efergy/sensor.py index 0fb58319b4..1f544a7a97 100644 --- a/homeassistant/components/efergy/sensor.py +++ b/homeassistant/components/efergy/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from . import EfergyEntity @@ -104,9 +104,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Efergy sensors.""" api: Efergy = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/modem_callerid/button.py b/homeassistant/components/modem_callerid/button.py index 63a88a8a4e..4b149deece 100644 --- a/homeassistant/components/modem_callerid/button.py +++ b/homeassistant/components/modem_callerid/button.py @@ -7,15 +7,13 @@ from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DATA_KEY_API, DOMAIN async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Modem Caller ID sensor.""" api = hass.data[DOMAIN][entry.entry_id][DATA_KEY_API] diff --git a/homeassistant/components/modem_callerid/sensor.py b/homeassistant/components/modem_callerid/sensor.py index 4f84abd453..1cb1043a5e 100644 --- a/homeassistant/components/modem_callerid/sensor.py +++ b/homeassistant/components/modem_callerid/sensor.py @@ -7,15 +7,13 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, STATE_IDLE from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers import entity_platform +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import CID, DATA_KEY_API, DOMAIN, ICON async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Modem Caller ID sensor.""" api = hass.data[DOMAIN][entry.entry_id][DATA_KEY_API] diff --git a/homeassistant/components/switchbot/humidifier.py b/homeassistant/components/switchbot/humidifier.py index 2bb71bacea..148e4c3545 100644 --- a/homeassistant/components/switchbot/humidifier.py +++ b/homeassistant/components/switchbot/humidifier.py @@ -14,7 +14,7 @@ from homeassistant.components.humidifier import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .coordinator import SwitchbotDataUpdateCoordinator @@ -25,9 +25,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Switchbot based on a config entry.""" coordinator: SwitchbotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/switchbot/switch.py b/homeassistant/components/switchbot/switch.py index 67749ea0c5..76214a4412 100644 --- a/homeassistant/components/switchbot/switch.py +++ b/homeassistant/components/switchbot/switch.py @@ -9,7 +9,7 @@ from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .const import DOMAIN @@ -22,9 +22,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Switchbot based on a config entry.""" coordinator: SwitchbotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] From d397217b5b04546c3d47ae7b0812810a3b8cc9bc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Feb 2023 10:23:36 +0100 Subject: [PATCH 0477/2810] 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 070378b342..b294dfa51e 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 0000000000..0a9cf0004b --- /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 a514760212..7ff096795c 100644 --- a/tests/components/thread/test_config_flow.py +++ b/tests/components/thread/test_config_flow.py @@ -103,14 +103,18 @@ async def test_user(hass: HomeAssistant) -> None: async def test_zeroconf(hass: HomeAssistant) -> None: """Test the zeroconf flow.""" + result = await hass.config_entries.flow.async_init( + thread.DOMAIN, context={"source": "zeroconf"}, data=TEST_ZEROCONF_RECORD + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + assert result["step_id"] == "confirm" + with patch( "homeassistant.components.thread.async_setup_entry", return_value=True, ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - thread.DOMAIN, context={"source": "zeroconf"}, data=TEST_ZEROCONF_RECORD - ) - + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "Thread" assert result["data"] == {} @@ -124,16 +128,34 @@ async def test_zeroconf(hass: HomeAssistant) -> None: assert config_entry.unique_id is None -async def test_zeroconf_then_import(hass: HomeAssistant) -> None: - """Test the import flow.""" +async def test_zeroconf_setup_onboarding(hass: HomeAssistant) -> None: + """Test we automatically finish a zeroconf flow during onboarding.""" with patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=False + ), patch( "homeassistant.components.thread.async_setup_entry", return_value=True, ) as mock_setup_entry: result = await hass.config_entries.flow.async_init( thread.DOMAIN, context={"source": "zeroconf"}, data=TEST_ZEROCONF_RECORD ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Thread" + assert result["data"] == {} + assert result["options"] == {} + assert len(mock_setup_entry.mock_calls) == 1 + +async def test_zeroconf_then_import(hass: HomeAssistant) -> None: + """Test the import flow.""" + result = await hass.config_entries.flow.async_init( + thread.DOMAIN, context={"source": "zeroconf"}, data=TEST_ZEROCONF_RECORD + ) + with patch( + "homeassistant.components.thread.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] == FlowResultType.CREATE_ENTRY with patch( From bef5fde832df91fa6ccb02f61d13c99944b42be0 Mon Sep 17 00:00:00 2001 From: rodriguestiago0 Date: Tue, 28 Feb 2023 09:28:44 +0000 Subject: [PATCH 0478/2810] Add stop charge button to renault integration (#88003) * Added service to start/stop charge * Remove comment * Fixed service * removed service for start/stop charge * Remove version Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Format Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Revert change * Fix lint * Add tests --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/renault/button.py | 7 +++++ .../components/renault/renault_vehicle.py | 5 ++++ tests/components/renault/const.py | 18 ++++++++++++ .../fixtures/action.set_charge_stop.json | 7 +++++ tests/components/renault/test_button.py | 28 +++++++++++++++++++ 5 files changed, 65 insertions(+) create mode 100644 tests/components/renault/fixtures/action.set_charge_stop.json diff --git a/homeassistant/components/renault/button.py b/homeassistant/components/renault/button.py index b34e14d365..67dfe8fc97 100644 --- a/homeassistant/components/renault/button.py +++ b/homeassistant/components/renault/button.py @@ -71,4 +71,11 @@ BUTTON_TYPES: tuple[RenaultButtonEntityDescription, ...] = ( name="Start charge", requires_electricity=True, ), + RenaultButtonEntityDescription( + async_press=lambda x: x.vehicle.set_charge_stop(), + key="stop_charge", + icon="mdi:ev-station", + name="Stop charge", + requires_electricity=True, + ), ) diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index 69835552ba..9580ea2b7d 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -151,6 +151,11 @@ class RenaultVehicleProxy: """Start vehicle charge.""" return await self._vehicle.set_charge_start() + @with_error_wrapping + async def set_charge_stop(self) -> models.KamereonVehicleChargingStartActionData: + """Stop vehicle charge.""" + return await self._vehicle.set_charge_stop() + @with_error_wrapping async def set_ac_stop(self) -> models.KamereonVehicleHvacStartActionData: """Stop vehicle ac.""" diff --git a/tests/components/renault/const.py b/tests/components/renault/const.py index ee4f1683aa..e17e6b6494 100644 --- a/tests/components/renault/const.py +++ b/tests/components/renault/const.py @@ -114,6 +114,12 @@ MOCK_VEHICLES = { ATTR_STATE: STATE_UNKNOWN, ATTR_UNIQUE_ID: "vf1aaaaa555777999_start_charge", }, + { + ATTR_ENTITY_ID: "button.reg_number_stop_charge", + ATTR_ICON: "mdi:ev-station", + ATTR_STATE: STATE_UNKNOWN, + ATTR_UNIQUE_ID: "vf1aaaaa555777999_stop_charge", + }, ], Platform.DEVICE_TRACKER: [], Platform.SELECT: [ @@ -336,6 +342,12 @@ MOCK_VEHICLES = { ATTR_STATE: STATE_UNKNOWN, ATTR_UNIQUE_ID: "vf1aaaaa555777999_start_charge", }, + { + ATTR_ENTITY_ID: "button.reg_number_stop_charge", + ATTR_ICON: "mdi:ev-station", + ATTR_STATE: STATE_UNKNOWN, + ATTR_UNIQUE_ID: "vf1aaaaa555777999_stop_charge", + }, ], Platform.DEVICE_TRACKER: [ { @@ -565,6 +577,12 @@ MOCK_VEHICLES = { ATTR_STATE: STATE_UNKNOWN, ATTR_UNIQUE_ID: "vf1aaaaa555777123_start_charge", }, + { + ATTR_ENTITY_ID: "button.reg_number_stop_charge", + ATTR_ICON: "mdi:ev-station", + ATTR_STATE: STATE_UNKNOWN, + ATTR_UNIQUE_ID: "vf1aaaaa555777123_stop_charge", + }, ], Platform.DEVICE_TRACKER: [ { diff --git a/tests/components/renault/fixtures/action.set_charge_stop.json b/tests/components/renault/fixtures/action.set_charge_stop.json new file mode 100644 index 0000000000..017059782b --- /dev/null +++ b/tests/components/renault/fixtures/action.set_charge_stop.json @@ -0,0 +1,7 @@ +{ + "data": { + "type": "ChargingStart", + "id": "guid", + "attributes": { "action": "stop" } + } +} diff --git a/tests/components/renault/test_button.py b/tests/components/renault/test_button.py index 8ec18e3b10..695be73089 100644 --- a/tests/components/renault/test_button.py +++ b/tests/components/renault/test_button.py @@ -160,6 +160,34 @@ async def test_button_start_charge( assert mock_action.mock_calls[0][1] == () +@pytest.mark.usefixtures("fixtures_with_data") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +async def test_button_stop_charge( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: + """Test that button invokes renault_api with correct data.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + data = { + ATTR_ENTITY_ID: "button.reg_number_stop_charge", + } + + with patch( + "renault_api.renault_vehicle.RenaultVehicle.set_charge_stop", + return_value=( + schemas.KamereonVehicleChargingStartActionDataSchema.loads( + load_fixture("renault/action.set_charge_stop.json") + ) + ), + ) as mock_action: + await hass.services.async_call( + BUTTON_DOMAIN, SERVICE_PRESS, service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + assert mock_action.mock_calls[0][1] == () + + @pytest.mark.usefixtures("fixtures_with_data") @pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) async def test_button_start_air_conditioner( From 4e66554298b5f65e47536535136d2d294efbb83c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 28 Feb 2023 11:14:04 +0100 Subject: [PATCH 0479/2810] Also set `hass.config_entries` when `mock_hass_config` fixture is used (#88669) * Set `hass.config_entries` with `mock_hass_config` * Update tests/conftest.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- tests/conftest.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 22701ee0e8..61d5a70d99 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,7 +45,7 @@ from homeassistant.components.websocket_api.auth import ( ) from homeassistant.components.websocket_api.http import URL from homeassistant.config import YAML_CONFIG_FILE -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntries, ConfigEntry from homeassistant.const import HASSIO_USER_NAME from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import ( @@ -938,8 +938,11 @@ def mock_hass_config( ) -> Generator[None, None, None]: """Fixture to mock the content of main configuration. - Patches homeassistant.config.load_yaml_config_file with `hass_config` parameterized as content. + Patches homeassistant.config.load_yaml_config_file and hass.config_entries + with `hass_config` as parameterized. """ + if hass_config: + hass.config_entries = ConfigEntries(hass, hass_config) with patch("homeassistant.config.load_yaml_config_file", return_value=hass_config): yield From b6f66b3568664d17e128a9d911c689b4c4e182d1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Feb 2023 11:35:47 +0100 Subject: [PATCH 0480/2810] Add WS command weather/convertible_units (#85681) --- homeassistant/components/weather/__init__.py | 85 +++++-------------- homeassistant/components/weather/const.py | 76 +++++++++++++++++ .../components/weather/websocket_api.py | 30 +++++++ .../components/weather/test_websocket_api.py | 31 +++++++ 4 files changed, 157 insertions(+), 65 deletions(-) create mode 100644 homeassistant/components/weather/const.py create mode 100644 homeassistant/components/weather/websocket_api.py create mode 100644 tests/components/weather/test_websocket_api.py diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 52642c4f1b..0a99b6aaaf 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -1,7 +1,6 @@ """Weather component that handles meteorological data for your location.""" from __future__ import annotations -from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass from datetime import timedelta @@ -16,8 +15,6 @@ from homeassistant.const import ( PRECISION_HALVES, PRECISION_TENTHS, PRECISION_WHOLE, - UnitOfLength, - UnitOfPrecipitationDepth, UnitOfPressure, UnitOfSpeed, UnitOfTemperature, @@ -30,14 +27,27 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from homeassistant.util.unit_conversion import ( - DistanceConverter, - PressureConverter, - SpeedConverter, - TemperatureConverter, -) from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM +from .const import ( + ATTR_WEATHER_HUMIDITY, + ATTR_WEATHER_OZONE, + ATTR_WEATHER_PRECIPITATION_UNIT, + ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_PRESSURE_UNIT, + ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_TEMPERATURE_UNIT, + ATTR_WEATHER_VISIBILITY, + ATTR_WEATHER_VISIBILITY_UNIT, + ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_SPEED, + ATTR_WEATHER_WIND_SPEED_UNIT, + DOMAIN, + UNIT_CONVERSIONS, + VALID_UNITS, +) +from .websocket_api import async_setup as async_setup_ws_api + _LOGGER = logging.getLogger(__name__) ATTR_CONDITION_CLASS = "condition_class" @@ -71,20 +81,6 @@ ATTR_FORECAST_TIME: Final = "datetime" ATTR_FORECAST_WIND_BEARING: Final = "wind_bearing" ATTR_FORECAST_NATIVE_WIND_SPEED: Final = "native_wind_speed" ATTR_FORECAST_WIND_SPEED: Final = "wind_speed" -ATTR_WEATHER_HUMIDITY = "humidity" -ATTR_WEATHER_OZONE = "ozone" -ATTR_WEATHER_PRESSURE = "pressure" -ATTR_WEATHER_PRESSURE_UNIT = "pressure_unit" -ATTR_WEATHER_TEMPERATURE = "temperature" -ATTR_WEATHER_TEMPERATURE_UNIT = "temperature_unit" -ATTR_WEATHER_VISIBILITY = "visibility" -ATTR_WEATHER_VISIBILITY_UNIT = "visibility_unit" -ATTR_WEATHER_WIND_BEARING = "wind_bearing" -ATTR_WEATHER_WIND_SPEED = "wind_speed" -ATTR_WEATHER_WIND_SPEED_UNIT = "wind_speed_unit" -ATTR_WEATHER_PRECIPITATION_UNIT = "precipitation_unit" - -DOMAIN = "weather" ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -92,48 +88,6 @@ SCAN_INTERVAL = timedelta(seconds=30) ROUNDING_PRECISION = 2 -VALID_UNITS_PRESSURE: set[str] = { - UnitOfPressure.HPA, - UnitOfPressure.MBAR, - UnitOfPressure.INHG, - UnitOfPressure.MMHG, -} -VALID_UNITS_TEMPERATURE: set[str] = { - UnitOfTemperature.CELSIUS, - UnitOfTemperature.FAHRENHEIT, -} -VALID_UNITS_PRECIPITATION: set[str] = { - UnitOfPrecipitationDepth.MILLIMETERS, - UnitOfPrecipitationDepth.INCHES, -} -VALID_UNITS_VISIBILITY: set[str] = { - UnitOfLength.KILOMETERS, - UnitOfLength.MILES, -} -VALID_UNITS_WIND_SPEED: set[str] = { - UnitOfSpeed.FEET_PER_SECOND, - UnitOfSpeed.KILOMETERS_PER_HOUR, - UnitOfSpeed.KNOTS, - UnitOfSpeed.METERS_PER_SECOND, - UnitOfSpeed.MILES_PER_HOUR, -} - -UNIT_CONVERSIONS: dict[str, Callable[[float, str, str], float]] = { - ATTR_WEATHER_PRESSURE_UNIT: PressureConverter.convert, - ATTR_WEATHER_TEMPERATURE_UNIT: TemperatureConverter.convert, - ATTR_WEATHER_VISIBILITY_UNIT: DistanceConverter.convert, - ATTR_WEATHER_PRECIPITATION_UNIT: DistanceConverter.convert, - ATTR_WEATHER_WIND_SPEED_UNIT: SpeedConverter.convert, -} - -VALID_UNITS: dict[str, set[str]] = { - ATTR_WEATHER_PRESSURE_UNIT: VALID_UNITS_PRESSURE, - ATTR_WEATHER_TEMPERATURE_UNIT: VALID_UNITS_TEMPERATURE, - ATTR_WEATHER_VISIBILITY_UNIT: VALID_UNITS_VISIBILITY, - ATTR_WEATHER_PRECIPITATION_UNIT: VALID_UNITS_PRECIPITATION, - ATTR_WEATHER_WIND_SPEED_UNIT: VALID_UNITS_WIND_SPEED, -} - # mypy: disallow-any-generics @@ -182,6 +136,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component = hass.data[DOMAIN] = EntityComponent[WeatherEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) + async_setup_ws_api(hass) await component.async_setup(config) return True diff --git a/homeassistant/components/weather/const.py b/homeassistant/components/weather/const.py new file mode 100644 index 0000000000..2dcfd8a2dd --- /dev/null +++ b/homeassistant/components/weather/const.py @@ -0,0 +1,76 @@ +"""Constants for weather.""" +from __future__ import annotations + +from collections.abc import Callable +from typing import Final + +from homeassistant.const import ( + UnitOfLength, + UnitOfPrecipitationDepth, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, +) +from homeassistant.util.unit_conversion import ( + DistanceConverter, + PressureConverter, + SpeedConverter, + TemperatureConverter, +) + +ATTR_WEATHER_HUMIDITY = "humidity" +ATTR_WEATHER_OZONE = "ozone" +ATTR_WEATHER_PRESSURE = "pressure" +ATTR_WEATHER_PRESSURE_UNIT = "pressure_unit" +ATTR_WEATHER_TEMPERATURE = "temperature" +ATTR_WEATHER_TEMPERATURE_UNIT = "temperature_unit" +ATTR_WEATHER_VISIBILITY = "visibility" +ATTR_WEATHER_VISIBILITY_UNIT = "visibility_unit" +ATTR_WEATHER_WIND_BEARING = "wind_bearing" +ATTR_WEATHER_WIND_SPEED = "wind_speed" +ATTR_WEATHER_WIND_SPEED_UNIT = "wind_speed_unit" +ATTR_WEATHER_PRECIPITATION_UNIT = "precipitation_unit" + +DOMAIN: Final = "weather" + +VALID_UNITS_PRESSURE: set[str] = { + UnitOfPressure.HPA, + UnitOfPressure.MBAR, + UnitOfPressure.INHG, + UnitOfPressure.MMHG, +} +VALID_UNITS_TEMPERATURE: set[str] = { + UnitOfTemperature.CELSIUS, + UnitOfTemperature.FAHRENHEIT, +} +VALID_UNITS_PRECIPITATION: set[str] = { + UnitOfPrecipitationDepth.MILLIMETERS, + UnitOfPrecipitationDepth.INCHES, +} +VALID_UNITS_VISIBILITY: set[str] = { + UnitOfLength.KILOMETERS, + UnitOfLength.MILES, +} +VALID_UNITS_WIND_SPEED: set[str] = { + UnitOfSpeed.FEET_PER_SECOND, + UnitOfSpeed.KILOMETERS_PER_HOUR, + UnitOfSpeed.KNOTS, + UnitOfSpeed.METERS_PER_SECOND, + UnitOfSpeed.MILES_PER_HOUR, +} + +UNIT_CONVERSIONS: dict[str, Callable[[float, str, str], float]] = { + ATTR_WEATHER_PRESSURE_UNIT: PressureConverter.convert, + ATTR_WEATHER_TEMPERATURE_UNIT: TemperatureConverter.convert, + ATTR_WEATHER_VISIBILITY_UNIT: DistanceConverter.convert, + ATTR_WEATHER_PRECIPITATION_UNIT: DistanceConverter.convert, + ATTR_WEATHER_WIND_SPEED_UNIT: SpeedConverter.convert, +} + +VALID_UNITS: dict[str, set[str]] = { + ATTR_WEATHER_PRESSURE_UNIT: VALID_UNITS_PRESSURE, + ATTR_WEATHER_TEMPERATURE_UNIT: VALID_UNITS_TEMPERATURE, + ATTR_WEATHER_VISIBILITY_UNIT: VALID_UNITS_VISIBILITY, + ATTR_WEATHER_PRECIPITATION_UNIT: VALID_UNITS_PRECIPITATION, + ATTR_WEATHER_WIND_SPEED_UNIT: VALID_UNITS_WIND_SPEED, +} diff --git a/homeassistant/components/weather/websocket_api.py b/homeassistant/components/weather/websocket_api.py new file mode 100644 index 0000000000..793efeeed7 --- /dev/null +++ b/homeassistant/components/weather/websocket_api.py @@ -0,0 +1,30 @@ +"""The weather websocket API.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback + +from .const import VALID_UNITS + + +@callback +def async_setup(hass: HomeAssistant) -> None: + """Set up the weather websocket API.""" + websocket_api.async_register_command(hass, ws_convertible_units) + + +@callback +@websocket_api.websocket_command( + { + vol.Required("type"): "weather/convertible_units", + } +) +def ws_convertible_units( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Return supported units for a device class.""" + connection.send_result(msg["id"], {"units": VALID_UNITS}) diff --git a/tests/components/weather/test_websocket_api.py b/tests/components/weather/test_websocket_api.py new file mode 100644 index 0000000000..3995bd2a54 --- /dev/null +++ b/tests/components/weather/test_websocket_api.py @@ -0,0 +1,31 @@ +"""Test the weather websocket API.""" +from pytest_unordered import unordered + +from homeassistant.components.weather.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + + +async def test_device_class_units(hass: HomeAssistant, hass_ws_client) -> None: + """Test we can get supported units.""" + assert await async_setup_component(hass, DOMAIN, {}) + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 1, + "type": "weather/convertible_units", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "units": { + "precipitation_unit": unordered(["mm", "in"]), + "pressure_unit": unordered(["mbar", "mmHg", "inHg", "hPa"]), + "temperature_unit": unordered(["°F", "°C"]), + "visibility_unit": unordered(["km", "mi"]), + "wind_speed_unit": unordered(["mph", "km/h", "kn", "m/s", "ft/s"]), + } + } From 4d58c9de8d13b110ca3deffe1c37a35d9bcc2809 Mon Sep 17 00:00:00 2001 From: Felix Rotthowe Date: Tue, 28 Feb 2023 13:08:52 +0100 Subject: [PATCH 0481/2810] Add human readable name for Livisi climate devices (#88891) * Add human readable climate device name * Remove room name from entity name and set "has_entity_name" --- homeassistant/components/livisi/climate.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/livisi/climate.py b/homeassistant/components/livisi/climate.py index 58589b62e3..f99ad8dbe7 100644 --- a/homeassistant/components/livisi/climate.py +++ b/homeassistant/components/livisi/climate.py @@ -70,6 +70,7 @@ def create_entity( ) -> ClimateEntity: """Create Climate Entity.""" capabilities: Mapping[str, Any] = device[CAPABILITY_MAP] + config_details: Mapping[str, Any] = device["config"] room_id: str = device["location"] room_name: str = coordinator.rooms[room_id] livisi_climate = LivisiClimate( @@ -82,6 +83,7 @@ def create_entity( temperature_capability=capabilities["RoomTemperature"], humidity_capability=capabilities["RoomHumidity"], room=room_name, + name=config_details["name"], ) return livisi_climate @@ -95,6 +97,7 @@ class LivisiClimate(CoordinatorEntity[LivisiDataUpdateCoordinator], ClimateEntit _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_target_temperature_high = MAX_TEMPERATURE _attr_target_temperature_low = MIN_TEMPERATURE + _attr_has_entity_name = True def __init__( self, @@ -107,6 +110,7 @@ class LivisiClimate(CoordinatorEntity[LivisiDataUpdateCoordinator], ClimateEntit temperature_capability: str, humidity_capability: str, room: str, + name: str, ) -> None: """Initialize the Livisi Climate.""" self.config_entry = config_entry @@ -116,6 +120,7 @@ class LivisiClimate(CoordinatorEntity[LivisiDataUpdateCoordinator], ClimateEntit self._humidity_capability = humidity_capability self.aio_livisi = coordinator.aiolivisi self._attr_available = False + self._attr_name = name self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, manufacturer=manufacturer, From f41bec6ba9ae1734db1a3572d9bad18a0c383bb3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Feb 2023 13:50:56 +0100 Subject: [PATCH 0482/2810] 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 c202040228..78c5893c88 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 24fb89f214..0a6482b040 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 58b32276ba..a05c3f3e92 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 16fadd9b06..547def8345 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 451f9c426a..37e8ce7c82 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 c9dfd63871..d6df57112a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1493,7 +1493,7 @@ python-nest==4.2.0 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==1.0.4 +python-otbr-api==1.0.5 # homeassistant.components.picnic python-picnic-api==1.1.0 diff --git a/tests/components/otbr/__init__.py b/tests/components/otbr/__init__.py index 2180a091eb..a133f6fda3 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 d02524cb61..ac120b3e16 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 7818d736e0..9261004ec1 100644 --- a/tests/components/otbr/test_init.py +++ b/tests/components/otbr/test_init.py @@ -10,8 +10,15 @@ import python_otbr_api from homeassistant.components import otbr from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import issue_registry as ir -from . import BASE_URL, CONFIG_ENTRY_DATA, DATASET +from . import ( + BASE_URL, + CONFIG_ENTRY_DATA, + DATASET, + DATASET_INSECURE_NW_KEY, + DATASET_INSECURE_PASSPHRASE, +) from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -19,6 +26,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_import_dataset(hass: HomeAssistant) -> None: """Test the active dataset is imported at setup.""" + issue_registry = ir.async_get(hass) config_entry = MockConfigEntry( data=CONFIG_ENTRY_DATA, @@ -35,6 +43,39 @@ async def test_import_dataset(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) mock_add.assert_called_once_with(config_entry.title, DATASET.hex()) + assert not issue_registry.async_get_issue( + domain=otbr.DOMAIN, issue_id=f"insecure_thread_network_{config_entry.entry_id}" + ) + + +@pytest.mark.parametrize( + "dataset", [DATASET_INSECURE_NW_KEY, DATASET_INSECURE_PASSPHRASE] +) +async def test_import_insecure_dataset(hass: HomeAssistant, dataset: bytes) -> None: + """Test the active dataset is imported at setup. + + This imports a dataset with insecure settings. + """ + issue_registry = ir.async_get(hass) + + config_entry = MockConfigEntry( + data=CONFIG_ENTRY_DATA, + domain=otbr.DOMAIN, + options={}, + title="My OTBR", + ) + config_entry.add_to_hass(hass) + with patch( + "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=dataset + ), patch( + "homeassistant.components.thread.dataset_store.DatasetStore.async_add" + ) as mock_add: + assert await hass.config_entries.async_setup(config_entry.entry_id) + + mock_add.assert_called_once_with(config_entry.title, dataset.hex()) + assert issue_registry.async_get_issue( + domain=otbr.DOMAIN, issue_id=f"insecure_thread_network_{config_entry.entry_id}" + ) @pytest.mark.parametrize( From 1c4aa26ab66c6d37d383bb6fc9b9b7f2d70ff995 Mon Sep 17 00:00:00 2001 From: Felix Rotthowe Date: Tue, 28 Feb 2023 14:48:13 +0100 Subject: [PATCH 0483/2810] Add myself to codeowners of Livisi integration (#88900) Add @planbnet to codeowners of Livisi integration --- CODEOWNERS | 4 ++-- homeassistant/components/livisi/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index eb370cf77c..edee0e9b53 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -659,8 +659,8 @@ build.json @home-assistant/supervisor /tests/components/litejet/ @joncar /homeassistant/components/litterrobot/ @natekspencer @tkdrob /tests/components/litterrobot/ @natekspencer @tkdrob -/homeassistant/components/livisi/ @StefanIacobLivisi -/tests/components/livisi/ @StefanIacobLivisi +/homeassistant/components/livisi/ @StefanIacobLivisi @planbnet +/tests/components/livisi/ @StefanIacobLivisi @planbnet /homeassistant/components/local_calendar/ @allenporter /tests/components/local_calendar/ @allenporter /homeassistant/components/local_ip/ @issacg diff --git a/homeassistant/components/livisi/manifest.json b/homeassistant/components/livisi/manifest.json index 6cdebeb307..5b5facd445 100644 --- a/homeassistant/components/livisi/manifest.json +++ b/homeassistant/components/livisi/manifest.json @@ -1,7 +1,7 @@ { "domain": "livisi", "name": "LIVISI Smart Home", - "codeowners": ["@StefanIacobLivisi"], + "codeowners": ["@StefanIacobLivisi", "@planbnet"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/livisi", "iot_class": "local_polling", From 7b5c978b95571a2028e963c6bcba30c368a9c6fe Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Feb 2023 14:59:48 +0100 Subject: [PATCH 0484/2810] Add missing mock in overkiz config flow tests (#88899) --- tests/components/overkiz/conftest.py | 14 +++++++++++ tests/components/overkiz/test_config_flow.py | 26 +++++++++----------- 2 files changed, 25 insertions(+), 15 deletions(-) create mode 100644 tests/components/overkiz/conftest.py diff --git a/tests/components/overkiz/conftest.py b/tests/components/overkiz/conftest.py new file mode 100644 index 0000000000..6e00b6f5fe --- /dev/null +++ b/tests/components/overkiz/conftest.py @@ -0,0 +1,14 @@ +"""Configuration for overkiz tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.overkiz.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/overkiz/test_config_flow.py b/tests/components/overkiz/test_config_flow.py index 0156833b86..89b0b7e842 100644 --- a/tests/components/overkiz/test_config_flow.py +++ b/tests/components/overkiz/test_config_flow.py @@ -1,7 +1,7 @@ """Tests for Overkiz (by Somfy) config flow.""" from __future__ import annotations -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch from aiohttp import ClientError from pyoverkiz.exceptions import ( @@ -21,6 +21,8 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + TEST_EMAIL = "test@testdomain.com" TEST_EMAIL2 = "test@testdomain.nl" TEST_PASSWORD = "test-password" @@ -49,7 +51,7 @@ FAKE_ZERO_CONF_INFO = ZeroconfServiceInfo( ) -async def test_form(hass: HomeAssistant) -> None: +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -60,9 +62,7 @@ async def test_form(hass: HomeAssistant) -> None: with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( "pyoverkiz.client.OverkizClient.get_gateways", return_value=None - ), patch( - "homeassistant.components.overkiz.async_setup_entry", return_value=True - ) as mock_setup_entry: + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, @@ -157,7 +157,7 @@ async def test_abort_on_duplicate_entry(hass: HomeAssistant) -> None: with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( "pyoverkiz.client.OverkizClient.get_gateways", return_value=MOCK_GATEWAY_RESPONSE, - ), patch("homeassistant.components.overkiz.async_setup_entry", return_value=True): + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": TEST_EMAIL, "password": TEST_PASSWORD}, @@ -182,7 +182,7 @@ async def test_allow_multiple_unique_entries(hass: HomeAssistant) -> None: with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( "pyoverkiz.client.OverkizClient.get_gateways", return_value=MOCK_GATEWAY_RESPONSE, - ), patch("homeassistant.components.overkiz.async_setup_entry", return_value=True): + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, @@ -197,7 +197,7 @@ async def test_allow_multiple_unique_entries(hass: HomeAssistant) -> None: } -async def test_dhcp_flow(hass: HomeAssistant) -> None: +async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test that DHCP discovery for new bridge works.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -214,9 +214,7 @@ async def test_dhcp_flow(hass: HomeAssistant) -> None: with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( "pyoverkiz.client.OverkizClient.get_gateways", return_value=None - ), patch( - "homeassistant.components.overkiz.async_setup_entry", return_value=True - ) as mock_setup_entry: + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, @@ -256,7 +254,7 @@ async def test_dhcp_flow_already_configured(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_zeroconf_flow(hass: HomeAssistant) -> None: +async def test_zeroconf_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test that zeroconf discovery for new bridge works.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -269,9 +267,7 @@ async def test_zeroconf_flow(hass: HomeAssistant) -> None: with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( "pyoverkiz.client.OverkizClient.get_gateways", return_value=None - ), patch( - "homeassistant.components.overkiz.async_setup_entry", return_value=True - ) as mock_setup_entry: + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, From e3e4b449585ad781334379fbca0fecdf5ca928b2 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 28 Feb 2023 15:02:40 +0100 Subject: [PATCH 0485/2810] 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 a05c3f3e92..f2efea0c1e 100644 --- a/homeassistant/components/otbr/strings.json +++ b/homeassistant/components/otbr/strings.json @@ -12,7 +12,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } }, "issues": { From a2a23564a47798c0e7a09d8db83aab4be26d2575 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 28 Feb 2023 15:50:00 +0100 Subject: [PATCH 0486/2810] 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 abf3e5412e..1020879ce8 100644 --- a/homeassistant/components/hue/scene.py +++ b/homeassistant/components/hue/scene.py @@ -118,13 +118,14 @@ class HueSceneEntityBase(HueBaseEntity, SceneEntity): """Return device (service) info.""" # we create a virtual service/device for Hue scenes # so we have a parent for grouped lights and scenes + group_type = self.group.type.value.title() return DeviceInfo( identifiers={(DOMAIN, self.group.id)}, entry_type=DeviceEntryType.SERVICE, name=self.group.metadata.name, manufacturer=self.bridge.api.config.bridge_device.product_data.manufacturer_name, model=self.group.type.value.title(), - suggested_area=self.group.metadata.name, + suggested_area=self.group.metadata.name if group_type == "Room" else None, via_device=(DOMAIN, self.bridge.api.config.bridge_device.id), ) From 390daf1723c395535f87cfae2f6cba5ecc58892a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Feb 2023 16:12:49 +0100 Subject: [PATCH 0487/2810] Sort unit lists sent to frontend (#88898) --- .../components/number/websocket_api.py | 7 +++-- .../components/sensor/websocket_api.py | 7 +++-- .../components/weather/websocket_api.py | 5 +++- tests/components/number/test_websocket_api.py | 15 ++++------ tests/components/sensor/test_websocket_api.py | 30 +++++++++++-------- .../components/weather/test_websocket_api.py | 12 ++++---- 6 files changed, 41 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/number/websocket_api.py b/homeassistant/components/number/websocket_api.py index eca280d7d4..1ca61fd158 100644 --- a/homeassistant/components/number/websocket_api.py +++ b/homeassistant/components/number/websocket_api.py @@ -29,7 +29,10 @@ def ws_device_class_units( ) -> None: """Return supported units for a device class.""" device_class = msg["device_class"] - convertible_units = set() + convertible_units = [] if device_class in UNIT_CONVERTERS and device_class in DEVICE_CLASS_UNITS: - convertible_units = DEVICE_CLASS_UNITS[device_class] + convertible_units = sorted( + DEVICE_CLASS_UNITS[device_class], + key=lambda s: str.casefold(str(s)), + ) connection.send_result(msg["id"], {"units": convertible_units}) diff --git a/homeassistant/components/sensor/websocket_api.py b/homeassistant/components/sensor/websocket_api.py index 10699b8c1c..2457bfcabe 100644 --- a/homeassistant/components/sensor/websocket_api.py +++ b/homeassistant/components/sensor/websocket_api.py @@ -29,7 +29,10 @@ def ws_device_class_units( ) -> None: """Return supported units for a device class.""" device_class = msg["device_class"] - convertible_units = set() + convertible_units = [] if device_class in UNIT_CONVERTERS and device_class in DEVICE_CLASS_UNITS: - convertible_units = DEVICE_CLASS_UNITS[device_class] + convertible_units = sorted( + DEVICE_CLASS_UNITS[device_class], + key=lambda s: str.casefold(str(s)), + ) connection.send_result(msg["id"], {"units": convertible_units}) diff --git a/homeassistant/components/weather/websocket_api.py b/homeassistant/components/weather/websocket_api.py index 793efeeed7..51f129fc4a 100644 --- a/homeassistant/components/weather/websocket_api.py +++ b/homeassistant/components/weather/websocket_api.py @@ -27,4 +27,7 @@ def ws_convertible_units( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Return supported units for a device class.""" - connection.send_result(msg["id"], {"units": VALID_UNITS}) + sorted_units = { + key: sorted(units, key=str.casefold) for key, units in VALID_UNITS.items() + } + connection.send_result(msg["id"], {"units": sorted_units}) diff --git a/tests/components/number/test_websocket_api.py b/tests/components/number/test_websocket_api.py index 4f487a6326..194f24dc9d 100644 --- a/tests/components/number/test_websocket_api.py +++ b/tests/components/number/test_websocket_api.py @@ -1,6 +1,4 @@ """Test the number websocket API.""" -from pytest_unordered import unordered - from homeassistant.components.number.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -17,21 +15,19 @@ async def test_device_class_units( client = await hass_ws_client(hass) # Device class with units which number allows customizing & converting - await client.send_json( + await client.send_json_auto_id( { - "id": 1, "type": "number/device_class_convertible_units", "device_class": "temperature", } ) msg = await client.receive_json() assert msg["success"] - assert msg["result"] == {"units": unordered(["°F", "°C", "K"])} + assert msg["result"] == {"units": ["K", "°C", "°F"]} # Device class with units which number doesn't allow customizing & converting - await client.send_json( + await client.send_json_auto_id( { - "id": 2, "type": "number/device_class_convertible_units", "device_class": "energy", } @@ -41,13 +37,12 @@ async def test_device_class_units( assert msg["result"] == {"units": []} # Unknown device class - await client.send_json( + await client.send_json_auto_id( { - "id": 3, "type": "number/device_class_convertible_units", "device_class": "kebabsås", } ) msg = await client.receive_json() assert msg["success"] - assert msg["result"] == {"units": unordered([])} + assert msg["result"] == {"units": []} diff --git a/tests/components/sensor/test_websocket_api.py b/tests/components/sensor/test_websocket_api.py index 91eff6e277..17b8a2ab5c 100644 --- a/tests/components/sensor/test_websocket_api.py +++ b/tests/components/sensor/test_websocket_api.py @@ -1,6 +1,4 @@ """Test the sensor websocket API.""" -from pytest_unordered import unordered - from homeassistant.components.sensor.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -17,9 +15,8 @@ async def test_device_class_units( client = await hass_ws_client(hass) # Device class with units which sensor allows customizing & converting - await client.send_json( + await client.send_json_auto_id( { - "id": 1, "type": "sensor/device_class_convertible_units", "device_class": "speed", } @@ -27,15 +24,23 @@ async def test_device_class_units( msg = await client.receive_json() assert msg["success"] assert msg["result"] == { - "units": unordered( - ["km/h", "kn", "mph", "in/h", "in/d", "ft/s", "mm/d", "mm/h", "m/s"] - ) + "units": ["ft/s", "in/d", "in/h", "km/h", "kn", "m/s", "mm/d", "mm/h", "mph"] } - # Device class with units which sensor doesn't allow customizing & converting - await client.send_json( + # Device class with units which include `None` + await client.send_json_auto_id( + { + "type": "sensor/device_class_convertible_units", + "device_class": "power_factor", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"units": ["%", None]} + + # Device class with units which sensor doesn't allow customizing & converting + await client.send_json_auto_id( { - "id": 2, "type": "sensor/device_class_convertible_units", "device_class": "pm1", } @@ -45,13 +50,12 @@ async def test_device_class_units( assert msg["result"] == {"units": []} # Unknown device class - await client.send_json( + await client.send_json_auto_id( { - "id": 3, "type": "sensor/device_class_convertible_units", "device_class": "kebabsås", } ) msg = await client.receive_json() assert msg["success"] - assert msg["result"] == {"units": unordered([])} + assert msg["result"] == {"units": []} diff --git a/tests/components/weather/test_websocket_api.py b/tests/components/weather/test_websocket_api.py index 3995bd2a54..1112d7713e 100644 --- a/tests/components/weather/test_websocket_api.py +++ b/tests/components/weather/test_websocket_api.py @@ -1,6 +1,4 @@ """Test the weather websocket API.""" -from pytest_unordered import unordered - from homeassistant.components.weather.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -22,10 +20,10 @@ async def test_device_class_units(hass: HomeAssistant, hass_ws_client) -> None: assert msg["success"] assert msg["result"] == { "units": { - "precipitation_unit": unordered(["mm", "in"]), - "pressure_unit": unordered(["mbar", "mmHg", "inHg", "hPa"]), - "temperature_unit": unordered(["°F", "°C"]), - "visibility_unit": unordered(["km", "mi"]), - "wind_speed_unit": unordered(["mph", "km/h", "kn", "m/s", "ft/s"]), + "precipitation_unit": ["in", "mm"], + "pressure_unit": ["hPa", "inHg", "mbar", "mmHg"], + "temperature_unit": ["°C", "°F"], + "visibility_unit": ["km", "mi"], + "wind_speed_unit": ["ft/s", "km/h", "kn", "m/s", "mph"], } } From e74613f8bed330c2ffdab64fa9c9a65a64401478 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 28 Feb 2023 17:04:10 +0100 Subject: [PATCH 0488/2810] 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 85b7046585..5878f01889 100644 --- a/homeassistant/components/hue/v2/entity.py +++ b/homeassistant/components/hue/v2/entity.py @@ -55,7 +55,13 @@ class HueBaseEntity(Entity): self._attr_unique_id = resource.id # device is precreated in main handler # this attaches the entity to the precreated device - if self.device is not None: + if self.device is None: + # attach all device-less entities to the bridge itself + # e.g. config based sensors like entertainment area + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, bridge.api.config.bridge.bridge_id)}, + ) + else: self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.device.id)}, ) @@ -137,17 +143,14 @@ class HueBaseEntity(Entity): def _handle_event(self, event_type: EventType, resource: HueResource) -> None: """Handle status event for this resource (or it's parent).""" if event_type == EventType.RESOURCE_DELETED: - # remove any services created for zones/rooms + # handle removal of room and zone 'virtual' devices/services # regular devices are removed automatically by the logic in device.py. if resource.type in (ResourceTypes.ROOM, ResourceTypes.ZONE): dev_reg = async_get_device_registry(self.hass) if device := dev_reg.async_get_device({(DOMAIN, resource.id)}): dev_reg.async_remove_device(device.id) - if resource.type in ( - ResourceTypes.GROUPED_LIGHT, - ResourceTypes.SCENE, - ResourceTypes.SMART_SCENE, - ): + # cleanup entities that are not strictly device-bound and have the bridge as parent + if self.device is None: ent_reg = async_get_entity_registry(self.hass) ent_reg.async_remove(self.entity_id) return From f93bd8ef2c6acef44fe54f46e2fe86e70e1a7735 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Feb 2023 17:08:45 +0100 Subject: [PATCH 0489/2810] 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 00aae5b8a0..0e9c8e9606 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 72884a198d..cc3e4a9e6c 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 d88581696c..7c69a8d0a2 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 a133f6fda3..d6b2a406aa 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 ac120b3e16..368ecfe809 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 e27cfb219c..2ec79dcaee 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 9261004ec1..86443ce5c0 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 de01c6153e..7893565743 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -124,7 +124,9 @@ async def test_create_network( assert msg["result"] is None create_dataset_mock.assert_called_once_with( - python_otbr_api.models.OperationalDataSet(network_name="home-assistant") + python_otbr_api.models.OperationalDataSet( + channel=15, network_name="home-assistant" + ) ) assert len(set_enabled_mock.mock_calls) == 2 assert set_enabled_mock.mock_calls[0][1][0] is False From c38df1102a63396906c4e5bc10b273a86ff063f6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Feb 2023 17:34:46 +0100 Subject: [PATCH 0490/2810] Fix typo in thread (#88916) --- homeassistant/components/thread/websocket_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/thread/websocket_api.py b/homeassistant/components/thread/websocket_api.py index 5edea3a61a..053ec69a0f 100644 --- a/homeassistant/components/thread/websocket_api.py +++ b/homeassistant/components/thread/websocket_api.py @@ -160,7 +160,7 @@ async def ws_discover_routers( @callback def router_removed(key: str) -> None: - """Forward router discovery or update to websocket.""" + """Forward router removed to websocket.""" connection.send_message( websocket_api.event_message( From 36e6a879ad3ba9ed6b0a0b7209e4e4a339e85b00 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 28 Feb 2023 17:53:15 +0100 Subject: [PATCH 0491/2810] 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 ce09c4c7ac..21d0da074a 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 7c6adc30f9..e55bd2782d 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 538509ed5c..466b593b56 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 37e8ce7c82..0cd38f0f1c 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 d6df57112a..6de4fcb1f1 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 cb84547432..81410b0658 100644 --- a/tests/components/hue/test_device_trigger_v2.py +++ b/tests/components/hue/test_device_trigger_v2.py @@ -84,6 +84,7 @@ async def test_get_triggers( } for event_type in ( ButtonEvent.INITIAL_PRESS, + ButtonEvent.LONG_PRESS, ButtonEvent.LONG_RELEASE, ButtonEvent.REPEAT, ButtonEvent.SHORT_RELEASE, From ee144d34a95f4365f44a53ddc51c95343ec6fd52 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Feb 2023 18:03:36 +0100 Subject: [PATCH 0492/2810] Adjust core test to avoid lingering task (#88918) --- tests/test_core.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_core.py b/tests/test_core.py index eb81efae92..f627475270 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2034,7 +2034,8 @@ async def test_shutdown_does_not_block_on_shielded_tasks( ) -> None: """Ensure shutdown does not block on shielded tasks.""" result = asyncio.Future() - shielded_task = asyncio.shield(asyncio.sleep(10)) + sleep_task = asyncio.ensure_future(asyncio.sleep(10)) + shielded_task = asyncio.shield(sleep_task) async def test_task(): try: @@ -2050,3 +2051,6 @@ async def test_shutdown_does_not_block_on_shielded_tasks( assert result.done() assert task.done() assert time.monotonic() - start < 0.5 + + # Cleanup lingering task after test is done + sleep_task.cancel() From ac6bbc2f1c6e1e32c3f2c9456c89fe92771b3e0c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Feb 2023 18:04:40 +0100 Subject: [PATCH 0493/2810] Add missing mock in webostv config flow tests (#88913) --- tests/components/webostv/conftest.py | 10 ++++++++++ tests/components/webostv/test_config_flow.py | 18 +++++++++--------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/tests/components/webostv/conftest.py b/tests/components/webostv/conftest.py index 5a55ac492d..b78046c22e 100644 --- a/tests/components/webostv/conftest.py +++ b/tests/components/webostv/conftest.py @@ -1,4 +1,5 @@ """Common fixtures and objects for the LG webOS integration tests.""" +from collections.abc import Generator from unittest.mock import AsyncMock, Mock, patch import pytest @@ -10,6 +11,15 @@ from .const import CHANNEL_1, CHANNEL_2, CLIENT_KEY, FAKE_UUID, MOCK_APPS, MOCK_ from tests.common import async_mock_service +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.webostv.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture def calls(hass): """Track calls to a mock service.""" diff --git a/tests/components/webostv/test_config_flow.py b/tests/components/webostv/test_config_flow.py index cc588c9c21..ad57b4647e 100644 --- a/tests/components/webostv/test_config_flow.py +++ b/tests/components/webostv/test_config_flow.py @@ -1,6 +1,6 @@ """Test the WebOS Tv config flow.""" import dataclasses -from unittest.mock import Mock, patch +from unittest.mock import Mock from aiowebostv import WebOsTvPairError import pytest @@ -16,6 +16,8 @@ from homeassistant.data_entry_flow import FlowResultType from . import setup_webostv from .const import CLIENT_KEY, FAKE_UUID, HOST, MOCK_APPS, MOCK_INPUTS, TV_NAME +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + MOCK_USER_CONFIG = { CONF_HOST: HOST, CONF_NAME: TV_NAME, @@ -65,10 +67,9 @@ async def test_form(hass: HomeAssistant, client) -> None: assert result["type"] == FlowResultType.FORM assert result["step_id"] == "pairing" - with patch("homeassistant.components.webostv.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) await hass.async_block_till_done() @@ -184,10 +185,9 @@ async def test_form_ssdp(hass: HomeAssistant, client) -> None: """Test that the ssdp confirmation form is served.""" assert client - with patch("homeassistant.components.webostv.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=MOCK_DISCOVERY_INFO - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=MOCK_DISCOVERY_INFO + ) await hass.async_block_till_done() assert result["type"] == FlowResultType.FORM From 7bfc7f134cd6c7912a0e5d11f014cd3cb2e99e03 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Feb 2023 18:06:40 +0100 Subject: [PATCH 0494/2810] 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 9aafc719f1..1e0fdfacc8 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 e89773d998..acf49eb546 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -72,7 +72,7 @@ OPEN_CLOSE_COVER_CONFIG = { ( "cover.test_state", "dog", - STATE_CLOSING, + STATE_UNKNOWN, {}, -1, "Received invalid cover is_on state: dog", From 83214431936b001cf60005a58655f43d6fd61748 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Feb 2023 18:07:01 +0100 Subject: [PATCH 0495/2810] 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 1f2d83a258..2f57d9802b 100644 --- a/homeassistant/components/dormakaba_dkey/__init__.py +++ b/homeassistant/components/dormakaba_dkey/__init__.py @@ -19,7 +19,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_ASSOCIATION_DATA, DOMAIN, UPDATE_SECONDS from .models import DormakabaDkeyData -PLATFORMS: list[Platform] = [Platform.LOCK, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.LOCK, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) From 95ed6fbc082ccd269211a0bceb5b4fa9994bf245 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 28 Feb 2023 18:07:17 +0100 Subject: [PATCH 0496/2810] 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 57ae906317..a9b32bd7f4 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 82e8382461..1c139a5916 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 2112c66804a4504684f3638712b6b1939c6c4c21 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Feb 2023 10:23:36 +0100 Subject: [PATCH 0497/2810] 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 070378b342..b294dfa51e 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 0000000000..0a9cf0004b --- /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 a514760212..7ff096795c 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 c444e1c860433d03bceece75525c86d4748a6b87 Mon Sep 17 00:00:00 2001 From: b-uwe <61052367+b-uwe@users.noreply.github.com> Date: Tue, 28 Feb 2023 18:09:52 +0100 Subject: [PATCH 0498/2810] 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 0000000000..d9e85a8954 --- /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 0f5e0ff08e..6111681bd3 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 32b138b6c6b6871f143036f49958981e3234098e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Feb 2023 16:19:13 +0100 Subject: [PATCH 0499/2810] 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 ebe2ab0025..c202040228 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 a07819793b..d88581696c 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 44baf6a2d9..e41a1b18b9 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 0500/2810] 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 c202040228..78c5893c88 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 24fb89f214..0a6482b040 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 58b32276ba..a05c3f3e92 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 16fadd9b06..547def8345 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 109d88605b..f733c0fcaf 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 8e4c697c7c..5248e53e3f 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 2180a091eb..a133f6fda3 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 d02524cb61..ac120b3e16 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 7818d736e0..9261004ec1 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 0501/2810] 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 00aae5b8a0..0e9c8e9606 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 72884a198d..cc3e4a9e6c 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 d88581696c..7c69a8d0a2 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 a133f6fda3..d6b2a406aa 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 ac120b3e16..368ecfe809 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 e27cfb219c..2ec79dcaee 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 9261004ec1..86443ce5c0 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 e41a1b18b9..98432fe0be 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 0502/2810] 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 0000000000..d9e85a8954 --- /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 cee5b2167a..15f191c22d 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 0503/2810] 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 85b7046585..5878f01889 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 0504/2810] 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 a05c3f3e92..f2efea0c1e 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 0505/2810] 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 abf3e5412e..1020879ce8 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 0506/2810] 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 ce09c4c7ac..21d0da074a 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 7c6adc30f9..e55bd2782d 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 538509ed5c..466b593b56 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 f733c0fcaf..c9961f8e2f 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 5248e53e3f..c7276ac4cb 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 cb84547432..81410b0658 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 0507/2810] 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 9aafc719f1..1e0fdfacc8 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 e89773d998..acf49eb546 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 0508/2810] 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 57ae906317..a9b32bd7f4 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 82e8382461..1c139a5916 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 0509/2810] 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 1f2d83a258..2f57d9802b 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 0510/2810] 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 f9a6d24e9e..2a31d750fc 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 e5bcef1765..2c387e8b64 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 69ce6980d6e4ebab73907a0a7094f28cf37f38bd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Feb 2023 19:35:43 +0100 Subject: [PATCH 0511/2810] Add number + sensor device class energy storage (#88310) * Add number + sensor device class energy storage * Format code * Update device automations --- homeassistant/components/number/const.py | 10 ++++++++++ homeassistant/components/sensor/const.py | 14 ++++++++++++++ .../components/sensor/device_condition.py | 1 + homeassistant/components/sensor/device_trigger.py | 1 + tests/components/sensor/test_device_condition.py | 2 ++ tests/components/sensor/test_device_trigger.py | 2 ++ 6 files changed, 30 insertions(+) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 91c1306c22..aa57bb2b71 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -127,6 +127,15 @@ class NumberDeviceClass(StrEnum): Unit of measurement: `Wh`, `kWh`, `MWh`, `MJ`, `GJ` """ + ENERGY_STORAGE = "energy_storage" + """Stored energy. + + Use this device class for sensors measuring stored energy, for example the amount + of electric energy currently stored in a battery or the capacity of a battery. + + Unit of measurement: `Wh`, `kWh`, `MWh`, `MJ`, `GJ` + """ + FREQUENCY = "frequency" """Frequency. @@ -365,6 +374,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { NumberDeviceClass.DATA_SIZE: set(UnitOfInformation), NumberDeviceClass.DISTANCE: set(UnitOfLength), NumberDeviceClass.ENERGY: set(UnitOfEnergy), + NumberDeviceClass.ENERGY_STORAGE: set(UnitOfEnergy), NumberDeviceClass.FREQUENCY: set(UnitOfFrequency), NumberDeviceClass.GAS: { UnitOfVolume.CENTUM_CUBIC_FEET, diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 58cf985b09..8ded1c304d 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -160,6 +160,17 @@ class SensorDeviceClass(StrEnum): ENERGY = "energy" """Energy. + Use this device class for sensors measuring energy consumption, for example + electric energy consumption. + Unit of measurement: `Wh`, `kWh`, `MWh`, `MJ`, `GJ` + """ + + ENERGY_STORAGE = "energy_storage" + """Stored energy. + + Use this device class for sensors measuring stored energy, for example the amount + of electric energy currently stored in a battery or the capacity of a battery. + Unit of measurement: `Wh`, `kWh`, `MWh`, `MJ`, `GJ` """ @@ -429,6 +440,7 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = SensorDeviceClass.DATA_SIZE: InformationConverter, SensorDeviceClass.DISTANCE: DistanceConverter, SensorDeviceClass.ENERGY: EnergyConverter, + SensorDeviceClass.ENERGY_STORAGE: EnergyConverter, SensorDeviceClass.GAS: VolumeConverter, SensorDeviceClass.POWER: PowerConverter, SensorDeviceClass.POWER_FACTOR: UnitlessRatioConverter, @@ -462,6 +474,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { UnitOfTime.SECONDS, }, SensorDeviceClass.ENERGY: set(UnitOfEnergy), + SensorDeviceClass.ENERGY_STORAGE: set(UnitOfEnergy), SensorDeviceClass.FREQUENCY: set(UnitOfFrequency), SensorDeviceClass.GAS: { UnitOfVolume.CENTUM_CUBIC_FEET, @@ -526,6 +539,7 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { SensorStateClass.TOTAL, SensorStateClass.TOTAL_INCREASING, }, + SensorDeviceClass.ENERGY_STORAGE: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.ENUM: set(), SensorDeviceClass.FREQUENCY: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.GAS: {SensorStateClass.TOTAL, SensorStateClass.TOTAL_INCREASING}, diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index 5746b8b8e8..6ed47cbf63 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -89,6 +89,7 @@ ENTITY_CONDITIONS = { SensorDeviceClass.DISTANCE: [{CONF_TYPE: CONF_IS_DISTANCE}], SensorDeviceClass.DURATION: [{CONF_TYPE: CONF_IS_DURATION}], SensorDeviceClass.ENERGY: [{CONF_TYPE: CONF_IS_ENERGY}], + SensorDeviceClass.ENERGY_STORAGE: [{CONF_TYPE: CONF_IS_ENERGY}], SensorDeviceClass.FREQUENCY: [{CONF_TYPE: CONF_IS_FREQUENCY}], SensorDeviceClass.GAS: [{CONF_TYPE: CONF_IS_GAS}], SensorDeviceClass.HUMIDITY: [{CONF_TYPE: CONF_IS_HUMIDITY}], diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index dfd0a576d2..7e498321b3 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -88,6 +88,7 @@ ENTITY_TRIGGERS = { SensorDeviceClass.DISTANCE: [{CONF_TYPE: CONF_DISTANCE}], SensorDeviceClass.DURATION: [{CONF_TYPE: CONF_DURATION}], SensorDeviceClass.ENERGY: [{CONF_TYPE: CONF_ENERGY}], + SensorDeviceClass.ENERGY_STORAGE: [{CONF_TYPE: CONF_ENERGY}], SensorDeviceClass.FREQUENCY: [{CONF_TYPE: CONF_FREQUENCY}], SensorDeviceClass.GAS: [{CONF_TYPE: CONF_GAS}], SensorDeviceClass.HUMIDITY: [{CONF_TYPE: CONF_HUMIDITY}], diff --git a/tests/components/sensor/test_device_condition.py b/tests/components/sensor/test_device_condition.py index 02b369b6a6..24d480e24b 100644 --- a/tests/components/sensor/test_device_condition.py +++ b/tests/components/sensor/test_device_condition.py @@ -51,12 +51,14 @@ def test_matches_device_classes(device_class: SensorDeviceClass) -> None: SensorDeviceClass.BATTERY: "CONF_IS_BATTERY_LEVEL", SensorDeviceClass.CO: "CONF_IS_CO", SensorDeviceClass.CO2: "CONF_IS_CO2", + SensorDeviceClass.ENERGY_STORAGE: "CONF_IS_ENERGY", }.get(device_class, f"CONF_IS_{device_class.value.upper()}") assert hasattr(device_condition, constant_name), f"Missing constant {constant_name}" # Ensure it has correct value constant_value = { SensorDeviceClass.BATTERY: "is_battery_level", + SensorDeviceClass.ENERGY_STORAGE: "is_energy", }.get(device_class, f"is_{device_class.value}") assert getattr(device_condition, constant_name) == constant_value diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index 298aab2953..34b5d6fb40 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -55,12 +55,14 @@ def test_matches_device_classes(device_class: SensorDeviceClass) -> None: SensorDeviceClass.BATTERY: "CONF_BATTERY_LEVEL", SensorDeviceClass.CO: "CONF_CO", SensorDeviceClass.CO2: "CONF_CO2", + SensorDeviceClass.ENERGY_STORAGE: "CONF_ENERGY", }.get(device_class, f"CONF_{device_class.value.upper()}") assert hasattr(device_trigger, constant_name), f"Missing constant {constant_name}" # Ensure it has correct value constant_value = { SensorDeviceClass.BATTERY: "battery_level", + SensorDeviceClass.ENERGY_STORAGE: "energy", }.get(device_class, device_class.value) assert getattr(device_trigger, constant_name) == constant_value From ad55a5db11e36926cbb5098e53ac32ca57d45265 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Feb 2023 21:33:50 +0100 Subject: [PATCH 0512/2810] 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 dca19c802b..3da1fd841f 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 206e575b7a..b837cf8dfe 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 0cd38f0f1c..bc86f5041c 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 6de4fcb1f1..a82f753326 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1045,7 +1045,7 @@ py-canary==0.5.3 py-cpuinfo==8.0.0 # homeassistant.components.dormakaba_dkey -py-dormakaba-dkey==1.0.2 +py-dormakaba-dkey==1.0.3 # homeassistant.components.melissa py-melissa-climate==2.1.4 From 47a3c27c9a524edd823cd80697ae3fad39e7a23a Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Tue, 28 Feb 2023 15:34:07 -0500 Subject: [PATCH 0513/2810] 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 35d7f624c5..40316a6ba3 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 bc86f5041c..fccda2618d 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 a82f753326..42f03d72fe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1212,7 +1212,7 @@ pyialarm==2.2.0 pyicloud==1.0.0 # homeassistant.components.insteon -pyinsteon==1.3.2 +pyinsteon==1.3.3 # homeassistant.components.ipma pyipma==3.0.6 From dccd3e277ef190d07aef37177e9c92b1fa33bef1 Mon Sep 17 00:00:00 2001 From: djtimca <60706061+djtimca@users.noreply.github.com> Date: Tue, 28 Feb 2023 17:33:05 -0500 Subject: [PATCH 0514/2810] 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 a5bb332733..018e8ab813 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 fccda2618d..619e5cc014 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 42f03d72fe..b0191b62c3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -334,7 +334,7 @@ async-upnp-client==0.33.1 asyncsleepiq==1.2.3 # homeassistant.components.aurora -auroranoaa==0.0.2 +auroranoaa==0.0.3 # homeassistant.components.aurora_abb_powerone aurorapy==0.2.7 From c5e39f7039d5a276376d84782175a95fff22aeb9 Mon Sep 17 00:00:00 2001 From: djtimca <60706061+djtimca@users.noreply.github.com> Date: Tue, 28 Feb 2023 17:47:29 -0500 Subject: [PATCH 0515/2810] Add state class to Aurora (#88938) * Bump aurora_api version to fix issues with NOAA conversion values. Fix #82587 * update requirements for aurora. * Add state_class to aurora sensor. * Fixed environment to run requirements_all script. * Revert "Add state_class to aurora sensor." This reverts commit 213e21e8424aafd50242e77bcedc39f0a4b50074. * Add state class to aurora sensor. * Revert "Fixed environment to run requirements_all script." This reverts commit f3f624226ee2d1853c4a6220dfa6456b4d86da5c. * Revert "update requirements for aurora." This reverts commit a3546ad88d33e127e84030764d0e3e40401a8865. * Revert "Bump aurora_api version to fix issues with NOAA conversion values. Fix #82587" This reverts commit faf3ba7b5b25aeedb5041f196de84e4fa55d4c89. * Move state class to _attr_state_class. --- homeassistant/components/aurora/sensor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/aurora/sensor.py b/homeassistant/components/aurora/sensor.py index 2710badb51..de5e566e26 100644 --- a/homeassistant/components/aurora/sensor.py +++ b/homeassistant/components/aurora/sensor.py @@ -1,5 +1,5 @@ """Support for Aurora Forecast sensor.""" -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant @@ -28,6 +28,7 @@ class AuroraSensor(AuroraEntity, SensorEntity): """Implementation of an aurora sensor.""" _attr_native_unit_of_measurement = PERCENTAGE + _attr_state_class = SensorStateClass.MEASUREMENT @property def native_value(self): From e0bdb3ecc3aca3489176b860c3efaec9d7d523eb Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 28 Feb 2023 20:07:18 -0600 Subject: [PATCH 0516/2810] 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 3be3f8cfc6..78002b42f6 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 5e4e2e8902..7630eed01f 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 16b1969f61..4a00b05d21 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 619e5cc014..58eb01371b 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 b0191b62c3..7e23d75ecb 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 9ea95231b2..7211f2bb9b 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -173,4 +173,4 @@ async def test_cant_turn_on_lock(hass: HomeAssistant) -> None: ) assert result.response.response_type == intent.IntentResponseType.ERROR - assert result.response.error_code == intent.IntentResponseErrorCode.FAILED_TO_HANDLE + assert result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH From 8f6cfc25c0fa2e7fab3bee0113e6fd794c4d0b31 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Feb 2023 20:09:47 -0600 Subject: [PATCH 0517/2810] Use ulid-transform for constructing ulids (#88939) * Use ulid-transform for constructing ulids A future PR will use the new library to reduce the storage overhead of ulids in the database * tweak * tweak * bump --- homeassistant/core.py | 4 +- homeassistant/package_constraints.txt | 1 + homeassistant/util/ulid.py | 53 ++------------------------- pyproject.toml | 1 + requirements.txt | 1 + 5 files changed, 8 insertions(+), 52 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 7003b87ce6..b2525e2f09 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -871,7 +871,7 @@ class Event: self.origin = origin self.time_fired = time_fired or dt_util.utcnow() self.context: Context = context or Context( - id=ulid_util.ulid(dt_util.utc_to_timestamp(self.time_fired)) + id=ulid_util.ulid_at_time(dt_util.utc_to_timestamp(self.time_fired)) ) def as_dict(self) -> dict[str, Any]: @@ -1533,7 +1533,7 @@ class StateMachine: now = dt_util.utcnow() if context is None: - context = Context(id=ulid_util.ulid(dt_util.utc_to_timestamp(now))) + context = Context(id=ulid_util.ulid_at_time(dt_util.utc_to_timestamp(now))) state = State( entity_id, new_state, diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4a00b05d21..5d05bec39c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -44,6 +44,7 @@ requests==2.28.1 scapy==2.5.0 sqlalchemy==2.0.4 typing-extensions>=4.5.0,<5.0 +ulid-transform==0.3.1 voluptuous-serialize==2.6.0 voluptuous==0.13.1 yarl==1.8.1 diff --git a/homeassistant/util/ulid.py b/homeassistant/util/ulid.py index d40b0f48e1..304a42ec61 100644 --- a/homeassistant/util/ulid.py +++ b/homeassistant/util/ulid.py @@ -1,21 +1,11 @@ """Helpers to generate ulids.""" from __future__ import annotations -from random import getrandbits import time +from ulid_transform import ulid_at_time, ulid_hex -def ulid_hex() -> str: - """Generate a ULID in lowercase hex that will work for a UUID. - - This ulid should not be used for cryptographically secure - operations. - - This string can be converted with https://github.com/ahawker/ulid - - ulid.from_uuid(uuid.UUID(ulid_hex)) - """ - return f"{int(time.time()*1000):012x}{getrandbits(80):020x}" +__all__ = ["ulid", "ulid_hex", "ulid_at_time"] def ulid(timestamp: float | None = None) -> str: @@ -35,41 +25,4 @@ def ulid(timestamp: float | None = None) -> str: import ulid ulid.parse(ulid_util.ulid()) """ - ulid_bytes = int((timestamp or time.time()) * 1000).to_bytes( - 6, byteorder="big" - ) + int(getrandbits(80)).to_bytes(10, byteorder="big") - - # This is base32 crockford encoding with the loop unrolled for performance - # - # This code is adapted from: - # https://github.com/ahawker/ulid/blob/06289583e9de4286b4d80b4ad000d137816502ca/ulid/base32.py#L102 - # - enc = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" - return ( - enc[(ulid_bytes[0] & 224) >> 5] - + enc[ulid_bytes[0] & 31] - + enc[(ulid_bytes[1] & 248) >> 3] - + enc[((ulid_bytes[1] & 7) << 2) | ((ulid_bytes[2] & 192) >> 6)] - + enc[((ulid_bytes[2] & 62) >> 1)] - + enc[((ulid_bytes[2] & 1) << 4) | ((ulid_bytes[3] & 240) >> 4)] - + enc[((ulid_bytes[3] & 15) << 1) | ((ulid_bytes[4] & 128) >> 7)] - + enc[(ulid_bytes[4] & 124) >> 2] - + enc[((ulid_bytes[4] & 3) << 3) | ((ulid_bytes[5] & 224) >> 5)] - + enc[ulid_bytes[5] & 31] - + enc[(ulid_bytes[6] & 248) >> 3] - + enc[((ulid_bytes[6] & 7) << 2) | ((ulid_bytes[7] & 192) >> 6)] - + enc[(ulid_bytes[7] & 62) >> 1] - + enc[((ulid_bytes[7] & 1) << 4) | ((ulid_bytes[8] & 240) >> 4)] - + enc[((ulid_bytes[8] & 15) << 1) | ((ulid_bytes[9] & 128) >> 7)] - + enc[(ulid_bytes[9] & 124) >> 2] - + enc[((ulid_bytes[9] & 3) << 3) | ((ulid_bytes[10] & 224) >> 5)] - + enc[ulid_bytes[10] & 31] - + enc[(ulid_bytes[11] & 248) >> 3] - + enc[((ulid_bytes[11] & 7) << 2) | ((ulid_bytes[12] & 192) >> 6)] - + enc[(ulid_bytes[12] & 62) >> 1] - + enc[((ulid_bytes[12] & 1) << 4) | ((ulid_bytes[13] & 240) >> 4)] - + enc[((ulid_bytes[13] & 15) << 1) | ((ulid_bytes[14] & 128) >> 7)] - + enc[(ulid_bytes[14] & 124) >> 2] - + enc[((ulid_bytes[14] & 3) << 3) | ((ulid_bytes[15] & 224) >> 5)] - + enc[ulid_bytes[15] & 31] - ) + return ulid_at_time(timestamp or time.time()) diff --git a/pyproject.toml b/pyproject.toml index a3d6d2f244..7a0aff9ce9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ dependencies = [ "pyyaml==6.0", "requests==2.28.1", "typing-extensions>=4.5.0,<5.0", + "ulid-transform==0.3.1", "voluptuous==0.13.1", "voluptuous-serialize==2.6.0", "yarl==1.8.1", diff --git a/requirements.txt b/requirements.txt index aa6e85d152..31831a93e2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,6 +24,7 @@ python-slugify==4.0.1 pyyaml==6.0 requests==2.28.1 typing-extensions>=4.5.0,<5.0 +ulid-transform==0.3.1 voluptuous==0.13.1 voluptuous-serialize==2.6.0 yarl==1.8.1 From 39f5f0946e1d7c2e2fcf36fb8fdaa7b81fca9eb8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 1 Mar 2023 03:15:44 +0100 Subject: [PATCH 0518/2810] Store source entity in switch_as_x entity options (#88914) --- .../components/switch_as_x/entity.py | 11 ++++-- tests/components/switch_as_x/test_init.py | 36 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switch_as_x/entity.py b/homeassistant/components/switch_as_x/entity.py index bc24460a10..ac56b4c607 100644 --- a/homeassistant/components/switch_as_x/entity.py +++ b/homeassistant/components/switch_as_x/entity.py @@ -17,6 +17,8 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import Entity, ToggleEntity from homeassistant.helpers.event import async_track_state_change_event +from .const import DOMAIN as SWITCH_AS_X_DOMAIN + class BaseEntity(Entity): """Represents a Switch as a X.""" @@ -28,8 +30,8 @@ class BaseEntity(Entity): name: str, switch_entity_id: str, unique_id: str | None, - device_id: str | None = None, - entity_category: EntityCategory | None = None, + device_id: str | None, + entity_category: EntityCategory | None, ) -> None: """Initialize Light Switch.""" self._device_id = device_id @@ -71,6 +73,11 @@ class BaseEntity(Entity): registry = er.async_get(self.hass) if registry.async_get(self.entity_id) is not None: registry.async_update_entity(self.entity_id, device_id=self._device_id) + registry.async_update_entity_options( + self.entity_id, + SWITCH_AS_X_DOMAIN, + {"entity_id": self._switch_entity_id}, + ) class BaseToggleEntity(BaseEntity, ToggleEntity): diff --git a/tests/components/switch_as_x/test_init.py b/tests/components/switch_as_x/test_init.py index 964e0a0d43..a95725999d 100644 --- a/tests/components/switch_as_x/test_init.py +++ b/tests/components/switch_as_x/test_init.py @@ -438,3 +438,39 @@ async def test_entity_category_inheritance( assert entity_entry assert entity_entry.device_id == switch_entity_entry.device_id assert entity_entry.entity_category is EntityCategory.CONFIG + + +@pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) +async def test_entity_options( + hass: HomeAssistant, + target_domain: Platform, +) -> None: + """Test the source entity is stored as an entity option.""" + registry = er.async_get(hass) + + switch_entity_entry = registry.async_get_or_create("switch", "test", "unique") + registry.async_update_entity( + switch_entity_entry.entity_id, entity_category=EntityCategory.CONFIG + ) + + # Add the config entry + switch_as_x_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_ENTITY_ID: switch_entity_entry.id, + CONF_TARGET_DOMAIN: target_domain, + }, + title="ABC", + ) + switch_as_x_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(switch_as_x_config_entry.entry_id) + await hass.async_block_till_done() + + entity_entry = registry.async_get(f"{target_domain}.abc") + assert entity_entry + assert entity_entry.device_id == switch_entity_entry.device_id + assert entity_entry.options == { + DOMAIN: {"entity_id": switch_entity_entry.entity_id} + } From 86acc4262e07e61da0047fbaf1f0f008a27adc60 Mon Sep 17 00:00:00 2001 From: Volker Stolz Date: Wed, 1 Mar 2023 03:52:45 +0100 Subject: [PATCH 0519/2810] 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 3e8b7bbe39..f5a954b16d 100644 --- a/homeassistant/components/entur_public_transport/sensor.py +++ b/homeassistant/components/entur_public_transport/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import datetime, timedelta +from random import randint from enturclient import EnturPublicTransportData import voluptuous as vol @@ -22,7 +23,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle import homeassistant.util.dt as dt_util -API_CLIENT_NAME = "homeassistant-homeassistant" +API_CLIENT_NAME = "homeassistant-{}" CONF_STOP_IDS = "stop_ids" CONF_EXPAND_PLATFORMS = "expand_platforms" @@ -105,7 +106,7 @@ async def async_setup_platform( quays = [s for s in stop_ids if "Quay" in s] data = EnturPublicTransportData( - API_CLIENT_NAME, + API_CLIENT_NAME.format(str(randint(100000, 999999))), stops=stops, quays=quays, line_whitelist=line_whitelist, From 6ab0b2751dedf2bdf0b05f1a964cf611ef18ef51 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 03:55:44 +0100 Subject: [PATCH 0520/2810] Adjust issue_registry imports (#88878) * Add issue_registry to RUFF extend aliases * Adjust code accordingly * Revert "Add issue_registry to RUFF extend aliases" This reverts commit 4e73dd567be42c74d0db4a51bac8d7aa9d7c93e7. * Revert changes to common.py --- homeassistant/components/bayesian/repairs.py | 10 ++--- homeassistant/components/obihai/sensor.py | 6 +-- .../components/repairs/test_websocket_api.py | 10 ++--- tests/helpers/test_issue_registry.py | 38 +++++++++---------- 4 files changed, 30 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/bayesian/repairs.py b/homeassistant/components/bayesian/repairs.py index 9a52763694..47d7dff6e1 100644 --- a/homeassistant/components/bayesian/repairs.py +++ b/homeassistant/components/bayesian/repairs.py @@ -2,7 +2,7 @@ from __future__ import annotations from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry +from homeassistant.helpers import issue_registry as ir from . import DOMAIN from .helpers import Observation @@ -15,13 +15,13 @@ def raise_mirrored_entries( if len(observations) != 2: return if observations[0].is_mirror(observations[1]): - issue_registry.async_create_issue( + ir.async_create_issue( hass, DOMAIN, "mirrored_entry/" + text, breaks_in_ha_version="2022.10.0", is_fixable=False, - severity=issue_registry.IssueSeverity.WARNING, + severity=ir.IssueSeverity.WARNING, translation_key="manual_migration", translation_placeholders={"entity": text}, learn_more_url="https://github.com/home-assistant/core/pull/67631", @@ -31,13 +31,13 @@ def raise_mirrored_entries( # Should deprecate in some future version (2022.10 at time of writing) & make prob_given_false required in schemas. def raise_no_prob_given_false(hass: HomeAssistant, text: str) -> None: """In previous 2022.9 and earlier, prob_given_false was optional and had a default version.""" - issue_registry.async_create_issue( + ir.async_create_issue( hass, DOMAIN, f"no_prob_given_false/{text}", breaks_in_ha_version="2022.10.0", is_fixable=False, - severity=issue_registry.IssueSeverity.ERROR, + severity=ir.IssueSeverity.ERROR, translation_key="no_prob_given_false", translation_placeholders={"entity": text}, learn_more_url="https://github.com/home-assistant/core/pull/67631", diff --git a/homeassistant/components/obihai/sensor.py b/homeassistant/components/obihai/sensor.py index 953193a5ab..4f7b6195e4 100644 --- a/homeassistant/components/obihai/sensor.py +++ b/homeassistant/components/obihai/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry +from homeassistant.helpers import issue_registry as ir import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -40,13 +40,13 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Obihai sensor platform.""" - issue_registry.async_create_issue( + ir.async_create_issue( hass, DOMAIN, "manual_migration", breaks_in_ha_version="2023.6.0", is_fixable=False, - severity=issue_registry.IssueSeverity.ERROR, + severity=ir.IssueSeverity.ERROR, translation_key="manual_migration", ) diff --git a/tests/components/repairs/test_websocket_api.py b/tests/components/repairs/test_websocket_api.py index 0e12bf0562..be50dba14b 100644 --- a/tests/components/repairs/test_websocket_api.py +++ b/tests/components/repairs/test_websocket_api.py @@ -16,7 +16,7 @@ from homeassistant.components.repairs import RepairsFlow from homeassistant.components.repairs.const import DOMAIN from homeassistant.const import __version__ as ha_version from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from tests.common import MockUser, mock_platform @@ -53,7 +53,7 @@ async def create_issues(hass, ws_client, issues=None): issues = DEFAULT_ISSUES for issue in issues: - issue_registry.async_create_issue( + ir.async_create_issue( hass, issue["domain"], issue["issue_id"], @@ -439,8 +439,8 @@ async def test_list_issues( """Test we can list issues.""" # Add an inactive issue, this should not be exposed in the list - hass_storage[issue_registry.STORAGE_KEY] = { - "version": issue_registry.STORAGE_VERSION_MAJOR, + hass_storage[ir.STORAGE_KEY] = { + "version": ir.STORAGE_VERSION_MAJOR, "data": { "issues": [ { @@ -491,7 +491,7 @@ async def test_list_issues( ] for issue in issues: - issue_registry.async_create_issue( + ir.async_create_issue( hass, issue["domain"], issue["issue_id"], diff --git a/tests/helpers/test_issue_registry.py b/tests/helpers/test_issue_registry.py index 21600b776c..51cffbc781 100644 --- a/tests/helpers/test_issue_registry.py +++ b/tests/helpers/test_issue_registry.py @@ -4,7 +4,7 @@ from typing import Any import pytest from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry +from homeassistant.helpers import issue_registry as ir from tests.common import async_capture_events, flush_store @@ -59,12 +59,10 @@ async def test_load_issues(hass: HomeAssistant) -> None: }, ] - events = async_capture_events( - hass, issue_registry.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED - ) + events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) for issue in issues: - issue_registry.async_create_issue( + ir.async_create_issue( hass, issue["domain"], issue["issue_id"], @@ -101,9 +99,7 @@ async def test_load_issues(hass: HomeAssistant) -> None: "issue_id": "issue_4", } - issue_registry.async_ignore_issue( - hass, issues[0]["domain"], issues[0]["issue_id"], True - ) + ir.async_ignore_issue(hass, issues[0]["domain"], issues[0]["issue_id"], True) await hass.async_block_till_done() assert len(events) == 5 @@ -113,7 +109,7 @@ async def test_load_issues(hass: HomeAssistant) -> None: "issue_id": "issue_1", } - issue_registry.async_delete_issue(hass, issues[2]["domain"], issues[2]["issue_id"]) + ir.async_delete_issue(hass, issues[2]["domain"], issues[2]["issue_id"]) await hass.async_block_till_done() assert len(events) == 6 @@ -123,20 +119,20 @@ async def test_load_issues(hass: HomeAssistant) -> None: "issue_id": "issue_3", } - registry: issue_registry.IssueRegistry = hass.data[issue_registry.DATA_REGISTRY] + registry: ir.IssueRegistry = hass.data[ir.DATA_REGISTRY] assert len(registry.issues) == 3 issue1 = registry.async_get_issue("test", "issue_1") issue2 = registry.async_get_issue("test", "issue_2") issue4 = registry.async_get_issue("test", "issue_4") - registry2 = issue_registry.IssueRegistry(hass) + registry2 = ir.IssueRegistry(hass) await flush_store(registry._store) await registry2.async_load() assert list(registry.issues) == list(registry2.issues) issue1_registry2 = registry2.async_get_issue("test", "issue_1") - assert issue1_registry2 == issue_registry.IssueEntry( + assert issue1_registry2 == ir.IssueEntry( active=False, breaks_in_ha_version=None, created=issue1.created, @@ -153,7 +149,7 @@ async def test_load_issues(hass: HomeAssistant) -> None: translation_placeholders=None, ) issue2_registry2 = registry2.async_get_issue("test", "issue_2") - assert issue2_registry2 == issue_registry.IssueEntry( + assert issue2_registry2 == ir.IssueEntry( active=False, breaks_in_ha_version=None, created=issue2.created, @@ -178,9 +174,9 @@ async def test_loading_issues_from_storage( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test loading stored issues on start.""" - hass_storage[issue_registry.STORAGE_KEY] = { - "version": issue_registry.STORAGE_VERSION_MAJOR, - "minor_version": issue_registry.STORAGE_VERSION_MINOR, + hass_storage[ir.STORAGE_KEY] = { + "version": ir.STORAGE_VERSION_MAJOR, + "minor_version": ir.STORAGE_VERSION_MINOR, "data": { "issues": [ { @@ -216,16 +212,16 @@ async def test_loading_issues_from_storage( }, } - await issue_registry.async_load(hass) + await ir.async_load(hass) - registry: issue_registry.IssueRegistry = hass.data[issue_registry.DATA_REGISTRY] + registry: ir.IssueRegistry = hass.data[ir.DATA_REGISTRY] assert len(registry.issues) == 3 @pytest.mark.parametrize("load_registries", [False]) async def test_migration_1_1(hass: HomeAssistant, hass_storage: dict[str, Any]) -> None: """Test migration from version 1.1.""" - hass_storage[issue_registry.STORAGE_KEY] = { + hass_storage[ir.STORAGE_KEY] = { "version": 1, "minor_version": 1, "data": { @@ -246,7 +242,7 @@ async def test_migration_1_1(hass: HomeAssistant, hass_storage: dict[str, Any]) }, } - await issue_registry.async_load(hass) + await ir.async_load(hass) - registry: issue_registry.IssueRegistry = hass.data[issue_registry.DATA_REGISTRY] + registry: ir.IssueRegistry = hass.data[ir.DATA_REGISTRY] assert len(registry.issues) == 2 From 0e4c32efe2b761ff376706ec0d56ba9b4573e7bc Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 03:56:18 +0100 Subject: [PATCH 0521/2810] Adjust registry access in conversation (#88879) --- .../components/conversation/default_agent.py | 16 +-- .../conversation/test_default_agent.py | 46 ++++---- tests/components/conversation/test_init.py | 100 +++++++++++------- 3 files changed, 94 insertions(+), 68 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 78002b42f6..49569f66ac 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -18,9 +18,9 @@ import yaml from homeassistant import core, setup from homeassistant.helpers import ( - area_registry, - device_registry, - entity_registry, + area_registry as ar, + device_registry as dr, + entity_registry as er, intent, template, translation, @@ -95,12 +95,12 @@ class DefaultAgent(AbstractConversationAgent): self._config_intents = config_intents self.hass.bus.async_listen( - area_registry.EVENT_AREA_REGISTRY_UPDATED, + ar.EVENT_AREA_REGISTRY_UPDATED, self._async_handle_area_registry_changed, run_immediately=True, ) self.hass.bus.async_listen( - entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, + er.EVENT_ENTITY_REGISTRY_UPDATED, self._async_handle_entity_registry_changed, run_immediately=True, ) @@ -471,8 +471,8 @@ class DefaultAgent(AbstractConversationAgent): states = [ state for state in self.hass.states.async_all() if is_entity_exposed(state) ] - entities = entity_registry.async_get(self.hass) - devices = device_registry.async_get(self.hass) + entities = er.async_get(self.hass) + devices = dr.async_get(self.hass) # Gather exposed entity names entity_names = [] @@ -512,7 +512,7 @@ class DefaultAgent(AbstractConversationAgent): entity_names.append((state.name, state.name, context)) # Gather areas from exposed entities - areas = area_registry.async_get(self.hass) + areas = ar.async_get(self.hass) area_names = [] for area_id in area_ids_with_entities: area = areas.async_get_area(area_id) diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 726ee4dc6e..338d840c4a 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -7,10 +7,10 @@ from homeassistant.components import conversation from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import DOMAIN as HASS_DOMAIN, Context, HomeAssistant from homeassistant.helpers import ( - area_registry, - device_registry, + area_registry as ar, + device_registry as dr, entity, - entity_registry, + entity_registry as er, intent, ) from homeassistant.setup import async_setup_component @@ -29,19 +29,18 @@ async def init_components(hass): @pytest.mark.parametrize( "er_kwargs", [ - {"hidden_by": entity_registry.RegistryEntryHider.USER}, - {"hidden_by": entity_registry.RegistryEntryHider.INTEGRATION}, + {"hidden_by": er.RegistryEntryHider.USER}, + {"hidden_by": er.RegistryEntryHider.INTEGRATION}, {"entity_category": entity.EntityCategory.CONFIG}, {"entity_category": entity.EntityCategory.DIAGNOSTIC}, ], ) async def test_hidden_entities_skipped( - hass: HomeAssistant, init_components, er_kwargs + hass: HomeAssistant, init_components, er_kwargs, entity_registry: er.EntityRegistry ) -> None: """Test we skip hidden entities.""" - er = entity_registry.async_get(hass) - er.async_get_or_create( + entity_registry.async_get_or_create( "light", "demo", "1234", suggested_object_id="Test light", **er_kwargs ) hass.states.async_set("light.test_light", "off") @@ -71,27 +70,34 @@ async def test_exposed_domains(hass: HomeAssistant, init_components) -> None: assert result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH -async def test_exposed_areas(hass: HomeAssistant, init_components) -> None: +async def test_exposed_areas( + hass: HomeAssistant, + init_components, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test that only expose areas with an exposed entity/device.""" - areas = area_registry.async_get(hass) - area_kitchen = areas.async_get_or_create("kitchen") - area_bedroom = areas.async_get_or_create("bedroom") + area_kitchen = area_registry.async_get_or_create("kitchen") + area_bedroom = area_registry.async_get_or_create("bedroom") - devices = device_registry.async_get(hass) - kitchen_device = devices.async_get_or_create( + kitchen_device = device_registry.async_get_or_create( config_entry_id="1234", connections=set(), identifiers={("demo", "id-1234")} ) - devices.async_update_device(kitchen_device.id, area_id=area_kitchen.id) + device_registry.async_update_device(kitchen_device.id, area_id=area_kitchen.id) - entities = entity_registry.async_get(hass) - kitchen_light = entities.async_get_or_create("light", "demo", "1234") - entities.async_update_entity(kitchen_light.entity_id, device_id=kitchen_device.id) + kitchen_light = entity_registry.async_get_or_create("light", "demo", "1234") + entity_registry.async_update_entity( + kitchen_light.entity_id, device_id=kitchen_device.id + ) hass.states.async_set( kitchen_light.entity_id, "on", attributes={ATTR_FRIENDLY_NAME: "kitchen light"} ) - bedroom_light = entities.async_get_or_create("light", "demo", "5678") - entities.async_update_entity(bedroom_light.entity_id, area_id=area_bedroom.id) + bedroom_light = entity_registry.async_get_or_create("light", "demo", "5678") + entity_registry.async_update_entity( + bedroom_light.entity_id, area_id=area_bedroom.id + ) hass.states.async_set( bedroom_light.entity_id, "on", attributes={ATTR_FRIENDLY_NAME: "bedroom light"} ) diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index f0f5698705..55a345bd60 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -12,9 +12,9 @@ from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import ( - area_registry, - device_registry, - entity_registry, + area_registry as ar, + device_registry as dr, + entity_registry as er, intent, ) from homeassistant.setup import async_setup_component @@ -53,12 +53,14 @@ async def test_http_processing_intent( hass_client: ClientSessionGenerator, hass_admin_user: MockUser, agent_id, + entity_registry: er.EntityRegistry, ) -> None: """Test processing intent via HTTP API.""" # Add an alias - entities = entity_registry.async_get(hass) - entities.async_get_or_create("light", "demo", "1234", suggested_object_id="kitchen") - entities.async_update_entity("light.kitchen", aliases={"my cool light"}) + entity_registry.async_get_or_create( + "light", "demo", "1234", suggested_object_id="kitchen" + ) + entity_registry.async_update_entity("light.kitchen", aliases={"my cool light"}) hass.states.async_set("light.kitchen", "off") calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") @@ -101,12 +103,14 @@ async def test_http_processing_intent_target_ha_agent( hass_client: ClientSessionGenerator, hass_admin_user: MockUser, mock_agent, + entity_registry: er.EntityRegistry, ) -> None: """Test processing intent can be processed via HTTP API with picking agent.""" # Add an alias - entities = entity_registry.async_get(hass) - entities.async_get_or_create("light", "demo", "1234", suggested_object_id="kitchen") - entities.async_update_entity("light.kitchen", aliases={"my cool light"}) + entity_registry.async_get_or_create( + "light", "demo", "1234", suggested_object_id="kitchen" + ) + entity_registry.async_update_entity("light.kitchen", aliases={"my cool light"}) hass.states.async_set("light.kitchen", "off") calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") @@ -148,15 +152,17 @@ async def test_http_processing_intent_entity_added( init_components, hass_client: ClientSessionGenerator, hass_admin_user: MockUser, + entity_registry: er.EntityRegistry, ) -> None: """Test processing intent via HTTP API with entities added later. We want to ensure that adding an entity later busts the cache so that the new entity is available as well as any aliases. """ - er = entity_registry.async_get(hass) - er.async_get_or_create("light", "demo", "1234", suggested_object_id="kitchen") - er.async_update_entity("light.kitchen", aliases={"my cool light"}) + entity_registry.async_get_or_create( + "light", "demo", "1234", suggested_object_id="kitchen" + ) + entity_registry.async_update_entity("light.kitchen", aliases={"my cool light"}) hass.states.async_set("light.kitchen", "off") calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") @@ -192,7 +198,9 @@ async def test_http_processing_intent_entity_added( } # Add an alias - er.async_get_or_create("light", "demo", "5678", suggested_object_id="late") + entity_registry.async_get_or_create( + "light", "demo", "5678", suggested_object_id="late" + ) hass.states.async_set("light.late", "off", {"friendly_name": "friendly light"}) client = await hass_client() @@ -226,7 +234,7 @@ async def test_http_processing_intent_entity_added( } # Now add an alias - er.async_update_entity("light.late", aliases={"late added light"}) + entity_registry.async_update_entity("light.late", aliases={"late added light"}) client = await hass_client() resp = await client.post( @@ -259,7 +267,7 @@ async def test_http_processing_intent_entity_added( } # Now delete the entity - er.async_remove("light.late") + entity_registry.async_remove("light.late") client = await hass_client() resp = await client.post( @@ -786,23 +794,28 @@ async def test_non_default_response(hass: HomeAssistant, init_components) -> Non assert result.response.speech["plain"]["speech"] == "Opened" -async def test_turn_on_area(hass: HomeAssistant, init_components) -> None: +async def test_turn_on_area( + hass: HomeAssistant, + init_components, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test turning on an area.""" - er = entity_registry.async_get(hass) - dr = device_registry.async_get(hass) - ar = area_registry.async_get(hass) entry = MockConfigEntry(domain="test") - device = dr.async_get_or_create( + device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, - connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - kitchen_area = ar.async_create("kitchen") - dr.async_update_device(device.id, area_id=kitchen_area.id) + kitchen_area = area_registry.async_create("kitchen") + device_registry.async_update_device(device.id, area_id=kitchen_area.id) - er.async_get_or_create("light", "demo", "1234", suggested_object_id="stove") - er.async_update_entity( + entity_registry.async_get_or_create( + "light", "demo", "1234", suggested_object_id="stove" + ) + entity_registry.async_update_entity( "light.stove", aliases={"my stove light"}, area_id=kitchen_area.id ) hass.states.async_set("light.stove", "off") @@ -822,9 +835,9 @@ async def test_turn_on_area(hass: HomeAssistant, init_components) -> None: assert call.service == "turn_on" assert call.data == {"entity_id": ["light.stove"]} - basement_area = ar.async_create("basement") - dr.async_update_device(device.id, area_id=basement_area.id) - er.async_update_entity("light.stove", area_id=basement_area.id) + basement_area = area_registry.async_create("basement") + device_registry.async_update_device(device.id, area_id=basement_area.id) + entity_registry.async_update_entity("light.stove", area_id=basement_area.id) calls.clear() # Test that the area is updated @@ -852,33 +865,40 @@ async def test_turn_on_area(hass: HomeAssistant, init_components) -> None: assert call.data == {"entity_id": ["light.stove"]} -async def test_light_area_same_name(hass: HomeAssistant, init_components) -> None: +async def test_light_area_same_name( + hass: HomeAssistant, + init_components, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test turning on a light with the same name as an area.""" - entities = entity_registry.async_get(hass) - devices = device_registry.async_get(hass) - areas = area_registry.async_get(hass) entry = MockConfigEntry(domain="test") - device = devices.async_get_or_create( + device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, - connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - kitchen_area = areas.async_create("kitchen") - devices.async_update_device(device.id, area_id=kitchen_area.id) + kitchen_area = area_registry.async_create("kitchen") + device_registry.async_update_device(device.id, area_id=kitchen_area.id) - kitchen_light = entities.async_get_or_create( + kitchen_light = entity_registry.async_get_or_create( "light", "demo", "1234", original_name="kitchen light" ) - entities.async_update_entity(kitchen_light.entity_id, area_id=kitchen_area.id) + entity_registry.async_update_entity( + kitchen_light.entity_id, area_id=kitchen_area.id + ) hass.states.async_set( kitchen_light.entity_id, "off", attributes={ATTR_FRIENDLY_NAME: "kitchen light"} ) - ceiling_light = entities.async_get_or_create( + ceiling_light = entity_registry.async_get_or_create( "light", "demo", "5678", original_name="ceiling light" ) - entities.async_update_entity(ceiling_light.entity_id, area_id=kitchen_area.id) + entity_registry.async_update_entity( + ceiling_light.entity_id, area_id=kitchen_area.id + ) hass.states.async_set( ceiling_light.entity_id, "off", attributes={ATTR_FRIENDLY_NAME: "ceiling light"} ) From 1bed5c777587121308f9401c5aa84013119f425b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 03:56:46 +0100 Subject: [PATCH 0522/2810] Adjust registry access in tests root (#88880) --- tests/common.py | 46 +++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/tests/common.py b/tests/common.py index 66875eb6e9..c25ad0cca6 100644 --- a/tests/common.py +++ b/tests/common.py @@ -50,13 +50,13 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import ( - area_registry, - device_registry, + area_registry as ar, + device_registry as dr, entity, entity_platform, - entity_registry, + entity_registry as er, intent, - issue_registry, + issue_registry as ir, recorder as recorder_helper, restore_state, storage, @@ -251,10 +251,10 @@ async def async_test_home_assistant(event_loop, load_registries=True): if load_registries: with patch("homeassistant.helpers.storage.Store.async_load", return_value=None): await asyncio.gather( - area_registry.async_load(hass), - device_registry.async_load(hass), - entity_registry.async_load(hass), - issue_registry.async_load(hass), + ar.async_load(hass), + dr.async_load(hass), + er.async_load(hass), + ir.async_load(hass), ) hass.data[bootstrap.DATA_REGISTRIES_LOADED] = None @@ -481,8 +481,8 @@ def mock_component(hass: HomeAssistant, component: str) -> None: def mock_registry( hass: HomeAssistant, - mock_entries: dict[str, entity_registry.RegistryEntry] | None = None, -) -> entity_registry.EntityRegistry: + mock_entries: dict[str, er.RegistryEntry] | None = None, +) -> er.EntityRegistry: """Mock the Entity Registry. This should only be used if you need to mock/re-stage a clean mocked @@ -494,20 +494,20 @@ def mock_registry( If you just need to access the existing registry, use the `entity_registry` fixture instead. """ - registry = entity_registry.EntityRegistry(hass) + registry = er.EntityRegistry(hass) if mock_entries is None: mock_entries = {} - registry.entities = entity_registry.EntityRegistryItems() + registry.entities = er.EntityRegistryItems() for key, entry in mock_entries.items(): registry.entities[key] = entry - hass.data[entity_registry.DATA_REGISTRY] = registry + hass.data[er.DATA_REGISTRY] = registry return registry def mock_area_registry( - hass: HomeAssistant, mock_entries: dict[str, area_registry.AreaEntry] | None = None -) -> area_registry.AreaRegistry: + hass: HomeAssistant, mock_entries: dict[str, ar.AreaEntry] | None = None +) -> ar.AreaRegistry: """Mock the Area Registry. This should only be used if you need to mock/re-stage a clean mocked @@ -519,17 +519,17 @@ def mock_area_registry( If you just need to access the existing registry, use the `area_registry` fixture instead. """ - registry = area_registry.AreaRegistry(hass) + registry = ar.AreaRegistry(hass) registry.areas = mock_entries or OrderedDict() - hass.data[area_registry.DATA_REGISTRY] = registry + hass.data[ar.DATA_REGISTRY] = registry return registry def mock_device_registry( hass: HomeAssistant, - mock_entries: dict[str, device_registry.DeviceEntry] | None = None, -) -> device_registry.DeviceRegistry: + mock_entries: dict[str, dr.DeviceEntry] | None = None, +) -> dr.DeviceRegistry: """Mock the Device Registry. This should only be used if you need to mock/re-stage a clean mocked @@ -541,15 +541,15 @@ def mock_device_registry( If you just need to access the existing registry, use the `device_registry` fixture instead. """ - registry = device_registry.DeviceRegistry(hass) - registry.devices = device_registry.DeviceRegistryItems() + registry = dr.DeviceRegistry(hass) + registry.devices = dr.DeviceRegistryItems() if mock_entries is None: mock_entries = {} for key, entry in mock_entries.items(): registry.devices[key] = entry - registry.deleted_devices = device_registry.DeviceRegistryItems() + registry.deleted_devices = dr.DeviceRegistryItems() - hass.data[device_registry.DATA_REGISTRY] = registry + hass.data[dr.DATA_REGISTRY] = registry return registry From 54f709f70401ddf526846407c9e5e79d91af6e67 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 03:58:19 +0100 Subject: [PATCH 0523/2810] Adjust registry access in intent (#88881) --- homeassistant/components/intent/__init__.py | 6 ++-- homeassistant/components/light/intent.py | 6 ++-- tests/components/intent/test_init.py | 40 +++++++++++---------- 3 files changed, 27 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index a52f4897d2..6bc3d88287 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant, State from homeassistant.helpers import ( - area_registry, + area_registry as ar, config_validation as cv, integration_platform, intent, @@ -109,9 +109,9 @@ class GetStateIntentHandler(intent.IntentHandler): # Look up area first to fail early area_name = slots.get("area", {}).get("value") - area: area_registry.AreaEntry | None = None + area: ar.AreaEntry | None = None if area_name is not None: - areas = area_registry.async_get(hass) + areas = ar.async_get(hass) area = areas.async_get_area(area_name) or areas.async_get_area_by_name( area_name ) diff --git a/homeassistant/components/light/intent.py b/homeassistant/components/light/intent.py index 7b75821ab4..605434af91 100644 --- a/homeassistant/components/light/intent.py +++ b/homeassistant/components/light/intent.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers import area_registry, config_validation as cv, intent +from homeassistant.helpers import area_registry as ar, config_validation as cv, intent import homeassistant.util.color as color_util from . import ( @@ -56,9 +56,9 @@ class SetIntentHandler(intent.IntentHandler): # Look up area first to fail early area_name = slots.get("area", {}).get("value") - area: area_registry.AreaEntry | None = None + area: ar.AreaEntry | None = None if area_name is not None: - areas = area_registry.async_get(hass) + areas = ar.async_get(hass) area = areas.async_get_area(area_name) or areas.async_get_area_by_name( area_name ) diff --git a/tests/components/intent/test_init.py b/tests/components/intent/test_init.py index d615d12603..fa8eb9cad6 100644 --- a/tests/components/intent/test_init.py +++ b/tests/components/intent/test_init.py @@ -10,7 +10,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import area_registry, entity_registry, intent +from homeassistant.helpers import area_registry as ar, entity_registry as er, intent from homeassistant.setup import async_setup_component from tests.common import MockUser, async_mock_service @@ -186,7 +186,11 @@ async def test_turn_on_multiple_intent(hass: HomeAssistant) -> None: assert call.data == {"entity_id": ["light.test_lights_2"]} -async def test_get_state_intent(hass: HomeAssistant) -> None: +async def test_get_state_intent( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test HassGetState intent. This tests name, area, domain, device class, and state constraints. @@ -194,33 +198,31 @@ async def test_get_state_intent(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "intent", {}) - areas = area_registry.async_get(hass) - bedroom = areas.async_get_or_create("bedroom") - kitchen = areas.async_get_or_create("kitchen") - office = areas.async_get_or_create("office") + bedroom = area_registry.async_get_or_create("bedroom") + kitchen = area_registry.async_get_or_create("kitchen") + office = area_registry.async_get_or_create("office") # 1 light in bedroom (off) # 1 light in kitchen (on) # 1 sensor in kitchen (50) # 2 binary sensors in the office (problem, moisture, on) - entities = entity_registry.async_get(hass) - bedroom_light = entities.async_get_or_create("light", "demo", "1") - entities.async_update_entity(bedroom_light.entity_id, area_id=bedroom.id) + bedroom_light = entity_registry.async_get_or_create("light", "demo", "1") + entity_registry.async_update_entity(bedroom_light.entity_id, area_id=bedroom.id) - kitchen_sensor = entities.async_get_or_create("sensor", "demo", "2") - entities.async_update_entity(kitchen_sensor.entity_id, area_id=kitchen.id) + kitchen_sensor = entity_registry.async_get_or_create("sensor", "demo", "2") + entity_registry.async_update_entity(kitchen_sensor.entity_id, area_id=kitchen.id) - kitchen_light = entities.async_get_or_create("light", "demo", "3") - entities.async_update_entity(kitchen_light.entity_id, area_id=kitchen.id) + kitchen_light = entity_registry.async_get_or_create("light", "demo", "3") + entity_registry.async_update_entity(kitchen_light.entity_id, area_id=kitchen.id) - kitchen_sensor = entities.async_get_or_create("sensor", "demo", "4") - entities.async_update_entity(kitchen_sensor.entity_id, area_id=kitchen.id) + kitchen_sensor = entity_registry.async_get_or_create("sensor", "demo", "4") + entity_registry.async_update_entity(kitchen_sensor.entity_id, area_id=kitchen.id) - problem_sensor = entities.async_get_or_create("binary_sensor", "demo", "5") - entities.async_update_entity(problem_sensor.entity_id, area_id=office.id) + problem_sensor = entity_registry.async_get_or_create("binary_sensor", "demo", "5") + entity_registry.async_update_entity(problem_sensor.entity_id, area_id=office.id) - moisture_sensor = entities.async_get_or_create("binary_sensor", "demo", "6") - entities.async_update_entity(moisture_sensor.entity_id, area_id=office.id) + moisture_sensor = entity_registry.async_get_or_create("binary_sensor", "demo", "6") + entity_registry.async_update_entity(moisture_sensor.entity_id, area_id=office.id) hass.states.async_set( bedroom_light.entity_id, "off", attributes={ATTR_FRIENDLY_NAME: "bedroom light"} From ee781e4f494282fdfc0c6329a89f08f56bf1cbde Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 03:58:47 +0100 Subject: [PATCH 0524/2810] Adjust registry access in scripts (#88884) --- homeassistant/scripts/check_config.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 85d0e77a4e..92f5b442d9 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -15,7 +15,11 @@ from homeassistant import core from homeassistant.config import get_default_config_dir from homeassistant.config_entries import ConfigEntries from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import area_registry, device_registry, entity_registry +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.check_config import async_check_ha_config_file from homeassistant.util.yaml import Secrets import homeassistant.util.yaml.loader as yaml_loader @@ -230,9 +234,9 @@ async def async_check_config(config_dir): hass = core.HomeAssistant() hass.config.config_dir = config_dir hass.config_entries = ConfigEntries(hass, {}) - await area_registry.async_load(hass) - await device_registry.async_load(hass) - await entity_registry.async_load(hass) + await ar.async_load(hass) + await dr.async_load(hass) + await er.async_load(hass) components = await async_check_ha_config_file(hass) await hass.async_stop(force=True) return components From 246f9784c81b849985bfd396d955a0075d7d7a2a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 03:59:26 +0100 Subject: [PATCH 0525/2810] Adjust registry access in Google Assistant (#88883) --- .../components/google_assistant/helpers.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 196fa580ea..e194242df9 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -22,7 +22,12 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import Context, HomeAssistant, State, callback -from homeassistant.helpers import area_registry, device_registry, entity_registry, start +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, + start, +) from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import get_url from homeassistant.helpers.storage import Store @@ -52,15 +57,11 @@ LOCAL_SDK_MIN_VERSION = AwesomeVersion("2.1.5") @callback def _get_registry_entries( hass: HomeAssistant, entity_id: str -) -> tuple[ - entity_registry.RegistryEntry | None, - device_registry.DeviceEntry | None, - area_registry.AreaEntry | None, -]: +) -> tuple[er.RegistryEntry | None, dr.DeviceEntry | None, ar.AreaEntry | None,]: """Get registry entries.""" - ent_reg = entity_registry.async_get(hass) - dev_reg = device_registry.async_get(hass) - area_reg = area_registry.async_get(hass) + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) + area_reg = ar.async_get(hass) if (entity_entry := ent_reg.async_get(entity_id)) and entity_entry.device_id: device_entry = dev_reg.devices.get(entity_entry.device_id) From c724e7c29f97699c6a64d28e8e19762858671101 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 03:59:44 +0100 Subject: [PATCH 0526/2810] Adjust registry access in openai_conversation (#88882) --- .../openai_conversation/__init__.py | 4 +-- .../openai_conversation/test_init.py | 36 ++++++++++--------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 41ff6bcf9c..355b7764b0 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, TemplateError -from homeassistant.helpers import area_registry, intent, template +from homeassistant.helpers import area_registry as ar, intent, template from homeassistant.util import ulid from .const import ( @@ -150,7 +150,7 @@ class OpenAIAgent(conversation.AbstractConversationAgent): return template.Template(raw_prompt, self.hass).async_render( { "ha_name": self.hass.config.location_name, - "areas": list(area_registry.async_get(self.hass).areas.values()), + "areas": list(ar.async_get(self.hass).areas.values()), }, parse_result=False, ) diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index defadcc0d9..3b78a90f40 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -5,20 +5,22 @@ from openai import error from homeassistant.components import conversation from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import area_registry, device_registry, intent +from homeassistant.helpers import area_registry as ar, device_registry as dr, intent from tests.common import MockConfigEntry -async def test_default_prompt(hass: HomeAssistant, mock_init_component) -> None: +async def test_default_prompt( + hass: HomeAssistant, + mock_init_component, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, +) -> None: """Test that the default prompt works.""" - device_reg = device_registry.async_get(hass) - area_reg = area_registry.async_get(hass) - for i in range(3): - area_reg.async_create(f"{i}Empty Area") + area_registry.async_create(f"{i}Empty Area") - device_reg.async_get_or_create( + device_registry.async_get_or_create( config_entry_id="1234", connections={("test", "1234")}, name="Test Device", @@ -27,16 +29,16 @@ async def test_default_prompt(hass: HomeAssistant, mock_init_component) -> None: suggested_area="Test Area", ) for i in range(3): - device_reg.async_get_or_create( + device_registry.async_get_or_create( config_entry_id="1234", connections={("test", f"{i}abcd")}, name="Test Service", manufacturer="Test Manufacturer", model="Test Model", suggested_area="Test Area", - entry_type=device_registry.DeviceEntryType.SERVICE, + entry_type=dr.DeviceEntryType.SERVICE, ) - device_reg.async_get_or_create( + device_registry.async_get_or_create( config_entry_id="1234", connections={("test", "5678")}, name="Test Device 2", @@ -44,7 +46,7 @@ async def test_default_prompt(hass: HomeAssistant, mock_init_component) -> None: model="Device 2", suggested_area="Test Area 2", ) - device_reg.async_get_or_create( + device_registry.async_get_or_create( config_entry_id="1234", connections={("test", "9876")}, name="Test Device 3", @@ -52,13 +54,13 @@ async def test_default_prompt(hass: HomeAssistant, mock_init_component) -> None: model="Test Model 3A", suggested_area="Test Area 2", ) - device_reg.async_get_or_create( + device_registry.async_get_or_create( config_entry_id="1234", connections={("test", "qwer")}, name="Test Device 4", suggested_area="Test Area 2", ) - device = device_reg.async_get_or_create( + device = device_registry.async_get_or_create( config_entry_id="1234", connections={("test", "9876-disabled")}, name="Test Device 3", @@ -66,17 +68,17 @@ async def test_default_prompt(hass: HomeAssistant, mock_init_component) -> None: model="Test Model 3A", suggested_area="Test Area 2", ) - device_reg.async_update_device( - device.id, disabled_by=device_registry.DeviceEntryDisabler.USER + device_registry.async_update_device( + device.id, disabled_by=dr.DeviceEntryDisabler.USER ) - device_reg.async_get_or_create( + device_registry.async_get_or_create( config_entry_id="1234", connections={("test", "9876-no-name")}, manufacturer="Test Manufacturer NoName", model="Test Model NoName", suggested_area="Test Area 2", ) - device_reg.async_get_or_create( + device_registry.async_get_or_create( config_entry_id="1234", connections={("test", "9876-integer-values")}, name=1, From 3e8716b37edcc2c161edab2c4835314d53907798 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 04:01:36 +0100 Subject: [PATCH 0527/2810] Adjust AddEntitiesCallback import (part 2) (#88873) --- homeassistant/components/eight_sleep/sensor.py | 9 ++++++--- homeassistant/components/ezviz/camera.py | 14 ++++++-------- homeassistant/components/hue/scene.py | 9 ++++++--- homeassistant/components/keymitt_ble/switch.py | 12 +++++++----- homeassistant/components/unifiprotect/select.py | 16 +++++++--------- homeassistant/components/zha/lock.py | 10 +++++++--- 6 files changed, 39 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/eight_sleep/sensor.py b/homeassistant/components/eight_sleep/sensor.py index 58648123dc..e546318a4d 100644 --- a/homeassistant/components/eight_sleep/sensor.py +++ b/homeassistant/components/eight_sleep/sensor.py @@ -15,7 +15,10 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform as ep +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import EightSleepBaseEntity, EightSleepConfigEntryData @@ -68,7 +71,7 @@ SERVICE_EIGHT_SCHEMA = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: ep.AddEntitiesCallback + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the eight sleep sensors.""" config_entry_data: EightSleepConfigEntryData = hass.data[DOMAIN][entry.entry_id] @@ -95,7 +98,7 @@ async def async_setup_entry( async_add_entities(all_sensors) - platform = ep.async_get_current_platform() + platform = async_get_current_platform() platform.async_register_entity_service( SERVICE_HEAT_SET, SERVICE_EIGHT_SCHEMA, diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index 65b5df100d..7901061c02 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -17,10 +17,10 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.helpers import ( - config_validation as cv, - discovery_flow, - entity_platform, +from homeassistant.helpers import config_validation as cv, discovery_flow +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, ) from .const import ( @@ -53,9 +53,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up EZVIZ cameras based on a config entry.""" @@ -132,7 +130,7 @@ async def async_setup_entry( async_add_entities(camera_entities) - platform = entity_platform.async_get_current_platform() + platform = async_get_current_platform() platform.async_register_entity_service( SERVICE_PTZ, diff --git a/homeassistant/components/hue/scene.py b/homeassistant/components/hue/scene.py index 1020879ce8..2c6c167977 100644 --- a/homeassistant/components/hue/scene.py +++ b/homeassistant/components/hue/scene.py @@ -13,9 +13,12 @@ import voluptuous as vol from homeassistant.components.scene import ATTR_TRANSITION, Scene as SceneEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_platform from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) from .bridge import HueBridge from .const import DOMAIN @@ -31,7 +34,7 @@ ATTR_BRIGHTNESS = "brightness" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up scene platform from Hue group scenes.""" bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] @@ -62,7 +65,7 @@ async def async_setup_entry( ) # add platform service to turn_on/activate scene with advanced options - platform = entity_platform.async_get_current_platform() + platform = async_get_current_platform() platform.async_register_entity_service( SERVICE_ACTIVATE_SCENE, { diff --git a/homeassistant/components/keymitt_ble/switch.py b/homeassistant/components/keymitt_ble/switch.py index 099ad1f228..3e5883ae5d 100644 --- a/homeassistant/components/keymitt_ble/switch.py +++ b/homeassistant/components/keymitt_ble/switch.py @@ -8,7 +8,11 @@ import voluptuous as vol from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) from .const import DOMAIN from .coordinator import MicroBotDataUpdateCoordinator @@ -23,14 +27,12 @@ CALIBRATE_SCHEMA = { async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up MicroBot based on a config entry.""" coordinator: MicroBotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities([MicroBotBinarySwitch(coordinator, entry)]) - platform = entity_platform.async_get_current_platform() + platform = async_get_current_platform() platform.async_register_entity_service( CALIBRATE, CALIBRATE_SCHEMA, diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 7bc54aa7af..7fe43bee9b 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -32,12 +32,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import ( - config_validation as cv, - entity_platform, - issue_registry as ir, -) +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) from homeassistant.util.dt import utcnow from .const import ATTR_DURATION, ATTR_MESSAGE, DISPATCH_ADOPT, DOMAIN, TYPE_EMPTY_VALUE @@ -319,9 +319,7 @@ VIEWER_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up number entities for UniFi Protect integration.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] @@ -354,7 +352,7 @@ async def async_setup_entry( ) async_add_entities(entities) - platform = entity_platform.async_get_current_platform() + platform = async_get_current_platform() platform.async_register_entity_service( SERVICE_SET_DOORBELL_MESSAGE, SET_DOORBELL_LCD_MESSAGE_SCHEMA, diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py index a2ec5e068c..433f662a78 100644 --- a/homeassistant/components/zha/lock.py +++ b/homeassistant/components/zha/lock.py @@ -9,8 +9,12 @@ from homeassistant.components.lock import STATE_LOCKED, STATE_UNLOCKED, LockEnti from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) from homeassistant.helpers.typing import StateType from .core import discovery @@ -38,7 +42,7 @@ SERVICE_CLEAR_LOCK_USER_CODE = "clear_lock_user_code" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation Door Lock from config entry.""" entities_to_create = hass.data[DATA_ZHA][Platform.LOCK] @@ -52,7 +56,7 @@ async def async_setup_entry( ) config_entry.async_on_unload(unsub) - platform = entity_platform.async_get_current_platform() + platform = async_get_current_platform() platform.async_register_entity_service( SERVICE_SET_LOCK_USER_CODE, From 5b496488469fd9d55995da4c53d9469f1139aea4 Mon Sep 17 00:00:00 2001 From: PatrickGlesner <34370149+PatrickGlesner@users.noreply.github.com> Date: Wed, 1 Mar 2023 04:02:52 +0100 Subject: [PATCH 0528/2810] 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 8f58587939..3c5a830698 100644 --- a/homeassistant/components/tado/services.yaml +++ b/homeassistant/components/tado/services.yaml @@ -21,7 +21,6 @@ set_climate_timer: description: Choose this or Overlay. Set the time period for the change if you want to be specific. Alternatively use Overlay required: false example: "01:30:00" - default: "01:00:00" selector: text: requested_overlay: @@ -29,7 +28,6 @@ set_climate_timer: description: Choose this or Time Period. Allows you to choose an overlay. MANUAL:=Overlay until user removes; NEXT_TIME_BLOCK:=Overlay until next timeblock; TADO_DEFAULT:=Overlay based on tado app setting required: false example: "MANUAL" - default: "TADO_DEFAULT" selector: select: options: From 09d01286012febe46a938a92a2c5c89c772c78de Mon Sep 17 00:00:00 2001 From: Chuck Deal <106625807+chuckdeal97@users.noreply.github.com> Date: Tue, 28 Feb 2023 22:12:48 -0500 Subject: [PATCH 0529/2810] Add diagnostics to VeSync (#86350) * Add diagnostics to VeSync * Create unit tests for diagnostics and init * Improved diagnostic test coverage * Peer review fixes * Fixed Peer Review comments * Updated based on Peer Review * Additional diagnostic redactions * Removed account_id from diagnostic output --- .coveragerc | 1 - .../components/vesync/diagnostics.py | 119 ++++++++ tests/components/vesync/common.py | 72 +++++ tests/components/vesync/conftest.py | 104 +++++++ ..._api_call__device_details__single_fan.json | 15 + ...ll__device_details__single_humidifier.json | 27 ++ .../vesync_api_call__devices__no_devices.json | 11 + .../vesync_api_call__devices__single_fan.json | 37 +++ ..._api_call__devices__single_humidifier.json | 37 +++ .../fixtures/vesync_api_call__login.json | 9 + .../vesync/snapshots/test_diagnostics.ambr | 272 ++++++++++++++++++ tests/components/vesync/test_diagnostics.py | 99 +++++++ tests/components/vesync/test_init.py | 103 +++++++ 13 files changed, 905 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/vesync/diagnostics.py create mode 100644 tests/components/vesync/common.py create mode 100644 tests/components/vesync/conftest.py create mode 100644 tests/components/vesync/fixtures/vesync_api_call__device_details__single_fan.json create mode 100644 tests/components/vesync/fixtures/vesync_api_call__device_details__single_humidifier.json create mode 100644 tests/components/vesync/fixtures/vesync_api_call__devices__no_devices.json create mode 100644 tests/components/vesync/fixtures/vesync_api_call__devices__single_fan.json create mode 100644 tests/components/vesync/fixtures/vesync_api_call__devices__single_humidifier.json create mode 100644 tests/components/vesync/fixtures/vesync_api_call__login.json create mode 100644 tests/components/vesync/snapshots/test_diagnostics.ambr create mode 100644 tests/components/vesync/test_diagnostics.py create mode 100644 tests/components/vesync/test_init.py diff --git a/.coveragerc b/.coveragerc index 8a5b90b5d7..4737683367 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1368,7 +1368,6 @@ omit = homeassistant/components/verisure/sensor.py homeassistant/components/verisure/switch.py homeassistant/components/versasense/* - homeassistant/components/vesync/__init__.py homeassistant/components/vesync/common.py homeassistant/components/vesync/fan.py homeassistant/components/vesync/light.py diff --git a/homeassistant/components/vesync/diagnostics.py b/homeassistant/components/vesync/diagnostics.py new file mode 100644 index 0000000000..8043e93b9e --- /dev/null +++ b/homeassistant/components/vesync/diagnostics.py @@ -0,0 +1,119 @@ +"""Diagnostics support for VeSync.""" +from __future__ import annotations + +from typing import Any + +from pyvesync import VeSync + +from homeassistant.components.diagnostics import REDACTED +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceEntry + +from .common import VeSyncBaseDevice +from .const import DOMAIN, VS_MANAGER + +KEYS_TO_REDACT = {"manager", "uuid", "mac_id"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + manager: VeSync = hass.data[DOMAIN][VS_MANAGER] + + data = { + DOMAIN: { + "bulb_count": len(manager.bulbs), + "fan_count": len(manager.fans), + "outlets_count": len(manager.outlets), + "switch_count": len(manager.switches), + "timezone": manager.time_zone, + }, + "devices": { + "bulbs": [_redact_device_values(device) for device in manager.bulbs], + "fans": [_redact_device_values(device) for device in manager.fans], + "outlets": [_redact_device_values(device) for device in manager.outlets], + "switches": [_redact_device_values(device) for device in manager.switches], + }, + } + + return data + + +async def async_get_device_diagnostics( + hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry +) -> dict[str, Any]: + """Return diagnostics for a device entry.""" + manager: VeSync = hass.data[DOMAIN][VS_MANAGER] + device_dict = _build_device_dict(manager) + vesync_device_id = next(iden[1] for iden in device.identifiers if iden[0] == DOMAIN) + + # Base device information, without sensitive information. + data = _redact_device_values(device_dict[vesync_device_id]) + + data["home_assistant"] = { + "name": device.name, + "name_by_user": device.name_by_user, + "disabled": device.disabled, + "disabled_by": device.disabled_by, + "entities": [], + } + + # Gather information how this VeSync device is represented in Home Assistant + entity_registry = er.async_get(hass) + hass_entities = er.async_entries_for_device( + entity_registry, + device_id=device.id, + include_disabled_entities=True, + ) + + for entity_entry in hass_entities: + state = hass.states.get(entity_entry.entity_id) + state_dict = None + if state: + state_dict = dict(state.as_dict()) + # The context doesn't provide useful information in this case. + state_dict.pop("context", None) + + data["home_assistant"]["entities"].append( + { + "domain": entity_entry.domain, + "entity_id": entity_entry.entity_id, + "entity_category": entity_entry.entity_category, + "device_class": entity_entry.device_class, + "original_device_class": entity_entry.original_device_class, + "name": entity_entry.name, + "original_name": entity_entry.original_name, + "icon": entity_entry.icon, + "original_icon": entity_entry.original_icon, + "unit_of_measurement": entity_entry.unit_of_measurement, + "state": state_dict, + "disabled": entity_entry.disabled, + "disabled_by": entity_entry.disabled_by, + } + ) + + return data + + +def _build_device_dict(manager: VeSync) -> dict: + """Build a dictionary of ALL VeSync devices.""" + device_dict = {x.cid: x for x in manager.switches} + device_dict.update({x.cid: x for x in manager.fans}) + device_dict.update({x.cid: x for x in manager.outlets}) + device_dict.update({x.cid: x for x in manager.bulbs}) + return device_dict + + +def _redact_device_values(device: VeSyncBaseDevice) -> dict: + """Rebuild and redact values of a VeSync device.""" + data = {} + for key, item in device.__dict__.items(): + if key not in KEYS_TO_REDACT: + data[key] = item + else: + data[key] = REDACTED + + return data diff --git a/tests/components/vesync/common.py b/tests/components/vesync/common.py new file mode 100644 index 0000000000..39cd66a593 --- /dev/null +++ b/tests/components/vesync/common.py @@ -0,0 +1,72 @@ +"""Common methods used across tests for VeSync.""" +import json + +from tests.common import load_fixture + + +def call_api_side_effect__no_devices(*args, **kwargs): + """Build a side_effects method for the Helpers.call_api method.""" + if args[0] == "/cloud/v1/user/login" and args[1] == "post": + return json.loads(load_fixture("vesync_api_call__login.json", "vesync")), 200 + elif args[0] == "/cloud/v1/deviceManaged/devices" and args[1] == "post": + return ( + json.loads( + load_fixture("vesync_api_call__devices__no_devices.json", "vesync") + ), + 200, + ) + else: + raise ValueError(f"Unhandled API call args={args}, kwargs={kwargs}") + + +def call_api_side_effect__single_humidifier(*args, **kwargs): + """Build a side_effects method for the Helpers.call_api method.""" + if args[0] == "/cloud/v1/user/login" and args[1] == "post": + return json.loads(load_fixture("vesync_api_call__login.json", "vesync")), 200 + elif args[0] == "/cloud/v1/deviceManaged/devices" and args[1] == "post": + return ( + json.loads( + load_fixture( + "vesync_api_call__devices__single_humidifier.json", "vesync" + ) + ), + 200, + ) + elif args[0] == "/cloud/v2/deviceManaged/bypassV2" and kwargs["method"] == "post": + return ( + json.loads( + load_fixture( + "vesync_api_call__device_details__single_humidifier.json", "vesync" + ) + ), + 200, + ) + else: + raise ValueError(f"Unhandled API call args={args}, kwargs={kwargs}") + + +def call_api_side_effect__single_fan(*args, **kwargs): + """Build a side_effects method for the Helpers.call_api method.""" + if args[0] == "/cloud/v1/user/login" and args[1] == "post": + return json.loads(load_fixture("vesync_api_call__login.json", "vesync")), 200 + elif args[0] == "/cloud/v1/deviceManaged/devices" and args[1] == "post": + return ( + json.loads( + load_fixture("vesync_api_call__devices__single_fan.json", "vesync") + ), + 200, + ) + elif ( + args[0] == "/131airPurifier/v1/device/deviceDetail" + and kwargs["method"] == "post" + ): + return ( + json.loads( + load_fixture( + "vesync_api_call__device_details__single_fan.json", "vesync" + ) + ), + 200, + ) + else: + raise ValueError(f"Unhandled API call args={args}, kwargs={kwargs}") diff --git a/tests/components/vesync/conftest.py b/tests/components/vesync/conftest.py new file mode 100644 index 0000000000..8815a4b974 --- /dev/null +++ b/tests/components/vesync/conftest.py @@ -0,0 +1,104 @@ +"""Configuration for VeSync tests.""" +from __future__ import annotations + +from unittest.mock import Mock, patch + +import pytest +from pyvesync import VeSync +from pyvesync.vesyncbulb import VeSyncBulb +from pyvesync.vesyncfan import VeSyncAirBypass +from pyvesync.vesyncoutlet import VeSyncOutlet +from pyvesync.vesyncswitch import VeSyncSwitch + +from homeassistant.components.vesync import DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(hass: HomeAssistant, config) -> ConfigEntry: + """Create a mock VeSync config entry.""" + entry = MockConfigEntry( + title="VeSync", + domain=DOMAIN, + data=config[DOMAIN], + ) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture(name="config") +def config_fixture() -> ConfigType: + """Create hass config fixture.""" + return {DOMAIN: {CONF_USERNAME: "user", CONF_PASSWORD: "pass"}} + + +@pytest.fixture(name="manager") +def manager_fixture() -> VeSync: + """Create a mock VeSync manager fixture.""" + + outlets = [] + switches = [] + fans = [] + bulbs = [] + + mock_vesync = Mock(VeSync) + mock_vesync.login = Mock(return_value=True) + mock_vesync.update = Mock() + mock_vesync.outlets = outlets + mock_vesync.switches = switches + mock_vesync.fans = fans + mock_vesync.bulbs = bulbs + mock_vesync._dev_list = { + "fans": fans, + "outlets": outlets, + "switches": switches, + "bulbs": bulbs, + } + mock_vesync.account_id = "account_id" + mock_vesync.time_zone = "America/New_York" + mock = Mock(return_value=mock_vesync) + + with patch("homeassistant.components.vesync.VeSync", new=mock): + yield mock_vesync + + +@pytest.fixture(name="fan") +def fan_fixture(): + """Create a mock VeSync fan fixture.""" + mock_fixture = Mock(VeSyncAirBypass) + return mock_fixture + + +@pytest.fixture(name="bulb") +def bulb_fixture(): + """Create a mock VeSync bulb fixture.""" + mock_fixture = Mock(VeSyncBulb) + return mock_fixture + + +@pytest.fixture(name="switch") +def switch_fixture(): + """Create a mock VeSync switch fixture.""" + mock_fixture = Mock(VeSyncSwitch) + mock_fixture.is_dimmable = Mock(return_value=False) + return mock_fixture + + +@pytest.fixture(name="dimmable_switch") +def dimmable_switch_fixture(): + """Create a mock VeSync switch fixture.""" + mock_fixture = Mock(VeSyncSwitch) + mock_fixture.is_dimmable = Mock(return_value=True) + return mock_fixture + + +@pytest.fixture(name="outlet") +def outlet_fixture(): + """Create a mock VeSync outlet fixture.""" + mock_fixture = Mock(VeSyncOutlet) + return mock_fixture diff --git a/tests/components/vesync/fixtures/vesync_api_call__device_details__single_fan.json b/tests/components/vesync/fixtures/vesync_api_call__device_details__single_fan.json new file mode 100644 index 0000000000..35b5a02fb3 --- /dev/null +++ b/tests/components/vesync/fixtures/vesync_api_call__device_details__single_fan.json @@ -0,0 +1,15 @@ +{ + "traceId": "0000000000", + "code": 0, + "msg": "request success", + "module": null, + "stacktrace": null, + "result": { + "traceId": "0000000000", + "code": 0, + "result": { + "enabled": false, + "mode": "humidity" + } + } +} diff --git a/tests/components/vesync/fixtures/vesync_api_call__device_details__single_humidifier.json b/tests/components/vesync/fixtures/vesync_api_call__device_details__single_humidifier.json new file mode 100644 index 0000000000..f9e4b0e18f --- /dev/null +++ b/tests/components/vesync/fixtures/vesync_api_call__device_details__single_humidifier.json @@ -0,0 +1,27 @@ +{ + "traceId": "0000000000", + "code": 0, + "msg": "request success", + "module": null, + "stacktrace": null, + "result": { + "traceId": "0000000000", + "code": 0, + "result": { + "enabled": false, + "mist_virtual_level": 9, + "mist_level": 3, + "mode": "humidity", + "water_lacks": false, + "water_tank_lifted": false, + "humidity": 35, + "humidity_high": false, + "display": false, + "warm_enabled": false, + "warm_level": 0, + "automatic_stop_reach_target": true, + "configuration": { "auto_target_humidity": 60, "display": true }, + "extension": { "schedule_count": 0, "timer_remain": 0 } + } + } +} diff --git a/tests/components/vesync/fixtures/vesync_api_call__devices__no_devices.json b/tests/components/vesync/fixtures/vesync_api_call__devices__no_devices.json new file mode 100644 index 0000000000..f1eaa52310 --- /dev/null +++ b/tests/components/vesync/fixtures/vesync_api_call__devices__no_devices.json @@ -0,0 +1,11 @@ +{ + "traceId": "0000000000", + "code": 0, + "msg": "request success", + "result": { + "total": 1, + "pageSize": 100, + "pageNo": 1, + "list": [] + } +} diff --git a/tests/components/vesync/fixtures/vesync_api_call__devices__single_fan.json b/tests/components/vesync/fixtures/vesync_api_call__devices__single_fan.json new file mode 100644 index 0000000000..2951ab63f0 --- /dev/null +++ b/tests/components/vesync/fixtures/vesync_api_call__devices__single_fan.json @@ -0,0 +1,37 @@ +{ + "traceId": "0000000000", + "code": 0, + "msg": "request success", + "result": { + "total": 1, + "pageSize": 100, + "pageNo": 1, + "list": [ + { + "deviceRegion": "US", + "isOwner": true, + "authKey": null, + "deviceName": "Fan", + "deviceImg": "", + "cid": "abcdefghabcdefghabcdefghabcdefgh", + "deviceStatus": "off", + "connectionStatus": "online", + "connectionType": "WiFi+BTOnboarding+BTNotify", + "deviceType": "LV-PUR131S", + "type": "wifi-air", + "uuid": "00000000-1111-2222-3333-444444444444", + "configModule": "WFON_AHM_LV-PUR131S_US", + "macID": "00:00:00:00:00:00", + "mode": null, + "speed": null, + "currentFirmVersion": null, + "subDeviceNo": null, + "subDeviceType": null, + "deviceFirstSetupTime": "Jan 24, 2022 12:09:01 AM", + "subDeviceList": null, + "extension": null, + "deviceProp": null + } + ] + } +} diff --git a/tests/components/vesync/fixtures/vesync_api_call__devices__single_humidifier.json b/tests/components/vesync/fixtures/vesync_api_call__devices__single_humidifier.json new file mode 100644 index 0000000000..0f04339440 --- /dev/null +++ b/tests/components/vesync/fixtures/vesync_api_call__devices__single_humidifier.json @@ -0,0 +1,37 @@ +{ + "traceId": "0000000000", + "code": 0, + "msg": "request success", + "result": { + "total": 1, + "pageSize": 100, + "pageNo": 1, + "list": [ + { + "deviceRegion": "US", + "isOwner": true, + "authKey": null, + "deviceName": "Humidifier", + "deviceImg": "https://image.vesync.com/defaultImages/LV_600S_Series/icon_lv600s_humidifier_160.png", + "cid": "abcdefghabcdefghabcdefghabcdefgh", + "deviceStatus": "off", + "connectionStatus": "online", + "connectionType": "WiFi+BTOnboarding+BTNotify", + "deviceType": "LUH-A602S-WUS", + "type": "wifi-air", + "uuid": "00000000-1111-2222-3333-444444444444", + "configModule": "WFON_AHM_LUH-A602S-WUS_US", + "macID": "00:00:00:00:00:00", + "mode": null, + "speed": null, + "currentFirmVersion": null, + "subDeviceNo": null, + "subDeviceType": null, + "deviceFirstSetupTime": "Jan 24, 2022 12:09:01 AM", + "subDeviceList": null, + "extension": null, + "deviceProp": null + } + ] + } +} diff --git a/tests/components/vesync/fixtures/vesync_api_call__login.json b/tests/components/vesync/fixtures/vesync_api_call__login.json new file mode 100644 index 0000000000..4a956f6734 --- /dev/null +++ b/tests/components/vesync/fixtures/vesync_api_call__login.json @@ -0,0 +1,9 @@ +{ + "traceId": "0000000000", + "code": 0, + "msg": "request success", + "result": { + "accountID": "9999999", + "token": "TOKEN" + } +} diff --git a/tests/components/vesync/snapshots/test_diagnostics.ambr b/tests/components/vesync/snapshots/test_diagnostics.ambr new file mode 100644 index 0000000000..33378d7ccd --- /dev/null +++ b/tests/components/vesync/snapshots/test_diagnostics.ambr @@ -0,0 +1,272 @@ +# serializer version: 1 +# name: test_async_get_config_entry_diagnostics__no_devices + dict({ + 'devices': dict({ + 'bulbs': list([ + ]), + 'fans': list([ + ]), + 'outlets': list([ + ]), + 'switches': list([ + ]), + }), + 'vesync': dict({ + 'bulb_count': 0, + 'fan_count': 0, + 'outlets_count': 0, + 'switch_count': 0, + 'timezone': 'US/Pacific', + }), + }) +# --- +# name: test_async_get_config_entry_diagnostics__single_humidifier + dict({ + 'devices': dict({ + 'bulbs': list([ + ]), + 'fans': list([ + dict({ + '_api_modes': list([ + 'getHumidifierStatus', + 'setAutomaticStop', + 'setSwitch', + 'setNightLightBrightness', + 'setVirtualLevel', + 'setTargetHumidity', + 'setHumidityMode', + 'setDisplay', + 'setLevel', + ]), + 'cid': 'abcdefghabcdefghabcdefghabcdefgh', + 'config': dict({ + 'auto_target_humidity': 60, + 'automatic_stop': True, + 'display': True, + }), + 'config_dict': dict({ + 'features': list([ + 'warm_mist', + 'nightlight', + ]), + 'mist_levels': list([ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + ]), + 'mist_modes': list([ + 'humidity', + 'sleep', + 'manual', + ]), + 'models': list([ + 'LUH-A602S-WUSR', + 'LUH-A602S-WUS', + 'LUH-A602S-WEUR', + 'LUH-A602S-WEU', + 'LUH-A602S-WJP', + ]), + 'module': 'VeSyncHumid200300S', + 'warm_mist_levels': list([ + 0, + 1, + 2, + 3, + ]), + }), + 'config_module': 'WFON_AHM_LUH-A602S-WUS_US', + 'connection_status': 'online', + 'connection_type': 'WiFi+BTOnboarding+BTNotify', + 'current_firm_version': None, + 'details': dict({ + 'automatic_stop_reach_target': True, + 'display': False, + 'humidity': 35, + 'humidity_high': False, + 'mist_level': 3, + 'mist_virtual_level': 9, + 'mode': 'humidity', + 'night_light_brightness': 0, + 'warm_mist_enabled': False, + 'warm_mist_level': 0, + 'water_lacks': False, + 'water_tank_lifted': False, + }), + 'device_image': 'https://image.vesync.com/defaultImages/LV_600S_Series/icon_lv600s_humidifier_160.png', + 'device_name': 'Humidifier', + 'device_status': 'off', + 'device_type': 'LUH-A602S-WUS', + 'enabled': False, + 'extension': None, + 'features': list([ + 'warm_mist', + 'nightlight', + ]), + 'mac_id': '**REDACTED**', + 'manager': '**REDACTED**', + 'mist_levels': list([ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + ]), + 'mist_modes': list([ + 'humidity', + 'sleep', + 'manual', + ]), + 'mode': None, + 'night_light': True, + 'speed': None, + 'sub_device_no': None, + 'type': 'wifi-air', + 'uuid': '**REDACTED**', + 'warm_mist_feature': True, + 'warm_mist_levels': list([ + 0, + 1, + 2, + 3, + ]), + }), + ]), + 'outlets': list([ + ]), + 'switches': list([ + ]), + }), + 'vesync': dict({ + 'bulb_count': 0, + 'fan_count': 1, + 'outlets_count': 0, + 'switch_count': 0, + 'timezone': 'US/Pacific', + }), + }) +# --- +# name: test_async_get_device_diagnostics__single_fan + dict({ + 'cid': 'abcdefghabcdefghabcdefghabcdefgh', + 'config': dict({ + }), + 'config_module': 'WFON_AHM_LV-PUR131S_US', + 'connection_status': 'unknown', + 'connection_type': 'WiFi+BTOnboarding+BTNotify', + 'current_firm_version': None, + 'details': dict({ + 'active_time': 0, + 'air_quality': 'unknown', + 'filter_life': dict({ + }), + 'level': 0, + 'screen_status': 'unknown', + }), + 'device_image': '', + 'device_name': 'Fan', + 'device_status': 'unknown', + 'device_type': 'LV-PUR131S', + 'extension': None, + 'home_assistant': dict({ + 'disabled': False, + 'disabled_by': None, + 'entities': list([ + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.fan', + 'icon': None, + 'name': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fan', + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Fan', + 'preset_modes': list([ + 'auto', + 'sleep', + ]), + 'supported_features': 1, + }), + 'entity_id': 'fan.fan', + 'last_changed': str, + 'last_updated': str, + 'state': 'unavailable', + }), + 'unit_of_measurement': None, + }), + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': 'diagnostic', + 'entity_id': 'sensor.fan_filter_life', + 'icon': None, + 'name': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fan Filter Life', + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Fan Filter Life', + 'state_class': 'measurement', + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.fan_filter_life', + 'last_changed': str, + 'last_updated': str, + 'state': 'unavailable', + }), + 'unit_of_measurement': '%', + }), + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fan_air_quality', + 'icon': None, + 'name': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fan Air Quality', + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Fan Air Quality', + }), + 'entity_id': 'sensor.fan_air_quality', + 'last_changed': str, + 'last_updated': str, + 'state': 'unavailable', + }), + 'unit_of_measurement': None, + }), + ]), + 'name': 'Fan', + 'name_by_user': None, + }), + 'mac_id': '**REDACTED**', + 'manager': '**REDACTED**', + 'mode': None, + 'speed': None, + 'sub_device_no': None, + 'type': 'wifi-air', + 'uuid': '**REDACTED**', + }) +# --- diff --git a/tests/components/vesync/test_diagnostics.py b/tests/components/vesync/test_diagnostics.py new file mode 100644 index 0000000000..eb802bb41b --- /dev/null +++ b/tests/components/vesync/test_diagnostics.py @@ -0,0 +1,99 @@ +"""Tests for the diagnostics data provided by the VeSync integration.""" +from unittest.mock import patch + +from pyvesync.helpers import Helpers +from syrupy import SnapshotAssertion +from syrupy.matchers import path_type + +from homeassistant.components.vesync.const import DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.typing import ConfigType +from homeassistant.setup import async_setup_component + +from .common import ( + call_api_side_effect__no_devices, + call_api_side_effect__single_fan, + call_api_side_effect__single_humidifier, +) + +from tests.components.diagnostics import ( + get_diagnostics_for_config_entry, + get_diagnostics_for_device, +) +from tests.typing import ClientSessionGenerator + + +async def test_async_get_config_entry_diagnostics__no_devices( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: ConfigEntry, + config: ConfigType, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for config entry.""" + with patch.object(Helpers, "call_api") as call_api: + call_api.side_effect = call_api_side_effect__no_devices + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + assert isinstance(diag, dict) + assert diag == snapshot + + +async def test_async_get_config_entry_diagnostics__single_humidifier( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: ConfigEntry, + config: ConfigType, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for config entry.""" + with patch.object(Helpers, "call_api") as call_api: + call_api.side_effect = call_api_side_effect__single_humidifier + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + assert isinstance(diag, dict) + assert diag == snapshot + + +async def test_async_get_device_diagnostics__single_fan( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: ConfigEntry, + config: ConfigType, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for config entry.""" + with patch.object(Helpers, "call_api") as call_api: + call_api.side_effect = call_api_side_effect__single_fan + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device( + identifiers={(DOMAIN, "abcdefghabcdefghabcdefghabcdefgh")}, + ) + assert device is not None + + diag = await get_diagnostics_for_device(hass, hass_client, config_entry, device) + + assert isinstance(diag, dict) + assert diag == snapshot( + matcher=path_type( + { + "home_assistant.entities.0.state.last_changed": (str,), + "home_assistant.entities.0.state.last_updated": (str,), + "home_assistant.entities.1.state.last_changed": (str,), + "home_assistant.entities.1.state.last_updated": (str,), + "home_assistant.entities.2.state.last_changed": (str,), + "home_assistant.entities.2.state.last_updated": (str,), + } + ) + ) diff --git a/tests/components/vesync/test_init.py b/tests/components/vesync/test_init.py new file mode 100644 index 0000000000..0f77c9cbf3 --- /dev/null +++ b/tests/components/vesync/test_init.py @@ -0,0 +1,103 @@ +"""Tests for the init module.""" +from unittest.mock import Mock, patch + +import pytest +from pyvesync import VeSync + +from homeassistant.components.vesync import async_setup_entry +from homeassistant.components.vesync.const import ( + DOMAIN, + VS_FANS, + VS_LIGHTS, + VS_MANAGER, + VS_SENSORS, + VS_SWITCHES, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + + +async def test_async_setup_entry__not_login( + hass: HomeAssistant, + config_entry: ConfigEntry, + manager: VeSync, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setup does not create config entry when not logged in.""" + manager.login = Mock(return_value=False) + + with patch.object( + hass.config_entries, "async_forward_entry_setups" + ) as setups_mock, patch.object( + hass.config_entries, "async_forward_entry_setup" + ) as setup_mock, patch( + "homeassistant.components.vesync.async_process_devices" + ) as process_mock, patch.object( + hass.services, "async_register" + ) as register_mock: + assert not await async_setup_entry(hass, config_entry) + await hass.async_block_till_done() + assert setups_mock.call_count == 0 + assert setup_mock.call_count == 0 + assert process_mock.call_count == 0 + assert register_mock.call_count == 0 + + assert manager.login.call_count == 1 + assert DOMAIN not in hass.data + assert "Unable to login to the VeSync server" in caplog.text + + +async def test_async_setup_entry__no_devices( + hass: HomeAssistant, config_entry: ConfigEntry, manager: VeSync +) -> None: + """Test setup connects to vesync and creates empty config when no devices.""" + with patch.object( + hass.config_entries, "async_forward_entry_setups" + ) as setups_mock, patch.object( + hass.config_entries, "async_forward_entry_setup" + ) as setup_mock: + assert await async_setup_entry(hass, config_entry) + # Assert platforms loaded + await hass.async_block_till_done() + assert setups_mock.call_count == 1 + assert setups_mock.call_args.args[0] == config_entry + assert setups_mock.call_args.args[1] == [] + assert setup_mock.call_count == 0 + + assert manager.login.call_count == 1 + assert hass.data[DOMAIN][VS_MANAGER] == manager + assert not hass.data[DOMAIN][VS_SWITCHES] + assert not hass.data[DOMAIN][VS_FANS] + assert not hass.data[DOMAIN][VS_LIGHTS] + assert not hass.data[DOMAIN][VS_SENSORS] + + +async def test_async_setup_entry__loads_fans( + hass: HomeAssistant, config_entry: ConfigEntry, manager: VeSync, fan +) -> None: + """Test setup connects to vesync and loads fan platform.""" + fans = [fan] + manager.fans = fans + manager._dev_list = { + "fans": fans, + } + + with patch.object( + hass.config_entries, "async_forward_entry_setups" + ) as setups_mock, patch.object( + hass.config_entries, "async_forward_entry_setup" + ) as setup_mock: + assert await async_setup_entry(hass, config_entry) + # Assert platforms loaded + await hass.async_block_till_done() + assert setups_mock.call_count == 1 + assert setups_mock.call_args.args[0] == config_entry + assert setups_mock.call_args.args[1] == [Platform.FAN, Platform.SENSOR] + assert setup_mock.call_count == 0 + assert manager.login.call_count == 1 + assert hass.data[DOMAIN][VS_MANAGER] == manager + assert not hass.data[DOMAIN][VS_SWITCHES] + assert hass.data[DOMAIN][VS_FANS] == [fan] + assert not hass.data[DOMAIN][VS_LIGHTS] + assert hass.data[DOMAIN][VS_SENSORS] == [fan] From 9f3f71d0c3a3af9484e7cab6e7288a98b5855f70 Mon Sep 17 00:00:00 2001 From: Volker Stolz Date: Wed, 1 Mar 2023 03:52:45 +0100 Subject: [PATCH 0530/2810] 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 3e8b7bbe39..f5a954b16d 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 0531/2810] 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 dca19c802b..3da1fd841f 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 206e575b7a..b837cf8dfe 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 c9961f8e2f..19b597fda8 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 c7276ac4cb..7d527b98a9 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 0532/2810] 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 35d7f624c5..40316a6ba3 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 19b597fda8..b5b01bb3c0 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 7d527b98a9..aa5693e93d 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 0533/2810] 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 a5bb332733..018e8ab813 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 b5b01bb3c0..a947cb37df 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 aa5693e93d..516759e099 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 0534/2810] 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 8f58587939..3c5a830698 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 0535/2810] 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 114c71f53c..28c19f9acd 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 5e4e2e8902..7630eed01f 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 16b1969f61..4a00b05d21 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 a947cb37df..1bd057d5a5 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 516759e099..a5414ce3f0 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 9ea95231b2..7211f2bb9b 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 0536/2810] 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 2a31d750fc..879cce9eac 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 2c387e8b64..641dda0257 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 9fc6700c5acd0601ff2db792463ffa1d0891b66c Mon Sep 17 00:00:00 2001 From: Marius Stedjan Date: Wed, 1 Mar 2023 06:47:47 +0100 Subject: [PATCH 0537/2810] Add ZWaveDiscoverySchema for Merten 507801 (#88342) * Add ZWaveDiscoverySchema for Merten 507801 * Add discovery tests to Merten 507801 z-wave device * Add Z-Wave discovery schemas for Merten 507801 to disable endpoint 2 by default * Add more discovery tests for Merten 507801 z-wave device --- .../components/zwave_js/discovery.py | 47 ++ tests/components/zwave_js/conftest.py | 14 + .../fixtures/cover_merten_507801_state.json | 798 ++++++++++++++++++ tests/components/zwave_js/test_discovery.py | 39 + 4 files changed, 898 insertions(+) create mode 100644 tests/components/zwave_js/fixtures/cover_merten_507801_state.json diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index fa0c3dc13d..5dfab3077e 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -390,6 +390,53 @@ DISCOVERY_SCHEMAS = [ product_type={0x0003}, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, ), + # Merten 507801 Connect Roller Shutter + ZWaveDiscoverySchema( + platform=Platform.COVER, + hint="window_shutter", + manufacturer_id={0x007A}, + product_id={0x0001}, + product_type={0x8003}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SWITCH_MULTILEVEL}, + property={CURRENT_VALUE_PROPERTY}, + endpoint={0, 1}, + type={ValueType.NUMBER}, + ), + assumed_state=True, + ), + # Merten 507801 Connect Roller Shutter. + # Disable endpoint 2, as it has no practical function. CC: Switch_Multilevel + ZWaveDiscoverySchema( + platform=Platform.COVER, + hint="window_shutter", + manufacturer_id={0x007A}, + product_id={0x0001}, + product_type={0x8003}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SWITCH_MULTILEVEL}, + property={CURRENT_VALUE_PROPERTY}, + endpoint={2}, + type={ValueType.NUMBER}, + ), + assumed_state=True, + entity_registry_enabled_default=False, + ), + # Merten 507801 Connect Roller Shutter. + # Disable endpoint 2, as it has no practical function. CC: Protection + ZWaveDiscoverySchema( + platform=Platform.SELECT, + manufacturer_id={0x007A}, + product_id={0x0001}, + product_type={0x8003}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.PROTECTION}, + property={LOCAL_PROPERTY, RF_PROPERTY}, + endpoint={2}, + type={ValueType.NUMBER}, + ), + entity_registry_enabled_default=False, + ), # Vision Security ZL7432 In Wall Dual Relay Switch ZWaveDiscoverySchema( platform=Platform.SWITCH, diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 1f332f04fd..f20c814bdc 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -458,6 +458,12 @@ def fibaro_fgr222_shutter_state_fixture(): return json.loads(load_fixture("zwave_js/cover_fibaro_fgr222_state.json")) +@pytest.fixture(name="merten_507801_state", scope="session") +def merten_507801_state_fixture(): + """Load the Merten 507801 Shutter node state fixture data.""" + return json.loads(load_fixture("zwave_js/cover_merten_507801_state.json")) + + @pytest.fixture(name="aeon_smart_switch_6_state", scope="session") def aeon_smart_switch_6_state_fixture(): """Load the AEON Labs (ZW096) Smart Switch 6 node state fixture data.""" @@ -952,6 +958,14 @@ def fibaro_fgr222_shutter_cover_fixture(client, fibaro_fgr222_shutter_state): return node +@pytest.fixture(name="merten_507801") +def merten_507801_cover_fixture(client, merten_507801_state): + """Mock a Merten 507801 Shutter node.""" + node = Node(client, copy.deepcopy(merten_507801_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="aeon_smart_switch_6") def aeon_smart_switch_6_fixture(client, aeon_smart_switch_6_state): """Mock an AEON Labs (ZW096) Smart Switch 6 node.""" diff --git a/tests/components/zwave_js/fixtures/cover_merten_507801_state.json b/tests/components/zwave_js/fixtures/cover_merten_507801_state.json new file mode 100644 index 0000000000..f7b8dbd8c7 --- /dev/null +++ b/tests/components/zwave_js/fixtures/cover_merten_507801_state.json @@ -0,0 +1,798 @@ +{ + "nodeId": 41, + "index": 0, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": false, + "manufacturerId": 122, + "productId": 1, + "productType": 32771, + "firmwareVersion": "2.2", + "deviceConfig": { + "filename": "/opt/node_modules/@zwave-js/config/config/devices/0x007a/507801.json", + "isEmbedded": false, + "manufacturer": "Merten", + "manufacturerId": 122, + "label": "507801", + "description": "Connect Roller Shutter", + "devices": [ + { + "productType": 32771, + "productId": 1 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "associations": {}, + "paramInformation": { + "_map": {} + }, + "metadata": { + "inclusion": "Triple click button", + "exclusion": "Triple click button", + "reset": "Triple click button, then click and hold for 5 seconds", + "manual": "https://download.schneider-electric.com/files?p_Doc_Ref=MTN507801_HW_2008_43_EN&p_enDocType=User+guide&p_File_Name=MTN507801_HW_2008_43_EN.pdf" + } + }, + "label": "507801", + "endpointCountIsDynamic": false, + "endpointsHaveIdenticalCapabilities": true, + "individualEndpointCount": 2, + "interviewAttempts": 1, + "endpoints": [ + { + "nodeId": 41, + "index": 0, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 9, + "label": "Window Covering" + }, + "specific": { + "key": 0, + "label": "Unused" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 114, + "name": "Manufacturer Specific", + "version": 1, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 1, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 2, + "isSecure": false + }, + { + "id": 96, + "name": "Multi Channel", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 1, + "isSecure": false + }, + { + "id": 38, + "name": "Multilevel Switch", + "version": 1, + "isSecure": false + }, + { + "id": 117, + "name": "Protection", + "version": 2, + "isSecure": false + } + ] + }, + { + "nodeId": 41, + "index": 1, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 9, + "label": "Window Covering" + }, + "specific": { + "key": 0, + "label": "Unused" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 114, + "name": "Manufacturer Specific", + "version": 1, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 1, + "isSecure": false + }, + { + "id": 38, + "name": "Multilevel Switch", + "version": 1, + "isSecure": false + }, + { + "id": 117, + "name": "Protection", + "version": 2, + "isSecure": false + } + ] + }, + { + "nodeId": 41, + "index": 2, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 9, + "label": "Window Covering" + }, + "specific": { + "key": 0, + "label": "Unused" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 114, + "name": "Manufacturer Specific", + "version": 1, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 1, + "isSecure": false + }, + { + "id": 38, + "name": "Multilevel Switch", + "version": 1, + "isSecure": false + }, + { + "id": 117, + "name": "Protection", + "version": 2, + "isSecure": false + } + ] + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 176, + "propertyName": "Changeover Delay", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "For motor protection", + "label": "Changeover Delay", + "default": 10, + "min": 0, + "max": 255, + "unit": "0.1 seconds", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 177, + "propertyName": "Travel Time Up, Byte 1", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Travel Time Up, Byte 1", + "default": 4, + "min": 0, + "max": 255, + "unit": "25.6 seconds", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 4 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 178, + "propertyName": "Travel Time Up, Byte 2", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Travel Time Up, Byte 2", + "default": 176, + "min": 0, + "max": 255, + "unit": "0.1 seconds", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 176 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 179, + "propertyName": "Travel Time Down, Byte 1", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Travel Time Down, Byte 1", + "default": 4, + "min": 0, + "max": 255, + "unit": "25.6 seconds", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 4 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 180, + "propertyName": "Travel Time Down, Byte 2", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Travel Time Down, Byte 2", + "default": 176, + "min": 0, + "max": 255, + "unit": "0.1 seconds", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 176 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535 + }, + "value": 122 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535 + }, + "value": 32771 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535 + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + } + }, + "value": 6 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 1, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "2.27" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 1, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions" + }, + "value": ["2.2"] + }, + { + "endpoint": 1, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99 + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"] + } + }, + { + "endpoint": 1, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"] + } + }, + { + "endpoint": 1, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99 + } + }, + { + "endpoint": 1, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration" + } + }, + { + "endpoint": 1, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value" + } + }, + { + "endpoint": 1, + "commandClass": 117, + "commandClassName": "Protection", + "property": "local", + "propertyName": "local", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Local protection state", + "states": { + "0": "Unprotected", + "2": "NoOperationPossible" + } + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 117, + "commandClassName": "Protection", + "property": "rf", + "propertyName": "rf", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "RF protection state", + "states": { + "0": "Unprotected", + "1": "NoControl" + } + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 117, + "commandClassName": "Protection", + "property": "exclusiveControlNodeId", + "propertyName": "exclusiveControlNodeId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Node ID with exclusive control", + "min": 1, + "max": 232 + } + }, + { + "endpoint": 1, + "commandClass": 117, + "commandClassName": "Protection", + "property": "timeout", + "propertyName": "timeout", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "RF protection timeout", + "min": 0, + "max": 255 + } + }, + { + "endpoint": 2, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99 + }, + "value": 0 + }, + { + "endpoint": 2, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"] + } + }, + { + "endpoint": 2, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"] + } + }, + { + "endpoint": 2, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99 + } + }, + { + "endpoint": 2, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration" + } + }, + { + "endpoint": 2, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value" + } + }, + { + "endpoint": 2, + "commandClass": 117, + "commandClassName": "Protection", + "property": "local", + "propertyName": "local", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Local protection state", + "states": { + "0": "Unprotected", + "1": "ProtectedBySequence", + "2": "NoOperationPossible" + } + }, + "value": 2 + }, + { + "endpoint": 2, + "commandClass": 117, + "commandClassName": "Protection", + "property": "rf", + "propertyName": "rf", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "RF protection state", + "states": { + "0": "Unprotected", + "1": "NoControl", + "2": "NoResponse" + } + }, + "value": 1 + }, + { + "endpoint": 2, + "commandClass": 117, + "commandClassName": "Protection", + "property": "exclusiveControlNodeId", + "propertyName": "exclusiveControlNodeId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Node ID with exclusive control", + "min": 1, + "max": 232 + } + }, + { + "endpoint": 2, + "commandClass": 117, + "commandClassName": "Protection", + "property": "timeout", + "propertyName": "timeout", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "RF protection timeout", + "min": 0, + "max": 255 + } + } + ], + "isFrequentListening": false, + "maxDataRate": 9600, + "supportedDataRates": [9600], + "protocolVersion": 1, + "supportsBeaming": false, + "supportsSecurity": false, + "nodeType": 1, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 9, + "label": "Window Covering" + }, + "specific": { + "key": 0, + "label": "Unused" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x007a:0x8003:0x0001:2.2", + "highestSecurityClass": -1, + "isControllerNode": false, + "keepAwake": false +} diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 601947f009..66969c51ff 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -10,6 +10,7 @@ from homeassistant.components.zwave_js.discovery_data_template import ( DynamicCurrentTempClimateDataTemplate, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er async def test_iblinds_v2(hass: HomeAssistant, client, iblinds_v2, integration) -> None: @@ -100,3 +101,41 @@ async def test_dynamic_climate_data_discovery_template_failure( DynamicCurrentTempClimateDataTemplate().resolve_data( node.values[f"{node.node_id}-49-0-Ultraviolet"] ) + + +async def test_merten_507801(hass, client, merten_507801, integration): + """Test that Merten 507801 multilevel switch value is discovered as a cover.""" + node = merten_507801 + assert node.device_class.specific.label == "Unused" + + state = hass.states.get("light.connect_roller_shutter") + assert not state + + state = hass.states.get("cover.connect_roller_shutter") + assert state + + +async def test_merten_507801_disabled_enitites( + hass, client, merten_507801, integration +): + """Test that Merten 507801 entities created by endpoint 2 are disabled.""" + registry = er.async_get(hass) + entity_ids = [ + "cover.connect_roller_shutter_2", + "select.connect_roller_shutter_local_protection_state_2", + "select.connect_roller_shutter_rf_protection_state_2", + ] + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state is None + entry = registry.async_get(entity_id) + assert entry + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + # Test enabling entity + updated_entry = registry.async_update_entity( + entry.entity_id, **{"disabled_by": None} + ) + assert updated_entry != entry + assert updated_entry.disabled is False From 95dd62186e33910a7936220a52e281818e356406 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 07:43:33 +0100 Subject: [PATCH 0538/2810] Use json_loads_object in arwn (#88611) --- homeassistant/components/arwn/sensor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/arwn/sensor.py b/homeassistant/components/arwn/sensor.py index 420ffb2d8a..f03734e63d 100644 --- a/homeassistant/components/arwn/sensor.py +++ b/homeassistant/components/arwn/sensor.py @@ -1,7 +1,6 @@ """Support for collecting data from the ARWN project.""" from __future__ import annotations -import json import logging from homeassistant.components import mqtt @@ -11,6 +10,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import slugify +from homeassistant.util.json import json_loads_object _LOGGER = logging.getLogger(__name__) @@ -102,7 +102,7 @@ async def async_setup_platform( """Set up the ARWN platform.""" @callback - def async_sensor_event_received(msg): + def async_sensor_event_received(msg: mqtt.ReceiveMessage) -> None: """Process events as sensors. When a new event on our topic (arwn/#) is received we map it @@ -115,7 +115,7 @@ async def async_setup_platform( This lets us dynamically incorporate sensors without any configuration on our side. """ - event = json.loads(msg.payload) + event = json_loads_object(msg.payload) sensors = discover_sensors(msg.topic, event) if not sensors: return From 50f908ce2d0d4bdce892c4f451f918cc1a824fce Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 07:44:29 +0100 Subject: [PATCH 0539/2810] Use load_json_object in fitbit (#88585) * Use load_json_object in fitbit * Remove unnecessary cast --- homeassistant/components/fitbit/sensor.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index d703699a43..c53c01c84a 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -27,7 +27,7 @@ from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.json import save_json from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util.json import load_json +from homeassistant.util.json import load_json_object from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ( @@ -85,7 +85,7 @@ def request_app_setup( """Handle configuration updates.""" config_path = hass.config.path(FITBIT_CONFIG_FILE) if os.path.isfile(config_path): - config_file = load_json(config_path) + config_file = load_json_object(config_path) if config_file == DEFAULT_CONFIG: error_msg = ( f"You didn't correctly modify {FITBIT_CONFIG_FILE}, please try" @@ -161,7 +161,7 @@ def setup_platform( """Set up the Fitbit sensor.""" config_path = hass.config.path(FITBIT_CONFIG_FILE) if os.path.isfile(config_path): - config_file: ConfigType = cast(ConfigType, load_json(config_path)) + config_file = load_json_object(config_path) if config_file == DEFAULT_CONFIG: request_app_setup( hass, config, add_entities, config_path, discovery_info=None @@ -175,13 +175,10 @@ def setup_platform( if "fitbit" in _CONFIGURING: configurator.request_done(hass, _CONFIGURING.pop("fitbit")) - access_token: str | None = config_file.get(ATTR_ACCESS_TOKEN) - refresh_token: str | None = config_file.get(ATTR_REFRESH_TOKEN) - expires_at: int | None = config_file.get(ATTR_LAST_SAVED_AT) if ( - access_token is not None - and refresh_token is not None - and expires_at is not None + (access_token := config_file.get(ATTR_ACCESS_TOKEN)) is not None + and (refresh_token := config_file.get(ATTR_REFRESH_TOKEN)) is not None + and (expires_at := config_file.get(ATTR_LAST_SAVED_AT)) is not None ): authd_client = Fitbit( config_file.get(CONF_CLIENT_ID), @@ -192,7 +189,7 @@ def setup_platform( refresh_cb=lambda x: None, ) - if int(time.time()) - expires_at > 3600: + if int(time.time()) - cast(int, expires_at) > 3600: authd_client.client.refresh_token() user_profile = authd_client.user_profile_get()["user"] From f2b736fad0bb1f9c2796551936efa3a54291dbdb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 08:02:16 +0100 Subject: [PATCH 0540/2810] Adjust entity registry access in core platforms (#88944) * Adjust entity registry access in platforms * Adjust more core components --- homeassistant/auth/permissions/models.py | 9 +++---- .../alarm_control_panel/device_action.py | 6 ++--- .../alarm_control_panel/device_condition.py | 10 +++++--- .../alarm_control_panel/device_trigger.py | 6 ++--- .../components/button/device_action.py | 6 ++--- .../components/button/device_trigger.py | 6 ++--- .../components/climate/device_action.py | 6 ++--- .../components/climate/device_condition.py | 10 +++++--- .../components/climate/device_trigger.py | 6 ++--- homeassistant/components/config/automation.py | 4 ++-- homeassistant/components/config/scene.py | 4 ++-- .../components/cover/device_action.py | 6 ++--- .../components/cover/device_condition.py | 10 +++++--- .../components/cover/device_trigger.py | 6 ++--- .../device_tracker/device_condition.py | 10 +++++--- .../device_tracker/device_trigger.py | 6 ++--- .../components/fan/device_condition.py | 10 +++++--- .../components/humidifier/device_action.py | 6 ++--- .../components/humidifier/device_condition.py | 10 +++++--- .../components/humidifier/device_trigger.py | 6 ++--- .../components/lock/device_action.py | 6 ++--- .../components/lock/device_condition.py | 10 +++++--- .../components/lock/device_trigger.py | 6 ++--- .../media_player/device_condition.py | 10 +++++--- .../components/media_player/device_trigger.py | 6 ++--- .../components/number/device_action.py | 6 ++--- homeassistant/components/person/__init__.py | 4 ++-- .../components/recorder/statistics.py | 4 ++-- homeassistant/components/search/__init__.py | 24 ++++++++----------- .../components/select/device_action.py | 6 ++--- .../components/select/device_condition.py | 10 +++++--- .../components/select/device_trigger.py | 6 ++--- .../components/text/device_action.py | 6 ++--- .../components/vacuum/device_action.py | 6 ++--- .../components/vacuum/device_condition.py | 10 +++++--- .../components/vacuum/device_trigger.py | 6 ++--- .../components/water_heater/device_action.py | 6 ++--- 37 files changed, 154 insertions(+), 121 deletions(-) diff --git a/homeassistant/auth/permissions/models.py b/homeassistant/auth/permissions/models.py index aa1a777ced..9b9c384c74 100644 --- a/homeassistant/auth/permissions/models.py +++ b/homeassistant/auth/permissions/models.py @@ -6,15 +6,12 @@ from typing import TYPE_CHECKING import attr if TYPE_CHECKING: - from homeassistant.helpers import ( - device_registry as dev_reg, - entity_registry as ent_reg, - ) + from homeassistant.helpers import device_registry as dr, entity_registry as er @attr.s(slots=True) class PermissionLookup: """Class to hold data for permission lookups.""" - entity_registry: ent_reg.EntityRegistry = attr.ib() - device_registry: dev_reg.DeviceRegistry = attr.ib() + entity_registry: er.EntityRegistry = attr.ib() + device_registry: dr.DeviceRegistry = attr.ib() diff --git a/homeassistant/components/alarm_control_panel/device_action.py b/homeassistant/components/alarm_control_panel/device_action.py index dd0c3d03a4..de4f3df257 100644 --- a/homeassistant/components/alarm_control_panel/device_action.py +++ b/homeassistant/components/alarm_control_panel/device_action.py @@ -21,7 +21,7 @@ from homeassistant.const import ( SERVICE_ALARM_TRIGGER, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -57,11 +57,11 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for Alarm control panel devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) actions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/alarm_control_panel/device_condition.py b/homeassistant/components/alarm_control_panel/device_condition.py index 4764d5cfcb..a097aa9853 100644 --- a/homeassistant/components/alarm_control_panel/device_condition.py +++ b/homeassistant/components/alarm_control_panel/device_condition.py @@ -21,7 +21,11 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -64,11 +68,11 @@ async def async_get_conditions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device conditions for Alarm control panel devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) conditions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/alarm_control_panel/device_trigger.py b/homeassistant/components/alarm_control_panel/device_trigger.py index 303243d66c..9106942c5e 100644 --- a/homeassistant/components/alarm_control_panel/device_trigger.py +++ b/homeassistant/components/alarm_control_panel/device_trigger.py @@ -23,7 +23,7 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -57,11 +57,11 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Alarm control panel devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) triggers: list[dict[str, str]] = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/button/device_action.py b/homeassistant/components/button/device_action.py index 7003372969..8398b4990c 100644 --- a/homeassistant/components/button/device_action.py +++ b/homeassistant/components/button/device_action.py @@ -11,7 +11,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -31,7 +31,7 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for button devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) return [ { CONF_DEVICE_ID: device_id, @@ -39,7 +39,7 @@ async def async_get_actions( CONF_ENTITY_ID: entry.entity_id, CONF_TYPE: "press", } - for entry in entity_registry.async_entries_for_device(registry, device_id) + for entry in er.async_entries_for_device(registry, device_id) if entry.domain == DOMAIN ] diff --git a/homeassistant/components/button/device_trigger.py b/homeassistant/components/button/device_trigger.py index 673806be7d..fbf054996c 100644 --- a/homeassistant/components/button/device_trigger.py +++ b/homeassistant/components/button/device_trigger.py @@ -16,7 +16,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -36,7 +36,7 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for button devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) return [ { CONF_PLATFORM: "device", @@ -45,7 +45,7 @@ async def async_get_triggers( CONF_ENTITY_ID: entry.entity_id, CONF_TYPE: "pressed", } - for entry in entity_registry.async_entries_for_device(registry, device_id) + for entry in er.async_entries_for_device(registry, device_id) if entry.domain == DOMAIN ] diff --git a/homeassistant/components/climate/device_action.py b/homeassistant/components/climate/device_action.py index 3c9934d5cb..0119ad6580 100644 --- a/homeassistant/components/climate/device_action.py +++ b/homeassistant/components/climate/device_action.py @@ -12,7 +12,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import get_capability, get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -44,11 +44,11 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for Climate devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) actions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/climate/device_condition.py b/homeassistant/components/climate/device_condition.py index c6179d8221..97dc27cfa0 100644 --- a/homeassistant/components/climate/device_condition.py +++ b/homeassistant/components/climate/device_condition.py @@ -13,7 +13,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.entity import get_capability, get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -45,11 +49,11 @@ async def async_get_conditions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device conditions for Climate devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) conditions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/climate/device_trigger.py b/homeassistant/components/climate/device_trigger.py index 0b0bedb49b..005e744b53 100644 --- a/homeassistant/components/climate/device_trigger.py +++ b/homeassistant/components/climate/device_trigger.py @@ -20,7 +20,7 @@ from homeassistant.const import ( PERCENTAGE, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -62,11 +62,11 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Climate devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) triggers = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/config/automation.py b/homeassistant/components/config/automation.py index 5a39b786e2..72a493f8c1 100644 --- a/homeassistant/components/config/automation.py +++ b/homeassistant/components/config/automation.py @@ -8,7 +8,7 @@ from homeassistant.components.automation.config import ( ) from homeassistant.config import AUTOMATION_CONFIG_PATH from homeassistant.const import CONF_ID, SERVICE_RELOAD -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from . import ACTION_DELETE, EditIdBasedConfigView @@ -23,7 +23,7 @@ async def async_setup(hass): if action != ACTION_DELETE: return - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) entity_id = ent_reg.async_get_entity_id(DOMAIN, DOMAIN, config_key) diff --git a/homeassistant/components/config/scene.py b/homeassistant/components/config/scene.py index befbfd052a..037cd55d6a 100644 --- a/homeassistant/components/config/scene.py +++ b/homeassistant/components/config/scene.py @@ -5,7 +5,7 @@ from homeassistant.components.scene import DOMAIN, PLATFORM_SCHEMA from homeassistant.config import SCENE_CONFIG_PATH from homeassistant.const import CONF_ID, SERVICE_RELOAD from homeassistant.core import DOMAIN as HA_DOMAIN -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from . import ACTION_DELETE, EditIdBasedConfigView @@ -19,7 +19,7 @@ async def async_setup(hass): await hass.services.async_call(DOMAIN, SERVICE_RELOAD) return - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) entity_id = ent_reg.async_get_entity_id(DOMAIN, HA_DOMAIN, config_key) diff --git a/homeassistant/components/cover/device_action.py b/homeassistant/components/cover/device_action.py index c3c0e928f0..9b2bb05bb0 100644 --- a/homeassistant/components/cover/device_action.py +++ b/homeassistant/components/cover/device_action.py @@ -18,7 +18,7 @@ from homeassistant.const import ( SERVICE_STOP_COVER, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -63,11 +63,11 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for Cover devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) actions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/cover/device_condition.py b/homeassistant/components/cover/device_condition.py index bb66d54b79..6144bdb6db 100644 --- a/homeassistant/components/cover/device_condition.py +++ b/homeassistant/components/cover/device_condition.py @@ -18,7 +18,11 @@ from homeassistant.const import ( STATE_OPENING, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -66,11 +70,11 @@ async def async_get_conditions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device conditions for Cover devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) conditions: list[dict[str, str]] = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/cover/device_trigger.py b/homeassistant/components/cover/device_trigger.py index b0be418f31..aad225c803 100644 --- a/homeassistant/components/cover/device_trigger.py +++ b/homeassistant/components/cover/device_trigger.py @@ -24,7 +24,7 @@ from homeassistant.const import ( STATE_OPENING, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -71,11 +71,11 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Cover devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) triggers = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/device_tracker/device_condition.py b/homeassistant/components/device_tracker/device_condition.py index 1a6adabda6..96ee70baca 100644 --- a/homeassistant/components/device_tracker/device_condition.py +++ b/homeassistant/components/device_tracker/device_condition.py @@ -13,7 +13,11 @@ from homeassistant.const import ( STATE_HOME, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -33,11 +37,11 @@ async def async_get_conditions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device conditions for Device tracker devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) conditions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/device_tracker/device_trigger.py b/homeassistant/components/device_tracker/device_trigger.py index 231fab65d3..150b587227 100644 --- a/homeassistant/components/device_tracker/device_trigger.py +++ b/homeassistant/components/device_tracker/device_trigger.py @@ -17,7 +17,7 @@ from homeassistant.const import ( CONF_ZONE, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -38,11 +38,11 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Device Tracker devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) triggers = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/fan/device_condition.py b/homeassistant/components/fan/device_condition.py index 7e27ea29f9..d4bd5f2e41 100644 --- a/homeassistant/components/fan/device_condition.py +++ b/homeassistant/components/fan/device_condition.py @@ -14,7 +14,11 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -34,11 +38,11 @@ async def async_get_conditions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device conditions for Fan devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) conditions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/humidifier/device_action.py b/homeassistant/components/humidifier/device_action.py index 773caa72f9..1c027ba22e 100644 --- a/homeassistant/components/humidifier/device_action.py +++ b/homeassistant/components/humidifier/device_action.py @@ -14,7 +14,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import get_capability, get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -48,11 +48,11 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for Humidifier devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) actions = await toggle_entity.async_get_actions(hass, device_id, DOMAIN) # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/humidifier/device_condition.py b/homeassistant/components/humidifier/device_condition.py index 949b25fdd1..05812e35a3 100644 --- a/homeassistant/components/humidifier/device_condition.py +++ b/homeassistant/components/humidifier/device_condition.py @@ -15,7 +15,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.entity import get_capability, get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -41,11 +45,11 @@ async def async_get_conditions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device conditions for Humidifier devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) conditions = await toggle_entity.async_get_conditions(hass, device_id, DOMAIN) # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/humidifier/device_trigger.py b/homeassistant/components/humidifier/device_trigger.py index ed1620c51a..5fbb248a8b 100644 --- a/homeassistant/components/humidifier/device_trigger.py +++ b/homeassistant/components/humidifier/device_trigger.py @@ -22,7 +22,7 @@ from homeassistant.const import ( PERCENTAGE, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -56,11 +56,11 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Humidifier devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) triggers = await toggle_entity.async_get_triggers(hass, device_id, DOMAIN) # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/lock/device_action.py b/homeassistant/components/lock/device_action.py index 3ff8d10c7a..01e7b21d4b 100644 --- a/homeassistant/components/lock/device_action.py +++ b/homeassistant/components/lock/device_action.py @@ -14,7 +14,7 @@ from homeassistant.const import ( SERVICE_UNLOCK, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -35,11 +35,11 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for Lock devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) actions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/lock/device_condition.py b/homeassistant/components/lock/device_condition.py index cdaa02de61..c439fe99d1 100644 --- a/homeassistant/components/lock/device_condition.py +++ b/homeassistant/components/lock/device_condition.py @@ -17,7 +17,11 @@ from homeassistant.const import ( STATE_UNLOCKING, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -45,11 +49,11 @@ async def async_get_conditions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device conditions for Lock devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) conditions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/lock/device_trigger.py b/homeassistant/components/lock/device_trigger.py index 9fc35fb135..ec996d4f0b 100644 --- a/homeassistant/components/lock/device_trigger.py +++ b/homeassistant/components/lock/device_trigger.py @@ -19,7 +19,7 @@ from homeassistant.const import ( STATE_UNLOCKING, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -40,11 +40,11 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Lock devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) triggers = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/media_player/device_condition.py b/homeassistant/components/media_player/device_condition.py index 3bf6c5956f..9e3981ed98 100644 --- a/homeassistant/components/media_player/device_condition.py +++ b/homeassistant/components/media_player/device_condition.py @@ -18,7 +18,11 @@ from homeassistant.const import ( STATE_PLAYING, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -45,11 +49,11 @@ async def async_get_conditions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device conditions for Media player devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) conditions: list[dict[str, str]] = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/media_player/device_trigger.py b/homeassistant/components/media_player/device_trigger.py index 9b61c89daf..58fc0aca84 100644 --- a/homeassistant/components/media_player/device_trigger.py +++ b/homeassistant/components/media_player/device_trigger.py @@ -23,7 +23,7 @@ from homeassistant.const import ( STATE_PLAYING, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -52,11 +52,11 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Media player entities.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) triggers = await entity.async_get_triggers(hass, device_id, DOMAIN) # Get all the integration entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/number/device_action.py b/homeassistant/components/number/device_action.py index e4311f50dd..971f8d5a51 100644 --- a/homeassistant/components/number/device_action.py +++ b/homeassistant/components/number/device_action.py @@ -11,7 +11,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -32,11 +32,11 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for Number.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) actions: list[dict[str, str]] = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index 523d21aa69..e3b719d166 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -42,7 +42,7 @@ from homeassistant.core import ( from homeassistant.helpers import ( collection, config_validation as cv, - entity_registry, + entity_registry as er, service, ) from homeassistant.helpers.entity_component import EntityComponent @@ -226,7 +226,7 @@ class PersonStorageCollection(collection.StorageCollection): """Load the Storage collection.""" await super().async_load() self.hass.bus.async_listen( - entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, self._entity_registry_updated + er.EVENT_ENTITY_REGISTRY_UPDATED, self._entity_registry_updated ) async def _entity_registry_updated(self, event) -> None: diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 294c521762..2a958d3b62 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -29,7 +29,7 @@ import voluptuous as vol from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import Event, HomeAssistant, callback, valid_entity_id from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers.start import async_at_start from homeassistant.helpers.storage import STORAGE_DIR @@ -338,7 +338,7 @@ def async_setup(hass: HomeAssistant) -> None: def setup_entity_registry_event_handler(hass: HomeAssistant) -> None: """Subscribe to event registry events.""" hass.bus.async_listen( - entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, + er.EVENT_ENTITY_REGISTRY_UPDATED, _async_entity_id_changed, event_filter=entity_registry_changed_filter, ) diff --git a/homeassistant/components/search/__init__.py b/homeassistant/components/search/__init__.py index 70702f351f..b574081d5d 100644 --- a/homeassistant/components/search/__init__.py +++ b/homeassistant/components/search/__init__.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.components import automation, group, person, script, websocket_api from homeassistant.components.homeassistant import scene from homeassistant.core import HomeAssistant, callback, split_entity_id -from homeassistant.helpers import device_registry, entity_registry +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity import entity_sources as get_entity_sources from homeassistant.helpers.typing import ConfigType @@ -53,8 +53,8 @@ def websocket_search_related( """Handle search.""" searcher = Searcher( hass, - device_registry.async_get(hass), - entity_registry.async_get(hass), + dr.async_get(hass), + er.async_get(hass), get_entity_sources(hass), ) connection.send_result( @@ -86,8 +86,8 @@ class Searcher: def __init__( self, hass: HomeAssistant, - device_reg: device_registry.DeviceRegistry, - entity_reg: entity_registry.EntityRegistry, + device_reg: dr.DeviceRegistry, + entity_reg: er.EntityRegistry, entity_sources: dict[str, dict[str, str]], ) -> None: """Search results.""" @@ -141,12 +141,10 @@ class Searcher: @callback def _resolve_area(self, area_id) -> None: """Resolve an area.""" - for device in device_registry.async_entries_for_area(self._device_reg, area_id): + for device in dr.async_entries_for_area(self._device_reg, area_id): self._add_or_resolve("device", device.id) - for entity_entry in entity_registry.async_entries_for_area( - self._entity_reg, area_id - ): + for entity_entry in er.async_entries_for_area(self._entity_reg, area_id): self._add_or_resolve("entity", entity_entry.entity_id) for entity_id in script.scripts_with_area(self.hass, area_id): @@ -178,12 +176,12 @@ class Searcher: Will only be called if config entry is an entry point. """ - for device_entry in device_registry.async_entries_for_config_entry( + for device_entry in dr.async_entries_for_config_entry( self._device_reg, config_entry_id ): self._add_or_resolve("device", device_entry.id) - for entity_entry in entity_registry.async_entries_for_config_entry( + for entity_entry in er.async_entries_for_config_entry( self._entity_reg, config_entry_id ): self._add_or_resolve("entity", entity_entry.entity_id) @@ -203,9 +201,7 @@ class Searcher: # We do not resolve device_entry.via_device_id because that # device is not related data-wise inside HA. - for entity_entry in entity_registry.async_entries_for_device( - self._entity_reg, device_id - ): + for entity_entry in er.async_entries_for_device(self._entity_reg, device_id): self._add_or_resolve("entity", entity_entry.entity_id) for entity_id in script.scripts_with_device(self.hass, device_id): diff --git a/homeassistant/components/select/device_action.py b/homeassistant/components/select/device_action.py index ce1cea89c9..d553cdf304 100644 --- a/homeassistant/components/select/device_action.py +++ b/homeassistant/components/select/device_action.py @@ -14,7 +14,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import get_capability from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -74,7 +74,7 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for Select devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) return [ { CONF_DEVICE_ID: device_id, @@ -89,7 +89,7 @@ async def async_get_actions( SERVICE_SELECT_OPTION, SERVICE_SELECT_PREVIOUS, ) - for entry in entity_registry.async_entries_for_device(registry, device_id) + for entry in er.async_entries_for_device(registry, device_id) if entry.domain == DOMAIN ] diff --git a/homeassistant/components/select/device_condition.py b/homeassistant/components/select/device_condition.py index 6e6a3c704b..13280ba4f0 100644 --- a/homeassistant/components/select/device_condition.py +++ b/homeassistant/components/select/device_condition.py @@ -13,7 +13,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.entity import get_capability from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -38,7 +42,7 @@ async def async_get_conditions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device conditions for Select devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) return [ { CONF_CONDITION: "device", @@ -47,7 +51,7 @@ async def async_get_conditions( CONF_ENTITY_ID: entry.entity_id, CONF_TYPE: "selected_option", } - for entry in entity_registry.async_entries_for_device(registry, device_id) + for entry in er.async_entries_for_device(registry, device_id) if entry.domain == DOMAIN ] diff --git a/homeassistant/components/select/device_trigger.py b/homeassistant/components/select/device_trigger.py index 897ed855a5..8e8267cb5e 100644 --- a/homeassistant/components/select/device_trigger.py +++ b/homeassistant/components/select/device_trigger.py @@ -20,7 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity import get_capability from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -44,7 +44,7 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Select devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) return [ { CONF_PLATFORM: "device", @@ -53,7 +53,7 @@ async def async_get_triggers( CONF_ENTITY_ID: entry.entity_id, CONF_TYPE: "current_option_changed", } - for entry in entity_registry.async_entries_for_device(registry, device_id) + for entry in er.async_entries_for_device(registry, device_id) if entry.domain == DOMAIN ] diff --git a/homeassistant/components/text/device_action.py b/homeassistant/components/text/device_action.py index 3d14da9bdb..89fbbc7fbc 100644 --- a/homeassistant/components/text/device_action.py +++ b/homeassistant/components/text/device_action.py @@ -11,7 +11,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -32,11 +32,11 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for Text.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) actions: list[dict[str, str]] = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/vacuum/device_action.py b/homeassistant/components/vacuum/device_action.py index e8fe53b08a..9b53c76124 100644 --- a/homeassistant/components/vacuum/device_action.py +++ b/homeassistant/components/vacuum/device_action.py @@ -11,7 +11,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -31,11 +31,11 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for Vacuum devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) actions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/vacuum/device_condition.py b/homeassistant/components/vacuum/device_condition.py index fa76dd800e..cf5b093466 100644 --- a/homeassistant/components/vacuum/device_condition.py +++ b/homeassistant/components/vacuum/device_condition.py @@ -12,7 +12,11 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -32,11 +36,11 @@ async def async_get_conditions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device conditions for Vacuum devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) conditions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/vacuum/device_trigger.py b/homeassistant/components/vacuum/device_trigger.py index c90aa1756e..6a2646922b 100644 --- a/homeassistant/components/vacuum/device_trigger.py +++ b/homeassistant/components/vacuum/device_trigger.py @@ -14,7 +14,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -35,11 +35,11 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Vacuum devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) triggers = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/water_heater/device_action.py b/homeassistant/components/water_heater/device_action.py index 6bc7e1ca63..8ae75527ab 100644 --- a/homeassistant/components/water_heater/device_action.py +++ b/homeassistant/components/water_heater/device_action.py @@ -13,7 +13,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -33,10 +33,10 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for Water Heater devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) actions = [] - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue From b3d6f098d2af00f9955a11deaedd4d636463d7ec Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 08:02:34 +0100 Subject: [PATCH 0541/2810] Adjust entity registry access in integrations (1) (#88946) --- homeassistant/components/rachio/entity.py | 4 ++-- homeassistant/components/rainmachine/util.py | 4 ++-- .../components/ruckus_unleashed/__init__.py | 9 ++++----- .../ruckus_unleashed/device_tracker.py | 4 ++-- homeassistant/components/sleepiq/entity.py | 4 ++-- homeassistant/components/sonos/speaker.py | 4 ++-- .../components/switcher_kis/__init__.py | 6 +++--- .../components/switcher_kis/button.py | 6 ++---- .../components/switcher_kis/climate.py | 6 ++---- homeassistant/components/switcher_kis/cover.py | 6 ++---- .../components/switcher_kis/sensor.py | 6 ++---- .../components/switcher_kis/switch.py | 6 ++---- .../components/tplink_omada/entity.py | 4 ++-- .../components/traccar/device_tracker.py | 5 ++--- homeassistant/components/tradfri/sensor.py | 4 ++-- homeassistant/components/upnp/__init__.py | 8 ++++---- homeassistant/components/velbus/__init__.py | 10 +++------- homeassistant/components/wiffi/__init__.py | 4 ++-- homeassistant/components/zwave_js/__init__.py | 18 +++++++++--------- .../components/zwave_js/device_action.py | 7 +++---- .../components/zwave_js/device_trigger.py | 8 ++++---- homeassistant/components/zwave_me/__init__.py | 7 +++---- 22 files changed, 61 insertions(+), 79 deletions(-) diff --git a/homeassistant/components/rachio/entity.py b/homeassistant/components/rachio/entity.py index 1bb971e3e0..a109c4b99f 100644 --- a/homeassistant/components/rachio/entity.py +++ b/homeassistant/components/rachio/entity.py @@ -1,6 +1,6 @@ """Adapter to wrap the rachiopy api for home assistant.""" -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import DeviceInfo, Entity from .const import DEFAULT_NAME, DOMAIN @@ -25,7 +25,7 @@ class RachioDevice(Entity): }, connections={ ( - device_registry.CONNECTION_NETWORK_MAC, + dr.CONNECTION_NETWORK_MAC, self._controller.mac_address, ) }, diff --git a/homeassistant/components/rainmachine/util.py b/homeassistant/components/rainmachine/util.py index 67ffc83d5b..d4131fdb02 100644 --- a/homeassistant/components/rainmachine/util.py +++ b/homeassistant/components/rainmachine/util.py @@ -9,7 +9,7 @@ from typing import Any from homeassistant.backports.enum import StrEnum from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -55,7 +55,7 @@ def async_finish_entity_domain_replacements( entity_replacement_strategies: Iterable[EntityDomainReplacementStrategy], ) -> None: """Remove old entities and create a repairs issue with info on their replacement.""" - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) for strategy in entity_replacement_strategies: try: [registry_entry] = [ diff --git a/homeassistant/components/ruckus_unleashed/__init__.py b/homeassistant/components/ruckus_unleashed/__init__.py index 5861486457..f276c0f8fc 100644 --- a/homeassistant/components/ruckus_unleashed/__init__.py +++ b/homeassistant/components/ruckus_unleashed/__init__.py @@ -6,8 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers import device_registry as dr from .const import ( API_AP, @@ -43,13 +42,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: system_info = await ruckus.system_info() - registry = device_registry.async_get(hass) + registry = dr.async_get(hass) ap_info = await ruckus.ap_info() for device in ap_info[API_AP][API_ID].values(): registry.async_get_or_create( config_entry_id=entry.entry_id, - connections={(CONNECTION_NETWORK_MAC, device[API_MAC])}, - identifiers={(CONNECTION_NETWORK_MAC, device[API_MAC])}, + connections={(dr.CONNECTION_NETWORK_MAC, device[API_MAC])}, + identifiers={(dr.CONNECTION_NETWORK_MAC, device[API_MAC])}, manufacturer=MANUFACTURER, name=device[API_DEVICE_NAME], model=device[API_MODEL], diff --git a/homeassistant/components/ruckus_unleashed/device_tracker.py b/homeassistant/components/ruckus_unleashed/device_tracker.py index 5e8998c47d..dd6d7fd676 100644 --- a/homeassistant/components/ruckus_unleashed/device_tracker.py +++ b/homeassistant/components/ruckus_unleashed/device_tracker.py @@ -4,7 +4,7 @@ from __future__ import annotations from homeassistant.components.device_tracker import ScannerEntity, SourceType from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -37,7 +37,7 @@ async def async_setup_entry( coordinator.async_add_listener(router_update) ) - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) restore_entities(registry, coordinator, entry, async_add_entities, tracked) diff --git a/homeassistant/components/sleepiq/entity.py b/homeassistant/components/sleepiq/entity.py index d4ca2c894d..e6eeaa98c2 100644 --- a/homeassistant/components/sleepiq/entity.py +++ b/homeassistant/components/sleepiq/entity.py @@ -5,7 +5,7 @@ from typing import TypeVar from asyncsleepiq import SleepIQBed, SleepIQSleeper from homeassistant.core import callback -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -21,7 +21,7 @@ _SleepIQCoordinatorT = TypeVar( def device_from_bed(bed: SleepIQBed) -> DeviceInfo: """Create a device given a bed.""" return DeviceInfo( - connections={(device_registry.CONNECTION_NETWORK_MAC, bed.mac_addr)}, + connections={(dr.CONNECTION_NETWORK_MAC, bed.mac_addr)}, manufacturer="SleepNumber", name=bed.name, model=bed.model, diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 3e66f5690b..f97d134c9c 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -23,7 +23,7 @@ from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as ent_reg +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -837,7 +837,7 @@ class SonosSpeaker: # Skip updating existing single speakers in polling mode return - entity_registry = ent_reg.async_get(self.hass) + entity_registry = er.async_get(self.hass) sonos_group = [] sonos_group_entities = [] diff --git a/homeassistant/components/switcher_kis/__init__.py b/homeassistant/components/switcher_kis/__init__.py index bc35298979..abb18a19ed 100644 --- a/homeassistant/components/switcher_kis/__init__.py +++ b/homeassistant/components/switcher_kis/__init__.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_DEVICE_ID, EVENT_HOMEASSISTANT_STOP, Platfo from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import ( config_validation as cv, - device_registry, + device_registry as dr, update_coordinator, ) from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -165,10 +165,10 @@ class SwitcherDataUpdateCoordinator( @callback def async_setup(self) -> None: """Set up the coordinator.""" - dev_reg = device_registry.async_get(self.hass) + dev_reg = dr.async_get(self.hass) dev_reg.async_get_or_create( config_entry_id=self.entry.entry_id, - connections={(device_registry.CONNECTION_NETWORK_MAC, self.mac_address)}, + connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)}, identifiers={(DOMAIN, self.device_id)}, manufacturer="Switcher", name=self.name, diff --git a/homeassistant/components/switcher_kis/button.py b/homeassistant/components/switcher_kis/button.py index a8e4f503d1..ec2f4c0bc9 100644 --- a/homeassistant/components/switcher_kis/button.py +++ b/homeassistant/components/switcher_kis/button.py @@ -19,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -132,9 +132,7 @@ class SwitcherThermostatButtonEntity( self._attr_name = f"{coordinator.name} {description.name}" self._attr_unique_id = f"{coordinator.mac_address}-{description.key}" self._attr_device_info = DeviceInfo( - connections={ - (device_registry.CONNECTION_NETWORK_MAC, coordinator.mac_address) - } + connections={(dr.CONNECTION_NETWORK_MAC, coordinator.mac_address)} ) async def async_press(self) -> None: diff --git a/homeassistant/components/switcher_kis/climate.py b/homeassistant/components/switcher_kis/climate.py index 57d4d9977f..be966d67ee 100644 --- a/homeassistant/components/switcher_kis/climate.py +++ b/homeassistant/components/switcher_kis/climate.py @@ -29,7 +29,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -94,9 +94,7 @@ class SwitcherClimateEntity( self._attr_name = coordinator.name self._attr_unique_id = f"{coordinator.device_id}-{coordinator.mac_address}" self._attr_device_info = DeviceInfo( - connections={ - (device_registry.CONNECTION_NETWORK_MAC, coordinator.mac_address) - } + connections={(dr.CONNECTION_NETWORK_MAC, coordinator.mac_address)} ) self._attr_min_temp = remote.min_temperature diff --git a/homeassistant/components/switcher_kis/cover.py b/homeassistant/components/switcher_kis/cover.py index 584f3d7124..1d72184ad4 100644 --- a/homeassistant/components/switcher_kis/cover.py +++ b/homeassistant/components/switcher_kis/cover.py @@ -17,7 +17,7 @@ from homeassistant.components.cover import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -70,9 +70,7 @@ class SwitcherCoverEntity( self._attr_name = coordinator.name self._attr_unique_id = f"{coordinator.device_id}-{coordinator.mac_address}" self._attr_device_info = DeviceInfo( - connections={ - (device_registry.CONNECTION_NETWORK_MAC, coordinator.mac_address) - } + connections={(dr.CONNECTION_NETWORK_MAC, coordinator.mac_address)} ) self._update_data() diff --git a/homeassistant/components/switcher_kis/sensor.py b/homeassistant/components/switcher_kis/sensor.py index c75d27d67d..2c74f14cb5 100644 --- a/homeassistant/components/switcher_kis/sensor.py +++ b/homeassistant/components/switcher_kis/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfElectricCurrent, UnitOfPower from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -118,9 +118,7 @@ class SwitcherSensorEntity( f"{coordinator.device_id}-{coordinator.mac_address}-{attribute}" ) self._attr_device_info = { - "connections": { - (device_registry.CONNECTION_NETWORK_MAC, coordinator.mac_address) - } + "connections": {(dr.CONNECTION_NETWORK_MAC, coordinator.mac_address)} } @property diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index 9d1b5d4bdc..caed3c3c32 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import ( config_validation as cv, - device_registry, + device_registry as dr, entity_platform, ) from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -92,9 +92,7 @@ class SwitcherBaseSwitchEntity( self._attr_name = coordinator.name self._attr_unique_id = f"{coordinator.device_id}-{coordinator.mac_address}" self._attr_device_info = DeviceInfo( - connections={ - (device_registry.CONNECTION_NETWORK_MAC, coordinator.mac_address) - } + connections={(dr.CONNECTION_NETWORK_MAC, coordinator.mac_address)} ) @callback diff --git a/homeassistant/components/tplink_omada/entity.py b/homeassistant/components/tplink_omada/entity.py index 3e7f21409b..c3cc1433b9 100644 --- a/homeassistant/components/tplink_omada/entity.py +++ b/homeassistant/components/tplink_omada/entity.py @@ -1,7 +1,7 @@ """Base entity definitions.""" from tplink_omada_client.devices import OmadaSwitch, OmadaSwitchPortDetails -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -25,7 +25,7 @@ class OmadaSwitchDeviceEntity( def device_info(self) -> DeviceInfo: """Return information about the device.""" return DeviceInfo( - connections={(device_registry.CONNECTION_NETWORK_MAC, self.device.mac)}, + connections={(dr.CONNECTION_NETWORK_MAC, self.device.mac)}, identifiers={(DOMAIN, (self.device.mac))}, manufacturer="TP-Link", model=self.device.model_display_name, diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index bbc089de0c..4581e28681 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -36,9 +36,8 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval @@ -153,7 +152,7 @@ async def async_setup_entry( ] = async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data) # Restore previously loaded devices - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) dev_ids = { identifier[1] for device in dev_reg.devices.values() diff --git a/homeassistant/components/tradfri/sensor.py b/homeassistant/components/tradfri/sensor.py index 689964cb15..81cce80aa7 100644 --- a/homeassistant/components/tradfri/sensor.py +++ b/homeassistant/components/tradfri/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from .base_class import TradfriBaseEntity @@ -108,7 +108,7 @@ SENSOR_DESCRIPTIONS_FAN: tuple[TradfriSensorEntityDescription, ...] = ( @callback def _migrate_old_unique_ids(hass: HomeAssistant, old_unique_id: str, key: str) -> None: """Migrate unique IDs to the new format.""" - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) entity_id = ent_reg.async_get_entity_id(Platform.SENSOR, DOMAIN, old_unique_id) diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 7ddec4e3fb..5f77d58c5e 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv, device_registry +from homeassistant.helpers import config_validation as cv, device_registry as dr from .const import ( CONFIG_ENTRY_HOST, @@ -118,11 +118,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if device.serial_number: identifiers.add((IDENTIFIER_SERIAL_NUMBER, device.serial_number)) - connections = {(device_registry.CONNECTION_UPNP, device.udn)} + connections = {(dr.CONNECTION_UPNP, device.udn)} if device_mac_address: - connections.add((device_registry.CONNECTION_NETWORK_MAC, device_mac_address)) + connections.add((dr.CONNECTION_NETWORK_MAC, device_mac_address)) - dev_registry = device_registry.async_get(hass) + dev_registry = dr.async_get(hass) device_entry = dev_registry.async_get_device( identifiers=identifiers, connections=connections ) diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index fc451ff262..a51cef0a56 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -13,9 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, CONF_PORT, Platform from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers import device_registry -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.storage import STORAGE_DIR from .const import ( @@ -55,10 +53,8 @@ async def velbus_connect_task( def _migrate_device_identifiers(hass: HomeAssistant, entry_id: str) -> None: """Migrate old device indentifiers.""" - dev_reg = device_registry.async_get(hass) - devices: list[DeviceEntry] = device_registry.async_entries_for_config_entry( - dev_reg, entry_id - ) + dev_reg = dr.async_get(hass) + devices: list[dr.DeviceEntry] = dr.async_entries_for_config_entry(dev_reg, entry_id) for device in devices: old_identifier = list(next(iter(device.identifiers))) if len(old_identifier) > 2: diff --git a/homeassistant/components/wiffi/__init__.py b/homeassistant/components/wiffi/__init__.py index d44c3aaefb..a802535441 100644 --- a/homeassistant/components/wiffi/__init__.py +++ b/homeassistant/components/wiffi/__init__.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT, CONF_TIMEOUT, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -144,7 +144,7 @@ class WiffiEntity(Entity): """Initialize the base elements of a wiffi entity.""" self._id = generate_unique_id(device, metric) self._device_info = DeviceInfo( - connections={(device_registry.CONNECTION_NETWORK_MAC, device.mac_address)}, + connections={(dr.CONNECTION_NETWORK_MAC, device.mac_address)}, identifiers={(DOMAIN, device.mac_address)}, manufacturer="stall.biz", model=device.moduletype, diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 8d3b93ad9d..a2d729e22d 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -32,7 +32,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry, entity_registry +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.issue_registry import ( @@ -161,8 +161,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async_delete_issue(hass, DOMAIN, "invalid_server_version") LOGGER.info("Connected to Zwave JS Server") - dev_reg = device_registry.async_get(hass) - ent_reg = entity_registry.async_get(hass) + dev_reg = dr.async_get(hass) + ent_reg = er.async_get(hass) services = ZWaveServices(hass, ent_reg, dev_reg) services.async_register() @@ -220,7 +220,7 @@ class DriverEvents: def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Set up the driver events instance.""" self.config_entry = entry - self.dev_reg = device_registry.async_get(hass) + self.dev_reg = dr.async_get(hass) self.hass = hass self.platform_setup_tasks: dict[str, asyncio.Task] = {} self.ready = asyncio.Event() @@ -240,7 +240,7 @@ class DriverEvents: await driver.async_disable_statistics() # Check for nodes that no longer exist and remove them - stored_devices = device_registry.async_entries_for_config_entry( + stored_devices = dr.async_entries_for_config_entry( self.dev_reg, self.config_entry.entry_id ) known_devices = [ @@ -311,7 +311,7 @@ class ControllerEvents: self.node_events = NodeEvents(hass, self) @callback - def remove_device(self, device: device_registry.DeviceEntry) -> None: + def remove_device(self, device: dr.DeviceEntry) -> None: """Remove device from registry.""" # note: removal of entity registry entry is handled by core self.dev_reg.async_remove_device(device.id) @@ -385,7 +385,7 @@ class ControllerEvents: self.remove_device(device) @callback - def register_node_in_dev_reg(self, node: ZwaveNode) -> device_registry.DeviceEntry: + def register_node_in_dev_reg(self, node: ZwaveNode) -> dr.DeviceEntry: """Register node in dev reg.""" driver = self.driver_events.driver device_id = get_device_id(driver, node) @@ -448,7 +448,7 @@ class NodeEvents: self.config_entry = controller_events.config_entry self.controller_events = controller_events self.dev_reg = controller_events.dev_reg - self.ent_reg = entity_registry.async_get(hass) + self.ent_reg = er.async_get(hass) self.hass = hass async def async_on_node_ready(self, node: ZwaveNode) -> None: @@ -532,7 +532,7 @@ class NodeEvents: async def async_handle_discovery_info( self, - device: device_registry.DeviceEntry, + device: dr.DeviceEntry, disc_info: ZwaveDiscoveryInfo, value_updates_disc_info: dict[str, ZwaveDiscoveryInfo], ) -> None: diff --git a/homeassistant/components/zwave_js/device_action.py b/homeassistant/components/zwave_js/device_action.py index 0172176d75..3a585b44f5 100644 --- a/homeassistant/components/zwave_js/device_action.py +++ b/homeassistant/components/zwave_js/device_action.py @@ -25,8 +25,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.typing import ConfigType, TemplateVarsType from .config_validation import VALUE_SCHEMA @@ -145,7 +144,7 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, Any]]: """List device actions for Z-Wave JS devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) actions: list[dict] = [] node = async_get_node_from_device_id(hass, device_id) @@ -179,7 +178,7 @@ async def async_get_actions( meter_endpoints: dict[int, dict[str, Any]] = defaultdict(dict) - for entry in entity_registry.async_entries_for_device( + for entry in er.async_entries_for_device( registry, device_id, include_disabled_entities=False ): # If an entry is unavailable, it is possible that the underlying value diff --git a/homeassistant/components/zwave_js/device_trigger.py b/homeassistant/components/zwave_js/device_trigger.py index 067551109e..a0ac70ccb3 100644 --- a/homeassistant/components/zwave_js/device_trigger.py +++ b/homeassistant/components/zwave_js/device_trigger.py @@ -22,8 +22,8 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( config_validation as cv, - device_registry, - entity_registry, + device_registry as dr, + entity_registry as er, ) from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -255,14 +255,14 @@ async def async_get_triggers( CONF_DOMAIN: DOMAIN, } - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) node = async_get_node_from_device_id(hass, device_id, dev_reg) if node.client.driver and node.client.driver.controller.own_node == node: return triggers # We can add a node status trigger if the node status sensor is enabled - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) entity_id = async_get_node_status_sensor_entity_id( hass, device_id, ent_reg, dev_reg ) diff --git a/homeassistant/components/zwave_me/__init__.py b/homeassistant/components/zwave_me/__init__.py index f47b77b29d..346831b34d 100644 --- a/homeassistant/components/zwave_me/__init__.py +++ b/homeassistant/components/zwave_me/__init__.py @@ -7,8 +7,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TOKEN, CONF_URL from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry -from homeassistant.helpers.device_registry import DeviceRegistry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity import DeviceInfo, Entity @@ -24,7 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: controller = hass.data[DOMAIN][entry.entry_id] = ZWaveMeController(hass, entry) if await controller.async_establish_connection(): await async_setup_platforms(hass, entry, controller) - registry = device_registry.async_get(hass) + registry = dr.async_get(hass) controller.remove_stale_devices(registry) return True raise ConfigEntryNotReady() @@ -83,7 +82,7 @@ class ZWaveMeController: """Send signal to update device.""" dispatcher_send(self._hass, f"ZWAVE_ME_INFO_{new_info.id}", new_info) - def remove_stale_devices(self, registry: DeviceRegistry): + def remove_stale_devices(self, registry: dr.DeviceRegistry): """Remove old-format devices in the registry.""" for device_id in self.device_ids: device = registry.async_get_device( From f69d76702adec64af469af3a6afe6294bfeced8b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 08:02:51 +0100 Subject: [PATCH 0542/2810] Adjust entity registry access in integrations (2) (#88947) --- homeassistant/components/iotawatt/sensor.py | 9 +++++---- homeassistant/components/ipma/weather.py | 8 +++----- homeassistant/components/keenetic_ndms2/__init__.py | 6 +++--- .../components/keenetic_ndms2/device_tracker.py | 4 ++-- homeassistant/components/kodi/device_trigger.py | 6 +++--- homeassistant/components/kodi/media_player.py | 4 ++-- homeassistant/components/kraken/sensor.py | 8 ++++---- homeassistant/components/mazda/__init__.py | 11 +++++++---- homeassistant/components/metoffice/__init__.py | 9 ++++----- homeassistant/components/mikrotik/device_tracker.py | 4 ++-- homeassistant/components/nam/__init__.py | 7 +++---- homeassistant/components/nam/sensor.py | 4 ++-- homeassistant/components/netatmo/device_trigger.py | 6 +++--- homeassistant/components/nobo_hub/__init__.py | 4 ++-- homeassistant/components/nuki/__init__.py | 6 +++--- homeassistant/components/owntracks/device_tracker.py | 4 ++-- homeassistant/components/plex/__init__.py | 12 +++++------- homeassistant/components/point/__init__.py | 6 ++---- homeassistant/components/ps4/__init__.py | 4 ++-- homeassistant/components/ps4/media_player.py | 6 +++--- 20 files changed, 62 insertions(+), 66 deletions(-) diff --git a/homeassistant/components/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py index 0870e2234d..849a2055ce 100644 --- a/homeassistant/components/iotawatt/sensor.py +++ b/homeassistant/components/iotawatt/sensor.py @@ -24,8 +24,7 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity, entity_registry -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers import device_registry as dr, entity, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -186,7 +185,9 @@ class IotaWattSensor(CoordinatorEntity[IotawattUpdater], SensorEntity): def device_info(self) -> entity.DeviceInfo: """Return device info.""" return entity.DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, self._sensor_data.hub_mac_address)}, + connections={ + (dr.CONNECTION_NETWORK_MAC, self._sensor_data.hub_mac_address) + }, manufacturer="IoTaWatt", model="IoTaWatt", ) @@ -196,7 +197,7 @@ class IotaWattSensor(CoordinatorEntity[IotawattUpdater], SensorEntity): """Handle updated data from the coordinator.""" if self._key not in self.coordinator.data["sensors"]: if self._attr_unique_id: - entity_registry.async_get(self.hass).async_remove(self.entity_id) + er.async_get(self.hass).async_remove(self.entity_id) else: self.hass.async_create_task(self.async_remove()) return diff --git a/homeassistant/components/ipma/weather.py b/homeassistant/components/ipma/weather.py index 8e46bf27d5..bfd1b820c7 100644 --- a/homeassistant/components/ipma/weather.py +++ b/homeassistant/components/ipma/weather.py @@ -43,7 +43,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.sun import is_up from homeassistant.util import Throttle @@ -89,7 +89,7 @@ async def async_setup_entry( # Migrate old unique_id @callback - def _async_migrator(entity_entry: entity_registry.RegistryEntry): + def _async_migrator(entity_entry: er.RegistryEntry): # Reject if new unique_id if entity_entry.unique_id.count(",") == 2: return None @@ -105,9 +105,7 @@ async def async_setup_entry( ) return {"new_unique_id": new_unique_id} - await entity_registry.async_migrate_entries( - hass, config_entry.entry_id, _async_migrator - ) + await er.async_migrate_entries(hass, config_entry.entry_id, _async_migrator) async_add_entities([IPMAWeather(location, api, config_entry.data)], True) diff --git a/homeassistant/components/keenetic_ndms2/__init__.py b/homeassistant/components/keenetic_ndms2/__init__.py index 68465c26c4..207c9e353a 100644 --- a/homeassistant/components/keenetic_ndms2/__init__.py +++ b/homeassistant/components/keenetic_ndms2/__init__.py @@ -6,7 +6,7 @@ import logging from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry, entity_registry +from homeassistant.helpers import device_registry as dr, entity_registry as er from .const import ( CONF_CONSIDER_HOME, @@ -67,8 +67,8 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> _LOGGER.debug( "Cleaning device_tracker entities since some interfaces are now untracked:" ) - ent_reg = entity_registry.async_get(hass) - dev_reg = device_registry.async_get(hass) + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) # We keep devices currently connected to new_tracked_interfaces keep_devices: set[str] = { mac diff --git a/homeassistant/components/keenetic_ndms2/device_tracker.py b/homeassistant/components/keenetic_ndms2/device_tracker.py index fd4265a4ef..c51d30431b 100644 --- a/homeassistant/components/keenetic_ndms2/device_tracker.py +++ b/homeassistant/components/keenetic_ndms2/device_tracker.py @@ -12,7 +12,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util @@ -40,7 +40,7 @@ async def async_setup_entry( update_from_router() - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) # Restore devices that are not a part of active clients list. restored = [] for entity_entry in registry.entities.values(): diff --git a/homeassistant/components/kodi/device_trigger.py b/homeassistant/components/kodi/device_trigger.py index 07fcf11c07..c15c415bd9 100644 --- a/homeassistant/components/kodi/device_trigger.py +++ b/homeassistant/components/kodi/device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -33,11 +33,11 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Kodi devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) triggers = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain == "media_player": triggers.append( { diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index 1ebc5ad6b8..029eedb242 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -39,7 +39,7 @@ from homeassistant.const import ( from homeassistant.core import CoreState, HomeAssistant, callback from homeassistant.helpers import ( config_validation as cv, - device_registry, + device_registry as dr, entity_platform, ) from homeassistant.helpers.entity import DeviceInfo @@ -407,7 +407,7 @@ class KodiEntity(MediaPlayerEntity): version = (await self._kodi.get_application_properties(["version"]))["version"] sw_version = f"{version['major']}.{version['minor']}" - dev_reg = device_registry.async_get(self.hass) + dev_reg = dr.async_get(self.hass) device = dev_reg.async_get_device({(DOMAIN, self.unique_id)}) dev_reg.async_update_device(device.id, sw_version=sw_version) diff --git a/homeassistant/components/kraken/sensor.py b/homeassistant/components/kraken/sensor.py index dc86fb73d9..0250f17052 100644 --- a/homeassistant/components/kraken/sensor.py +++ b/homeassistant/components/kraken/sensor.py @@ -6,7 +6,7 @@ import logging from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -55,11 +55,11 @@ async def async_setup_entry( @callback def async_update_sensors(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Add or remove sensors for configured tracked asset pairs.""" - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) existing_devices = { device.name: device.id - for device in device_registry.async_entries_for_config_entry( + for device in dr.async_entries_for_config_entry( dev_reg, config_entry.entry_id ) } @@ -125,7 +125,7 @@ class KrakenSensor( self._attr_device_info = DeviceInfo( configuration_url="https://www.kraken.com/", - entry_type=device_registry.DeviceEntryType.SERVICE, + entry_type=dr.DeviceEntryType.SERVICE, identifiers={(DOMAIN, "_".join(self._device_name.split(" ")))}, manufacturer="Kraken.com", name=self._device_name, diff --git a/homeassistant/components/mazda/__init__.py b/homeassistant/components/mazda/__init__.py index 403627147f..c9adac2318 100644 --- a/homeassistant/components/mazda/__init__.py +++ b/homeassistant/components/mazda/__init__.py @@ -24,8 +24,11 @@ from homeassistant.exceptions import ( ConfigEntryNotReady, HomeAssistantError, ) -from homeassistant.helpers import aiohttp_client, device_registry -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + aiohttp_client, + config_validation as cv, + device_registry as dr, +) from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -81,7 +84,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_handle_service_call(service_call: ServiceCall) -> None: """Handle a service call.""" # Get device entry from device registry - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) device_id = service_call.data["device_id"] device_entry = dev_reg.async_get(device_id) if TYPE_CHECKING: @@ -121,7 +124,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def validate_mazda_device_id(device_id): """Check that a device ID exists in the registry and has at least one 'mazda' identifier.""" - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) if (device_entry := dev_reg.async_get(device_id)) is None: raise vol.Invalid("Invalid device ID") diff --git a/homeassistant/components/metoffice/__init__.py b/homeassistant/components/metoffice/__init__.py index 057947d76e..695c6c8f47 100644 --- a/homeassistant/components/metoffice/__init__.py +++ b/homeassistant/components/metoffice/__init__.py @@ -18,8 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import entity_registry -from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -53,7 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @callback def update_unique_id( - entity_entry: entity_registry.RegistryEntry, + entity_entry: er.RegistryEntry, ) -> dict[str, Any] | None: """Update unique ID of entity entry.""" @@ -86,7 +85,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: } return None - await entity_registry.async_migrate_entries(hass, entry.entry_id, update_unique_id) + await er.async_migrate_entries(hass, entry.entry_id, update_unique_id) connection = datapoint.connection(api_key=api_key) @@ -154,7 +153,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def get_device_info(coordinates: str, name: str) -> DeviceInfo: """Return device registry information.""" return DeviceInfo( - entry_type=DeviceEntryType.SERVICE, + entry_type=dr.DeviceEntryType.SERVICE, identifiers={(DOMAIN, coordinates)}, manufacturer="Met Office", name=f"Met Office {name}", diff --git a/homeassistant/components/mikrotik/device_tracker.py b/homeassistant/components/mikrotik/device_tracker.py index 71d94a27fe..14fbb83b61 100644 --- a/homeassistant/components/mikrotik/device_tracker.py +++ b/homeassistant/components/mikrotik/device_tracker.py @@ -10,7 +10,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity import homeassistant.util.dt as dt_util @@ -31,7 +31,7 @@ async def async_setup_entry( tracked: dict[str, MikrotikDataUpdateCoordinatorTracker] = {} - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) # Restore clients that is not a part of active clients list. for entity in registry.entities.values(): diff --git a/homeassistant/components/nam/__init__.py b/homeassistant/components/nam/__init__.py index c011bdfa42..7327601725 100644 --- a/homeassistant/components/nam/__init__.py +++ b/homeassistant/components/nam/__init__.py @@ -21,9 +21,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import entity_registry +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -70,7 +69,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # Remove air_quality entities from registry if they exist - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) for sensor_type in ("sds", ATTR_SDS011, ATTR_SPS30): unique_id = f"{coordinator.unique_id}-{sensor_type}" if entity_id := ent_reg.async_get_entity_id( @@ -130,7 +129,7 @@ class NAMDataUpdateCoordinator(DataUpdateCoordinator[NAMSensors]): def device_info(self) -> DeviceInfo: """Return the device info.""" return DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, cast(str, self._unique_id))}, + connections={(dr.CONNECTION_NETWORK_MAC, cast(str, self._unique_id))}, name="Nettigo Air Monitor", sw_version=self.nam.software_version, manufacturer=MANUFACTURER, diff --git a/homeassistant/components/nam/sensor.py b/homeassistant/components/nam/sensor.py index 878e9b9d96..b78acbf324 100644 --- a/homeassistant/components/nam/sensor.py +++ b/homeassistant/components/nam/sensor.py @@ -26,7 +26,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -369,7 +369,7 @@ async def async_setup_entry( # Due to the change of the attribute name of two sensors, it is necessary to migrate # the unique_ids to the new names. - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) for old_sensor, new_sensor in MIGRATION_SENSORS: old_unique_id = f"{coordinator.unique_id}-{old_sensor}" new_unique_id = f"{coordinator.unique_id}-{new_sensor}" diff --git a/homeassistant/components/netatmo/device_trigger.py b/homeassistant/components/netatmo/device_trigger.py index c6a519a37d..f3f45458d7 100644 --- a/homeassistant/components/netatmo/device_trigger.py +++ b/homeassistant/components/netatmo/device_trigger.py @@ -20,7 +20,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers import ( config_validation as cv, device_registry as dr, - entity_registry, + entity_registry as er, ) from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -93,11 +93,11 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Netatmo devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) device_registry = dr.async_get(hass) triggers = [] - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if ( device := device_registry.async_get(device_id) ) is None or device.model is None: diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index d828fb78b7..bc2c328d64 100644 --- a/homeassistant/components/nobo_hub/__init__.py +++ b/homeassistant/components/nobo_hub/__init__.py @@ -11,7 +11,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from .const import ( ATTR_HARDWARE_VERSION, @@ -38,7 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) # Register hub as device - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) dev_reg.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, hub.hub_info[ATTR_SERIAL])}, diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index 3a75c10333..f1c3d71496 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -16,7 +16,7 @@ from homeassistant import exceptions from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry, entity_registry +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -78,7 +78,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Device registration for the bridge info = bridge.info() bridge_id = parse_id(info["ids"]["hardwareId"]) - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) dev_reg.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, bridge_id)}, @@ -150,7 +150,7 @@ class NukiCoordinator(DataUpdateCoordinator[None]): except RequestException as err: raise UpdateFailed(f"Error communicating with Bridge: {err}") from err - ent_reg = entity_registry.async_get(self.hass) + ent_reg = er.async_get(self.hass) for event, device_ids in events.items(): for device_id in device_ids: entity_id = ent_reg.async_get_entity_id( diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index f983d0f98d..a1fc632c2f 100644 --- a/homeassistant/components/owntracks/device_tracker.py +++ b/homeassistant/components/owntracks/device_tracker.py @@ -13,7 +13,7 @@ from homeassistant.const import ( ATTR_LONGITUDE, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -26,7 +26,7 @@ async def async_setup_entry( ) -> None: """Set up OwnTracks based off an entry.""" # Restore previously loaded devices - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) dev_ids = { identifier[1] for device in dev_reg.devices.values() diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index b215bc0d82..78e8fac23a 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL, CONF_VERIFY_SSL, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dev_reg, entity_registry as ent_reg +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.dispatcher import ( @@ -289,17 +289,15 @@ async def async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None @callback def async_cleanup_plex_devices(hass, entry): """Clean up old and invalid devices from the registry.""" - device_registry = dev_reg.async_get(hass) - entity_registry = ent_reg.async_get(hass) + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) - device_entries = dev_reg.async_entries_for_config_entry( - device_registry, entry.entry_id - ) + device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) for device_entry in device_entries: if ( len( - ent_reg.async_entries_for_device( + er.async_entries_for_device( entity_registry, device_entry.id, include_disabled_entities=True ) ) diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index d4b8377230..6600a8240a 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv, device_registry +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -322,9 +322,7 @@ class MinutPointEntity(Entity): """Return a device description for device registry.""" device = self.device.device return DeviceInfo( - connections={ - (device_registry.CONNECTION_NETWORK_MAC, device["device_mac"]) - }, + connections={(dr.CONNECTION_NETWORK_MAC, device["device_mac"])}, identifiers={(DOMAIN, device["device_id"])}, manufacturer="Minut", model=f"Point v{device['hardware_version']}", diff --git a/homeassistant/components/ps4/__init__.py b/homeassistant/components/ps4/__init__.py index d9c5f2f6dd..0f5c57c5e4 100644 --- a/homeassistant/components/ps4/__init__.py +++ b/homeassistant/components/ps4/__init__.py @@ -23,7 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall, split_entity_id from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType @@ -116,7 +116,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Migrate Version 2 -> Version 3: Update identifier format. if version == 2: # Prevent changing entity_id. Updates entity registry. - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) for entity_id, e_entry in registry.entities.items(): if e_entry.config_entry_id == entry.entry_id: diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py index 5df92fd795..3e6a15df34 100644 --- a/homeassistant/components/ps4/media_player.py +++ b/homeassistant/components/ps4/media_player.py @@ -24,7 +24,7 @@ from homeassistant.const import ( CONF_TOKEN, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry, entity_registry +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -321,8 +321,8 @@ class PS4Device(MediaPlayerEntity): # If cannot get status on startup, assume info from registry. if status is None: _LOGGER.info("Assuming status from registry") - e_registry = entity_registry.async_get(self.hass) - d_registry = device_registry.async_get(self.hass) + e_registry = er.async_get(self.hass) + d_registry = dr.async_get(self.hass) for entity_id, entry in e_registry.entities.items(): if entry.config_entry_id == self._entry_id: self._attr_unique_id = entry.unique_id From 42b74e7f565876d1f5617856e4f44dce6534ebb1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 08:24:56 +0100 Subject: [PATCH 0543/2810] Adjust entity registry access in integrations (3) (#88948) --- homeassistant/components/amcrest/camera.py | 5 +-- .../components/arcam_fmj/device_trigger.py | 6 +-- homeassistant/components/coinbase/__init__.py | 9 ++-- .../components/coronavirus/__init__.py | 12 +++--- .../devolo_home_network/device_tracker.py | 8 ++-- .../components/dlna_dmr/config_flow.py | 5 +-- .../components/dlna_dmr/media_player.py | 12 +++--- homeassistant/components/enocean/switch.py | 5 +-- .../components/geofency/device_tracker.py | 4 +- homeassistant/components/glances/sensor.py | 4 +- .../components/gpslogger/device_tracker.py | 4 +- homeassistant/components/guardian/util.py | 4 +- homeassistant/components/harmony/__init__.py | 6 +-- homeassistant/components/homekit/__init__.py | 41 ++++++++++--------- .../components/homekit/config_flow.py | 13 +++--- .../components/homekit/type_triggers.py | 4 +- .../components/huawei_lte/__init__.py | 6 +-- .../components/huawei_lte/device_tracker.py | 4 +- homeassistant/components/hue/config_flow.py | 13 +++--- homeassistant/components/hue/v2/device.py | 14 +++---- homeassistant/components/hue/v2/hue_event.py | 4 +- 21 files changed, 90 insertions(+), 93 deletions(-) diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index 9162d7841d..43201aba77 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -20,13 +20,12 @@ from homeassistant.components.camera import ( from homeassistant.components.ffmpeg import FFmpegManager, get_ffmpeg_manager from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import ( async_aiohttp_proxy_stream, async_aiohttp_proxy_web, async_get_clientsession, ) -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -146,7 +145,7 @@ async def async_setup_platform( # with this version, update the old entity with the new unique id. serial_number = await device.api.async_serial_number serial_number = serial_number.strip() - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) entity_id = registry.async_get_entity_id(CAMERA_DOMAIN, DOMAIN, serial_number) if entity_id is not None: _LOGGER.debug("Updating unique id for camera %s", entity_id) diff --git a/homeassistant/components/arcam_fmj/device_trigger.py b/homeassistant/components/arcam_fmj/device_trigger.py index f3722c81ec..ecaec0e0e7 100644 --- a/homeassistant/components/arcam_fmj/device_trigger.py +++ b/homeassistant/components/arcam_fmj/device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -32,11 +32,11 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Arcam FMJ Receiver control devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) triggers = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain == "media_player": triggers.append( { diff --git a/homeassistant/components/coinbase/__init__.py b/homeassistant/components/coinbase/__init__.py index ecba1900b6..69d2bd9e90 100644 --- a/homeassistant/components/coinbase/__init__.py +++ b/homeassistant/components/coinbase/__init__.py @@ -10,8 +10,7 @@ from coinbase.wallet.error import AuthenticationError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.util import Throttle from .const import ( @@ -71,10 +70,8 @@ async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> Non await hass.config_entries.async_reload(config_entry.entry_id) - registry = entity_registry.async_get(hass) - entities = entity_registry.async_entries_for_config_entry( - registry, config_entry.entry_id - ) + registry = er.async_get(hass) + entities = er.async_entries_for_config_entry(registry, config_entry.entry_id) # Remove orphaned entities for entity in entities: diff --git a/homeassistant/components/coronavirus/__init__.py b/homeassistant/components/coronavirus/__init__.py index a1c4f876f6..a3bc07ee0a 100644 --- a/homeassistant/components/coronavirus/__init__.py +++ b/homeassistant/components/coronavirus/__init__.py @@ -8,7 +8,11 @@ import coronavirus from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import aiohttp_client, entity_registry, update_coordinator +from homeassistant.helpers import ( + aiohttp_client, + entity_registry as er, + update_coordinator, +) from homeassistant.helpers.typing import ConfigType from .const import DOMAIN @@ -31,16 +35,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) @callback - def _async_migrator(entity_entry: entity_registry.RegistryEntry): + def _async_migrator(entity_entry: er.RegistryEntry): """Migrate away from unstable ID.""" country, info_type = entity_entry.unique_id.rsplit("-", 1) if not country.isnumeric(): return None return {"new_unique_id": f"{entry.title}-{info_type}"} - await entity_registry.async_migrate_entries( - hass, entry.entry_id, _async_migrator - ) + await er.async_migrate_entries(hass, entry.entry_id, _async_migrator) if not entry.unique_id: hass.config_entries.async_update_entry(entry, unique_id=entry.data["country"]) diff --git a/homeassistant/components/devolo_home_network/device_tracker.py b/homeassistant/components/devolo_home_network/device_tracker.py index 79f2eb1f49..eb6e9cf6ec 100644 --- a/homeassistant/components/devolo_home_network/device_tracker.py +++ b/homeassistant/components/devolo_home_network/device_tracker.py @@ -12,7 +12,7 @@ from homeassistant.components.device_tracker import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNKNOWN, UnitOfFrequency from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -30,7 +30,7 @@ async def async_setup_entry( coordinators: dict[ str, DataUpdateCoordinator[list[ConnectedStationInfo]] ] = hass.data[DOMAIN][entry.entry_id]["coordinators"] - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) tracked = set() @callback @@ -53,9 +53,7 @@ async def async_setup_entry( def restore_entities() -> None: """Restore clients that are not a part of active clients list.""" missing = [] - for entity in entity_registry.async_entries_for_config_entry( - registry, entry.entry_id - ): + for entity in er.async_entries_for_config_entry(registry, entry.entry_id): if ( entity.platform == DOMAIN and entity.domain == DEVICE_TRACKER_DOMAIN diff --git a/homeassistant/components/dlna_dmr/config_flow.py b/homeassistant/components/dlna_dmr/config_flow.py index 219f0497ff..bcd402e6a6 100644 --- a/homeassistant/components/dlna_dmr/config_flow.py +++ b/homeassistant/components/dlna_dmr/config_flow.py @@ -21,8 +21,7 @@ from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_MAC, CONF_TYPE, from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import IntegrationError -from homeassistant.helpers import device_registry -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from .const import ( CONF_BROWSE_UNFILTERED, @@ -501,4 +500,4 @@ async def _async_get_mac_address(hass: HomeAssistant, host: str) -> str | None: if not mac_address: return None - return device_registry.format_mac(mac_address) + return dr.format_mac(mac_address) diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index 63bdb8fa60..a866b911f3 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -29,7 +29,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_DEVICE_ID, CONF_MAC, CONF_TYPE, CONF_URL from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry, entity_registry +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( @@ -363,21 +363,21 @@ class DlnaDmrEntity(MediaPlayerEntity): # device's UDN. They may be the same, if the DMR is the root device. connections.add( ( - device_registry.CONNECTION_UPNP, + dr.CONNECTION_UPNP, self._device.profile_device.root_device.udn, ) ) - connections.add((device_registry.CONNECTION_UPNP, self._device.udn)) + connections.add((dr.CONNECTION_UPNP, self._device.udn)) if self.mac_address: # Connection based on MAC address, if known connections.add( # Device MAC is obtained from the config entry, which uses getmac - (device_registry.CONNECTION_NETWORK_MAC, self.mac_address) + (dr.CONNECTION_NETWORK_MAC, self.mac_address) ) # Create linked HA DeviceEntry now the information is known. - dev_reg = device_registry.async_get(self.hass) + dev_reg = dr.async_get(self.hass) device_entry = dev_reg.async_get_or_create( config_entry_id=self.registry_entry.config_entry_id, connections=connections, @@ -388,7 +388,7 @@ class DlnaDmrEntity(MediaPlayerEntity): ) # Update entity registry to link to the device - ent_reg = entity_registry.async_get(self.hass) + ent_reg = er.async_get(self.hass) ent_reg.async_get_or_create( self.registry_entry.domain, self.registry_entry.platform, diff --git a/homeassistant/components/enocean/switch.py b/homeassistant/components/enocean/switch.py index 28727bfb76..11ca8a2a62 100644 --- a/homeassistant/components/enocean/switch.py +++ b/homeassistant/components/enocean/switch.py @@ -9,8 +9,7 @@ import voluptuous as vol from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity from homeassistant.const import CONF_ID, CONF_NAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -38,7 +37,7 @@ def _migrate_to_new_unique_id(hass: HomeAssistant, dev_id, channel) -> None: """Migrate old unique ids to new unique ids.""" old_unique_id = f"{combine_hex(dev_id)}" - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) entity_id = ent_reg.async_get_entity_id(Platform.SWITCH, DOMAIN, old_unique_id) if entity_id is not None: diff --git a/homeassistant/components/geofency/device_tracker.py b/homeassistant/components/geofency/device_tracker.py index cc47883d05..892116121a 100644 --- a/homeassistant/components/geofency/device_tracker.py +++ b/homeassistant/components/geofency/device_tracker.py @@ -3,7 +3,7 @@ from homeassistant.components.device_tracker import SourceType, TrackerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -34,7 +34,7 @@ async def async_setup_entry( ] = async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data) # Restore previously loaded devices - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) dev_ids = { identifier[1] for device in dev_reg.devices.values() diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index e0eaf3bb38..b8b5d80a20 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -257,7 +257,7 @@ async def async_setup_entry( hass: HomeAssistant, old_unique_id: str, new_key: str ) -> None: """Migrate unique IDs to the new format.""" - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) if entity_id := ent_reg.async_get_entity_id( Platform.SENSOR, DOMAIN, old_unique_id diff --git a/homeassistant/components/gpslogger/device_tracker.py b/homeassistant/components/gpslogger/device_tracker.py index a452d32e54..317f2619be 100644 --- a/homeassistant/components/gpslogger/device_tracker.py +++ b/homeassistant/components/gpslogger/device_tracker.py @@ -8,7 +8,7 @@ from homeassistant.const import ( ATTR_LONGITUDE, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -44,7 +44,7 @@ async def async_setup_entry( ] = async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data) # Restore previously loaded devices - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) dev_ids = { identifier[1] for device in dev_reg.devices.values() diff --git a/homeassistant/components/guardian/util.py b/homeassistant/components/guardian/util.py index 010f65cd11..ff41c6e493 100644 --- a/homeassistant/components/guardian/util.py +++ b/homeassistant/components/guardian/util.py @@ -12,7 +12,7 @@ from aioguardian.errors import GuardianError from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -41,7 +41,7 @@ def async_finish_entity_domain_replacements( entity_replacement_strategies: Iterable[EntityDomainReplacementStrategy], ) -> None: """Remove old entities and create a repairs issue with info on their replacement.""" - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) for strategy in entity_replacement_strategies: try: [registry_entry] = [ diff --git a/homeassistant/components/harmony/__init__.py b/homeassistant/components/harmony/__init__.py index 259ea66031..d861068629 100644 --- a/homeassistant/components/harmony/__init__.py +++ b/homeassistant/components/harmony/__init__.py @@ -5,7 +5,7 @@ from homeassistant.components.remote import ATTR_ACTIVITY, ATTR_DELAY_SECS from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( @@ -60,7 +60,7 @@ async def _migrate_old_unique_ids( names_to_ids = {activity["label"]: activity["id"] for activity in data.activities} @callback - def _async_migrator(entity_entry: entity_registry.RegistryEntry): + def _async_migrator(entity_entry: er.RegistryEntry): # Old format for switches was {remote_unique_id}-{activity_name} # New format is activity_{activity_id} parts = entity_entry.unique_id.split("-", 1) @@ -78,7 +78,7 @@ async def _migrate_old_unique_ids( return None - await entity_registry.async_migrate_entries(hass, entry_id, _async_migrator) + await er.async_migrate_entries(hass, entry_id, _async_migrator) @callback diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index c9faa2e28e..d5a6202ea2 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -50,8 +50,12 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, HomeAssistant, ServiceCall, State, callback from homeassistant.exceptions import HomeAssistantError, Unauthorized -from homeassistant.helpers import device_registry, entity_registry, instance_id -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, + instance_id, +) from homeassistant.helpers.entityfilter import ( BASE_FILTER_SCHEMA, FILTER_SCHEMA, @@ -431,20 +435,19 @@ def _async_register_events_and_services(hass: HomeAssistant) -> None: async def async_handle_homekit_unpair(service: ServiceCall) -> None: """Handle unpair HomeKit service call.""" referenced = async_extract_referenced_entity_ids(hass, service) - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) for device_id in referenced.referenced_devices: if not (dev_reg_ent := dev_reg.async_get(device_id)): raise HomeAssistantError(f"No device found for device id: {device_id}") macs = [ cval for ctype, cval in dev_reg_ent.connections - if ctype == device_registry.CONNECTION_NETWORK_MAC + if ctype == dr.CONNECTION_NETWORK_MAC ] matching_instances = [ homekit for homekit in _async_all_homekit_instances(hass) - if homekit.driver - and device_registry.format_mac(homekit.driver.state.mac) in macs + if homekit.driver and dr.format_mac(homekit.driver.state.mac) in macs ] if not matching_instances: raise HomeAssistantError( @@ -698,7 +701,7 @@ class HomeKit: return False def add_bridge_triggers_accessory( - self, device: device_registry.DeviceEntry, device_triggers: list[dict[str, Any]] + self, device: dr.DeviceEntry, device_triggers: list[dict[str, Any]] ) -> None: """Add device automation triggers to the bridge.""" if self._would_exceed_max_devices(device.name): @@ -734,8 +737,8 @@ class HomeKit: async def async_configure_accessories(self) -> list[State]: """Configure accessories for the included states.""" - dev_reg = device_registry.async_get(self.hass) - ent_reg = entity_registry.async_get(self.hass) + dev_reg = dr.async_get(self.hass) + ent_reg = er.async_get(self.hass) device_lookup = ent_reg.async_get_device_class_lookup( { (BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass.BATTERY_CHARGING), @@ -830,8 +833,8 @@ class HomeKit: def _async_register_bridge(self) -> None: """Register the bridge as a device so homekit_controller and exclude it from discovery.""" assert self.driver is not None - dev_reg = device_registry.async_get(self.hass) - formatted_mac = device_registry.format_mac(self.driver.state.mac) + dev_reg = dr.async_get(self.hass) + formatted_mac = dr.format_mac(self.driver.state.mac) # Connections and identifiers are both used here. # # connections exists so homekit_controller can know the @@ -844,7 +847,7 @@ class HomeKit: # because this was the way you had to fix homekit when pairing # failed. # - connection = (device_registry.CONNECTION_NETWORK_MAC, formatted_mac) + connection = (dr.CONNECTION_NETWORK_MAC, formatted_mac) identifier = (DOMAIN, self._entry_id, BRIDGE_SERIAL_NUMBER) self._async_purge_old_bridges(dev_reg, identifier, connection) is_accessory_mode = self._homekit_mode == HOMEKIT_MODE_ACCESSORY @@ -858,13 +861,13 @@ class HomeKit: manufacturer=MANUFACTURER, name=accessory_friendly_name(self._entry_title, self.driver.accessory), model=f"HomeKit {hk_mode_name}", - entry_type=device_registry.DeviceEntryType.SERVICE, + entry_type=dr.DeviceEntryType.SERVICE, ) @callback def _async_purge_old_bridges( self, - dev_reg: device_registry.DeviceRegistry, + dev_reg: dr.DeviceRegistry, identifier: tuple[str, str, str], connection: tuple[str, str], ) -> None: @@ -920,7 +923,7 @@ class HomeKit: async def _async_add_trigger_accessories(self) -> None: """Add devices with triggers to the bridge.""" - dev_reg = device_registry.async_get(self.hass) + dev_reg = dr.async_get(self.hass) valid_device_ids = [] for device_id in self._devices: if not dev_reg.async_get(device_id): @@ -989,7 +992,7 @@ class HomeKit: @callback def _async_configure_linked_sensors( self, - ent_reg_ent: entity_registry.RegistryEntry, + ent_reg_ent: er.RegistryEntry, device_lookup: dict[str, dict[tuple[str, str | None], str]], state: State, ) -> None: @@ -1051,8 +1054,8 @@ class HomeKit: async def _async_set_device_info_attributes( self, - ent_reg_ent: entity_registry.RegistryEntry, - dev_reg: device_registry.DeviceRegistry, + ent_reg_ent: er.RegistryEntry, + dev_reg: dr.DeviceRegistry, entity_id: str, ) -> None: """Set attributes that will be used for homekit device info.""" @@ -1070,7 +1073,7 @@ class HomeKit: ent_cfg[ATTR_INTEGRATION] = ent_reg_ent.platform def _fill_config_from_device_registry_entry( - self, device_entry: device_registry.DeviceEntry, config: dict[str, Any] + self, device_entry: dr.DeviceEntry, config: dict[str, Any] ) -> None: """Populate a config dict from the registry.""" if device_entry.manufacturer: diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index dddce5eae3..3747af3edc 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -28,8 +28,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback, split_entity_id from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import device_registry, entity_registry -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.entityfilter import ( CONF_EXCLUDE_DOMAINS, CONF_EXCLUDE_ENTITIES, @@ -630,7 +633,7 @@ async def _async_get_supported_devices(hass: HomeAssistant) -> dict[str, str]: results = await device_automation.async_get_device_automations( hass, device_automation.DeviceAutomationType.TRIGGER ) - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) unsorted: dict[str, str] = {} for device_id in results: entry = dev_reg.async_get(device_id) @@ -639,7 +642,7 @@ async def _async_get_supported_devices(hass: HomeAssistant) -> dict[str, str]: def _exclude_by_entity_registry( - ent_reg: entity_registry.EntityRegistry, + ent_reg: er.EntityRegistry, entity_id: str, include_entity_category: bool, include_hidden: bool, @@ -661,7 +664,7 @@ def _async_get_matching_entities( include_hidden: bool = False, ) -> dict[str, str]: """Fetch all entities or entities in the given domains.""" - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) return { state.entity_id: ( f"{state.attributes.get(ATTR_FRIENDLY_NAME, state.entity_id)} ({state.entity_id})" diff --git a/homeassistant/components/homekit/type_triggers.py b/homeassistant/components/homekit/type_triggers.py index b239d67877..eb2cd5d34a 100644 --- a/homeassistant/components/homekit/type_triggers.py +++ b/homeassistant/components/homekit/type_triggers.py @@ -7,7 +7,7 @@ from typing import Any from pyhap.const import CATEGORY_SENSOR from homeassistant.core import CALLBACK_TYPE, Context -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.trigger import async_initialize_triggers from .accessories import TYPES, HomeAccessory @@ -42,7 +42,7 @@ class DeviceTriggerAccessory(HomeAccessory): self._remove_triggers: CALLBACK_TYPE | None = None self.triggers = [] assert device_triggers is not None - ent_reg = entity_registry.async_get(self.hass) + ent_reg = er.async_get(self.hass) for idx, trigger in enumerate(device_triggers): type_: str = trigger["type"] subtype: str | None = trigger.get("subtype") diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 0f66149871..5e5b2c8dc9 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -44,7 +44,7 @@ from homeassistant.helpers import ( config_validation as cv, device_registry as dr, discovery, - entity_registry, + entity_registry as er, ) from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity import DeviceInfo, Entity @@ -359,8 +359,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Transitional from < 2021.8: update None config entry and entity unique ids if router_info and (serial_number := router_info.get("SerialNumber")): hass.config_entries.async_update_entry(entry, unique_id=serial_number) - ent_reg = entity_registry.async_get(hass) - for entity_entry in entity_registry.async_entries_for_config_entry( + ent_reg = er.async_get(hass) + for entity_entry in er.async_entries_for_config_entry( ent_reg, entry.entry_id ): if not entity_entry.unique_id.startswith("None-"): diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index 52d12d2000..b8833b24d9 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -66,7 +66,7 @@ async def async_setup_entry( # Initialize already tracked entities tracked: set[str] = set() - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) known_entities: list[Entity] = [] track_wired_clients = router.config_entry.options.get( CONF_TRACK_WIRED_CLIENTS, DEFAULT_TRACK_WIRED_CLIENTS diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index d87da5b5ac..cd2553353b 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -18,8 +18,11 @@ from homeassistant.components import zeroconf from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import aiohttp_client, device_registry -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + aiohttp_client, + config_validation as cv, + device_registry as dr, +) from homeassistant.util.network import is_ipv6_address from .const import ( @@ -306,10 +309,8 @@ class HueV2OptionsFlowHandler(config_entries.OptionsFlow): # create a list of Hue device ID's that the user can select # to ignore availability status - dev_reg = device_registry.async_get(self.hass) - entries = device_registry.async_entries_for_config_entry( - dev_reg, self.config_entry.entry_id - ) + dev_reg = dr.async_get(self.hass) + entries = dr.async_entries_for_config_entry(dev_reg, self.config_entry.entry_id) dev_ids = { identifier[1]: entry.name for entry in entries diff --git a/homeassistant/components/hue/v2/device.py b/homeassistant/components/hue/v2/device.py index c3deee4002..bc3ce49cb6 100644 --- a/homeassistant/components/hue/v2/device.py +++ b/homeassistant/components/hue/v2/device.py @@ -16,7 +16,7 @@ from homeassistant.const import ( ATTR_VIA_DEVICE, ) from homeassistant.core import callback -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from ..const import DOMAIN @@ -29,11 +29,11 @@ async def async_setup_devices(bridge: "HueBridge"): entry = bridge.config_entry hass = bridge.hass api: HueBridgeV2 = bridge.api # to satisfy typing - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) dev_controller = api.devices @callback - def add_device(hue_device: Device) -> device_registry.DeviceEntry: + def add_device(hue_device: Device) -> dr.DeviceEntry: """Register a Hue device in device registry.""" model = f"{hue_device.product_data.product_name} ({hue_device.product_data.model_id})" params = { @@ -51,9 +51,7 @@ async def async_setup_devices(bridge: "HueBridge"): params[ATTR_VIA_DEVICE] = (DOMAIN, api.config.bridge_device.id) zigbee = dev_controller.get_zigbee_connectivity(hue_device.id) if zigbee and zigbee.mac_address: - params[ATTR_CONNECTIONS] = { - (device_registry.CONNECTION_NETWORK_MAC, zigbee.mac_address) - } + params[ATTR_CONNECTIONS] = {(dr.CONNECTION_NETWORK_MAC, zigbee.mac_address)} return dev_reg.async_get_or_create(config_entry_id=entry.entry_id, **params) @@ -77,9 +75,7 @@ async def async_setup_devices(bridge: "HueBridge"): known_devices = [add_device(hue_device) for hue_device in dev_controller] # Check for nodes that no longer exist and remove them - for device in device_registry.async_entries_for_config_entry( - dev_reg, entry.entry_id - ): + for device in dr.async_entries_for_config_entry(dev_reg, entry.entry_id): if device not in known_devices: # handle case where a virtual device was created for a Hue group hue_dev_id = next(x[1] for x in device.identifiers if x[0] == DOMAIN) diff --git a/homeassistant/components/hue/v2/hue_event.py b/homeassistant/components/hue/v2/hue_event.py index 07a54e0f84..e0296bcb43 100644 --- a/homeassistant/components/hue/v2/hue_event.py +++ b/homeassistant/components/hue/v2/hue_event.py @@ -9,7 +9,7 @@ from aiohue.v2.models.relative_rotary import RelativeRotary from homeassistant.const import CONF_DEVICE_ID, CONF_ID, CONF_TYPE, CONF_UNIQUE_ID from homeassistant.core import callback -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.util import slugify from ..const import ATTR_HUE_EVENT, CONF_SUBTYPE, DOMAIN @@ -29,7 +29,7 @@ async def async_setup_hue_events(bridge: "HueBridge"): hass = bridge.hass api: HueBridgeV2 = bridge.api # to satisfy typing conf_entry = bridge.config_entry - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) btn_controller = api.sensors.button rotary_controller = api.sensors.relative_rotary From 9ab95b6348e79b2f0529994f13228c25002a77b2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 1 Mar 2023 08:53:05 +0100 Subject: [PATCH 0544/2810] 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 a8cacc0e20..adf91eb706 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 9c2f68fd68..c1541812d1 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -4,7 +4,6 @@ from math import sin import random from unittest.mock import patch -from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass from homeassistant.const import UnitOfPower, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -79,7 +78,6 @@ async def setup_tests(hass, config, times, values, expected_state): assert state is not None assert round(float(state.state), config["sensor"]["round"]) == expected_state - assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT return state From 202bed5d51fa5931cde153446c1b0acc3e6fe3e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Mar 2023 02:07:46 -0600 Subject: [PATCH 0545/2810] Fix lingering reload task in notion reauth (#88949) Co-authored-by: Martin Hjelmare --- tests/components/notion/test_config_flow.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/components/notion/test_config_flow.py b/tests/components/notion/test_config_flow.py index de5c871ce5..d0d1cad035 100644 --- a/tests/components/notion/test_config_flow.py +++ b/tests/components/notion/test_config_flow.py @@ -109,6 +109,11 @@ async def test_reauth( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_PASSWORD: "password"} ) + # Block to ensure the setup_config_entry fixture does not + # get undone before hass is shutdown so we do not try + # to setup the config entry via reload. + await hass.async_block_till_done() + assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "reauth_successful" assert len(hass.config_entries.async_entries()) == 1 From 853bd52a22ee254d6fc3ac1d6a6264678d5b4732 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 09:11:14 +0100 Subject: [PATCH 0546/2810] Adjust entity registry access in tests (1) (#88950) --- .../components/aladdin_connect/test_sensor.py | 32 +++++++++---------- .../components/broadlink/test_config_flow.py | 6 ++-- tests/components/coinbase/test_init.py | 25 ++++++++------- .../command_line/test_binary_sensor.py | 22 ++++++------- tests/components/command_line/test_cover.py | 17 +++++----- tests/components/command_line/test_sensor.py | 17 +++++----- tests/components/command_line/test_switch.py | 17 +++++----- .../devolo_home_control/test_binary_sensor.py | 9 +++--- .../devolo_home_control/test_sensor.py | 9 +++--- .../devolo_home_network/test_binary_sensor.py | 11 ++++--- .../test_device_tracker.py | 16 ++++++---- .../devolo_home_network/test_sensor.py | 18 +++++++---- .../devolo_home_network/test_switch.py | 9 +++--- tests/components/dlna_dmr/test_init.py | 7 ++-- tests/components/flipr/test_binary_sensor.py | 8 ++--- tests/components/flipr/test_sensor.py | 16 ++++------ tests/components/generic/test_config_flow.py | 12 +++---- tests/components/harmony/test_switch.py | 22 +++++++------ tests/components/hassio/test_binary_sensor.py | 11 ++++--- tests/components/hassio/test_sensor.py | 11 ++++--- 20 files changed, 153 insertions(+), 142 deletions(-) diff --git a/tests/components/aladdin_connect/test_sensor.py b/tests/components/aladdin_connect/test_sensor.py index 282f6d3e04..c01d6c5c78 100644 --- a/tests/components/aladdin_connect/test_sensor.py +++ b/tests/components/aladdin_connect/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from homeassistant.components.aladdin_connect.const import DOMAIN from homeassistant.components.aladdin_connect.cover import SCAN_INTERVAL from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.util.dt import utcnow from tests.common import MockConfigEntry, async_fire_time_changed @@ -28,6 +28,7 @@ RELOAD_AFTER_UPDATE_DELAY = timedelta(seconds=31) async def test_sensors( hass: HomeAssistant, mock_aladdinconnect_api: MagicMock, + entity_registry: er.EntityRegistry, ) -> None: """Test Sensors for AladdinConnect.""" config_entry = MockConfigEntry( @@ -46,12 +47,11 @@ async def test_sensors( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - registry = entity_registry.async_get(hass) - entry = registry.async_get("sensor.home_battery_level") + entry = entity_registry.async_get("sensor.home_battery_level") assert entry assert entry.disabled - assert entry.disabled_by is entity_registry.RegistryEntryDisabler.INTEGRATION - update_entry = registry.async_update_entity( + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + update_entry = entity_registry.async_update_entity( entry.entity_id, **{"disabled_by": None} ) await hass.async_block_till_done() @@ -68,12 +68,12 @@ async def test_sensors( state = hass.states.get("sensor.home_battery_level") assert state - entry = registry.async_get("sensor.home_wi_fi_rssi") + entry = entity_registry.async_get("sensor.home_wi_fi_rssi") await hass.async_block_till_done() assert entry assert entry.disabled - assert entry.disabled_by is entity_registry.RegistryEntryDisabler.INTEGRATION - update_entry = registry.async_update_entity( + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + update_entry = entity_registry.async_update_entity( entry.entity_id, **{"disabled_by": None} ) await hass.async_block_till_done() @@ -82,7 +82,7 @@ async def test_sensors( state = hass.states.get("sensor.home_wi_fi_rssi") assert state is None - update_entry = registry.async_update_entity( + update_entry = entity_registry.async_update_entity( entry.entity_id, **{"disabled_by": None} ) await hass.async_block_till_done() @@ -99,6 +99,7 @@ async def test_sensors( async def test_sensors_model_01( hass: HomeAssistant, mock_aladdinconnect_api: MagicMock, + entity_registry: er.EntityRegistry, ) -> None: """Test Sensors for AladdinConnect.""" config_entry = MockConfigEntry( @@ -120,20 +121,19 @@ async def test_sensors_model_01( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - registry = entity_registry.async_get(hass) - entry = registry.async_get("sensor.home_battery_level") + entry = entity_registry.async_get("sensor.home_battery_level") assert entry assert entry.disabled is False assert entry.disabled_by is None state = hass.states.get("sensor.home_battery_level") assert state - entry = registry.async_get("sensor.home_wi_fi_rssi") + entry = entity_registry.async_get("sensor.home_wi_fi_rssi") await hass.async_block_till_done() assert entry assert entry.disabled - assert entry.disabled_by is entity_registry.RegistryEntryDisabler.INTEGRATION - update_entry = registry.async_update_entity( + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + update_entry = entity_registry.async_update_entity( entry.entity_id, **{"disabled_by": None} ) await hass.async_block_till_done() @@ -142,7 +142,7 @@ async def test_sensors_model_01( state = hass.states.get("sensor.home_wi_fi_rssi") assert state is None - update_entry = registry.async_update_entity( + update_entry = entity_registry.async_update_entity( entry.entity_id, **{"disabled_by": None} ) await hass.async_block_till_done() @@ -155,7 +155,7 @@ async def test_sensors_model_01( state = hass.states.get("sensor.home_wi_fi_rssi") assert state - entry = registry.async_get("sensor.home_ble_strength") + entry = entity_registry.async_get("sensor.home_ble_strength") await hass.async_block_till_done() assert entry assert entry.disabled is False diff --git a/tests/components/broadlink/test_config_flow.py b/tests/components/broadlink/test_config_flow.py index 63af7a70c7..314e017bed 100644 --- a/tests/components/broadlink/test_config_flow.py +++ b/tests/components/broadlink/test_config_flow.py @@ -10,7 +10,7 @@ from homeassistant import config_entries from homeassistant.components import dhcp from homeassistant.components.broadlink.const import DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from . import get_device @@ -838,7 +838,7 @@ async def test_dhcp_can_finish(hass: HomeAssistant) -> None: data=dhcp.DhcpServiceInfo( hostname="broadlink", ip="1.2.3.4", - macaddress=device_registry.format_mac(device.mac), + macaddress=dr.format_mac(device.mac), ), ) await hass.async_block_till_done() @@ -932,7 +932,7 @@ async def test_dhcp_device_not_supported(hass: HomeAssistant) -> None: data=dhcp.DhcpServiceInfo( hostname="broadlink", ip=device.host, - macaddress=device_registry.format_mac(device.mac), + macaddress=dr.format_mac(device.mac), ), ) diff --git a/tests/components/coinbase/test_init.py b/tests/components/coinbase/test_init.py index 60449570d2..c518c71098 100644 --- a/tests/components/coinbase/test_init.py +++ b/tests/components/coinbase/test_init.py @@ -9,7 +9,7 @@ from homeassistant.components.coinbase.const import ( DOMAIN, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from .common import ( init_mock_coinbase, @@ -49,7 +49,9 @@ async def test_unload_entry(hass: HomeAssistant) -> None: assert not hass.data.get(DOMAIN) -async def test_option_updates(hass: HomeAssistant) -> None: +async def test_option_updates( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test handling option updates.""" with patch( @@ -75,9 +77,8 @@ async def test_option_updates(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - registry = entity_registry.async_get(hass) - entities = entity_registry.async_entries_for_config_entry( - registry, config_entry.entry_id + entities = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id ) assert len(entities) == 4 currencies = [ @@ -106,9 +107,8 @@ async def test_option_updates(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - registry = entity_registry.async_get(hass) - entities = entity_registry.async_entries_for_config_entry( - registry, config_entry.entry_id + entities = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id ) assert len(entities) == 2 currencies = [ @@ -127,7 +127,9 @@ async def test_option_updates(hass: HomeAssistant) -> None: assert rates == [GOOD_EXCHANGE_RATE] -async def test_ignore_vaults_wallets(hass: HomeAssistant) -> None: +async def test_ignore_vaults_wallets( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test vaults are ignored in wallet sensors.""" with patch( @@ -142,9 +144,8 @@ async def test_ignore_vaults_wallets(hass: HomeAssistant) -> None: config_entry = await init_mock_coinbase(hass, currencies=[GOOD_CURRENCY]) await hass.async_block_till_done() - registry = entity_registry.async_get(hass) - entities = entity_registry.async_entries_for_config_entry( - registry, config_entry.entry_id + entities = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id ) assert len(entities) == 1 entity = entities[0] diff --git a/tests/components/command_line/test_binary_sensor.py b/tests/components/command_line/test_binary_sensor.py index 3f70673849..a6486b4004 100644 --- a/tests/components/command_line/test_binary_sensor.py +++ b/tests/components/command_line/test_binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant import setup from homeassistant.components.binary_sensor import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er async def setup_test_entity(hass: HomeAssistant, config_dict: dict[str, Any]) -> None: @@ -72,7 +72,9 @@ async def test_sensor_off(hass: HomeAssistant) -> None: assert entity_state.state == STATE_OFF -async def test_unique_id(hass: HomeAssistant) -> None: +async def test_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test unique_id option and if it only creates one binary sensor per id.""" assert await setup.async_setup_component( hass, @@ -101,18 +103,12 @@ async def test_unique_id(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 2 - ent_reg = entity_registry.async_get(hass) - - assert len(ent_reg.entities) == 2 - assert ( - ent_reg.async_get_entity_id("binary_sensor", "command_line", "unique") - is not None + assert len(entity_registry.entities) == 2 + assert entity_registry.async_get_entity_id( + "binary_sensor", "command_line", "unique" ) - assert ( - ent_reg.async_get_entity_id( - "binary_sensor", "command_line", "not-so-unique-anymore" - ) - is not None + assert entity_registry.async_get_entity_id( + "binary_sensor", "command_line", "not-so-unique-anymore" ) diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py index 220da18409..bfb74832f9 100644 --- a/tests/components/command_line/test_cover.py +++ b/tests/components/command_line/test_cover.py @@ -18,7 +18,7 @@ from homeassistant.const import ( SERVICE_STOP_COVER, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed, get_fixture_path @@ -171,7 +171,9 @@ async def test_move_cover_failure( assert "return code 1" in caplog.text -async def test_unique_id(hass: HomeAssistant) -> None: +async def test_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test unique_id option and if it only creates one cover per id.""" await setup_test_entity( hass, @@ -199,11 +201,8 @@ async def test_unique_id(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 2 - ent_reg = entity_registry.async_get(hass) - - assert len(ent_reg.entities) == 2 - assert ent_reg.async_get_entity_id("cover", "command_line", "unique") is not None - assert ( - ent_reg.async_get_entity_id("cover", "command_line", "not-so-unique-anymore") - is not None + assert len(entity_registry.entities) == 2 + assert entity_registry.async_get_entity_id("cover", "command_line", "unique") + assert entity_registry.async_get_entity_id( + "cover", "command_line", "not-so-unique-anymore" ) diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index c67e97ef81..347c6a7ffd 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -9,7 +9,7 @@ import pytest from homeassistant import setup from homeassistant.components.sensor import DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er async def setup_test_entities(hass: HomeAssistant, config_dict: dict[str, Any]) -> None: @@ -260,7 +260,9 @@ async def test_update_with_unnecessary_json_attrs( assert "key_three" not in entity_state.attributes -async def test_unique_id(hass: HomeAssistant) -> None: +async def test_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test unique_id option and if it only creates one sensor per id.""" assert await setup.async_setup_component( hass, @@ -289,11 +291,8 @@ async def test_unique_id(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 2 - ent_reg = entity_registry.async_get(hass) - - assert len(ent_reg.entities) == 2 - assert ent_reg.async_get_entity_id("sensor", "command_line", "unique") is not None - assert ( - ent_reg.async_get_entity_id("sensor", "command_line", "not-so-unique-anymore") - is not None + assert len(entity_registry.entities) == 2 + assert entity_registry.async_get_entity_id("sensor", "command_line", "unique") + assert entity_registry.async_get_entity_id( + "sensor", "command_line", "not-so-unique-anymore" ) diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py index c1ff567f15..ac1ae35712 100644 --- a/tests/components/command_line/test_switch.py +++ b/tests/components/command_line/test_switch.py @@ -20,7 +20,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed @@ -393,7 +393,9 @@ async def test_no_switches( assert "No switches" in caplog.text -async def test_unique_id(hass: HomeAssistant) -> None: +async def test_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test unique_id option and if it only creates one switch per id.""" await setup_test_entity( hass, @@ -418,13 +420,10 @@ async def test_unique_id(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 2 - ent_reg = entity_registry.async_get(hass) - - assert len(ent_reg.entities) == 2 - assert ent_reg.async_get_entity_id("switch", "command_line", "unique") is not None - assert ( - ent_reg.async_get_entity_id("switch", "command_line", "not-so-unique-anymore") - is not None + assert len(entity_registry.entities) == 2 + assert entity_registry.async_get_entity_id("switch", "command_line", "unique") + assert entity_registry.async_get_entity_id( + "switch", "command_line", "not-so-unique-anymore" ) diff --git a/tests/components/devolo_home_control/test_binary_sensor.py b/tests/components/devolo_home_control/test_binary_sensor.py index fa132158a6..1fa2248c71 100644 --- a/tests/components/devolo_home_control/test_binary_sensor.py +++ b/tests/components/devolo_home_control/test_binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from . import configure_integration from .mocks import ( @@ -24,7 +24,9 @@ from .mocks import ( @pytest.mark.usefixtures("mock_zeroconf") -async def test_binary_sensor(hass: HomeAssistant) -> None: +async def test_binary_sensor( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test setup and state change of a binary sensor device.""" entry = configure_integration(hass) test_gateway = HomeControlMockBinarySensor() @@ -44,9 +46,8 @@ async def test_binary_sensor(hass: HomeAssistant) -> None: state = hass.states.get(f"{DOMAIN}.test_overload") assert state is not None assert state.attributes[ATTR_FRIENDLY_NAME] == "Test Overload" - er = entity_registry.async_get(hass) assert ( - er.async_get(f"{DOMAIN}.test_overload").entity_category + entity_registry.async_get(f"{DOMAIN}.test_overload").entity_category == EntityCategory.DIAGNOSTIC ) diff --git a/tests/components/devolo_home_control/test_sensor.py b/tests/components/devolo_home_control/test_sensor.py index 35ff0358ca..9746fca6b6 100644 --- a/tests/components/devolo_home_control/test_sensor.py +++ b/tests/components/devolo_home_control/test_sensor.py @@ -15,7 +15,7 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from . import configure_integration from .mocks import HomeControlMock, HomeControlMockConsumption, HomeControlMockSensor @@ -43,10 +43,11 @@ async def test_temperature_sensor(hass: HomeAssistant) -> None: assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE -async def test_battery_sensor(hass: HomeAssistant) -> None: +async def test_battery_sensor( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test setup and state change of a battery sensor device.""" entry = configure_integration(hass) - er = entity_registry.async_get(hass) test_gateway = HomeControlMockSensor() test_gateway.devices["Test"].battery_level = 25 with patch( @@ -63,7 +64,7 @@ async def test_battery_sensor(hass: HomeAssistant) -> None: assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.BATTERY assert ( - er.async_get(f"{DOMAIN}.test_battery_level").entity_category + entity_registry.async_get(f"{DOMAIN}.test_battery_level").entity_category is EntityCategory.DIAGNOSTIC ) diff --git a/tests/components/devolo_home_network/test_binary_sensor.py b/tests/components/devolo_home_network/test_binary_sensor.py index 6c76d775ec..5906112ffd 100644 --- a/tests/components/devolo_home_network/test_binary_sensor.py +++ b/tests/components/devolo_home_network/test_binary_sensor.py @@ -17,7 +17,7 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt from . import configure_integration @@ -42,15 +42,13 @@ async def test_binary_sensor_setup(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_update_attached_to_router( - hass: HomeAssistant, mock_device: MockDevice + hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry ) -> None: """Test state change of a attached_to_router binary sensor device.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() state_key = f"{DOMAIN}.{device_name}_{CONNECTED_TO_ROUTER}" - er = entity_registry.async_get(hass) - await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -59,7 +57,10 @@ async def test_update_attached_to_router( assert state.state == STATE_OFF assert state.attributes[ATTR_FRIENDLY_NAME] == f"{entry.title} Connected to router" - assert er.async_get(state_key).entity_category == EntityCategory.DIAGNOSTIC + assert ( + entity_registry.async_get(state_key).entity_category + == EntityCategory.DIAGNOSTIC + ) # Emulate device failure mock_device.plcnet.async_get_network_overview = AsyncMock( diff --git a/tests/components/devolo_home_network/test_device_tracker.py b/tests/components/devolo_home_network/test_device_tracker.py index ffcfb71d31..963956abd9 100644 --- a/tests/components/devolo_home_network/test_device_tracker.py +++ b/tests/components/devolo_home_network/test_device_tracker.py @@ -17,7 +17,7 @@ from homeassistant.const import ( UnitOfFrequency, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt from . import configure_integration @@ -30,20 +30,21 @@ STATION = CONNECTED_STATIONS[0] SERIAL = DISCOVERY_INFO.properties["SN"] -async def test_device_tracker(hass: HomeAssistant, mock_device: MockDevice) -> None: +async def test_device_tracker( + hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry +) -> None: """Test device tracker states.""" state_key = ( f"{PLATFORM}.{DOMAIN}_{SERIAL}_{STATION.mac_address.lower().replace(':', '_')}" ) entry = configure_integration(hass) - er = entity_registry.async_get(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) await hass.async_block_till_done() # Enable entity - er.async_update_entity(state_key, disabled_by=None) + entity_registry.async_update_entity(state_key, disabled_by=None) await hass.async_block_till_done() async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) await hass.async_block_till_done() @@ -82,14 +83,15 @@ async def test_device_tracker(hass: HomeAssistant, mock_device: MockDevice) -> N await hass.config_entries.async_unload(entry.entry_id) -async def test_restoring_clients(hass: HomeAssistant, mock_device: MockDevice) -> None: +async def test_restoring_clients( + hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry +) -> None: """Test restoring existing device_tracker entities.""" state_key = ( f"{PLATFORM}.{DOMAIN}_{SERIAL}_{STATION.mac_address.lower().replace(':', '_')}" ) entry = configure_integration(hass) - er = entity_registry.async_get(hass) - er.async_get_or_create( + entity_registry.async_get_or_create( PLATFORM, DOMAIN, f"{SERIAL}_{STATION.mac_address}", diff --git a/tests/components/devolo_home_network/test_sensor.py b/tests/components/devolo_home_network/test_sensor.py index ee4c5f782c..fc8afbe1ae 100644 --- a/tests/components/devolo_home_network/test_sensor.py +++ b/tests/components/devolo_home_network/test_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.devolo_home_network.const import ( from homeassistant.components.sensor import DOMAIN, SensorStateClass from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt from . import configure_integration @@ -78,13 +78,12 @@ async def test_update_connected_wifi_clients( @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_update_neighboring_wifi_networks( - hass: HomeAssistant, mock_device: MockDevice + hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry ) -> None: """Test state change of a neighboring_wifi_networks sensor device.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() state_key = f"{DOMAIN}.{device_name}_neighboring_wifi_networks" - er = entity_registry.async_get(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -95,7 +94,10 @@ async def test_update_neighboring_wifi_networks( state.attributes[ATTR_FRIENDLY_NAME] == f"{entry.title} Neighboring Wifi networks" ) - assert er.async_get(state_key).entity_category is EntityCategory.DIAGNOSTIC + assert ( + entity_registry.async_get(state_key).entity_category + is EntityCategory.DIAGNOSTIC + ) # Emulate device failure mock_device.device.async_get_wifi_neighbor_access_points = AsyncMock( @@ -122,13 +124,12 @@ async def test_update_neighboring_wifi_networks( @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_update_connected_plc_devices( - hass: HomeAssistant, mock_device: MockDevice + hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry ) -> None: """Test state change of a connected_plc_devices sensor device.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() state_key = f"{DOMAIN}.{device_name}_connected_plc_devices" - er = entity_registry.async_get(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -138,7 +139,10 @@ async def test_update_connected_plc_devices( assert ( state.attributes[ATTR_FRIENDLY_NAME] == f"{entry.title} Connected PLC devices" ) - assert er.async_get(state_key).entity_category is EntityCategory.DIAGNOSTIC + assert ( + entity_registry.async_get(state_key).entity_category + is EntityCategory.DIAGNOSTIC + ) # Emulate device failure mock_device.plcnet.async_get_network_overview = AsyncMock( diff --git a/tests/components/devolo_home_network/test_switch.py b/tests/components/devolo_home_network/test_switch.py index dfe2b1176c..257ccfbb6e 100644 --- a/tests/components/devolo_home_network/test_switch.py +++ b/tests/components/devolo_home_network/test_switch.py @@ -21,7 +21,7 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.update_coordinator import REQUEST_REFRESH_DEFAULT_COOLDOWN from homeassistant.util import dt @@ -157,7 +157,9 @@ async def test_update_enable_guest_wifi( await hass.config_entries.async_unload(entry.entry_id) -async def test_update_enable_leds(hass: HomeAssistant, mock_device: MockDevice) -> None: +async def test_update_enable_leds( + hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry +) -> None: """Test state change of a enable_leds switch device.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() @@ -170,8 +172,7 @@ async def test_update_enable_leds(hass: HomeAssistant, mock_device: MockDevice) assert state is not None assert state.state == STATE_OFF - er = entity_registry.async_get(hass) - assert er.async_get(state_key).entity_category == EntityCategory.CONFIG + assert entity_registry.async_get(state_key).entity_category == EntityCategory.CONFIG # Emulate state change mock_device.device.async_get_led_setting.return_value = True diff --git a/tests/components/dlna_dmr/test_init.py b/tests/components/dlna_dmr/test_init.py index be793d67c5..f1c3151fb2 100644 --- a/tests/components/dlna_dmr/test_init.py +++ b/tests/components/dlna_dmr/test_init.py @@ -5,7 +5,7 @@ from unittest.mock import Mock from homeassistant.components import media_player from homeassistant.components.dlna_dmr.const import DOMAIN as DLNA_DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -17,6 +17,7 @@ async def test_resource_lifecycle( config_entry_mock: MockConfigEntry, ssdp_scanner_mock: Mock, dmr_device_mock: Mock, + entity_registry: er.EntityRegistry, ) -> None: """Test that resources are acquired/released as the entity is setup/unloaded.""" # Set up the config entry @@ -25,8 +26,8 @@ async def test_resource_lifecycle( await hass.async_block_till_done() # Check the entity is created and working - entries = entity_registry.async_entries_for_config_entry( - entity_registry.async_get(hass), config_entry_mock.entry_id + entries = er.async_entries_for_config_entry( + entity_registry, config_entry_mock.entry_id ) assert len(entries) == 1 entity_id = entries[0].entity_id diff --git a/tests/components/flipr/test_binary_sensor.py b/tests/components/flipr/test_binary_sensor.py index fc24ddee34..fa938521d3 100644 --- a/tests/components/flipr/test_binary_sensor.py +++ b/tests/components/flipr/test_binary_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import patch from homeassistant.components.flipr.const import CONF_FLIPR_ID, DOMAIN from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as entity_reg +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry @@ -23,7 +23,7 @@ MOCK_FLIPR_MEASURE = { } -async def test_sensors(hass: HomeAssistant) -> None: +async def test_sensors(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test the creation and values of the Flipr binary sensors.""" entry = MockConfigEntry( domain=DOMAIN, @@ -37,8 +37,6 @@ async def test_sensors(hass: HomeAssistant) -> None: entry.add_to_hass(hass) - registry = entity_reg.async_get(hass) - with patch( "flipr_api.FliprAPIRestClient.get_pool_measure_latest", return_value=MOCK_FLIPR_MEASURE, @@ -47,7 +45,7 @@ async def test_sensors(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Check entity unique_id value that is generated in FliprEntity base class. - entity = registry.async_get("binary_sensor.flipr_myfliprid_ph_status") + entity = entity_registry.async_get("binary_sensor.flipr_myfliprid_ph_status") assert entity.unique_id == "myfliprid-ph_status" state = hass.states.get("binary_sensor.flipr_myfliprid_ph_status") diff --git a/tests/components/flipr/test_sensor.py b/tests/components/flipr/test_sensor.py index 75ab6ffd0b..cd31ec33c1 100644 --- a/tests/components/flipr/test_sensor.py +++ b/tests/components/flipr/test_sensor.py @@ -15,7 +15,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as entity_reg +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry @@ -34,7 +34,7 @@ MOCK_FLIPR_MEASURE = { } -async def test_sensors(hass: HomeAssistant) -> None: +async def test_sensors(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test the creation and values of the Flipr sensors.""" entry = MockConfigEntry( domain=DOMAIN, @@ -48,8 +48,6 @@ async def test_sensors(hass: HomeAssistant) -> None: entry.add_to_hass(hass) - registry = entity_reg.async_get(hass) - with patch( "flipr_api.FliprAPIRestClient.get_pool_measure_latest", return_value=MOCK_FLIPR_MEASURE, @@ -58,7 +56,7 @@ async def test_sensors(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Check entity unique_id value that is generated in FliprEntity base class. - entity = registry.async_get("sensor.flipr_myfliprid_red_ox") + entity = entity_registry.async_get("sensor.flipr_myfliprid_red_ox") assert entity.unique_id == "myfliprid-red_ox" state = hass.states.get("sensor.flipr_myfliprid_ph") @@ -104,7 +102,9 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "95.0" -async def test_error_flipr_api_sensors(hass: HomeAssistant) -> None: +async def test_error_flipr_api_sensors( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test the Flipr sensors error.""" entry = MockConfigEntry( domain=DOMAIN, @@ -118,8 +118,6 @@ async def test_error_flipr_api_sensors(hass: HomeAssistant) -> None: entry.add_to_hass(hass) - registry = entity_reg.async_get(hass) - with patch( "flipr_api.FliprAPIRestClient.get_pool_measure_latest", side_effect=FliprError("Error during flipr data retrieval..."), @@ -128,5 +126,5 @@ async def test_error_flipr_api_sensors(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Check entity is not generated because of the FliprError raised. - entity = registry.async_get("sensor.flipr_myfliprid_red_ox") + entity = entity_registry.async_get("sensor.flipr_myfliprid_red_ox") assert entity is None diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index a4fdf92895..8817349975 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -34,7 +34,7 @@ from homeassistant.const import ( HTTP_BASIC_AUTHENTICATION, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator @@ -809,11 +809,11 @@ async def test_reload_on_title_change(hass: HomeAssistant) -> None: assert hass.states.get("camera.my_title").attributes["friendly_name"] == "New Title" -async def test_migrate_existing_ids(hass: HomeAssistant) -> None: +async def test_migrate_existing_ids( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test that existing ids are migrated for issue #70568.""" - registry = entity_registry.async_get(hass) - test_data = TESTDATA_OPTIONS.copy() test_data[CONF_CONTENT_TYPE] = "image/png" old_unique_id = "54321" @@ -825,7 +825,7 @@ async def test_migrate_existing_ids(hass: HomeAssistant) -> None: new_unique_id = mock_entry.entry_id mock_entry.add_to_hass(hass) - entity_entry = registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( "camera", DOMAIN, old_unique_id, @@ -838,7 +838,7 @@ async def test_migrate_existing_ids(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() - entity_entry = registry.async_get(entity_id) + entity_entry = entity_registry.async_get(entity_id) assert entity_entry.unique_id == new_unique_id diff --git a/tests/components/harmony/test_switch.py b/tests/components/harmony/test_switch.py index ee276fdec9..58cbd3eac5 100644 --- a/tests/components/harmony/test_switch.py +++ b/tests/components/harmony/test_switch.py @@ -16,7 +16,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.util import utcnow from .const import ENTITY_PLAY_MUSIC, ENTITY_REMOTE, ENTITY_WATCH_TV, HUB_NAME @@ -25,7 +25,11 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_connection_state_changes( - harmony_client, mock_hc, hass: HomeAssistant, mock_write_config + harmony_client, + mock_hc, + hass: HomeAssistant, + mock_write_config, + entity_registry: er.EntityRegistry, ) -> None: """Ensure connection changes are reflected in the switch states.""" entry = MockConfigEntry( @@ -41,9 +45,8 @@ async def test_connection_state_changes( assert not hass.states.get(ENTITY_PLAY_MUSIC) # enable switch entities - ent_reg = entity_registry.async_get(hass) - ent_reg.async_update_entity(ENTITY_WATCH_TV, disabled_by=None) - ent_reg.async_update_entity(ENTITY_PLAY_MUSIC, disabled_by=None) + entity_registry.async_update_entity(ENTITY_WATCH_TV, disabled_by=None) + entity_registry.async_update_entity(ENTITY_PLAY_MUSIC, disabled_by=None) await hass.config_entries.async_reload(entry.entry_id) await hass.async_block_till_done() @@ -80,7 +83,9 @@ async def test_connection_state_changes( assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_OFF) -async def test_switch_toggles(mock_hc, hass: HomeAssistant, mock_write_config) -> None: +async def test_switch_toggles( + mock_hc, hass: HomeAssistant, mock_write_config, entity_registry: er.EntityRegistry +) -> None: """Ensure calls to the switch modify the harmony state.""" entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "192.0.2.0", CONF_NAME: HUB_NAME} @@ -91,9 +96,8 @@ async def test_switch_toggles(mock_hc, hass: HomeAssistant, mock_write_config) - await hass.async_block_till_done() # enable switch entities - ent_reg = entity_registry.async_get(hass) - ent_reg.async_update_entity(ENTITY_WATCH_TV, disabled_by=None) - ent_reg.async_update_entity(ENTITY_PLAY_MUSIC, disabled_by=None) + entity_registry.async_update_entity(ENTITY_WATCH_TV, disabled_by=None) + entity_registry.async_update_entity(ENTITY_PLAY_MUSIC, disabled_by=None) await hass.config_entries.async_reload(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/hassio/test_binary_sensor.py b/tests/components/hassio/test_binary_sensor.py index a172771cee..133074d7c9 100644 --- a/tests/components/hassio/test_binary_sensor.py +++ b/tests/components/hassio/test_binary_sensor.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components.hassio import DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -157,7 +157,11 @@ def mock_all(aioclient_mock, request): ], ) async def test_binary_sensor( - hass: HomeAssistant, entity_id, expected, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + entity_id, + expected, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test hassio OS and addons binary sensor.""" config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) @@ -176,8 +180,7 @@ async def test_binary_sensor( assert hass.states.get(entity_id) is None # Enable the entity. - ent_reg = entity_registry.async_get(hass) - ent_reg.async_update_entity(entity_id, disabled_by=None) + entity_registry.async_update_entity(entity_id, disabled_by=None) await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/hassio/test_sensor.py b/tests/components/hassio/test_sensor.py index 225824e535..4088ba631f 100644 --- a/tests/components/hassio/test_sensor.py +++ b/tests/components/hassio/test_sensor.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components.hassio import DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -158,7 +158,11 @@ def mock_all(aioclient_mock, request): ], ) async def test_sensor( - hass: HomeAssistant, entity_id, expected, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + entity_id, + expected, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test hassio OS and addons sensor.""" config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) @@ -177,8 +181,7 @@ async def test_sensor( assert hass.states.get(entity_id) is None # Enable the entity. - ent_reg = entity_registry.async_get(hass) - ent_reg.async_update_entity(entity_id, disabled_by=None) + entity_registry.async_update_entity(entity_id, disabled_by=None) await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() From 1fa3f324745053dce0bbfc29e6b090618c7f8515 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 09:41:55 +0100 Subject: [PATCH 0547/2810] Add missing mock in notion tests (#88951) --- tests/components/notion/conftest.py | 10 ++++++++++ tests/components/notion/test_config_flow.py | 6 ++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/components/notion/conftest.py b/tests/components/notion/conftest.py index a66d99d41c..7484e8a997 100644 --- a/tests/components/notion/conftest.py +++ b/tests/components/notion/conftest.py @@ -1,4 +1,5 @@ """Define fixtures for Notion tests.""" +from collections.abc import Generator import json from unittest.mock import AsyncMock, Mock, patch @@ -13,6 +14,15 @@ TEST_USERNAME = "user@host.com" TEST_PASSWORD = "password123" +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.notion.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(name="client") def client_fixture(data_bridge, data_sensor, data_task): """Define a fixture for an aionotion client.""" diff --git a/tests/components/notion/test_config_flow.py b/tests/components/notion/test_config_flow.py index d0d1cad035..e9f340fae1 100644 --- a/tests/components/notion/test_config_flow.py +++ b/tests/components/notion/test_config_flow.py @@ -12,6 +12,8 @@ from homeassistant.core import HomeAssistant from .conftest import TEST_PASSWORD, TEST_USERNAME +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + @pytest.mark.parametrize( ("get_client_with_exception", "errors"), @@ -58,7 +60,7 @@ async def test_create_entry( } -async def test_duplicate_error(hass: HomeAssistant, config, setup_config_entry) -> None: +async def test_duplicate_error(hass: HomeAssistant, config, config_entry) -> None: """Test that errors are shown when duplicates are added.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=config @@ -81,7 +83,7 @@ async def test_reauth( config_entry, errors, get_client_with_exception, - setup_config_entry, + mock_aionotion, ) -> None: """Test that re-auth works.""" result = await hass.config_entries.flow.async_init( From ed3cdd8fb9aec598819152fd85de520218699461 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 09:42:55 +0100 Subject: [PATCH 0548/2810] Fix lingering task in timeout test (#88953) --- tests/util/test_timeout.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/util/test_timeout.py b/tests/util/test_timeout.py index 60c87fa743..e89c6cd3f0 100644 --- a/tests/util/test_timeout.py +++ b/tests/util/test_timeout.py @@ -273,6 +273,9 @@ async def test_mix_zone_timeout_trigger_global_cool_down() -> None: await asyncio.sleep(0.2) + # Cleanup lingering (cool_down) task after test is done + await asyncio.sleep(0.3) + async def test_simple_zone_timeout_freeze_without_timeout_cleanup( hass: HomeAssistant, From 6febe00516cc32989048bcef0a707072b56c5acf Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 10:49:13 +0100 Subject: [PATCH 0549/2810] Fix lingering task in entity_platform test (#88957) * Fix lingering task in entity_platform test * Speed up the test --- tests/helpers/test_entity_platform.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 441f47ff12..597045f557 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -233,7 +233,7 @@ async def test_platform_error_slow_setup( async def setup_platform(*args): called.append(1) - await asyncio.sleep(1) + await asyncio.sleep(0.1) platform = MockPlatform(async_setup_platform=setup_platform) component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -244,6 +244,9 @@ async def test_platform_error_slow_setup( assert "test_domain.test_platform" not in hass.config.components assert "test_platform is taking longer than 0 seconds" in caplog.text + # Cleanup lingering (setup_platform) task after test is done + await asyncio.sleep(0.1) + async def test_updated_state_used_for_entity_id(hass: HomeAssistant) -> None: """Test that first update results used for entity ID generation.""" From 29b049fc57902b20a6e6db9c0c85b798fb5ba23c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 1 Mar 2023 11:11:29 +0100 Subject: [PATCH 0550/2810] Don't create new venv if script/setup is run from within a venv (#88906) --- script/setup | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/script/setup b/script/setup index 9df3fdb25d..782eb51065 100755 --- a/script/setup +++ b/script/setup @@ -16,7 +16,7 @@ fi mkdir -p config -if [ ! -n "$DEVCONTAINER" ];then +if [ ! -n "$DEVCONTAINER" ] && [ ! -n "$VIRTUAL_ENV" ];then python3 -m venv venv source venv/bin/activate fi @@ -36,4 +36,4 @@ logger: logs: homeassistant.components.cloud: debug " >> config/configuration.yaml -fi \ No newline at end of file +fi From fca5cc6ea3c3e96fa4224eb73821fe6695c33177 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 1 Mar 2023 11:22:57 +0100 Subject: [PATCH 0551/2810] Add number + sensor device class volume storage (#88312) * Add number + sensor device class volume storage * Fix typo * Format code * Update device automations --- homeassistant/components/number/const.py | 13 +++++++++++++ homeassistant/components/sensor/const.py | 14 ++++++++++++++ .../components/sensor/device_condition.py | 1 + homeassistant/components/sensor/device_trigger.py | 1 + tests/components/sensor/test_device_condition.py | 2 ++ tests/components/sensor/test_device_trigger.py | 2 ++ 6 files changed, 33 insertions(+) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index aa57bb2b71..48cd04dc26 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -324,6 +324,18 @@ class NumberDeviceClass(StrEnum): USCS/imperial units are currently assumed to be US volumes) """ + VOLUME_STORAGE = "volume_storage" + """Generic stored volume. + + Use this device class for sensors measuring stored volume, for example the amount + of fuel in a fuel tank. + + Unit of measurement: `VOLUME_*` units + - SI / metric: `mL`, `L`, `m³` + - USCS / imperial: `ft³`, `CCF`, `fl. oz.`, `gal` (warning: volumes expressed in + USCS/imperial units are currently assumed to be US volumes) + """ + WATER = "water" """Water. @@ -411,6 +423,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { }, NumberDeviceClass.VOLTAGE: set(UnitOfElectricPotential), NumberDeviceClass.VOLUME: set(UnitOfVolume), + NumberDeviceClass.VOLUME_STORAGE: set(UnitOfVolume), NumberDeviceClass.WATER: { UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CUBIC_FEET, diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 8ded1c304d..356eb68b4d 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -362,6 +362,18 @@ class SensorDeviceClass(StrEnum): USCS/imperial units are currently assumed to be US volumes) """ + VOLUME_STORAGE = "volume_storage" + """Generic stored volume. + + Use this device class for sensors measuring stored volume, for example the amount + of fuel in a fuel tank. + + Unit of measurement: `VOLUME_*` units + - SI / metric: `mL`, `L`, `m³` + - USCS / imperial: `ft³`, `CCF`, `fl. oz.`, `gal` (warning: volumes expressed in + USCS/imperial units are currently assumed to be US volumes) + """ + WATER = "water" """Water. @@ -451,6 +463,7 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = SensorDeviceClass.TEMPERATURE: TemperatureConverter, SensorDeviceClass.VOLTAGE: ElectricPotentialConverter, SensorDeviceClass.VOLUME: VolumeConverter, + SensorDeviceClass.VOLUME_STORAGE: VolumeConverter, SensorDeviceClass.WATER: VolumeConverter, SensorDeviceClass.WEIGHT: MassConverter, SensorDeviceClass.WIND_SPEED: SpeedConverter, @@ -573,6 +586,7 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { SensorStateClass.TOTAL, SensorStateClass.TOTAL_INCREASING, }, + SensorDeviceClass.VOLUME_STORAGE: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.WATER: { SensorStateClass.TOTAL, SensorStateClass.TOTAL_INCREASING, diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index 6ed47cbf63..8547827d74 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -122,6 +122,7 @@ ENTITY_CONDITIONS = { ], SensorDeviceClass.VOLTAGE: [{CONF_TYPE: CONF_IS_VOLTAGE}], SensorDeviceClass.VOLUME: [{CONF_TYPE: CONF_IS_VOLUME}], + SensorDeviceClass.VOLUME_STORAGE: [{CONF_TYPE: CONF_IS_VOLUME}], SensorDeviceClass.WATER: [{CONF_TYPE: CONF_IS_WATER}], SensorDeviceClass.WEIGHT: [{CONF_TYPE: CONF_IS_WEIGHT}], SensorDeviceClass.WIND_SPEED: [{CONF_TYPE: CONF_IS_WIND_SPEED}], diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index 7e498321b3..3b2a048555 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -121,6 +121,7 @@ ENTITY_TRIGGERS = { ], SensorDeviceClass.VOLTAGE: [{CONF_TYPE: CONF_VOLTAGE}], SensorDeviceClass.VOLUME: [{CONF_TYPE: CONF_VOLUME}], + SensorDeviceClass.VOLUME_STORAGE: [{CONF_TYPE: CONF_VOLUME}], SensorDeviceClass.WATER: [{CONF_TYPE: CONF_WATER}], SensorDeviceClass.WEIGHT: [{CONF_TYPE: CONF_WEIGHT}], SensorDeviceClass.WIND_SPEED: [{CONF_TYPE: CONF_WIND_SPEED}], diff --git a/tests/components/sensor/test_device_condition.py b/tests/components/sensor/test_device_condition.py index 24d480e24b..5e93bf2a64 100644 --- a/tests/components/sensor/test_device_condition.py +++ b/tests/components/sensor/test_device_condition.py @@ -52,6 +52,7 @@ def test_matches_device_classes(device_class: SensorDeviceClass) -> None: SensorDeviceClass.CO: "CONF_IS_CO", SensorDeviceClass.CO2: "CONF_IS_CO2", SensorDeviceClass.ENERGY_STORAGE: "CONF_IS_ENERGY", + SensorDeviceClass.VOLUME_STORAGE: "CONF_IS_VOLUME", }.get(device_class, f"CONF_IS_{device_class.value.upper()}") assert hasattr(device_condition, constant_name), f"Missing constant {constant_name}" @@ -59,6 +60,7 @@ def test_matches_device_classes(device_class: SensorDeviceClass) -> None: constant_value = { SensorDeviceClass.BATTERY: "is_battery_level", SensorDeviceClass.ENERGY_STORAGE: "is_energy", + SensorDeviceClass.VOLUME_STORAGE: "is_volume", }.get(device_class, f"is_{device_class.value}") assert getattr(device_condition, constant_name) == constant_value diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index 34b5d6fb40..37f44a5b40 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -56,6 +56,7 @@ def test_matches_device_classes(device_class: SensorDeviceClass) -> None: SensorDeviceClass.CO: "CONF_CO", SensorDeviceClass.CO2: "CONF_CO2", SensorDeviceClass.ENERGY_STORAGE: "CONF_ENERGY", + SensorDeviceClass.VOLUME_STORAGE: "CONF_VOLUME", }.get(device_class, f"CONF_{device_class.value.upper()}") assert hasattr(device_trigger, constant_name), f"Missing constant {constant_name}" @@ -63,6 +64,7 @@ def test_matches_device_classes(device_class: SensorDeviceClass) -> None: constant_value = { SensorDeviceClass.BATTERY: "battery_level", SensorDeviceClass.ENERGY_STORAGE: "energy", + SensorDeviceClass.VOLUME_STORAGE: "volume", }.get(device_class, device_class.value) assert getattr(device_trigger, constant_name) == constant_value From 42a69566ac8ef8bed9042b62ba631d97c5ba918c Mon Sep 17 00:00:00 2001 From: Mitch Date: Wed, 1 Mar 2023 11:39:14 +0100 Subject: [PATCH 0552/2810] 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 91b0a9eb19..cda1e9b02d 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 58eb01371b..07e0316ab8 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 7e23d75ecb..e1cf843250 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -903,7 +903,7 @@ notify-events==1.0.4 nsw-fuel-api-client==1.1.0 # homeassistant.components.nuheat -nuheat==1.0.0 +nuheat==1.0.1 # homeassistant.components.numato numato-gpio==0.10.0 From ab9bd5c29e4f53242db184c73c2a86b100cf49fb Mon Sep 17 00:00:00 2001 From: Aaron Godfrey Date: Wed, 1 Mar 2023 03:01:54 -0800 Subject: [PATCH 0553/2810] 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 0a822d0515..8fdafee6cf 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 4b55ac6859..fece314c91 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -1,4 +1,5 @@ """Unit tests for the Todoist calendar platform.""" +from datetime import datetime from unittest.mock import AsyncMock, patch import pytest @@ -9,6 +10,7 @@ from homeassistant.components.todoist.calendar import DOMAIN from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry +from homeassistant.helpers.entity_component import async_update_entity @pytest.fixture(name="task") @@ -23,9 +25,11 @@ def mock_task() -> Task: created_at="2021-10-01T00:00:00", creator_id="1", description="A task", - due=Due(is_recurring=False, date="2022-01-01", string="today"), + due=Due( + is_recurring=False, date=datetime.now().strftime("%Y-%m-%d"), string="today" + ), id="1", - labels=[], + labels=["Label1"], order=1, parent_id=None, priority=1, @@ -37,7 +41,7 @@ def mock_task() -> Task: @pytest.fixture(name="api") -def mock_api() -> AsyncMock: +def mock_api(task) -> AsyncMock: """Mock the api state.""" api = AsyncMock() api.get_projects.return_value = [ @@ -57,9 +61,10 @@ def mock_api() -> AsyncMock: ) ] api.get_labels.return_value = [ - Label(id="1", name="label1", color="1", order=1, is_favorite=False) + Label(id="1", name="Label1", color="1", order=1, is_favorite=False) ] api.get_collaborators.return_value = [] + api.get_tasks.return_value = [task] return api @@ -84,6 +89,29 @@ async def test_calendar_entity_unique_id(todoist_api, hass: HomeAssistant, api) assert entity.unique_id == "12345" +@patch("homeassistant.components.todoist.calendar.TodoistAPIAsync") +async def test_update_entity_for_custom_project_with_labels_on(todoist_api, hass, api): + """Test that the calendar's state is on for a custom project using labels.""" + todoist_api.return_value = api + assert await setup.async_setup_component( + hass, + "calendar", + { + "calendar": { + "platform": DOMAIN, + CONF_TOKEN: "token", + "custom_projects": [{"name": "All projects", "labels": ["Label1"]}], + } + }, + ) + await hass.async_block_till_done() + + await async_update_entity(hass, "calendar.all_projects") + state = hass.states.get("calendar.all_projects") + assert state.attributes["labels"] == ["Label1"] + assert state.state == "on" + + @patch("homeassistant.components.todoist.calendar.TodoistAPIAsync") async def test_calendar_custom_project_unique_id( todoist_api, hass: HomeAssistant, api From b75879194dbe79b97d40241ae6db391d41470b80 Mon Sep 17 00:00:00 2001 From: RogerSelwyn Date: Wed, 1 Mar 2023 11:34:41 +0000 Subject: [PATCH 0554/2810] 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 3f8fb0c680..21ef280936 100644 --- a/homeassistant/components/geniushub/climate.py +++ b/homeassistant/components/geniushub/climate.py @@ -79,10 +79,10 @@ class GeniusClimateZone(GeniusHeatingZone, ClimateEntity): def hvac_action(self) -> str | None: """Return the current running hvac operation if supported.""" if "_state" in self._zone.data: # only for v3 API + if self._zone.data["output"] == 1: + return HVACAction.HEATING if not self._zone.data["_state"].get("bIsActive"): return HVACAction.OFF - if self._zone.data["_state"].get("bOutRequestHeat"): - return HVACAction.HEATING return HVACAction.IDLE return None From 85f2693353b006855d35c011c29d8c807343a8d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Guardia?= <79667811+FredericGuardia@users.noreply.github.com> Date: Wed, 1 Mar 2023 12:54:07 +0100 Subject: [PATCH 0555/2810] 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 af203906b8..b248ffbac2 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 a04c74259d..33eac82a6b 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -1101,7 +1101,7 @@ async def test_temperature_control(hass: HomeAssistant) -> None: BASIC_CONFIG, ) assert trt.sync_attributes() == { - "queryOnlyTemperatureSetting": True, + "queryOnlyTemperatureControl": True, "temperatureUnitForUX": "C", "temperatureRange": {"maxThresholdCelsius": 100, "minThresholdCelsius": -100}, } @@ -2941,7 +2941,7 @@ async def test_temperature_control_sensor_data( ) assert trt.sync_attributes() == { - "queryOnlyTemperatureSetting": True, + "queryOnlyTemperatureControl": True, "temperatureUnitForUX": unit_out, "temperatureRange": {"maxThresholdCelsius": 100, "minThresholdCelsius": -100}, } From 0c66346fb089acee3cd6bb3fed761679fc371e9d Mon Sep 17 00:00:00 2001 From: Thibaut Date: Wed, 1 Mar 2023 13:46:26 +0100 Subject: [PATCH 0556/2810] Add dynamic unit of measurement support for Overkiz sensor (#80490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add dynamic unit support * Import all units * Fix typing * Add fallback to CORE_ELECTRIC_POWER_CONSUMPTION_STATE_MEASURED_VALUE_TYPE * Fix rebase * Give priority to the more accurate attribute * Don’t use hardcoded seconds unit * Don’t change SensorDescription * Rework comment --- homeassistant/components/overkiz/const.py | 62 +++++++++++++++++++++- homeassistant/components/overkiz/sensor.py | 30 ++++++++++- 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py index 806ba435c2..0db01a2d84 100644 --- a/homeassistant/components/overkiz/const.py +++ b/homeassistant/components/overkiz/const.py @@ -5,9 +5,28 @@ from datetime import timedelta import logging from typing import Final -from pyoverkiz.enums import OverkizCommandParam, UIClass, UIWidget +from pyoverkiz.enums import MeasuredValueType, OverkizCommandParam, UIClass, UIWidget -from homeassistant.const import Platform +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + DEGREE, + LIGHT_LUX, + PERCENTAGE, + Platform, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfIrradiance, + UnitOfLength, + UnitOfPower, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, + UnitOfTime, + UnitOfVolume, + UnitOfVolumeFlowRate, +) DOMAIN: Final = "overkiz" LOGGER: logging.Logger = logging.getLogger(__package__) @@ -98,3 +117,42 @@ OVERKIZ_STATE_TO_TRANSLATION: dict[str, str] = { OverkizCommandParam.SFC: "sfc", OverkizCommandParam.UPS: "ups", } + +OVERKIZ_UNIT_TO_HA: dict[str, str] = { + MeasuredValueType.ABSOLUTE_VALUE: "", + MeasuredValueType.ANGLE_IN_DEGREES: DEGREE, + MeasuredValueType.ANGULAR_SPEED_IN_DEGREES_PER_SECOND: f"{DEGREE}/{UnitOfTime.SECONDS}", + MeasuredValueType.ELECTRICAL_ENERGY_IN_KWH: UnitOfEnergy.KILO_WATT_HOUR, + MeasuredValueType.ELECTRICAL_ENERGY_IN_WH: UnitOfEnergy.WATT_HOUR, + MeasuredValueType.ELECTRICAL_POWER_IN_KW: UnitOfPower.KILO_WATT, + MeasuredValueType.ELECTRICAL_POWER_IN_W: UnitOfPower.WATT, + MeasuredValueType.ELECTRIC_CURRENT_IN_AMPERE: UnitOfElectricCurrent.AMPERE, + MeasuredValueType.ELECTRIC_CURRENT_IN_MILLI_AMPERE: UnitOfElectricCurrent.MILLIAMPERE, + MeasuredValueType.ENERGY_IN_CAL: "cal", + MeasuredValueType.ENERGY_IN_KCAL: "kcal", + MeasuredValueType.FLOW_IN_LITRE_PER_SECOND: f"{UnitOfVolume.LITERS}/{UnitOfTime.SECONDS}", + MeasuredValueType.FLOW_IN_METER_CUBE_PER_HOUR: UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + MeasuredValueType.FLOW_IN_METER_CUBE_PER_SECOND: f"{UnitOfVolume.CUBIC_METERS}/{UnitOfTime.SECONDS}", + MeasuredValueType.FOSSIL_ENERGY_IN_WH: UnitOfEnergy.WATT_HOUR, + MeasuredValueType.GRADIENT_IN_PERCENTAGE_PER_SECOND: f"{PERCENTAGE}/{UnitOfTime.SECONDS}", + MeasuredValueType.LENGTH_IN_METER: UnitOfLength.METERS, + MeasuredValueType.LINEAR_SPEED_IN_METER_PER_SECOND: UnitOfSpeed.METERS_PER_SECOND, + MeasuredValueType.LUMINANCE_IN_LUX: LIGHT_LUX, + MeasuredValueType.PARTS_PER_BILLION: CONCENTRATION_PARTS_PER_BILLION, + MeasuredValueType.PARTS_PER_MILLION: CONCENTRATION_PARTS_PER_MILLION, + MeasuredValueType.PARTS_PER_QUADRILLION: "ppq", + MeasuredValueType.PARTS_PER_TRILLION: "ppt", + MeasuredValueType.POWER_PER_SQUARE_METER: UnitOfIrradiance.WATTS_PER_SQUARE_METER, + MeasuredValueType.PRESSURE_IN_HPA: UnitOfPressure.HPA, + MeasuredValueType.PRESSURE_IN_MILLI_BAR: UnitOfPressure.MBAR, + MeasuredValueType.RELATIVE_VALUE_IN_PERCENTAGE: PERCENTAGE, + MeasuredValueType.TEMPERATURE_IN_CELCIUS: UnitOfTemperature.CELSIUS, + MeasuredValueType.TEMPERATURE_IN_KELVIN: UnitOfTemperature.KELVIN, + MeasuredValueType.TIME_IN_SECOND: UnitOfTime.SECONDS, + # MeasuredValueType.VECTOR_COORDINATE: "", + MeasuredValueType.VOLTAGE_IN_MILLI_VOLT: UnitOfElectricPotential.MILLIVOLT, + MeasuredValueType.VOLTAGE_IN_VOLT: UnitOfElectricPotential.VOLT, + MeasuredValueType.VOLUME_IN_CUBIC_METER: UnitOfVolume.CUBIC_METERS, + MeasuredValueType.VOLUME_IN_GALLON: UnitOfVolume.GALLONS, + MeasuredValueType.VOLUME_IN_LITER: UnitOfVolume.LITERS, +} diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index 1e37d938cc..4c70bab70f 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -34,7 +34,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from . import HomeAssistantOverkizData -from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES, OVERKIZ_STATE_TO_TRANSLATION +from .const import ( + DOMAIN, + IGNORED_OVERKIZ_DEVICES, + OVERKIZ_STATE_TO_TRANSLATION, + OVERKIZ_UNIT_TO_HA, +) from .coordinator import OverkizDataUpdateCoordinator from .entity import OverkizDescriptiveEntity, OverkizEntity @@ -473,6 +478,29 @@ class OverkizStateSensor(OverkizDescriptiveEntity, SensorEntity): return state.value + @property + def native_unit_of_measurement(self) -> str | None: + """Return the unit of measurement.""" + if ( + not (default_unit := self.entity_description.native_unit_of_measurement) + or not (state := self.device.states.get(self.entity_description.key)) + or not state.value + ): + return default_unit + + attrs = self.device.attributes + if (unit := attrs[f"{state.name}MeasuredValueType"]) and ( + unit_value := unit.value_as_str + ): + return OVERKIZ_UNIT_TO_HA.get(unit_value, default_unit) + + if (unit := attrs[OverkizAttribute.CORE_MEASURED_VALUE_TYPE]) and ( + unit_value := unit.value_as_str + ): + return OVERKIZ_UNIT_TO_HA.get(unit_value, default_unit) + + return default_unit + class OverkizHomeKitSetupCodeSensor(OverkizEntity, SensorEntity): """Representation of an Overkiz HomeKit Setup Code.""" From 23cdafd12f5a04a28d91a413471277eeca9d6ceb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 15:26:39 +0100 Subject: [PATCH 0557/2810] Use UnitOfVolumeFlowRate in huisbaasje and plugwise (#88967) --- homeassistant/components/huisbaasje/const.py | 4 ---- homeassistant/components/huisbaasje/sensor.py | 11 ++++++++--- homeassistant/components/plugwise/sensor.py | 4 ++-- tests/components/huisbaasje/test_sensor.py | 4 ++-- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/huisbaasje/const.py b/homeassistant/components/huisbaasje/const.py index 9931b33a99..f908483126 100644 --- a/homeassistant/components/huisbaasje/const.py +++ b/homeassistant/components/huisbaasje/const.py @@ -8,14 +8,10 @@ from energyflip.const import ( SOURCE_TYPE_GAS, ) -from homeassistant.const import UnitOfTime, UnitOfVolume - DATA_COORDINATOR = "coordinator" DOMAIN = "huisbaasje" -FLOW_CUBIC_METERS_PER_HOUR = f"{UnitOfVolume.CUBIC_METERS}/{UnitOfTime.HOURS}" - """Interval in seconds between polls to huisbaasje.""" POLLING_INTERVAL = 20 diff --git a/homeassistant/components/huisbaasje/sensor.py b/homeassistant/components/huisbaasje/sensor.py index f73d4bf312..369c6eba07 100644 --- a/homeassistant/components/huisbaasje/sensor.py +++ b/homeassistant/components/huisbaasje/sensor.py @@ -21,7 +21,13 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ID, UnitOfEnergy, UnitOfPower, UnitOfVolume +from homeassistant.const import ( + CONF_ID, + UnitOfEnergy, + UnitOfPower, + UnitOfVolume, + UnitOfVolumeFlowRate, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( @@ -32,7 +38,6 @@ from homeassistant.helpers.update_coordinator import ( from .const import ( DATA_COORDINATOR, DOMAIN, - FLOW_CUBIC_METERS_PER_HOUR, SENSOR_TYPE_RATE, SENSOR_TYPE_THIS_DAY, SENSOR_TYPE_THIS_MONTH, @@ -179,7 +184,7 @@ SENSORS_INFO = [ ), HuisbaasjeSensorEntityDescription( name="Huisbaasje Current Gas", - native_unit_of_measurement=FLOW_CUBIC_METERS_PER_HOUR, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, sensor_type=SENSOR_TYPE_RATE, state_class=SensorStateClass.MEASUREMENT, key=SOURCE_TYPE_GAS, diff --git a/homeassistant/components/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py index 354656ecd9..d708fe741c 100644 --- a/homeassistant/components/plugwise/sensor.py +++ b/homeassistant/components/plugwise/sensor.py @@ -17,8 +17,8 @@ from homeassistant.const import ( UnitOfPower, UnitOfPressure, UnitOfTemperature, - UnitOfTime, UnitOfVolume, + UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -305,7 +305,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( key="gas_consumed_interval", name="Gas consumed interval", icon="mdi:meter-gas", - native_unit_of_measurement=f"{UnitOfVolume.CUBIC_METERS}/{UnitOfTime.HOURS}", + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( diff --git a/tests/components/huisbaasje/test_sensor.py b/tests/components/huisbaasje/test_sensor.py index 53734cde48..d3a65a0be6 100644 --- a/tests/components/huisbaasje/test_sensor.py +++ b/tests/components/huisbaasje/test_sensor.py @@ -2,7 +2,6 @@ from unittest.mock import patch from homeassistant.components import huisbaasje -from homeassistant.components.huisbaasje.const import FLOW_CUBIC_METERS_PER_HOUR from homeassistant.components.sensor import ( ATTR_STATE_CLASS, SensorDeviceClass, @@ -18,6 +17,7 @@ from homeassistant.const import ( UnitOfEnergy, UnitOfPower, UnitOfVolume, + UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant @@ -292,7 +292,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: ) assert ( current_gas.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == FLOW_CUBIC_METERS_PER_HOUR + == UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR ) gas_today = hass.states.get("sensor.huisbaasje_gas_today") From 9762b684c248a65ce82340236adf026333a2de4d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 16:04:40 +0100 Subject: [PATCH 0558/2810] Adjust entity registry access in tests (3) (#88964) --- tests/components/sonos/test_number.py | 6 +- tests/components/sonos/test_sensor.py | 45 ++++++----- tests/components/sonos/test_switch.py | 19 ++--- .../components/template/test_binary_sensor.py | 33 ++++---- tests/components/template/test_sensor.py | 39 ++++----- .../components/universal/test_media_player.py | 12 ++- tests/components/utility_meter/test_sensor.py | 13 +-- tests/components/webostv/test_media_player.py | 14 ++-- tests/components/wemo/test_wemo_device.py | 21 ++--- tests/components/whirlpool/test_sensor.py | 19 ++--- .../xiaomi_ble/test_device_trigger.py | 55 +++++++------ tests/components/zha/common.py | 8 +- tests/components/zha/test_discover.py | 6 +- .../components/zwave_js/test_device_action.py | 52 ++++++------ .../zwave_js/test_device_condition.py | 79 +++++++++++++------ tests/components/zwave_js/test_fan.py | 41 ++++++---- 16 files changed, 252 insertions(+), 210 deletions(-) diff --git a/tests/components/sonos/test_number.py b/tests/components/sonos/test_number.py index f63c1a1ee0..a393f699a5 100644 --- a/tests/components/sonos/test_number.py +++ b/tests/components/sonos/test_number.py @@ -4,15 +4,13 @@ from unittest.mock import patch from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as ent_reg +from homeassistant.helpers import entity_registry as er async def test_number_entities( - hass: HomeAssistant, async_autosetup_sonos, soco + hass: HomeAssistant, async_autosetup_sonos, soco, entity_registry: er.EntityRegistry ) -> None: """Test number entities.""" - entity_registry = ent_reg.async_get(hass) - bass_number = entity_registry.entities["number.zone_a_bass"] bass_state = hass.states.get(bass_number.entity_id) assert bass_state.state == "1" diff --git a/tests/components/sonos/test_sensor.py b/tests/components/sonos/test_sensor.py index 6c66435e64..2d7a9322ae 100644 --- a/tests/components/sonos/test_sensor.py +++ b/tests/components/sonos/test_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.sonos.binary_sensor import ATTR_BATTERY_POWER_SOUR from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as ent_reg +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from .conftest import SonosMockEvent @@ -19,37 +19,31 @@ from tests.common import async_fire_time_changed async def test_entity_registry_unsupported( - hass: HomeAssistant, async_setup_sonos, soco + hass: HomeAssistant, async_setup_sonos, soco, entity_registry: er.EntityRegistry ) -> None: """Test sonos device without battery registered in the device registry.""" soco.get_battery_info.side_effect = NotSupportedException await async_setup_sonos() - entity_registry = ent_reg.async_get(hass) - assert "media_player.zone_a" in entity_registry.entities assert "sensor.zone_a_battery" not in entity_registry.entities assert "binary_sensor.zone_a_power" not in entity_registry.entities async def test_entity_registry_supported( - hass: HomeAssistant, async_autosetup_sonos, soco + hass: HomeAssistant, async_autosetup_sonos, soco, entity_registry: er.EntityRegistry ) -> None: """Test sonos device with battery registered in the device registry.""" - entity_registry = ent_reg.async_get(hass) - assert "media_player.zone_a" in entity_registry.entities assert "sensor.zone_a_battery" in entity_registry.entities assert "binary_sensor.zone_a_power" in entity_registry.entities async def test_battery_attributes( - hass: HomeAssistant, async_autosetup_sonos, soco + hass: HomeAssistant, async_autosetup_sonos, soco, entity_registry: er.EntityRegistry ) -> None: """Test sonos device with battery state.""" - entity_registry = ent_reg.async_get(hass) - battery = entity_registry.entities["sensor.zone_a_battery"] battery_state = hass.states.get(battery.entity_id) assert battery_state.state == "100" @@ -64,7 +58,11 @@ async def test_battery_attributes( async def test_battery_on_s1( - hass: HomeAssistant, async_setup_sonos, soco, device_properties_event + hass: HomeAssistant, + async_setup_sonos, + soco, + device_properties_event, + entity_registry: er.EntityRegistry, ) -> None: """Test battery state updates on a Sonos S1 device.""" soco.get_battery_info.return_value = {} @@ -74,8 +72,6 @@ async def test_battery_on_s1( subscription = soco.deviceProperties.subscribe.return_value sub_callback = subscription.callback - entity_registry = ent_reg.async_get(hass) - assert "sensor.zone_a_battery" not in entity_registry.entities assert "binary_sensor.zone_a_power" not in entity_registry.entities @@ -142,11 +138,14 @@ async def test_device_payload_without_battery_and_ignored_keys( async def test_audio_input_sensor( - hass: HomeAssistant, async_autosetup_sonos, soco, tv_event, no_media_event + hass: HomeAssistant, + async_autosetup_sonos, + soco, + tv_event, + no_media_event, + entity_registry: er.EntityRegistry, ) -> None: """Test audio input sensor.""" - entity_registry = ent_reg.async_get(hass) - subscription = soco.avTransport.subscribe.return_value sub_callback = subscription.callback sub_callback(tv_event) @@ -183,10 +182,13 @@ async def test_audio_input_sensor( async def test_microphone_binary_sensor( - hass: HomeAssistant, async_autosetup_sonos, soco, device_properties_event + hass: HomeAssistant, + async_autosetup_sonos, + soco, + device_properties_event, + entity_registry: er.EntityRegistry, ) -> None: """Test microphone binary sensor.""" - entity_registry = ent_reg.async_get(hass) assert "binary_sensor.zone_a_microphone" in entity_registry.entities mic_binary_sensor = entity_registry.entities["binary_sensor.zone_a_microphone"] @@ -203,10 +205,13 @@ async def test_microphone_binary_sensor( async def test_favorites_sensor( - hass: HomeAssistant, async_autosetup_sonos, soco, fire_zgs_event + hass: HomeAssistant, + async_autosetup_sonos, + soco, + fire_zgs_event, + entity_registry: er.EntityRegistry, ) -> None: """Test Sonos favorites sensor.""" - entity_registry = ent_reg.async_get(hass) favorites = entity_registry.entities["sensor.sonos_favorites"] assert hass.states.get(favorites.entity_id) is None diff --git a/tests/components/sonos/test_switch.py b/tests/components/sonos/test_switch.py index fbb3f42036..405d99f5a1 100644 --- a/tests/components/sonos/test_switch.py +++ b/tests/components/sonos/test_switch.py @@ -15,7 +15,7 @@ from homeassistant.components.sonos.switch import ( from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ATTR_TIME, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as ent_reg +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt from .conftest import SonosMockEvent @@ -23,10 +23,10 @@ from .conftest import SonosMockEvent from tests.common import async_fire_time_changed -async def test_entity_registry(hass: HomeAssistant, async_autosetup_sonos) -> None: +async def test_entity_registry( + hass: HomeAssistant, async_autosetup_sonos, entity_registry: er.EntityRegistry +) -> None: """Test sonos device with alarm registered in the device registry.""" - entity_registry = ent_reg.async_get(hass) - assert "media_player.zone_a" in entity_registry.entities assert "switch.sonos_alarm_14" in entity_registry.entities assert "switch.zone_a_status_light" in entity_registry.entities @@ -39,11 +39,13 @@ async def test_entity_registry(hass: HomeAssistant, async_autosetup_sonos) -> No async def test_switch_attributes( - hass: HomeAssistant, async_autosetup_sonos, soco, fire_zgs_event + hass: HomeAssistant, + async_autosetup_sonos, + soco, + fire_zgs_event, + entity_registry: er.EntityRegistry, ) -> None: """Test for correct Sonos switch states.""" - entity_registry = ent_reg.async_get(hass) - alarm = entity_registry.entities["switch.sonos_alarm_14"] alarm_state = hass.states.get(alarm.entity_id) assert alarm_state.state == STATE_ON @@ -135,10 +137,9 @@ async def test_alarm_create_delete( alarm_clock, alarm_clock_extended, alarm_event, + entity_registry: er.EntityRegistry, ) -> None: """Test for correct creation and deletion of alarms during runtime.""" - entity_registry = ent_reg.async_get(hass) - one_alarm = copy(alarm_clock.ListAlarms.return_value) two_alarms = copy(alarm_clock_extended.ListAlarms.return_value) diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index b34a26ceeb..6483524545 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Context, CoreState, HomeAssistant, State -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -804,22 +804,18 @@ async def test_no_update_template_match_all( }, ], ) -async def test_unique_id(hass: HomeAssistant, start_ha) -> None: +async def test_unique_id( + hass: HomeAssistant, start_ha, entity_registry: er.EntityRegistry +) -> None: """Test unique_id option only creates one binary sensor per id.""" assert len(hass.states.async_all()) == 2 - ent_reg = entity_registry.async_get(hass) - - assert len(ent_reg.entities) == 2 - assert ( - ent_reg.async_get_entity_id("binary_sensor", "template", "group-id-sensor-id") - is not None + assert len(entity_registry.entities) == 2 + assert entity_registry.async_get_entity_id( + "binary_sensor", "template", "group-id-sensor-id" ) - assert ( - ent_reg.async_get_entity_id( - "binary_sensor", "template", "not-so-unique-anymore" - ) - is not None + assert entity_registry.async_get_entity_id( + "binary_sensor", "template", "not-so-unique-anymore" ) @@ -1052,7 +1048,9 @@ async def test_restore_state( }, ], ) -async def test_trigger_entity(hass: HomeAssistant, start_ha) -> None: +async def test_trigger_entity( + hass: HomeAssistant, start_ha, entity_registry: er.EntityRegistry +) -> None: """Test trigger entity works.""" await hass.async_block_till_done() state = hass.states.get("binary_sensor.hello_name") @@ -1075,14 +1073,13 @@ async def test_trigger_entity(hass: HomeAssistant, start_ha) -> None: assert state.attributes.get("plus_one") == 3 assert state.context is context - ent_reg = entity_registry.async_get(hass) - assert len(ent_reg.entities) == 2 + assert len(entity_registry.entities) == 2 assert ( - ent_reg.entities["binary_sensor.hello_name"].unique_id + entity_registry.entities["binary_sensor.hello_name"].unique_id == "listening-test-event-hello_name-id" ) assert ( - ent_reg.entities["binary_sensor.via_list"].unique_id + entity_registry.entities["binary_sensor.via_list"].unique_id == "listening-test-event-via_list-id" ) diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 3f407795c5..d3e3ebf581 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Context, CoreState, HomeAssistant, State, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from homeassistant.helpers.template import Template from homeassistant.setup import ATTR_COMPONENT, async_setup_component @@ -558,19 +558,18 @@ async def test_no_template_match_all( }, ], ) -async def test_unique_id(hass: HomeAssistant, start_ha) -> None: +async def test_unique_id( + hass: HomeAssistant, start_ha, entity_registry: er.EntityRegistry +) -> None: """Test unique_id option only creates one sensor per id.""" assert len(hass.states.async_all()) == 2 - ent_reg = entity_registry.async_get(hass) - assert len(ent_reg.entities) == 2 - assert ( - ent_reg.async_get_entity_id("sensor", "template", "group-id-sensor-id") - is not None + assert len(entity_registry.entities) == 2 + assert entity_registry.async_get_entity_id( + "sensor", "template", "group-id-sensor-id" ) - assert ( - ent_reg.async_get_entity_id("sensor", "template", "not-so-unique-anymore") - is not None + assert entity_registry.async_get_entity_id( + "sensor", "template", "not-so-unique-anymore" ) @@ -1094,7 +1093,9 @@ async def test_duplicate_templates(hass: HomeAssistant, start_ha) -> None: }, ], ) -async def test_trigger_entity(hass: HomeAssistant, start_ha) -> None: +async def test_trigger_entity( + hass: HomeAssistant, start_ha, entity_registry: er.EntityRegistry +) -> None: """Test trigger entity works.""" state = hass.states.get("sensor.hello_name") assert state is not None @@ -1117,14 +1118,13 @@ async def test_trigger_entity(hass: HomeAssistant, start_ha) -> None: assert state.attributes.get("unit_of_measurement") == "%" assert state.context is context - ent_reg = entity_registry.async_get(hass) - assert len(ent_reg.entities) == 2 + assert len(entity_registry.entities) == 2 assert ( - ent_reg.entities["sensor.hello_name"].unique_id + entity_registry.entities["sensor.hello_name"].unique_id == "listening-test-event-hello_name-id" ) assert ( - ent_reg.entities["sensor.via_list"].unique_id + entity_registry.entities["sensor.via_list"].unique_id == "listening-test-event-via_list-id" ) @@ -1157,7 +1157,9 @@ async def test_trigger_entity(hass: HomeAssistant, start_ha) -> None: }, ], ) -async def test_trigger_entity_render_error(hass: HomeAssistant, start_ha) -> None: +async def test_trigger_entity_render_error( + hass: HomeAssistant, start_ha, entity_registry: er.EntityRegistry +) -> None: """Test trigger entity handles render error.""" state = hass.states.get("sensor.hello") assert state is not None @@ -1170,9 +1172,8 @@ async def test_trigger_entity_render_error(hass: HomeAssistant, start_ha) -> Non state = hass.states.get("sensor.hello") assert state.state == STATE_UNAVAILABLE - ent_reg = entity_registry.async_get(hass) - assert len(ent_reg.entities) == 1 - assert ent_reg.entities["sensor.hello"].unique_id == "no-base-id" + assert len(entity_registry.entities) == 1 + assert entity_registry.entities["sensor.hello"].unique_id == "no-base-id" @pytest.mark.parametrize(("count", "domain"), [(0, sensor.DOMAIN)]) diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index fd8c572685..12d7b44409 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -22,7 +22,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Context, HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component @@ -1183,7 +1183,9 @@ async def test_device_class(hass: HomeAssistant) -> None: assert hass.states.get("media_player.tv").attributes["device_class"] == "tv" -async def test_unique_id(hass: HomeAssistant) -> None: +async def test_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test unique_id property.""" hass.states.async_set("sensor.test_sensor", "on") @@ -1199,8 +1201,10 @@ async def test_unique_id(hass: HomeAssistant) -> None: }, ) await hass.async_block_till_done() - er = entity_registry.async_get(hass) - assert er.async_get("media_player.tv").unique_id == "universal_master_bed_tv" + assert ( + entity_registry.async_get("media_player.tv").unique_id + == "universal_master_bed_tv" + ) async def test_invalid_state_template(hass: HomeAssistant) -> None: diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 58cf616748..c56010e36e 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -38,7 +38,7 @@ from homeassistant.const import ( UnitOfEnergy, ) from homeassistant.core import CoreState, HomeAssistant, State -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -324,7 +324,9 @@ async def test_init(hass: HomeAssistant, yaml_config, config_entry_config) -> No assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR -async def test_unique_id(hass: HomeAssistant) -> None: +async def test_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test unique_id configuration option.""" yaml_config = { "utility_meter": { @@ -342,10 +344,9 @@ async def test_unique_id(hass: HomeAssistant) -> None: hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() - ent_reg = entity_registry.async_get(hass) - assert len(ent_reg.entities) == 4 - assert ent_reg.entities["select.energy_bill"].unique_id == "1" - assert ent_reg.entities["sensor.energy_bill_onpeak"].unique_id == "1_onpeak" + assert len(entity_registry.entities) == 4 + assert entity_registry.entities["select.energy_bill"].unique_id == "1" + assert entity_registry.entities["sensor.energy_bill_onpeak"].unique_id == "1_onpeak" @pytest.mark.parametrize( diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py index 5ecd1a5d7b..afc7bca513 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -61,7 +61,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from homeassistant.util import dt @@ -275,7 +275,7 @@ async def test_select_sound_output(hass: HomeAssistant, client) -> None: async def test_device_info_startup_off( - hass: HomeAssistant, client, monkeypatch + hass: HomeAssistant, client, monkeypatch, device_registry: dr.DeviceRegistry ) -> None: """Test device info when device is off at startup.""" monkeypatch.setattr(client, "system_info", None) @@ -285,8 +285,7 @@ async def test_device_info_startup_off( assert hass.states.get(ENTITY_ID).state == STATE_OFF - device_reg = device_registry.async_get(hass) - device = device_reg.async_get_device({(DOMAIN, entry.unique_id)}) + device = device_registry.async_get_device({(DOMAIN, entry.unique_id)}) assert device assert device.identifiers == {(DOMAIN, entry.unique_id)} @@ -296,7 +295,9 @@ async def test_device_info_startup_off( assert device.model is None -async def test_entity_attributes(hass: HomeAssistant, client, monkeypatch) -> None: +async def test_entity_attributes( + hass: HomeAssistant, client, monkeypatch, device_registry: dr.DeviceRegistry +) -> None: """Test entity attributes.""" entry = await setup_webostv(hass) await client.mock_state_update() @@ -331,8 +332,7 @@ async def test_entity_attributes(hass: HomeAssistant, client, monkeypatch) -> No assert attrs[ATTR_MEDIA_TITLE] == "Channel Name 2" # Device Info - device_reg = device_registry.async_get(hass) - device = device_reg.async_get_device({(DOMAIN, entry.unique_id)}) + device = device_registry.async_get_device({(DOMAIN, entry.unique_id)}) assert device assert device.identifiers == {(DOMAIN, entry.unique_id)} diff --git a/tests/components/wemo/test_wemo_device.py b/tests/components/wemo/test_wemo_device.py index b447a9b24f..49c6664f7b 100644 --- a/tests/components/wemo/test_wemo_device.py +++ b/tests/components/wemo/test_wemo_device.py @@ -12,7 +12,7 @@ from homeassistant import runner from homeassistant.components.wemo import CONF_DISCOVERY, CONF_STATIC, wemo_device from homeassistant.components.wemo.const import DOMAIN, WEMO_SUBSCRIPTION_EVENT from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -31,7 +31,7 @@ def pywemo_model(): async def test_async_register_device_longpress_fails( - hass: HomeAssistant, pywemo_device + hass: HomeAssistant, pywemo_device, device_registry: dr.DeviceRegistry ) -> None: """Device is still registered if ensure_long_press_virtual_device fails.""" with patch.object(pywemo_device, "ensure_long_press_virtual_device") as elp: @@ -47,8 +47,7 @@ async def test_async_register_device_longpress_fails( }, ) await hass.async_block_till_done() - dr = device_registry.async_get(hass) - device_entries = list(dr.devices.values()) + device_entries = list(device_registry.devices.values()) assert len(device_entries) == 1 device = wemo_device.async_get_coordinator(hass, device_entries[0].id) assert device.supports_long_press is False @@ -164,10 +163,11 @@ async def test_async_update_data_subscribed( pywemo_device.get_state.assert_not_called() -async def test_device_info(hass: HomeAssistant, wemo_entity) -> None: +async def test_device_info( + hass: HomeAssistant, wemo_entity, device_registry: dr.DeviceRegistry +) -> None: """Verify the DeviceInfo data is set properly.""" - dr = device_registry.async_get(hass) - device_entries = list(dr.devices.values()) + device_entries = list(device_registry.devices.values()) assert len(device_entries) == 1 assert device_entries[0].connections == { @@ -178,10 +178,11 @@ async def test_device_info(hass: HomeAssistant, wemo_entity) -> None: assert device_entries[0].sw_version == MOCK_FIRMWARE_VERSION -async def test_dli_device_info(hass: HomeAssistant, wemo_dli_entity) -> None: +async def test_dli_device_info( + hass: HomeAssistant, wemo_dli_entity, device_registry: dr.DeviceRegistry +) -> None: """Verify the DeviceInfo data for Digital Loggers emulated wemo device.""" - dr = device_registry.async_get(hass) - device_entries = list(dr.devices.values()) + device_entries = list(device_registry.devices.values()) assert device_entries[0].configuration_url == "http://127.0.0.1" assert device_entries[0].identifiers == {(DOMAIN, "123456789")} diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index eef13c08dc..429e8895ad 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock from whirlpool.washerdryer import MachineState from homeassistant.core import CoreState, HomeAssistant, State -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.util.dt import as_timestamp, utc_from_timestamp from . import init_integration @@ -44,6 +44,7 @@ async def test_dryer_sensor_values( hass: HomeAssistant, mock_sensor_api_instances: MagicMock, mock_sensor2_api: MagicMock, + entity_registry: er.EntityRegistry, ) -> None: """Test the sensor value callbacks.""" hass.state = CoreState.not_running @@ -69,8 +70,7 @@ async def test_dryer_sensor_values( entity_id = "sensor.dryer_state" mock_instance = mock_sensor2_api - registry = entity_registry.async_get(hass) - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry state = hass.states.get(entity_id) assert state is not None @@ -108,6 +108,7 @@ async def test_washer_sensor_values( hass: HomeAssistant, mock_sensor_api_instances: MagicMock, mock_sensor1_api: MagicMock, + entity_registry: er.EntityRegistry, ) -> None: """Test the sensor value callbacks.""" hass.state = CoreState.not_running @@ -133,8 +134,7 @@ async def test_washer_sensor_values( entity_id = "sensor.washer_state" mock_instance = mock_sensor1_api - registry = entity_registry.async_get(hass) - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry state = hass.states.get(entity_id) assert state is not None @@ -147,13 +147,14 @@ async def test_washer_sensor_values( assert state.state == thetimestamp.isoformat() state_id = f"{entity_id.split('_')[0]}_detergent_level" - registry = entity_registry.async_get(hass) - entry = registry.async_get(state_id) + entry = entity_registry.async_get(state_id) assert entry assert entry.disabled - assert entry.disabled_by is entity_registry.RegistryEntryDisabler.INTEGRATION + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - update_entry = registry.async_update_entity(entry.entity_id, disabled_by=None) + update_entry = entity_registry.async_update_entity( + entry.entity_id, disabled_by=None + ) await hass.async_block_till_done() assert update_entry != entry diff --git a/tests/components/xiaomi_ble/test_device_trigger.py b/tests/components/xiaomi_ble/test_device_trigger.py index f39354ecac..85454959cf 100644 --- a/tests/components/xiaomi_ble/test_device_trigger.py +++ b/tests/components/xiaomi_ble/test_device_trigger.py @@ -19,8 +19,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry -from homeassistant.helpers.device_registry import async_get as async_get_dev_reg +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from . import make_advertisement @@ -82,7 +81,9 @@ async def test_event_motion_detected(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_get_triggers(hass: HomeAssistant) -> None: +async def test_get_triggers( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test that we get the expected triggers from a Xiaomi BLE motion sensor.""" mac = "DE:70:E8:B2:39:0C" entry = await _async_setup_xiaomi_device(hass, mac) @@ -98,8 +99,7 @@ async def test_get_triggers(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(events) == 1 - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device({get_device_id(mac)}) + device = device_registry.async_get_device({get_device_id(mac)}) assert device expected_trigger = { CONF_PLATFORM: "device", @@ -118,7 +118,9 @@ async def test_get_triggers(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_get_triggers_for_invalid_xiami_ble_device(hass: HomeAssistant) -> None: +async def test_get_triggers_for_invalid_xiami_ble_device( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test that we don't get triggers for an invalid device.""" mac = "DE:70:E8:B2:39:0C" entry = await _async_setup_xiaomi_device(hass, mac) @@ -134,8 +136,7 @@ async def test_get_triggers_for_invalid_xiami_ble_device(hass: HomeAssistant) -> await hass.async_block_till_done() assert len(events) == 1 - dev_reg = async_get_dev_reg(hass) - invalid_device = dev_reg.async_get_or_create( + invalid_device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, "invdevmac")}, ) @@ -149,7 +150,9 @@ async def test_get_triggers_for_invalid_xiami_ble_device(hass: HomeAssistant) -> await hass.async_block_till_done() -async def test_get_triggers_for_invalid_device_id(hass: HomeAssistant) -> None: +async def test_get_triggers_for_invalid_device_id( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test that we don't get triggers when using an invalid device_id.""" mac = "DE:70:E8:B2:39:0C" entry = await _async_setup_xiaomi_device(hass, mac) @@ -163,11 +166,9 @@ async def test_get_triggers_for_invalid_device_id(hass: HomeAssistant) -> None: # wait for the event await hass.async_block_till_done() - dev_reg = async_get_dev_reg(hass) - - invalid_device = dev_reg.async_get_or_create( + invalid_device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, - connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) assert invalid_device triggers = await async_get_device_automations( @@ -179,7 +180,9 @@ async def test_get_triggers_for_invalid_device_id(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_if_fires_on_motion_detected(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_motion_detected( + hass: HomeAssistant, calls, device_registry: dr.DeviceRegistry +) -> None: """Test for motion event trigger firing.""" mac = "DE:70:E8:B2:39:0C" entry = await _async_setup_xiaomi_device(hass, mac) @@ -193,8 +196,7 @@ async def test_if_fires_on_motion_detected(hass: HomeAssistant, calls) -> None: # wait for the event await hass.async_block_till_done() - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device({get_device_id(mac)}) + device = device_registry.async_get_device({get_device_id(mac)}) device_id = device.id assert await async_setup_component( @@ -237,7 +239,9 @@ async def test_if_fires_on_motion_detected(hass: HomeAssistant, calls) -> None: async def test_automation_with_invalid_trigger_type( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, ) -> None: """Test for automation with invalid trigger type.""" mac = "DE:70:E8:B2:39:0C" @@ -252,8 +256,7 @@ async def test_automation_with_invalid_trigger_type( # wait for the event await hass.async_block_till_done() - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device({get_device_id(mac)}) + device = device_registry.async_get_device({get_device_id(mac)}) device_id = device.id assert await async_setup_component( @@ -285,7 +288,9 @@ async def test_automation_with_invalid_trigger_type( async def test_automation_with_invalid_trigger_event_property( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, ) -> None: """Test for automation with invalid trigger event property.""" mac = "DE:70:E8:B2:39:0C" @@ -300,8 +305,7 @@ async def test_automation_with_invalid_trigger_event_property( # wait for the event await hass.async_block_till_done() - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device({get_device_id(mac)}) + device = device_registry.async_get_device({get_device_id(mac)}) device_id = device.id assert await async_setup_component( @@ -332,7 +336,9 @@ async def test_automation_with_invalid_trigger_event_property( await hass.async_block_till_done() -async def test_triggers_for_invalid__model(hass: HomeAssistant, calls) -> None: +async def test_triggers_for_invalid__model( + hass: HomeAssistant, calls, device_registry: dr.DeviceRegistry +) -> None: """Test invalid model doesn't return triggers.""" mac = "DE:70:E8:B2:39:0C" entry = await _async_setup_xiaomi_device(hass, mac) @@ -346,9 +352,8 @@ async def test_triggers_for_invalid__model(hass: HomeAssistant, calls) -> None: # wait for the event await hass.async_block_till_done() - dev_reg = async_get_dev_reg(hass) # modify model to invalid model - invalid_model = dev_reg.async_get_or_create( + invalid_model = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, mac)}, model="invalid model", diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index ff819413fc..cae67f8d76 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -10,7 +10,7 @@ import zigpy.zcl.foundation as zcl_f import homeassistant.components.zha.core.const as zha_const from homeassistant.components.zha.core.helpers import async_get_zha_config_value -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed @@ -157,12 +157,10 @@ def find_entity_ids(domain, zha_device, hass): machine so that we can test state changes. """ - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) return [ entity.entity_id - for entity in entity_registry.async_entries_for_device( - registry, zha_device.device_id - ) + for entity in er.async_entries_for_device(registry, zha_device.device_id) if entity.domain == domain ] diff --git a/tests/components/zha/test_discover.py b/tests/components/zha/test_discover.py index e6b0cb77be..20db04d961 100644 --- a/tests/components/zha/test_discover.py +++ b/tests/components/zha/test_discover.py @@ -28,7 +28,7 @@ import homeassistant.components.zha.sensor import homeassistant.components.zha.switch from homeassistant.const import Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry +import homeassistant.helpers.entity_registry as er from .common import get_zha_gateway from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE @@ -104,9 +104,7 @@ async def test_devices( zha_device_joined_restored, ) -> None: """Test device discovery.""" - entity_registry = homeassistant.helpers.entity_registry.async_get( - hass_disable_services - ) + entity_registry = er.async_get(hass_disable_services) zigpy_device = zigpy_device_mock( device[SIG_ENDPOINTS], diff --git a/tests/components/zwave_js/test_device_action.py b/tests/components/zwave_js/test_device_action.py index de94cb6c5c..8672e886ab 100644 --- a/tests/components/zwave_js/test_device_action.py +++ b/tests/components/zwave_js/test_device_action.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, device_registry +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.setup import async_setup_component from tests.common import async_get_device_automations @@ -26,13 +26,13 @@ async def test_get_actions( client: Client, lock_schlage_be469: Node, integration: ConfigEntry, + device_registry: dr.DeviceRegistry, ) -> None: """Test we get the expected actions from a zwave_js node.""" node = lock_schlage_be469 - dev_reg = device_registry.async_get(hass) driver = client.driver assert driver - device = dev_reg.async_get_device({get_device_id(driver, node)}) + device = device_registry.async_get_device({get_device_id(driver, node)}) assert device expected_actions = [ { @@ -92,7 +92,7 @@ async def test_get_actions( assert action in actions # Test that we don't return actions for a controller node - device = dev_reg.async_get_device( + device = device_registry.async_get_device( {get_device_id(driver, client.driver.controller.nodes[1])} ) assert device @@ -107,13 +107,13 @@ async def test_get_actions_meter( client: Client, aeon_smart_switch_6: Node, integration: ConfigEntry, + device_registry: dr.DeviceRegistry, ) -> None: """Test we get the expected meter actions from a zwave_js node.""" node = aeon_smart_switch_6 - dev_reg = device_registry.async_get(hass) driver = client.driver assert driver - device = dev_reg.async_get_device({get_device_id(driver, node)}) + device = device_registry.async_get_device({get_device_id(driver, node)}) assert device actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device.id @@ -127,14 +127,14 @@ async def test_actions( client: Client, climate_radio_thermostat_ct100_plus: Node, integration: ConfigEntry, + device_registry: dr.DeviceRegistry, ) -> None: """Test actions.""" node = climate_radio_thermostat_ct100_plus driver = client.driver assert driver device_id = get_device_id(driver, node) - dev_reg = device_registry.async_get(hass) - device = dev_reg.async_get_device({device_id}) + device = device_registry.async_get_device({device_id}) assert device assert await async_setup_component( @@ -249,14 +249,14 @@ async def test_actions_multiple_calls( client: Client, climate_radio_thermostat_ct100_plus: Node, integration: ConfigEntry, + device_registry: dr.DeviceRegistry, ) -> None: """Test actions can be called multiple times and still work.""" node = climate_radio_thermostat_ct100_plus driver = client.driver assert driver device_id = get_device_id(driver, node) - dev_reg = device_registry.async_get(hass) - device = dev_reg.async_get_device({device_id}) + device = device_registry.async_get_device({device_id}) assert device assert await async_setup_component( @@ -296,14 +296,14 @@ async def test_lock_actions( client: Client, lock_schlage_be469: Node, integration: ConfigEntry, + device_registry: dr.DeviceRegistry, ) -> None: """Test actions for locks.""" node = lock_schlage_be469 driver = client.driver assert driver device_id = get_device_id(driver, node) - dev_reg = device_registry.async_get(hass) - device = dev_reg.async_get_device({device_id}) + device = device_registry.async_get_device({device_id}) assert device assert await async_setup_component( @@ -367,14 +367,14 @@ async def test_reset_meter_action( client: Client, aeon_smart_switch_6: Node, integration: ConfigEntry, + device_registry: dr.DeviceRegistry, ) -> None: """Test reset_meter action.""" node = aeon_smart_switch_6 driver = client.driver assert driver device_id = get_device_id(driver, node) - dev_reg = device_registry.async_get(hass) - device = dev_reg.async_get_device({device_id}) + device = device_registry.async_get_device({device_id}) assert device assert await async_setup_component( @@ -415,10 +415,10 @@ async def test_get_action_capabilities( client: Client, climate_radio_thermostat_ct100_plus: Node, integration: ConfigEntry, + device_registry: dr.DeviceRegistry, ) -> None: """Test we get the expected action capabilities.""" - dev_reg = device_registry.async_get(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( {get_device_id(client.driver, climate_radio_thermostat_ct100_plus)} ) assert device @@ -582,12 +582,10 @@ async def test_get_action_capabilities_lock_triggers( client: Client, lock_schlage_be469: Node, integration: ConfigEntry, + device_registry: dr.DeviceRegistry, ) -> None: """Test we get the expected action capabilities for lock triggers.""" - dev_reg = device_registry.async_get(hass) - device = device_registry.async_entries_for_config_entry( - dev_reg, integration.entry_id - )[0] + device = dr.async_entries_for_config_entry(device_registry, integration.entry_id)[0] # Test clear_lock_usercode capabilities = await device_action.async_get_action_capabilities( @@ -632,13 +630,13 @@ async def test_get_action_capabilities_meter_triggers( client: Client, aeon_smart_switch_6: Node, integration: ConfigEntry, + device_registry: dr.DeviceRegistry, ) -> None: """Test we get the expected action capabilities for meter triggers.""" node = aeon_smart_switch_6 - dev_reg = device_registry.async_get(hass) driver = client.driver assert driver - device = dev_reg.async_get_device({get_device_id(driver, node)}) + device = device_registry.async_get_device({get_device_id(driver, node)}) assert device capabilities = await device_action.async_get_action_capabilities( hass, @@ -662,12 +660,10 @@ async def test_failure_scenarios( client: Client, hank_binary_switch: Node, integration: ConfigEntry, + device_registry: dr.DeviceRegistry, ) -> None: """Test failure scenarios.""" - dev_reg = device_registry.async_get(hass) - device = device_registry.async_entries_for_config_entry( - dev_reg, integration.entry_id - )[0] + device = dr.async_entries_for_config_entry(device_registry, integration.entry_id)[0] with pytest.raises(HomeAssistantError): await device_action.async_call_action_from_config( @@ -687,16 +683,16 @@ async def test_unavailable_entity_actions( client: Client, lock_schlage_be469: Node, integration: ConfigEntry, + device_registry: dr.DeviceRegistry, ) -> None: """Test unavailable entities are not included in actions list.""" entity_id_unavailable = "binary_sensor.touchscreen_deadbolt_home_security_intrusion" hass.states.async_set(entity_id_unavailable, STATE_UNAVAILABLE, force_update=True) await hass.async_block_till_done() node = lock_schlage_be469 - dev_reg = device_registry.async_get(hass) driver = client.driver assert driver - device = dev_reg.async_get_device({get_device_id(driver, node)}) + device = device_registry.async_get_device({get_device_id(driver, node)}) assert device actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device.id diff --git a/tests/components/zwave_js/test_device_condition.py b/tests/components/zwave_js/test_device_condition.py index 3902a29861..b66e804eb8 100644 --- a/tests/components/zwave_js/test_device_condition.py +++ b/tests/components/zwave_js/test_device_condition.py @@ -21,7 +21,7 @@ from homeassistant.components.zwave_js.helpers import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, device_registry +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.setup import async_setup_component from tests.common import async_get_device_automations, async_mock_service @@ -34,11 +34,14 @@ def calls(hass): async def test_get_conditions( - hass: HomeAssistant, client, lock_schlage_be469, integration + hass: HomeAssistant, + client, + lock_schlage_be469, + integration, + device_registry: dr.DeviceRegistry, ) -> None: """Test we get the expected onditions from a zwave_js.""" - dev_reg = device_registry.async_get(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( {get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -78,7 +81,7 @@ async def test_get_conditions( assert condition in conditions # Test that we don't return actions for a controller node - device = dev_reg.async_get_device( + device = device_registry.async_get_device( {get_device_id(client.driver, client.driver.controller.nodes[1])} ) assert device @@ -91,11 +94,15 @@ async def test_get_conditions( async def test_node_status_state( - hass: HomeAssistant, client, lock_schlage_be469, integration, calls + hass: HomeAssistant, + client, + lock_schlage_be469, + integration, + calls, + device_registry: dr.DeviceRegistry, ) -> None: """Test for node_status conditions.""" - dev_reg = device_registry.async_get(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( {get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -252,11 +259,15 @@ async def test_node_status_state( async def test_config_parameter_state( - hass: HomeAssistant, client, lock_schlage_be469, integration, calls + hass: HomeAssistant, + client, + lock_schlage_be469, + integration, + calls, + device_registry: dr.DeviceRegistry, ) -> None: """Test for config_parameter conditions.""" - dev_reg = device_registry.async_get(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( {get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -368,11 +379,15 @@ async def test_config_parameter_state( async def test_value_state( - hass: HomeAssistant, client, lock_schlage_be469, integration, calls + hass: HomeAssistant, + client, + lock_schlage_be469, + integration, + calls, + device_registry: dr.DeviceRegistry, ) -> None: """Test for value conditions.""" - dev_reg = device_registry.async_get(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( {get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -416,11 +431,14 @@ async def test_value_state( async def test_get_condition_capabilities_node_status( - hass: HomeAssistant, client, lock_schlage_be469, integration + hass: HomeAssistant, + client, + lock_schlage_be469, + integration, + device_registry: dr.DeviceRegistry, ) -> None: """Test we don't get capabilities from a node_status condition.""" - dev_reg = device_registry.async_get(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( {get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -453,11 +471,14 @@ async def test_get_condition_capabilities_node_status( async def test_get_condition_capabilities_value( - hass: HomeAssistant, client, lock_schlage_be469, integration + hass: HomeAssistant, + client, + lock_schlage_be469, + integration, + device_registry: dr.DeviceRegistry, ) -> None: """Test we get the expected capabilities from a value condition.""" - dev_reg = device_registry.async_get(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( {get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -502,12 +523,15 @@ async def test_get_condition_capabilities_value( async def test_get_condition_capabilities_config_parameter( - hass: HomeAssistant, client, climate_radio_thermostat_ct100_plus, integration + hass: HomeAssistant, + client, + climate_radio_thermostat_ct100_plus, + integration, + device_registry: dr.DeviceRegistry, ) -> None: """Test we get the expected capabilities from a config_parameter condition.""" node = climate_radio_thermostat_ct100_plus - dev_reg = device_registry.async_get(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( {get_device_id(client.driver, climate_radio_thermostat_ct100_plus)} ) assert device @@ -585,11 +609,14 @@ async def test_get_condition_capabilities_config_parameter( async def test_failure_scenarios( - hass: HomeAssistant, client, hank_binary_switch, integration + hass: HomeAssistant, + client, + hank_binary_switch, + integration, + device_registry: dr.DeviceRegistry, ) -> None: """Test failure scenarios.""" - dev_reg = device_registry.async_get(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( {get_device_id(client.driver, hank_binary_switch)} ) assert device diff --git a/tests/components/zwave_js/test_fan.py b/tests/components/zwave_js/test_fan.py index 6af7a1ba9f..d9de2379ce 100644 --- a/tests/components/zwave_js/test_fan.py +++ b/tests/components/zwave_js/test_fan.py @@ -30,7 +30,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er async def test_generic_fan( @@ -538,23 +538,26 @@ async def test_leviton_zw4sf_fan( async def test_thermostat_fan( - hass: HomeAssistant, client, climate_adc_t3000, integration + hass: HomeAssistant, + client, + climate_adc_t3000, + integration, + entity_registry: er.EntityRegistry, ) -> None: """Test the fan entity for a z-wave fan.""" node = climate_adc_t3000 entity_id = "fan.adc_t3000" - registry = entity_registry.async_get(hass) state = hass.states.get(entity_id) assert state is None - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.disabled - assert entry.disabled_by is entity_registry.RegistryEntryDisabler.INTEGRATION + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION # Test enabling entity - updated_entry = registry.async_update_entity(entity_id, disabled_by=None) + updated_entry = entity_registry.async_update_entity(entity_id, disabled_by=None) assert updated_entry != entry assert updated_entry.disabled is False @@ -769,22 +772,25 @@ async def test_thermostat_fan( async def test_thermostat_fan_without_off( - hass: HomeAssistant, client, climate_radio_thermostat_ct100_plus, integration + hass: HomeAssistant, + client, + climate_radio_thermostat_ct100_plus, + integration, + entity_registry: er.EntityRegistry, ) -> None: """Test the fan entity for a z-wave fan without "off" property.""" entity_id = "fan.z_wave_thermostat" - registry = entity_registry.async_get(hass) state = hass.states.get(entity_id) assert state is None - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.disabled - assert entry.disabled_by is entity_registry.RegistryEntryDisabler.INTEGRATION + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION # Test enabling entity - updated_entry = registry.async_update_entity(entity_id, disabled_by=None) + updated_entry = entity_registry.async_update_entity(entity_id, disabled_by=None) assert updated_entry != entry assert updated_entry.disabled is False @@ -827,22 +833,25 @@ async def test_thermostat_fan_without_off( async def test_thermostat_fan_without_preset_modes( - hass: HomeAssistant, client, climate_adc_t3000_missing_fan_mode_states, integration + hass: HomeAssistant, + client, + climate_adc_t3000_missing_fan_mode_states, + integration, + entity_registry: er.EntityRegistry, ) -> None: """Test the fan entity for a z-wave fan without "states" metadata.""" entity_id = "fan.adc_t3000_missing_fan_mode_states" - registry = entity_registry.async_get(hass) state = hass.states.get(entity_id) assert state is None - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.disabled - assert entry.disabled_by is entity_registry.RegistryEntryDisabler.INTEGRATION + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION # Test enabling entity - updated_entry = registry.async_update_entity(entity_id, disabled_by=None) + updated_entry = entity_registry.async_update_entity(entity_id, disabled_by=None) assert updated_entry != entry assert updated_entry.disabled is False From b94dffb7d3e157056ed3bbc9b56312cfe88399bc Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 16:11:21 +0100 Subject: [PATCH 0559/2810] Add missing mock in esphome tests (#88923) --- tests/components/esphome/test_config_flow.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 2b8038564c..f96ecc8327 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -592,7 +592,10 @@ async def test_reauth_fixed_via_dashboard_add_encryption_remove_password( async def test_reauth_fixed_via_remove_password( - hass: HomeAssistant, mock_client, mock_config_entry + hass: HomeAssistant, + mock_client, + mock_config_entry, + mock_dashboard, ) -> None: """Test reauth fixed automatically by seeing password removed.""" mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test") @@ -799,7 +802,7 @@ async def test_discovery_dhcp_no_changes(hass: HomeAssistant, mock_client) -> No assert entry.data[CONF_HOST] == "192.168.43.183" -async def test_discovery_hassio(hass: HomeAssistant) -> None: +async def test_discovery_hassio(hass: HomeAssistant, mock_dashboard) -> None: """Test dashboard discovery.""" result = await hass.config_entries.flow.async_init( "esphome", From eae12bd48d7c3dd7fe182dbbc0b1ddec1b525c53 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 1 Mar 2023 16:16:04 +0100 Subject: [PATCH 0560/2810] 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 d861c989ee..d93e009136 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 0b1482883a..47c0867187 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 5c95b4abd1..ceb20279c1 100644 --- a/tests/components/motion_blinds/test_config_flow.py +++ b/tests/components/motion_blinds/test_config_flow.py @@ -89,6 +89,12 @@ def motion_blinds_connect_fixture(mock_get_source_ip): ), patch( "homeassistant.components.motion_blinds.config_flow.MotionDiscovery.discover", return_value=TEST_DISCOVERY_1, + ), patch( + "homeassistant.components.motion_blinds.config_flow.MotionGateway.GetDeviceList", + return_value=True, + ), patch( + "homeassistant.components.motion_blinds.config_flow.MotionGateway.available", + True, ), patch( "homeassistant.components.motion_blinds.gateway.AsyncMotionMulticast.Start_listen", return_value=True, @@ -355,6 +361,46 @@ async def test_dhcp_flow(hass: HomeAssistant) -> None: } +async def test_dhcp_flow_abort(hass: HomeAssistant) -> None: + """Test that DHCP discovery aborts if not Motion Blinds.""" + dhcp_data = dhcp.DhcpServiceInfo( + ip=TEST_HOST, + hostname="MOTION_abcdef", + macaddress=TEST_MAC, + ) + + with patch( + "homeassistant.components.motion_blinds.config_flow.MotionGateway.GetDeviceList", + side_effect=socket.timeout, + ): + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=dhcp_data + ) + + assert result["type"] == "abort" + assert result["reason"] == "not_motionblinds" + + +async def test_dhcp_flow_abort_invalid_response(hass: HomeAssistant) -> None: + """Test that DHCP discovery aborts if device responded with invalid data.""" + dhcp_data = dhcp.DhcpServiceInfo( + ip=TEST_HOST, + hostname="MOTION_abcdef", + macaddress=TEST_MAC, + ) + + with patch( + "homeassistant.components.motion_blinds.config_flow.MotionGateway.available", + False, + ): + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=dhcp_data + ) + + assert result["type"] == "abort" + assert result["reason"] == "not_motionblinds" + + async def test_options_flow(hass: HomeAssistant) -> None: """Test specifying non default settings using options flow.""" config_entry = MockConfigEntry( From 341d046ba79b675b688346b759f933c16db26f1b Mon Sep 17 00:00:00 2001 From: Mitch Date: Wed, 1 Mar 2023 16:17:55 +0100 Subject: [PATCH 0561/2810] 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 5d05bec39c..b8f1d3f5a7 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 7a0aff9ce9..2e0c0d9ebc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ dependencies = [ "pip>=21.0,<23.1", "python-slugify==4.0.1", "pyyaml==6.0", - "requests==2.28.1", + "requests==2.28.2", "typing-extensions>=4.5.0,<5.0", "ulid-transform==0.3.1", "voluptuous==0.13.1", diff --git a/requirements.txt b/requirements.txt index 31831a93e2..d7d65f10b6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ orjson==3.8.6 pip>=21.0,<23.1 python-slugify==4.0.1 pyyaml==6.0 -requests==2.28.1 +requests==2.28.2 typing-extensions>=4.5.0,<5.0 ulid-transform==0.3.1 voluptuous==0.13.1 From 54de16875d2a4475738de2acd4610ef8efd0506c Mon Sep 17 00:00:00 2001 From: mkmer Date: Wed, 1 Mar 2023 10:19:46 -0500 Subject: [PATCH 0562/2810] 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 3677c0f8d5..9184b8c3d6 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 4b8e73e9fe..989e605749 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 07e0316ab8..13818635d9 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 e1cf843250..9e2eab1743 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -254,7 +254,7 @@ aioskybell==22.7.0 aioslimproto==2.1.1 # homeassistant.components.honeywell -aiosomecomfort==0.0.10 +aiosomecomfort==0.0.11 # homeassistant.components.steamist aiosteamist==0.3.2 From 09f1c2318d44c1346fdeb5d4b7fd6be0af79da2b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 1 Mar 2023 16:21:11 +0100 Subject: [PATCH 0563/2810] Disable gc in-between energy sensor tests (#88593) --- tests/components/energy/test_sensor.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index 3a47423c21..538cc94918 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -1,7 +1,6 @@ """Test the Energy sensors.""" import copy from datetime import timedelta -import gc from typing import Any from unittest.mock import patch @@ -33,18 +32,6 @@ from tests.components.recorder.common import async_wait_recording_done from tests.typing import WebSocketGenerator -@pytest.fixture(autouse=True) -def garbage_collection(): - """Make sure garbage collection is run between all tests. - - There are unknown issues with GC triggering during a test - case, leading to the test breaking down. Make sure we - clean up between each testcase to avoid this issue. - """ - yield - gc.collect() - - @pytest.fixture async def setup_integration(recorder_mock): """Set up the integration.""" From d65dff3f9e923fc0bbd1fc76aaa90b7168dd68a6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 16:23:36 +0100 Subject: [PATCH 0564/2810] Adjust entity registry access in tests (2) (#88960) --- .../google_assistant/test_smart_home.py | 31 +-- tests/components/hue/test_logbook.py | 11 +- tests/components/knx/test_climate.py | 9 +- .../kostal_plenticore/test_select.py | 20 +- .../landisgyr_heat_meter/test_sensor.py | 13 +- tests/components/litterrobot/test_select.py | 9 +- tests/components/litterrobot/test_switch.py | 9 +- tests/components/logbook/test_init.py | 12 +- .../components/logbook/test_websocket_api.py | 56 +++-- tests/components/melnor/test_sensor.py | 11 +- tests/components/powerwall/test_switch.py | 7 +- tests/components/prometheus/test_init.py | 209 +++++++++++------- .../components/qnap_qsw/test_binary_sensor.py | 11 +- .../test_binary_sensor.py | 9 +- .../rituals_perfume_genie/test_number.py | 10 +- .../rituals_perfume_genie/test_select.py | 10 +- .../rituals_perfume_genie/test_sensor.py | 15 +- .../rituals_perfume_genie/test_switch.py | 10 +- tests/components/snooz/test_fan.py | 28 ++- tests/components/todoist/test_calendar.py | 14 +- 20 files changed, 291 insertions(+), 213 deletions(-) diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index e336473f8f..cf83e47b3b 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -1,5 +1,6 @@ """Test Google Smart Home.""" import asyncio +from types import SimpleNamespace from unittest.mock import ANY, call, patch import pytest @@ -23,30 +24,32 @@ from homeassistant.components.google_assistant import ( from homeassistant.config import async_process_ha_core_config from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature, __version__ from homeassistant.core import EVENT_CALL_SERVICE, HomeAssistant, State -from homeassistant.helpers import device_registry, entity_platform +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_platform, + entity_registry as er, +) from homeassistant.setup import async_setup_component from . import BASIC_CONFIG, MockConfig -from tests.common import ( - async_capture_events, - mock_area_registry, - mock_device_registry, - mock_registry, -) +from tests.common import async_capture_events REQ_ID = "ff36a3cc-ec34-11e6-b1a0-64510650abcf" @pytest.fixture -def registries(hass): +def registries( + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + area_registry: ar.AreaRegistry, +) -> SimpleNamespace: """Registry mock setup.""" - from types import SimpleNamespace - ret = SimpleNamespace() - ret.entity = mock_registry(hass) - ret.device = mock_device_registry(hass) - ret.area = mock_area_registry(hass) + ret.entity = entity_registry + ret.device = device_registry + ret.area = area_registry return ret @@ -238,7 +241,7 @@ async def test_sync_in_area(area_on_device, hass: HomeAssistant, registries) -> manufacturer="Someone", model="Some model", sw_version="Some Version", - connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) registries.device.async_update_device( device.id, area_id=area.id if area_on_device else None diff --git a/tests/components/hue/test_logbook.py b/tests/components/hue/test_logbook.py index c3d8766023..3f49efcdeb 100644 --- a/tests/components/hue/test_logbook.py +++ b/tests/components/hue/test_logbook.py @@ -10,7 +10,7 @@ from homeassistant.const import ( CONF_UNIQUE_ID, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from .conftest import setup_platform @@ -35,7 +35,9 @@ SAMPLE_V2_EVENT = { } -async def test_humanify_hue_events(hass: HomeAssistant, mock_bridge_v2) -> None: +async def test_humanify_hue_events( + hass: HomeAssistant, mock_bridge_v2, device_registry: dr.DeviceRegistry +) -> None: """Test hue events when the devices are present in the registry.""" await setup_platform(hass, mock_bridge_v2, "sensor") hass.config.components.add("recorder") @@ -43,11 +45,10 @@ async def test_humanify_hue_events(hass: HomeAssistant, mock_bridge_v2) -> None: await hass.async_block_till_done() entry: ConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] - dev_reg = device_registry.async_get(hass) - v1_device = dev_reg.async_get_or_create( + v1_device = device_registry.async_get_or_create( identifiers={(DOMAIN, "v1")}, name="Remote 1", config_entry_id=entry.entry_id ) - v2_device = dev_reg.async_get_or_create( + v2_device = device_registry.async_get_or_create( identifiers={(DOMAIN, "v2")}, name="Remote 2", config_entry_id=entry.entry_id ) diff --git a/tests/components/knx/test_climate.py b/tests/components/knx/test_climate.py index 477db04b56..e10ac76cb4 100644 --- a/tests/components/knx/test_climate.py +++ b/tests/components/knx/test_climate.py @@ -3,7 +3,7 @@ from homeassistant.components.climate import PRESET_ECO, PRESET_SLEEP, HVACMode from homeassistant.components.knx.schema import ClimateSchema from homeassistant.const import CONF_NAME, STATE_IDLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from .conftest import KNXTestKit @@ -99,7 +99,9 @@ async def test_climate_hvac_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: await knx.assert_write("1/2/6", (0x01,)) -async def test_climate_preset_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: +async def test_climate_preset_mode( + hass: HomeAssistant, knx: KNXTestKit, entity_registry: er.EntityRegistry +) -> None: """Test KNX climate preset mode.""" events = async_capture_events(hass, "state_changed") await knx.setup_integration( @@ -155,8 +157,7 @@ async def test_climate_preset_mode(hass: HomeAssistant, knx: KNXTestKit) -> None assert len(knx.xknx.devices[0].device_updated_cbs) == 2 assert len(knx.xknx.devices[1].device_updated_cbs) == 2 # test removing also removes hooks - er = entity_registry.async_get(hass) - er.async_remove("climate.test") + entity_registry.async_remove("climate.test") await hass.async_block_till_done() # If we remove the entity the underlying devices should disappear too diff --git a/tests/components/kostal_plenticore/test_select.py b/tests/components/kostal_plenticore/test_select.py index b892c0a457..682e8f72ac 100644 --- a/tests/components/kostal_plenticore/test_select.py +++ b/tests/components/kostal_plenticore/test_select.py @@ -3,13 +3,16 @@ from pykoplenti import SettingsData from homeassistant.components.kostal_plenticore.helper import Plenticore from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry async def test_select_battery_charging_usage_available( - hass: HomeAssistant, mock_plenticore: Plenticore, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_plenticore: Plenticore, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, ) -> None: """Test that the battery charging usage select entity is added if the settings are available.""" @@ -25,13 +28,14 @@ async def test_select_battery_charging_usage_available( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert entity_registry.async_get(hass).async_is_registered( - "select.battery_charging_usage_mode" - ) + assert entity_registry.async_is_registered("select.battery_charging_usage_mode") async def test_select_battery_charging_usage_not_available( - hass: HomeAssistant, mock_plenticore: Plenticore, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_plenticore: Plenticore, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, ) -> None: """Test that the battery charging usage select entity is not added if the settings are unavailable.""" @@ -40,6 +44,4 @@ async def test_select_battery_charging_usage_not_available( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert not entity_registry.async_get(hass).async_is_registered( - "select.battery_charging_usage_mode" - ) + assert not entity_registry.async_is_registered("select.battery_charging_usage_mode") diff --git a/tests/components/landisgyr_heat_meter/test_sensor.py b/tests/components/landisgyr_heat_meter/test_sensor.py index 1c68ceef77..9a94491a94 100644 --- a/tests/components/landisgyr_heat_meter/test_sensor.py +++ b/tests/components/landisgyr_heat_meter/test_sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import CoreState, HomeAssistant, State -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -43,7 +43,9 @@ class MockHeatMeterResponse: @patch("homeassistant.components.landisgyr_heat_meter.ultraheat_api.HeatMeterService") -async def test_create_sensors(mock_heat_meter, hass: HomeAssistant) -> None: +async def test_create_sensors( + mock_heat_meter, hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test sensor.""" entry_data = { "device": "/dev/USB0", @@ -77,7 +79,6 @@ async def test_create_sensors(mock_heat_meter, hass: HomeAssistant) -> None: # check if 26 attributes have been created assert len(hass.states.async_all()) == 27 - entity_reg = entity_registry.async_get(hass) state = hass.states.get("sensor.heat_meter_heat_usage") assert state @@ -96,14 +97,16 @@ async def test_create_sensors(mock_heat_meter, hass: HomeAssistant) -> None: assert state assert state.state == "devicenr_789" assert state.attributes.get(ATTR_STATE_CLASS) is None - entity_registry_entry = entity_reg.async_get("sensor.heat_meter_device_number") + entity_registry_entry = entity_registry.async_get("sensor.heat_meter_device_number") assert entity_registry_entry.entity_category == EntityCategory.DIAGNOSTIC state = hass.states.get("sensor.heat_meter_meter_date_time") assert state assert state.attributes.get(ATTR_ICON) == "mdi:clock-outline" assert state.attributes.get(ATTR_STATE_CLASS) is None - entity_registry_entry = entity_reg.async_get("sensor.heat_meter_meter_date_time") + entity_registry_entry = entity_registry.async_get( + "sensor.heat_meter_meter_date_time" + ) assert entity_registry_entry.entity_category == EntityCategory.DIAGNOSTIC diff --git a/tests/components/litterrobot/test_select.py b/tests/components/litterrobot/test_select.py index 7cfa7d221f..478d801e4d 100644 --- a/tests/components/litterrobot/test_select.py +++ b/tests/components/litterrobot/test_select.py @@ -9,22 +9,23 @@ from homeassistant.components.select import ( ) from homeassistant.const import ATTR_ENTITY_ID, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from .conftest import setup_integration SELECT_ENTITY_ID = "select.test_clean_cycle_wait_time_minutes" -async def test_wait_time_select(hass: HomeAssistant, mock_account) -> None: +async def test_wait_time_select( + hass: HomeAssistant, mock_account, entity_registry: er.EntityRegistry +) -> None: """Tests the wait time select entity.""" await setup_integration(hass, mock_account, PLATFORM_DOMAIN) select = hass.states.get(SELECT_ENTITY_ID) assert select - ent_reg = entity_registry.async_get(hass) - entity_entry = ent_reg.async_get(SELECT_ENTITY_ID) + entity_entry = entity_registry.async_get(SELECT_ENTITY_ID) assert entity_entry assert entity_entry.entity_category is EntityCategory.CONFIG diff --git a/tests/components/litterrobot/test_switch.py b/tests/components/litterrobot/test_switch.py index a8ae2a38ee..eee06101cf 100644 --- a/tests/components/litterrobot/test_switch.py +++ b/tests/components/litterrobot/test_switch.py @@ -11,7 +11,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from .conftest import setup_integration @@ -19,7 +19,9 @@ NIGHT_LIGHT_MODE_ENTITY_ID = "switch.test_night_light_mode" PANEL_LOCKOUT_ENTITY_ID = "switch.test_panel_lockout" -async def test_switch(hass: HomeAssistant, mock_account: MagicMock) -> None: +async def test_switch( + hass: HomeAssistant, mock_account: MagicMock, entity_registry: er.EntityRegistry +) -> None: """Tests the switch entity was set up.""" await setup_integration(hass, mock_account, PLATFORM_DOMAIN) @@ -27,8 +29,7 @@ async def test_switch(hass: HomeAssistant, mock_account: MagicMock) -> None: assert state assert state.state == STATE_ON - ent_reg = entity_registry.async_get(hass) - entity_entry = ent_reg.async_get(NIGHT_LIGHT_MODE_ENTITY_ID) + entity_entry = entity_registry.async_get(NIGHT_LIGHT_MODE_ENTITY_ID) assert entity_entry assert entity_entry.entity_category is EntityCategory.CONFIG diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index f98d38cec3..bb83c1fdb5 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -41,7 +41,7 @@ from homeassistant.const import ( ) import homeassistant.core as ha from homeassistant.core import Event, HomeAssistant -from homeassistant.helpers import device_registry, entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entityfilter import CONF_ENTITY_GLOBS from homeassistant.helpers.json import JSONEncoder from homeassistant.setup import async_setup_component @@ -2586,7 +2586,10 @@ async def test_get_events_invalid_filters( async def test_get_events_with_device_ids( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator + recorder_mock: Recorder, + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, ) -> None: """Test logbook get_events for device ids.""" now = dt_util.utcnow() @@ -2599,10 +2602,9 @@ async def test_get_events_with_device_ids( entry = MockConfigEntry(domain="test", data={"first": True}, options=None) entry.add_to_hass(hass) - dev_reg = device_registry.async_get(hass) - device = dev_reg.async_get_or_create( + device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, - connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, sw_version="sw-version", name="device name", diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index b8ea095d8d..6b21c66de8 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -31,7 +31,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import Event, HomeAssistant, State -from homeassistant.helpers import device_registry, entity_registry +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entityfilter import CONF_ENTITY_GLOBS from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -86,12 +86,13 @@ async def _async_mock_logbook_platform(hass: HomeAssistant) -> None: await logbook._process_logbook_platform(hass, "test", MockLogbookPlatform) -async def _async_mock_entity_with_logbook_platform(hass): +async def _async_mock_entity_with_logbook_platform( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> er.RegistryEntry: """Mock an integration that provides an entity that are described by the logbook.""" entry = MockConfigEntry(domain="test", data={"first": True}, options=None) entry.add_to_hass(hass) - ent_reg = entity_registry.async_get(hass) - entry = ent_reg.async_get_or_create( + entry = entity_registry.async_get_or_create( platform="test", domain="sensor", config_entry=entry, @@ -102,14 +103,15 @@ async def _async_mock_entity_with_logbook_platform(hass): return entry -async def _async_mock_devices_with_logbook_platform(hass): +async def _async_mock_devices_with_logbook_platform( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> list[dr.DeviceEntry]: """Mock an integration that provides a device that are described by the logbook.""" entry = MockConfigEntry(domain="test", data={"first": True}, options=None) entry.add_to_hass(hass) - dev_reg = device_registry.async_get(hass) - device = dev_reg.async_get_or_create( + device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, - connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, sw_version="sw-version", name="device name", @@ -117,9 +119,9 @@ async def _async_mock_devices_with_logbook_platform(hass): model="model", suggested_area="Game Room", ) - device2 = dev_reg.async_get_or_create( + device2 = device_registry.async_get_or_create( config_entry_id=entry.entry_id, - connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:CC")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:CC")}, identifiers={("bridgeid", "4567")}, sw_version="sw-version", name="device name", @@ -413,7 +415,10 @@ async def test_get_events_invalid_filters( async def test_get_events_with_device_ids( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator + recorder_mock: Recorder, + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, ) -> None: """Test logbook get_events for device ids.""" now = dt_util.utcnow() @@ -424,7 +429,7 @@ async def test_get_events_with_device_ids( ] ) - devices = await _async_mock_devices_with_logbook_platform(hass) + devices = await _async_mock_devices_with_logbook_platform(hass, device_registry) device = devices[0] device2 = devices[1] @@ -1797,7 +1802,10 @@ async def test_subscribe_unsubscribe_logbook_stream_big_query( @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_unsubscribe_logbook_stream_device( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator + recorder_mock: Recorder, + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, ) -> None: """Test subscribe/unsubscribe logbook stream with a device.""" now = dt_util.utcnow() @@ -1807,7 +1815,7 @@ async def test_subscribe_unsubscribe_logbook_stream_device( for comp in ("homeassistant", "logbook", "automation", "script") ] ) - devices = await _async_mock_devices_with_logbook_platform(hass) + devices = await _async_mock_devices_with_logbook_platform(hass, device_registry) device = devices[0] device2 = devices[1] @@ -1923,7 +1931,10 @@ async def test_event_stream_bad_start_time( @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_logbook_stream_match_multiple_entities( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator + recorder_mock: Recorder, + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, ) -> None: """Test logbook stream with a described integration that uses multiple entities.""" now = dt_util.utcnow() @@ -1933,7 +1944,7 @@ async def test_logbook_stream_match_multiple_entities( for comp in ("homeassistant", "logbook", "automation", "script") ] ) - entry = await _async_mock_entity_with_logbook_platform(hass) + entry = await _async_mock_entity_with_logbook_platform(hass, entity_registry) entity_id = entry.entity_id hass.states.async_set(entity_id, STATE_ON) @@ -2066,6 +2077,7 @@ async def test_live_stream_with_one_second_commit_interval( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, ) -> None: """Test the recorder with a 1s commit interval.""" config = {recorder.CONF_COMMIT_INTERVAL: 0.5} @@ -2077,7 +2089,7 @@ async def test_live_stream_with_one_second_commit_interval( for comp in ("homeassistant", "logbook", "automation", "script") ] ) - devices = await _async_mock_devices_with_logbook_platform(hass) + devices = await _async_mock_devices_with_logbook_platform(hass, device_registry) device = devices[0] await hass.async_block_till_done() @@ -2281,6 +2293,7 @@ async def test_recorder_is_far_behind( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, ) -> None: """Test we still start live streaming if the recorder is far behind.""" now = dt_util.utcnow() @@ -2291,7 +2304,7 @@ async def test_recorder_is_far_behind( ] ) await async_wait_recording_done(hass) - devices = await _async_mock_devices_with_logbook_platform(hass) + devices = await _async_mock_devices_with_logbook_platform(hass, device_registry) device = devices[0] await async_wait_recording_done(hass) @@ -2705,7 +2718,10 @@ async def test_logbook_stream_ignores_forced_updates( @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_all_entities_are_continuous_with_device( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator + recorder_mock: Recorder, + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, ) -> None: """Test subscribe/unsubscribe logbook stream with entities that are always filtered and a device.""" now = dt_util.utcnow() @@ -2716,7 +2732,7 @@ async def test_subscribe_all_entities_are_continuous_with_device( ] ) await async_wait_recording_done(hass) - devices = await _async_mock_devices_with_logbook_platform(hass) + devices = await _async_mock_devices_with_logbook_platform(hass, device_registry) device = devices[0] device2 = devices[1] diff --git a/tests/components/melnor/test_sensor.py b/tests/components/melnor/test_sensor.py index d51a600492..b525ec67b1 100644 --- a/tests/components/melnor/test_sensor.py +++ b/tests/components/melnor/test_sensor.py @@ -6,7 +6,7 @@ from freezegun import freeze_time from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util from .conftest import ( @@ -72,7 +72,9 @@ async def test_minutes_remaining_sensor(hass: HomeAssistant) -> None: assert minutes_remaining_sensor.state == end_time.isoformat(timespec="seconds") -async def test_rssi_sensor(hass: HomeAssistant) -> None: +async def test_rssi_sensor( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test the rssi sensor.""" entry = mock_config_entry(hass) @@ -88,15 +90,14 @@ async def test_rssi_sensor(hass: HomeAssistant) -> None: entity_id = f"sensor.{device.name}_rssi" # Ensure the entity is disabled by default by checking the registry - ent_registry = entity_registry.async_get(hass) - rssi_registry_entry = ent_registry.async_get(entity_id) + rssi_registry_entry = entity_registry.async_get(entity_id) assert rssi_registry_entry is not None assert rssi_registry_entry.disabled_by is not None # Enable the entity and assert everything else is working as expected - ent_registry.async_update_entity(entity_id, disabled_by=None) + entity_registry.async_update_entity(entity_id, disabled_by=None) await hass.config_entries.async_reload(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/powerwall/test_switch.py b/tests/components/powerwall/test_switch.py index 0d01541bf6..393f89e62f 100644 --- a/tests/components/powerwall/test_switch.py +++ b/tests/components/powerwall/test_switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( from homeassistant.const import ATTR_ENTITY_ID, CONF_IP_ADDRESS, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as ent_reg +from homeassistant.helpers import entity_registry as er from .mocks import _mock_powerwall_with_fixtures @@ -38,11 +38,12 @@ async def mock_powerwall_fixture(hass): yield mock_powerwall -async def test_entity_registry(hass: HomeAssistant, mock_powerwall) -> None: +async def test_entity_registry( + hass: HomeAssistant, mock_powerwall, entity_registry: er.EntityRegistry +) -> None: """Test powerwall off-grid switch device.""" mock_powerwall.get_grid_status = Mock(return_value=GridStatus.CONNECTED) - entity_registry = ent_reg.async_get(hass) assert ENTITY_ID in entity_registry.entities diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index fc325663f5..e328487fa7 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -2,6 +2,7 @@ from dataclasses import dataclass import datetime from http import HTTPStatus +from typing import Any from unittest import mock import prometheus_client @@ -58,7 +59,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, split_entity_id -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -522,7 +523,11 @@ async def test_counter(client, counter_entities) -> None: @pytest.mark.parametrize("namespace", [""]) async def test_renaming_entity_name( - hass: HomeAssistant, registry, client, sensor_entities, climate_entities + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + client, + sensor_entities, + climate_entities, ) -> None: """Test renaming entity name.""" data = {**sensor_entities, **climate_entities} @@ -566,9 +571,9 @@ async def test_renaming_entity_name( 'friendly_name="HeatPump"} 0.0' in body ) - assert "sensor.outside_temperature" in registry.entities - assert "climate.heatpump" in registry.entities - registry.async_update_entity( + assert "sensor.outside_temperature" in entity_registry.entities + assert "climate.heatpump" in entity_registry.entities + entity_registry.async_update_entity( entity_id=data["sensor_1"].entity_id, name="Outside Temperature Renamed", ) @@ -578,7 +583,7 @@ async def test_renaming_entity_name( 15.6, {ATTR_FRIENDLY_NAME: "Outside Temperature Renamed"}, ) - registry.async_update_entity( + entity_registry.async_update_entity( entity_id=data["climate_1"].entity_id, name="HeatPump Renamed", ) @@ -644,7 +649,11 @@ async def test_renaming_entity_name( @pytest.mark.parametrize("namespace", [""]) async def test_renaming_entity_id( - hass: HomeAssistant, registry, client, sensor_entities, climate_entities + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + client, + sensor_entities, + climate_entities, ) -> None: """Test renaming entity id.""" data = {**sensor_entities, **climate_entities} @@ -674,9 +683,9 @@ async def test_renaming_entity_id( 'friendly_name="Outside Humidity"} 1.0' in body ) - assert "sensor.outside_temperature" in registry.entities - assert "climate.heatpump" in registry.entities - registry.async_update_entity( + assert "sensor.outside_temperature" in entity_registry.entities + assert "climate.heatpump" in entity_registry.entities + entity_registry.async_update_entity( entity_id="sensor.outside_temperature", new_entity_id="sensor.outside_temperature_renamed", ) @@ -720,7 +729,11 @@ async def test_renaming_entity_id( @pytest.mark.parametrize("namespace", [""]) async def test_deleting_entity( - hass: HomeAssistant, registry, client, sensor_entities, climate_entities + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + client, + sensor_entities, + climate_entities, ) -> None: """Test deleting a entity.""" data = {**sensor_entities, **climate_entities} @@ -764,10 +777,10 @@ async def test_deleting_entity( 'friendly_name="HeatPump"} 0.0' in body ) - assert "sensor.outside_temperature" in registry.entities - assert "climate.heatpump" in registry.entities - registry.async_remove(data["sensor_1"].entity_id) - registry.async_remove(data["climate_1"].entity_id) + assert "sensor.outside_temperature" in entity_registry.entities + assert "climate.heatpump" in entity_registry.entities + entity_registry.async_remove(data["sensor_1"].entity_id) + entity_registry.async_remove(data["climate_1"].entity_id) await hass.async_block_till_done() body = await generate_latest_metrics(client) @@ -795,7 +808,11 @@ async def test_deleting_entity( @pytest.mark.parametrize("namespace", [""]) async def test_disabling_entity( - hass: HomeAssistant, registry, client, sensor_entities, climate_entities + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + client, + sensor_entities, + climate_entities, ) -> None: """Test disabling a entity.""" data = {**sensor_entities, **climate_entities} @@ -848,15 +865,15 @@ async def test_disabling_entity( 'friendly_name="HeatPump"} 0.0' in body ) - assert "sensor.outside_temperature" in registry.entities - assert "climate.heatpump" in registry.entities - registry.async_update_entity( + assert "sensor.outside_temperature" in entity_registry.entities + assert "climate.heatpump" in entity_registry.entities + entity_registry.async_update_entity( entity_id=data["sensor_1"].entity_id, - disabled_by=entity_registry.RegistryEntryDisabler.USER, + disabled_by=er.RegistryEntryDisabler.USER, ) - registry.async_update_entity( + entity_registry.async_update_entity( entity_id="climate.heatpump", - disabled_by=entity_registry.RegistryEntryDisabler.USER, + disabled_by=er.RegistryEntryDisabler.USER, ) await hass.async_block_till_done() @@ -883,17 +900,13 @@ async def test_disabling_entity( ) -@pytest.fixture(name="registry") -def entity_registry_fixture(hass): - """Provide entity registry.""" - return entity_registry.async_get(hass) - - @pytest.fixture(name="sensor_entities") -async def sensor_fixture(hass, registry): +async def sensor_fixture( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> dict[str, er.RegistryEntry]: """Simulate sensor entities.""" data = {} - sensor_1 = registry.async_get_or_create( + sensor_1 = entity_registry.async_get_or_create( domain=sensor.DOMAIN, platform="test", unique_id="sensor_1", @@ -907,7 +920,7 @@ async def sensor_fixture(hass, registry): data["sensor_1"] = sensor_1 data["sensor_1_attributes"] = sensor_1_attributes - sensor_2 = registry.async_get_or_create( + sensor_2 = entity_registry.async_get_or_create( domain=sensor.DOMAIN, platform="test", unique_id="sensor_2", @@ -919,7 +932,7 @@ async def sensor_fixture(hass, registry): set_state_with_entry(hass, sensor_2, 54.0) data["sensor_2"] = sensor_2 - sensor_3 = registry.async_get_or_create( + sensor_3 = entity_registry.async_get_or_create( domain=sensor.DOMAIN, platform="test", unique_id="sensor_3", @@ -935,7 +948,7 @@ async def sensor_fixture(hass, registry): set_state_with_entry(hass, sensor_3, 14) data["sensor_3"] = sensor_3 - sensor_4 = registry.async_get_or_create( + sensor_4 = entity_registry.async_get_or_create( domain=sensor.DOMAIN, platform="test", unique_id="sensor_4", @@ -946,7 +959,7 @@ async def sensor_fixture(hass, registry): set_state_with_entry(hass, sensor_4, 74) data["sensor_4"] = sensor_4 - sensor_5 = registry.async_get_or_create( + sensor_5 = entity_registry.async_get_or_create( domain=sensor.DOMAIN, platform="test", unique_id="sensor_5", @@ -957,7 +970,7 @@ async def sensor_fixture(hass, registry): set_state_with_entry(hass, sensor_5, 0.123) data["sensor_5"] = sensor_5 - sensor_6 = registry.async_get_or_create( + sensor_6 = entity_registry.async_get_or_create( domain=sensor.DOMAIN, platform="test", unique_id="sensor_6", @@ -968,7 +981,7 @@ async def sensor_fixture(hass, registry): set_state_with_entry(hass, sensor_6, 25) data["sensor_6"] = sensor_6 - sensor_7 = registry.async_get_or_create( + sensor_7 = entity_registry.async_get_or_create( domain=sensor.DOMAIN, platform="test", unique_id="sensor_7", @@ -979,7 +992,7 @@ async def sensor_fixture(hass, registry): set_state_with_entry(hass, sensor_7, 3.7069) data["sensor_7"] = sensor_7 - sensor_8 = registry.async_get_or_create( + sensor_8 = entity_registry.async_get_or_create( domain=sensor.DOMAIN, platform="test", unique_id="sensor_8", @@ -989,7 +1002,7 @@ async def sensor_fixture(hass, registry): set_state_with_entry(hass, sensor_8, 0.002) data["sensor_8"] = sensor_8 - sensor_9 = registry.async_get_or_create( + sensor_9 = entity_registry.async_get_or_create( domain=sensor.DOMAIN, platform="test", unique_id="sensor_9", @@ -999,7 +1012,7 @@ async def sensor_fixture(hass, registry): set_state_with_entry(hass, sensor_9, "should_not_work") data["sensor_9"] = sensor_9 - sensor_10 = registry.async_get_or_create( + sensor_10 = entity_registry.async_get_or_create( domain=sensor.DOMAIN, platform="test", unique_id="sensor_10", @@ -1010,7 +1023,7 @@ async def sensor_fixture(hass, registry): set_state_with_entry(hass, sensor_10, "should_not_work") data["sensor_10"] = sensor_10 - sensor_11 = registry.async_get_or_create( + sensor_11 = entity_registry.async_get_or_create( domain=sensor.DOMAIN, platform="test", unique_id="sensor_11", @@ -1027,10 +1040,12 @@ async def sensor_fixture(hass, registry): @pytest.fixture(name="climate_entities") -async def climate_fixture(hass, registry): +async def climate_fixture( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> dict[str, er.RegistryEntry | dict[str, Any]]: """Simulate climate entities.""" data = {} - climate_1 = registry.async_get_or_create( + climate_1 = entity_registry.async_get_or_create( domain=climate.DOMAIN, platform="test", unique_id="climate_1", @@ -1049,7 +1064,7 @@ async def climate_fixture(hass, registry): data["climate_1"] = climate_1 data["climate_1_attributes"] = climate_1_attributes - climate_2 = registry.async_get_or_create( + climate_2 = entity_registry.async_get_or_create( domain=climate.DOMAIN, platform="test", unique_id="climate_2", @@ -1070,7 +1085,7 @@ async def climate_fixture(hass, registry): data["climate_2"] = climate_2 data["climate_2_attributes"] = climate_2_attributes - climate_3 = registry.async_get_or_create( + climate_3 = entity_registry.async_get_or_create( domain=climate.DOMAIN, platform="test", unique_id="climate_3", @@ -1092,10 +1107,12 @@ async def climate_fixture(hass, registry): @pytest.fixture(name="humidifier_entities") -async def humidifier_fixture(hass, registry): +async def humidifier_fixture( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> dict[str, er.RegistryEntry | dict[str, Any]]: """Simulate humidifier entities.""" data = {} - humidifier_1 = registry.async_get_or_create( + humidifier_1 = entity_registry.async_get_or_create( domain=humidifier.DOMAIN, platform="test", unique_id="humidifier_1", @@ -1110,7 +1127,7 @@ async def humidifier_fixture(hass, registry): data["humidifier_1"] = humidifier_1 data["humidifier_1_attributes"] = humidifier_1_attributes - humidifier_2 = registry.async_get_or_create( + humidifier_2 = entity_registry.async_get_or_create( domain=humidifier.DOMAIN, platform="test", unique_id="humidifier_2", @@ -1125,7 +1142,7 @@ async def humidifier_fixture(hass, registry): data["humidifier_2"] = humidifier_2 data["humidifier_2_attributes"] = humidifier_2_attributes - humidifier_3 = registry.async_get_or_create( + humidifier_3 = entity_registry.async_get_or_create( domain=humidifier.DOMAIN, platform="test", unique_id="humidifier_3", @@ -1146,10 +1163,12 @@ async def humidifier_fixture(hass, registry): @pytest.fixture(name="lock_entities") -async def lock_fixture(hass, registry): +async def lock_fixture( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> dict[str, er.RegistryEntry]: """Simulate lock entities.""" data = {} - lock_1 = registry.async_get_or_create( + lock_1 = entity_registry.async_get_or_create( domain=lock.DOMAIN, platform="test", unique_id="lock_1", @@ -1159,7 +1178,7 @@ async def lock_fixture(hass, registry): set_state_with_entry(hass, lock_1, STATE_LOCKED) data["lock_1"] = lock_1 - lock_2 = registry.async_get_or_create( + lock_2 = entity_registry.async_get_or_create( domain=lock.DOMAIN, platform="test", unique_id="lock_2", @@ -1174,10 +1193,12 @@ async def lock_fixture(hass, registry): @pytest.fixture(name="cover_entities") -async def cover_fixture(hass, registry): +async def cover_fixture( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> dict[str, er.RegistryEntry]: """Simulate cover entities.""" data = {} - cover_open = registry.async_get_or_create( + cover_open = entity_registry.async_get_or_create( domain=cover.DOMAIN, platform="test", unique_id="cover_open", @@ -1187,7 +1208,7 @@ async def cover_fixture(hass, registry): set_state_with_entry(hass, cover_open, STATE_OPEN) data["cover_open"] = cover_open - cover_closed = registry.async_get_or_create( + cover_closed = entity_registry.async_get_or_create( domain=cover.DOMAIN, platform="test", unique_id="cover_closed", @@ -1197,7 +1218,7 @@ async def cover_fixture(hass, registry): set_state_with_entry(hass, cover_closed, STATE_CLOSED) data["cover_closed"] = cover_closed - cover_closing = registry.async_get_or_create( + cover_closing = entity_registry.async_get_or_create( domain=cover.DOMAIN, platform="test", unique_id="cover_closing", @@ -1207,7 +1228,7 @@ async def cover_fixture(hass, registry): set_state_with_entry(hass, cover_closing, STATE_CLOSING) data["cover_closing"] = cover_closing - cover_opening = registry.async_get_or_create( + cover_opening = entity_registry.async_get_or_create( domain=cover.DOMAIN, platform="test", unique_id="cover_opening", @@ -1217,7 +1238,7 @@ async def cover_fixture(hass, registry): set_state_with_entry(hass, cover_opening, STATE_OPENING) data["cover_opening"] = cover_opening - cover_position = registry.async_get_or_create( + cover_position = entity_registry.async_get_or_create( domain=cover.DOMAIN, platform="test", unique_id="cover_position", @@ -1228,7 +1249,7 @@ async def cover_fixture(hass, registry): set_state_with_entry(hass, cover_position, STATE_OPEN, cover_position_attributes) data["cover_position"] = cover_position - cover_tilt_position = registry.async_get_or_create( + cover_tilt_position = entity_registry.async_get_or_create( domain=cover.DOMAIN, platform="test", unique_id="cover_tilt_position", @@ -1246,10 +1267,12 @@ async def cover_fixture(hass, registry): @pytest.fixture(name="input_number_entities") -async def input_number_fixture(hass, registry): +async def input_number_fixture( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> dict[str, er.RegistryEntry]: """Simulate input_number entities.""" data = {} - input_number_1 = registry.async_get_or_create( + input_number_1 = entity_registry.async_get_or_create( domain=input_number.DOMAIN, platform="test", unique_id="input_number_1", @@ -1259,7 +1282,7 @@ async def input_number_fixture(hass, registry): set_state_with_entry(hass, input_number_1, 5.2) data["input_number_1"] = input_number_1 - input_number_2 = registry.async_get_or_create( + input_number_2 = entity_registry.async_get_or_create( domain=input_number.DOMAIN, platform="test", unique_id="input_number_2", @@ -1268,7 +1291,7 @@ async def input_number_fixture(hass, registry): set_state_with_entry(hass, input_number_2, 60) data["input_number_2"] = input_number_2 - input_number_3 = registry.async_get_or_create( + input_number_3 = entity_registry.async_get_or_create( domain=input_number.DOMAIN, platform="test", unique_id="input_number_3", @@ -1284,10 +1307,12 @@ async def input_number_fixture(hass, registry): @pytest.fixture(name="input_boolean_entities") -async def input_boolean_fixture(hass, registry): +async def input_boolean_fixture( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> dict[str, er.RegistryEntry]: """Simulate input_boolean entities.""" data = {} - input_boolean_1 = registry.async_get_or_create( + input_boolean_1 = entity_registry.async_get_or_create( domain=input_boolean.DOMAIN, platform="test", unique_id="input_boolean_1", @@ -1297,7 +1322,7 @@ async def input_boolean_fixture(hass, registry): set_state_with_entry(hass, input_boolean_1, STATE_ON) data["input_boolean_1"] = input_boolean_1 - input_boolean_2 = registry.async_get_or_create( + input_boolean_2 = entity_registry.async_get_or_create( domain=input_boolean.DOMAIN, platform="test", unique_id="input_boolean_2", @@ -1312,10 +1337,12 @@ async def input_boolean_fixture(hass, registry): @pytest.fixture(name="binary_sensor_entities") -async def binary_sensor_fixture(hass, registry): +async def binary_sensor_fixture( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> dict[str, er.RegistryEntry]: """Simulate binary_sensor entities.""" data = {} - binary_sensor_1 = registry.async_get_or_create( + binary_sensor_1 = entity_registry.async_get_or_create( domain=binary_sensor.DOMAIN, platform="test", unique_id="binary_sensor_1", @@ -1325,7 +1352,7 @@ async def binary_sensor_fixture(hass, registry): set_state_with_entry(hass, binary_sensor_1, STATE_ON) data["binary_sensor_1"] = binary_sensor_1 - binary_sensor_2 = registry.async_get_or_create( + binary_sensor_2 = entity_registry.async_get_or_create( domain=binary_sensor.DOMAIN, platform="test", unique_id="binary_sensor_2", @@ -1340,10 +1367,12 @@ async def binary_sensor_fixture(hass, registry): @pytest.fixture(name="light_entities") -async def light_fixture(hass, registry): +async def light_fixture( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> dict[str, er.RegistryEntry]: """Simulate light entities.""" data = {} - light_1 = registry.async_get_or_create( + light_1 = entity_registry.async_get_or_create( domain=light.DOMAIN, platform="test", unique_id="light_1", @@ -1353,7 +1382,7 @@ async def light_fixture(hass, registry): set_state_with_entry(hass, light_1, STATE_ON) data["light_1"] = light_1 - light_2 = registry.async_get_or_create( + light_2 = entity_registry.async_get_or_create( domain=light.DOMAIN, platform="test", unique_id="light_2", @@ -1363,7 +1392,7 @@ async def light_fixture(hass, registry): set_state_with_entry(hass, light_2, STATE_OFF) data["light_2"] = light_2 - light_3 = registry.async_get_or_create( + light_3 = entity_registry.async_get_or_create( domain=light.DOMAIN, platform="test", unique_id="light_3", @@ -1375,7 +1404,7 @@ async def light_fixture(hass, registry): data["light_3"] = light_3 data["light_3_attributes"] = light_3_attributes - light_4 = registry.async_get_or_create( + light_4 = entity_registry.async_get_or_create( domain=light.DOMAIN, platform="test", unique_id="light_4", @@ -1392,10 +1421,12 @@ async def light_fixture(hass, registry): @pytest.fixture(name="switch_entities") -async def switch_fixture(hass, registry): +async def switch_fixture( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> dict[str, er.RegistryEntry | dict[str, Any]]: """Simulate switch entities.""" data = {} - switch_1 = registry.async_get_or_create( + switch_1 = entity_registry.async_get_or_create( domain=switch.DOMAIN, platform="test", unique_id="switch_1", @@ -1407,7 +1438,7 @@ async def switch_fixture(hass, registry): data["switch_1"] = switch_1 data["switch_1_attributes"] = switch_1_attributes - switch_2 = registry.async_get_or_create( + switch_2 = entity_registry.async_get_or_create( domain=switch.DOMAIN, platform="test", unique_id="switch_2", @@ -1424,10 +1455,12 @@ async def switch_fixture(hass, registry): @pytest.fixture(name="person_entities") -async def person_fixture(hass, registry): +async def person_fixture( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> dict[str, er.RegistryEntry]: """Simulate person entities.""" data = {} - person_1 = registry.async_get_or_create( + person_1 = entity_registry.async_get_or_create( domain=person.DOMAIN, platform="test", unique_id="person_1", @@ -1437,7 +1470,7 @@ async def person_fixture(hass, registry): set_state_with_entry(hass, person_1, STATE_HOME) data["person_1"] = person_1 - person_2 = registry.async_get_or_create( + person_2 = entity_registry.async_get_or_create( domain=person.DOMAIN, platform="test", unique_id="person_2", @@ -1452,10 +1485,12 @@ async def person_fixture(hass, registry): @pytest.fixture(name="device_tracker_entities") -async def device_tracker_fixture(hass, registry): +async def device_tracker_fixture( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> dict[str, er.RegistryEntry]: """Simulate device_tracker entities.""" data = {} - device_tracker_1 = registry.async_get_or_create( + device_tracker_1 = entity_registry.async_get_or_create( domain=device_tracker.DOMAIN, platform="test", unique_id="device_tracker_1", @@ -1465,7 +1500,7 @@ async def device_tracker_fixture(hass, registry): set_state_with_entry(hass, device_tracker_1, STATE_HOME) data["device_tracker_1"] = device_tracker_1 - device_tracker_2 = registry.async_get_or_create( + device_tracker_2 = entity_registry.async_get_or_create( domain=device_tracker.DOMAIN, platform="test", unique_id="device_tracker_2", @@ -1480,10 +1515,12 @@ async def device_tracker_fixture(hass, registry): @pytest.fixture(name="counter_entities") -async def counter_fixture(hass, registry): +async def counter_fixture( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> dict[str, er.RegistryEntry]: """Simulate counter entities.""" data = {} - counter_1 = registry.async_get_or_create( + counter_1 = entity_registry.async_get_or_create( domain=counter.DOMAIN, platform="test", unique_id="counter_1", @@ -1497,8 +1534,8 @@ async def counter_fixture(hass, registry): def set_state_with_entry( - hass, - entry: entity_registry.RegistryEntry, + hass: HomeAssistant, + entry: er.RegistryEntry, state, additional_attributes=None, new_entity_id=None, diff --git a/tests/components/qnap_qsw/test_binary_sensor.py b/tests/components/qnap_qsw/test_binary_sensor.py index a270e78f05..f007f79934 100644 --- a/tests/components/qnap_qsw/test_binary_sensor.py +++ b/tests/components/qnap_qsw/test_binary_sensor.py @@ -5,18 +5,19 @@ from unittest.mock import AsyncMock from homeassistant.components.qnap_qsw.const import ATTR_MESSAGE from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from .util import async_init_integration async def test_qnap_qsw_create_binary_sensors( - hass: HomeAssistant, entity_registry_enabled_by_default: AsyncMock + hass: HomeAssistant, + entity_registry_enabled_by_default: AsyncMock, + entity_registry: er.EntityRegistry, ) -> None: """Test creation of binary sensors.""" await async_init_integration(hass) - er = entity_registry.async_get(hass) state = hass.states.get("binary_sensor.qsw_m408_4c_anomaly") assert state.state == STATE_OFF @@ -24,7 +25,7 @@ async def test_qnap_qsw_create_binary_sensors( state = hass.states.get("binary_sensor.qsw_m408_4c_lacp_port_1_link") assert state.state == STATE_OFF - entry = er.async_get(state.entity_id) + entry = entity_registry.async_get(state.entity_id) assert entry.unique_id == "qsw_unique_id_ports-status_lacp_port_1_link" state = hass.states.get("binary_sensor.qsw_m408_4c_lacp_port_2_link") @@ -44,7 +45,7 @@ async def test_qnap_qsw_create_binary_sensors( state = hass.states.get("binary_sensor.qsw_m408_4c_port_1_link") assert state.state == STATE_ON - entry = er.async_get(state.entity_id) + entry = entity_registry.async_get(state.entity_id) assert entry.unique_id == "qsw_unique_id_ports-status_port_1_link" state = hass.states.get("binary_sensor.qsw_m408_4c_port_2_link") diff --git a/tests/components/rituals_perfume_genie/test_binary_sensor.py b/tests/components/rituals_perfume_genie/test_binary_sensor.py index 58c68b8a92..ea4d8021eb 100644 --- a/tests/components/rituals_perfume_genie/test_binary_sensor.py +++ b/tests/components/rituals_perfume_genie/test_binary_sensor.py @@ -3,7 +3,7 @@ from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.rituals_perfume_genie.binary_sensor import CHARGING_SUFFIX from homeassistant.const import ATTR_DEVICE_CLASS, STATE_ON, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from .common import ( init_integration, @@ -12,12 +12,13 @@ from .common import ( ) -async def test_binary_sensors(hass: HomeAssistant) -> None: +async def test_binary_sensors( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test the creation and values of the Rituals Perfume Genie binary sensor.""" config_entry = mock_config_entry(unique_id="binary_sensor_test_diffuser_v1") diffuser = mock_diffuser_v1_battery_cartridge() await init_integration(hass, config_entry, [diffuser]) - registry = entity_registry.async_get(hass) hublot = diffuser.hublot state = hass.states.get("binary_sensor.genie_battery_charging") @@ -27,7 +28,7 @@ async def test_binary_sensors(hass: HomeAssistant) -> None: state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.BATTERY_CHARGING ) - entry = registry.async_get("binary_sensor.genie_battery_charging") + entry = entity_registry.async_get("binary_sensor.genie_battery_charging") assert entry assert entry.unique_id == f"{hublot}{CHARGING_SUFFIX}" assert entry.entity_category == EntityCategory.DIAGNOSTIC diff --git a/tests/components/rituals_perfume_genie/test_number.py b/tests/components/rituals_perfume_genie/test_number.py index 3c15352b64..028ab40ed7 100644 --- a/tests/components/rituals_perfume_genie/test_number.py +++ b/tests/components/rituals_perfume_genie/test_number.py @@ -18,7 +18,7 @@ from homeassistant.components.rituals_perfume_genie.number import ( ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_ICON from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from .common import ( @@ -29,14 +29,14 @@ from .common import ( ) -async def test_number_entity(hass: HomeAssistant) -> None: +async def test_number_entity( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test the creation and values of the diffuser number entity.""" config_entry = mock_config_entry(unique_id="number_test") diffuser = mock_diffuser(hublot="lot123", perfume_amount=2) await init_integration(hass, config_entry, [diffuser]) - registry = entity_registry.async_get(hass) - state = hass.states.get("number.genie_perfume_amount") assert state assert state.state == str(diffuser.perfume_amount) @@ -44,7 +44,7 @@ async def test_number_entity(hass: HomeAssistant) -> None: assert state.attributes[ATTR_MIN] == MIN_PERFUME_AMOUNT assert state.attributes[ATTR_MAX] == MAX_PERFUME_AMOUNT - entry = registry.async_get("number.genie_perfume_amount") + entry = entity_registry.async_get("number.genie_perfume_amount") assert entry assert entry.unique_id == f"{diffuser.hublot}{PERFUME_AMOUNT_SUFFIX}" diff --git a/tests/components/rituals_perfume_genie/test_select.py b/tests/components/rituals_perfume_genie/test_select.py index d9b7690106..00147b9073 100644 --- a/tests/components/rituals_perfume_genie/test_select.py +++ b/tests/components/rituals_perfume_genie/test_select.py @@ -16,27 +16,27 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from .common import init_integration, mock_config_entry, mock_diffuser -async def test_select_entity(hass: HomeAssistant) -> None: +async def test_select_entity( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test the creation and state of the diffuser select entity.""" config_entry = mock_config_entry(unique_id="select_test") diffuser = mock_diffuser(hublot="lot123", room_size_square_meter=60) await init_integration(hass, config_entry, [diffuser]) - registry = entity_registry.async_get(hass) - state = hass.states.get("select.genie_room_size") assert state assert state.state == str(diffuser.room_size_square_meter) assert state.attributes[ATTR_ICON] == "mdi:ruler-square" assert state.attributes[ATTR_OPTIONS] == ["15", "30", "60", "100"] - entry = registry.async_get("select.genie_room_size") + entry = entity_registry.async_get("select.genie_room_size") assert entry assert entry.unique_id == f"{diffuser.hublot}{ROOM_SIZE_SUFFIX}" assert entry.unit_of_measurement == AREA_SQUARE_METERS diff --git a/tests/components/rituals_perfume_genie/test_sensor.py b/tests/components/rituals_perfume_genie/test_sensor.py index eef92f71a2..5573ddc633 100644 --- a/tests/components/rituals_perfume_genie/test_sensor.py +++ b/tests/components/rituals_perfume_genie/test_sensor.py @@ -14,7 +14,7 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from .common import ( init_integration, @@ -24,12 +24,13 @@ from .common import ( ) -async def test_sensors_diffuser_v1_battery_cartridge(hass: HomeAssistant) -> None: +async def test_sensors_diffuser_v1_battery_cartridge( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test the creation and values of the Rituals Perfume Genie sensors.""" config_entry = mock_config_entry(unique_id="id_123_sensor_test_diffuser_v1") diffuser = mock_diffuser_v1_battery_cartridge() await init_integration(hass, config_entry, [diffuser]) - registry = entity_registry.async_get(hass) hublot = diffuser.hublot state = hass.states.get("sensor.genie_perfume") @@ -37,7 +38,7 @@ async def test_sensors_diffuser_v1_battery_cartridge(hass: HomeAssistant) -> Non assert state.state == diffuser.perfume assert state.attributes.get(ATTR_ICON) == "mdi:tag-text" - entry = registry.async_get("sensor.genie_perfume") + entry = entity_registry.async_get("sensor.genie_perfume") assert entry assert entry.unique_id == f"{hublot}{PERFUME_SUFFIX}" @@ -46,7 +47,7 @@ async def test_sensors_diffuser_v1_battery_cartridge(hass: HomeAssistant) -> Non assert state.state == diffuser.fill assert state.attributes.get(ATTR_ICON) == "mdi:beaker" - entry = registry.async_get("sensor.genie_fill") + entry = entity_registry.async_get("sensor.genie_fill") assert entry assert entry.unique_id == f"{hublot}{FILL_SUFFIX}" @@ -56,7 +57,7 @@ async def test_sensors_diffuser_v1_battery_cartridge(hass: HomeAssistant) -> Non assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.BATTERY assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - entry = registry.async_get("sensor.genie_battery") + entry = entity_registry.async_get("sensor.genie_battery") assert entry assert entry.unique_id == f"{hublot}{BATTERY_SUFFIX}" assert entry.entity_category == EntityCategory.DIAGNOSTIC @@ -67,7 +68,7 @@ async def test_sensors_diffuser_v1_battery_cartridge(hass: HomeAssistant) -> Non assert state.attributes.get(ATTR_DEVICE_CLASS) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - entry = registry.async_get("sensor.genie_wifi") + entry = entity_registry.async_get("sensor.genie_wifi") assert entry assert entry.unique_id == f"{hublot}{WIFI_SUFFIX}" assert entry.entity_category == EntityCategory.DIAGNOSTIC diff --git a/tests/components/rituals_perfume_genie/test_switch.py b/tests/components/rituals_perfume_genie/test_switch.py index 960923a1b7..cb688f528f 100644 --- a/tests/components/rituals_perfume_genie/test_switch.py +++ b/tests/components/rituals_perfume_genie/test_switch.py @@ -13,7 +13,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from .common import ( @@ -23,20 +23,20 @@ from .common import ( ) -async def test_switch_entity(hass: HomeAssistant) -> None: +async def test_switch_entity( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test the creation and values of the Rituals Perfume Genie diffuser switch.""" config_entry = mock_config_entry(unique_id="id_123_switch_test") diffuser = mock_diffuser_v1_battery_cartridge() await init_integration(hass, config_entry, [diffuser]) - registry = entity_registry.async_get(hass) - state = hass.states.get("switch.genie") assert state assert state.state == STATE_ON assert state.attributes.get(ATTR_ICON) == "mdi:fan" - entry = registry.async_get("switch.genie") + entry = entity_registry.async_get("switch.genie") assert entry assert entry.unique_id == diffuser.hublot diff --git a/tests/components/snooz/test_fan.py b/tests/components/snooz/test_fan.py index 698a839c84..cda4ce24db 100644 --- a/tests/components/snooz/test_fan.py +++ b/tests/components/snooz/test_fan.py @@ -27,7 +27,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from . import SnoozFixture, create_mock_snooz, create_mock_snooz_config_entry @@ -172,12 +172,14 @@ async def test_push_events( assert state.attributes[ATTR_ASSUMED_STATE] is True -async def test_restore_state(hass: HomeAssistant) -> None: +async def test_restore_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Tests restoring entity state.""" device = await create_mock_snooz(connected=False, initial_state=UnknownSnoozState) entry = await create_mock_snooz_config_entry(hass, device) - entity_id = get_fan_entity_id(hass, device) + entity_id = get_fan_entity_id(hass, device, entity_registry) # call service to store state await hass.services.async_call( @@ -203,12 +205,14 @@ async def test_restore_state(hass: HomeAssistant) -> None: assert state.attributes[ATTR_ASSUMED_STATE] is True -async def test_restore_unknown_state(hass: HomeAssistant) -> None: +async def test_restore_unknown_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Tests restoring entity state that was unknown.""" device = await create_mock_snooz(connected=False, initial_state=UnknownSnoozState) entry = await create_mock_snooz_config_entry(hass, device) - entity_id = get_fan_entity_id(hass, device) + entity_id = get_fan_entity_id(hass, device, entity_registry) # unload entry await hass.config_entries.async_unload(entry.entry_id) @@ -282,16 +286,18 @@ async def test_command_results( @pytest.fixture(name="snooz_fan_entity_id") async def fixture_snooz_fan_entity_id( - hass: HomeAssistant, mock_connected_snooz: SnoozFixture + hass: HomeAssistant, + mock_connected_snooz: SnoozFixture, + entity_registry: er.EntityRegistry, ) -> str: """Mock a Snooz fan entity and config entry.""" - return get_fan_entity_id(hass, mock_connected_snooz.device) + return get_fan_entity_id(hass, mock_connected_snooz.device, entity_registry) -def get_fan_entity_id(hass: HomeAssistant, device: MockSnoozDevice) -> str: +def get_fan_entity_id( + hass: HomeAssistant, device: MockSnoozDevice, entity_registry: er.EntityRegistry +) -> str: """Get the entity ID for a mock device.""" - return entity_registry.async_get(hass).async_get_entity_id( - Platform.FAN, DOMAIN, device.address - ) + return entity_registry.async_get_entity_id(Platform.FAN, DOMAIN, device.address) diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index fece314c91..5b5dc817d6 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -9,7 +9,7 @@ from homeassistant import setup from homeassistant.components.todoist.calendar import DOMAIN from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity @@ -69,7 +69,9 @@ def mock_api(task) -> AsyncMock: @patch("homeassistant.components.todoist.calendar.TodoistAPIAsync") -async def test_calendar_entity_unique_id(todoist_api, hass: HomeAssistant, api) -> None: +async def test_calendar_entity_unique_id( + todoist_api, hass: HomeAssistant, api, entity_registry: er.EntityRegistry +) -> None: """Test unique id is set to project id.""" todoist_api.return_value = api assert await setup.async_setup_component( @@ -84,8 +86,7 @@ async def test_calendar_entity_unique_id(todoist_api, hass: HomeAssistant, api) ) await hass.async_block_till_done() - registry = entity_registry.async_get(hass) - entity = registry.async_get("calendar.name") + entity = entity_registry.async_get("calendar.name") assert entity.unique_id == "12345" @@ -114,7 +115,7 @@ async def test_update_entity_for_custom_project_with_labels_on(todoist_api, hass @patch("homeassistant.components.todoist.calendar.TodoistAPIAsync") async def test_calendar_custom_project_unique_id( - todoist_api, hass: HomeAssistant, api + todoist_api, hass: HomeAssistant, api, entity_registry: er.EntityRegistry ) -> None: """Test unique id is None for any custom projects.""" todoist_api.return_value = api @@ -131,8 +132,7 @@ async def test_calendar_custom_project_unique_id( ) await hass.async_block_till_done() - registry = entity_registry.async_get(hass) - entity = registry.async_get("calendar.all_projects") + entity = entity_registry.async_get("calendar.all_projects") assert entity is None state = hass.states.get("calendar.all_projects") From b84eead3f8023e181fef818c6a835780f4908686 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 16:24:31 +0100 Subject: [PATCH 0565/2810] Adjust entity registry access in helper tests (#88965) --- tests/helpers/test_intent.py | 60 +++++++++++++++++++-------------- tests/helpers/test_service.py | 63 +++++++++++++++++------------------ 2 files changed, 66 insertions(+), 57 deletions(-) diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py index 7211f2bb9b..edc6a28117 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -7,10 +7,10 @@ from homeassistant.components.switch import SwitchDeviceClass from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import Context, HomeAssistant, State from homeassistant.helpers import ( - area_registry, + area_registry as ar, config_validation as cv, - device_registry, - entity_registry, + device_registry as dr, + entity_registry as er, intent, ) from homeassistant.setup import async_setup_component @@ -24,12 +24,15 @@ class MockIntentHandler(intent.IntentHandler): self.slot_schema = slot_schema -async def test_async_match_states(hass: HomeAssistant) -> None: +async def test_async_match_states( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test async_match_state helper.""" - areas = area_registry.async_get(hass) - area_kitchen = areas.async_get_or_create("kitchen") - areas.async_update(area_kitchen.id, aliases={"food room"}) - area_bedroom = areas.async_get_or_create("bedroom") + area_kitchen = area_registry.async_get_or_create("kitchen") + area_registry.async_update(area_kitchen.id, aliases={"food room"}) + area_bedroom = area_registry.async_get_or_create("bedroom") state1 = State( "light.kitchen", "on", attributes={ATTR_FRIENDLY_NAME: "kitchen light"} @@ -39,14 +42,15 @@ async def test_async_match_states(hass: HomeAssistant) -> None: ) # Put entities into different areas - entities = entity_registry.async_get(hass) - entities.async_get_or_create("light", "demo", "1234", suggested_object_id="kitchen") - entities.async_update_entity(state1.entity_id, area_id=area_kitchen.id) + entity_registry.async_get_or_create( + "light", "demo", "1234", suggested_object_id="kitchen" + ) + entity_registry.async_update_entity(state1.entity_id, area_id=area_kitchen.id) - entities.async_get_or_create( + entity_registry.async_get_or_create( "switch", "demo", "5678", suggested_object_id="bedroom" ) - entities.async_update_entity( + entity_registry.async_update_entity( state2.entity_id, area_id=area_bedroom.id, device_class=SwitchDeviceClass.OUTLET, @@ -102,17 +106,20 @@ async def test_async_match_states(hass: HomeAssistant) -> None: ) == [state2] -async def test_match_device_area(hass: HomeAssistant) -> None: +async def test_match_device_area( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test async_match_state with a device in an area.""" - areas = area_registry.async_get(hass) - area_kitchen = areas.async_get_or_create("kitchen") - area_bedroom = areas.async_get_or_create("bedroom") + area_kitchen = area_registry.async_get_or_create("kitchen") + area_bedroom = area_registry.async_get_or_create("bedroom") - devices = device_registry.async_get(hass) - kitchen_device = devices.async_get_or_create( + kitchen_device = device_registry.async_get_or_create( config_entry_id="1234", connections=set(), identifiers={("demo", "id-1234")} ) - devices.async_update_device(kitchen_device.id, area_id=area_kitchen.id) + device_registry.async_update_device(kitchen_device.id, area_id=area_kitchen.id) state1 = State( "light.kitchen", "on", attributes={ATTR_FRIENDLY_NAME: "kitchen light"} @@ -123,12 +130,15 @@ async def test_match_device_area(hass: HomeAssistant) -> None: state3 = State( "light.living_room", "on", attributes={ATTR_FRIENDLY_NAME: "living room light"} ) - entities = entity_registry.async_get(hass) - entities.async_get_or_create("light", "demo", "1234", suggested_object_id="kitchen") - entities.async_update_entity(state1.entity_id, device_id=kitchen_device.id) + entity_registry.async_get_or_create( + "light", "demo", "1234", suggested_object_id="kitchen" + ) + entity_registry.async_update_entity(state1.entity_id, device_id=kitchen_device.id) - entities.async_get_or_create("light", "demo", "5678", suggested_object_id="bedroom") - entities.async_update_entity(state2.entity_id, area_id=area_bedroom.id) + entity_registry.async_get_or_create( + "light", "demo", "5678", suggested_object_id="bedroom" + ) + entity_registry.async_update_entity(state2.entity_id, area_id=area_bedroom.id) # Match on area/domain assert list( diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 9f47bb7219..b93562621c 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -21,8 +21,8 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import ( - device_registry as dev_reg, - entity_registry as ent_reg, + device_registry as dr, + entity_registry as er, service, template, ) @@ -96,10 +96,10 @@ def area_mock(hass): hass.states.async_set("light.Ceiling", STATE_OFF) hass.states.async_set("light.Kitchen", STATE_OFF) - device_in_area = dev_reg.DeviceEntry(area_id="test-area") - device_no_area = dev_reg.DeviceEntry(id="device-no-area-id") - device_diff_area = dev_reg.DeviceEntry(area_id="diff-area") - device_area_a = dev_reg.DeviceEntry(id="device-area-a-id", area_id="area-a") + device_in_area = dr.DeviceEntry(area_id="test-area") + device_no_area = dr.DeviceEntry(id="device-no-area-id") + device_diff_area = dr.DeviceEntry(area_id="diff-area") + device_area_a = dr.DeviceEntry(id="device-area-a-id", area_id="area-a") mock_device_registry( hass, @@ -111,94 +111,94 @@ def area_mock(hass): }, ) - entity_in_own_area = ent_reg.RegistryEntry( + entity_in_own_area = er.RegistryEntry( entity_id="light.in_own_area", unique_id="in-own-area-id", platform="test", area_id="own-area", ) - config_entity_in_own_area = ent_reg.RegistryEntry( + config_entity_in_own_area = er.RegistryEntry( entity_id="light.config_in_own_area", unique_id="config-in-own-area-id", platform="test", area_id="own-area", entity_category=EntityCategory.CONFIG, ) - hidden_entity_in_own_area = ent_reg.RegistryEntry( + hidden_entity_in_own_area = er.RegistryEntry( entity_id="light.hidden_in_own_area", unique_id="hidden-in-own-area-id", platform="test", area_id="own-area", - hidden_by=ent_reg.RegistryEntryHider.USER, + hidden_by=er.RegistryEntryHider.USER, ) - entity_in_area = ent_reg.RegistryEntry( + entity_in_area = er.RegistryEntry( entity_id="light.in_area", unique_id="in-area-id", platform="test", device_id=device_in_area.id, ) - config_entity_in_area = ent_reg.RegistryEntry( + config_entity_in_area = er.RegistryEntry( entity_id="light.config_in_area", unique_id="config-in-area-id", platform="test", device_id=device_in_area.id, entity_category=EntityCategory.CONFIG, ) - hidden_entity_in_area = ent_reg.RegistryEntry( + hidden_entity_in_area = er.RegistryEntry( entity_id="light.hidden_in_area", unique_id="hidden-in-area-id", platform="test", device_id=device_in_area.id, - hidden_by=ent_reg.RegistryEntryHider.USER, + hidden_by=er.RegistryEntryHider.USER, ) - entity_in_other_area = ent_reg.RegistryEntry( + entity_in_other_area = er.RegistryEntry( entity_id="light.in_other_area", unique_id="in-area-a-id", platform="test", device_id=device_in_area.id, area_id="other-area", ) - entity_assigned_to_area = ent_reg.RegistryEntry( + entity_assigned_to_area = er.RegistryEntry( entity_id="light.assigned_to_area", unique_id="assigned-area-id", platform="test", device_id=device_in_area.id, area_id="test-area", ) - entity_no_area = ent_reg.RegistryEntry( + entity_no_area = er.RegistryEntry( entity_id="light.no_area", unique_id="no-area-id", platform="test", device_id=device_no_area.id, ) - config_entity_no_area = ent_reg.RegistryEntry( + config_entity_no_area = er.RegistryEntry( entity_id="light.config_no_area", unique_id="config-no-area-id", platform="test", device_id=device_no_area.id, entity_category=EntityCategory.CONFIG, ) - hidden_entity_no_area = ent_reg.RegistryEntry( + hidden_entity_no_area = er.RegistryEntry( entity_id="light.hidden_no_area", unique_id="hidden-no-area-id", platform="test", device_id=device_no_area.id, - hidden_by=ent_reg.RegistryEntryHider.USER, + hidden_by=er.RegistryEntryHider.USER, ) - entity_diff_area = ent_reg.RegistryEntry( + entity_diff_area = er.RegistryEntry( entity_id="light.diff_area", unique_id="diff-area-id", platform="test", device_id=device_diff_area.id, ) - entity_in_area_a = ent_reg.RegistryEntry( + entity_in_area_a = er.RegistryEntry( entity_id="light.in_area_a", unique_id="in-area-a-id", platform="test", device_id=device_area_a.id, area_id="area-a", ) - entity_in_area_b = ent_reg.RegistryEntry( + entity_in_area_b = er.RegistryEntry( entity_id="light.in_area_b", unique_id="in-area-b-id", platform="test", @@ -403,11 +403,12 @@ class TestServiceHelpers(unittest.TestCase): assert mock_log.call_count == 3 -async def test_service_call_entry_id(hass: HomeAssistant) -> None: +async def test_service_call_entry_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test service call with entity specified by entity registry ID.""" - registry = ent_reg.async_get(hass) calls = async_mock_service(hass, "test_domain", "test_service") - entry = registry.async_get_or_create( + entry = entity_registry.async_get_or_create( "hello", "hue", "1234", suggested_object_id="world" ) @@ -946,7 +947,7 @@ async def test_domain_control_unauthorized( mock_registry( hass, { - "light.kitchen": ent_reg.RegistryEntry( + "light.kitchen": er.RegistryEntry( entity_id="light.kitchen", unique_id="kitchen", platform="test_domain", @@ -987,7 +988,7 @@ async def test_domain_control_admin( mock_registry( hass, { - "light.kitchen": ent_reg.RegistryEntry( + "light.kitchen": er.RegistryEntry( entity_id="light.kitchen", unique_id="kitchen", platform="test_domain", @@ -1025,7 +1026,7 @@ async def test_domain_control_no_user(hass: HomeAssistant) -> None: mock_registry( hass, { - "light.kitchen": ent_reg.RegistryEntry( + "light.kitchen": er.RegistryEntry( entity_id="light.kitchen", unique_id="kitchen", platform="test_domain", @@ -1213,9 +1214,7 @@ async def test_async_extract_entities_warn_referenced( async def test_async_extract_config_entry_ids(hass: HomeAssistant) -> None: """Test we can find devices that have no entities.""" - device_no_entities = dev_reg.DeviceEntry( - id="device-no-entities", config_entries={"abc"} - ) + device_no_entities = dr.DeviceEntry(id="device-no-entities", config_entries={"abc"}) call = ServiceCall( "homeassistant", From 79bcdf43f7d6e472cac80c2128c00228c5dd7095 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 1 Mar 2023 16:26:20 +0100 Subject: [PATCH 0566/2810] Add `current` sensor for Shelly RPC devices (#88863) --- homeassistant/components/shelly/sensor.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 2737270ac7..5e05134fdc 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -439,6 +439,16 @@ RPC_SENSORS: Final = { state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + "current": RpcSensorDescription( + key="switch", + sub_key="current", + name="Current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value=lambda status, _: None if status is None else float(status), + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), "a_current": RpcSensorDescription( key="em", sub_key="a_current", From 137d2f0d735bb65fc8fa407e616418e6b55126c4 Mon Sep 17 00:00:00 2001 From: Emory Penney Date: Wed, 1 Mar 2023 07:33:32 -0800 Subject: [PATCH 0567/2810] Obihai config flow fixes (#88853) * Commit split issue * Clearer name * Add yaml_failure test case * Not sure why this is failing now * Update homeassistant/components/obihai/strings.json Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * PR Feedback * Update homeassistant/components/obihai/config_flow.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .coveragerc | 1 + .../components/obihai/config_flow.py | 37 ++++++++++++------- homeassistant/components/obihai/sensor.py | 2 +- homeassistant/components/obihai/strings.json | 2 +- tests/components/obihai/test_config_flow.py | 25 ++++++++++--- 5 files changed, 45 insertions(+), 22 deletions(-) diff --git a/.coveragerc b/.coveragerc index 4737683367..3c9a1c378a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -807,6 +807,7 @@ omit = homeassistant/components/nuki/sensor.py homeassistant/components/nx584/alarm_control_panel.py homeassistant/components/oasa_telematics/sensor.py + homeassistant/components/obihai/__init__.py homeassistant/components/obihai/connectivity.py homeassistant/components/obihai/sensor.py homeassistant/components/octoprint/__init__.py diff --git a/homeassistant/components/obihai/config_flow.py b/homeassistant/components/obihai/config_flow.py index dd2aa0db06..2f8dd0075b 100644 --- a/homeassistant/components/obihai/config_flow.py +++ b/homeassistant/components/obihai/config_flow.py @@ -7,6 +7,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from .connectivity import validate_auth @@ -27,6 +28,16 @@ DATA_SCHEMA = vol.Schema( ) +async def async_validate_creds(hass: HomeAssistant, user_input: dict[str, Any]) -> bool: + """Manage Obihai options.""" + return await hass.async_add_executor_job( + validate_auth, + user_input[CONF_HOST], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ) + + class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN): """Config flow for Obihai.""" @@ -40,12 +51,7 @@ class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) - if await self.hass.async_add_executor_job( - validate_auth, - user_input[CONF_HOST], - user_input[CONF_USERNAME], - user_input[CONF_PASSWORD], - ): + if await async_validate_creds(self.hass, user_input): return self.async_create_entry( title=user_input[CONF_HOST], data=user_input, @@ -63,11 +69,14 @@ class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN): async def async_step_import(self, config: dict[str, Any]) -> FlowResult: """Handle a flow initialized by importing a config.""" self._async_abort_entries_match({CONF_HOST: config[CONF_HOST]}) - return self.async_create_entry( - title=config.get(CONF_NAME, config[CONF_HOST]), - data={ - CONF_HOST: config[CONF_HOST], - CONF_PASSWORD: config[CONF_PASSWORD], - CONF_USERNAME: config[CONF_USERNAME], - }, - ) + if await async_validate_creds(self.hass, config): + return self.async_create_entry( + title=config.get(CONF_NAME, config[CONF_HOST]), + data={ + CONF_HOST: config[CONF_HOST], + CONF_PASSWORD: config[CONF_PASSWORD], + CONF_USERNAME: config[CONF_USERNAME], + }, + ) + + return self.async_abort(reason="cannot_connect") diff --git a/homeassistant/components/obihai/sensor.py b/homeassistant/components/obihai/sensor.py index 4f7b6195e4..7524fbc7d4 100644 --- a/homeassistant/components/obihai/sensor.py +++ b/homeassistant/components/obihai/sensor.py @@ -46,7 +46,7 @@ async def async_setup_platform( "manual_migration", breaks_in_ha_version="2023.6.0", is_fixable=False, - severity=ir.IssueSeverity.ERROR, + severity=ir.IssueSeverity.WARNING, translation_key="manual_migration", ) diff --git a/homeassistant/components/obihai/strings.json b/homeassistant/components/obihai/strings.json index 053343b450..fb673675ad 100644 --- a/homeassistant/components/obihai/strings.json +++ b/homeassistant/components/obihai/strings.json @@ -18,7 +18,7 @@ }, "issues": { "manual_migration": { - "title": "Manual migration required for Obihai", + "title": "Obihai YAML configuration is being removed", "description": "Configuration of the Obihai platform in YAML is deprecated and will be removed in Home Assistant 2023.6; Your existing configuration has been imported into the UI automatically and can be safely removed from your configuration.yaml file." } } diff --git a/tests/components/obihai/test_config_flow.py b/tests/components/obihai/test_config_flow.py index 07d00f1577..234f1d5996 100644 --- a/tests/components/obihai/test_config_flow.py +++ b/tests/components/obihai/test_config_flow.py @@ -10,6 +10,8 @@ from homeassistant.data_entry_flow import FlowResultType from . import USER_INPUT +VALIDATE_AUTH_PATCH = "homeassistant.components.obihai.config_flow.validate_auth" + pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -43,9 +45,7 @@ async def test_auth_failure(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "homeassistant.components.obihai.config_flow.validate_auth", return_value=False - ): + with patch(VALIDATE_AUTH_PATCH, return_value=False): result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT, @@ -59,9 +59,7 @@ async def test_auth_failure(hass: HomeAssistant) -> None: async def test_yaml_import(hass: HomeAssistant) -> None: """Test we get the YAML imported.""" - with patch( - "homeassistant.components.obihai.config_flow.validate_auth", return_value=True - ): + with patch(VALIDATE_AUTH_PATCH, return_value=True): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, @@ -71,3 +69,18 @@ async def test_yaml_import(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.CREATE_ENTRY assert "errors" not in result + + +async def test_yaml_import_fail(hass: HomeAssistant) -> None: + """Test the YAML import fails.""" + with patch(VALIDATE_AUTH_PATCH, return_value=False): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + assert "errors" not in result From 12933353b24be8c5bfcaa1f739caef50f8cd5f91 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 1 Mar 2023 16:46:19 +0100 Subject: [PATCH 0568/2810] Drop codeowner from threshold integration (#88973) --- CODEOWNERS | 2 -- homeassistant/components/threshold/manifest.json | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index edee0e9b53..fb122a5a0e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1213,8 +1213,6 @@ build.json @home-assistant/supervisor /homeassistant/components/thethingsnetwork/ @fabaff /homeassistant/components/thread/ @home-assistant/core /tests/components/thread/ @home-assistant/core -/homeassistant/components/threshold/ @fabaff -/tests/components/threshold/ @fabaff /homeassistant/components/tibber/ @danielhiversen /tests/components/tibber/ @danielhiversen /homeassistant/components/tile/ @bachya diff --git a/homeassistant/components/threshold/manifest.json b/homeassistant/components/threshold/manifest.json index f149bda05d..60ef45c845 100644 --- a/homeassistant/components/threshold/manifest.json +++ b/homeassistant/components/threshold/manifest.json @@ -1,7 +1,7 @@ { "domain": "threshold", "name": "Threshold", - "codeowners": ["@fabaff"], + "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/threshold", "integration_type": "helper", From 3818e318db3b6613df1cc4a0cf2d3459342be9db Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 1 Mar 2023 16:53:42 +0100 Subject: [PATCH 0569/2810] Improve threshold binary sensor tests (#88972) --- .../threshold/test_binary_sensor.py | 379 +++++++++++++----- 1 file changed, 268 insertions(+), 111 deletions(-) diff --git a/tests/components/threshold/test_binary_sensor.py b/tests/components/threshold/test_binary_sensor.py index a89c9f4e17..f009e4c48a 100644 --- a/tests/components/threshold/test_binary_sensor.py +++ b/tests/components/threshold/test_binary_sensor.py @@ -24,36 +24,50 @@ async def test_sensor_upper(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "binary_sensor", config) await hass.async_block_till_done() + # Set the monitored sensor's state to the threshold + hass.states.async_set("sensor.test_monitored", 15) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "unknown" + hass.states.async_set( "sensor.test_monitored", 16, {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, ) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - - assert state.attributes.get("entity_id") == "sensor.test_monitored" - assert state.attributes.get("sensor_value") == 16 - assert state.attributes.get("position") == "above" - assert state.attributes.get("upper") == float(config["binary_sensor"]["upper"]) - assert state.attributes.get("hysteresis") == 0.0 - assert state.attributes.get("type") == "upper" - + assert state.attributes["entity_id"] == "sensor.test_monitored" + assert state.attributes["sensor_value"] == 16 + assert state.attributes["position"] == "above" + assert state.attributes["upper"] == float(config["binary_sensor"]["upper"]) + assert state.attributes["hysteresis"] == 0.0 + assert state.attributes["type"] == "upper" assert state.state == "on" hass.states.async_set("sensor.test_monitored", 14) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - + assert state.attributes["position"] == "below" assert state.state == "off" hass.states.async_set("sensor.test_monitored", 15) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "below" + assert state.state == "off" + hass.states.async_set("sensor.test_monitored", "cat") + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "off" + + hass.states.async_set("sensor.test_monitored", 15) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" assert state.state == "off" @@ -70,27 +84,48 @@ async def test_sensor_lower(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "binary_sensor", config) await hass.async_block_till_done() + # Set the monitored sensor's state to the threshold + hass.states.async_set("sensor.test_monitored", 15) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "unknown" + hass.states.async_set("sensor.test_monitored", 16) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - - assert state.attributes.get("position") == "above" - assert state.attributes.get("lower") == float(config["binary_sensor"]["lower"]) - assert state.attributes.get("hysteresis") == 0.0 - assert state.attributes.get("type") == "lower" - + assert state.attributes["position"] == "above" + assert state.attributes["lower"] == float(config["binary_sensor"]["lower"]) + assert state.attributes["hysteresis"] == 0.0 + assert state.attributes["type"] == "lower" assert state.state == "off" hass.states.async_set("sensor.test_monitored", 14) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - + assert state.attributes["position"] == "below" assert state.state == "on" + hass.states.async_set("sensor.test_monitored", 15) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "below" + assert state.state == "on" -async def test_sensor_hysteresis(hass: HomeAssistant) -> None: + hass.states.async_set("sensor.test_monitored", "cat") + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "off" + + hass.states.async_set("sensor.test_monitored", 15) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "off" + + +async def test_sensor_upper_hysteresis(hass: HomeAssistant) -> None: """Test if source is above threshold using hysteresis.""" config = { "binary_sensor": { @@ -104,46 +139,141 @@ async def test_sensor_hysteresis(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "binary_sensor", config) await hass.async_block_till_done() + # Set the monitored sensor's state to the threshold + hysteresis + hass.states.async_set("sensor.test_monitored", 17.5) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "unknown" + + # Set the monitored sensor's state to the threshold - hysteresis + hass.states.async_set("sensor.test_monitored", 12.5) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "unknown" + hass.states.async_set("sensor.test_monitored", 20) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - - assert state.attributes.get("position") == "above" - assert state.attributes.get("upper") == float(config["binary_sensor"]["upper"]) - assert state.attributes.get("hysteresis") == 2.5 - assert state.attributes.get("type") == "upper" - + assert state.attributes["position"] == "above" + assert state.attributes["upper"] == float(config["binary_sensor"]["upper"]) + assert state.attributes["hysteresis"] == 2.5 + assert state.attributes["type"] == "upper" + assert state.attributes["position"] == "above" assert state.state == "on" hass.states.async_set("sensor.test_monitored", 13) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - + assert state.attributes["position"] == "above" assert state.state == "on" hass.states.async_set("sensor.test_monitored", 12) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - + assert state.attributes["position"] == "below" assert state.state == "off" hass.states.async_set("sensor.test_monitored", 17) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - + assert state.attributes["position"] == "below" assert state.state == "off" hass.states.async_set("sensor.test_monitored", 18) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - + assert state.attributes["position"] == "above" assert state.state == "on" + hass.states.async_set("sensor.test_monitored", "cat") + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "off" + + hass.states.async_set("sensor.test_monitored", 18) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "above" + assert state.state == "on" + + +async def test_sensor_lower_hysteresis(hass: HomeAssistant) -> None: + """Test if source is below threshold using hysteresis.""" + config = { + "binary_sensor": { + "platform": "threshold", + "lower": "15", + "hysteresis": "2.5", + "entity_id": "sensor.test_monitored", + } + } + + assert await async_setup_component(hass, "binary_sensor", config) + await hass.async_block_till_done() + + # Set the monitored sensor's state to the threshold + hysteresis + hass.states.async_set("sensor.test_monitored", 17.5) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "unknown" + + # Set the monitored sensor's state to the threshold - hysteresis + hass.states.async_set("sensor.test_monitored", 12.5) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "unknown" + + hass.states.async_set("sensor.test_monitored", 20) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "above" + assert state.attributes["lower"] == float(config["binary_sensor"]["lower"]) + assert state.attributes["hysteresis"] == 2.5 + assert state.attributes["type"] == "lower" + assert state.attributes["position"] == "above" + assert state.state == "off" + + hass.states.async_set("sensor.test_monitored", 13) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "above" + assert state.state == "off" + + hass.states.async_set("sensor.test_monitored", 12) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "below" + assert state.state == "on" + + hass.states.async_set("sensor.test_monitored", 17) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "below" + assert state.state == "on" + + hass.states.async_set("sensor.test_monitored", 18) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "above" + assert state.state == "off" + + hass.states.async_set("sensor.test_monitored", "cat") + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "off" + + hass.states.async_set("sensor.test_monitored", 18) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "above" + assert state.state == "off" + async def test_sensor_in_range_no_hysteresis(hass: HomeAssistant) -> None: """Test if source is within the range.""" @@ -159,39 +289,58 @@ async def test_sensor_in_range_no_hysteresis(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "binary_sensor", config) await hass.async_block_till_done() + # Set the monitored sensor's state to the lower threshold + hass.states.async_set("sensor.test_monitored", 10) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "unknown" + + # Set the monitored sensor's state to the upper threshold + hass.states.async_set("sensor.test_monitored", 20) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "unknown" + hass.states.async_set( "sensor.test_monitored", 16, {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, ) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - - assert state.attributes.get("entity_id") == "sensor.test_monitored" - assert state.attributes.get("sensor_value") == 16 - assert state.attributes.get("position") == "in_range" - assert state.attributes.get("lower") == float(config["binary_sensor"]["lower"]) - assert state.attributes.get("upper") == float(config["binary_sensor"]["upper"]) - assert state.attributes.get("hysteresis") == 0.0 - assert state.attributes.get("type") == "range" - + assert state.attributes["entity_id"] == "sensor.test_monitored" + assert state.attributes["sensor_value"] == 16 + assert state.attributes["position"] == "in_range" + assert state.attributes["lower"] == float(config["binary_sensor"]["lower"]) + assert state.attributes["upper"] == float(config["binary_sensor"]["upper"]) + assert state.attributes["hysteresis"] == 0.0 + assert state.attributes["type"] == "range" assert state.state == "on" hass.states.async_set("sensor.test_monitored", 9) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - - assert state.attributes.get("position") == "below" + assert state.attributes["position"] == "below" assert state.state == "off" hass.states.async_set("sensor.test_monitored", 21) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "above" + assert state.state == "off" - assert state.attributes.get("position") == "above" + hass.states.async_set("sensor.test_monitored", "cat") + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "off" + + hass.states.async_set("sensor.test_monitored", 21) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "above" assert state.state == "off" @@ -210,6 +359,34 @@ async def test_sensor_in_range_with_hysteresis(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "binary_sensor", config) await hass.async_block_till_done() + # Set the monitored sensor's state to the lower threshold - hysteresis + hass.states.async_set("sensor.test_monitored", 8) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "unknown" + + # Set the monitored sensor's state to the lower threshold + hysteresis + hass.states.async_set("sensor.test_monitored", 12) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "unknown" + + # Set the monitored sensor's state to the upper threshold + hysteresis + hass.states.async_set("sensor.test_monitored", 22) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "unknown" + + # Set the monitored sensor's state to the upper threshold - hysteresis + hass.states.async_set("sensor.test_monitored", 18) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "unknown" + hass.states.async_set( "sensor.test_monitored", 16, @@ -219,80 +396,75 @@ async def test_sensor_in_range_with_hysteresis(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.threshold") - assert state.attributes.get("entity_id") == "sensor.test_monitored" - assert state.attributes.get("sensor_value") == 16 - assert state.attributes.get("position") == "in_range" - assert state.attributes.get("lower") == float(config["binary_sensor"]["lower"]) - assert state.attributes.get("upper") == float(config["binary_sensor"]["upper"]) - assert state.attributes.get("hysteresis") == float( + assert state.attributes["entity_id"] == "sensor.test_monitored" + assert state.attributes["sensor_value"] == 16 + assert state.attributes["position"] == "in_range" + assert state.attributes["lower"] == float(config["binary_sensor"]["lower"]) + assert state.attributes["upper"] == float(config["binary_sensor"]["upper"]) + assert state.attributes["hysteresis"] == float( config["binary_sensor"]["hysteresis"] ) - assert state.attributes.get("type") == "range" - + assert state.attributes["type"] == "range" assert state.state == "on" hass.states.async_set("sensor.test_monitored", 8) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - - assert state.attributes.get("position") == "in_range" + assert state.attributes["position"] == "in_range" assert state.state == "on" hass.states.async_set("sensor.test_monitored", 7) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - - assert state.attributes.get("position") == "below" + assert state.attributes["position"] == "below" assert state.state == "off" hass.states.async_set("sensor.test_monitored", 12) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - - assert state.attributes.get("position") == "below" + assert state.attributes["position"] == "below" assert state.state == "off" hass.states.async_set("sensor.test_monitored", 13) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - - assert state.attributes.get("position") == "in_range" + assert state.attributes["position"] == "in_range" assert state.state == "on" hass.states.async_set("sensor.test_monitored", 22) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - - assert state.attributes.get("position") == "in_range" + assert state.attributes["position"] == "in_range" assert state.state == "on" hass.states.async_set("sensor.test_monitored", 23) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - - assert state.attributes.get("position") == "above" + assert state.attributes["position"] == "above" assert state.state == "off" hass.states.async_set("sensor.test_monitored", 18) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - - assert state.attributes.get("position") == "above" + assert state.attributes["position"] == "above" assert state.state == "off" hass.states.async_set("sensor.test_monitored", 17) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "in_range" + assert state.state == "on" - assert state.attributes.get("position") == "in_range" + hass.states.async_set("sensor.test_monitored", "cat") + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "off" + + hass.states.async_set("sensor.test_monitored", 17) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "in_range" assert state.state == "on" @@ -321,30 +493,25 @@ async def test_sensor_in_range_unknown_state( state = hass.states.get("binary_sensor.threshold") - assert state.attributes.get("entity_id") == "sensor.test_monitored" - assert state.attributes.get("sensor_value") == 16 - assert state.attributes.get("position") == "in_range" - assert state.attributes.get("lower") == float(config["binary_sensor"]["lower"]) - assert state.attributes.get("upper") == float(config["binary_sensor"]["upper"]) - assert state.attributes.get("hysteresis") == 0.0 - assert state.attributes.get("type") == "range" - + assert state.attributes["entity_id"] == "sensor.test_monitored" + assert state.attributes["sensor_value"] == 16 + assert state.attributes["position"] == "in_range" + assert state.attributes["lower"] == float(config["binary_sensor"]["lower"]) + assert state.attributes["upper"] == float(config["binary_sensor"]["upper"]) + assert state.attributes["hysteresis"] == 0.0 + assert state.attributes["type"] == "range" assert state.state == "on" hass.states.async_set("sensor.test_monitored", STATE_UNKNOWN) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - - assert state.attributes.get("position") == "unknown" + assert state.attributes["position"] == "unknown" assert state.state == "off" hass.states.async_set("sensor.test_monitored", STATE_UNAVAILABLE) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - - assert state.attributes.get("position") == "unknown" + assert state.attributes["position"] == "unknown" assert state.state == "off" assert "State is not numerical" not in caplog.text @@ -365,19 +532,14 @@ async def test_sensor_lower_zero_threshold(hass: HomeAssistant) -> None: hass.states.async_set("sensor.test_monitored", 16) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - - assert state.attributes.get("type") == "lower" - assert state.attributes.get("lower") == float(config["binary_sensor"]["lower"]) - + assert state.attributes["type"] == "lower" + assert state.attributes["lower"] == float(config["binary_sensor"]["lower"]) assert state.state == "off" hass.states.async_set("sensor.test_monitored", -3) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.state == "on" @@ -396,17 +558,12 @@ async def test_sensor_upper_zero_threshold(hass: HomeAssistant) -> None: hass.states.async_set("sensor.test_monitored", -10) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - - assert state.attributes.get("type") == "upper" - assert state.attributes.get("upper") == float(config["binary_sensor"]["upper"]) - + assert state.attributes["type"] == "upper" + assert state.attributes["upper"] == float(config["binary_sensor"]["upper"]) assert state.state == "off" hass.states.async_set("sensor.test_monitored", 2) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.state == "on" From ee78864b0568330a8118dcf54e9d461b55134516 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 16:54:00 +0100 Subject: [PATCH 0570/2810] Adjust entity registry access in homekit tests (#88959) --- tests/components/homekit/conftest.py | 14 +- tests/components/homekit/test_aidmanager.py | 55 +++--- tests/components/homekit/test_config_flow.py | 26 +-- tests/components/homekit/test_diagnostics.py | 7 +- tests/components/homekit/test_homekit.py | 162 ++++++++++-------- tests/components/homekit/test_init.py | 5 +- .../components/homekit/test_type_triggers.py | 10 +- 7 files changed, 149 insertions(+), 130 deletions(-) diff --git a/tests/components/homekit/conftest.py b/tests/components/homekit/conftest.py index 92cd46b51e..fe151c902c 100644 --- a/tests/components/homekit/conftest.py +++ b/tests/components/homekit/conftest.py @@ -10,7 +10,7 @@ from homeassistant.components.homekit.accessories import HomeDriver from homeassistant.components.homekit.const import BRIDGE_NAME, EVENT_HOMEKIT_CHANGED from homeassistant.components.homekit.iidmanager import AccessoryIIDStorage -from tests.common import async_capture_events, mock_device_registry, mock_registry +from tests.common import async_capture_events @pytest.fixture @@ -103,18 +103,6 @@ def events(hass): return async_capture_events(hass, EVENT_HOMEKIT_CHANGED) -@pytest.fixture(name="device_reg") -def device_reg_fixture(hass): - """Return an empty, loaded, registry.""" - return mock_device_registry(hass) - - -@pytest.fixture(name="entity_reg") -def entity_reg_fixture(hass): - """Return an empty, loaded, registry.""" - return mock_registry(hass) - - @pytest.fixture def demo_cleanup(hass): """Clean up device tracker demo file.""" diff --git a/tests/components/homekit/test_aidmanager.py b/tests/components/homekit/test_aidmanager.py index 1dafe7a11a..64a44cd38a 100644 --- a/tests/components/homekit/test_aidmanager.py +++ b/tests/components/homekit/test_aidmanager.py @@ -3,7 +3,6 @@ import os from unittest.mock import patch from fnvhash import fnv1a_32 -import pytest from homeassistant.components.homekit.aidmanager import ( AccessoryAidStorage, @@ -11,39 +10,31 @@ from homeassistant.components.homekit.aidmanager import ( get_system_unique_id, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.storage import STORAGE_DIR -from tests.common import MockConfigEntry, mock_device_registry, mock_registry +from tests.common import MockConfigEntry -@pytest.fixture -def device_reg(hass): - """Return an empty, loaded, registry.""" - return mock_device_registry(hass) - - -@pytest.fixture -def entity_reg(hass): - """Return an empty, loaded, registry.""" - return mock_registry(hass) - - -async def test_aid_generation(hass: HomeAssistant, device_reg, entity_reg) -> None: +async def test_aid_generation( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test generating aids.""" config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) - device_entry = device_reg.async_get_or_create( + device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - light_ent = entity_reg.async_get_or_create( + light_ent = entity_registry.async_get_or_create( "light", "device", "unique_id", device_id=device_entry.id ) - light_ent2 = entity_reg.async_get_or_create( + light_ent2 = entity_registry.async_get_or_create( "light", "device", "other_unique_id", device_id=device_entry.id ) - remote_ent = entity_reg.async_get_or_create( + remote_ent = entity_registry.async_get_or_create( "remote", "device", "unique_id", device_id=device_entry.id ) hass.states.async_set(light_ent.entity_id, "on") @@ -99,13 +90,17 @@ async def test_aid_generation(hass: HomeAssistant, device_reg, entity_reg) -> No ) -async def test_no_aid_collision(hass: HomeAssistant, device_reg, entity_reg) -> None: +async def test_no_aid_collision( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test generating aids.""" config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) - device_entry = device_reg.async_get_or_create( + device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) with patch( @@ -117,7 +112,7 @@ async def test_no_aid_collision(hass: HomeAssistant, device_reg, entity_reg) -> seen_aids = set() for unique_id in range(0, 202): - ent = entity_reg.async_get_or_create( + ent = entity_registry.async_get_or_create( "light", "device", unique_id, device_id=device_entry.id ) hass.states.async_set(ent.entity_id, "on") @@ -127,7 +122,9 @@ async def test_no_aid_collision(hass: HomeAssistant, device_reg, entity_reg) -> async def test_aid_generation_no_unique_ids_handles_collision( - hass: HomeAssistant, device_reg, entity_reg + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test colliding aids is stable.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -135,9 +132,9 @@ async def test_aid_generation_no_unique_ids_handles_collision( aid_storage = AccessoryAidStorage(hass, config_entry) await aid_storage.async_initialize() - device_entry = device_reg.async_get_or_create( + device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) seen_aids = set() @@ -154,7 +151,7 @@ async def test_aid_generation_no_unique_ids_handles_collision( assert aid not in seen_aids seen_aids.add(aid) - light_ent = entity_reg.async_get_or_create( + light_ent = entity_registry.async_get_or_create( "light", "device", "unique_id", device_id=device_entry.id ) hass.states.async_set(light_ent.entity_id, "on") diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index 2beece05b6..3de10491f3 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -13,7 +13,7 @@ from homeassistant.components.homekit.const import ( from homeassistant.config_entries import SOURCE_IGNORE, SOURCE_IMPORT from homeassistant.const import CONF_NAME, CONF_PORT, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_registry import RegistryEntry, RegistryEntryHider +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entityfilter import CONF_INCLUDE_DOMAINS from homeassistant.setup import async_setup_component @@ -398,8 +398,8 @@ async def test_options_flow_devices( port_mock, hass: HomeAssistant, demo_cleanup, - device_reg, - entity_reg, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, mock_get_source_ip, mock_async_zeroconf: None, ) -> None: @@ -451,7 +451,7 @@ async def test_options_flow_devices( assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "exclude" - entry = entity_reg.async_get("light.ceiling_lights") + entry = entity_registry.async_get("light.ceiling_lights") assert entry is not None device_id = entry.device_id @@ -1379,7 +1379,7 @@ async def test_options_flow_exclude_mode_skips_category_entities( mock_get_source_ip, hk_driver, mock_async_zeroconf: None, - entity_reg, + entity_registry: er.EntityRegistry, ) -> None: """Ensure exclude mode does not offer category entities.""" config_entry = _mock_config_entry_with_options_populated() @@ -1389,7 +1389,7 @@ async def test_options_flow_exclude_mode_skips_category_entities( hass.states.async_set("media_player.sonos", "off") hass.states.async_set("switch.other", "off") - sonos_config_switch: RegistryEntry = entity_reg.async_get_or_create( + sonos_config_switch = entity_registry.async_get_or_create( "switch", "sonos", "config", @@ -1398,7 +1398,7 @@ async def test_options_flow_exclude_mode_skips_category_entities( ) hass.states.async_set(sonos_config_switch.entity_id, "off") - sonos_notconfig_switch: RegistryEntry = entity_reg.async_get_or_create( + sonos_notconfig_switch = entity_registry.async_get_or_create( "switch", "sonos", "notconfig", @@ -1484,7 +1484,7 @@ async def test_options_flow_exclude_mode_skips_hidden_entities( mock_get_source_ip, hk_driver, mock_async_zeroconf: None, - entity_reg, + entity_registry: er.EntityRegistry, ) -> None: """Ensure exclude mode does not offer hidden entities.""" config_entry = _mock_config_entry_with_options_populated() @@ -1494,12 +1494,12 @@ async def test_options_flow_exclude_mode_skips_hidden_entities( hass.states.async_set("media_player.sonos", "off") hass.states.async_set("switch.other", "off") - sonos_hidden_switch: RegistryEntry = entity_reg.async_get_or_create( + sonos_hidden_switch = entity_registry.async_get_or_create( "switch", "sonos", "config", device_id="1234", - hidden_by=RegistryEntryHider.INTEGRATION, + hidden_by=er.RegistryEntryHider.INTEGRATION, ) hass.states.async_set(sonos_hidden_switch.entity_id, "off") await hass.async_block_till_done() @@ -1569,7 +1569,7 @@ async def test_options_flow_include_mode_allows_hidden_entities( mock_get_source_ip, hk_driver, mock_async_zeroconf: None, - entity_reg, + entity_registry: er.EntityRegistry, ) -> None: """Ensure include mode does not offer hidden entities.""" config_entry = _mock_config_entry_with_options_populated() @@ -1579,12 +1579,12 @@ async def test_options_flow_include_mode_allows_hidden_entities( hass.states.async_set("media_player.sonos", "off") hass.states.async_set("switch.other", "off") - sonos_hidden_switch: RegistryEntry = entity_reg.async_get_or_create( + sonos_hidden_switch = entity_registry.async_get_or_create( "switch", "sonos", "config", device_id="1234", - hidden_by=RegistryEntryHider.INTEGRATION, + hidden_by=er.RegistryEntryHider.INTEGRATION, ) hass.states.async_set(sonos_hidden_switch.entity_id, "off") await hass.async_block_till_done() diff --git a/tests/components/homekit/test_diagnostics.py b/tests/components/homekit/test_diagnostics.py index 8d4dfe36c0..58babc0ccb 100644 --- a/tests/components/homekit/test_diagnostics.py +++ b/tests/components/homekit/test_diagnostics.py @@ -9,6 +9,7 @@ from homeassistant.components.homekit.const import ( ) from homeassistant.const import CONF_NAME, CONF_PORT, EVENT_HOMEASSISTANT_STARTED from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from .util import async_init_integration @@ -314,8 +315,8 @@ async def test_config_entry_with_trigger_accessory( mock_async_zeroconf: None, events, demo_cleanup, - device_reg, - entity_reg, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test generating diagnostics for a bridge config entry with a trigger accessory.""" assert await async_setup_component(hass, "demo", {"demo": {}}) @@ -326,7 +327,7 @@ async def test_config_entry_with_trigger_accessory( assert await async_setup_component(hass, "demo", {"demo": {}}) await hass.async_block_till_done() - entry = entity_reg.async_get("light.ceiling_lights") + entry = entity_registry.async_get("light.ceiling_lights") assert entry is not None device_id = entry.device_id diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 5b6a6d5018..a6dbdbebdd 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -46,9 +46,14 @@ from homeassistant.const import ( PERCENTAGE, SERVICE_RELOAD, STATE_ON, + EntityCategory, ) from homeassistant.core import HomeAssistant, HomeAssistantError, State -from homeassistant.helpers import device_registry, entity_registry as er, instance_id +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + instance_id, +) from homeassistant.helpers.entityfilter import ( CONF_EXCLUDE_DOMAINS, CONF_EXCLUDE_ENTITIES, @@ -503,15 +508,12 @@ async def test_homekit_entity_glob_filter( async def test_homekit_entity_glob_filter_with_config_entities( - hass: HomeAssistant, mock_async_zeroconf: None, entity_reg + hass: HomeAssistant, mock_async_zeroconf: None, entity_registry: er.EntityRegistry ) -> None: """Test the entity filter with configuration entities.""" entry = await async_init_integration(hass) - from homeassistant.const import EntityCategory - from homeassistant.helpers.entity_registry import RegistryEntry - - select_config_entity: RegistryEntry = entity_reg.async_get_or_create( + select_config_entity = entity_registry.async_get_or_create( "select", "any", "any", @@ -520,7 +522,7 @@ async def test_homekit_entity_glob_filter_with_config_entities( ) hass.states.async_set(select_config_entity.entity_id, "off") - switch_config_entity: RegistryEntry = entity_reg.async_get_or_create( + switch_config_entity = entity_registry.async_get_or_create( "switch", "any", "any", @@ -559,14 +561,12 @@ async def test_homekit_entity_glob_filter_with_config_entities( async def test_homekit_entity_glob_filter_with_hidden_entities( - hass: HomeAssistant, mock_async_zeroconf: None, entity_reg + hass: HomeAssistant, mock_async_zeroconf: None, entity_registry: er.EntityRegistry ) -> None: """Test the entity filter with hidden entities.""" entry = await async_init_integration(hass) - from homeassistant.helpers.entity_registry import RegistryEntry - - select_config_entity: RegistryEntry = entity_reg.async_get_or_create( + select_config_entity = entity_registry.async_get_or_create( "select", "any", "any", @@ -575,7 +575,7 @@ async def test_homekit_entity_glob_filter_with_hidden_entities( ) hass.states.async_set(select_config_entity.entity_id, "off") - switch_config_entity: RegistryEntry = entity_reg.async_get_or_create( + switch_config_entity = entity_registry.async_get_or_create( "switch", "any", "any", @@ -614,7 +614,10 @@ async def test_homekit_entity_glob_filter_with_hidden_entities( async def test_homekit_start( - hass: HomeAssistant, hk_driver, mock_async_zeroconf: None, device_reg + hass: HomeAssistant, + hk_driver, + mock_async_zeroconf: None, + device_registry: dr.DeviceRegistry, ) -> None: """Test HomeKit start method.""" entry = await async_init_integration(hass) @@ -627,8 +630,8 @@ async def test_homekit_start( acc = Accessory(hk_driver, "any") homekit.driver.accessory = acc - connection = (device_registry.CONNECTION_NETWORK_MAC, "AA:BB:CC:DD:EE:FF") - bridge_with_wrong_mac = device_reg.async_get_or_create( + connection = (dr.CONNECTION_NETWORK_MAC, "AA:BB:CC:DD:EE:FF") + bridge_with_wrong_mac = device_registry.async_get_or_create( config_entry_id=entry.entry_id, connections={connection}, manufacturer="Any", @@ -661,14 +664,14 @@ async def test_homekit_start( await hass.async_block_till_done() assert not hk_driver_start.called - assert device_reg.async_get(bridge_with_wrong_mac.id) is None + assert device_registry.async_get(bridge_with_wrong_mac.id) is None - device = device_reg.async_get_device( + device = device_registry.async_get_device( {(DOMAIN, entry.entry_id, BRIDGE_SERIAL_NUMBER)} ) assert device - formatted_mac = device_registry.format_mac(homekit.driver.state.mac) - assert (device_registry.CONNECTION_NETWORK_MAC, formatted_mac) in device.connections + formatted_mac = dr.format_mac(homekit.driver.state.mac) + assert (dr.CONNECTION_NETWORK_MAC, formatted_mac) in device.connections # Start again to make sure the registry entry is kept homekit.status = STATUS_READY @@ -679,14 +682,14 @@ async def test_homekit_start( ) as hk_driver_start: await homekit.async_start() - device = device_reg.async_get_device( + device = device_registry.async_get_device( {(DOMAIN, entry.entry_id, BRIDGE_SERIAL_NUMBER)} ) assert device - formatted_mac = device_registry.format_mac(homekit.driver.state.mac) - assert (device_registry.CONNECTION_NETWORK_MAC, formatted_mac) in device.connections + formatted_mac = dr.format_mac(homekit.driver.state.mac) + assert (dr.CONNECTION_NETWORK_MAC, formatted_mac) in device.connections - assert len(device_reg.devices) == 1 + assert len(device_registry.devices) == 1 assert homekit.driver.state.config_version == 1 @@ -736,8 +739,8 @@ async def test_homekit_start_with_a_device( hk_driver, mock_async_zeroconf: None, demo_cleanup, - device_reg, - entity_reg, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test HomeKit start method with a device.""" @@ -747,7 +750,7 @@ async def test_homekit_start_with_a_device( assert await async_setup_component(hass, "demo", {"demo": {}}) await hass.async_block_till_done() - reg_entry = entity_reg.async_get("light.ceiling_lights") + reg_entry = entity_registry.async_get("light.ceiling_lights") assert reg_entry is not None device_id = reg_entry.device_id await async_init_entry(hass, entry) @@ -841,7 +844,7 @@ async def test_homekit_reset_accessories( async def test_homekit_unpair( - hass: HomeAssistant, device_reg, mock_async_zeroconf: None + hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_async_zeroconf: None ) -> None: """Test unpairing HomeKit accessories.""" @@ -873,9 +876,9 @@ async def test_homekit_unpair( state.add_paired_client("client4", "any", b"0") state.add_paired_client("client5", "any", b"0") - formatted_mac = device_registry.format_mac(state.mac) - hk_bridge_dev = device_reg.async_get_device( - {}, {(device_registry.CONNECTION_NETWORK_MAC, formatted_mac)} + formatted_mac = dr.format_mac(state.mac) + hk_bridge_dev = device_registry.async_get_device( + {}, {(dr.CONNECTION_NETWORK_MAC, formatted_mac)} ) await hass.services.async_call( @@ -890,7 +893,7 @@ async def test_homekit_unpair( async def test_homekit_unpair_missing_device_id( - hass: HomeAssistant, device_reg, mock_async_zeroconf: None + hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_async_zeroconf: None ) -> None: """Test unpairing HomeKit accessories with invalid device id.""" @@ -930,7 +933,7 @@ async def test_homekit_unpair_missing_device_id( async def test_homekit_unpair_not_homekit_device( - hass: HomeAssistant, device_reg, mock_async_zeroconf: None + hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_async_zeroconf: None ) -> None: """Test unpairing HomeKit accessories with a non-homekit device id.""" @@ -957,12 +960,12 @@ async def test_homekit_unpair_not_homekit_device( homekit.bridge.accessories = {aid: acc_mock} homekit.status = STATUS_RUNNING - device_entry = device_reg.async_get_or_create( + device_entry = device_registry.async_get_or_create( config_entry_id=not_homekit_entry.entry_id, sw_version="0.16.0", model="Powerwall 2", manufacturer="Tesla", - connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) state = homekit.driver.state @@ -1299,7 +1302,11 @@ async def test_homekit_too_many_accessories( async def test_homekit_finds_linked_batteries( - hass: HomeAssistant, hk_driver, device_reg, entity_reg, mock_async_zeroconf: None + hass: HomeAssistant, + hk_driver, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_async_zeroconf: None, ) -> None: """Test HomeKit start method.""" entry = await async_init_integration(hass) @@ -1311,30 +1318,30 @@ async def test_homekit_finds_linked_batteries( config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) - device_entry = device_reg.async_get_or_create( + device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, sw_version="0.16.0", hw_version="2.34", model="Powerwall 2", manufacturer="Tesla", - connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - binary_charging_sensor = entity_reg.async_get_or_create( + binary_charging_sensor = entity_registry.async_get_or_create( "binary_sensor", "powerwall", "battery_charging", device_id=device_entry.id, original_device_class=BinarySensorDeviceClass.BATTERY_CHARGING, ) - battery_sensor = entity_reg.async_get_or_create( + battery_sensor = entity_registry.async_get_or_create( "sensor", "powerwall", "battery", device_id=device_entry.id, original_device_class=SensorDeviceClass.BATTERY, ) - light = entity_reg.async_get_or_create( + light = entity_registry.async_get_or_create( "light", "powerwall", "demo", device_id=device_entry.id ) @@ -1372,7 +1379,11 @@ async def test_homekit_finds_linked_batteries( async def test_homekit_async_get_integration_fails( - hass: HomeAssistant, hk_driver, device_reg, entity_reg, mock_async_zeroconf: None + hass: HomeAssistant, + hk_driver, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_async_zeroconf: None, ) -> None: """Test that we continue if async_get_integration fails.""" entry = await async_init_integration(hass) @@ -1383,28 +1394,28 @@ async def test_homekit_async_get_integration_fails( config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) - device_entry = device_reg.async_get_or_create( + device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, sw_version="0.16.0", model="Powerwall 2", - connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - binary_charging_sensor = entity_reg.async_get_or_create( + binary_charging_sensor = entity_registry.async_get_or_create( "binary_sensor", "invalid_integration_does_not_exist", "battery_charging", device_id=device_entry.id, original_device_class=BinarySensorDeviceClass.BATTERY_CHARGING, ) - battery_sensor = entity_reg.async_get_or_create( + battery_sensor = entity_registry.async_get_or_create( "sensor", "invalid_integration_does_not_exist", "battery", device_id=device_entry.id, original_device_class=SensorDeviceClass.BATTERY, ) - light = entity_reg.async_get_or_create( + light = entity_registry.async_get_or_create( "light", "invalid_integration_does_not_exist", "demo", device_id=device_entry.id ) @@ -1594,7 +1605,11 @@ async def test_homekit_uses_system_zeroconf( async def test_homekit_ignored_missing_devices( - hass: HomeAssistant, hk_driver, device_reg, entity_reg, mock_async_zeroconf: None + hass: HomeAssistant, + hk_driver, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_async_zeroconf: None, ) -> None: """Test HomeKit handles a device in the entity registry but missing from the device registry.""" @@ -1606,40 +1621,40 @@ async def test_homekit_ignored_missing_devices( config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) - device_entry = device_reg.async_get_or_create( + device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, sw_version="0.16.0", model="Powerwall 2", manufacturer="Tesla", - connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_reg.async_get_or_create( + entity_registry.async_get_or_create( "binary_sensor", "powerwall", "battery_charging", device_id=device_entry.id, original_device_class=BinarySensorDeviceClass.BATTERY_CHARGING, ) - entity_reg.async_get_or_create( + entity_registry.async_get_or_create( "sensor", "powerwall", "battery", device_id=device_entry.id, original_device_class=SensorDeviceClass.BATTERY, ) - light = entity_reg.async_get_or_create( + light = entity_registry.async_get_or_create( "light", "powerwall", "demo", device_id=device_entry.id ) - before_removal = entity_reg.entities.copy() + before_removal = entity_registry.entities.copy() # Delete the device to make sure we fallback # to using the platform - device_reg.async_remove_device(device_entry.id) + device_registry.async_remove_device(device_entry.id) # Wait for the entities to be removed await asyncio.sleep(0) await asyncio.sleep(0) # Restore the registry - entity_reg.entities = before_removal + entity_registry.entities = before_removal hass.states.async_set(light.entity_id, STATE_ON) hass.states.async_set("light.two", STATE_ON) @@ -1664,7 +1679,11 @@ async def test_homekit_ignored_missing_devices( async def test_homekit_finds_linked_motion_sensors( - hass: HomeAssistant, hk_driver, device_reg, entity_reg, mock_async_zeroconf: None + hass: HomeAssistant, + hk_driver, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_async_zeroconf: None, ) -> None: """Test HomeKit start method.""" entry = await async_init_integration(hass) @@ -1676,22 +1695,22 @@ async def test_homekit_finds_linked_motion_sensors( config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) - device_entry = device_reg.async_get_or_create( + device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, sw_version="0.16.0", model="Camera Server", manufacturer="Ubq", - connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - binary_motion_sensor = entity_reg.async_get_or_create( + binary_motion_sensor = entity_registry.async_get_or_create( "binary_sensor", "camera", "motion_sensor", device_id=device_entry.id, original_device_class=BinarySensorDeviceClass.MOTION, ) - camera = entity_reg.async_get_or_create( + camera = entity_registry.async_get_or_create( "camera", "camera", "demo", device_id=device_entry.id ) @@ -1726,7 +1745,11 @@ async def test_homekit_finds_linked_motion_sensors( async def test_homekit_finds_linked_humidity_sensors( - hass: HomeAssistant, hk_driver, device_reg, entity_reg, mock_async_zeroconf: None + hass: HomeAssistant, + hk_driver, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_async_zeroconf: None, ) -> None: """Test HomeKit start method.""" entry = await async_init_integration(hass) @@ -1738,22 +1761,22 @@ async def test_homekit_finds_linked_humidity_sensors( config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) - device_entry = device_reg.async_get_or_create( + device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, sw_version="0.16.1", model="Smart Brainy Clever Humidifier", manufacturer="Home Assistant", - connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - humidity_sensor = entity_reg.async_get_or_create( + humidity_sensor = entity_registry.async_get_or_create( "sensor", "humidifier", "humidity_sensor", device_id=device_entry.id, original_device_class=SensorDeviceClass.HUMIDITY, ) - humidifier = entity_reg.async_get_or_create( + humidifier = entity_registry.async_get_or_create( "humidifier", "humidifier", "demo", device_id=device_entry.id ) @@ -1862,7 +1885,10 @@ async def test_reload(hass: HomeAssistant, mock_async_zeroconf: None) -> None: async def test_homekit_start_in_accessory_mode( - hass: HomeAssistant, hk_driver, mock_async_zeroconf: None, device_reg + hass: HomeAssistant, + hk_driver, + mock_async_zeroconf: None, + device_registry: dr.DeviceRegistry, ) -> None: """Test HomeKit start method in accessory mode.""" entry = await async_init_integration(hass) @@ -1896,7 +1922,7 @@ async def test_homekit_start_in_accessory_mode_unsupported_entity( hass: HomeAssistant, hk_driver, mock_async_zeroconf: None, - device_reg, + device_registry: dr.DeviceRegistry, caplog: pytest.LogCaptureFixture, ) -> None: """Test HomeKit start method in accessory mode with an unsupported entity.""" @@ -1930,7 +1956,7 @@ async def test_homekit_start_in_accessory_mode_missing_entity( hass: HomeAssistant, hk_driver, mock_async_zeroconf: None, - device_reg, + device_registry: dr.DeviceRegistry, caplog: pytest.LogCaptureFixture, ) -> None: """Test HomeKit start method in accessory mode when entity is not available.""" diff --git a/tests/components/homekit/test_init.py b/tests/components/homekit/test_init.py index 5445d3c8ae..2bb9a4972a 100644 --- a/tests/components/homekit/test_init.py +++ b/tests/components/homekit/test_init.py @@ -16,6 +16,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STARTED, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from .util import PATH_HOMEKIT @@ -71,7 +72,7 @@ async def test_bridge_with_triggers( hass: HomeAssistant, hk_driver, mock_async_zeroconf: None, - entity_reg, + entity_registry: er.EntityRegistry, caplog: pytest.LogCaptureFixture, ) -> None: """Test we can setup a bridge with triggers and we ignore numeric states. @@ -83,7 +84,7 @@ async def test_bridge_with_triggers( assert await async_setup_component(hass, "demo", {"demo": {}}) await hass.async_block_till_done() - entry = entity_reg.async_get("cover.living_room_window") + entry = entity_registry.async_get("cover.living_room_window") assert entry is not None device_id = entry.device_id diff --git a/tests/components/homekit/test_type_triggers.py b/tests/components/homekit/test_type_triggers.py index e46bcaf82d..fd77499ff0 100644 --- a/tests/components/homekit/test_type_triggers.py +++ b/tests/components/homekit/test_type_triggers.py @@ -6,13 +6,19 @@ from homeassistant.components.homekit.const import CHAR_PROGRAMMABLE_SWITCH_EVEN from homeassistant.components.homekit.type_triggers import DeviceTriggerAccessory from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, async_get_device_automations async def test_programmable_switch_button_fires_on_trigger( - hass: HomeAssistant, hk_driver, events, demo_cleanup, device_reg, entity_reg + hass: HomeAssistant, + hk_driver, + events, + demo_cleanup, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test that DeviceTriggerAccessory fires the programmable switch event on trigger.""" hk_driver.publish = MagicMock() @@ -24,7 +30,7 @@ async def test_programmable_switch_button_fires_on_trigger( hass.states.async_set("light.ceiling_lights", STATE_OFF) await hass.async_block_till_done() - entry = entity_reg.async_get("light.ceiling_lights") + entry = entity_registry.async_get("light.ceiling_lights") assert entry is not None device_id = entry.device_id From b607a09e4b28e0351c4b740540ee77a0f85d4b70 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 1 Mar 2023 17:10:19 +0100 Subject: [PATCH 0571/2810] 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 d78c546cce..5a2ee54c5b 100644 --- a/homeassistant/components/thread/discovery.py +++ b/homeassistant/components/thread/discovery.py @@ -18,6 +18,7 @@ KNOWN_BRANDS: dict[str | None, str] = { "Apple Inc.": "apple", "Google Inc.": "google", "HomeAssistant": "homeassistant", + "Home Assistant": "homeassistant", } THREAD_TYPE = "_meshcop._udp.local." CLASS_IN = 1 From 89c276bb6b66cd13fc989b32e32a25ad7daabd80 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 1 Mar 2023 17:12:37 +0100 Subject: [PATCH 0572/2810] 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 3d6fedb070..9cd10bb4d0 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 b8f1d3f5a7..1c35e9910f 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 13818635d9..927ee4d395 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 9e2eab1743..d9026fc51e 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 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 0573/2810] 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 af203906b8..b248ffbac2 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 a04c74259d..33eac82a6b 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 0574/2810] 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 3f8fb0c680..21ef280936 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 0575/2810] 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 0a822d0515..8fdafee6cf 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 4b55ac6859..fece314c91 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 0576/2810] 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 d861c989ee..d93e009136 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 0b1482883a..47c0867187 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 5c95b4abd1..ceb20279c1 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 0577/2810] 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 a8cacc0e20..adf91eb706 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 9c2f68fd68..c1541812d1 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 0578/2810] 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 3677c0f8d5..9184b8c3d6 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 4b8e73e9fe..989e605749 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 1bd057d5a5..6f31ba81e6 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 a5414ce3f0..5134f0900a 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 0579/2810] 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 3d6fedb070..9cd10bb4d0 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 4a00b05d21..9ea8763eb9 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 6f31ba81e6..4ef3016268 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 5134f0900a..bf10bcc52d 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 0580/2810] 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 d78c546cce..5a2ee54c5b 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 0581/2810] 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 879cce9eac..2edef39b7c 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 641dda0257..ef4253a43d 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 0582/2810] 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 98432fe0be..7893565743 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 3f32c5d2addafc53c6621f5c7bfa793057b1667c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 1 Mar 2023 12:29:57 -0500 Subject: [PATCH 0583/2810] Yaml use dict (#88977) * Use built-in dict instead of OrderedDict * Use dict instead of OrderedDict in YAML --- homeassistant/util/yaml/dumper.py | 7 +- homeassistant/util/yaml/loader.py | 21 +- homeassistant/util/yaml/objects.py | 4 + .../blueprint/snapshots/test_importer.ambr | 338 +++++++++--------- 4 files changed, 190 insertions(+), 180 deletions(-) diff --git a/homeassistant/util/yaml/dumper.py b/homeassistant/util/yaml/dumper.py index db8b496d90..a3fba65304 100644 --- a/homeassistant/util/yaml/dumper.py +++ b/homeassistant/util/yaml/dumper.py @@ -4,7 +4,7 @@ from typing import Any import yaml -from .objects import Input, NodeListClass +from .objects import Input, NodeDictClass, NodeListClass # mypy: allow-untyped-calls, no-warn-return-any @@ -74,6 +74,11 @@ add_representer( lambda dumper, value: represent_odict(dumper, "tag:yaml.org,2002:map", value), ) +add_representer( + NodeDictClass, + lambda dumper, value: represent_odict(dumper, "tag:yaml.org,2002:map", value), +) + add_representer( NodeListClass, lambda dumper, value: dumper.represent_sequence("tag:yaml.org,2002:seq", value), diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index bf8a4e9541..b5840a79e8 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -1,7 +1,6 @@ """Custom loader.""" from __future__ import annotations -from collections import OrderedDict from collections.abc import Iterator import fnmatch from io import StringIO, TextIOWrapper @@ -25,7 +24,7 @@ except ImportError: from homeassistant.exceptions import HomeAssistantError from .const import SECRET_YAML -from .objects import Input, NodeListClass, NodeStrClass +from .objects import Input, NodeDictClass, NodeListClass, NodeStrClass # mypy: allow-untyped-calls, no-warn-return-any @@ -205,7 +204,7 @@ def _parse_yaml( # We convert that to an empty dict return ( yaml.load(content, Loader=lambda stream: loader(stream, secrets)) - or OrderedDict() + or NodeDictClass() ) @@ -276,9 +275,9 @@ def _find_files(directory: str, pattern: str) -> Iterator[str]: yield filename -def _include_dir_named_yaml(loader: LoaderType, node: yaml.nodes.Node) -> OrderedDict: +def _include_dir_named_yaml(loader: LoaderType, node: yaml.nodes.Node) -> NodeDictClass: """Load multiple files from directory as a dictionary.""" - mapping: OrderedDict = OrderedDict() + mapping = NodeDictClass() loc = os.path.join(os.path.dirname(loader.get_name()), node.value) for fname in _find_files(loc, "*.yaml"): filename = os.path.splitext(os.path.basename(fname))[0] @@ -290,9 +289,9 @@ def _include_dir_named_yaml(loader: LoaderType, node: yaml.nodes.Node) -> Ordere def _include_dir_merge_named_yaml( loader: LoaderType, node: yaml.nodes.Node -) -> OrderedDict: +) -> NodeDictClass: """Load multiple files from directory as a merged dictionary.""" - mapping: OrderedDict = OrderedDict() + mapping = NodeDictClass() loc = os.path.join(os.path.dirname(loader.get_name()), node.value) for fname in _find_files(loc, "*.yaml"): if os.path.basename(fname) == SECRET_YAML: @@ -330,7 +329,9 @@ def _include_dir_merge_list_yaml( return _add_reference(merged_list, loader, node) -def _ordered_dict(loader: LoaderType, node: yaml.nodes.MappingNode) -> OrderedDict: +def _handle_mapping_tag( + loader: LoaderType, node: yaml.nodes.MappingNode +) -> NodeDictClass: """Load YAML mappings into an ordered dictionary to preserve key order.""" loader.flatten_mapping(node) nodes = loader.construct_pairs(node) @@ -361,7 +362,7 @@ def _ordered_dict(loader: LoaderType, node: yaml.nodes.MappingNode) -> OrderedDi ) seen[key] = line - return _add_reference(OrderedDict(nodes), loader, node) + return _add_reference(NodeDictClass(nodes), loader, node) def _construct_seq(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE: @@ -398,7 +399,7 @@ def add_constructor(tag: Any, constructor: Any) -> None: add_constructor("!include", _include_yaml) -add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _ordered_dict) +add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _handle_mapping_tag) add_constructor(yaml.resolver.BaseResolver.DEFAULT_SEQUENCE_TAG, _construct_seq) add_constructor("!env_var", _env_var_yaml) add_constructor("!secret", secret_yaml) diff --git a/homeassistant/util/yaml/objects.py b/homeassistant/util/yaml/objects.py index 2d318a9def..e7b262ad49 100644 --- a/homeassistant/util/yaml/objects.py +++ b/homeassistant/util/yaml/objects.py @@ -14,6 +14,10 @@ class NodeStrClass(str): """Wrapper class to be able to add attributes on a string.""" +class NodeDictClass(dict): + """Wrapper class to be able to add attributes on a dict.""" + + @dataclass(frozen=True) class Input: """Input that should be substituted.""" diff --git a/tests/components/blueprint/snapshots/test_importer.ambr b/tests/components/blueprint/snapshots/test_importer.ambr index 6e5648b54d..002d5204dc 100644 --- a/tests/components/blueprint/snapshots/test_importer.ambr +++ b/tests/components/blueprint/snapshots/test_importer.ambr @@ -1,25 +1,79 @@ # serializer version: 1 # name: test_extract_blueprint_from_community_topic - OrderedDict({ - 'remote': OrderedDict({ - 'name': 'Remote', - 'description': 'IKEA remote to use', + NodeDictClass({ + 'brightness': NodeDictClass({ + 'default': 50, + 'description': 'Brightness of the light(s) when turning on', + 'name': 'Brightness', 'selector': dict({ - 'device': OrderedDict({ - 'integration': 'zha', - 'manufacturer': 'IKEA of Sweden', - 'model': 'TRADFRI remote control', - 'multiple': False, + 'number': NodeDictClass({ + 'max': 100.0, + 'min': 0.0, + 'mode': 'slider', + 'step': 1.0, + 'unit_of_measurement': '%', }), }), }), - 'light': OrderedDict({ - 'name': 'Light(s)', - 'description': 'The light(s) to control', + 'button_left_long': NodeDictClass({ + 'default': NodeListClass([ + ]), + 'description': 'Action to run on long left button press', + 'name': 'Left button - long press', 'selector': dict({ - 'target': OrderedDict({ + 'action': dict({ + }), + }), + }), + 'button_left_short': NodeDictClass({ + 'default': NodeListClass([ + ]), + 'description': 'Action to run on short left button press', + 'name': 'Left button - short press', + 'selector': dict({ + 'action': dict({ + }), + }), + }), + 'button_right_long': NodeDictClass({ + 'default': NodeListClass([ + ]), + 'description': 'Action to run on long right button press', + 'name': 'Right button - long press', + 'selector': dict({ + 'action': dict({ + }), + }), + }), + 'button_right_short': NodeDictClass({ + 'default': NodeListClass([ + ]), + 'description': 'Action to run on short right button press', + 'name': 'Right button - short press', + 'selector': dict({ + 'action': dict({ + }), + }), + }), + 'force_brightness': NodeDictClass({ + 'default': False, + 'description': ''' + Force the brightness to the set level below, when the "on" button on the remote is pushed and lights turn on. + + ''', + 'name': 'Force turn on brightness', + 'selector': dict({ + 'boolean': dict({ + }), + }), + }), + 'light': NodeDictClass({ + 'description': 'The light(s) to control', + 'name': 'Light(s)', + 'selector': dict({ + 'target': NodeDictClass({ 'entity': list([ - OrderedDict({ + NodeDictClass({ 'domain': list([ 'light', ]), @@ -28,95 +82,95 @@ }), }), }), - 'force_brightness': OrderedDict({ - 'name': 'Force turn on brightness', - 'description': ''' - Force the brightness to the set level below, when the "on" button on the remote is pushed and lights turn on. - - ''', - 'default': False, + 'remote': NodeDictClass({ + 'description': 'IKEA remote to use', + 'name': 'Remote', 'selector': dict({ - 'boolean': dict({ - }), - }), - }), - 'brightness': OrderedDict({ - 'name': 'Brightness', - 'description': 'Brightness of the light(s) when turning on', - 'default': 50, - 'selector': dict({ - 'number': OrderedDict({ - 'min': 0.0, - 'max': 100.0, - 'mode': 'slider', - 'step': 1.0, - 'unit_of_measurement': '%', - }), - }), - }), - 'button_left_short': OrderedDict({ - 'name': 'Left button - short press', - 'description': 'Action to run on short left button press', - 'default': NodeListClass([ - ]), - 'selector': dict({ - 'action': dict({ - }), - }), - }), - 'button_left_long': OrderedDict({ - 'name': 'Left button - long press', - 'description': 'Action to run on long left button press', - 'default': NodeListClass([ - ]), - 'selector': dict({ - 'action': dict({ - }), - }), - }), - 'button_right_short': OrderedDict({ - 'name': 'Right button - short press', - 'description': 'Action to run on short right button press', - 'default': NodeListClass([ - ]), - 'selector': dict({ - 'action': dict({ - }), - }), - }), - 'button_right_long': OrderedDict({ - 'name': 'Right button - long press', - 'description': 'Action to run on long right button press', - 'default': NodeListClass([ - ]), - 'selector': dict({ - 'action': dict({ + 'device': NodeDictClass({ + 'integration': 'zha', + 'manufacturer': 'IKEA of Sweden', + 'model': 'TRADFRI remote control', + 'multiple': False, }), }), }), }) # --- # name: test_fetch_blueprint_from_community_url - OrderedDict({ - 'remote': OrderedDict({ - 'name': 'Remote', - 'description': 'IKEA remote to use', + NodeDictClass({ + 'brightness': NodeDictClass({ + 'default': 50, + 'description': 'Brightness of the light(s) when turning on', + 'name': 'Brightness', 'selector': dict({ - 'device': OrderedDict({ - 'integration': 'zha', - 'manufacturer': 'IKEA of Sweden', - 'model': 'TRADFRI remote control', - 'multiple': False, + 'number': NodeDictClass({ + 'max': 100.0, + 'min': 0.0, + 'mode': 'slider', + 'step': 1.0, + 'unit_of_measurement': '%', }), }), }), - 'light': OrderedDict({ - 'name': 'Light(s)', - 'description': 'The light(s) to control', + 'button_left_long': NodeDictClass({ + 'default': NodeListClass([ + ]), + 'description': 'Action to run on long left button press', + 'name': 'Left button - long press', 'selector': dict({ - 'target': OrderedDict({ + 'action': dict({ + }), + }), + }), + 'button_left_short': NodeDictClass({ + 'default': NodeListClass([ + ]), + 'description': 'Action to run on short left button press', + 'name': 'Left button - short press', + 'selector': dict({ + 'action': dict({ + }), + }), + }), + 'button_right_long': NodeDictClass({ + 'default': NodeListClass([ + ]), + 'description': 'Action to run on long right button press', + 'name': 'Right button - long press', + 'selector': dict({ + 'action': dict({ + }), + }), + }), + 'button_right_short': NodeDictClass({ + 'default': NodeListClass([ + ]), + 'description': 'Action to run on short right button press', + 'name': 'Right button - short press', + 'selector': dict({ + 'action': dict({ + }), + }), + }), + 'force_brightness': NodeDictClass({ + 'default': False, + 'description': ''' + Force the brightness to the set level below, when the "on" button on the remote is pushed and lights turn on. + + ''', + 'name': 'Force turn on brightness', + 'selector': dict({ + 'boolean': dict({ + }), + }), + }), + 'light': NodeDictClass({ + 'description': 'The light(s) to control', + 'name': 'Light(s)', + 'selector': dict({ + 'target': NodeDictClass({ 'entity': list([ - OrderedDict({ + NodeDictClass({ 'domain': list([ 'light', ]), @@ -125,94 +179,26 @@ }), }), }), - 'force_brightness': OrderedDict({ - 'name': 'Force turn on brightness', - 'description': ''' - Force the brightness to the set level below, when the "on" button on the remote is pushed and lights turn on. - - ''', - 'default': False, + 'remote': NodeDictClass({ + 'description': 'IKEA remote to use', + 'name': 'Remote', 'selector': dict({ - 'boolean': dict({ - }), - }), - }), - 'brightness': OrderedDict({ - 'name': 'Brightness', - 'description': 'Brightness of the light(s) when turning on', - 'default': 50, - 'selector': dict({ - 'number': OrderedDict({ - 'min': 0.0, - 'max': 100.0, - 'mode': 'slider', - 'step': 1.0, - 'unit_of_measurement': '%', - }), - }), - }), - 'button_left_short': OrderedDict({ - 'name': 'Left button - short press', - 'description': 'Action to run on short left button press', - 'default': NodeListClass([ - ]), - 'selector': dict({ - 'action': dict({ - }), - }), - }), - 'button_left_long': OrderedDict({ - 'name': 'Left button - long press', - 'description': 'Action to run on long left button press', - 'default': NodeListClass([ - ]), - 'selector': dict({ - 'action': dict({ - }), - }), - }), - 'button_right_short': OrderedDict({ - 'name': 'Right button - short press', - 'description': 'Action to run on short right button press', - 'default': NodeListClass([ - ]), - 'selector': dict({ - 'action': dict({ - }), - }), - }), - 'button_right_long': OrderedDict({ - 'name': 'Right button - long press', - 'description': 'Action to run on long right button press', - 'default': NodeListClass([ - ]), - 'selector': dict({ - 'action': dict({ + 'device': NodeDictClass({ + 'integration': 'zha', + 'manufacturer': 'IKEA of Sweden', + 'model': 'TRADFRI remote control', + 'multiple': False, }), }), }), }) # --- # name: test_fetch_blueprint_from_github_gist_url - OrderedDict({ - 'motion_entity': OrderedDict({ - 'name': 'Motion Sensor', - 'selector': dict({ - 'entity': OrderedDict({ - 'domain': list([ - 'binary_sensor', - ]), - 'device_class': list([ - 'motion', - ]), - 'multiple': False, - }), - }), - }), - 'light_entity': OrderedDict({ + NodeDictClass({ + 'light_entity': NodeDictClass({ 'name': 'Light', 'selector': dict({ - 'entity': OrderedDict({ + 'entity': NodeDictClass({ 'domain': list([ 'light', ]), @@ -220,5 +206,19 @@ }), }), }), + 'motion_entity': NodeDictClass({ + 'name': 'Motion Sensor', + 'selector': dict({ + 'entity': NodeDictClass({ + 'device_class': list([ + 'motion', + ]), + 'domain': list([ + 'binary_sensor', + ]), + 'multiple': False, + }), + }), + }), }) # --- From bdbec491eb49652d774b0fef67d5eb89677111cc Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 18:40:26 +0100 Subject: [PATCH 0584/2810] Enable RUFF ICN001 for registries (#88875) * Add issue_registry to RUFF extend aliases * Add area_registry to RUFF extend aliases * Add device_registry to RUFF extend aliases * Add entity_registry to RUFF extend aliases * Adjust scaffold --- pyproject.toml | 4 ++++ .../device_action/integration/device_action.py | 7 +++---- .../integration/device_condition.py | 13 ++++++++----- .../device_trigger/integration/device_trigger.py | 6 +++--- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2e0c0d9ebc..c6e8d6db0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -278,7 +278,11 @@ ignore = [ [tool.ruff.flake8-import-conventions.extend-aliases] voluptuous = "vol" +"homeassistant.helpers.area_registry" = "ar" "homeassistant.helpers.config_validation" = "cv" +"homeassistant.helpers.device_registry" = "dr" +"homeassistant.helpers.entity_registry" = "er" +"homeassistant.helpers.issue_registry" = "ir" [tool.ruff.flake8-pytest-style] fixture-parentheses = false diff --git a/script/scaffold/templates/device_action/integration/device_action.py b/script/scaffold/templates/device_action/integration/device_action.py index a9d77853e5..4732d9bd71 100644 --- a/script/scaffold/templates/device_action/integration/device_action.py +++ b/script/scaffold/templates/device_action/integration/device_action.py @@ -13,8 +13,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from . import DOMAIN @@ -33,7 +32,7 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for NEW_NAME devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) actions = [] # TODO Read this comment and remove it. @@ -44,7 +43,7 @@ async def async_get_actions( # return zha_device.device_actions # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/script/scaffold/templates/device_condition/integration/device_condition.py b/script/scaffold/templates/device_condition/integration/device_condition.py index cc5ad76588..00acd23698 100644 --- a/script/scaffold/templates/device_condition/integration/device_condition.py +++ b/script/scaffold/templates/device_condition/integration/device_condition.py @@ -14,8 +14,11 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import condition, config_validation as cv, entity_registry -from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN @@ -23,7 +26,7 @@ from . import DOMAIN # TODO specify your supported condition types. CONDITION_TYPES = {"is_on", "is_off"} -CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( +CONDITION_SCHEMA = cv.DEVICE_CONDITION_BASE_SCHEMA.extend( { vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Required(CONF_TYPE): vol.In(CONDITION_TYPES), @@ -35,11 +38,11 @@ async def async_get_conditions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device conditions for NEW_NAME devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) conditions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/script/scaffold/templates/device_trigger/integration/device_trigger.py b/script/scaffold/templates/device_trigger/integration/device_trigger.py index a03e27394e..1fd8810bd8 100644 --- a/script/scaffold/templates/device_trigger/integration/device_trigger.py +++ b/script/scaffold/templates/device_trigger/integration/device_trigger.py @@ -21,7 +21,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.typing import ConfigType from . import DOMAIN @@ -41,7 +41,7 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, Any]]: """List device triggers for NEW_NAME devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) triggers = [] # TODO Read this comment and remove it. @@ -52,7 +52,7 @@ async def async_get_triggers( # return zha_device.device_triggers # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue From ae04c5d7737dcd9e8878e1f150fb8d6a474f5be5 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 1 Mar 2023 18:42:34 +0100 Subject: [PATCH 0585/2810] Clean up unused and deprecated TLS version setting on MQTT client (#88674) * Cleanup CONF_TLS_VERSION remains * Fix diagnostics tests --- homeassistant/components/mqtt/__init__.py | 2 -- homeassistant/components/mqtt/config_integration.py | 5 ----- homeassistant/components/mqtt/const.py | 1 - tests/components/mqtt/test_diagnostics.py | 1 - tests/components/mqtt/test_discovery.py | 1 - 5 files changed, 10 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index a1b194284c..c73eec1244 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -74,7 +74,6 @@ from .const import ( # noqa: F401 CONF_QOS, CONF_STATE_TOPIC, CONF_TLS_INSECURE, - CONF_TLS_VERSION, CONF_TOPIC, CONF_TRANSPORT, CONF_WILL_MESSAGE, @@ -160,7 +159,6 @@ CONFIG_SCHEMA = vol.Schema( cv.deprecated(CONF_PORT), # Deprecated in HA Core 2022.3 cv.deprecated(CONF_PROTOCOL), # Deprecated in HA Core 2022.11 cv.deprecated(CONF_TLS_INSECURE), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_TLS_VERSION), # Deprecated June 2020 cv.deprecated(CONF_USERNAME), # Deprecated in HA Core 2022.3 cv.deprecated(CONF_WILL_MESSAGE), # Deprecated in HA Core 2022.3 CONFIG_SCHEMA_BASE, diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index bbd6861435..47f8a7cf49 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -45,7 +45,6 @@ from .const import ( CONF_DISCOVERY_PREFIX, CONF_KEEPALIVE, CONF_TLS_INSECURE, - CONF_TLS_VERSION, CONF_TRANSPORT, CONF_WILL_MESSAGE, CONF_WS_HEADERS, @@ -72,7 +71,6 @@ DEFAULT_VALUES = { CONF_DISCOVERY_PREFIX: DEFAULT_PREFIX, CONF_PORT: DEFAULT_PORT, CONF_PROTOCOL: DEFAULT_PROTOCOL, - CONF_TLS_VERSION: DEFAULT_TLS_PROTOCOL, CONF_TRANSPORT: DEFAULT_TRANSPORT, CONF_WILL_MESSAGE: DEFAULT_WILL, CONF_KEEPALIVE: DEFAULT_KEEPALIVE, @@ -182,7 +180,6 @@ CONFIG_SCHEMA_ENTRY = vol.Schema( CONF_CLIENT_CERT, "client_key_auth", msg=CLIENT_KEY_AUTH_MSG ): str, vol.Optional(CONF_TLS_INSECURE): cv.boolean, - vol.Optional(CONF_TLS_VERSION): vol.Any("auto", "1.0", "1.1", "1.2"), vol.Optional(CONF_PROTOCOL): vol.All(cv.string, vol.In(SUPPORTED_PROTOCOLS)), vol.Optional(CONF_WILL_MESSAGE): valid_birth_will, vol.Optional(CONF_BIRTH_MESSAGE): valid_birth_will, @@ -214,7 +211,6 @@ CONFIG_SCHEMA_BASE = PLATFORM_CONFIG_SCHEMA_BASE.extend( CONF_CLIENT_CERT, "client_key_auth", msg=CLIENT_KEY_AUTH_MSG ): cv.isfile, vol.Optional(CONF_TLS_INSECURE): cv.boolean, - vol.Optional(CONF_TLS_VERSION): vol.Any("auto", "1.0", "1.1", "1.2"), vol.Optional(CONF_PROTOCOL): vol.All(cv.string, vol.In(SUPPORTED_PROTOCOLS)), vol.Optional(CONF_WILL_MESSAGE): valid_birth_will, vol.Optional(CONF_BIRTH_MESSAGE): valid_birth_will, @@ -236,7 +232,6 @@ DEPRECATED_CONFIG_KEYS = [ CONF_PORT, CONF_PROTOCOL, CONF_TLS_INSECURE, - CONF_TLS_VERSION, CONF_USERNAME, CONF_WILL_MESSAGE, ] diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index f7e2cbe5b1..bb6b8ed497 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -33,7 +33,6 @@ CONF_CERTIFICATE = "certificate" CONF_CLIENT_KEY = "client_key" CONF_CLIENT_CERT = "client_cert" CONF_TLS_INSECURE = "tls_insecure" -CONF_TLS_VERSION = "tls_version" DATA_MQTT = "mqtt" diff --git a/tests/components/mqtt/test_diagnostics.py b/tests/components/mqtt/test_diagnostics.py index b0ff769e72..780be72925 100644 --- a/tests/components/mqtt/test_diagnostics.py +++ b/tests/components/mqtt/test_diagnostics.py @@ -24,7 +24,6 @@ default_config = { "keepalive": 60, "port": 1883, "protocol": "3.1.1", - "tls_version": "auto", "transport": "tcp", "ws_headers": {}, "ws_path": "/", diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index ba472cce04..5cd615e0eb 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -1266,7 +1266,6 @@ ABBREVIATIONS_WHITE_LIST = [ "CONF_EMBEDDED", "CONF_KEEPALIVE", "CONF_TLS_INSECURE", - "CONF_TLS_VERSION", "CONF_TRANSPORT", "CONF_WILL_MESSAGE", "CONF_WS_PATH", From 07839cc971bf11bb1034c3d76dc06e44bc107a58 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Mar 2023 12:35:53 -0600 Subject: [PATCH 0586/2810] Bump ulid-transform to 0.4.0 (#88982) changelog: https://github.com/bdraco/ulid-transform/compare/v0.3.1...v0.4.0 --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1c35e9910f..0da05b2d57 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -44,7 +44,7 @@ requests==2.28.2 scapy==2.5.0 sqlalchemy==2.0.4 typing-extensions>=4.5.0,<5.0 -ulid-transform==0.3.1 +ulid-transform==0.4.0 voluptuous-serialize==2.6.0 voluptuous==0.13.1 yarl==1.8.1 diff --git a/pyproject.toml b/pyproject.toml index c6e8d6db0e..a262936da0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ dependencies = [ "pyyaml==6.0", "requests==2.28.2", "typing-extensions>=4.5.0,<5.0", - "ulid-transform==0.3.1", + "ulid-transform==0.4.0", "voluptuous==0.13.1", "voluptuous-serialize==2.6.0", "yarl==1.8.1", diff --git a/requirements.txt b/requirements.txt index d7d65f10b6..76d4f68fbe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,7 @@ python-slugify==4.0.1 pyyaml==6.0 requests==2.28.2 typing-extensions>=4.5.0,<5.0 -ulid-transform==0.3.1 +ulid-transform==0.4.0 voluptuous==0.13.1 voluptuous-serialize==2.6.0 yarl==1.8.1 From adb0455bd297571e125fb69ad1d055161fe6f00f Mon Sep 17 00:00:00 2001 From: Stephan Uhle Date: Wed, 1 Mar 2023 21:19:20 +0100 Subject: [PATCH 0587/2810] Add config flow to EDL21 (#87655) * Added config_flow for edl21. * Added already_configured check. * Added config_flow test * Added setup of the edl21 from configuration.yaml * Ran script.gen_requirements_all * Removed the generated translation file. * Added a deprecation warning when importing from configuration.yaml. * Readded the platform schema. * Added handling of optional name for legacy configuration. * Fixed handling of default value in legacy configuration. * Added duplication check entries created via legacy config. * Apply suggestions from code review Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Apply suggestions from code review * Apply suggestions from code review * Apply suggestions from code review * Apply suggestions from code review Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Apply suggestions from code review --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .coveragerc | 3 +- homeassistant/components/edl21/__init__.py | 17 ++++ homeassistant/components/edl21/config_flow.py | 50 ++++++++++++ homeassistant/components/edl21/const.py | 12 +++ homeassistant/components/edl21/manifest.json | 2 + homeassistant/components/edl21/sensor.py | 44 +++++++--- homeassistant/components/edl21/strings.json | 21 +++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/edl21/__init__.py | 1 + tests/components/edl21/conftest.py | 15 ++++ tests/components/edl21/test_config_flow.py | 81 +++++++++++++++++++ 13 files changed, 240 insertions(+), 12 deletions(-) create mode 100644 homeassistant/components/edl21/config_flow.py create mode 100644 homeassistant/components/edl21/const.py create mode 100644 homeassistant/components/edl21/strings.json create mode 100644 tests/components/edl21/__init__.py create mode 100644 tests/components/edl21/conftest.py create mode 100644 tests/components/edl21/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 3c9a1c378a..5da330bb20 100644 --- a/.coveragerc +++ b/.coveragerc @@ -249,7 +249,8 @@ omit = homeassistant/components/ecowitt/sensor.py homeassistant/components/eddystone_temperature/sensor.py homeassistant/components/edimax/switch.py - homeassistant/components/edl21/* + homeassistant/components/edl21/__init__.py + homeassistant/components/edl21/sensor.py homeassistant/components/egardia/* homeassistant/components/eight_sleep/__init__.py homeassistant/components/eight_sleep/binary_sensor.py diff --git a/homeassistant/components/edl21/__init__.py b/homeassistant/components/edl21/__init__.py index f1cd598474..2ece8517db 100644 --- a/homeassistant/components/edl21/__init__.py +++ b/homeassistant/components/edl21/__init__.py @@ -1 +1,18 @@ """The edl21 component.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up EDL21 integration from a config entry.""" + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/edl21/config_flow.py b/homeassistant/components/edl21/config_flow.py new file mode 100644 index 0000000000..b66a988958 --- /dev/null +++ b/homeassistant/components/edl21/config_flow.py @@ -0,0 +1,50 @@ +"""Config flow for EDL21 integration.""" +from typing import Any + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_NAME +from homeassistant.data_entry_flow import FlowResult + +from .const import CONF_SERIAL_PORT, DEFAULT_TITLE, DOMAIN + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_SERIAL_PORT): str, + } +) + + +class EDL21ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """EDL21 config flow.""" + + VERSION = 1 + + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + + self._async_abort_entries_match( + {CONF_SERIAL_PORT: import_config[CONF_SERIAL_PORT]} + ) + return self.async_create_entry( + title=import_config[CONF_NAME] or DEFAULT_TITLE, + data=import_config, + ) + + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the user setup step.""" + if user_input is not None: + self._async_abort_entries_match( + {CONF_SERIAL_PORT: user_input[CONF_SERIAL_PORT]} + ) + + return self.async_create_entry( + title=DEFAULT_TITLE, + data=user_input, + ) + + data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA, user_input) + return self.async_show_form(step_id="user", data_schema=data_schema) diff --git a/homeassistant/components/edl21/const.py b/homeassistant/components/edl21/const.py new file mode 100644 index 0000000000..f57966a000 --- /dev/null +++ b/homeassistant/components/edl21/const.py @@ -0,0 +1,12 @@ +"""Constants for the EDL21 component.""" +import logging + +LOGGER = logging.getLogger(__package__) + +DOMAIN = "edl21" + +CONF_SERIAL_PORT = "serial_port" + +SIGNAL_EDL21_TELEGRAM = "edl21_telegram" + +DEFAULT_TITLE = "Smart Meter" diff --git a/homeassistant/components/edl21/manifest.json b/homeassistant/components/edl21/manifest.json index dc7e861ce8..48bab7d84f 100644 --- a/homeassistant/components/edl21/manifest.json +++ b/homeassistant/components/edl21/manifest.json @@ -2,7 +2,9 @@ "domain": "edl21", "name": "EDL21", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/edl21", + "integration_type": "hub", "iot_class": "local_push", "loggers": ["sml"], "requirements": ["pysml==0.0.8"] diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index 497f6867df..e34c9c823f 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -1,8 +1,9 @@ """Support for EDL21 Smart Meters.""" from __future__ import annotations +from collections.abc import Mapping from datetime import timedelta -import logging +from typing import Any from sml import SmlGetListResponse from sml.asyncio import SmlProtocol @@ -15,6 +16,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_NAME, DEGREE, @@ -31,15 +33,13 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.dt import utcnow -_LOGGER = logging.getLogger(__name__) +from .const import CONF_SERIAL_PORT, DOMAIN, LOGGER, SIGNAL_EDL21_TELEGRAM -DOMAIN = "edl21" -CONF_SERIAL_PORT = "serial_port" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) -SIGNAL_EDL21_TELEGRAM = "edl21_telegram" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -269,9 +269,33 @@ async def async_setup_platform( config: ConfigType, async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up EDL21 sensors via configuration.yaml and show deprecation warning.""" + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.2.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the EDL21 sensor.""" - hass.data[DOMAIN] = EDL21(hass, config, async_add_entities) + hass.data[DOMAIN] = EDL21(hass, config_entry.data, async_add_entities) await hass.data[DOMAIN].connect() @@ -295,14 +319,14 @@ class EDL21: def __init__( self, hass: HomeAssistant, - config: ConfigType, + config: Mapping[str, Any], async_add_entities: AddEntitiesCallback, ) -> None: """Initialize an EDL21 object.""" self._registered_obis: set[tuple[str, str]] = set() self._hass = hass self._async_add_entities = async_add_entities - self._name = config[CONF_NAME] + self._name = config.get(CONF_NAME) self._proto = SmlProtocol(config[CONF_SERIAL_PORT]) self._proto.add_listener(self.event, ["SmlGetListResponse"]) @@ -347,7 +371,7 @@ class EDL21: ) self._registered_obis.add((electricity_id, obis)) elif obis not in self._OBIS_BLACKLIST: - _LOGGER.warning( + LOGGER.warning( "Unhandled sensor %s detected. Please report at %s", obis, "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+edl21%22", @@ -366,7 +390,7 @@ class EDL21: "sensor", DOMAIN, entity.old_unique_id ) if old_entity_id is not None: - _LOGGER.debug( + LOGGER.debug( "Migrating unique_id from [%s] to [%s]", entity.old_unique_id, entity.unique_id, diff --git a/homeassistant/components/edl21/strings.json b/homeassistant/components/edl21/strings.json new file mode 100644 index 0000000000..284e8229c5 --- /dev/null +++ b/homeassistant/components/edl21/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "step": { + "user": { + "title": "Add your EDL21 smart meter", + "data": { + "serial_port": "[%key:common::config_flow::data::usb_path%]" + } + } + } + }, + "issues": { + "deprecated_yaml": { + "title": "EDL21 YAML configuration is being removed", + "description": "Configuring EDL21 using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the EDL21 YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 5055547676..3621c1d48d 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -107,6 +107,7 @@ FLOWS = { "ecobee", "econet", "ecowitt", + "edl21", "efergy", "eight_sleep", "elgato", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 6111681bd3..a79f06bbd3 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1272,7 +1272,7 @@ "edl21": { "name": "EDL21", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_push" }, "efergy": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d9026fc51e..a2dead7912 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1431,6 +1431,9 @@ pysmartapp==0.3.3 # homeassistant.components.smartthings pysmartthings==0.7.6 +# homeassistant.components.edl21 +pysml==0.0.8 + # homeassistant.components.snmp pysnmplib==5.0.20 diff --git a/tests/components/edl21/__init__.py b/tests/components/edl21/__init__.py new file mode 100644 index 0000000000..e9b7055689 --- /dev/null +++ b/tests/components/edl21/__init__.py @@ -0,0 +1 @@ +"""Tests for the EDL21 integration.""" diff --git a/tests/components/edl21/conftest.py b/tests/components/edl21/conftest.py new file mode 100644 index 0000000000..dc64659d2b --- /dev/null +++ b/tests/components/edl21/conftest.py @@ -0,0 +1,15 @@ +"""Define test fixtures for EDL21.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.edl21.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/edl21/test_config_flow.py b/tests/components/edl21/test_config_flow.py new file mode 100644 index 0000000000..4dbd69b237 --- /dev/null +++ b/tests/components/edl21/test_config_flow.py @@ -0,0 +1,81 @@ +"""Test EDL21 config flow.""" + +import pytest + +from homeassistant.components.edl21.const import CONF_SERIAL_PORT, DEFAULT_TITLE, DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +VALID_CONFIG = {CONF_SERIAL_PORT: "/dev/ttyUSB1"} +VALID_LEGACY_CONFIG = {CONF_NAME: "My Smart Meter", CONF_SERIAL_PORT: "/dev/ttyUSB1"} + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_show_form(hass: HomeAssistant) -> None: + """Test that the form is served with no input.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_TITLE + assert result["data"][CONF_SERIAL_PORT] == VALID_CONFIG[CONF_SERIAL_PORT] + + +async def test_integration_already_exists(hass: HomeAssistant) -> None: + """Test that a new entry must not have the same serial port as an existing entry.""" + + MockConfigEntry( + domain=DOMAIN, + data=VALID_CONFIG, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=VALID_CONFIG, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_create_entry_by_import(hass: HomeAssistant) -> None: + """Test that the import step works.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=VALID_LEGACY_CONFIG, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == VALID_LEGACY_CONFIG[CONF_NAME] + assert result["data"][CONF_NAME] == VALID_LEGACY_CONFIG[CONF_NAME] + assert result["data"][CONF_SERIAL_PORT] == VALID_LEGACY_CONFIG[CONF_SERIAL_PORT] + + # Test the import step with an empty string as name + # (the name is optional in the old schema and defaults to "") + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_SERIAL_PORT: "/dev/ttyUSB2", CONF_NAME: ""}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_TITLE + assert result["data"][CONF_NAME] == "" + assert result["data"][CONF_SERIAL_PORT] == "/dev/ttyUSB2" From 19c08bfdd522616899e4b02828a457f46fb4bbfc Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 1 Mar 2023 23:44:12 +0100 Subject: [PATCH 0588/2810] Refactor WLED binary sensor test (#88579) --- .../wled/snapshots/test_binary_sensor.ambr | 75 +++++++++++++++++++ tests/components/wled/test_binary_sensor.py | 42 ++++------- 2 files changed, 91 insertions(+), 26 deletions(-) create mode 100644 tests/components/wled/snapshots/test_binary_sensor.ambr diff --git a/tests/components/wled/snapshots/test_binary_sensor.ambr b/tests/components/wled/snapshots/test_binary_sensor.ambr new file mode 100644 index 0000000000..7520ea7a6a --- /dev/null +++ b/tests/components/wled/snapshots/test_binary_sensor.ambr @@ -0,0 +1,75 @@ +# serializer version: 1 +# name: test_update_available + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'update', + 'friendly_name': 'WLED RGB Light Firmware', + }), + 'context': , + 'entity_id': 'binary_sensor.wled_rgb_light_firmware', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update_available.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.wled_rgb_light_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'wled', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_update', + 'unit_of_measurement': None, + }) +# --- +# name: test_update_available.2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://127.0.0.1', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'esp8266', + 'id': , + 'identifiers': set({ + tuple( + 'wled', + 'aabbccddeeff', + ), + }), + 'is_new': False, + 'manufacturer': 'WLED', + 'model': 'DIY light', + 'name': 'WLED RGB Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.8.5', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/wled/test_binary_sensor.py b/tests/components/wled/test_binary_sensor.py index aa1bec313d..eb5faadd53 100644 --- a/tests/components/wled/test_binary_sensor.py +++ b/tests/components/wled/test_binary_sensor.py @@ -1,56 +1,46 @@ """Tests for the WLED binary sensor platform.""" import pytest +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, - STATE_OFF, - STATE_ON, - EntityCategory, -) +from homeassistant.const import STATE_OFF from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er pytestmark = pytest.mark.usefixtures("init_integration") @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_update_available( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test the firmware update binary sensor.""" assert (state := hass.states.get("binary_sensor.wled_rgb_light_firmware")) - assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.UPDATE - assert state.state == STATE_ON - assert ATTR_ICON not in state.attributes + assert state == snapshot - assert (entry := entity_registry.async_get("binary_sensor.wled_rgb_light_firmware")) - assert entry.unique_id == "aabbccddeeff_update" - assert entry.entity_category is EntityCategory.DIAGNOSTIC + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert entity_entry == snapshot + + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry == snapshot @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize("device_fixture", ["rgb_websocket"]) -async def test_no_update_available( - hass: HomeAssistant, entity_registry: er.EntityRegistry -) -> None: +async def test_no_update_available(hass: HomeAssistant) -> None: """Test the update binary sensor. There is no update available.""" assert (state := hass.states.get("binary_sensor.wled_websocket_firmware")) - assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.UPDATE assert state.state == STATE_OFF - assert ATTR_ICON not in state.attributes - - assert (entry := entity_registry.async_get("binary_sensor.wled_websocket_firmware")) - assert entry.unique_id == "aabbccddeeff_update" - assert entry.entity_category is EntityCategory.DIAGNOSTIC async def test_disabled_by_default( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test that the binary update sensor is disabled by default.""" - assert hass.states.get("binary_sensor.wled_rgb_light_firmware") is None + assert not hass.states.get("binary_sensor.wled_rgb_light_firmware") assert (entry := entity_registry.async_get("binary_sensor.wled_rgb_light_firmware")) assert entry.disabled From aa92d053170062ed84cb93b2d0e1fd2569220a3d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 2 Mar 2023 03:07:12 +0100 Subject: [PATCH 0589/2810] 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 b837cf8dfe..7a4f6b9d90 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 927ee4d395..0796c39ccc 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 a2dead7912..d30b292bac 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1045,7 +1045,7 @@ py-canary==0.5.3 py-cpuinfo==8.0.0 # homeassistant.components.dormakaba_dkey -py-dormakaba-dkey==1.0.3 +py-dormakaba-dkey==1.0.4 # homeassistant.components.melissa py-melissa-climate==2.1.4 From 28e8fae2803a26c7fe830577370ceb9abfbe7e6f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 2 Mar 2023 12:33:04 +0100 Subject: [PATCH 0590/2810] Fix flaky energy tests (#89026) --- tests/components/energy/test_sensor.py | 119 +++++++++++-------------- 1 file changed, 54 insertions(+), 65 deletions(-) diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index 538cc94918..f5fea15338 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -2,9 +2,7 @@ import copy from datetime import timedelta from typing import Any -from unittest.mock import patch -from freezegun import freeze_time import pytest from homeassistant.components.energy import data @@ -31,6 +29,8 @@ from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from tests.components.recorder.common import async_wait_recording_done from tests.typing import WebSocketGenerator +TEST_TIME_ADVANCE_INTERVAL = timedelta(milliseconds=10) + @pytest.fixture async def setup_integration(recorder_mock): @@ -44,10 +44,10 @@ async def setup_integration(recorder_mock): @pytest.fixture(autouse=True) -@freeze_time("2022-04-19 07:53:05") -def frozen_time(): +def frozen_time(freezer): """Freeze clock for tests.""" - return + freezer.move_to("2022-04-19 07:53:05") + return freezer def get_statistics_for_entity(statistics_results, entity_id): @@ -139,6 +139,7 @@ async def test_cost_sensor_attributes( ], ) async def test_cost_sensor_price_entity_total_increasing( + frozen_time, setup_integration, hass: HomeAssistant, hass_storage: dict[str, Any], @@ -206,8 +207,7 @@ async def test_cost_sensor_price_entity_total_increasing( ) hass.states.async_set("sensor.energy_price", "1") - with patch("homeassistant.util.dt.utcnow", return_value=now): - await setup_integration(hass) + await setup_integration(hass) state = hass.states.get(cost_sensor_entity_id) assert state.state == initial_cost @@ -219,13 +219,12 @@ async def test_cost_sensor_price_entity_total_increasing( # Optional late setup of dependent entities if initial_energy is None: - with patch("homeassistant.util.dt.utcnow", return_value=now): - hass.states.async_set( - usage_sensor_entity_id, - "0", - energy_attributes, - ) - await hass.async_block_till_done() + hass.states.async_set( + usage_sensor_entity_id, + "0", + energy_attributes, + ) + await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "0.0" @@ -242,6 +241,7 @@ async def test_cost_sensor_price_entity_total_increasing( assert entry.hidden_by is er.RegistryEntryHider.INTEGRATION # Energy use bumped to 10 kWh + frozen_time.tick(TEST_TIME_ADVANCE_INTERVAL) hass.states.async_set( usage_sensor_entity_id, "10", @@ -268,6 +268,7 @@ async def test_cost_sensor_price_entity_total_increasing( assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor # Additional consumption is using the new price + frozen_time.tick(TEST_TIME_ADVANCE_INTERVAL) hass.states.async_set( usage_sensor_entity_id, "14.5", @@ -285,6 +286,7 @@ async def test_cost_sensor_price_entity_total_increasing( assert statistics["stat"]["sum"] == 19.0 # Energy sensor has a small dip, no reset should be detected + frozen_time.tick(TEST_TIME_ADVANCE_INTERVAL) hass.states.async_set( usage_sensor_entity_id, "14", @@ -296,6 +298,7 @@ async def test_cost_sensor_price_entity_total_increasing( assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor # Energy sensor is reset, with initial state at 4kWh, 0 kWh is used as zero-point + frozen_time.tick(TEST_TIME_ADVANCE_INTERVAL) hass.states.async_set( usage_sensor_entity_id, "4", @@ -308,6 +311,7 @@ async def test_cost_sensor_price_entity_total_increasing( last_reset_cost_sensor = state.attributes[ATTR_LAST_RESET] # Energy use bumped to 10 kWh + frozen_time.tick(TEST_TIME_ADVANCE_INTERVAL) hass.states.async_set( usage_sensor_entity_id, "10", @@ -344,6 +348,7 @@ async def test_cost_sensor_price_entity_total_increasing( ) @pytest.mark.parametrize("energy_state_class", ["total", "measurement"]) async def test_cost_sensor_price_entity_total( + frozen_time, setup_integration, hass: HomeAssistant, hass_storage: dict[str, Any], @@ -360,7 +365,9 @@ async def test_cost_sensor_price_entity_total( """Test energy cost price from total type sensor entity.""" def _compile_statistics(_): - return compile_statistics(hass, now, now + timedelta(seconds=1)).platform_stats + return compile_statistics( + hass, now, now + timedelta(seconds=0.17) + ).platform_stats energy_attributes = { ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, @@ -413,8 +420,7 @@ async def test_cost_sensor_price_entity_total( ) hass.states.async_set("sensor.energy_price", "1") - with patch("homeassistant.util.dt.utcnow", return_value=now): - await setup_integration(hass) + await setup_integration(hass) state = hass.states.get(cost_sensor_entity_id) assert state.state == initial_cost @@ -426,13 +432,12 @@ async def test_cost_sensor_price_entity_total( # Optional late setup of dependent entities if initial_energy is None: - with patch("homeassistant.util.dt.utcnow", return_value=now): - hass.states.async_set( - usage_sensor_entity_id, - "0", - {**energy_attributes, **{"last_reset": last_reset}}, - ) - await hass.async_block_till_done() + hass.states.async_set( + usage_sensor_entity_id, + "0", + {**energy_attributes, **{"last_reset": last_reset}}, + ) + await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "0.0" @@ -449,6 +454,7 @@ async def test_cost_sensor_price_entity_total( assert entry.hidden_by is er.RegistryEntryHider.INTEGRATION # Energy use bumped to 10 kWh + frozen_time.tick(TEST_TIME_ADVANCE_INTERVAL) hass.states.async_set( usage_sensor_entity_id, "10", @@ -475,6 +481,7 @@ async def test_cost_sensor_price_entity_total( assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor # Additional consumption is using the new price + frozen_time.tick(TEST_TIME_ADVANCE_INTERVAL) hass.states.async_set( usage_sensor_entity_id, "14.5", @@ -492,6 +499,7 @@ async def test_cost_sensor_price_entity_total( assert statistics["stat"]["sum"] == 19.0 # Energy sensor has a small dip + frozen_time.tick(TEST_TIME_ADVANCE_INTERVAL) hass.states.async_set( usage_sensor_entity_id, "14", @@ -503,7 +511,8 @@ async def test_cost_sensor_price_entity_total( assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor # Energy sensor is reset, with initial state at 4kWh, 0 kWh is used as zero-point - last_reset = (now + timedelta(seconds=1)).isoformat() + frozen_time.tick(TEST_TIME_ADVANCE_INTERVAL) + last_reset = dt_util.utcnow() hass.states.async_set( usage_sensor_entity_id, "4", @@ -516,6 +525,7 @@ async def test_cost_sensor_price_entity_total( last_reset_cost_sensor = state.attributes[ATTR_LAST_RESET] # Energy use bumped to 10 kWh + frozen_time.tick(TEST_TIME_ADVANCE_INTERVAL) hass.states.async_set( usage_sensor_entity_id, "10", @@ -552,6 +562,7 @@ async def test_cost_sensor_price_entity_total( ) @pytest.mark.parametrize("energy_state_class", ["total"]) async def test_cost_sensor_price_entity_total_no_reset( + frozen_time, setup_integration, hass: HomeAssistant, hass_storage: dict[str, Any], @@ -620,8 +631,7 @@ async def test_cost_sensor_price_entity_total_no_reset( ) hass.states.async_set("sensor.energy_price", "1") - with patch("homeassistant.util.dt.utcnow", return_value=now): - await setup_integration(hass) + await setup_integration(hass) state = hass.states.get(cost_sensor_entity_id) assert state.state == initial_cost @@ -633,13 +643,12 @@ async def test_cost_sensor_price_entity_total_no_reset( # Optional late setup of dependent entities if initial_energy is None: - with patch("homeassistant.util.dt.utcnow", return_value=now): - hass.states.async_set( - usage_sensor_entity_id, - "0", - energy_attributes, - ) - await hass.async_block_till_done() + hass.states.async_set( + usage_sensor_entity_id, + "0", + energy_attributes, + ) + await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "0.0" @@ -656,6 +665,7 @@ async def test_cost_sensor_price_entity_total_no_reset( assert entry.hidden_by is er.RegistryEntryHider.INTEGRATION # Energy use bumped to 10 kWh + frozen_time.tick(TEST_TIME_ADVANCE_INTERVAL) hass.states.async_set( usage_sensor_entity_id, "10", @@ -682,6 +692,7 @@ async def test_cost_sensor_price_entity_total_no_reset( assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor # Additional consumption is using the new price + frozen_time.tick(TEST_TIME_ADVANCE_INTERVAL) hass.states.async_set( usage_sensor_entity_id, "14.5", @@ -699,6 +710,7 @@ async def test_cost_sensor_price_entity_total_no_reset( assert statistics["stat"]["sum"] == 19.0 # Energy sensor has a small dip + frozen_time.tick(TEST_TIME_ADVANCE_INTERVAL) hass.states.async_set( usage_sensor_entity_id, "14", @@ -759,8 +771,6 @@ async def test_cost_sensor_handle_energy_units( "data": energy_data, } - now = dt_util.utcnow() - # Initial state: 10kWh hass.states.async_set( "sensor.energy_consumption", @@ -768,8 +778,7 @@ async def test_cost_sensor_handle_energy_units( energy_attributes, ) - with patch("homeassistant.util.dt.utcnow", return_value=now): - await setup_integration(hass) + await setup_integration(hass) state = hass.states.get("sensor.energy_consumption_cost") assert state.state == "0.0" @@ -833,8 +842,6 @@ async def test_cost_sensor_handle_price_units( "data": energy_data, } - now = dt_util.utcnow() - # Initial state: 10kWh hass.states.async_set("sensor.energy_price", "2", price_attributes) hass.states.async_set( @@ -843,8 +850,7 @@ async def test_cost_sensor_handle_price_units( energy_attributes, ) - with patch("homeassistant.util.dt.utcnow", return_value=now): - await setup_integration(hass) + await setup_integration(hass) state = hass.states.get("sensor.energy_consumption_cost") assert state.state == "0.0" @@ -889,16 +895,13 @@ async def test_cost_sensor_handle_gas( "data": energy_data, } - now = dt_util.utcnow() - hass.states.async_set( "sensor.gas_consumption", 100, energy_attributes, ) - with patch("homeassistant.util.dt.utcnow", return_value=now): - await setup_integration(hass) + await setup_integration(hass) state = hass.states.get("sensor.gas_consumption_cost") assert state.state == "0.0" @@ -939,16 +942,13 @@ async def test_cost_sensor_handle_gas_kwh( "data": energy_data, } - now = dt_util.utcnow() - hass.states.async_set( "sensor.gas_consumption", 100, energy_attributes, ) - with patch("homeassistant.util.dt.utcnow", return_value=now): - await setup_integration(hass) + await setup_integration(hass) state = hass.states.get("sensor.gas_consumption_cost") assert state.state == "0.0" @@ -1004,16 +1004,13 @@ async def test_cost_sensor_handle_water( "data": energy_data, } - now = dt_util.utcnow() - hass.states.async_set( "sensor.water_consumption", 100, energy_attributes, ) - with patch("homeassistant.util.dt.utcnow", return_value=now): - await setup_integration(hass) + await setup_integration(hass) state = hass.states.get("sensor.water_consumption_cost") assert state.state == "0.0" @@ -1065,16 +1062,13 @@ async def test_cost_sensor_wrong_state_class( "data": energy_data, } - now = dt_util.utcnow() - hass.states.async_set( "sensor.energy_consumption", 10000, energy_attributes, ) - with patch("homeassistant.util.dt.utcnow", return_value=now): - await setup_integration(hass) + await setup_integration(hass) state = hass.states.get("sensor.energy_consumption_cost") assert state.state == STATE_UNKNOWN @@ -1130,16 +1124,13 @@ async def test_cost_sensor_state_class_measurement_no_reset( "data": energy_data, } - now = dt_util.utcnow() - hass.states.async_set( "sensor.energy_consumption", 10000, energy_attributes, ) - with patch("homeassistant.util.dt.utcnow", return_value=now): - await setup_integration(hass) + await setup_integration(hass) state = hass.states.get("sensor.energy_consumption_cost") assert state.state == STATE_UNKNOWN @@ -1176,7 +1167,6 @@ async def test_inherit_source_unique_id( "data": energy_data, } - now = dt_util.utcnow() entity_registry = er.async_get(hass) source_entry = entity_registry.async_get_or_create( "sensor", "test", "123456", suggested_object_id="gas_consumption" @@ -1191,8 +1181,7 @@ async def test_inherit_source_unique_id( }, ) - with patch("homeassistant.util.dt.utcnow", return_value=now): - await setup_integration(hass) + await setup_integration(hass) state = hass.states.get("sensor.gas_consumption_cost") assert state From e5fc2d3f7892d220f95a1379893a2729863767d8 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 2 Mar 2023 22:13:31 +1000 Subject: [PATCH 0591/2810] Add Turn On and Turn Off for Advantage Air climate platform (#88684) * Added Climate On and Climate Off * Add Tests * Fix off and on in zone * Add test assertions for zone HVAC mode --- .../components/advantage_air/climate.py | 60 +++++++++++---- .../components/advantage_air/test_climate.py | 73 ++++++++++++++++--- 2 files changed, 107 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index 362701f3b9..53a41994fc 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -116,6 +116,30 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): """Return the current fan modes.""" return ADVANTAGE_AIR_FAN_MODES.get(self._ac["fan"]) + async def async_turn_on(self) -> None: + """Set the HVAC State to on.""" + await self.aircon( + { + self.ac_key: { + "info": { + "state": ADVANTAGE_AIR_STATE_ON, + } + } + } + ) + + async def async_turn_off(self) -> None: + """Set the HVAC State to off.""" + await self.aircon( + { + self.ac_key: { + "info": { + "state": ADVANTAGE_AIR_STATE_OFF, + } + } + } + ) + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the HVAC Mode and State.""" if hvac_mode == HVACMode.OFF: @@ -181,24 +205,32 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity): """Return the target temperature.""" return self._zone["setTemp"] + async def async_turn_on(self) -> None: + """Set the HVAC State to on.""" + await self.aircon( + { + self.ac_key: { + "zones": {self.zone_key: {"state": ADVANTAGE_AIR_STATE_OPEN}} + } + } + ) + + async def async_turn_off(self) -> None: + """Set the HVAC State to off.""" + await self.aircon( + { + self.ac_key: { + "zones": {self.zone_key: {"state": ADVANTAGE_AIR_STATE_CLOSE}} + } + } + ) + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the HVAC Mode and State.""" if hvac_mode == HVACMode.OFF: - await self.aircon( - { - self.ac_key: { - "zones": {self.zone_key: {"state": ADVANTAGE_AIR_STATE_CLOSE}} - } - } - ) + await self.async_turn_off() else: - await self.aircon( - { - self.ac_key: { - "zones": {self.zone_key: {"state": ADVANTAGE_AIR_STATE_OPEN}} - } - } - ) + await self.async_turn_on() async def async_set_temperature(self, **kwargs: Any) -> None: """Set the Temperature.""" diff --git a/tests/components/advantage_air/test_climate.py b/tests/components/advantage_air/test_climate.py index f12aec7388..b3412cb1bc 100644 --- a/tests/components/advantage_air/test_climate.py +++ b/tests/components/advantage_air/test_climate.py @@ -8,8 +8,10 @@ from homeassistant.components.advantage_air.climate import ( HASS_HVAC_MODES, ) from homeassistant.components.advantage_air.const import ( + ADVANTAGE_AIR_STATE_CLOSE, ADVANTAGE_AIR_STATE_OFF, ADVANTAGE_AIR_STATE_ON, + ADVANTAGE_AIR_STATE_OPEN, ) from homeassistant.components.climate import ( ATTR_FAN_MODE, @@ -19,6 +21,8 @@ from homeassistant.components.climate import ( SERVICE_SET_FAN_MODE, SERVICE_SET_HVAC_MODE, SERVICE_SET_TEMPERATURE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, HVACMode, ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE @@ -54,8 +58,6 @@ async def test_climate_async_setup_entry( registry = er.async_get(hass) - assert len(aioclient_mock.mock_calls) == 1 - # Test Main Climate Entity entity_id = "climate.ac_one" state = hass.states.get(entity_id) @@ -76,7 +78,6 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 3 assert aioclient_mock.mock_calls[-2][0] == "GET" assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) @@ -91,7 +92,6 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.OFF}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 5 assert aioclient_mock.mock_calls[-2][0] == "GET" assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) @@ -105,7 +105,6 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_FAN_MODE: FAN_LOW}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 7 assert aioclient_mock.mock_calls[-2][0] == "GET" assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) @@ -119,7 +118,6 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_TEMPERATURE: 25}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 9 assert aioclient_mock.mock_calls[-2][0] == "GET" assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) @@ -127,6 +125,32 @@ async def test_climate_async_setup_entry( assert aioclient_mock.mock_calls[-1][0] == "GET" assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + assert aioclient_mock.mock_calls[-2][0] == "GET" + assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" + data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) + assert data["ac1"]["info"]["state"] == ADVANTAGE_AIR_STATE_OFF + assert aioclient_mock.mock_calls[-1][0] == "GET" + assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + assert aioclient_mock.mock_calls[-2][0] == "GET" + assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" + data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) + assert data["ac1"]["info"]["state"] == ADVANTAGE_AIR_STATE_ON + assert aioclient_mock.mock_calls[-1][0] == "GET" + assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + # Test Climate Zone Entity entity_id = "climate.ac_one_zone_open_with_sensor" state = hass.states.get(entity_id) @@ -146,9 +170,11 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 11 assert aioclient_mock.mock_calls[-2][0] == "GET" assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" + data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) + + assert data["ac1"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_OPEN assert aioclient_mock.mock_calls[-1][0] == "GET" assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" @@ -158,9 +184,10 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.OFF}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 13 assert aioclient_mock.mock_calls[-2][0] == "GET" assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" + data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) + assert data["ac1"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_CLOSE assert aioclient_mock.mock_calls[-1][0] == "GET" assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" @@ -170,12 +197,37 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_TEMPERATURE: 25}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 15 assert aioclient_mock.mock_calls[-2][0] == "GET" assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" assert aioclient_mock.mock_calls[-1][0] == "GET" assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + assert aioclient_mock.mock_calls[-2][0] == "GET" + assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" + data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) + assert data["ac1"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_CLOSE + assert aioclient_mock.mock_calls[-1][0] == "GET" + assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + assert aioclient_mock.mock_calls[-2][0] == "GET" + assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" + data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) + assert data["ac1"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_OPEN + assert aioclient_mock.mock_calls[-1][0] == "GET" + assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + async def test_climate_async_failed_update( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker @@ -192,8 +244,6 @@ async def test_climate_async_failed_update( ) await add_mock_config(hass) - assert len(aioclient_mock.mock_calls) == 1 - with pytest.raises(HomeAssistantError): await hass.services.async_call( CLIMATE_DOMAIN, @@ -201,6 +251,5 @@ async def test_climate_async_failed_update( {ATTR_ENTITY_ID: ["climate.ac_one"], ATTR_TEMPERATURE: 25}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 2 assert aioclient_mock.mock_calls[-1][0] == "GET" assert aioclient_mock.mock_calls[-1][1].path == "/setAircon" From 1efc33d4c61053146c6095fab2096b89582e6beb Mon Sep 17 00:00:00 2001 From: Xavier Decuyper Date: Thu, 2 Mar 2023 13:40:22 +0100 Subject: [PATCH 0592/2810] Nuki: show actual device model in device registry (#89017) * Bump pynuki to 1.6.1 (adds friendly device model names) * Nuki: use friendly model name for device registry * Update global dependencies --- homeassistant/components/nuki/__init__.py | 2 +- homeassistant/components/nuki/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index f1c3d71496..9504d38c93 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -215,7 +215,7 @@ class NukiEntity(CoordinatorEntity[NukiCoordinator], Generic[_NukiDeviceT]): "identifiers": {(DOMAIN, parse_id(self._nuki_device.nuki_id))}, "name": self._nuki_device.name, "manufacturer": "Nuki Home Solutions GmbH", - "model": self._nuki_device.device_type_str.capitalize(), + "model": self._nuki_device.device_model_str.capitalize(), "sw_version": self._nuki_device.firmware_version, "via_device": (DOMAIN, self.coordinator.bridge_id), } diff --git a/homeassistant/components/nuki/manifest.json b/homeassistant/components/nuki/manifest.json index ac69f97a9b..e6b741d442 100644 --- a/homeassistant/components/nuki/manifest.json +++ b/homeassistant/components/nuki/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/nuki", "iot_class": "local_polling", "loggers": ["pynuki"], - "requirements": ["pynuki==1.6.0"] + "requirements": ["pynuki==1.6.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0796c39ccc..57291a8960 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1816,7 +1816,7 @@ pynina==0.2.0 pynobo==1.6.0 # homeassistant.components.nuki -pynuki==1.6.0 +pynuki==1.6.1 # homeassistant.components.nut pynut2==2.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d30b292bac..e38918ee46 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1308,7 +1308,7 @@ pynina==0.2.0 pynobo==1.6.0 # homeassistant.components.nuki -pynuki==1.6.0 +pynuki==1.6.1 # homeassistant.components.nut pynut2==2.1.2 From ec32b934a56d2c3bac3a082e508cb4b8d73e9739 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 2 Mar 2023 15:40:46 +0100 Subject: [PATCH 0593/2810] 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 0da05b2d57..b1c74f4207 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 a262936da0..10fa38be35 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 76d4f68fbe..478b8a64d5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,7 @@ lru-dict==1.1.8 PyJWT==2.5.0 cryptography==39.0.1 pyOpenSSL==23.0.0 -orjson==3.8.6 +orjson==3.8.7 pip>=21.0,<23.1 python-slugify==4.0.1 pyyaml==6.0 From fd4d79d24cf96e7fad6a562d6b16b8227d8b21c6 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 2 Mar 2023 16:10:26 +0100 Subject: [PATCH 0594/2810] 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 9cd10bb4d0..c09f2d501c 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 b1c74f4207..cd2950c964 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 57291a8960..a007390b9a 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 e38918ee46..ddcad6f002 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -690,7 +690,7 @@ hole==0.8.0 holidays==0.18.0 # homeassistant.components.frontend -home-assistant-frontend==20230301.0 +home-assistant-frontend==20230302.0 # homeassistant.components.conversation home-assistant-intents==2023.2.28 From f69aa7ad9ddbeea6b11b9b480529f266ebfc8a82 Mon Sep 17 00:00:00 2001 From: Toni Juvani Date: Thu, 2 Mar 2023 17:11:34 +0200 Subject: [PATCH 0595/2810] 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 4d9c056068..6bd68e17c4 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 d0adc0391a..b5cb4486cc 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 0e23729df7..e716192b8b 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 5f375ee22e..7c56320872 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 a007390b9a..778258f4e1 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 ddcad6f002..6ebbbe7536 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1076,7 +1076,7 @@ pyMetno==0.9.0 pyRFXtrx==0.30.1 # homeassistant.components.tibber -pyTibber==0.26.13 +pyTibber==0.27.0 # homeassistant.components.dlink pyW215==0.7.0 From e849878a48d5aec2277483e394808abf2a1489d9 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Thu, 2 Mar 2023 16:13:02 +0100 Subject: [PATCH 0596/2810] 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 ec79dbc5f9..85e23cbe54 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 4ac6a36611..054d784471 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -77,16 +77,17 @@ def patch_file_upload(return_value=FIXTURE_KEYRING, side_effect=None): side_effect=side_effect, ), patch( "pathlib.Path.mkdir" - ) as mkdir_mock: - file_path_mock = Mock() - file_upload_mock.return_value.__enter__.return_value = file_path_mock + ) as mkdir_mock, patch( + "shutil.move" + ) as shutil_move_mock: + file_upload_mock.return_value.__enter__.return_value = Mock() yield return_value if side_effect: mkdir_mock.assert_not_called() - file_path_mock.rename.assert_not_called() + shutil_move_mock.assert_not_called() else: mkdir_mock.assert_called_once() - file_path_mock.rename.assert_called_once() + shutil_move_mock.assert_called_once() def _gateway_descriptor( From eebcf70b41aec5b4614051471119bf8ddf8628df Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 2 Mar 2023 19:01:05 +0100 Subject: [PATCH 0597/2810] Re-enable Ruff D411 (#89035) --- homeassistant/components/esphome/bluetooth/client.py | 4 ++++ pyproject.toml | 1 - 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index 7eb38edbf4..545a436ee8 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -234,6 +234,7 @@ class ESPHomeClient(BaseBleakClient): Keyword Args: timeout (float): Timeout for required ``BleakScanner.find_device_by_address`` call. Defaults to 10.0. + Returns: Boolean representing connection status. """ @@ -504,6 +505,7 @@ class ESPHomeClient(BaseBleakClient): The characteristic to read from, specified by either integer handle, UUID or directly by the BleakGATTCharacteristic object representing it. + Returns: (bytearray) The read data. """ @@ -519,6 +521,7 @@ class ESPHomeClient(BaseBleakClient): Args: handle (int): The handle of the descriptor to read from. + Returns: (bytearray) The read data. """ @@ -583,6 +586,7 @@ class ESPHomeClient(BaseBleakClient): def callback(sender: int, data: bytearray): print(f"{sender}: {data}") client.start_notify(char_uuid, callback) + Args: characteristic (BleakGATTCharacteristic): The characteristic to activate notifications/indications on a diff --git a/pyproject.toml b/pyproject.toml index 10fa38be35..c0c2316688 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -271,7 +271,6 @@ ignore = [ "D404", # First word of the docstring should not be This "D406", # Section name should end with a newline "D407", # Section name underlining - "D411", # Missing blank line before section "E501", # line too long "E731", # do not assign a lambda expression, use a def ] From 5cab63c5b842752d4fa712018c2797e79eac64ce Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 2 Mar 2023 19:01:50 +0100 Subject: [PATCH 0598/2810] Fix lingering task in debounce tests (#89019) * Fix lingering task in debounce tests * Correct fix * Use async_fire_time_changed --- tests/helpers/test_debounce.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/helpers/test_debounce.py b/tests/helpers/test_debounce.py index eb1cdf6db7..4c25413d9f 100644 --- a/tests/helpers/test_debounce.py +++ b/tests/helpers/test_debounce.py @@ -1,8 +1,12 @@ """Tests for debounce.""" +from datetime import timedelta from unittest.mock import AsyncMock from homeassistant.core import HomeAssistant from homeassistant.helpers import debounce +from homeassistant.util.dt import utcnow + +from ..common import async_fire_time_changed async def test_immediate_works(hass: HomeAssistant) -> None: @@ -41,7 +45,8 @@ async def test_immediate_works(hass: HomeAssistant) -> None: # Call and let timer run out await debouncer.async_call() assert len(calls) == 2 - await debouncer._handle_timer_finish() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=1)) + await hass.async_block_till_done() assert len(calls) == 2 assert debouncer._timer_task is None assert debouncer._execute_at_end_of_timer is False @@ -89,7 +94,8 @@ async def test_not_immediate_works(hass: HomeAssistant) -> None: # Call and let timer run out await debouncer.async_call() assert len(calls) == 0 - await debouncer._handle_timer_finish() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=1)) + await hass.async_block_till_done() assert len(calls) == 1 assert debouncer._timer_task is not None assert debouncer._execute_at_end_of_timer is False @@ -150,7 +156,8 @@ async def test_immediate_works_with_function_swapped(hass: HomeAssistant) -> Non await debouncer.async_call() assert len(calls) == 2 assert calls == [1, 2] - await debouncer._handle_timer_finish() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=1)) + await hass.async_block_till_done() assert len(calls) == 2 assert calls == [1, 2] assert debouncer._timer_task is None From 8968ed1c47314db4b9ee7eeb302c92c4e71915a3 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 2 Mar 2023 20:20:26 +0100 Subject: [PATCH 0599/2810] 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 fd86024fbd..1812f41693 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 7d96d51d5c..8be15f1c7c 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 e2e8d74aa68bca0a67f0702cc2f500af3df5cdfc Mon Sep 17 00:00:00 2001 From: Toni Juvani Date: Thu, 2 Mar 2023 17:11:34 +0200 Subject: [PATCH 0600/2810] 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 4d9c056068..6bd68e17c4 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 d0adc0391a..b5cb4486cc 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 0e23729df7..e716192b8b 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 5f375ee22e..7c56320872 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 4ef3016268..940e0f4445 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 bf10bcc52d..c627d49699 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 0601/2810] 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 9ea8763eb9..fafaf00f93 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 ef4253a43d..13f27aae43 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 aa6e85d152..114d9acbcd 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 0602/2810] 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 b837cf8dfe..7a4f6b9d90 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 940e0f4445..b1f1d83cd7 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 c627d49699..0fa288700a 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 0603/2810] 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 ec79dbc5f9..85e23cbe54 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 4ac6a36611..054d784471 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 0604/2810] 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 fafaf00f93..eb05738c9a 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 13f27aae43..3e4f8d23bd 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 114d9acbcd..68d6bceb24 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 0605/2810] 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 9cd10bb4d0..c09f2d501c 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 eb05738c9a..3a19985363 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 b1f1d83cd7..e97294a3cb 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 0fa288700a..3e3e0629a8 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 0606/2810] 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 fd86024fbd..1812f41693 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 7d96d51d5c..8be15f1c7c 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 0607/2810] 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 2edef39b7c..1ec896a415 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 3e4f8d23bd..1a81cc5f50 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 0608/2810] 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 91b0a9eb19..cda1e9b02d 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 e97294a3cb..d11e4d6602 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 3e3e0629a8..22d6a64361 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 7365522d1fa8fb9e0f466c2ea1ef9fe0fe8a1a27 Mon Sep 17 00:00:00 2001 From: Guy Martin Date: Thu, 2 Mar 2023 19:43:11 -0500 Subject: [PATCH 0609/2810] Add matching on quirk_classes to zha (#87653) * Add matching on quirk_classes. * Add and fix tests for matching on quirk_classes. * Black fix. * Add a unit test to validate quirk classes. --- .../components/zha/core/channels/__init__.py | 5 + .../components/zha/core/discovery.py | 17 +- .../components/zha/core/registries.py | 60 ++++++-- tests/components/zha/test_registries.py | 145 ++++++++++++++++-- 4 files changed, 196 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py index 149b733be3..a708e65a07 100644 --- a/homeassistant/components/zha/core/channels/__init__.py +++ b/homeassistant/components/zha/core/channels/__init__.py @@ -239,6 +239,11 @@ class ChannelPool: """Return device model.""" return self._channels.zha_device.model + @property + def quirk_class(self) -> str: + """Return device quirk class.""" + return self._channels.zha_device.quirk_class + @property def skip_configuration(self) -> bool: """Return True if device does not require channel configuration.""" diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index eb7dd81e38..d256b98cfb 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -95,7 +95,11 @@ class ProbeEndpoint: if component and component in zha_const.PLATFORMS: channels = channel_pool.unclaimed_channels() entity_class, claimed = zha_regs.ZHA_ENTITIES.get_entity( - component, channel_pool.manufacturer, channel_pool.model, channels + component, + channel_pool.manufacturer, + channel_pool.model, + channels, + channel_pool.quirk_class, ) if entity_class is None: return @@ -145,7 +149,11 @@ class ProbeEndpoint: unique_id = f"{ep_channels.unique_id}-{channel.cluster.cluster_id}" entity_class, claimed = zha_regs.ZHA_ENTITIES.get_entity( - component, ep_channels.manufacturer, ep_channels.model, channel_list + component, + ep_channels.manufacturer, + ep_channels.model, + channel_list, + ep_channels.quirk_class, ) if entity_class is None: return @@ -190,12 +198,14 @@ class ProbeEndpoint: channel_pool.manufacturer, channel_pool.model, list(channel_pool.all_channels.values()), + channel_pool.quirk_class, ) else: matches, claimed = zha_regs.ZHA_ENTITIES.get_multi_entity( channel_pool.manufacturer, channel_pool.model, channel_pool.unclaimed_channels(), + channel_pool.quirk_class, ) channel_pool.claim_channels(claimed) @@ -210,8 +220,7 @@ class ProbeEndpoint: for component, ent_n_chan_list in matches.items(): for entity_and_channel in ent_n_chan_list: if component == cmpt_by_dev_type: - # for well known device types, like thermostats - # we'll take only 1st class + # for well known device types, like thermostats we'll take only 1st class channel_pool.async_new_entity( component, entity_and_channel.entity_class, diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 6b99d41268..a7504ae7a9 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -93,9 +93,7 @@ DEVICE_CLASS = { zigpy.profiles.zha.DeviceType.ON_OFF_PLUG_IN_UNIT: Platform.SWITCH, zigpy.profiles.zha.DeviceType.SHADE: Platform.COVER, zigpy.profiles.zha.DeviceType.SMART_PLUG: Platform.SWITCH, - zigpy.profiles.zha.DeviceType.IAS_ANCILLARY_CONTROL: ( - Platform.ALARM_CONTROL_PANEL - ), + zigpy.profiles.zha.DeviceType.IAS_ANCILLARY_CONTROL: Platform.ALARM_CONTROL_PANEL, zigpy.profiles.zha.DeviceType.IAS_WARNING_DEVICE: Platform.SIREN, }, zigpy.profiles.zll.PROFILE_ID: { @@ -146,13 +144,17 @@ class MatchRule: aux_channels: frozenset[str] | Callable = attr.ib( factory=_get_empty_frozenset, converter=set_or_callable ) + quirk_classes: frozenset[str] | Callable = attr.ib( + factory=_get_empty_frozenset, converter=set_or_callable + ) @property def weight(self) -> int: """Return the weight of the matching rule. - More specific matches should be preferred over less specific. Model matching - rules have a priority over manufacturer matching rules and rules matching a + More specific matches should be preferred over less specific. Quirk class + matching rules have priority over model matching rules + and have a priority over manufacturer matching rules and rules matching a single model/manufacturer get a better priority over rules matching multiple models/manufacturers. And any model or manufacturers matching rules get better priority over rules matching only channels. @@ -160,6 +162,11 @@ class MatchRule: multiple channels a better priority over rules matching a single channel. """ weight = 0 + if self.quirk_classes: + weight += 501 - ( + 1 if callable(self.quirk_classes) else len(self.quirk_classes) + ) + if self.models: weight += 401 - (1 if callable(self.models) else len(self.models)) @@ -187,15 +194,21 @@ class MatchRule: claimed.extend([ch for ch in channel_pool if ch.name in self.aux_channels]) return claimed - def strict_matched(self, manufacturer: str, model: str, channels: list) -> bool: + def strict_matched( + self, manufacturer: str, model: str, channels: list, quirk_class: str + ) -> bool: """Return True if this device matches the criteria.""" - return all(self._matched(manufacturer, model, channels)) + return all(self._matched(manufacturer, model, channels, quirk_class)) - def loose_matched(self, manufacturer: str, model: str, channels: list) -> bool: + def loose_matched( + self, manufacturer: str, model: str, channels: list, quirk_class: str + ) -> bool: """Return True if this device matches the criteria.""" - return any(self._matched(manufacturer, model, channels)) + return any(self._matched(manufacturer, model, channels, quirk_class)) - def _matched(self, manufacturer: str, model: str, channels: list) -> list: + def _matched( + self, manufacturer: str, model: str, channels: list, quirk_class: str + ) -> list: """Return a list of field matches.""" if not any(attr.asdict(self).values()): return [False] @@ -221,6 +234,12 @@ class MatchRule: else: matches.append(model in self.models) + if self.quirk_classes: + if callable(self.quirk_classes): + matches.append(self.quirk_classes(quirk_class)) + else: + matches.append(quirk_class in self.quirk_classes) + return matches @@ -261,12 +280,13 @@ class ZHAEntityRegistry: manufacturer: str, model: str, channels: list[ZigbeeChannel], + quirk_class: str, default: type[ZhaEntity] | None = None, ) -> tuple[type[ZhaEntity] | None, list[ZigbeeChannel]]: """Match a ZHA Channels to a ZHA Entity class.""" matches = self._strict_registry[component] for match in sorted(matches, key=lambda x: x.weight, reverse=True): - if match.strict_matched(manufacturer, model, channels): + if match.strict_matched(manufacturer, model, channels, quirk_class): claimed = match.claim_channels(channels) return self._strict_registry[component][match], claimed @@ -277,6 +297,7 @@ class ZHAEntityRegistry: manufacturer: str, model: str, channels: list[ZigbeeChannel], + quirk_class: str, ) -> tuple[dict[str, list[EntityClassAndChannels]], list[ZigbeeChannel]]: """Match ZHA Channels to potentially multiple ZHA Entity classes.""" result: dict[str, list[EntityClassAndChannels]] = collections.defaultdict(list) @@ -285,7 +306,7 @@ class ZHAEntityRegistry: for stop_match_grp, matches in stop_match_groups.items(): sorted_matches = sorted(matches, key=lambda x: x.weight, reverse=True) for match in sorted_matches: - if match.strict_matched(manufacturer, model, channels): + if match.strict_matched(manufacturer, model, channels, quirk_class): claimed = match.claim_channels(channels) for ent_class in stop_match_groups[stop_match_grp][match]: ent_n_channels = EntityClassAndChannels(ent_class, claimed) @@ -301,6 +322,7 @@ class ZHAEntityRegistry: manufacturer: str, model: str, channels: list[ZigbeeChannel], + quirk_class: str, ) -> tuple[dict[str, list[EntityClassAndChannels]], list[ZigbeeChannel]]: """Match ZHA Channels to potentially multiple ZHA Entity classes.""" result: dict[str, list[EntityClassAndChannels]] = collections.defaultdict(list) @@ -312,7 +334,7 @@ class ZHAEntityRegistry: for stop_match_grp, matches in stop_match_groups.items(): sorted_matches = sorted(matches, key=lambda x: x.weight, reverse=True) for match in sorted_matches: - if match.strict_matched(manufacturer, model, channels): + if match.strict_matched(manufacturer, model, channels, quirk_class): claimed = match.claim_channels(channels) for ent_class in stop_match_groups[stop_match_grp][match]: ent_n_channels = EntityClassAndChannels(ent_class, claimed) @@ -335,11 +357,17 @@ class ZHAEntityRegistry: manufacturers: Callable | set[str] | str | None = None, models: Callable | set[str] | str | None = None, aux_channels: Callable | set[str] | str | None = None, + quirk_classes: set[str] | str | None = None, ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: """Decorate a strict match rule.""" rule = MatchRule( - channel_names, generic_ids, manufacturers, models, aux_channels + channel_names, + generic_ids, + manufacturers, + models, + aux_channels, + quirk_classes, ) def decorator(zha_ent: _ZhaEntityT) -> _ZhaEntityT: @@ -361,6 +389,7 @@ class ZHAEntityRegistry: models: Callable | set[str] | str | None = None, aux_channels: Callable | set[str] | str | None = None, stop_on_match_group: int | str | None = None, + quirk_classes: set[str] | str | None = None, ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: """Decorate a loose match rule.""" @@ -370,6 +399,7 @@ class ZHAEntityRegistry: manufacturers, models, aux_channels, + quirk_classes, ) def decorator(zha_entity: _ZhaEntityT) -> _ZhaEntityT: @@ -394,6 +424,7 @@ class ZHAEntityRegistry: models: Callable | set[str] | str | None = None, aux_channels: Callable | set[str] | str | None = None, stop_on_match_group: int | str | None = None, + quirk_classes: set[str] | str | None = None, ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: """Decorate a loose match rule.""" @@ -403,6 +434,7 @@ class ZHAEntityRegistry: manufacturers, models, aux_channels, + quirk_classes, ) def decorator(zha_entity: _ZhaEntityT) -> _ZhaEntityT: diff --git a/tests/components/zha/test_registries.py b/tests/components/zha/test_registries.py index db7aa2791c..24cd7a5785 100644 --- a/tests/components/zha/test_registries.py +++ b/tests/components/zha/test_registries.py @@ -1,13 +1,16 @@ """Test ZHA registries.""" +import inspect from unittest import mock import pytest +import zhaquirks import homeassistant.components.zha.core.registries as registries from homeassistant.helpers import entity_registry as er MANUFACTURER = "mock manufacturer" MODEL = "mock model" +QUIRK_CLASS = "mock.class" @pytest.fixture @@ -16,6 +19,7 @@ def zha_device(): dev = mock.MagicMock() dev.manufacturer = MANUFACTURER dev.model = MODEL + dev.quirk_class = QUIRK_CLASS return dev @@ -70,6 +74,16 @@ def channels(channel): (registries.MatchRule(models="no match"), False), (registries.MatchRule(models=MODEL, aux_channels="aux_channel"), True), (registries.MatchRule(models="no match", aux_channels="aux_channel"), False), + (registries.MatchRule(quirk_classes=QUIRK_CLASS), True), + (registries.MatchRule(quirk_classes="no match"), False), + ( + registries.MatchRule(quirk_classes=QUIRK_CLASS, aux_channels="aux_channel"), + True, + ), + ( + registries.MatchRule(quirk_classes="no match", aux_channels="aux_channel"), + False, + ), # match everything ( registries.MatchRule( @@ -77,6 +91,7 @@ def channels(channel): channel_names={"on_off", "level"}, manufacturers=MANUFACTURER, models=MODEL, + quirk_classes=QUIRK_CLASS, ), True, ), @@ -124,11 +139,35 @@ def channels(channel): registries.MatchRule(channel_names="on_off", models=lambda x: x != MODEL), False, ), + ( + registries.MatchRule( + channel_names="on_off", quirk_classes={"random quirk", QUIRK_CLASS} + ), + True, + ), + ( + registries.MatchRule( + channel_names="on_off", quirk_classes={"random quirk", "another quirk"} + ), + False, + ), + ( + registries.MatchRule( + channel_names="on_off", quirk_classes=lambda x: x == QUIRK_CLASS + ), + True, + ), + ( + registries.MatchRule( + channel_names="on_off", quirk_classes=lambda x: x != QUIRK_CLASS + ), + False, + ), ], ) def test_registry_matching(rule, matched, channels) -> None: """Test strict rule matching.""" - assert rule.strict_matched(MANUFACTURER, MODEL, channels) is matched + assert rule.strict_matched(MANUFACTURER, MODEL, channels, QUIRK_CLASS) is matched @pytest.mark.parametrize( @@ -197,6 +236,8 @@ def test_registry_matching(rule, matched, channels) -> None: (registries.MatchRule(manufacturers=MANUFACTURER), True), (registries.MatchRule(models=MODEL), True), (registries.MatchRule(models="no match"), False), + (registries.MatchRule(quirk_classes=QUIRK_CLASS), True), + (registries.MatchRule(quirk_classes="no match"), False), # match everything ( registries.MatchRule( @@ -204,6 +245,7 @@ def test_registry_matching(rule, matched, channels) -> None: channel_names={"on_off", "level"}, manufacturers=MANUFACTURER, models=MODEL, + quirk_classes=QUIRK_CLASS, ), True, ), @@ -211,7 +253,7 @@ def test_registry_matching(rule, matched, channels) -> None: ) def test_registry_loose_matching(rule, matched, channels) -> None: """Test loose rule matching.""" - assert rule.loose_matched(MANUFACTURER, MODEL, channels) is matched + assert rule.loose_matched(MANUFACTURER, MODEL, channels, QUIRK_CLASS) is matched def test_match_rule_claim_channels_color(channel) -> None: @@ -264,18 +306,24 @@ def entity_registry(): @pytest.mark.parametrize( - ("manufacturer", "model", "match_name"), + ("manufacturer", "model", "quirk_class", "match_name"), ( - ("random manufacturer", "random model", "OnOff"), - ("random manufacturer", MODEL, "OnOffModel"), - (MANUFACTURER, "random model", "OnOffManufacturer"), - (MANUFACTURER, MODEL, "OnOffModelManufacturer"), - (MANUFACTURER, "some model", "OnOffMultimodel"), + ("random manufacturer", "random model", "random.class", "OnOff"), + ("random manufacturer", MODEL, "random.class", "OnOffModel"), + (MANUFACTURER, "random model", "random.class", "OnOffManufacturer"), + ("random manufacturer", "random model", QUIRK_CLASS, "OnOffQuirk"), + (MANUFACTURER, MODEL, "random.class", "OnOffModelManufacturer"), + (MANUFACTURER, "some model", "random.class", "OnOffMultimodel"), ), ) def test_weighted_match( - channel, entity_registry: er.EntityRegistry, manufacturer, model, match_name -) -> None: + channel, + entity_registry: er.EntityRegistry, + manufacturer, + model, + quirk_class, + match_name, +): """Test weightedd match.""" s = mock.sentinel @@ -308,11 +356,17 @@ def test_weighted_match( class OnOffModelManufacturer: pass + @entity_registry.strict_match( + s.component, channel_names="on_off", quirk_classes=QUIRK_CLASS + ) + class OnOffQuirk: + pass + ch_on_off = channel("on_off", 6) ch_level = channel("level", 8) match, claimed = entity_registry.get_entity( - s.component, manufacturer, model, [ch_on_off, ch_level] + s.component, manufacturer, model, [ch_on_off, ch_level], quirk_class ) assert match.__name__ == match_name @@ -335,7 +389,10 @@ def test_multi_sensor_match(channel, entity_registry: er.EntityRegistry) -> None ch_illuminati = channel("illuminance", 0x0401) match, claimed = entity_registry.get_multi_entity( - "manufacturer", "model", channels=[ch_se, ch_illuminati] + "manufacturer", + "model", + channels=[ch_se, ch_illuminati], + quirk_class="quirk_class", ) assert s.binary_sensor in match @@ -360,7 +417,10 @@ def test_multi_sensor_match(channel, entity_registry: er.EntityRegistry) -> None pass match, claimed = entity_registry.get_multi_entity( - "manufacturer", "model", channels={ch_se, ch_illuminati} + "manufacturer", + "model", + channels={ch_se, ch_illuminati}, + quirk_class="quirk_class", ) assert s.binary_sensor in match @@ -373,3 +433,62 @@ def test_multi_sensor_match(channel, entity_registry: er.EntityRegistry) -> None assert {cls.entity_class.__name__ for cls in match[s.component]} == { SmartEnergySensor1.__name__ } + + +def test_quirk_classes(): + """Make sure that quirk_classes in components matches are valid.""" + + def find_quirk_class(base_obj, quirk_mod, quirk_cls): + """Find a specific quirk class.""" + mods = dict(inspect.getmembers(base_obj, inspect.ismodule)) + + # Check if we have found the right module + if quirk_mod in mods: + # If so, look for the class + clss = dict(inspect.getmembers(mods[quirk_mod], inspect.isclass)) + if quirk_cls in clss: + # Quirk class found + return True + + else: + # Recurse into other modules + for mod in mods: + if not mods[mod].__name__.startswith("zhaquirks."): + continue + if find_quirk_class(mods[mod], quirk_mod, quirk_cls): + return True + return False + + def quirk_class_validator(value): + """Validate quirk classes during self test.""" + if callable(value): + # Callables cannot be tested + return + + if isinstance(value, (frozenset, set, list)): + for v in value: + # Unpack the value if needed + quirk_class_validator(v) + return + + quirk_tok = value.split(".") + if len(quirk_tok) != 2: + # quirk_class is always __module__.__class__ + raise ValueError(f"Invalid quirk class : '{value}'") + + if not find_quirk_class(zhaquirks, quirk_tok[0], quirk_tok[1]): + raise ValueError(f"Quirk class '{value}' does not exists.") + + for component in registries.ZHA_ENTITIES._strict_registry.items(): + for rule in component[1].items(): + quirk_class_validator(rule[0].quirk_classes) + + for component in registries.ZHA_ENTITIES._multi_entity_registry.items(): + for item in component[1].items(): + for rule in item[1].items(): + quirk_class_validator(rule[0].quirk_classes) + + for component in registries.ZHA_ENTITIES._config_diagnostic_entity_registry.items(): + for item in component[1].items(): + for rule in item[1].items(): + quirk_class_validator(rule[0].quirk_classes) From 48b93e03ee8b2d929c35e5b53fb6ab9d6bbb96da Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Mar 2023 16:31:12 -1000 Subject: [PATCH 0610/2810] 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 56a07a6bcf..5c0a60ecef 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 c7fc375008..90e244aaf0 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 e8008eb49b..fa5c6aac29 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 570bb980ab..61da000fc0 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -349,6 +349,52 @@ async def test_api_template(hass: HomeAssistant, mock_api_client: TestClient) -> assert body == "10" + hass.states.async_set("sensor.temperature", 20) + resp = await mock_api_client.post( + const.URL_API_TEMPLATE, + json={"template": "{{ states.sensor.temperature.state }}"}, + ) + + body = await resp.text() + + assert body == "20" + + hass.states.async_remove("sensor.temperature") + resp = await mock_api_client.post( + const.URL_API_TEMPLATE, + json={"template": "{{ states.sensor.temperature.state }}"}, + ) + + body = await resp.text() + + assert body == "" + + +async def test_api_template_cached( + hass: HomeAssistant, mock_api_client: TestClient +) -> None: + """Test the template API uses the cache.""" + hass.states.async_set("sensor.temperature", 30) + + resp = await mock_api_client.post( + const.URL_API_TEMPLATE, + json={"template": "{{ states.sensor.temperature.state }}"}, + ) + + body = await resp.text() + + assert body == "30" + + hass.states.async_set("sensor.temperature", 40) + resp = await mock_api_client.post( + const.URL_API_TEMPLATE, + json={"template": "{{ states.sensor.temperature.state }}"}, + ) + + body = await resp.text() + + assert body == "40" + async def test_api_template_error( hass: HomeAssistant, mock_api_client: TestClient From a689ce728381e254062057d17611c73a2c465ea1 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 2 Mar 2023 23:21:40 -0500 Subject: [PATCH 0611/2810] Remove unused constant (#89071) --- homeassistant/components/zwave_js/api.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 091de1949e..91b1e2a715 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -120,9 +120,6 @@ OPTED_IN = "opted_in" SECURITY_CLASSES = "security_classes" CLIENT_SIDE_AUTH = "client_side_auth" -# constants for migration -DRY_RUN = "dry_run" - # constants for inclusion INCLUSION_STRATEGY = "inclusion_strategy" From 1cb1dfa456f6d4feb7cb6cf5f8e5136fd2588bdd Mon Sep 17 00:00:00 2001 From: Emory Penney Date: Thu, 2 Mar 2023 22:31:56 -0800 Subject: [PATCH 0612/2810] Add Obihai reboot button (#88849) * Obihai: Add reboot service * Switch to button * Remove button.py from coverage * Update homeassistant/components/obihai/const.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/obihai/button.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/obihai/button.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * PR Feedback * Cleanup some typehints * As a class attr --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .coveragerc | 1 + homeassistant/components/obihai/button.py | 59 +++++++++++++++++++ .../components/obihai/connectivity.py | 2 +- homeassistant/components/obihai/const.py | 2 +- homeassistant/components/obihai/sensor.py | 3 +- 5 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/obihai/button.py diff --git a/.coveragerc b/.coveragerc index 5da330bb20..ce80bef0a9 100644 --- a/.coveragerc +++ b/.coveragerc @@ -809,6 +809,7 @@ omit = homeassistant/components/nx584/alarm_control_panel.py homeassistant/components/oasa_telematics/sensor.py homeassistant/components/obihai/__init__.py + homeassistant/components/obihai/button.py homeassistant/components/obihai/connectivity.py homeassistant/components/obihai/sensor.py homeassistant/components/octoprint/__init__.py diff --git a/homeassistant/components/obihai/button.py b/homeassistant/components/obihai/button.py new file mode 100644 index 0000000000..0b84d40f4d --- /dev/null +++ b/homeassistant/components/obihai/button.py @@ -0,0 +1,59 @@ +"""Obihai button module.""" + +from __future__ import annotations + +from pyobihai import PyObihai + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_platform + +from .connectivity import ObihaiConnection +from .const import OBIHAI + +BUTTON_DESCRIPTION = ButtonEntityDescription( + key="reboot", + name=f"{OBIHAI} Reboot", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: entity_platform.AddEntitiesCallback, +) -> None: + """Set up the Obihai sensor entries.""" + username = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + host = entry.data[CONF_HOST] + requester = ObihaiConnection(host, username, password) + + await hass.async_add_executor_job(requester.update) + buttons = [ObihaiButton(requester.pyobihai, requester.serial)] + async_add_entities(buttons, update_before_add=True) + + +class ObihaiButton(ButtonEntity): + """Obihai Reboot button.""" + + entity_description = BUTTON_DESCRIPTION + + def __init__(self, pyobihai: PyObihai, serial: str) -> None: + """Initialize monitor sensor.""" + self._pyobihai = pyobihai + self._attr_unique_id = f"{serial}-reboot" + + def press(self) -> None: + """Press button.""" + + if not self._pyobihai.call_reboot(): + raise HomeAssistantError("Reboot failed!") diff --git a/homeassistant/components/obihai/connectivity.py b/homeassistant/components/obihai/connectivity.py index 4a5c25b210..93eeccd1bb 100644 --- a/homeassistant/components/obihai/connectivity.py +++ b/homeassistant/components/obihai/connectivity.py @@ -45,7 +45,7 @@ class ObihaiConnection: self.host = host self.username = username self.password = password - self.serial: list = [] + self.serial: str self.services: list = [] self.line_services: list = [] self.call_direction: list = [] diff --git a/homeassistant/components/obihai/const.py b/homeassistant/components/obihai/const.py index 90bcd7736f..764534d479 100644 --- a/homeassistant/components/obihai/const.py +++ b/homeassistant/components/obihai/const.py @@ -12,4 +12,4 @@ OBIHAI = "Obihai" LOGGER = logging.getLogger(__package__) -PLATFORMS: Final = [Platform.SENSOR] +PLATFORMS: Final = [Platform.BUTTON, Platform.SENSOR] diff --git a/homeassistant/components/obihai/sensor.py b/homeassistant/components/obihai/sensor.py index 7524fbc7d4..61411b0ce2 100644 --- a/homeassistant/components/obihai/sensor.py +++ b/homeassistant/components/obihai/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import timedelta +from pyobihai import PyObihai import voluptuous as vol from homeassistant.components.sensor import ( @@ -89,7 +90,7 @@ async def async_setup_entry( class ObihaiServiceSensors(SensorEntity): """Get the status of each Obihai Lines.""" - def __init__(self, pyobihai, serial, service_name): + def __init__(self, pyobihai: PyObihai, serial: str, service_name: str) -> None: """Initialize monitor sensor.""" self._service_name = service_name self._state = None From 0f493d85c88094e7f79d004219d3b104f0b6e585 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 3 Mar 2023 08:32:23 +0100 Subject: [PATCH 0613/2810] Adjust xiaomi_ble tests (#89078) Adjust xiaomi_ble test docstrings --- tests/components/xiaomi_ble/test_sensor.py | 37 ++++++++++++++++------ 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/tests/components/xiaomi_ble/test_sensor.py b/tests/components/xiaomi_ble/test_sensor.py index 5dec404241..1d6344063b 100644 --- a/tests/components/xiaomi_ble/test_sensor.py +++ b/tests/components/xiaomi_ble/test_sensor.py @@ -152,8 +152,11 @@ async def test_xiaomi_battery_voltage(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_xiaomi_HHCCJCY01(hass: HomeAssistant) -> None: - """This device has multiple advertisements before all sensors are visible. Test that this works.""" +async def test_xiaomi_hhccjcy01(hass: HomeAssistant) -> None: + """Test HHCCJCY01 multiple advertisements. + + This device has multiple advertisements before all sensors are visible. + """ entry = MockConfigEntry( domain=DOMAIN, unique_id="C4:7C:8D:6A:3E:7A", @@ -230,8 +233,11 @@ async def test_xiaomi_HHCCJCY01(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_xiaomi_HHCCJCY01_not_connectable(hass: HomeAssistant) -> None: - """This device has multiple advertisements before all sensors are visible but not connectable.""" +async def test_xiaomi_hhccjcy01_not_connectable(hass: HomeAssistant) -> None: + """Test HHCCJCY01 when sensors are not connectable. + + This device has multiple advertisements before all sensors are visible but not connectable. + """ entry = MockConfigEntry( domain=DOMAIN, unique_id="C4:7C:8D:6A:3E:7A", @@ -311,10 +317,14 @@ async def test_xiaomi_HHCCJCY01_not_connectable(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_xiaomi_HHCCJCY01_only_some_sources_connectable( +async def test_xiaomi_hhccjcy01_only_some_sources_connectable( hass: HomeAssistant, ) -> None: - """This device has multiple advertisements before all sensors are visible and some sources are connectable.""" + """Test HHCCJCY01 partial sources. + + This device has multiple advertisements before all sensors are visible + and some sources are connectable. + """ entry = MockConfigEntry( domain=DOMAIN, unique_id="C4:7C:8D:6A:3E:7A", @@ -399,8 +409,12 @@ async def test_xiaomi_HHCCJCY01_only_some_sources_connectable( await hass.async_block_till_done() -async def test_xiaomi_CGDK2(hass: HomeAssistant) -> None: - """This device has encrypion so we need to retrieve its bindkey from the configentry.""" +async def test_xiaomi_cgdk2_bind_key(hass: HomeAssistant) -> None: + """Test CGDK2 bind key. + + This device has encryption so we need to retrieve its bind key + from the config entry. + """ entry = MockConfigEntry( domain=DOMAIN, unique_id="58:2D:34:12:20:89", @@ -436,8 +450,11 @@ async def test_xiaomi_CGDK2(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_hhcc_HHCCJCY10(hass: HomeAssistant) -> None: - """This device used a different UUID compared to the other Xiaomi sensors.""" +async def test_hhccjcy10_uuid(hass: HomeAssistant) -> None: + """Test HHCCJCY10 UUID. + + This device uses a different UUID compared to the other Xiaomi sensors. + """ entry = MockConfigEntry( domain=DOMAIN, unique_id="DC:23:4D:E5:5B:FC", From a5cf8210ae0080a388425bcdc7d21325d3b032c6 Mon Sep 17 00:00:00 2001 From: Thibaut Date: Fri, 3 Mar 2023 08:38:07 +0100 Subject: [PATCH 0614/2810] Move Cycle command from cover to button (#89043) Declare Cycle command as a button --- homeassistant/components/overkiz/button.py | 6 ++++++ .../components/overkiz/cover_entities/generic_cover.py | 2 -- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/overkiz/button.py b/homeassistant/components/overkiz/button.py index 23f1558b22..8388e2c3b2 100644 --- a/homeassistant/components/overkiz/button.py +++ b/homeassistant/components/overkiz/button.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from pyoverkiz.enums import OverkizCommand from pyoverkiz.types import StateType as OverkizStateType from homeassistant.components.button import ButtonEntity, ButtonEntityDescription @@ -65,6 +66,11 @@ BUTTON_DESCRIPTIONS: list[OverkizButtonDescription] = [ name="My position", icon="mdi:star", ), + OverkizButtonDescription( + key=OverkizCommand.CYCLE, + name="Toggle", + icon="mdi:sync", + ), ] SUPPORTED_COMMANDS = { diff --git a/homeassistant/components/overkiz/cover_entities/generic_cover.py b/homeassistant/components/overkiz/cover_entities/generic_cover.py index 1bc108b531..06f257d416 100644 --- a/homeassistant/components/overkiz/cover_entities/generic_cover.py +++ b/homeassistant/components/overkiz/cover_entities/generic_cover.py @@ -27,13 +27,11 @@ COMMANDS_STOP_TILT: list[OverkizCommand] = [ COMMANDS_OPEN: list[OverkizCommand] = [ OverkizCommand.OPEN, OverkizCommand.UP, - OverkizCommand.CYCLE, ] COMMANDS_OPEN_TILT: list[OverkizCommand] = [OverkizCommand.OPEN_SLATS] COMMANDS_CLOSE: list[OverkizCommand] = [ OverkizCommand.CLOSE, OverkizCommand.DOWN, - OverkizCommand.CYCLE, ] COMMANDS_CLOSE_TILT: list[OverkizCommand] = [OverkizCommand.CLOSE_SLATS] From 4a3c0cd0a874dab0f971435eb9d6345e35b2437a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 3 Mar 2023 11:26:13 +0100 Subject: [PATCH 0615/2810] Adjust docstrings for ruff D404 (#89077) --- homeassistant/components/__init__.py | 2 +- homeassistant/components/actiontec/device_tracker.py | 2 +- homeassistant/components/alexa/errors.py | 2 +- homeassistant/components/arris_tg2492lg/device_tracker.py | 2 +- homeassistant/components/aruba/device_tracker.py | 2 +- homeassistant/components/bbox/device_tracker.py | 2 +- homeassistant/components/bt_home_hub_5/device_tracker.py | 2 +- homeassistant/components/bt_smarthub/device_tracker.py | 2 +- homeassistant/components/cisco_ios/device_tracker.py | 2 +- .../components/cisco_mobility_express/device_tracker.py | 2 +- homeassistant/components/ddwrt/device_tracker.py | 2 +- homeassistant/components/demo/geo_location.py | 2 +- homeassistant/components/forked_daapd/media_player.py | 2 +- homeassistant/components/fortios/device_tracker.py | 2 +- homeassistant/components/foscam/camera.py | 2 +- homeassistant/components/fritz/device_tracker.py | 2 +- homeassistant/components/fritzbox_callmonitor/base.py | 2 +- homeassistant/components/gdacs/geo_location.py | 2 +- homeassistant/components/gdacs/sensor.py | 2 +- homeassistant/components/geo_json_events/geo_location.py | 2 +- homeassistant/components/geonetnz_quakes/geo_location.py | 2 +- homeassistant/components/geonetnz_quakes/sensor.py | 2 +- homeassistant/components/geonetnz_volcano/sensor.py | 2 +- homeassistant/components/group/binary_sensor.py | 2 +- homeassistant/components/group/cover.py | 2 +- homeassistant/components/group/fan.py | 2 +- homeassistant/components/group/light.py | 2 +- homeassistant/components/group/lock.py | 2 +- homeassistant/components/group/media_player.py | 2 +- homeassistant/components/group/sensor.py | 2 +- homeassistant/components/group/switch.py | 2 +- homeassistant/components/hitron_coda/device_tracker.py | 2 +- homeassistant/components/ign_sismologia/geo_location.py | 2 +- homeassistant/components/intellifire/fan.py | 2 +- homeassistant/components/intellifire/light.py | 2 +- homeassistant/components/linksys_smart/device_tracker.py | 2 +- homeassistant/components/lirc/__init__.py | 2 +- homeassistant/components/luci/device_tracker.py | 2 +- homeassistant/components/mqtt/alarm_control_panel.py | 2 +- homeassistant/components/nmap_tracker/__init__.py | 2 +- .../components/nsw_rural_fire_service_feed/geo_location.py | 2 +- homeassistant/components/opnsense/device_tracker.py | 2 +- homeassistant/components/qld_bushfire/geo_location.py | 2 +- homeassistant/components/quantum_gateway/device_tracker.py | 2 +- homeassistant/components/rainmachine/binary_sensor.py | 2 +- homeassistant/components/rainmachine/sensor.py | 2 +- homeassistant/components/rainmachine/switch.py | 2 +- homeassistant/components/reolink/binary_sensor.py | 2 +- homeassistant/components/reolink/camera.py | 2 +- homeassistant/components/reolink/host.py | 2 +- homeassistant/components/reolink/number.py | 2 +- homeassistant/components/ring/binary_sensor.py | 2 +- homeassistant/components/ring/camera.py | 2 +- homeassistant/components/ring/light.py | 2 +- homeassistant/components/ring/sensor.py | 2 +- homeassistant/components/ring/siren.py | 2 +- homeassistant/components/ring/switch.py | 2 +- homeassistant/components/sky_hub/device_tracker.py | 2 +- homeassistant/components/stookalert/binary_sensor.py | 2 +- homeassistant/components/stookwijzer/sensor.py | 2 +- homeassistant/components/swisscom/device_tracker.py | 2 +- homeassistant/components/synology_srm/device_tracker.py | 2 +- homeassistant/components/tado/device_tracker.py | 2 +- homeassistant/components/thomson/device_tracker.py | 2 +- homeassistant/components/tomato/device_tracker.py | 2 +- homeassistant/components/travisci/sensor.py | 2 +- homeassistant/components/ubus/device_tracker.py | 2 +- homeassistant/components/unifi_direct/device_tracker.py | 2 +- homeassistant/components/unifiprotect/binary_sensor.py | 2 +- homeassistant/components/unifiprotect/light.py | 2 +- homeassistant/components/unifiprotect/number.py | 2 +- homeassistant/components/unifiprotect/select.py | 2 +- homeassistant/components/unifiprotect/sensor.py | 2 +- homeassistant/components/unifiprotect/switch.py | 2 +- homeassistant/components/upc_connect/device_tracker.py | 2 +- homeassistant/components/usgs_earthquakes_feed/geo_location.py | 2 +- homeassistant/components/xiaomi/camera.py | 2 +- homeassistant/components/xiaomi/device_tracker.py | 2 +- homeassistant/components/xiaomi_miio/device_tracker.py | 2 +- 79 files changed, 79 insertions(+), 79 deletions(-) diff --git a/homeassistant/components/__init__.py b/homeassistant/components/__init__.py index d0e631fb04..690b38b487 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -1,4 +1,4 @@ -"""This package contains components that can be plugged into Home Assistant. +"""Contains components that can be plugged into Home Assistant. Component design guidelines: - Each component defines a constant DOMAIN that is equal to its filename. diff --git a/homeassistant/components/actiontec/device_tracker.py b/homeassistant/components/actiontec/device_tracker.py index 9c18e2ba90..5397fed5e1 100644 --- a/homeassistant/components/actiontec/device_tracker.py +++ b/homeassistant/components/actiontec/device_tracker.py @@ -40,7 +40,7 @@ def get_scanner( class ActiontecDeviceScanner(DeviceScanner): - """This class queries an actiontec router for connected devices.""" + """Class which queries an actiontec router for connected devices.""" def __init__(self, config: ConfigType) -> None: """Initialize the scanner.""" diff --git a/homeassistant/components/alexa/errors.py b/homeassistant/components/alexa/errors.py index 5f0de6f746..7f4b41b9ec 100644 --- a/homeassistant/components/alexa/errors.py +++ b/homeassistant/components/alexa/errors.py @@ -9,7 +9,7 @@ from .const import API_TEMP_UNITS class UnsupportedProperty(HomeAssistantError): - """This entity does not support the requested Smart Home API property.""" + """Does not support the requested Smart Home API property.""" class NoTokenAvailable(HomeAssistantError): diff --git a/homeassistant/components/arris_tg2492lg/device_tracker.py b/homeassistant/components/arris_tg2492lg/device_tracker.py index b456aa3f70..48b8d9f13c 100644 --- a/homeassistant/components/arris_tg2492lg/device_tracker.py +++ b/homeassistant/components/arris_tg2492lg/device_tracker.py @@ -33,7 +33,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> ArrisDeviceScanner: class ArrisDeviceScanner(DeviceScanner): - """This class queries a Arris TG2492LG router for connected devices.""" + """Class which queries a Arris TG2492LG router for connected devices.""" def __init__(self, connect_box: ConnectBox) -> None: """Initialize the scanner.""" diff --git a/homeassistant/components/aruba/device_tracker.py b/homeassistant/components/aruba/device_tracker.py index d0794553b4..7b8c547fd5 100644 --- a/homeassistant/components/aruba/device_tracker.py +++ b/homeassistant/components/aruba/device_tracker.py @@ -42,7 +42,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> ArubaDeviceScanner | class ArubaDeviceScanner(DeviceScanner): - """This class queries a Aruba Access Point for connected devices.""" + """Class which queries a Aruba Access Point for connected devices.""" def __init__(self, config): """Initialize the scanner.""" diff --git a/homeassistant/components/bbox/device_tracker.py b/homeassistant/components/bbox/device_tracker.py index a9b0312673..9c83aaa173 100644 --- a/homeassistant/components/bbox/device_tracker.py +++ b/homeassistant/components/bbox/device_tracker.py @@ -42,7 +42,7 @@ Device = namedtuple("Device", ["mac", "name", "ip", "last_update"]) class BboxDeviceScanner(DeviceScanner): - """This class scans for devices connected to the bbox.""" + """Scanner for devices connected to the bbox.""" def __init__(self, config): """Get host from config.""" diff --git a/homeassistant/components/bt_home_hub_5/device_tracker.py b/homeassistant/components/bt_home_hub_5/device_tracker.py index 4d89c85124..0ffa3bc699 100644 --- a/homeassistant/components/bt_home_hub_5/device_tracker.py +++ b/homeassistant/components/bt_home_hub_5/device_tracker.py @@ -35,7 +35,7 @@ def get_scanner( class BTHomeHub5DeviceScanner(DeviceScanner): - """This class queries a BT Home Hub 5.""" + """Class which queries a BT Home Hub 5.""" def __init__(self, config): """Initialise the scanner.""" diff --git a/homeassistant/components/bt_smarthub/device_tracker.py b/homeassistant/components/bt_smarthub/device_tracker.py index 48475bbeac..65aa1bd6a6 100644 --- a/homeassistant/components/bt_smarthub/device_tracker.py +++ b/homeassistant/components/bt_smarthub/device_tracker.py @@ -54,7 +54,7 @@ _Device = namedtuple("_Device", ["ip_address", "mac", "host", "status", "name"]) class BTSmartHubScanner(DeviceScanner): - """This class queries a BT Smart Hub.""" + """Class which queries a BT Smart Hub.""" def __init__(self, smarthub_client): """Initialise the scanner.""" diff --git a/homeassistant/components/cisco_ios/device_tracker.py b/homeassistant/components/cisco_ios/device_tracker.py index 508b2b2d8b..1424d41006 100644 --- a/homeassistant/components/cisco_ios/device_tracker.py +++ b/homeassistant/components/cisco_ios/device_tracker.py @@ -39,7 +39,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> CiscoDeviceScanner | class CiscoDeviceScanner(DeviceScanner): - """This class queries a wireless router running Cisco IOS firmware.""" + """Class which queries a wireless router running Cisco IOS firmware.""" def __init__(self, config): """Initialize the scanner.""" diff --git a/homeassistant/components/cisco_mobility_express/device_tracker.py b/homeassistant/components/cisco_mobility_express/device_tracker.py index 9ce98ec4fe..a5ca469d10 100644 --- a/homeassistant/components/cisco_mobility_express/device_tracker.py +++ b/homeassistant/components/cisco_mobility_express/device_tracker.py @@ -56,7 +56,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> CiscoMEDeviceScanner class CiscoMEDeviceScanner(DeviceScanner): - """This class scans for devices associated to a Cisco ME controller.""" + """Scanner for devices associated to a Cisco ME controller.""" def __init__(self, controller): """Initialize the scanner.""" diff --git a/homeassistant/components/ddwrt/device_tracker.py b/homeassistant/components/ddwrt/device_tracker.py index ba34ec48e0..7874786adb 100644 --- a/homeassistant/components/ddwrt/device_tracker.py +++ b/homeassistant/components/ddwrt/device_tracker.py @@ -55,7 +55,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> DdWrtDeviceScanner | class DdWrtDeviceScanner(DeviceScanner): - """This class queries a wireless router running DD-WRT firmware.""" + """Class which queries a wireless router running DD-WRT firmware.""" def __init__(self, config): """Initialize the DD-WRT scanner.""" diff --git a/homeassistant/components/demo/geo_location.py b/homeassistant/components/demo/geo_location.py index cc29205b72..2af7437e0f 100644 --- a/homeassistant/components/demo/geo_location.py +++ b/homeassistant/components/demo/geo_location.py @@ -110,7 +110,7 @@ class DemoManager: class DemoGeolocationEvent(GeolocationEvent): - """This represents a demo geolocation event.""" + """Represents a demo geolocation event.""" _attr_should_poll = False diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index d42c72b65d..ca7e0cce27 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -1,4 +1,4 @@ -"""This library brings support for forked_daapd to Home Assistant.""" +"""Support forked_daapd media player.""" from __future__ import annotations import asyncio diff --git a/homeassistant/components/fortios/device_tracker.py b/homeassistant/components/fortios/device_tracker.py index 65f63829c0..95a418ae40 100644 --- a/homeassistant/components/fortios/device_tracker.py +++ b/homeassistant/components/fortios/device_tracker.py @@ -67,7 +67,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> FortiOSDeviceScanner class FortiOSDeviceScanner(DeviceScanner): - """This class queries a FortiOS unit for connected devices.""" + """Class which queries a FortiOS unit for connected devices.""" def __init__(self, fgt) -> None: """Initialize the scanner.""" diff --git a/homeassistant/components/foscam/camera.py b/homeassistant/components/foscam/camera.py index fe11b05688..ae28fd8d11 100644 --- a/homeassistant/components/foscam/camera.py +++ b/homeassistant/components/foscam/camera.py @@ -1,4 +1,4 @@ -"""This component provides basic support for Foscam IP cameras.""" +"""Component providing basic support for Foscam IP cameras.""" from __future__ import annotations import asyncio diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py index 212710a638..e32ee15279 100644 --- a/homeassistant/components/fritz/device_tracker.py +++ b/homeassistant/components/fritz/device_tracker.py @@ -68,7 +68,7 @@ def _async_add_entities( class FritzBoxTracker(FritzDeviceBase, ScannerEntity): - """This class queries a FRITZ!Box device.""" + """Class which queries a FRITZ!Box device.""" def __init__(self, avm_wrapper: AvmWrapper, device: FritzDevice) -> None: """Initialize a FRITZ!Box device.""" diff --git a/homeassistant/components/fritzbox_callmonitor/base.py b/homeassistant/components/fritzbox_callmonitor/base.py index 386e60ba19..df19bca7b1 100644 --- a/homeassistant/components/fritzbox_callmonitor/base.py +++ b/homeassistant/components/fritzbox_callmonitor/base.py @@ -19,7 +19,7 @@ MIN_TIME_PHONEBOOK_UPDATE = timedelta(hours=6) class FritzBoxPhonebook: - """This connects to a FritzBox router and downloads its phone book.""" + """Connects to a FritzBox router and downloads its phone book.""" fph: FritzPhonebook phonebook_dict: dict[str, list[str]] diff --git a/homeassistant/components/gdacs/geo_location.py b/homeassistant/components/gdacs/geo_location.py index 06ab1aa083..1d3dabc464 100644 --- a/homeassistant/components/gdacs/geo_location.py +++ b/homeassistant/components/gdacs/geo_location.py @@ -77,7 +77,7 @@ async def async_setup_entry( class GdacsEvent(GeolocationEvent): - """This represents an external event with GDACS feed data.""" + """Represents an external event with GDACS feed data.""" _attr_should_poll = False _attr_source = SOURCE diff --git a/homeassistant/components/gdacs/sensor.py b/homeassistant/components/gdacs/sensor.py index 531eb05dcf..6563e26368 100644 --- a/homeassistant/components/gdacs/sensor.py +++ b/homeassistant/components/gdacs/sensor.py @@ -39,7 +39,7 @@ async def async_setup_entry( class GdacsSensor(SensorEntity): - """This is a status sensor for the GDACS integration.""" + """Status sensor for the GDACS integration.""" _attr_should_poll = False diff --git a/homeassistant/components/geo_json_events/geo_location.py b/homeassistant/components/geo_json_events/geo_location.py index 166da1184c..74951bc3a9 100644 --- a/homeassistant/components/geo_json_events/geo_location.py +++ b/homeassistant/components/geo_json_events/geo_location.py @@ -142,7 +142,7 @@ class GeoJsonFeedEntityManager: class GeoJsonLocationEvent(GeolocationEvent): - """This represents an external event with GeoJSON data.""" + """Represents an external event with GeoJSON data.""" _attr_should_poll = False _attr_source = SOURCE diff --git a/homeassistant/components/geonetnz_quakes/geo_location.py b/homeassistant/components/geonetnz_quakes/geo_location.py index 411a037546..6fa84f590f 100644 --- a/homeassistant/components/geonetnz_quakes/geo_location.py +++ b/homeassistant/components/geonetnz_quakes/geo_location.py @@ -65,7 +65,7 @@ async def async_setup_entry( class GeonetnzQuakesEvent(GeolocationEvent): - """This represents an external event with GeoNet NZ Quakes feed data.""" + """Represents an external event with GeoNet NZ Quakes feed data.""" _attr_icon = "mdi:pulse" _attr_should_poll = False diff --git a/homeassistant/components/geonetnz_quakes/sensor.py b/homeassistant/components/geonetnz_quakes/sensor.py index 9183aead16..8fb2ff8535 100644 --- a/homeassistant/components/geonetnz_quakes/sensor.py +++ b/homeassistant/components/geonetnz_quakes/sensor.py @@ -40,7 +40,7 @@ async def async_setup_entry( class GeonetnzQuakesSensor(SensorEntity): - """This is a status sensor for the GeoNet NZ Quakes integration.""" + """Status sensor for the GeoNet NZ Quakes integration.""" _attr_should_poll = False diff --git a/homeassistant/components/geonetnz_volcano/sensor.py b/homeassistant/components/geonetnz_volcano/sensor.py index 25e02f4430..33a879eeb2 100644 --- a/homeassistant/components/geonetnz_volcano/sensor.py +++ b/homeassistant/components/geonetnz_volcano/sensor.py @@ -54,7 +54,7 @@ async def async_setup_entry( class GeonetnzVolcanoSensor(SensorEntity): - """This represents an external event with GeoNet NZ Volcano feed data.""" + """Represents an external event with GeoNet NZ Volcano feed data.""" _attr_should_poll = False diff --git a/homeassistant/components/group/binary_sensor.py b/homeassistant/components/group/binary_sensor.py index 815e3b76f0..112b111bdc 100644 --- a/homeassistant/components/group/binary_sensor.py +++ b/homeassistant/components/group/binary_sensor.py @@ -1,4 +1,4 @@ -"""This platform allows several binary sensor to be grouped into one binary sensor.""" +"""Platform allowing several binary sensor to be grouped into one binary sensor.""" from __future__ import annotations import voluptuous as vol diff --git a/homeassistant/components/group/cover.py b/homeassistant/components/group/cover.py index 2ecfbeaca4..38928302eb 100644 --- a/homeassistant/components/group/cover.py +++ b/homeassistant/components/group/cover.py @@ -1,4 +1,4 @@ -"""This platform allows several cover to be grouped into one cover.""" +"""Platform allowing several cover to be grouped into one cover.""" from __future__ import annotations from typing import Any diff --git a/homeassistant/components/group/fan.py b/homeassistant/components/group/fan.py index 682890dddd..0c4c59d245 100644 --- a/homeassistant/components/group/fan.py +++ b/homeassistant/components/group/fan.py @@ -1,4 +1,4 @@ -"""This platform allows several fans to be grouped into one fan.""" +"""Platform allowing several fans to be grouped into one fan.""" from __future__ import annotations from functools import reduce diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py index 6315e79d61..33d240a9a4 100644 --- a/homeassistant/components/group/light.py +++ b/homeassistant/components/group/light.py @@ -1,4 +1,4 @@ -"""This platform allows several lights to be grouped into one light.""" +"""Platform allowing several lights to be grouped into one light.""" from __future__ import annotations from collections import Counter diff --git a/homeassistant/components/group/lock.py b/homeassistant/components/group/lock.py index 9c39e14552..07d08c7851 100644 --- a/homeassistant/components/group/lock.py +++ b/homeassistant/components/group/lock.py @@ -1,4 +1,4 @@ -"""This platform allows several locks to be grouped into one lock.""" +"""Platform allowing several locks to be grouped into one lock.""" from __future__ import annotations import logging diff --git a/homeassistant/components/group/media_player.py b/homeassistant/components/group/media_player.py index a349a62800..3766c64cae 100644 --- a/homeassistant/components/group/media_player.py +++ b/homeassistant/components/group/media_player.py @@ -1,4 +1,4 @@ -"""This platform allows several media players to be grouped into one media player.""" +"""Platform allowing several media players to be grouped into one media player.""" from __future__ import annotations from contextlib import suppress diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py index 6c379832ce..265e1640d0 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -1,4 +1,4 @@ -"""This platform allows several sensors to be grouped into one sensor to provide numeric combinations.""" +"""Platform allowing several sensors to be grouped into one sensor to provide numeric combinations.""" from __future__ import annotations from collections.abc import Callable diff --git a/homeassistant/components/group/switch.py b/homeassistant/components/group/switch.py index 8b60e1f140..4b6b959ba1 100644 --- a/homeassistant/components/group/switch.py +++ b/homeassistant/components/group/switch.py @@ -1,4 +1,4 @@ -"""This platform allows several switches to be grouped into one switch.""" +"""Platform allowing several switches to be grouped into one switch.""" from __future__ import annotations import logging diff --git a/homeassistant/components/hitron_coda/device_tracker.py b/homeassistant/components/hitron_coda/device_tracker.py index c9ee93634b..df1189f9e7 100644 --- a/homeassistant/components/hitron_coda/device_tracker.py +++ b/homeassistant/components/hitron_coda/device_tracker.py @@ -45,7 +45,7 @@ Device = namedtuple("Device", ["mac", "name"]) class HitronCODADeviceScanner(DeviceScanner): - """This class scans for devices using the CODA's web interface.""" + """Scanner for devices using the CODA's web interface.""" def __init__(self, config): """Initialize the scanner.""" diff --git a/homeassistant/components/ign_sismologia/geo_location.py b/homeassistant/components/ign_sismologia/geo_location.py index e78dafae8e..794da41ea1 100644 --- a/homeassistant/components/ign_sismologia/geo_location.py +++ b/homeassistant/components/ign_sismologia/geo_location.py @@ -141,7 +141,7 @@ class IgnSismologiaFeedEntityManager: class IgnSismologiaLocationEvent(GeolocationEvent): - """This represents an external event with IGN Sismologia feed data.""" + """Represents an external event with IGN Sismologia feed data.""" _attr_icon = "mdi:pulse" _attr_should_poll = False diff --git a/homeassistant/components/intellifire/fan.py b/homeassistant/components/intellifire/fan.py index 0f43856938..aa74480fef 100644 --- a/homeassistant/components/intellifire/fan.py +++ b/homeassistant/components/intellifire/fan.py @@ -72,7 +72,7 @@ async def async_setup_entry( class IntellifireFan(IntellifireEntity, FanEntity): - """This is Fan entity for the fireplace.""" + """Fan entity for the fireplace.""" entity_description: IntellifireFanEntityDescription _attr_supported_features = FanEntityFeature.SET_SPEED diff --git a/homeassistant/components/intellifire/light.py b/homeassistant/components/intellifire/light.py index f1fd81ab45..5e7d5735a6 100644 --- a/homeassistant/components/intellifire/light.py +++ b/homeassistant/components/intellifire/light.py @@ -49,7 +49,7 @@ INTELLIFIRE_LIGHTS: tuple[IntellifireLightEntityDescription, ...] = ( class IntellifireLight(IntellifireEntity, LightEntity): - """This is a Light entity for the fireplace.""" + """Light entity for the fireplace.""" entity_description: IntellifireLightEntityDescription _attr_color_mode = ColorMode.BRIGHTNESS diff --git a/homeassistant/components/linksys_smart/device_tracker.py b/homeassistant/components/linksys_smart/device_tracker.py index 3b0aeffaa6..d0440c832c 100644 --- a/homeassistant/components/linksys_smart/device_tracker.py +++ b/homeassistant/components/linksys_smart/device_tracker.py @@ -35,7 +35,7 @@ def get_scanner( class LinksysSmartWifiDeviceScanner(DeviceScanner): - """This class queries a Linksys Access Point.""" + """Class which queries a Linksys Access Point.""" def __init__(self, config): """Initialize the scanner.""" diff --git a/homeassistant/components/lirc/__init__.py b/homeassistant/components/lirc/__init__.py index c5ebf87468..cf76213a88 100644 --- a/homeassistant/components/lirc/__init__.py +++ b/homeassistant/components/lirc/__init__.py @@ -42,7 +42,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: class LircInterface(threading.Thread): - """This interfaces with the lirc daemon to read IR commands. + """Interfaces with the lirc daemon to read IR commands. When using lirc in blocking mode, sometimes repeated commands get produced in the next read of a command so we use a thread here to just wait diff --git a/homeassistant/components/luci/device_tracker.py b/homeassistant/components/luci/device_tracker.py index d18ecf8bd4..f4ebe4376f 100644 --- a/homeassistant/components/luci/device_tracker.py +++ b/homeassistant/components/luci/device_tracker.py @@ -46,7 +46,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> LuciDeviceScanner | class LuciDeviceScanner(DeviceScanner): - """This class scans for devices connected to an OpenWrt router.""" + """Scanner for devices connected to an OpenWrt router.""" def __init__(self, config): """Initialize the scanner.""" diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 8651311328..b685daaf6f 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -1,4 +1,4 @@ -"""This platform enables the possibility to control a MQTT alarm.""" +"""Control a MQTT alarm.""" from __future__ import annotations import functools diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index 827fb93a01..0dafff996d 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -132,7 +132,7 @@ def signal_device_update(mac_address) -> str: class NmapDeviceScanner: - """This class scans for devices using nmap.""" + """Scanner for devices using nmap.""" def __init__( self, hass: HomeAssistant, entry: ConfigEntry, devices: NmapTrackedDevices diff --git a/homeassistant/components/nsw_rural_fire_service_feed/geo_location.py b/homeassistant/components/nsw_rural_fire_service_feed/geo_location.py index 3eb598ffd3..28e056e29f 100644 --- a/homeassistant/components/nsw_rural_fire_service_feed/geo_location.py +++ b/homeassistant/components/nsw_rural_fire_service_feed/geo_location.py @@ -177,7 +177,7 @@ class NswRuralFireServiceFeedEntityManager: class NswRuralFireServiceLocationEvent(GeolocationEvent): - """This represents an external event with NSW Rural Fire Service data.""" + """Represents an external event with NSW Rural Fire Service data.""" _attr_should_poll = False _attr_source = SOURCE diff --git a/homeassistant/components/opnsense/device_tracker.py b/homeassistant/components/opnsense/device_tracker.py index b5c75f1cc2..527856ed56 100644 --- a/homeassistant/components/opnsense/device_tracker.py +++ b/homeassistant/components/opnsense/device_tracker.py @@ -20,7 +20,7 @@ async def async_get_scanner( class OPNSenseDeviceScanner(DeviceScanner): - """This class queries a router running OPNsense.""" + """Class which queries a router running OPNsense.""" def __init__(self, client, interfaces): """Initialize the scanner.""" diff --git a/homeassistant/components/qld_bushfire/geo_location.py b/homeassistant/components/qld_bushfire/geo_location.py index fc9fd72761..1adddc4855 100644 --- a/homeassistant/components/qld_bushfire/geo_location.py +++ b/homeassistant/components/qld_bushfire/geo_location.py @@ -149,7 +149,7 @@ class QldBushfireFeedEntityManager: class QldBushfireLocationEvent(GeolocationEvent): - """This represents an external event with Qld Bushfire feed data.""" + """Represents an external event with Qld Bushfire feed data.""" _attr_icon = "mdi:fire" _attr_should_poll = False diff --git a/homeassistant/components/quantum_gateway/device_tracker.py b/homeassistant/components/quantum_gateway/device_tracker.py index 076c1d2722..c8e23b6841 100644 --- a/homeassistant/components/quantum_gateway/device_tracker.py +++ b/homeassistant/components/quantum_gateway/device_tracker.py @@ -40,7 +40,7 @@ def get_scanner( class QuantumGatewayDeviceScanner(DeviceScanner): - """This class queries a Quantum Gateway.""" + """Class which queries a Quantum Gateway.""" def __init__(self, config): """Initialize the scanner.""" diff --git a/homeassistant/components/rainmachine/binary_sensor.py b/homeassistant/components/rainmachine/binary_sensor.py index 5815c0ce12..33650cfc2f 100644 --- a/homeassistant/components/rainmachine/binary_sensor.py +++ b/homeassistant/components/rainmachine/binary_sensor.py @@ -1,4 +1,4 @@ -"""This platform provides binary sensors for key RainMachine data.""" +"""Binary sensors for key RainMachine data.""" from dataclasses import dataclass from homeassistant.components.binary_sensor import ( diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index 3d56ff59fc..22943d73fc 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -1,4 +1,4 @@ -"""This platform provides support for sensor data from RainMachine.""" +"""Support for sensor data from RainMachine.""" from __future__ import annotations from dataclasses import dataclass diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py index ae445d8278..60db508595 100644 --- a/homeassistant/components/rainmachine/switch.py +++ b/homeassistant/components/rainmachine/switch.py @@ -1,4 +1,4 @@ -"""This component provides support for RainMachine programs and zones.""" +"""Component providing support for RainMachine programs and zones.""" from __future__ import annotations import asyncio diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 541ad9ec99..3c97087c89 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -1,4 +1,4 @@ -"""This component provides support for Reolink binary sensors.""" +"""Component providing support for Reolink binary sensors.""" from __future__ import annotations from collections.abc import Callable diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index d14906a578..4a270d6f5a 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -1,4 +1,4 @@ -"""This component provides support for Reolink IP cameras.""" +"""Component providing support for Reolink IP cameras.""" from __future__ import annotations import logging diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 73c0e70812..9994afe79a 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -1,4 +1,4 @@ -"""This component encapsulates the NVR/camera API and subscription.""" +"""Module which encapsulates the NVR/camera API and subscription.""" from __future__ import annotations import asyncio diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index e9b692fffe..c1baf4b156 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -1,4 +1,4 @@ -"""This component provides support for Reolink number entities.""" +"""Component providing support for Reolink number entities.""" from __future__ import annotations from collections.abc import Callable diff --git a/homeassistant/components/ring/binary_sensor.py b/homeassistant/components/ring/binary_sensor.py index 06872cc733..d2c01bbd4f 100644 --- a/homeassistant/components/ring/binary_sensor.py +++ b/homeassistant/components/ring/binary_sensor.py @@ -1,4 +1,4 @@ -"""This component provides HA sensor support for Ring Door Bell/Chimes.""" +"""Component providing HA sensor support for Ring Door Bell/Chimes.""" from __future__ import annotations from dataclasses import dataclass diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index f5d70a86cb..e99fabfab2 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -1,4 +1,4 @@ -"""This component provides support to the Ring Door Bell camera.""" +"""Component providing support to the Ring Door Bell camera.""" from __future__ import annotations from datetime import timedelta diff --git a/homeassistant/components/ring/light.py b/homeassistant/components/ring/light.py index e6b29b94fb..143c333f60 100644 --- a/homeassistant/components/ring/light.py +++ b/homeassistant/components/ring/light.py @@ -1,4 +1,4 @@ -"""This component provides HA switch support for Ring Door Bell/Chimes.""" +"""Component providing HA switch support for Ring Door Bell/Chimes.""" from datetime import timedelta import logging from typing import Any diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index 027eccb1c3..3d198ce757 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -1,4 +1,4 @@ -"""This component provides HA sensor support for Ring Door Bell/Chimes.""" +"""Component providing HA sensor support for Ring Door Bell/Chimes.""" from __future__ import annotations from dataclasses import dataclass diff --git a/homeassistant/components/ring/siren.py b/homeassistant/components/ring/siren.py index b83d3e7b2a..626444a9dc 100644 --- a/homeassistant/components/ring/siren.py +++ b/homeassistant/components/ring/siren.py @@ -1,4 +1,4 @@ -"""This component provides HA Siren support for Ring Chimes.""" +"""Component providing HA Siren support for Ring Chimes.""" import logging from typing import Any diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index 0fa6e3b111..9a3c80114e 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -1,4 +1,4 @@ -"""This component provides HA switch support for Ring Door Bell/Chimes.""" +"""Component providing HA switch support for Ring Door Bell/Chimes.""" from datetime import timedelta import logging from typing import Any diff --git a/homeassistant/components/sky_hub/device_tracker.py b/homeassistant/components/sky_hub/device_tracker.py index 65d806a9bc..8741b2ed56 100644 --- a/homeassistant/components/sky_hub/device_tracker.py +++ b/homeassistant/components/sky_hub/device_tracker.py @@ -39,7 +39,7 @@ async def async_get_scanner( class SkyHubDeviceScanner(DeviceScanner): - """This class queries a Sky Hub router.""" + """Class which queries a Sky Hub router.""" def __init__(self, hub): """Initialise the scanner.""" diff --git a/homeassistant/components/stookalert/binary_sensor.py b/homeassistant/components/stookalert/binary_sensor.py index 70a25c2bfd..d3920d3f0e 100644 --- a/homeassistant/components/stookalert/binary_sensor.py +++ b/homeassistant/components/stookalert/binary_sensor.py @@ -1,4 +1,4 @@ -"""This integration provides support for Stookalert Binary Sensor.""" +"""Support for Stookalert Binary Sensor.""" from __future__ import annotations from datetime import timedelta diff --git a/homeassistant/components/stookwijzer/sensor.py b/homeassistant/components/stookwijzer/sensor.py index 9eb70fda7e..cd84bec11b 100644 --- a/homeassistant/components/stookwijzer/sensor.py +++ b/homeassistant/components/stookwijzer/sensor.py @@ -1,4 +1,4 @@ -"""This integration provides support for Stookwijzer Sensor.""" +"""Support for Stookwijzer Sensor.""" from __future__ import annotations from datetime import timedelta diff --git a/homeassistant/components/swisscom/device_tracker.py b/homeassistant/components/swisscom/device_tracker.py index 29da03b262..900117a54b 100644 --- a/homeassistant/components/swisscom/device_tracker.py +++ b/homeassistant/components/swisscom/device_tracker.py @@ -36,7 +36,7 @@ def get_scanner( class SwisscomDeviceScanner(DeviceScanner): - """This class queries a router running Swisscom Internet-Box firmware.""" + """Class which queries a router running Swisscom Internet-Box firmware.""" def __init__(self, config): """Initialize the scanner.""" diff --git a/homeassistant/components/synology_srm/device_tracker.py b/homeassistant/components/synology_srm/device_tracker.py index 15c61ff0a3..e67f7ecf34 100644 --- a/homeassistant/components/synology_srm/device_tracker.py +++ b/homeassistant/components/synology_srm/device_tracker.py @@ -80,7 +80,7 @@ def get_scanner( class SynologySrmDeviceScanner(DeviceScanner): - """This class scans for devices connected to a Synology SRM router.""" + """Scanner for devices connected to a Synology SRM router.""" def __init__(self, config): """Initialize the scanner.""" diff --git a/homeassistant/components/tado/device_tracker.py b/homeassistant/components/tado/device_tracker.py index 72eb9c8e28..4d50bc35c3 100644 --- a/homeassistant/components/tado/device_tracker.py +++ b/homeassistant/components/tado/device_tracker.py @@ -48,7 +48,7 @@ Device = namedtuple("Device", ["mac", "name"]) class TadoDeviceScanner(DeviceScanner): - """This class gets geofenced devices from Tado.""" + """Scanner for geofenced devices from Tado.""" def __init__(self, hass, config): """Initialize the scanner.""" diff --git a/homeassistant/components/thomson/device_tracker.py b/homeassistant/components/thomson/device_tracker.py index 4af21ec8e1..e42ee4478e 100644 --- a/homeassistant/components/thomson/device_tracker.py +++ b/homeassistant/components/thomson/device_tracker.py @@ -46,7 +46,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> ThomsonDeviceScanner class ThomsonDeviceScanner(DeviceScanner): - """This class queries a router running THOMSON firmware.""" + """Class which queries a router running THOMSON firmware.""" def __init__(self, config): """Initialize the scanner.""" diff --git a/homeassistant/components/tomato/device_tracker.py b/homeassistant/components/tomato/device_tracker.py index e10bc3b81d..da64157dad 100644 --- a/homeassistant/components/tomato/device_tracker.py +++ b/homeassistant/components/tomato/device_tracker.py @@ -49,7 +49,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> TomatoDeviceScanner: class TomatoDeviceScanner(DeviceScanner): - """This class queries a wireless router running Tomato firmware.""" + """Class which queries a wireless router running Tomato firmware.""" def __init__(self, config): """Initialize the scanner.""" diff --git a/homeassistant/components/travisci/sensor.py b/homeassistant/components/travisci/sensor.py index ec62da3765..6a30c1b62b 100644 --- a/homeassistant/components/travisci/sensor.py +++ b/homeassistant/components/travisci/sensor.py @@ -1,4 +1,4 @@ -"""This component provides HA sensor support for Travis CI framework.""" +"""Component providing HA sensor support for Travis CI framework.""" from __future__ import annotations from datetime import timedelta diff --git a/homeassistant/components/ubus/device_tracker.py b/homeassistant/components/ubus/device_tracker.py index 20b0ad6593..48d5b4bd6f 100644 --- a/homeassistant/components/ubus/device_tracker.py +++ b/homeassistant/components/ubus/device_tracker.py @@ -68,7 +68,7 @@ def _refresh_on_access_denied(func): class UbusDeviceScanner(DeviceScanner): - """This class queries a wireless router running OpenWrt firmware. + """Class which queries a wireless router running OpenWrt firmware. Adapted from Tomato scanner. """ diff --git a/homeassistant/components/unifi_direct/device_tracker.py b/homeassistant/components/unifi_direct/device_tracker.py index 42f83dad5d..13ebd0e33e 100644 --- a/homeassistant/components/unifi_direct/device_tracker.py +++ b/homeassistant/components/unifi_direct/device_tracker.py @@ -43,7 +43,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> UnifiDeviceScanner | class UnifiDeviceScanner(DeviceScanner): - """This class queries Unifi wireless access point.""" + """Class which queries Unifi wireless access point.""" def __init__(self, config): """Initialize the scanner.""" diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index d61a47e8c7..7aa7c6d5cf 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -1,4 +1,4 @@ -"""This component provides binary sensors for UniFi Protect.""" +"""Component providing binary sensors for UniFi Protect.""" from __future__ import annotations from copy import copy diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index feb0be66ec..500b4b4703 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -1,4 +1,4 @@ -"""This component provides Lights for UniFi Protect.""" +"""Component providing Lights for UniFi Protect.""" from __future__ import annotations import logging diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index ba6ae819dd..247e401b2c 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -1,4 +1,4 @@ -"""This component provides number entities for UniFi Protect.""" +"""Component providing number entities for UniFi Protect.""" from __future__ import annotations from dataclasses import dataclass diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 7fe43bee9b..36870bf9c3 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -1,4 +1,4 @@ -"""This component provides select entities for UniFi Protect.""" +"""Component providing select entities for UniFi Protect.""" from __future__ import annotations from collections.abc import Callable diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 5b17ed0020..783955b340 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -1,4 +1,4 @@ -"""This component provides sensors for UniFi Protect.""" +"""Component providing sensors for UniFi Protect.""" from __future__ import annotations from dataclasses import dataclass diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index 295b701426..ea2d8256cb 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -1,4 +1,4 @@ -"""This component provides Switches for UniFi Protect.""" +"""Component providing Switches for UniFi Protect.""" from __future__ import annotations from dataclasses import dataclass diff --git a/homeassistant/components/upc_connect/device_tracker.py b/homeassistant/components/upc_connect/device_tracker.py index 3025ea746d..2b5ee2915e 100644 --- a/homeassistant/components/upc_connect/device_tracker.py +++ b/homeassistant/components/upc_connect/device_tracker.py @@ -57,7 +57,7 @@ async def async_get_scanner( class UPCDeviceScanner(DeviceScanner): - """This class queries a router running UPC ConnectBox firmware.""" + """Class which queries a router running UPC ConnectBox firmware.""" def __init__(self, connect_box: ConnectBox) -> None: """Initialize the scanner.""" diff --git a/homeassistant/components/usgs_earthquakes_feed/geo_location.py b/homeassistant/components/usgs_earthquakes_feed/geo_location.py index 28927baf92..99aecfc406 100644 --- a/homeassistant/components/usgs_earthquakes_feed/geo_location.py +++ b/homeassistant/components/usgs_earthquakes_feed/geo_location.py @@ -195,7 +195,7 @@ class UsgsEarthquakesFeedEntityManager: class UsgsEarthquakesEvent(GeolocationEvent): - """This represents an external event with USGS Earthquake data.""" + """Represents an external event with USGS Earthquake data.""" _attr_icon = "mdi:pulse" _attr_should_poll = False diff --git a/homeassistant/components/xiaomi/camera.py b/homeassistant/components/xiaomi/camera.py index 8b7abcd2fe..e9d686a636 100644 --- a/homeassistant/components/xiaomi/camera.py +++ b/homeassistant/components/xiaomi/camera.py @@ -1,4 +1,4 @@ -"""This component provides support for Xiaomi Cameras.""" +"""Component providing support for Xiaomi Cameras.""" from __future__ import annotations from ftplib import FTP, error_perm diff --git a/homeassistant/components/xiaomi/device_tracker.py b/homeassistant/components/xiaomi/device_tracker.py index b8cf5f005c..f277060304 100644 --- a/homeassistant/components/xiaomi/device_tracker.py +++ b/homeassistant/components/xiaomi/device_tracker.py @@ -36,7 +36,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> XiaomiDeviceScanner class XiaomiDeviceScanner(DeviceScanner): - """This class queries a Xiaomi Mi router. + """Class which queries a Xiaomi Mi router. Adapted from Luci scanner. """ diff --git a/homeassistant/components/xiaomi_miio/device_tracker.py b/homeassistant/components/xiaomi_miio/device_tracker.py index e4bebdd0e6..977dc29ac4 100644 --- a/homeassistant/components/xiaomi_miio/device_tracker.py +++ b/homeassistant/components/xiaomi_miio/device_tracker.py @@ -53,7 +53,7 @@ def get_scanner( class XiaomiMiioDeviceScanner(DeviceScanner): - """This class queries a Xiaomi Mi WiFi Repeater.""" + """Class which queries a Xiaomi Mi WiFi Repeater.""" def __init__(self, device): """Initialize the scanner.""" From 699cc6c092880950aa8bcdcfb80fb25bafcf4768 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 3 Mar 2023 11:34:20 +0100 Subject: [PATCH 0616/2810] Adjust docstring on hassfest generated files (#89080) --- homeassistant/generated/application_credentials.py | 2 +- homeassistant/generated/bluetooth.py | 2 +- homeassistant/generated/config_flows.py | 2 +- homeassistant/generated/countries.py | 2 +- homeassistant/generated/currencies.py | 2 +- homeassistant/generated/dhcp.py | 2 +- homeassistant/generated/languages.py | 2 +- homeassistant/generated/mqtt.py | 2 +- homeassistant/generated/ssdp.py | 2 +- homeassistant/generated/usb.py | 2 +- homeassistant/generated/zeroconf.py | 2 +- script/hassfest/serializer.py | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index b15642d46e..59e76a9c8a 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -1,4 +1,4 @@ -"""This file is automatically generated. +"""Automatically generated file. To update, run python3 -m script.hassfest """ diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 86da242be8..fc29508431 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -1,4 +1,4 @@ -"""This file is automatically generated. +"""Automatically generated file. To update, run python3 -m script.hassfest """ diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 3621c1d48d..8e13dd971e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -1,4 +1,4 @@ -"""This file is automatically generated. +"""Automatically generated file. To update, run python3 -m script.hassfest """ diff --git a/homeassistant/generated/countries.py b/homeassistant/generated/countries.py index 76482a524d..452e65afb0 100644 --- a/homeassistant/generated/countries.py +++ b/homeassistant/generated/countries.py @@ -1,4 +1,4 @@ -"""This file is automatically generated. +"""Automatically generated file. To update, run python3 -m script.countries diff --git a/homeassistant/generated/currencies.py b/homeassistant/generated/currencies.py index 546bc125a0..3cf2b9a1ab 100644 --- a/homeassistant/generated/currencies.py +++ b/homeassistant/generated/currencies.py @@ -1,4 +1,4 @@ -"""This file is automatically generated. +"""Automatically generated file. To update, run python3 -m script.currencies """ diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 8956085a5a..5be29e022f 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -1,4 +1,4 @@ -"""This file is automatically generated. +"""Automatically generated file. To update, run python3 -m script.hassfest """ diff --git a/homeassistant/generated/languages.py b/homeassistant/generated/languages.py index 879d4a4cd4..b4aebb0f1a 100644 --- a/homeassistant/generated/languages.py +++ b/homeassistant/generated/languages.py @@ -1,4 +1,4 @@ -"""This file is automatically generated. +"""Automatically generated file. To update, run python3 -m script.languages [frontend_tag] """ diff --git a/homeassistant/generated/mqtt.py b/homeassistant/generated/mqtt.py index 5d64546b91..69abf7c64f 100644 --- a/homeassistant/generated/mqtt.py +++ b/homeassistant/generated/mqtt.py @@ -1,4 +1,4 @@ -"""This file is automatically generated. +"""Automatically generated file. To update, run python3 -m script.hassfest """ diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index ca6a22e85d..e5e83d5eae 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -1,4 +1,4 @@ -"""This file is automatically generated. +"""Automatically generated file. To update, run python3 -m script.hassfest """ diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index 2d0dced896..f58936caf8 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -1,4 +1,4 @@ -"""This file is automatically generated. +"""Automatically generated file. To update, run python3 -m script.hassfest """ diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index e00a0710c3..2f3dbaefb1 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -1,4 +1,4 @@ -"""This file is automatically generated. +"""Automatically generated file. To update, run python3 -m script.hassfest """ diff --git a/script/hassfest/serializer.py b/script/hassfest/serializer.py index 41f6a554af..0dd3d35bb1 100644 --- a/script/hassfest/serializer.py +++ b/script/hassfest/serializer.py @@ -65,7 +65,7 @@ def format_python( ) -> str: """Format Python code with Black. Optionally prepend a generator comment.""" if generator: - content = f"""\"\"\"This file is automatically generated. + content = f"""\"\"\"Automatically generated file. To update, run python3 -m {generator} \"\"\" From 9e6f869438f448c122b5dfcaec30278eae7a0f1c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 3 Mar 2023 11:57:41 +0100 Subject: [PATCH 0617/2810] Set Protocol inheritance on EnergyPlatform (#89079) --- homeassistant/components/energy/types.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/energy/types.py b/homeassistant/components/energy/types.py index 9a599cb9a5..819ed6ac5a 100644 --- a/homeassistant/components/energy/types.py +++ b/homeassistant/components/energy/types.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable -from typing import TypedDict +from typing import Protocol, TypedDict from homeassistant.core import HomeAssistant @@ -18,8 +18,8 @@ GetSolarForecastType = Callable[ ] -class EnergyPlatform: - """This class represents the methods we expect on the energy platforms.""" +class EnergyPlatform(Protocol): + """Represents the methods we expect on the energy platforms.""" @staticmethod async def async_get_solar_forecast( From 0598417894553ac008b30a57e0420e7d82e573d1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 3 Mar 2023 12:38:40 +0100 Subject: [PATCH 0618/2810] Enable ruff D404 (#89093) --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c0c2316688..850995273b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -268,7 +268,6 @@ ignore = [ "D202", # No blank lines allowed after function docstring "D203", # 1 blank line required before class docstring "D213", # Multi-line docstring summary should start at the second line - "D404", # First word of the docstring should not be This "D406", # Section name should end with a newline "D407", # Section name underlining "E501", # line too long From 9736fe1f999915f7746b544a41df13cd77a1af69 Mon Sep 17 00:00:00 2001 From: Stephan Uhle Date: Fri, 3 Mar 2023 13:44:57 +0100 Subject: [PATCH 0619/2810] Add missing Edl21 sensor 1-0:0.0.0*255 (#87389) * Added missing sensor. * OwnerShip entity is disabled by default. --- homeassistant/components/edl21/sensor.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index e34c9c823f..355d448e30 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -53,6 +53,14 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # A=1: Electricity # C=0: General purpose objects # D=0: Free ID-numbers for utilities + # E=0 Ownership ID + SensorEntityDescription( + key="1-0:0.0.0*255", + name="Ownership ID", + icon="mdi:flash", + entity_registry_enabled_default=False, + ), + # E=9: Electrity ID SensorEntityDescription( key="1-0:0.0.9*255", name="Electricity ID", icon="mdi:flash" ), From 415190683fa4d5fa0a515b48970652095f925998 Mon Sep 17 00:00:00 2001 From: Jeef Date: Fri, 3 Mar 2023 09:04:27 -0500 Subject: [PATCH 0620/2810] Updating Intellifire Naming scheme (#88666) --- .../components/intellifire/binary_sensor.py | 26 +++++++++---------- .../components/intellifire/coordinator.py | 2 +- .../components/intellifire/entity.py | 3 ++- homeassistant/components/intellifire/fan.py | 1 - homeassistant/components/intellifire/light.py | 4 +-- .../components/intellifire/number.py | 4 +-- .../components/intellifire/sensor.py | 6 ++--- .../components/intellifire/switch.py | 2 +- 8 files changed, 24 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/intellifire/binary_sensor.py b/homeassistant/components/intellifire/binary_sensor.py index d189a09a73..5a7407836f 100644 --- a/homeassistant/components/intellifire/binary_sensor.py +++ b/homeassistant/components/intellifire/binary_sensor.py @@ -44,25 +44,25 @@ INTELLIFIRE_BINARY_SENSORS: tuple[IntellifireBinarySensorEntityDescription, ...] ), IntellifireBinarySensorEntityDescription( key="timer_on", - name="Timer On", + name="Timer on", icon="mdi:camera-timer", value_fn=lambda data: data.timer_on, ), IntellifireBinarySensorEntityDescription( key="pilot_light_on", - name="Pilot Light On", + name="Pilot light on", icon="mdi:fire-alert", value_fn=lambda data: data.pilot_on, ), IntellifireBinarySensorEntityDescription( key="thermostat_on", - name="Thermostat On", + name="Thermostat on", icon="mdi:home-thermometer-outline", value_fn=lambda data: data.thermostat_on, ), IntellifireBinarySensorEntityDescription( key="error_pilot_flame", - name="Pilot Flame Error", + name="Pilot flame error", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_pilot_flame, device_class=BinarySensorDeviceClass.PROBLEM, @@ -76,7 +76,7 @@ INTELLIFIRE_BINARY_SENSORS: tuple[IntellifireBinarySensorEntityDescription, ...] ), IntellifireBinarySensorEntityDescription( key="error_fan_delay", - name="Fan Delay Error", + name="Fan delay error", icon="mdi:fan-alert", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_fan_delay, @@ -84,21 +84,21 @@ INTELLIFIRE_BINARY_SENSORS: tuple[IntellifireBinarySensorEntityDescription, ...] ), IntellifireBinarySensorEntityDescription( key="error_maintenance", - name="Maintenance Error", + name="Maintenance error", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_maintenance, device_class=BinarySensorDeviceClass.PROBLEM, ), IntellifireBinarySensorEntityDescription( key="error_disabled", - name="Disabled Error", + name="Disabled error", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_disabled, device_class=BinarySensorDeviceClass.PROBLEM, ), IntellifireBinarySensorEntityDescription( key="error_fan", - name="Fan Error", + name="Fan error", icon="mdi:fan-alert", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_fan, @@ -106,35 +106,35 @@ INTELLIFIRE_BINARY_SENSORS: tuple[IntellifireBinarySensorEntityDescription, ...] ), IntellifireBinarySensorEntityDescription( key="error_lights", - name="Lights Error", + name="Lights error", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_lights, device_class=BinarySensorDeviceClass.PROBLEM, ), IntellifireBinarySensorEntityDescription( key="error_accessory", - name="Accessory Error", + name="Accessory error", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_accessory, device_class=BinarySensorDeviceClass.PROBLEM, ), IntellifireBinarySensorEntityDescription( key="error_soft_lock_out", - name="Soft Lock Out Error", + name="Soft lock out error", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_soft_lock_out, device_class=BinarySensorDeviceClass.PROBLEM, ), IntellifireBinarySensorEntityDescription( key="error_ecm_offline", - name="ECM Offline Error", + name="ECM offline error", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_ecm_offline, device_class=BinarySensorDeviceClass.PROBLEM, ), IntellifireBinarySensorEntityDescription( key="error_offline", - name="Offline Error", + name="Offline error", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_offline, device_class=BinarySensorDeviceClass.PROBLEM, diff --git a/homeassistant/components/intellifire/coordinator.py b/homeassistant/components/intellifire/coordinator.py index b6753adef7..5003ed9143 100644 --- a/homeassistant/components/intellifire/coordinator.py +++ b/homeassistant/components/intellifire/coordinator.py @@ -67,7 +67,7 @@ class IntellifireDataUpdateCoordinator(DataUpdateCoordinator[IntellifirePollData return DeviceInfo( manufacturer="Hearth and Home", model="IFT-WFM", - name="IntelliFire Fireplace", + name="IntelliFire", identifiers={("IntelliFire", f"{self.read_api.data.serial}]")}, sw_version=self.read_api.data.fw_ver_str, configuration_url=f"http://{self._api.fireplace_ip}/poll", diff --git a/homeassistant/components/intellifire/entity.py b/homeassistant/components/intellifire/entity.py index 3c427250f1..1e406aeb11 100644 --- a/homeassistant/components/intellifire/entity.py +++ b/homeassistant/components/intellifire/entity.py @@ -21,7 +21,8 @@ class IntellifireEntity(CoordinatorEntity[IntellifireDataUpdateCoordinator]): super().__init__(coordinator=coordinator) self.entity_description = description # Set the Display name the User will see - self._attr_name = f"Fireplace {description.name}" + self._attr_name = description.name self._attr_unique_id = f"{description.key}_{coordinator.read_api.data.serial}" + self._attr_has_entity_name = True # Configure the Device Info self._attr_device_info = self.coordinator.device_info diff --git a/homeassistant/components/intellifire/fan.py b/homeassistant/components/intellifire/fan.py index aa74480fef..debc8237fc 100644 --- a/homeassistant/components/intellifire/fan.py +++ b/homeassistant/components/intellifire/fan.py @@ -46,7 +46,6 @@ INTELLIFIRE_FANS: tuple[IntellifireFanEntityDescription, ...] = ( IntellifireFanEntityDescription( key="fan", name="Fan", - has_entity_name=True, set_fn=lambda control_api, speed: control_api.set_fan_speed(speed=speed), value_fn=lambda data: data.fanspeed, speed_range=(1, 4), diff --git a/homeassistant/components/intellifire/light.py b/homeassistant/components/intellifire/light.py index 5e7d5735a6..383d61b8d4 100644 --- a/homeassistant/components/intellifire/light.py +++ b/homeassistant/components/intellifire/light.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DOMAIN, LOGGER from .coordinator import IntellifireDataUpdateCoordinator from .entity import IntellifireEntity @@ -41,7 +41,6 @@ INTELLIFIRE_LIGHTS: tuple[IntellifireLightEntityDescription, ...] = ( IntellifireLightEntityDescription( key="lights", name="Lights", - has_entity_name=True, set_fn=lambda control_api, level: control_api.set_lights(level=level), value_fn=lambda data: data.light_level, ), @@ -95,3 +94,4 @@ async def async_setup_entry( for description in INTELLIFIRE_LIGHTS ) return + LOGGER.debug("Disabling Lights - IntelliFire device does not appear to have one") diff --git a/homeassistant/components/intellifire/number.py b/homeassistant/components/intellifire/number.py index efa567d55c..1b0913f3f3 100644 --- a/homeassistant/components/intellifire/number.py +++ b/homeassistant/components/intellifire/number.py @@ -26,8 +26,8 @@ async def async_setup_entry( coordinator: IntellifireDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] description = NumberEntityDescription( - key="flame_control", - name="Flame control", + key="flame_height", + name="Flame height", icon="mdi:arrow-expand-vertical", ) diff --git a/homeassistant/components/intellifire/sensor.py b/homeassistant/components/intellifire/sensor.py index 12f66a3f27..e888ea1bbc 100644 --- a/homeassistant/components/intellifire/sensor.py +++ b/homeassistant/components/intellifire/sensor.py @@ -57,7 +57,7 @@ INTELLIFIRE_SENSORS: tuple[IntellifireSensorEntityDescription, ...] = ( IntellifireSensorEntityDescription( key="flame_height", icon="mdi:fire-circle", - name="Flame Height", + name="Flame height", state_class=SensorStateClass.MEASUREMENT, # UI uses 1-5 for flame height, backing lib uses 0-4 value_fn=lambda data: (data.flameheight + 1), @@ -72,7 +72,7 @@ INTELLIFIRE_SENSORS: tuple[IntellifireSensorEntityDescription, ...] = ( ), IntellifireSensorEntityDescription( key="target_temp", - name="Target Temperature", + name="Target temperature", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -116,7 +116,7 @@ INTELLIFIRE_SENSORS: tuple[IntellifireSensorEntityDescription, ...] = ( ), IntellifireSensorEntityDescription( key="ecm_latency", - name="ECM Latency", + name="ECM latency", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.ecm_latency, entity_registry_enabled_default=False, diff --git a/homeassistant/components/intellifire/switch.py b/homeassistant/components/intellifire/switch.py index ef0363696c..98abaa3884 100644 --- a/homeassistant/components/intellifire/switch.py +++ b/homeassistant/components/intellifire/switch.py @@ -44,7 +44,7 @@ INTELLIFIRE_SWITCHES: tuple[IntellifireSwitchEntityDescription, ...] = ( ), IntellifireSwitchEntityDescription( key="pilot", - name="Pilot Light", + name="Pilot light", icon="mdi:fire-alert", on_fn=lambda control_api: control_api.pilot_on(), off_fn=lambda control_api: control_api.pilot_off(), From 3a34f818e8add8527ec66c85a4066162d2a715d9 Mon Sep 17 00:00:00 2001 From: Felix Rotthowe Date: Fri, 3 Mar 2023 15:23:38 +0100 Subject: [PATCH 0621/2810] Refactor Livisi Switch and Climate to inherit from a common base class (#89085) * Refactor Livisi entities to inherit from a common base class * Add livisi_entity to .coveragerc * Device location can be None * Add use_room_as_device_name argument to constructor of LivisiEntity When initializing, set entity name attribute only if device name differs (i.e. use_room_as_device_name=True). * re-add comment for special handling of climate device names * Add explicit type to constructur argument * Make use_room_as_device_name a keyword only arg * rename livisi_entity.py to entity.py * change livisi_entity.py to entity.py in coveragerc * Code quality improvements as suggested in PR * sort .coveragerc * fix isort issue * fix all isort issues --- .coveragerc | 1 + homeassistant/components/livisi/climate.py | 94 +++++----------------- homeassistant/components/livisi/entity.py | 81 +++++++++++++++++++ homeassistant/components/livisi/switch.py | 83 +++---------------- 4 files changed, 112 insertions(+), 147 deletions(-) create mode 100644 homeassistant/components/livisi/entity.py diff --git a/.coveragerc b/.coveragerc index ce80bef0a9..48fb404457 100644 --- a/.coveragerc +++ b/.coveragerc @@ -643,6 +643,7 @@ omit = homeassistant/components/livisi/__init__.py homeassistant/components/livisi/climate.py homeassistant/components/livisi/coordinator.py + homeassistant/components/livisi/entity.py homeassistant/components/livisi/switch.py homeassistant/components/llamalab_automate/notify.py homeassistant/components/logi_circle/__init__.py diff --git a/homeassistant/components/livisi/climate.py b/homeassistant/components/livisi/climate.py index f99ad8dbe7..a6680a19af 100644 --- a/homeassistant/components/livisi/climate.py +++ b/homeassistant/components/livisi/climate.py @@ -1,11 +1,8 @@ """Code to handle a Livisi Virtual Climate Control.""" from __future__ import annotations -from collections.abc import Mapping from typing import Any -from aiolivisi.const import CAPABILITY_MAP - from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, @@ -16,13 +13,10 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( DOMAIN, - LIVISI_REACHABILITY_CHANGE, LIVISI_STATE_CHANGE, LOGGER, MAX_TEMPERATURE, @@ -30,6 +24,7 @@ from .const import ( VRCC_DEVICE_TYPE, ) from .coordinator import LivisiDataUpdateCoordinator +from .entity import LivisiEntity async def async_setup_entry( @@ -50,8 +45,8 @@ async def async_setup_entry( device["type"] == VRCC_DEVICE_TYPE and device["id"] not in coordinator.devices ): - livisi_climate: ClimateEntity = create_entity( - config_entry, device, coordinator + livisi_climate: ClimateEntity = LivisiClimate( + config_entry, coordinator, device ) LOGGER.debug("Include device type: %s", device.get("type")) coordinator.devices.add(device["id"]) @@ -63,32 +58,7 @@ async def async_setup_entry( ) -def create_entity( - config_entry: ConfigEntry, - device: dict[str, Any], - coordinator: LivisiDataUpdateCoordinator, -) -> ClimateEntity: - """Create Climate Entity.""" - capabilities: Mapping[str, Any] = device[CAPABILITY_MAP] - config_details: Mapping[str, Any] = device["config"] - room_id: str = device["location"] - room_name: str = coordinator.rooms[room_id] - livisi_climate = LivisiClimate( - config_entry, - coordinator, - unique_id=device["id"], - manufacturer=device["manufacturer"], - device_type=device["type"], - target_temperature_capability=capabilities["RoomSetpoint"], - temperature_capability=capabilities["RoomTemperature"], - humidity_capability=capabilities["RoomHumidity"], - room=room_name, - name=config_details["name"], - ) - return livisi_climate - - -class LivisiClimate(CoordinatorEntity[LivisiDataUpdateCoordinator], ClimateEntity): +class LivisiClimate(LivisiEntity, ClimateEntity): """Represents the Livisi Climate.""" _attr_hvac_modes = [HVACMode.HEAT] @@ -97,39 +67,21 @@ class LivisiClimate(CoordinatorEntity[LivisiDataUpdateCoordinator], ClimateEntit _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_target_temperature_high = MAX_TEMPERATURE _attr_target_temperature_low = MIN_TEMPERATURE - _attr_has_entity_name = True def __init__( self, config_entry: ConfigEntry, coordinator: LivisiDataUpdateCoordinator, - unique_id: str, - manufacturer: str, - device_type: str, - target_temperature_capability: str, - temperature_capability: str, - humidity_capability: str, - room: str, - name: str, + device: dict[str, Any], ) -> None: """Initialize the Livisi Climate.""" - self.config_entry = config_entry - self._attr_unique_id = unique_id - self._target_temperature_capability = target_temperature_capability - self._temperature_capability = temperature_capability - self._humidity_capability = humidity_capability - self.aio_livisi = coordinator.aiolivisi - self._attr_available = False - self._attr_name = name - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, unique_id)}, - manufacturer=manufacturer, - model=device_type, - name=room, - suggested_area=room, - via_device=(DOMAIN, config_entry.entry_id), + super().__init__( + config_entry, coordinator, device, use_room_as_device_name=True ) - super().__init__(coordinator) + + self._target_temperature_capability = self.capabilities["RoomSetpoint"] + self._temperature_capability = self.capabilities["RoomTemperature"] + self._humidity_capability = self.capabilities["RoomHumidity"] async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -142,11 +94,11 @@ class LivisiClimate(CoordinatorEntity[LivisiDataUpdateCoordinator], ClimateEntit self._attr_available = False raise HomeAssistantError(f"Failed to turn off {self._attr_name}") - def set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Do nothing as LIVISI devices do not support changing the hvac mode.""" - async def async_added_to_hass(self) -> None: """Register callbacks.""" + + await super().async_added_to_hass() + target_temperature = await self.coordinator.async_get_vrcc_target_temperature( self._target_temperature_capability ) @@ -184,13 +136,9 @@ class LivisiClimate(CoordinatorEntity[LivisiDataUpdateCoordinator], ClimateEntit self.update_humidity, ) ) - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{LIVISI_REACHABILITY_CHANGE}_{self.unique_id}", - self.update_reachability, - ) - ) + + def set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Do nothing as LIVISI devices do not support changing the hvac mode.""" @callback def update_target_temperature(self, target_temperature: float) -> None: @@ -206,12 +154,6 @@ class LivisiClimate(CoordinatorEntity[LivisiDataUpdateCoordinator], ClimateEntit @callback def update_humidity(self, humidity: int) -> None: - """Update the humidity temperature of the climate device.""" + """Update the humidity of the climate device.""" self._attr_current_humidity = humidity self.async_write_ha_state() - - @callback - def update_reachability(self, is_reachable: bool) -> None: - """Update the reachability of the climate device.""" - self._attr_available = is_reachable - self.async_write_ha_state() diff --git a/homeassistant/components/livisi/entity.py b/homeassistant/components/livisi/entity.py new file mode 100644 index 0000000000..613f55d1b7 --- /dev/null +++ b/homeassistant/components/livisi/entity.py @@ -0,0 +1,81 @@ +"""Code to handle a Livisi switches.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from aiolivisi.const import CAPABILITY_MAP + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, LIVISI_REACHABILITY_CHANGE +from .coordinator import LivisiDataUpdateCoordinator + + +class LivisiEntity(CoordinatorEntity[LivisiDataUpdateCoordinator], Entity): + """Represents a base livisi entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + config_entry: ConfigEntry, + coordinator: LivisiDataUpdateCoordinator, + device: dict[str, Any], + *, + use_room_as_device_name: bool = False, + ) -> None: + """Initialize the common properties of a Livisi device.""" + self.aio_livisi = coordinator.aiolivisi + self.capabilities: Mapping[str, Any] = device[CAPABILITY_MAP] + + name = device["config"]["name"] + unique_id = device["id"] + + room_id: str | None = device.get("location") + room_name: str | None = None + if room_id is not None: + room_name = coordinator.rooms.get(room_id) + + self._attr_available = False + self._attr_unique_id = unique_id + + device_name = name + + # For livisi climate entities, the device should have the room name from + # the livisi setup, as each livisi room gets exactly one VRCC device. The entity + # name will always be some localized value of "Climate", so the full element name + # in homeassistent will be in the form of "Bedroom Climate" + if use_room_as_device_name and room_name is not None: + self._attr_name = name + device_name = room_name + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer=device["manufacturer"], + model=device["type"], + name=device_name, + suggested_area=room_name, + via_device=(DOMAIN, config_entry.entry_id), + ) + super().__init__(coordinator) + + async def async_added_to_hass(self) -> None: + """Register callback for reachability.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{LIVISI_REACHABILITY_CHANGE}_{self.unique_id}", + self.update_reachability, + ) + ) + + @callback + def update_reachability(self, is_reachable: bool) -> None: + """Update the reachability of the device.""" + self._attr_available = is_reachable + self.async_write_ha_state() diff --git a/homeassistant/components/livisi/switch.py b/homeassistant/components/livisi/switch.py index bcb9a20441..f5201ab8fa 100644 --- a/homeassistant/components/livisi/switch.py +++ b/homeassistant/components/livisi/switch.py @@ -8,18 +8,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - DOMAIN, - LIVISI_REACHABILITY_CHANGE, - LIVISI_STATE_CHANGE, - LOGGER, - PSS_DEVICE_TYPE, -) +from .const import DOMAIN, LIVISI_STATE_CHANGE, LOGGER, PSS_DEVICE_TYPE from .coordinator import LivisiDataUpdateCoordinator +from .entity import LivisiEntity async def async_setup_entry( @@ -40,8 +33,8 @@ async def async_setup_entry( device["type"] == PSS_DEVICE_TYPE and device["id"] not in coordinator.devices ): - livisi_switch: SwitchEntity = create_entity( - config_entry, device, coordinator + livisi_switch: SwitchEntity = LivisiSwitch( + config_entry, coordinator, device ) LOGGER.debug("Include device type: %s", device["type"]) coordinator.devices.add(device["id"]) @@ -53,59 +46,18 @@ async def async_setup_entry( ) -def create_entity( - config_entry: ConfigEntry, - device: dict[str, Any], - coordinator: LivisiDataUpdateCoordinator, -) -> SwitchEntity: - """Create Switch Entity.""" - config_details: dict[str, Any] = device["config"] - capabilities: list = device["capabilities"] - room_id: str = device["location"] - room_name: str = coordinator.rooms[room_id] - livisi_switch = LivisiSwitch( - config_entry, - coordinator, - unique_id=device["id"], - manufacturer=device["manufacturer"], - device_type=device["type"], - name=config_details["name"], - capability_id=capabilities[0], - room=room_name, - ) - return livisi_switch - - -class LivisiSwitch(CoordinatorEntity[LivisiDataUpdateCoordinator], SwitchEntity): +class LivisiSwitch(LivisiEntity, SwitchEntity): """Represents the Livisi Switch.""" def __init__( self, config_entry: ConfigEntry, coordinator: LivisiDataUpdateCoordinator, - unique_id: str, - manufacturer: str, - device_type: str, - name: str, - capability_id: str, - room: str, + device: dict[str, Any], ) -> None: - """Initialize the Livisi Switch.""" - self.config_entry = config_entry - self._attr_unique_id = unique_id - self._attr_name = name - self._capability_id = capability_id - self.aio_livisi = coordinator.aiolivisi - self._attr_available = False - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, unique_id)}, - manufacturer=manufacturer, - model=device_type, - name=name, - suggested_area=room, - via_device=(DOMAIN, config_entry.entry_id), - ) - super().__init__(coordinator) + """Initialize the Livisi switch.""" + super().__init__(config_entry, coordinator, device) + self._capability_id = self.capabilities["SwitchActuator"] async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" @@ -127,6 +79,8 @@ class LivisiSwitch(CoordinatorEntity[LivisiDataUpdateCoordinator], SwitchEntity) async def async_added_to_hass(self) -> None: """Register callbacks.""" + await super().async_added_to_hass() + response = await self.coordinator.async_get_pss_state(self._capability_id) if response is None: self._attr_is_on = False @@ -140,22 +94,9 @@ class LivisiSwitch(CoordinatorEntity[LivisiDataUpdateCoordinator], SwitchEntity) self.update_states, ) ) - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{LIVISI_REACHABILITY_CHANGE}_{self.unique_id}", - self.update_reachability, - ) - ) @callback def update_states(self, state: bool) -> None: - """Update the states of the switch device.""" + """Update the state of the switch device.""" self._attr_is_on = state self.async_write_ha_state() - - @callback - def update_reachability(self, is_reachable: bool) -> None: - """Update the reachability of the switch device.""" - self._attr_available = is_reachable - self.async_write_ha_state() From 1d9e8c873fb31d27f392a8a3eda5f72886220256 Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Fri, 3 Mar 2023 11:16:12 -0500 Subject: [PATCH 0622/2810] Revert Intellifire breaking change from #88666 (#89110) --- homeassistant/components/intellifire/number.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/intellifire/number.py b/homeassistant/components/intellifire/number.py index 1b0913f3f3..efa567d55c 100644 --- a/homeassistant/components/intellifire/number.py +++ b/homeassistant/components/intellifire/number.py @@ -26,8 +26,8 @@ async def async_setup_entry( coordinator: IntellifireDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] description = NumberEntityDescription( - key="flame_height", - name="Flame height", + key="flame_control", + name="Flame control", icon="mdi:arrow-expand-vertical", ) From 1bd9767d8ce8e554d55789648dcc38c8440998a5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 3 Mar 2023 17:00:13 -1000 Subject: [PATCH 0623/2810] 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 431bc78ba8..0b8fe9243b 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 2a958d3b62..fee7be443b 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 3ff6b62b21..bfdd8ff5b1 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 44c3ffac99..19c7e6c695 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 c5ce8d272c..07c935129e 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 8685985def..dd51946c86 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -1231,8 +1231,9 @@ def test_delete_duplicates_no_duplicates( """Test removal of duplicated statistics.""" hass = hass_recorder() wait_recording_done(hass) + instance = recorder.get_instance(hass) with session_scope(hass=hass) as session: - delete_statistics_duplicates(hass, session) + delete_statistics_duplicates(instance, hass, session) assert "duplicated statistics rows" not in caplog.text assert "Found non identical" not in caplog.text assert "Found duplicated" not in caplog.text From b27b094e27bf7c7d8d41837d683f45b15cae3401 Mon Sep 17 00:00:00 2001 From: Bob van de Vijver Date: Sat, 4 Mar 2023 05:06:28 +0100 Subject: [PATCH 0624/2810] Add day to event end to correct TwenteMilieu event timespan (#89028) [TwenteMilieu] Add day to event end to correct event timespan Co-authored-by: Allen Porter --- homeassistant/components/twentemilieu/calendar.py | 6 +++--- tests/components/twentemilieu/snapshots/test_calendar.ambr | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/twentemilieu/calendar.py b/homeassistant/components/twentemilieu/calendar.py index d368505173..e4ecbd9d86 100644 --- a/homeassistant/components/twentemilieu/calendar.py +++ b/homeassistant/components/twentemilieu/calendar.py @@ -1,7 +1,7 @@ """Support for Twente Milieu Calendar.""" from __future__ import annotations -from datetime import date, datetime +from datetime import date, datetime, timedelta from twentemilieu import WasteType @@ -58,7 +58,7 @@ class TwenteMilieuCalendar(TwenteMilieuEntity, CalendarEntity): CalendarEvent( summary=WASTE_TYPE_TO_DESCRIPTION[waste_type], start=waste_date, - end=waste_date, + end=waste_date + timedelta(days=1), ) for waste_date in waste_dates if start_date.date() <= waste_date <= end_date.date() @@ -89,7 +89,7 @@ class TwenteMilieuCalendar(TwenteMilieuEntity, CalendarEntity): self._event = CalendarEvent( summary=WASTE_TYPE_TO_DESCRIPTION[next_waste_pickup_type], start=next_waste_pickup_date, - end=next_waste_pickup_date, + end=next_waste_pickup_date + timedelta(days=1), ) super()._handle_coordinator_update() diff --git a/tests/components/twentemilieu/snapshots/test_calendar.ambr b/tests/components/twentemilieu/snapshots/test_calendar.ambr index 04965b342b..d004084e06 100644 --- a/tests/components/twentemilieu/snapshots/test_calendar.ambr +++ b/tests/components/twentemilieu/snapshots/test_calendar.ambr @@ -12,7 +12,7 @@ dict({ 'description': None, 'end': dict({ - 'date': '2022-01-06', + 'date': '2022-01-07', }), 'location': None, 'recurrence_id': None, @@ -30,7 +30,7 @@ 'attributes': ReadOnlyDict({ 'all_day': True, 'description': '', - 'end_time': '2022-01-06 00:00:00', + 'end_time': '2022-01-07 00:00:00', 'friendly_name': 'Twente Milieu', 'icon': 'mdi:delete-empty', 'location': '', From bab758c951de5690a0b046e0d7faa1ef330c865b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 4 Mar 2023 11:45:53 +0100 Subject: [PATCH 0625/2810] Refactor WLED button tests (#88580) --- .../wled/snapshots/test_button.ambr | 75 +++++++++++++++++++ tests/components/wled/test_button.py | 66 +++++++--------- 2 files changed, 104 insertions(+), 37 deletions(-) create mode 100644 tests/components/wled/snapshots/test_button.ambr diff --git a/tests/components/wled/snapshots/test_button.ambr b/tests/components/wled/snapshots/test_button.ambr new file mode 100644 index 0000000000..da487b4948 --- /dev/null +++ b/tests/components/wled/snapshots/test_button.ambr @@ -0,0 +1,75 @@ +# serializer version: 1 +# name: test_button_restart + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'WLED RGB Light Restart', + }), + 'context': , + 'entity_id': 'button.wled_rgb_light_restart', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_restart.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.wled_rgb_light_restart', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart', + 'platform': 'wled', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_restart.2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://127.0.0.1', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'esp8266', + 'id': , + 'identifiers': set({ + tuple( + 'wled', + 'aabbccddeeff', + ), + }), + 'is_new': False, + 'manufacturer': 'WLED', + 'model': 'DIY light', + 'name': 'WLED RGB Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.8.5', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/wled/test_button.py b/tests/components/wled/test_button.py index daa6839557..c1f3165e5b 100644 --- a/tests/components/wled/test_button.py +++ b/tests/components/wled/test_button.py @@ -1,41 +1,41 @@ """Tests for the WLED button platform.""" from unittest.mock import MagicMock -from freezegun import freeze_time import pytest +from syrupy.assertion import SnapshotAssertion from wled import WLEDConnectionError, WLEDError -from homeassistant.components.button import ( - DOMAIN as BUTTON_DOMAIN, - SERVICE_PRESS, - ButtonDeviceClass, -) -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, - STATE_UNAVAILABLE, - STATE_UNKNOWN, - EntityCategory, -) +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er -pytestmark = pytest.mark.usefixtures("init_integration") +pytestmark = [ + pytest.mark.usefixtures("init_integration"), + pytest.mark.freeze_time("2021-11-04 17:37:00+01:00"), +] async def test_button_restart( - hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_wled: MagicMock + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_wled: MagicMock, + snapshot: SnapshotAssertion, ) -> None: """Test the creation and values of the WLED button.""" assert (state := hass.states.get("button.wled_rgb_light_restart")) + assert state == snapshot + + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert entity_entry == snapshot + + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry == snapshot + assert state.state == STATE_UNKNOWN - assert state.attributes[ATTR_DEVICE_CLASS] == ButtonDeviceClass.RESTART - - assert (entry := entity_registry.async_get("button.wled_rgb_light_restart")) - assert entry.unique_id == "aabbccddeeff_restart" - assert entry.entity_category is EntityCategory.CONFIG - await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, @@ -45,15 +45,11 @@ async def test_button_restart( assert mock_wled.reset.call_count == 1 mock_wled.reset.assert_called_with() + assert (state := hass.states.get("button.wled_rgb_light_restart")) + assert state.state == "2021-11-04T16:37:00+00:00" -@freeze_time("2021-11-04 17:37:00", tz_offset=-1) -async def test_button_error( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test error handling of the WLED buttons.""" + # Test with WLED error mock_wled.reset.side_effect = WLEDError - with pytest.raises(HomeAssistantError, match="Invalid response from WLED API"): await hass.services.async_call( BUTTON_DOMAIN, @@ -63,17 +59,12 @@ async def test_button_error( ) await hass.async_block_till_done() + # Ensure this didn't made the entity unavailable assert (state := hass.states.get("button.wled_rgb_light_restart")) - assert state.state == "2021-11-04T16:37:00+00:00" + assert state.state != STATE_UNAVAILABLE - -async def test_button_connection_error( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test error handling of the WLED buttons.""" + # Test with WLED connection error mock_wled.reset.side_effect = WLEDConnectionError - with pytest.raises(HomeAssistantError, match="Error communicating with WLED API"): await hass.services.async_call( BUTTON_DOMAIN, @@ -82,5 +73,6 @@ async def test_button_connection_error( blocking=True, ) + # Ensure this made the entity unavailable assert (state := hass.states.get("button.wled_rgb_light_restart")) assert state.state == STATE_UNAVAILABLE From 2e5801cb6d6ea44fc8cab25d5068eed45b4ecc2c Mon Sep 17 00:00:00 2001 From: rappenze Date: Sat, 4 Mar 2023 12:05:41 +0100 Subject: [PATCH 0626/2810] 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 6522d3b06e..6dd2104bd9 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 778258f4e1..5c01403688 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 6ebbbe7536..b458207aac 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1161,7 +1161,7 @@ pyevilgenius==2.0.0 pyezviz==0.2.0.9 # homeassistant.components.fibaro -pyfibaro==0.6.8 +pyfibaro==0.6.9 # homeassistant.components.fido pyfido==2.1.2 From 5e2b7c63779c13eb7e3a71bf4d29e4e1d638a2a6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 4 Mar 2023 15:09:47 +0100 Subject: [PATCH 0627/2810] Refactor WLED diagnostic tests (#88581) --- .../wled/snapshots/test_diagnostics.ambr | 198 +++++++++++++++ tests/components/wled/test_diagnostics.py | 229 +----------------- 2 files changed, 205 insertions(+), 222 deletions(-) create mode 100644 tests/components/wled/snapshots/test_diagnostics.ambr diff --git a/tests/components/wled/snapshots/test_diagnostics.ambr b/tests/components/wled/snapshots/test_diagnostics.ambr new file mode 100644 index 0000000000..e06608033c --- /dev/null +++ b/tests/components/wled/snapshots/test_diagnostics.ambr @@ -0,0 +1,198 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'effects': dict({ + '0': 'Solid', + '1': 'Blink', + '10': 'Scan', + '11': 'Dual Scan', + '12': 'Fade', + '13': 'Chase', + '14': 'Chase Rainbow', + '15': 'Running', + '16': 'Saw', + '17': 'Twinkle', + '18': 'Dissolve', + '19': 'Dissolve Rnd', + '2': 'Breathe', + '20': 'Sparkle', + '21': 'Dark Sparkle', + '22': 'Sparkle+', + '23': 'Strobe', + '24': 'Strobe Rainbow', + '25': 'Mega Strobe', + '26': 'Blink Rainbow', + '27': 'Android', + '28': 'Chase', + '29': 'Chase Random', + '3': 'Wipe', + '30': 'Chase Rainbow', + '31': 'Chase Flash', + '32': 'Chase Flash Rnd', + '33': 'Rainbow Runner', + '34': 'Colorful', + '35': 'Traffic Light', + '36': 'Sweep Random', + '37': 'Running 2', + '38': 'Red & Blue', + '39': 'Stream', + '4': 'Wipe Random', + '40': 'Scanner', + '41': 'Lighthouse', + '42': 'Fireworks', + '43': 'Rain', + '44': 'Merry Christmas', + '45': 'Fire Flicker', + '46': 'Gradient', + '47': 'Loading', + '48': 'In Out', + '49': 'In In', + '5': 'Random Colors', + '50': 'Out Out', + '51': 'Out In', + '52': 'Circus', + '53': 'Halloween', + '54': 'Tri Chase', + '55': 'Tri Wipe', + '56': 'Tri Fade', + '57': 'Lightning', + '58': 'ICU', + '59': 'Multi Comet', + '6': 'Sweep', + '60': 'Dual Scanner', + '61': 'Stream 2', + '62': 'Oscillate', + '63': 'Pride 2015', + '64': 'Juggle', + '65': 'Palette', + '66': 'Fire 2012', + '67': 'Colorwaves', + '68': 'BPM', + '69': 'Fill Noise', + '7': 'Dynamic', + '70': 'Noise 1', + '71': 'Noise 2', + '72': 'Noise 3', + '73': 'Noise 4', + '74': 'Colortwinkle', + '75': 'Lake', + '76': 'Meteor', + '77': 'Smooth Meteor', + '78': 'Railway', + '79': 'Ripple', + '8': 'Colorloop', + '80': 'Twinklefox', + '9': 'Rainbow', + }), + 'info': dict({ + 'architecture': 'esp8266', + 'arduino_core_version': '2.4.2', + 'brand': 'WLED', + 'build_type': 'bin', + 'effect_count': 81, + 'filesystem': None, + 'free_heap': 14600, + 'leds': dict({ + '__type': "", + 'repr': 'Leds(cct=False, count=30, fps=None, light_capabilities=None, max_power=850, max_segments=10, power=470, rgbw=False, wv=True, segment_light_capabilities=None)', + }), + 'live': False, + 'live_ip': 'Unknown', + 'live_mode': 'Unknown', + 'mac_address': 'aabbccddeeff', + 'name': 'WLED RGB Light', + 'pallet_count': 50, + 'product': 'DIY light', + 'udp_port': 21324, + 'uptime': 32, + 'version': '0.8.5', + 'version_id': 1909122, + 'version_latest_beta': '0.13.0b1', + 'version_latest_stable': '0.12.0', + 'websocket': None, + 'wifi': '**REDACTED**', + }), + 'palettes': dict({ + '0': 'Default', + '1': 'Random Cycle', + '10': 'Forest', + '11': 'Rainbow', + '12': 'Rainbow Bands', + '13': 'Sunset', + '14': 'Rivendell', + '15': 'Breeze', + '16': 'Red & Blue', + '17': 'Yellowout', + '18': 'Analogous', + '19': 'Splash', + '2': 'Primary Color', + '20': 'Pastel', + '21': 'Sunset 2', + '22': 'Beech', + '23': 'Vintage', + '24': 'Departure', + '25': 'Landscape', + '26': 'Beach', + '27': 'Sherbet', + '28': 'Hult', + '29': 'Hult 64', + '3': 'Based on Primary', + '30': 'Drywet', + '31': 'Jul', + '32': 'Grintage', + '33': 'Rewhi', + '34': 'Tertiary', + '35': 'Fire', + '36': 'Icefire', + '37': 'Cyane', + '38': 'Light Pink', + '39': 'Autumn', + '4': 'Set Colors', + '40': 'Magenta', + '41': 'Magred', + '42': 'Yelmag', + '43': 'Yelblu', + '44': 'Orange & Teal', + '45': 'Tiamat', + '46': 'April Night', + '47': 'Orangery', + '48': 'C9', + '49': 'Sakura', + '5': 'Based on Set', + '6': 'Party', + '7': 'Cloud', + '8': 'Lava', + '9': 'Ocean', + }), + 'playlists': dict({ + }), + 'presets': dict({ + }), + 'state': dict({ + 'brightness': 127, + 'lor': 0, + 'nightlight': dict({ + '__type': "", + 'repr': 'Nightlight(duration=60, fade=True, on=False, mode=, target_brightness=0)', + }), + 'on': True, + 'playlist': -1, + 'preset': -1, + 'segments': list([ + dict({ + '__type': "", + 'repr': "Segment(brightness=127, clones=-1, color_primary=(255, 159, 0), color_secondary=(0, 0, 0), color_tertiary=(0, 0, 0), effect=Effect(effect_id=0, name='Solid'), intensity=128, length=20, on=True, palette=Palette(name='Default', palette_id=0), reverse=False, segment_id=0, selected=True, speed=32, start=0, stop=19)", + }), + dict({ + '__type': "", + 'repr': "Segment(brightness=127, clones=-1, color_primary=(0, 255, 123), color_secondary=(0, 0, 0), color_tertiary=(0, 0, 0), effect=Effect(effect_id=1, name='Blink'), intensity=64, length=10, on=True, palette=Palette(name='Random Cycle', palette_id=1), reverse=True, segment_id=1, selected=True, speed=16, start=20, stop=30)", + }), + ]), + 'sync': dict({ + '__type': "", + 'repr': 'Sync(receive=True, send=False)', + }), + 'transition': 7, + }), + }) +# --- diff --git a/tests/components/wled/test_diagnostics.py b/tests/components/wled/test_diagnostics.py index fd6e156371..38e7ebe3e2 100644 --- a/tests/components/wled/test_diagnostics.py +++ b/tests/components/wled/test_diagnostics.py @@ -1,4 +1,6 @@ """Tests for the diagnostics data provided by the WLED integration.""" +from syrupy.assertion import SnapshotAssertion + from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -10,227 +12,10 @@ async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - assert await get_diagnostics_for_config_entry( - hass, hass_client, init_integration - ) == { - "info": { - "architecture": "esp8266", - "arduino_core_version": "2.4.2", - "brand": "WLED", - "build_type": "bin", - "effect_count": 81, - "filesystem": None, - "free_heap": 14600, - "leds": { - "__type": "", - "repr": ( - "Leds(cct=False, count=30, fps=None, light_capabilities=None, " - "max_power=850, max_segments=10, power=470, rgbw=False, wv=True, " - "segment_light_capabilities=None)" - ), - }, - "live_ip": "Unknown", - "live_mode": "Unknown", - "live": False, - "mac_address": "aabbccddeeff", - "name": "WLED RGB Light", - "pallet_count": 50, - "product": "DIY light", - "udp_port": 21324, - "uptime": 32, - "version_id": 1909122, - "version": "0.8.5", - "version_latest_beta": "0.13.0b1", - "version_latest_stable": "0.12.0", - "websocket": None, - "wifi": "**REDACTED**", - }, - "state": { - "brightness": 127, - "nightlight": { - "__type": "", - "repr": ( - "Nightlight(duration=60, fade=True, on=False," - " mode=, target_brightness=0)" - ), - }, - "on": True, - "playlist": -1, - "preset": -1, - "segments": [ - { - "__type": "", - "repr": ( - "Segment(brightness=127, clones=-1," - " color_primary=(255, 159, 0)," - " color_secondary=(0, 0, 0)," - " color_tertiary=(0, 0, 0)," - " effect=Effect(effect_id=0, name='Solid')," - " intensity=128, length=20, on=True," - " palette=Palette(name='Default', palette_id=0)," - " reverse=False, segment_id=0, selected=True," - " speed=32, start=0, stop=19)" - ), - }, - { - "__type": "", - "repr": ( - "Segment(brightness=127, clones=-1," - " color_primary=(0, 255, 123)," - " color_secondary=(0, 0, 0)," - " color_tertiary=(0, 0, 0)," - " effect=Effect(effect_id=1, name='Blink')," - " intensity=64, length=10, on=True," - " palette=Palette(name='Random Cycle', palette_id=1)," - " reverse=True, segment_id=1, selected=True," - " speed=16, start=20, stop=30)" - ), - }, - ], - "sync": { - "__type": "", - "repr": "Sync(receive=True, send=False)", - }, - "transition": 7, - "lor": 0, - }, - "effects": { - "27": "Android", - "68": "BPM", - "1": "Blink", - "26": "Blink Rainbow", - "2": "Breathe", - "13": "Chase", - "28": "Chase", - "31": "Chase Flash", - "32": "Chase Flash Rnd", - "14": "Chase Rainbow", - "30": "Chase Rainbow", - "29": "Chase Random", - "52": "Circus", - "34": "Colorful", - "8": "Colorloop", - "74": "Colortwinkle", - "67": "Colorwaves", - "21": "Dark Sparkle", - "18": "Dissolve", - "19": "Dissolve Rnd", - "11": "Dual Scan", - "60": "Dual Scanner", - "7": "Dynamic", - "12": "Fade", - "69": "Fill Noise", - "66": "Fire 2012", - "45": "Fire Flicker", - "42": "Fireworks", - "46": "Gradient", - "53": "Halloween", - "58": "ICU", - "49": "In In", - "48": "In Out", - "64": "Juggle", - "75": "Lake", - "41": "Lighthouse", - "57": "Lightning", - "47": "Loading", - "25": "Mega Strobe", - "44": "Merry Christmas", - "76": "Meteor", - "59": "Multi Comet", - "70": "Noise 1", - "71": "Noise 2", - "72": "Noise 3", - "73": "Noise 4", - "62": "Oscillate", - "51": "Out In", - "50": "Out Out", - "65": "Palette", - "63": "Pride 2015", - "78": "Railway", - "43": "Rain", - "9": "Rainbow", - "33": "Rainbow Runner", - "5": "Random Colors", - "38": "Red & Blue", - "79": "Ripple", - "15": "Running", - "37": "Running 2", - "16": "Saw", - "10": "Scan", - "40": "Scanner", - "77": "Smooth Meteor", - "0": "Solid", - "20": "Sparkle", - "22": "Sparkle+", - "39": "Stream", - "61": "Stream 2", - "23": "Strobe", - "24": "Strobe Rainbow", - "6": "Sweep", - "36": "Sweep Random", - "35": "Traffic Light", - "54": "Tri Chase", - "56": "Tri Fade", - "55": "Tri Wipe", - "17": "Twinkle", - "80": "Twinklefox", - "3": "Wipe", - "4": "Wipe Random", - }, - "palettes": { - "18": "Analogous", - "46": "April Night", - "39": "Autumn", - "3": "Based on Primary", - "5": "Based on Set", - "26": "Beach", - "22": "Beech", - "15": "Breeze", - "48": "C9", - "7": "Cloud", - "37": "Cyane", - "0": "Default", - "24": "Departure", - "30": "Drywet", - "35": "Fire", - "10": "Forest", - "32": "Grintage", - "28": "Hult", - "29": "Hult 64", - "36": "Icefire", - "31": "Jul", - "25": "Landscape", - "8": "Lava", - "38": "Light Pink", - "40": "Magenta", - "41": "Magred", - "9": "Ocean", - "44": "Orange & Teal", - "47": "Orangery", - "6": "Party", - "20": "Pastel", - "2": "Primary Color", - "11": "Rainbow", - "12": "Rainbow Bands", - "1": "Random Cycle", - "16": "Red & Blue", - "33": "Rewhi", - "14": "Rivendell", - "49": "Sakura", - "4": "Set Colors", - "27": "Sherbet", - "19": "Splash", - "13": "Sunset", - "21": "Sunset 2", - "34": "Tertiary", - "45": "Tiamat", - "23": "Vintage", - "43": "Yelblu", - "17": "Yellowout", - "42": "Yelmag", - }, - "playlists": {}, - "presets": {}, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) From 34f8e94ca9be9d660751cb259dea1045ffe39a5d Mon Sep 17 00:00:00 2001 From: Garrett <7310260+G-Two@users.noreply.github.com> Date: Sat, 4 Mar 2023 16:26:16 -0500 Subject: [PATCH 0628/2810] Bump subarulink to 0.7.5 (#89162) --- homeassistant/components/subaru/__init__.py | 7 ++-- homeassistant/components/subaru/const.py | 3 +- homeassistant/components/subaru/manifest.json | 2 +- homeassistant/components/subaru/sensor.py | 33 +++++++++++++------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/subaru/api_responses.py | 25 ++++++++++---- tests/components/subaru/conftest.py | 14 ++++++++ .../fixtures/diagnostics_config_entry.json | 2 +- .../subaru/fixtures/diagnostics_device.json | 2 +- tests/components/subaru/test_init.py | 14 ++++---- 11 files changed, 71 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/subaru/__init__.py b/homeassistant/components/subaru/__init__.py index 3e72b079ad..49ad3cf0d9 100644 --- a/homeassistant/components/subaru/__init__.py +++ b/homeassistant/components/subaru/__init__.py @@ -66,7 +66,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: vehicle_info = {} for vin in controller.get_vehicles(): - vehicle_info[vin] = get_vehicle_info(controller, vin) + if controller.get_subscription_status(vin): + vehicle_info[vin] = get_vehicle_info(controller, vin) async def async_update_data(): """Fetch data from API endpoint.""" @@ -116,10 +117,6 @@ async def refresh_subaru_data(config_entry, vehicle_info, controller): for vehicle in vehicle_info.values(): vin = vehicle[VEHICLE_VIN] - # Active subscription required - if not vehicle[VEHICLE_HAS_SAFETY_SERVICE]: - continue - # Optionally send an "update" remote command to vehicle (throttled with update_interval) if config_entry.options.get(CONF_UPDATE_ENABLED, False): await update_subaru(vehicle, controller) diff --git a/homeassistant/components/subaru/const.py b/homeassistant/components/subaru/const.py index 3de4930a69..42badfc018 100644 --- a/homeassistant/components/subaru/const.py +++ b/homeassistant/components/subaru/const.py @@ -28,11 +28,12 @@ VEHICLE_HAS_REMOTE_START = "has_res" VEHICLE_HAS_REMOTE_SERVICE = "has_remote" VEHICLE_HAS_SAFETY_SERVICE = "has_safety" VEHICLE_LAST_UPDATE = "last_update" -VEHICLE_STATUS = "status" +VEHICLE_STATUS = "vehicle_status" API_GEN_1 = "g1" API_GEN_2 = "g2" +API_GEN_3 = "g3" MANUFACTURER = "Subaru" PLATFORMS = [ diff --git a/homeassistant/components/subaru/manifest.json b/homeassistant/components/subaru/manifest.json index 1aade94654..5852136ca4 100644 --- a/homeassistant/components/subaru/manifest.json +++ b/homeassistant/components/subaru/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/subaru", "iot_class": "cloud_polling", "loggers": ["stdiomask", "subarulink"], - "requirements": ["subarulink==0.7.0"] + "requirements": ["subarulink==0.7.5"] } diff --git a/homeassistant/components/subaru/sensor.py b/homeassistant/components/subaru/sensor.py index 5479f56cf9..6c8e8fc100 100644 --- a/homeassistant/components/subaru/sensor.py +++ b/homeassistant/components/subaru/sensor.py @@ -31,12 +31,12 @@ from homeassistant.util.unit_system import ( from . import get_device_info from .const import ( API_GEN_2, + API_GEN_3, DOMAIN, ENTRY_COORDINATOR, ENTRY_VEHICLES, VEHICLE_API_GEN, VEHICLE_HAS_EV, - VEHICLE_HAS_SAFETY_SERVICE, VEHICLE_STATUS, VEHICLE_VIN, ) @@ -51,7 +51,7 @@ FUEL_CONSUMPTION_MILES_PER_GALLON = "mi/gal" L_PER_GAL = VolumeConverter.convert(1, UnitOfVolume.GALLONS, UnitOfVolume.LITERS) KM_PER_MI = DistanceConverter.convert(1, UnitOfLength.MILES, UnitOfLength.KILOMETERS) -# Sensor available to "Subaru Safety Plus" subscribers with Gen1 or Gen2 vehicles +# Sensor available for Gen1 or Gen2 vehicles SAFETY_SENSORS = [ SensorEntityDescription( key=sc.ODOMETER, @@ -63,7 +63,7 @@ SAFETY_SENSORS = [ ), ] -# Sensors available to "Subaru Safety Plus" subscribers with Gen2 vehicles +# Sensors available to subscribers with Gen2/Gen3 vehicles API_GEN_2_SENSORS = [ SensorEntityDescription( key=sc.AVG_FUEL_CONSUMPTION, @@ -110,7 +110,18 @@ API_GEN_2_SENSORS = [ ), ] -# Sensors available to "Subaru Safety Plus" subscribers with PHEV vehicles +# Sensors available for Gen3 vehicles +API_GEN_3_SENSORS = [ + SensorEntityDescription( + key=sc.REMAINING_FUEL_PERCENT, + icon="mdi:gas-station", + name="Fuel level", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), +] + +# Sensors available to subscribers with PHEV vehicles EV_SENSORS = [ SensorEntityDescription( key=sc.EV_DISTANCE_TO_EMPTY, @@ -156,14 +167,16 @@ def create_vehicle_sensors( ) -> list[SubaruSensor]: """Instantiate all available sensors for the vehicle.""" sensor_descriptions_to_add = [] - if vehicle_info[VEHICLE_HAS_SAFETY_SERVICE]: - sensor_descriptions_to_add.extend(SAFETY_SENSORS) + sensor_descriptions_to_add.extend(SAFETY_SENSORS) - if vehicle_info[VEHICLE_API_GEN] == API_GEN_2: - sensor_descriptions_to_add.extend(API_GEN_2_SENSORS) + if vehicle_info[VEHICLE_API_GEN] in [API_GEN_2, API_GEN_3]: + sensor_descriptions_to_add.extend(API_GEN_2_SENSORS) - if vehicle_info[VEHICLE_HAS_EV]: - sensor_descriptions_to_add.extend(EV_SENSORS) + if vehicle_info[VEHICLE_API_GEN] == API_GEN_3: + sensor_descriptions_to_add.extend(API_GEN_3_SENSORS) + + if vehicle_info[VEHICLE_HAS_EV]: + sensor_descriptions_to_add.extend(EV_SENSORS) return [ SubaruSensor( diff --git a/requirements_all.txt b/requirements_all.txt index 5c01403688..96bd1d20f0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2434,7 +2434,7 @@ streamlabswater==1.0.1 stringcase==1.2.0 # homeassistant.components.subaru -subarulink==0.7.0 +subarulink==0.7.5 # homeassistant.components.solarlog sunwatcher==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b458207aac..896ded1d1c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1734,7 +1734,7 @@ stookwijzer==1.3.0 stringcase==1.2.0 # homeassistant.components.subaru -subarulink==0.7.0 +subarulink==0.7.5 # homeassistant.components.solarlog sunwatcher==0.2.1 diff --git a/tests/components/subaru/api_responses.py b/tests/components/subaru/api_responses.py index 315530c15b..e2fdf9ae50 100644 --- a/tests/components/subaru/api_responses.py +++ b/tests/components/subaru/api_responses.py @@ -5,22 +5,28 @@ from datetime import datetime, timezone from homeassistant.components.subaru.const import ( API_GEN_1, API_GEN_2, + API_GEN_3, VEHICLE_API_GEN, VEHICLE_HAS_EV, VEHICLE_HAS_REMOTE_SERVICE, VEHICLE_HAS_REMOTE_START, VEHICLE_HAS_SAFETY_SERVICE, + VEHICLE_MODEL_NAME, + VEHICLE_MODEL_YEAR, VEHICLE_NAME, + VEHICLE_STATUS, VEHICLE_VIN, ) TEST_VIN_1_G1 = "JF2ABCDE6L0000001" TEST_VIN_2_EV = "JF2ABCDE6L0000002" -TEST_VIN_3_G2 = "JF2ABCDE6L0000003" +TEST_VIN_3_G3 = "JF2ABCDE6L0000003" VEHICLE_DATA = { TEST_VIN_1_G1: { VEHICLE_VIN: TEST_VIN_1_G1, + VEHICLE_MODEL_YEAR: "2017", + VEHICLE_MODEL_NAME: "Outback", VEHICLE_NAME: "test_vehicle_1", VEHICLE_HAS_EV: False, VEHICLE_API_GEN: API_GEN_1, @@ -30,6 +36,8 @@ VEHICLE_DATA = { }, TEST_VIN_2_EV: { VEHICLE_VIN: TEST_VIN_2_EV, + VEHICLE_MODEL_YEAR: "2019", + VEHICLE_MODEL_NAME: "Crosstrek", VEHICLE_NAME: "test_vehicle_2", VEHICLE_HAS_EV: True, VEHICLE_API_GEN: API_GEN_2, @@ -37,11 +45,13 @@ VEHICLE_DATA = { VEHICLE_HAS_REMOTE_SERVICE: True, VEHICLE_HAS_SAFETY_SERVICE: True, }, - TEST_VIN_3_G2: { - VEHICLE_VIN: TEST_VIN_3_G2, + TEST_VIN_3_G3: { + VEHICLE_VIN: TEST_VIN_3_G3, + VEHICLE_MODEL_YEAR: "2022", + VEHICLE_MODEL_NAME: "Ascent", VEHICLE_NAME: "test_vehicle_3", VEHICLE_HAS_EV: False, - VEHICLE_API_GEN: API_GEN_2, + VEHICLE_API_GEN: API_GEN_3, VEHICLE_HAS_REMOTE_START: True, VEHICLE_HAS_REMOTE_SERVICE: True, VEHICLE_HAS_SAFETY_SERVICE: True, @@ -51,7 +61,7 @@ VEHICLE_DATA = { MOCK_DATETIME = datetime.fromtimestamp(1595560000, timezone.utc) VEHICLE_STATUS_EV = { - "status": { + VEHICLE_STATUS: { "AVG_FUEL_CONSUMPTION": 2.3, "DISTANCE_TO_EMPTY_FUEL": 707, "DOOR_BOOT_LOCK_STATUS": "UNKNOWN", @@ -120,8 +130,8 @@ VEHICLE_STATUS_EV = { } -VEHICLE_STATUS_G2 = { - "status": { +VEHICLE_STATUS_G3 = { + VEHICLE_STATUS: { "AVG_FUEL_CONSUMPTION": 2.3, "DISTANCE_TO_EMPTY_FUEL": 707, "DOOR_BOOT_LOCK_STATUS": "UNKNOWN", @@ -136,6 +146,7 @@ VEHICLE_STATUS_G2 = { "DOOR_REAR_LEFT_POSITION": "CLOSED", "DOOR_REAR_RIGHT_LOCK_STATUS": "UNKNOWN", "DOOR_REAR_RIGHT_POSITION": "CLOSED", + "REMAINING_FUEL_PERCENT": 77, "ODOMETER": 1234, "POSITION_HEADING_DEGREE": 150, "POSITION_SPEED_KMPH": "0", diff --git a/tests/components/subaru/conftest.py b/tests/components/subaru/conftest.py index 94b803fea0..678e8ba503 100644 --- a/tests/components/subaru/conftest.py +++ b/tests/components/subaru/conftest.py @@ -17,6 +17,8 @@ from homeassistant.components.subaru.const import ( VEHICLE_HAS_REMOTE_SERVICE, VEHICLE_HAS_REMOTE_START, VEHICLE_HAS_SAFETY_SERVICE, + VEHICLE_MODEL_NAME, + VEHICLE_MODEL_YEAR, VEHICLE_NAME, ) from homeassistant.config_entries import ConfigEntryState @@ -40,10 +42,13 @@ MOCK_API_UPDATE_SAVED_PIN = f"{MOCK_API}update_saved_pin" MOCK_API_GET_VEHICLES = f"{MOCK_API}get_vehicles" MOCK_API_VIN_TO_NAME = f"{MOCK_API}vin_to_name" MOCK_API_GET_API_GEN = f"{MOCK_API}get_api_gen" +MOCK_API_GET_MODEL_NAME = f"{MOCK_API}get_model_name" +MOCK_API_GET_MODEL_YEAR = f"{MOCK_API}get_model_year" MOCK_API_GET_EV_STATUS = f"{MOCK_API}get_ev_status" MOCK_API_GET_RES_STATUS = f"{MOCK_API}get_res_status" MOCK_API_GET_REMOTE_STATUS = f"{MOCK_API}get_remote_status" MOCK_API_GET_SAFETY_STATUS = f"{MOCK_API}get_safety_status" +MOCK_API_GET_SUBSCRIPTION_STATUS = f"{MOCK_API}get_subscription_status" MOCK_API_GET_DATA = f"{MOCK_API}get_data" MOCK_API_UPDATE = f"{MOCK_API}update" MOCK_API_FETCH = f"{MOCK_API}fetch" @@ -114,6 +119,12 @@ async def setup_subaru_config_entry( ), patch( MOCK_API_GET_API_GEN, return_value=vehicle_data[VEHICLE_API_GEN], + ), patch( + MOCK_API_GET_MODEL_NAME, + return_value=vehicle_data[VEHICLE_MODEL_NAME], + ), patch( + MOCK_API_GET_MODEL_YEAR, + return_value=vehicle_data[VEHICLE_MODEL_YEAR], ), patch( MOCK_API_GET_EV_STATUS, return_value=vehicle_data[VEHICLE_HAS_EV], @@ -126,6 +137,9 @@ async def setup_subaru_config_entry( ), patch( MOCK_API_GET_SAFETY_STATUS, return_value=vehicle_data[VEHICLE_HAS_SAFETY_SERVICE], + ), patch( + MOCK_API_GET_SUBSCRIPTION_STATUS, + return_value=True, ), patch( MOCK_API_GET_DATA, return_value=vehicle_status, diff --git a/tests/components/subaru/fixtures/diagnostics_config_entry.json b/tests/components/subaru/fixtures/diagnostics_config_entry.json index 32e9ac070b..327b0c4817 100644 --- a/tests/components/subaru/fixtures/diagnostics_config_entry.json +++ b/tests/components/subaru/fixtures/diagnostics_config_entry.json @@ -11,7 +11,7 @@ }, "data": [ { - "status": { + "vehicle_status": { "AVG_FUEL_CONSUMPTION": 2.3, "DISTANCE_TO_EMPTY_FUEL": 707, "DOOR_BOOT_LOCK_STATUS": "UNKNOWN", diff --git a/tests/components/subaru/fixtures/diagnostics_device.json b/tests/components/subaru/fixtures/diagnostics_device.json index c3762925d0..f67be94a17 100644 --- a/tests/components/subaru/fixtures/diagnostics_device.json +++ b/tests/components/subaru/fixtures/diagnostics_device.json @@ -10,7 +10,7 @@ "update_enabled": true }, "data": { - "status": { + "vehicle_status": { "AVG_FUEL_CONSUMPTION": 2.3, "DISTANCE_TO_EMPTY_FUEL": 707, "DOOR_BOOT_LOCK_STATUS": "UNKNOWN", diff --git a/tests/components/subaru/test_init.py b/tests/components/subaru/test_init.py index 1723bd062f..e82d7a1d72 100644 --- a/tests/components/subaru/test_init.py +++ b/tests/components/subaru/test_init.py @@ -16,10 +16,10 @@ from homeassistant.setup import async_setup_component from .api_responses import ( TEST_VIN_1_G1, TEST_VIN_2_EV, - TEST_VIN_3_G2, + TEST_VIN_3_G3, VEHICLE_DATA, VEHICLE_STATUS_EV, - VEHICLE_STATUS_G2, + VEHICLE_STATUS_G3, ) from .conftest import ( MOCK_API_FETCH, @@ -43,14 +43,14 @@ async def test_setup_ev(hass: HomeAssistant, ev_entry) -> None: assert check_entry.state is ConfigEntryState.LOADED -async def test_setup_g2(hass: HomeAssistant, subaru_config_entry) -> None: - """Test setup with a G2 vehcile .""" +async def test_setup_g3(hass: HomeAssistant, subaru_config_entry) -> None: + """Test setup with a G3 vehicle .""" await setup_subaru_config_entry( hass, subaru_config_entry, - vehicle_list=[TEST_VIN_3_G2], - vehicle_data=VEHICLE_DATA[TEST_VIN_3_G2], - vehicle_status=VEHICLE_STATUS_G2, + vehicle_list=[TEST_VIN_3_G3], + vehicle_data=VEHICLE_DATA[TEST_VIN_3_G3], + vehicle_status=VEHICLE_STATUS_G3, ) check_entry = hass.config_entries.async_get_entry(subaru_config_entry.entry_id) assert check_entry From bfadc8453d6a396200cbc15f5027bfd6469d067d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 5 Mar 2023 02:41:31 +0100 Subject: [PATCH 0629/2810] Clean up import/migration repair in LaMetric (#89153) --- homeassistant/components/lametric/__init__.py | 31 ++----------------- .../components/lametric/strings.json | 6 ---- tests/components/lametric/test_init.py | 22 ------------- 3 files changed, 2 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/lametric/__init__.py b/homeassistant/components/lametric/__init__.py index 5fd531234b..867b80cf40 100644 --- a/homeassistant/components/lametric/__init__.py +++ b/homeassistant/components/lametric/__init__.py @@ -1,50 +1,23 @@ """Support for LaMetric time.""" -import voluptuous as vol - from homeassistant.components import notify as hass_notify from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_NAME, Platform +from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, PLATFORMS from .coordinator import LaMetricDataUpdateCoordinator from .services import async_setup_services -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_CLIENT_ID): cv.string, - vol.Required(CONF_CLIENT_SECRET): cv.string, - } - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the LaMetric integration.""" async_setup_services(hass) hass.data[DOMAIN] = {"hass_config": config} - if DOMAIN in config: - async_create_issue( - hass, - DOMAIN, - "manual_migration", - breaks_in_ha_version="2022.9.0", - is_fixable=False, - severity=IssueSeverity.ERROR, - translation_key="manual_migration", - ) - return True diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index f20732c634..eb90b21ff2 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -44,12 +44,6 @@ "unknown": "[%key:common::config_flow::error::unknown%]" } }, - "issues": { - "manual_migration": { - "title": "Manual migration required for LaMetric", - "description": "The LaMetric integration has been modernized: It is now configured and set up via the user interface and the communcations are now local.\n\nUnfortunately, there is no automatic migration path possible and thus requires you to re-set up your LaMetric with Home Assistant. Please consult the Home Assistant LaMetric integration documentation on how to set it up.\n\nRemove the old LaMetric YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } - }, "entity": { "select": { "brightness_mode": { diff --git a/tests/components/lametric/test_init.py b/tests/components/lametric/test_init.py index 50695fc4e5..eee09b3acc 100644 --- a/tests/components/lametric/test_init.py +++ b/tests/components/lametric/test_init.py @@ -1,8 +1,6 @@ """Tests for the LaMetric integration.""" -from collections.abc import Awaitable, Callable from unittest.mock import MagicMock -from aiohttp import ClientWebSocketResponse from demetriek import ( LaMetricAuthenticationError, LaMetricConnectionError, @@ -12,12 +10,9 @@ import pytest from homeassistant.components.lametric.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -from tests.components.repairs import get_repairs async def test_load_unload_config_entry( @@ -59,23 +54,6 @@ async def test_config_entry_not_ready( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_yaml_config_raises_repairs( - hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], - caplog: pytest.LogCaptureFixture, -) -> None: - """Test that YAML configuration raises an repairs issue.""" - await async_setup_component( - hass, DOMAIN, {DOMAIN: {CONF_CLIENT_ID: "foo", CONF_CLIENT_SECRET: "bar"}} - ) - - assert "The 'lametric' option is deprecated" in caplog.text - - issues = await get_repairs(hass, hass_ws_client) - assert len(issues) == 1 - assert issues[0]["issue_id"] == "manual_migration" - - async def test_config_entry_authentication_failed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From ec0223f32679abb7ab6494e72bfa673a001bc241 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 5 Mar 2023 05:38:26 +0100 Subject: [PATCH 0630/2810] Cleanup plex config flow tests (#88991) --- tests/components/plex/test_config_flow.py | 219 +++++++++------------- tests/components/plex/test_init.py | 22 +++ 2 files changed, 115 insertions(+), 126 deletions(-) diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index 288c95dfbf..36c9ab614f 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -39,6 +39,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from .const import DEFAULT_OPTIONS, MOCK_SERVERS, MOCK_TOKEN, PLEX_DIRECT_URL from .helpers import trigger_plex_update, wait_for_debouncer @@ -55,7 +56,7 @@ async def test_bad_credentials( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" with patch( @@ -66,14 +67,14 @@ async def test_bad_credentials( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - assert result["type"] == "external" + assert result["type"] == FlowResultType.EXTERNAL_STEP result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "external_done" + assert result["type"] == FlowResultType.EXTERNAL_STEP_DONE result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"][CONF_TOKEN] == "faulty_credentials" @@ -85,7 +86,7 @@ async def test_bad_hostname( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" with patch( @@ -97,14 +98,14 @@ async def test_bad_hostname( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - assert result["type"] == "external" + assert result["type"] == FlowResultType.EXTERNAL_STEP result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "external_done" + assert result["type"] == FlowResultType.EXTERNAL_STEP_DONE result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"][CONF_HOST] == "not_found" @@ -116,7 +117,7 @@ async def test_unknown_exception( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" with patch("plexapi.myplex.MyPlexAccount", side_effect=Exception), patch( @@ -125,13 +126,13 @@ async def test_unknown_exception( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - assert result["type"] == "external" + assert result["type"] == FlowResultType.EXTERNAL_STEP result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "external_done" + assert result["type"] == FlowResultType.EXTERNAL_STEP_DONE result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "abort" + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "unknown" @@ -148,7 +149,7 @@ async def test_no_servers_found( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" with patch("plexauth.PlexAuth.initiate_auth"), patch( @@ -157,13 +158,13 @@ async def test_no_servers_found( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - assert result["type"] == "external" + assert result["type"] == FlowResultType.EXTERNAL_STEP result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "external_done" + assert result["type"] == FlowResultType.EXTERNAL_STEP_DONE result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "no_servers" @@ -175,7 +176,7 @@ async def test_single_available_server( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" with patch("plexauth.PlexAuth.initiate_auth"), patch( @@ -184,25 +185,22 @@ async def test_single_available_server( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - assert result["type"] == "external" + assert result["type"] == FlowResultType.EXTERNAL_STEP result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "external_done" + assert result["type"] == FlowResultType.EXTERNAL_STEP_DONE result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "create_entry" + assert result["type"] == FlowResultType.CREATE_ENTRY - server_id = result["data"][CONF_SERVER_IDENTIFIER] - mock_plex_server = hass.data[DOMAIN][SERVERS][server_id] - - assert result["title"] == mock_plex_server.url_in_use - assert result["data"][CONF_SERVER] == mock_plex_server.friendly_name assert ( - result["data"][CONF_SERVER_IDENTIFIER] - == mock_plex_server.machine_identifier + result["title"] == "https://1-2-3-4.123456789001234567890.plex.direct:32400" ) + assert result["data"][CONF_SERVER] == "Plex Server 1" + assert result["data"][CONF_SERVER_IDENTIFIER] == "unique_id_123" assert ( - result["data"][PLEX_SERVER_CONFIG][CONF_URL] == mock_plex_server.url_in_use + result["data"][PLEX_SERVER_CONFIG][CONF_URL] + == "https://1-2-3-4.123456789001234567890.plex.direct:32400" ) assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN @@ -220,7 +218,7 @@ async def test_multiple_servers_with_selection( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" requests_mock.get( @@ -233,13 +231,13 @@ async def test_multiple_servers_with_selection( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - assert result["type"] == "external" + assert result["type"] == FlowResultType.EXTERNAL_STEP result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "external_done" + assert result["type"] == FlowResultType.EXTERNAL_STEP_DONE result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "select_server" result = await hass.config_entries.flow.async_configure( @@ -248,19 +246,16 @@ async def test_multiple_servers_with_selection( CONF_SERVER_IDENTIFIER: MOCK_SERVERS[0][CONF_SERVER_IDENTIFIER] }, ) - assert result["type"] == "create_entry" + assert result["type"] == FlowResultType.CREATE_ENTRY - server_id = result["data"][CONF_SERVER_IDENTIFIER] - mock_plex_server = hass.data[DOMAIN][SERVERS][server_id] - - assert result["title"] == mock_plex_server.url_in_use - assert result["data"][CONF_SERVER] == mock_plex_server.friendly_name assert ( - result["data"][CONF_SERVER_IDENTIFIER] - == mock_plex_server.machine_identifier + result["title"] == "https://1-2-3-4.123456789001234567890.plex.direct:32400" ) + assert result["data"][CONF_SERVER] == "Plex Server 1" + assert result["data"][CONF_SERVER_IDENTIFIER] == "unique_id_123" assert ( - result["data"][PLEX_SERVER_CONFIG][CONF_URL] == mock_plex_server.url_in_use + result["data"][PLEX_SERVER_CONFIG][CONF_URL] + == "https://1-2-3-4.123456789001234567890.plex.direct:32400" ) assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN @@ -286,7 +281,7 @@ async def test_adding_last_unconfigured_server( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" requests_mock.get( @@ -300,25 +295,22 @@ async def test_adding_last_unconfigured_server( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - assert result["type"] == "external" + assert result["type"] == FlowResultType.EXTERNAL_STEP result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "external_done" + assert result["type"] == FlowResultType.EXTERNAL_STEP_DONE result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "create_entry" + assert result["type"] == FlowResultType.CREATE_ENTRY - server_id = result["data"][CONF_SERVER_IDENTIFIER] - mock_plex_server = hass.data[DOMAIN][SERVERS][server_id] - - assert result["title"] == mock_plex_server.url_in_use - assert result["data"][CONF_SERVER] == mock_plex_server.friendly_name assert ( - result["data"][CONF_SERVER_IDENTIFIER] - == mock_plex_server.machine_identifier + result["title"] == "https://1-2-3-4.123456789001234567890.plex.direct:32400" ) + assert result["data"][CONF_SERVER] == "Plex Server 1" + assert result["data"][CONF_SERVER_IDENTIFIER] == "unique_id_123" assert ( - result["data"][PLEX_SERVER_CONFIG][CONF_URL] == mock_plex_server.url_in_use + result["data"][PLEX_SERVER_CONFIG][CONF_URL] + == "https://1-2-3-4.123456789001234567890.plex.direct:32400" ) assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN @@ -347,7 +339,7 @@ async def test_all_available_servers_configured( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" requests_mock.get("https://plex.tv/users/account", text=plextv_account) @@ -362,13 +354,13 @@ async def test_all_available_servers_configured( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - assert result["type"] == "external" + assert result["type"] == FlowResultType.EXTERNAL_STEP result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "external_done" + assert result["type"] == FlowResultType.EXTERNAL_STEP_DONE result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "abort" + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "all_configured" @@ -380,7 +372,7 @@ async def test_option_flow(hass: HomeAssistant, entry, mock_plex_server) -> None result = await hass.config_entries.options.async_init( entry.entry_id, context={"source": "test"}, data=None ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "plex_mp_settings" result = await hass.config_entries.options.async_configure( @@ -391,7 +383,7 @@ async def test_option_flow(hass: HomeAssistant, entry, mock_plex_server) -> None CONF_MONITORED_USERS: list(mock_plex_server.accounts), }, ) - assert result["type"] == "create_entry" + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["data"] == { Platform.MEDIA_PLAYER: { CONF_USE_EPISODE_ART: True, @@ -414,7 +406,7 @@ async def test_missing_option_flow( result = await hass.config_entries.options.async_init( entry.entry_id, context={"source": "test"}, data=None ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "plex_mp_settings" result = await hass.config_entries.options.async_configure( @@ -425,7 +417,7 @@ async def test_missing_option_flow( CONF_MONITORED_USERS: list(mock_plex_server.accounts), }, ) - assert result["type"] == "create_entry" + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["data"] == { Platform.MEDIA_PLAYER: { CONF_USE_EPISODE_ART: True, @@ -451,7 +443,7 @@ async def test_option_flow_new_users_available( mock_plex_server = await setup_plex_server(config_entry=entry) await hass.async_block_till_done() - server_id = mock_plex_server.machine_identifier + server_id = "unique_id_123" monitored_users = hass.data[DOMAIN][SERVERS][server_id].option_monitored_users new_users = [x for x in mock_plex_server.accounts if x not in monitored_users] @@ -461,7 +453,7 @@ async def test_option_flow_new_users_available( result = await hass.config_entries.options.async_init( entry.entry_id, context={"source": "test"}, data=None ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "plex_mp_settings" multiselect_defaults = result["data_schema"].schema["monitored_users"].options @@ -477,7 +469,7 @@ async def test_external_timed_out( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" with patch("plexauth.PlexAuth.initiate_auth"), patch( @@ -486,13 +478,13 @@ async def test_external_timed_out( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - assert result["type"] == "external" + assert result["type"] == FlowResultType.EXTERNAL_STEP result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "external_done" + assert result["type"] == FlowResultType.EXTERNAL_STEP_DONE result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "abort" + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "token_request_timeout" @@ -505,7 +497,7 @@ async def test_callback_view( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" with patch("plexauth.PlexAuth.initiate_auth"), patch( @@ -514,7 +506,7 @@ async def test_callback_view( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - assert result["type"] == "external" + assert result["type"] == FlowResultType.EXTERNAL_STEP client = await hass_client_no_auth() forward_url = f'{config_flow.AUTH_CALLBACK_PATH}?flow_id={result["flow_id"]}' @@ -541,7 +533,7 @@ async def test_manual_config( config_flow.DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["data_schema"] is None hass.config_entries.flow.async_abort(result["flow_id"]) @@ -553,7 +545,7 @@ async def test_manual_config( ) assert result["data_schema"] is not None - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user_advanced" with patch("plexauth.PlexAuth.initiate_auth"): @@ -561,7 +553,7 @@ async def test_manual_config( result["flow_id"], user_input={"setup_method": AUTOMATIC_SETUP_STRING} ) - assert result["type"] == "external" + assert result["type"] == FlowResultType.EXTERNAL_STEP hass.config_entries.flow.async_abort(result["flow_id"]) # Advanced manual @@ -571,14 +563,14 @@ async def test_manual_config( ) assert result["data_schema"] is not None - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user_advanced" result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"setup_method": MANUAL_SETUP_STRING} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "manual_setup" MANUAL_SERVER = { @@ -599,7 +591,7 @@ async def test_manual_config( result["flow_id"], user_input=MANUAL_SERVER_NO_HOST_OR_TOKEN ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "manual_setup" assert result["errors"]["base"] == "host_or_token" @@ -611,7 +603,7 @@ async def test_manual_config( result["flow_id"], user_input=MANUAL_SERVER ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "manual_setup" assert result["errors"]["base"] == "ssl_error" @@ -623,7 +615,7 @@ async def test_manual_config( result["flow_id"], user_input=MANUAL_SERVER ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "manual_setup" assert result["errors"]["base"] == "ssl_error" @@ -635,7 +627,7 @@ async def test_manual_config( result["flow_id"], user_input=MANUAL_SERVER ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "manual_setup" assert result["errors"]["base"] == "ssl_error" @@ -647,15 +639,12 @@ async def test_manual_config( ) await hass.async_block_till_done() - assert result["type"] == "create_entry" + assert result["type"] == FlowResultType.CREATE_ENTRY - server_id = result["data"][CONF_SERVER_IDENTIFIER] - mock_plex_server = hass.data[DOMAIN][SERVERS][server_id] - - assert result["title"] == mock_plex_server.url_in_use - assert result["data"][CONF_SERVER] == mock_plex_server.friendly_name - assert result["data"][CONF_SERVER_IDENTIFIER] == mock_plex_server.machine_identifier - assert result["data"][PLEX_SERVER_CONFIG][CONF_URL] == mock_plex_server.url_in_use + assert result["title"] == "http://1.2.3.4:32400" + assert result["data"][CONF_SERVER] == "Plex Server 1" + assert result["data"][CONF_SERVER_IDENTIFIER] == "unique_id_123" + assert result["data"][PLEX_SERVER_CONFIG][CONF_URL] == "http://1.2.3.4:32400" assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN @@ -673,14 +662,14 @@ async def test_manual_config_with_token( context={"source": SOURCE_USER, "show_advanced_options": True}, ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user_advanced" result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"setup_method": MANUAL_SETUP_STRING} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "manual_setup" with patch( @@ -690,15 +679,13 @@ async def test_manual_config_with_token( result["flow_id"], user_input={CONF_TOKEN: MOCK_TOKEN} ) - assert result["type"] == "create_entry" + assert result["type"] == FlowResultType.CREATE_ENTRY - server_id = result["data"][CONF_SERVER_IDENTIFIER] - mock_plex_server = hass.data[DOMAIN][SERVERS][server_id] - mock_url = mock_plex_server.url_in_use + mock_url = "https://1-2-3-4.123456789001234567890.plex.direct:32400" assert result["title"] == mock_url - assert result["data"][CONF_SERVER] == mock_plex_server.friendly_name - assert result["data"][CONF_SERVER_IDENTIFIER] == mock_plex_server.machine_identifier + assert result["data"][CONF_SERVER] == "Plex Server 1" + assert result["data"][CONF_SERVER_IDENTIFIER] == "unique_id_123" assert result["data"][PLEX_SERVER_CONFIG][CONF_URL] == mock_url assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN @@ -708,26 +695,6 @@ async def test_manual_config_with_token( await hass.async_block_till_done() -async def test_setup_with_limited_credentials( - hass: HomeAssistant, entry, setup_plex_server -) -> None: - """Test setup with a user with limited permissions.""" - with patch( - "plexapi.server.PlexServer.systemAccounts", - side_effect=plexapi.exceptions.Unauthorized, - ) as mock_accounts: - mock_plex_server = await setup_plex_server() - - assert mock_accounts.called - - plex_server = hass.data[DOMAIN][SERVERS][mock_plex_server.machine_identifier] - assert len(plex_server.accounts) == 0 - assert plex_server.owner is None - - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert entry.state is ConfigEntryState.LOADED - - async def test_integration_discovery(hass: HomeAssistant) -> None: """Test integration self-discovery.""" mock_gdm = MockGDM() @@ -781,13 +748,13 @@ async def test_trigger_reauth( "plexauth.PlexAuth.token", return_value="BRAND_NEW_TOKEN" ): result = await hass.config_entries.flow.async_configure(flow_id, user_input={}) - assert result["type"] == "external" + assert result["type"] == FlowResultType.EXTERNAL_STEP result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "external_done" + assert result["type"] == FlowResultType.EXTERNAL_STEP_DONE result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "abort" + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "reauth_successful" assert result["flow_id"] == flow_id @@ -795,8 +762,8 @@ async def test_trigger_reauth( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert entry.state is ConfigEntryState.LOADED - assert entry.data[CONF_SERVER] == mock_plex_server.friendly_name - assert entry.data[CONF_SERVER_IDENTIFIER] == mock_plex_server.machine_identifier + assert entry.data[CONF_SERVER] == "Plex Server 1" + assert entry.data[CONF_SERVER_IDENTIFIER] == "unique_id_123" assert entry.data[PLEX_SERVER_CONFIG][CONF_URL] == PLEX_DIRECT_URL assert entry.data[PLEX_SERVER_CONFIG][CONF_TOKEN] == "BRAND_NEW_TOKEN" @@ -837,13 +804,13 @@ async def test_trigger_reauth_multiple_servers_available( "plexauth.PlexAuth.token", return_value="BRAND_NEW_TOKEN" ): result = await hass.config_entries.flow.async_configure(flow_id, user_input={}) - assert result["type"] == "external" + assert result["type"] == FlowResultType.EXTERNAL_STEP result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "external_done" + assert result["type"] == FlowResultType.EXTERNAL_STEP_DONE result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "abort" + assert result["type"] == FlowResultType.ABORT assert result["flow_id"] == flow_id assert result["reason"] == "reauth_successful" @@ -851,8 +818,8 @@ async def test_trigger_reauth_multiple_servers_available( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert entry.state is ConfigEntryState.LOADED - assert entry.data[CONF_SERVER] == mock_plex_server.friendly_name - assert entry.data[CONF_SERVER_IDENTIFIER] == mock_plex_server.machine_identifier + assert entry.data[CONF_SERVER] == "Plex Server 1" + assert entry.data[CONF_SERVER_IDENTIFIER] == "unique_id_123" assert entry.data[PLEX_SERVER_CONFIG][CONF_URL] == PLEX_DIRECT_URL assert entry.data[PLEX_SERVER_CONFIG][CONF_TOKEN] == "BRAND_NEW_TOKEN" @@ -862,7 +829,7 @@ async def test_client_request_missing(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" with patch("plexauth.PlexAuth.initiate_auth"), patch( @@ -884,7 +851,7 @@ async def test_client_header_issues( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" with patch("plexauth.PlexAuth.initiate_auth"), patch( diff --git a/tests/components/plex/test_init.py b/tests/components/plex/test_init.py index 4ef3e3dc81..cdfa409237 100644 --- a/tests/components/plex/test_init.py +++ b/tests/components/plex/test_init.py @@ -314,3 +314,25 @@ async def test_scan_clients_schedule(hass: HomeAssistant, setup_plex_server) -> await hass.async_block_till_done() assert mock_scan_clients.called + + +async def test_setup_with_limited_credentials( + hass: HomeAssistant, entry, setup_plex_server +) -> None: + """Test setup with a user with limited permissions.""" + with patch( + "plexapi.server.PlexServer.systemAccounts", + side_effect=plexapi.exceptions.Unauthorized, + ) as mock_accounts: + mock_plex_server = await setup_plex_server() + + assert mock_accounts.called + + plex_server = hass.data[const.DOMAIN][const.SERVERS][ + mock_plex_server.machine_identifier + ] + assert len(plex_server.accounts) == 0 + assert plex_server.owner is None + + assert len(hass.config_entries.async_entries(const.DOMAIN)) == 1 + assert entry.state is ConfigEntryState.LOADED From 62b0603b764dd926a956250a503cb3e0c3ae90bb Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 5 Mar 2023 01:03:36 -0500 Subject: [PATCH 0631/2810] Bump pyvizio to 0.1.60 (#89160) --- homeassistant/components/vizio/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vizio/manifest.json b/homeassistant/components/vizio/manifest.json index 572aba0829..9b63ef17a9 100644 --- a/homeassistant/components/vizio/manifest.json +++ b/homeassistant/components/vizio/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["pyvizio"], "quality_scale": "platinum", - "requirements": ["pyvizio==0.1.57"], + "requirements": ["pyvizio==0.1.60"], "zeroconf": ["_viziocast._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 96bd1d20f0..000aa752b4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2174,7 +2174,7 @@ pyversasense==0.0.6 pyvesync==2.1.1 # homeassistant.components.vizio -pyvizio==0.1.57 +pyvizio==0.1.60 # homeassistant.components.velux pyvlx==0.2.20 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 896ded1d1c..e02ee6a6c3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1549,7 +1549,7 @@ pyvera==0.3.13 pyvesync==2.1.1 # homeassistant.components.vizio -pyvizio==0.1.57 +pyvizio==0.1.60 # homeassistant.components.volumio pyvolumio==0.1.5 From 6dc99d2ad8c960b674b3086838e248fcf5f7ee95 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 5 Mar 2023 12:40:12 +0100 Subject: [PATCH 0632/2810] Bump `brother` and `pysnmplib` backend libraries (#89100) * Bump brother and pysnmplib * Fix tests --- homeassistant/components/brother/manifest.json | 2 +- homeassistant/components/snmp/manifest.json | 2 +- homeassistant/components/snmp/sensor.py | 4 ++-- homeassistant/components/snmp/switch.py | 2 +- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- tests/components/snmp/test_sensor.py | 5 +---- 7 files changed, 10 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json index bd5d877b4f..cba44b68c6 100644 --- a/homeassistant/components/brother/manifest.json +++ b/homeassistant/components/brother/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["brother", "pyasn1", "pysmi", "pysnmp"], "quality_scale": "platinum", - "requirements": ["brother==2.2.0"], + "requirements": ["brother==2.3.0"], "zeroconf": [ { "type": "_printer._tcp.local.", diff --git a/homeassistant/components/snmp/manifest.json b/homeassistant/components/snmp/manifest.json index 8194b3f96c..324a1e4936 100644 --- a/homeassistant/components/snmp/manifest.json +++ b/homeassistant/components/snmp/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/snmp", "iot_class": "local_polling", "loggers": ["pyasn1", "pysmi", "pysnmp"], - "requirements": ["pysnmplib==5.0.20"] + "requirements": ["pysnmplib==5.0.21"] } diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index c20e5fe6e3..fc8068fb53 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -145,7 +145,7 @@ async def async_setup_platform( ContextData(), ] get_result = await getCmd(*request_args, ObjectType(ObjectIdentity(baseoid))) - errindication, _, _, _ = await get_result + errindication, _, _, _ = get_result if errindication and not accept_errors: _LOGGER.error("Please check the details in the configuration file") @@ -207,7 +207,7 @@ class SnmpData: get_result = await getCmd( *self._request_args, ObjectType(ObjectIdentity(self._baseoid)) ) - errindication, errstatus, errindex, restable = await get_result + errindication, errstatus, errindex, restable = get_result if errindication and not self._accept_errors: _LOGGER.error("SNMP error: %s", errindication) diff --git a/homeassistant/components/snmp/switch.py b/homeassistant/components/snmp/switch.py index 4699aaefd7..d0fe393d55 100644 --- a/homeassistant/components/snmp/switch.py +++ b/homeassistant/components/snmp/switch.py @@ -261,7 +261,7 @@ class SnmpSwitch(SwitchEntity): get_result = await getCmd( *self._request_args, ObjectType(ObjectIdentity(self._baseoid)) ) - errindication, errstatus, errindex, restable = await get_result + errindication, errstatus, errindex, restable = get_result if errindication: _LOGGER.error("SNMP error: %s", errindication) diff --git a/requirements_all.txt b/requirements_all.txt index 000aa752b4..7e6008ce14 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -480,7 +480,7 @@ boto3==1.20.24 broadlink==0.18.3 # homeassistant.components.brother -brother==2.2.0 +brother==2.3.0 # homeassistant.components.brottsplatskartan brottsplatskartan==0.0.1 @@ -1982,7 +1982,7 @@ pysmarty==0.8 pysml==0.0.8 # homeassistant.components.snmp -pysnmplib==5.0.20 +pysnmplib==5.0.21 # homeassistant.components.snooz pysnooz==0.8.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e02ee6a6c3..d74867edbf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -393,7 +393,7 @@ boschshcpy==0.2.35 broadlink==0.18.3 # homeassistant.components.brother -brother==2.2.0 +brother==2.3.0 # homeassistant.components.brunt brunt==1.2.0 @@ -1435,7 +1435,7 @@ pysmartthings==0.7.6 pysml==0.0.8 # homeassistant.components.snmp -pysnmplib==5.0.20 +pysnmplib==5.0.21 # homeassistant.components.snooz pysnooz==0.8.3 diff --git a/tests/components/snmp/test_sensor.py b/tests/components/snmp/test_sensor.py index b15cc4bfa6..d6637946da 100644 --- a/tests/components/snmp/test_sensor.py +++ b/tests/components/snmp/test_sensor.py @@ -1,6 +1,5 @@ """SNMP sensor tests.""" -import asyncio from unittest.mock import MagicMock, Mock, patch import pytest @@ -16,11 +15,9 @@ def hlapi_mock(): """Mock out 3rd party API.""" mock_data = MagicMock() mock_data.prettyPrint = Mock(return_value="13.5") - future = asyncio.get_event_loop().create_future() - future.set_result((None, None, None, [[mock_data]])) with patch( "homeassistant.components.snmp.sensor.getCmd", - return_value=future, + return_value=(None, None, None, [[mock_data]]), ): yield From 927b43626cc489fa8200d2c8f259011760847341 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 5 Mar 2023 01:44:45 -1000 Subject: [PATCH 0633/2810] Bump aiodiscover to 1.4.14 (#89174) --- homeassistant/components/dhcp/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index 0765c762b8..a5ee449dda 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["aiodiscover", "dnspython", "pyroute2", "scapy"], "quality_scale": "internal", - "requirements": ["scapy==2.5.0", "aiodiscover==1.4.13"] + "requirements": ["scapy==2.5.0", "aiodiscover==1.4.14"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cd2950c964..b6d109fbfb 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,6 +1,6 @@ PyJWT==2.5.0 PyNaCl==1.5.0 -aiodiscover==1.4.13 +aiodiscover==1.4.14 aiohttp==3.8.4 aiohttp_cors==0.7.0 astral==2.2 diff --git a/requirements_all.txt b/requirements_all.txt index 7e6008ce14..59663e1361 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -137,7 +137,7 @@ aiobafi6==0.7.3 aiobotocore==2.1.0 # homeassistant.components.dhcp -aiodiscover==1.4.13 +aiodiscover==1.4.14 # homeassistant.components.dnsip # homeassistant.components.minecraft_server diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d74867edbf..e59fe698e1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -124,7 +124,7 @@ aiobafi6==0.7.3 aiobotocore==2.1.0 # homeassistant.components.dhcp -aiodiscover==1.4.13 +aiodiscover==1.4.14 # homeassistant.components.dnsip # homeassistant.components.minecraft_server From 11681f3f315a50d1fe5a46e65c080a1df809597e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 5 Mar 2023 01:46:02 -1000 Subject: [PATCH 0634/2810] Pass a helpful name when creating common asyncio tasks in core (#89171) --- homeassistant/bootstrap.py | 4 +- .../homeassistant/triggers/homeassistant.py | 2 +- .../homeassistant/triggers/numeric_state.py | 2 +- .../homeassistant/triggers/state.py | 2 +- .../components/homeassistant/triggers/time.py | 2 +- .../homeassistant/triggers/time_pattern.py | 2 +- homeassistant/config_entries.py | 33 +++++++++--- homeassistant/core.py | 34 ++++++++---- homeassistant/helpers/debounce.py | 16 ++++-- homeassistant/helpers/discovery.py | 4 +- homeassistant/helpers/discovery_flow.py | 2 +- homeassistant/helpers/dispatcher.py | 3 +- homeassistant/helpers/entity.py | 9 +++- homeassistant/helpers/entity_component.py | 5 +- homeassistant/helpers/entity_platform.py | 2 + homeassistant/helpers/event.py | 54 +++++++++++++------ homeassistant/helpers/restore_state.py | 2 +- homeassistant/helpers/storage.py | 4 +- homeassistant/helpers/trigger.py | 2 +- homeassistant/helpers/update_coordinator.py | 9 +++- homeassistant/setup.py | 4 +- tests/common.py | 4 +- tests/test_core.py | 29 +++++++++- 23 files changed, 169 insertions(+), 61 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index e87ee1ae28..29772e865a 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -508,7 +508,9 @@ async def async_setup_multi_components( ) -> None: """Set up multiple domains. Log on failure.""" futures = { - domain: hass.async_create_task(async_setup_component(hass, domain, config)) + domain: hass.async_create_task( + async_setup_component(hass, domain, config), f"setup component {domain}" + ) for domain in domains } await asyncio.wait(futures.values()) diff --git a/homeassistant/components/homeassistant/triggers/homeassistant.py b/homeassistant/components/homeassistant/triggers/homeassistant.py index e3dc93a978..51686e54c5 100644 --- a/homeassistant/components/homeassistant/triggers/homeassistant.py +++ b/homeassistant/components/homeassistant/triggers/homeassistant.py @@ -27,7 +27,7 @@ async def async_attach_trigger( """Listen for events based on configuration.""" trigger_data = trigger_info["trigger_data"] event = config.get(CONF_EVENT) - job = HassJob(action) + job = HassJob(action, f"homeassistant trigger {trigger_info}") if event == EVENT_SHUTDOWN: diff --git a/homeassistant/components/homeassistant/triggers/numeric_state.py b/homeassistant/components/homeassistant/triggers/numeric_state.py index 53d3fb1217..d822cd523f 100644 --- a/homeassistant/components/homeassistant/triggers/numeric_state.py +++ b/homeassistant/components/homeassistant/triggers/numeric_state.py @@ -100,7 +100,7 @@ async def async_attach_trigger( armed_entities = set() period: dict = {} attribute = config.get(CONF_ATTRIBUTE) - job = HassJob(action) + job = HassJob(action, f"numeric state trigger {trigger_info}") trigger_data = trigger_info["trigger_data"] _variables = trigger_info["variables"] or {} diff --git a/homeassistant/components/homeassistant/triggers/state.py b/homeassistant/components/homeassistant/triggers/state.py index 25622e0a3c..7fc780d797 100644 --- a/homeassistant/components/homeassistant/triggers/state.py +++ b/homeassistant/components/homeassistant/triggers/state.py @@ -123,7 +123,7 @@ async def async_attach_trigger( unsub_track_same = {} period: dict[str, timedelta] = {} attribute = config.get(CONF_ATTRIBUTE) - job = HassJob(action) + job = HassJob(action, f"state trigger {trigger_info}") trigger_data = trigger_info["trigger_data"] _variables = trigger_info["variables"] or {} diff --git a/homeassistant/components/homeassistant/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py index f5473d66a5..a29cb5ff6d 100644 --- a/homeassistant/components/homeassistant/triggers/time.py +++ b/homeassistant/components/homeassistant/triggers/time.py @@ -49,7 +49,7 @@ async def async_attach_trigger( trigger_data = trigger_info["trigger_data"] entities: dict[str, CALLBACK_TYPE] = {} removes = [] - job = HassJob(action) + job = HassJob(action, f"time trigger {trigger_info}") @callback def time_automation_listener(description, now, *, entity_id=None): diff --git a/homeassistant/components/homeassistant/triggers/time_pattern.py b/homeassistant/components/homeassistant/triggers/time_pattern.py index 2a5022bebf..63f9b18cf9 100644 --- a/homeassistant/components/homeassistant/triggers/time_pattern.py +++ b/homeassistant/components/homeassistant/triggers/time_pattern.py @@ -66,7 +66,7 @@ async def async_attach_trigger( hours = config.get(CONF_HOURS) minutes = config.get(CONF_MINUTES) seconds = config.get(CONF_SECONDS) - job = HassJob(action) + job = HassJob(action, f"time pattern trigger {trigger_info}") # If larger units are specified, default the smaller units to zero if minutes is None and hours is not None: diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 94f2bab75a..29788a678a 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -729,7 +729,8 @@ class ConfigEntry: } | (context or {}), data=self.data | (data or {}), - ) + ), + f"config entry reauth {self.title} {self.domain} {self.entry_id}", ) @callback @@ -746,7 +747,10 @@ class ConfigEntry: @callback def async_create_task( - self, hass: HomeAssistant, target: Coroutine[Any, Any, _R] + self, + hass: HomeAssistant, + target: Coroutine[Any, Any, _R], + name: str | None = None, ) -> asyncio.Task[_R]: """Create a task from within the eventloop. @@ -754,7 +758,9 @@ class ConfigEntry: target: target to call. """ - task = hass.async_create_task(target) + task = hass.async_create_task( + target, f"{name} {self.title} {self.domain} {self.entry_id}" + ) self._tasks.add(task) task.add_done_callback(self._tasks.remove) @@ -824,7 +830,10 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): init_done: asyncio.Future[None] = asyncio.Future() self._pending_import_flows.setdefault(handler, {})[flow_id] = init_done - task = asyncio.create_task(self._async_init(flow_id, handler, context, data)) + task = asyncio.create_task( + self._async_init(flow_id, handler, context, data), + name=f"config entry flow {handler} {flow_id}", + ) self._initialize_tasks.setdefault(handler, []).append(task) try: @@ -1112,7 +1121,8 @@ class ConfigEntries: entry.domain, context={"source": SOURCE_UNIGNORE}, data={"unique_id": entry.unique_id}, - ) + ), + f"config entry unignore {entry.title} {entry.domain} {entry.unique_id}", ) self._async_dispatch(ConfigEntryChange.REMOVED, entry) @@ -1337,7 +1347,10 @@ class ConfigEntries: for listener_ref in entry.update_listeners: if (listener := listener_ref()) is not None: - self.hass.async_create_task(listener(self.hass, entry)) + self.hass.async_create_task( + listener(self.hass, entry), + f"config entry update listener {entry.title} {entry.domain} {entry.domain}", + ) self._async_schedule_save() self._async_dispatch(ConfigEntryChange.UPDATED, entry) @@ -1367,7 +1380,10 @@ class ConfigEntries: error_if_core=False, ) for platform in platforms: - self.hass.async_create_task(self.async_forward_entry_setup(entry, platform)) + self.hass.async_create_task( + self.async_forward_entry_setup(entry, platform), + f"config entry forward setup {entry.title} {entry.domain} {entry.entry_id} {platform}", + ) async def async_forward_entry_setups( self, entry: ConfigEntry, platforms: Iterable[Platform | str] @@ -1549,7 +1565,8 @@ class ConfigFlow(data_entry_flow.FlowHandler): continue if should_reload: self.hass.async_create_task( - self.hass.config_entries.async_reload(entry.entry_id) + self.hass.config_entries.async_reload(entry.entry_id), + f"config entry reload {entry.title} {entry.domain} {entry.entry_id}", ) raise data_entry_flow.AbortFlow(error) diff --git a/homeassistant/core.py b/homeassistant/core.py index b2525e2f09..8650b9a9e3 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -217,16 +217,17 @@ class HassJob(Generic[_P, _R_co]): we run the job. """ - __slots__ = ("job_type", "target") + __slots__ = ("job_type", "target", "name") - def __init__(self, target: Callable[_P, _R_co]) -> None: + def __init__(self, target: Callable[_P, _R_co], name: str | None = None) -> None: """Create a job object.""" self.target = target + self.name = name self.job_type = _get_hassjob_callable_job_type(target) def __repr__(self) -> str: """Return the job.""" - return f"" + return f"" def _get_hassjob_callable_job_type(target: Callable[..., Any]) -> HassJobType: @@ -488,7 +489,7 @@ class HomeAssistant: hassjob.target = cast( Callable[..., Coroutine[Any, Any, _R]], hassjob.target ) - task = self.loop.create_task(hassjob.target(*args)) + task = self.loop.create_task(hassjob.target(*args), name=hassjob.name) elif hassjob.job_type == HassJobType.Callback: if TYPE_CHECKING: hassjob.target = cast(Callable[..., _R], hassjob.target) @@ -512,7 +513,9 @@ class HomeAssistant: self.loop.call_soon_threadsafe(self.async_create_task, target) @callback - def async_create_task(self, target: Coroutine[Any, Any, _R]) -> asyncio.Task[_R]: + def async_create_task( + self, target: Coroutine[Any, Any, _R], name: str | None = None + ) -> asyncio.Task[_R]: """Create a task from within the eventloop. This method must be run in the event loop. If you are using this in your @@ -520,7 +523,7 @@ class HomeAssistant: target: target to call. """ - task = self.loop.create_task(target) + task = self.loop.create_task(target, name=name) self._tasks.add(task) task.add_done_callback(self._tasks.remove) return task @@ -1037,7 +1040,10 @@ class EventBus: if run_immediately and not is_callback(listener): raise HomeAssistantError(f"Event listener {listener} is not a callback") return self._async_listen_filterable_job( - event_type, _FilterableJob(HassJob(listener), event_filter, run_immediately) + event_type, + _FilterableJob( + HassJob(listener, "listen {event_type}"), event_filter, run_immediately + ), ) @callback @@ -1111,7 +1117,11 @@ class EventBus: _onetime_listener, listener, ("__name__", "__qualname__", "__module__"), [] ) - filterable_job = _FilterableJob(HassJob(_onetime_listener), None, False) + filterable_job = _FilterableJob( + HassJob(_onetime_listener, "onetime listen {event_type} {listener}"), + None, + False, + ) return self._async_listen_filterable_job(event_type, filterable_job) @@ -1558,16 +1568,18 @@ class StateMachine: class Service: """Representation of a callable service.""" - __slots__ = ["job", "schema"] + __slots__ = ["job", "schema", "domain", "service"] def __init__( self, func: Callable[[ServiceCall], Coroutine[Any, Any, None] | None], schema: vol.Schema | None, + domain: str, + service: str, context: Context | None = None, ) -> None: """Initialize a service.""" - self.job = HassJob(func) + self.job = HassJob(func, f"service {domain}.{service}") self.schema = schema @@ -1659,7 +1671,7 @@ class ServiceRegistry: """ domain = domain.lower() service = service.lower() - service_obj = Service(service_func, schema) + service_obj = Service(service_func, schema, domain, service) if domain in self._services: self._services[domain][service] = service_obj diff --git a/homeassistant/helpers/debounce.py b/homeassistant/helpers/debounce.py index 2fbdefd7ec..b4a4cde0c1 100644 --- a/homeassistant/helpers/debounce.py +++ b/homeassistant/helpers/debounce.py @@ -38,7 +38,11 @@ class Debouncer(Generic[_R_co]): self._execute_at_end_of_timer: bool = False self._execute_lock = asyncio.Lock() self._job: HassJob[[], _R_co] | None = ( - None if function is None else HassJob(function) + None + if function is None + else HassJob( + function, f"debouncer cooldown={cooldown}, immediate={immediate}" + ) ) @property @@ -51,7 +55,10 @@ class Debouncer(Generic[_R_co]): """Update the function being wrapped by the Debouncer.""" self._function = function if self._job is None or function != self._job.target: - self._job = HassJob(function) + self._job = HassJob( + function, + f"debouncer cooldown={self.cooldown}, immediate={self.immediate}", + ) async def async_call(self) -> None: """Call the function.""" @@ -126,5 +133,8 @@ class Debouncer(Generic[_R_co]): """Schedule a timer.""" self._timer_task = self.hass.loop.call_later( self.cooldown, - lambda: self.hass.async_create_task(self._handle_timer_finish()), + lambda: self.hass.async_create_task( + self._handle_timer_finish(), + f"debouncer {self._job} finish cooldown={self.cooldown}, immediate={self.immediate}", + ), ) diff --git a/homeassistant/helpers/discovery.py b/homeassistant/helpers/discovery.py index 375c3b09c2..824b1de701 100644 --- a/homeassistant/helpers/discovery.py +++ b/homeassistant/helpers/discovery.py @@ -44,7 +44,7 @@ def async_listen( Service can be a string or a list/tuple. """ - job = core.HassJob(callback) + job = core.HassJob(callback, f"discovery listener {service}") async def discovery_event_listener(discovered: DiscoveryDict) -> None: """Listen for discovery events.""" @@ -103,7 +103,7 @@ def async_listen_platform( This method must be run in the event loop. """ service = EVENT_LOAD_PLATFORM.format(component) - job = core.HassJob(callback) + job = core.HassJob(callback, f"platform loaded {component}") async def discovery_platform_listener(discovered: DiscoveryDict) -> None: """Listen for platform discovery events.""" diff --git a/homeassistant/helpers/discovery_flow.py b/homeassistant/helpers/discovery_flow.py index f7e78e82fb..bd5ee4942d 100644 --- a/homeassistant/helpers/discovery_flow.py +++ b/homeassistant/helpers/discovery_flow.py @@ -29,7 +29,7 @@ def async_create_flow( if not dispatcher or dispatcher.started: if init_coro := _async_init_flow(hass, domain, context, data): - hass.async_create_task(init_coro) + hass.async_create_task(init_coro, f"discovery flow {domain} {context}") return return dispatcher.async_create(domain, context, data) diff --git a/homeassistant/helpers/dispatcher.py b/homeassistant/helpers/dispatcher.py index c7ad4fb1ad..60aab15614 100644 --- a/homeassistant/helpers/dispatcher.py +++ b/homeassistant/helpers/dispatcher.py @@ -75,7 +75,8 @@ def _generate_job( signal, args, ), - ) + ), + f"dispatcher {signal}", ) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index c4dfd7e9c5..9c1bbe5b20 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -702,7 +702,10 @@ class Entity(ABC): been executed, the intermediate state transitions will be missed. """ if force_refresh: - self.hass.async_create_task(self.async_update_ha_state(force_refresh)) + self.hass.async_create_task( + self.async_update_ha_state(force_refresh), + f"Entity schedule update ha state {self.entity_id}", + ) else: self.async_write_ha_state() @@ -722,7 +725,9 @@ class Entity(ABC): try: task: asyncio.Future[None] if hasattr(self, "async_update"): - task = self.hass.async_create_task(self.async_update()) + task = self.hass.async_create_task( + self.async_update(), f"Entity async update {self.entity_id}" + ) elif hasattr(self, "update"): task = self.hass.async_add_executor_job(self.update) else: diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 874c37ffd9..0c43dddec6 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -131,7 +131,10 @@ class EntityComponent(Generic[_EntityT]): # Look in config for Domain, Domain 2, Domain 3 etc and load them for p_type, p_config in config_per_platform(config, self.domain): if p_type is not None: - self.hass.async_create_task(self.async_setup_platform(p_type, p_config)) + self.hass.async_create_task( + self.async_setup_platform(p_type, p_config), + f"EntityComponent setup platform {p_type} {self.domain}", + ) # Generic discovery listener for loading platform dynamically # Refer to: homeassistant.helpers.discovery.async_load_platform() diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index c002915a4d..e085f819e3 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -375,6 +375,7 @@ class EntityPlatform: """Schedule adding entities for a single platform async.""" task = self.hass.async_create_task( self.async_add_entities(new_entities, update_before_add=update_before_add), + f"EntityPlatform async_add_entities {self.domain}.{self.platform_name}", ) if not self._setup_complete: @@ -389,6 +390,7 @@ class EntityPlatform: task = self.config_entry.async_create_task( self.hass, self.async_add_entities(new_entities, update_before_add=update_before_add), + f"EntityPlatform async_add_entities_for_entry {self.domain}.{self.platform_name}", ) if not self._setup_complete: diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 7490206f03..a924d9cb88 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -176,7 +176,7 @@ def async_track_state_change( else: entity_ids = tuple(entity_id.lower() for entity_id in entity_ids) - job = HassJob(action) + job = HassJob(action, f"track state change {entity_ids} {from_state} {to_state}") @callback def state_change_filter(event: Event) -> bool: @@ -296,7 +296,7 @@ def _async_track_state_change_event( event_filter=_async_state_change_filter, ) - job = HassJob(action) + job = HassJob(action, f"track state change event {entity_ids}") for entity_id in entity_ids: entity_callbacks.setdefault(entity_id, []).append(job) @@ -393,7 +393,7 @@ def async_track_entity_registry_updated_event( event_filter=_async_entity_registry_updated_filter, ) - job = HassJob(action) + job = HassJob(action, f"track entity registry updated event {entity_ids}") for entity_id in entity_ids: entity_callbacks.setdefault(entity_id, []).append(job) @@ -476,7 +476,7 @@ def _async_track_state_added_domain( event_filter=_async_state_change_filter, ) - job = HassJob(action) + job = HassJob(action, f"track state added domain event {domains}") for domain in domains: domain_callbacks.setdefault(domain, []).append(job) @@ -530,7 +530,7 @@ def async_track_state_removed_domain( event_filter=_async_state_change_filter, ) - job = HassJob(action) + job = HassJob(action, f"track state removed domain event {domains}") for domain in domains: domain_callbacks.setdefault(domain, []).append(job) @@ -569,7 +569,9 @@ class _TrackStateChangeFiltered: """Handle removal / refresh of tracker init.""" self.hass = hass self._action = action - self._action_as_hassjob = HassJob(action) + self._action_as_hassjob = HassJob( + action, f"track state change filtered {track_states}" + ) self._listeners: dict[str, Callable[[], None]] = {} self._last_track_states: TrackStates = track_states @@ -764,7 +766,7 @@ def async_track_template( Callable to unregister the listener. """ - job = HassJob(action) + job = HassJob(action, f"track template {template}") @callback def _template_changed_listener( @@ -821,7 +823,7 @@ class TrackTemplateResultInfo: ) -> None: """Handle removal / refresh of tracker init.""" self.hass = hass - self._job = HassJob(action) + self._job = HassJob(action, f"track template result {track_templates}") for track_template_ in track_templates: track_template_.template.hass = hass @@ -1215,7 +1217,7 @@ def async_track_same_state( async_remove_state_for_cancel: CALLBACK_TYPE | None = None async_remove_state_for_listener: CALLBACK_TYPE | None = None - job = HassJob(action) + job = HassJob(action, f"track same state {period} {entity_ids}") @callback def clear_listener() -> None: @@ -1277,7 +1279,11 @@ def async_track_point_in_time( point_in_time: datetime, ) -> CALLBACK_TYPE: """Add a listener that fires once after a specific point in time.""" - job = action if isinstance(action, HassJob) else HassJob(action) + job = ( + action + if isinstance(action, HassJob) + else HassJob(action, f"track point in time {point_in_time}") + ) @callback def utc_converter(utc_now: datetime) -> None: @@ -1324,7 +1330,11 @@ def async_track_point_in_utc_time( hass.async_run_hass_job(job, utc_point_in_time) - job = action if isinstance(action, HassJob) else HassJob(action) + job = ( + action + if isinstance(action, HassJob) + else HassJob(action, f"track point in utc time {utc_point_in_time}") + ) delta = expected_fire_timestamp - time.time() cancel_callback = hass.loop.call_later(delta, run_action, job) @@ -1357,7 +1367,11 @@ def async_call_later( """Call the action.""" hass.async_run_hass_job(job, time_tracker_utcnow()) - job = action if isinstance(action, HassJob) else HassJob(action) + job = ( + action + if isinstance(action, HassJob) + else HassJob(action, f"call_later {delay}") + ) cancel_callback = hass.loop.call_later(delay, run_action, job) @callback @@ -1383,7 +1397,7 @@ def async_track_time_interval( remove: CALLBACK_TYPE interval_listener_job: HassJob[[datetime], None] - job = HassJob(action) + job = HassJob(action, f"track time interval {interval}") def next_interval() -> datetime: """Return the next interval.""" @@ -1400,7 +1414,9 @@ def async_track_time_interval( ) hass.async_run_hass_job(job, now) - interval_listener_job = HassJob(interval_listener) + interval_listener_job = HassJob( + interval_listener, f"track time interval listener {interval}" + ) remove = async_track_point_in_utc_time(hass, interval_listener_job, next_interval()) def remove_listener() -> None: @@ -1479,7 +1495,9 @@ def async_track_sunrise( hass: HomeAssistant, action: Callable[[], None], offset: timedelta | None = None ) -> CALLBACK_TYPE: """Add a listener that will fire a specified offset from sunrise daily.""" - listener = SunListener(hass, HassJob(action), SUN_EVENT_SUNRISE, offset) + listener = SunListener( + hass, HassJob(action, "track sunrise"), SUN_EVENT_SUNRISE, offset + ) listener.async_attach() return listener.async_detach @@ -1493,7 +1511,9 @@ def async_track_sunset( hass: HomeAssistant, action: Callable[[], None], offset: timedelta | None = None ) -> CALLBACK_TYPE: """Add a listener that will fire a specified offset from sunset daily.""" - listener = SunListener(hass, HassJob(action), SUN_EVENT_SUNSET, offset) + listener = SunListener( + hass, HassJob(action, "track sunset"), SUN_EVENT_SUNSET, offset + ) listener.async_attach() return listener.async_detach @@ -1526,7 +1546,7 @@ def async_track_utc_time_change( # misalignment we use async_track_time_interval here return async_track_time_interval(hass, action, timedelta(seconds=1)) - job = HassJob(action) + job = HassJob(action, f"track time change {hour}:{minute}:{second} local={local}") matching_seconds = dt_util.parse_time_expression(second, 0, 59) matching_minutes = dt_util.parse_time_expression(minute, 0, 59) matching_hours = dt_util.parse_time_expression(hour, 0, 23) diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index d7e30661b3..0263bd2868 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -212,7 +212,7 @@ class RestoreStateData: # Dump the initial states now. This helps minimize the risk of having # old states loaded by overwriting the last states once Home Assistant # has started and the old states have been read. - self.hass.async_create_task(_async_dump_states()) + self.hass.async_create_task(_async_dump_states(), "RestoreStateData dump") # Dump states periodically cancel_interval = async_track_time_interval( diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index 19e028af90..bd9b01cd6a 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -115,7 +115,9 @@ class Store(Generic[_T]): the second call will wait and return the result of the first call. """ if self._load_task is None: - self._load_task = self.hass.async_create_task(self._async_load()) + self._load_task = self.hass.async_create_task( + self._async_load(), f"Storage load {self.key}" + ) return await self._load_task diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 314c0e8939..e2963b15ab 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -169,7 +169,7 @@ class PluggableAction: if not entry.actions and not entry.plugs: del reg[key] - job = HassJob(action) + job = HassJob(action, f"trigger {trigger} {variables}") entry.actions[_remove] = (job, variables) _update() diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 9f9b1a30ba..e8ca1a1f91 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -87,7 +87,14 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_T]): ) self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {} - self._job = HassJob(self._handle_refresh_interval) + job_name = "DataUpdateCoordinator" + type_name = type(self).__name__ + if type_name != job_name: + job_name += f" {type_name}" + job_name += f" {name}" + if entry := self.config_entry: + job_name += f" {entry.title} {entry.domain} {entry.entry_id}" + self._job = HassJob(self._handle_refresh_interval, job_name) self._unsub_refresh: CALLBACK_TYPE | None = None self._request_refresh_task: asyncio.TimerHandle | None = None self.last_update_success = True diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 2377f47d7e..df5d825708 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -93,7 +93,7 @@ async def async_setup_component( return await setup_tasks[domain] task = setup_tasks[domain] = hass.async_create_task( - _async_setup_component(hass, domain, config) + _async_setup_component(hass, domain, config), f"setup component {domain}" ) try: @@ -426,7 +426,7 @@ def _async_when_setup( _LOGGER.exception("Error handling when_setup callback for %s", component) if component in hass.config.components: - hass.async_create_task(when_setup()) + hass.async_create_task(when_setup(), f"when setup {component}") return listeners: list[CALLBACK_TYPE] = [] diff --git a/tests/common.py b/tests/common.py index c25ad0cca6..8b9a9c2401 100644 --- a/tests/common.py +++ b/tests/common.py @@ -210,14 +210,14 @@ async def async_test_home_assistant(event_loop, load_registries=True): return orig_async_add_executor_job(target, *args) - def async_create_task(coroutine): + def async_create_task(coroutine, name=None): """Create task.""" if isinstance(coroutine, Mock) and not isinstance(coroutine, AsyncMock): fut = asyncio.Future() fut.set_result(None) return fut - return orig_async_create_task(coroutine) + return orig_async_create_task(coroutine, name) hass.async_add_job = async_add_job hass.async_add_executor_job = async_add_executor_job diff --git a/tests/test_core.py b/tests/test_core.py index f627475270..6d67376b41 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -65,7 +65,7 @@ def test_split_entity_id() -> None: def test_async_add_hass_job_schedule_callback() -> None: - """Test that we schedule coroutines and add jobs to the job pool.""" + """Test that we schedule callbacks and add jobs to the job pool.""" hass = MagicMock() job = MagicMock() @@ -75,6 +75,19 @@ def test_async_add_hass_job_schedule_callback() -> None: assert len(hass.add_job.mock_calls) == 0 +def test_async_add_hass_job_coro_named(hass) -> None: + """Test that we schedule coroutines and add jobs to the job pool with a name.""" + + async def mycoro(): + pass + + job = ha.HassJob(mycoro, "named coro") + assert "named coro" in str(job) + assert job.name == "named coro" + task = ha.HomeAssistant.async_add_hass_job(hass, job) + assert "named coro" in str(task) + + def test_async_add_hass_job_schedule_partial_callback() -> None: """Test that we schedule partial coros and add jobs to the job pool.""" hass = MagicMock() @@ -141,6 +154,20 @@ def test_async_create_task_schedule_coroutine(event_loop) -> None: assert len(hass.add_job.mock_calls) == 0 +def test_async_create_task_schedule_coroutine_with_name(event_loop) -> None: + """Test that we schedule coroutines and add jobs to the job pool with a name.""" + hass = MagicMock(loop=MagicMock(wraps=event_loop)) + + async def job(): + pass + + task = ha.HomeAssistant.async_create_task(hass, job(), "named task") + assert len(hass.loop.call_soon.mock_calls) == 0 + assert len(hass.loop.create_task.mock_calls) == 1 + assert len(hass.add_job.mock_calls) == 0 + assert "named task" in str(task) + + def test_async_run_hass_job_calls_callback() -> None: """Test that the callback annotation is respected.""" hass = MagicMock() From afd37c8a0a77c6b5c197b2031d620953672fde4c Mon Sep 17 00:00:00 2001 From: gjong Date: Sun, 5 Mar 2023 12:49:02 +0100 Subject: [PATCH 0635/2810] Bump youless api version to v1.0.1 (#89117) --- homeassistant/components/youless/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/youless/manifest.json b/homeassistant/components/youless/manifest.json index 02cca76c4a..7c0ea36a06 100644 --- a/homeassistant/components/youless/manifest.json +++ b/homeassistant/components/youless/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/youless", "iot_class": "local_polling", "loggers": ["youless_api"], - "requirements": ["youless-api==0.16"] + "requirements": ["youless-api==1.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 59663e1361..7be57484c0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2688,7 +2688,7 @@ yeelightsunflower==0.0.10 yolink-api==0.2.8 # homeassistant.components.youless -youless-api==0.16 +youless-api==1.0.1 # homeassistant.components.media_extractor youtube_dl==2021.12.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e59fe698e1..e7e13fdc4c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1916,7 +1916,7 @@ yeelight==0.7.10 yolink-api==0.2.8 # homeassistant.components.youless -youless-api==0.16 +youless-api==1.0.1 # homeassistant.components.zamg zamg==0.2.2 From 85618fd3cd3f3b82e1ac053c438742229fd56e56 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sun, 5 Mar 2023 12:54:39 +0100 Subject: [PATCH 0636/2810] Bump overkiz dependency to 1.7.7 (#89163) --- homeassistant/components/overkiz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index 6ba7db46dd..caa4f6c386 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.7.6"], + "requirements": ["pyoverkiz==1.7.7"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 7be57484c0..b49ebeca2a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1857,7 +1857,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.7.6 +pyoverkiz==1.7.7 # homeassistant.components.openweathermap pyowm==3.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e7e13fdc4c..dc58276e32 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1343,7 +1343,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.7.6 +pyoverkiz==1.7.7 # homeassistant.components.openweathermap pyowm==3.2.0 From 39db0ef17353692ef4059540e4b731e094b94e9c Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 5 Mar 2023 13:01:10 +0100 Subject: [PATCH 0637/2810] Add Reolink button platform (#88687) Co-authored-by: Franck Nijhof --- .coveragerc | 1 + homeassistant/components/reolink/__init__.py | 8 +- homeassistant/components/reolink/button.py | 136 +++++++++++++++++++ 3 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/reolink/button.py diff --git a/.coveragerc b/.coveragerc index 48fb404457..e9c34e4d8f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -977,6 +977,7 @@ omit = homeassistant/components/remote_rpi_gpio/* homeassistant/components/reolink/__init__.py homeassistant/components/reolink/binary_sensor.py + homeassistant/components/reolink/button.py homeassistant/components/reolink/camera.py homeassistant/components/reolink/entity.py homeassistant/components/reolink/host.py diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 2faa89232a..7de1123956 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -23,7 +23,13 @@ from .host import ReolinkHost _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.CAMERA, Platform.NUMBER, Platform.UPDATE] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.CAMERA, + Platform.NUMBER, + Platform.UPDATE, +] DEVICE_UPDATE_INTERVAL = timedelta(seconds=60) FIRMWARE_UPDATE_INTERVAL = timedelta(hours=12) diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py new file mode 100644 index 0000000000..528eb8c740 --- /dev/null +++ b/homeassistant/components/reolink/button.py @@ -0,0 +1,136 @@ +"""Component providing support for Reolink button entities.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from reolink_aio.api import GuardEnum, Host, PtzEnum + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ReolinkData +from .const import DOMAIN +from .entity import ReolinkCoordinatorEntity + + +@dataclass +class ReolinkButtonEntityDescriptionMixin: + """Mixin values for Reolink button entities.""" + + method: Callable[[Host, int], Any] + + +@dataclass +class ReolinkButtonEntityDescription( + ButtonEntityDescription, ReolinkButtonEntityDescriptionMixin +): + """A class that describes button entities.""" + + supported: Callable[[Host, int], bool] = lambda api, ch: True + + +BUTTON_ENTITIES = ( + ReolinkButtonEntityDescription( + key="ptz_stop", + name="PTZ stop", + icon="mdi:pan", + supported=lambda api, ch: api.supported(ch, "pan_tilt"), + method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.stop.value), + ), + ReolinkButtonEntityDescription( + key="ptz_left", + name="PTZ left", + icon="mdi:pan", + supported=lambda api, ch: api.supported(ch, "pan_tilt"), + method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.left.value), + ), + ReolinkButtonEntityDescription( + key="ptz_right", + name="PTZ right", + icon="mdi:pan", + supported=lambda api, ch: api.supported(ch, "pan_tilt"), + method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.right.value), + ), + ReolinkButtonEntityDescription( + key="ptz_up", + name="PTZ up", + icon="mdi:pan", + supported=lambda api, ch: api.supported(ch, "pan_tilt"), + method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.up.value), + ), + ReolinkButtonEntityDescription( + key="ptz_down", + name="PTZ down", + icon="mdi:pan", + supported=lambda api, ch: api.supported(ch, "pan_tilt"), + method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.down.value), + ), + ReolinkButtonEntityDescription( + key="ptz_calibrate", + name="PTZ calibrate", + icon="mdi:pan", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "ptz_callibrate"), + method=lambda api, ch: api.ptz_callibrate(ch), + ), + ReolinkButtonEntityDescription( + key="guard_go_to", + name="Guard go to", + icon="mdi:crosshairs-gps", + supported=lambda api, ch: api.supported(ch, "ptz_guard"), + method=lambda api, ch: api.set_ptz_guard(ch, command=GuardEnum.goto.value), + ), + ReolinkButtonEntityDescription( + key="guard_set", + name="Guard set current position", + icon="mdi:crosshairs-gps", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "ptz_guard"), + method=lambda api, ch: api.set_ptz_guard(ch, command=GuardEnum.set.value), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a Reolink button entities.""" + reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + ReolinkButtonEntity(reolink_data, channel, entity_description) + for entity_description in BUTTON_ENTITIES + for channel in reolink_data.host.api.channels + if entity_description.supported(reolink_data.host.api, channel) + ) + + +class ReolinkButtonEntity(ReolinkCoordinatorEntity, ButtonEntity): + """Base button entity class for Reolink IP cameras.""" + + entity_description: ReolinkButtonEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + channel: int, + entity_description: ReolinkButtonEntityDescription, + ) -> None: + """Initialize Reolink button entity.""" + super().__init__(reolink_data, channel) + self.entity_description = entity_description + + self._attr_unique_id = ( + f"{self._host.unique_id}_{channel}_{entity_description.key}" + ) + + async def async_press(self) -> None: + """Execute the button action.""" + await self.entity_description.method(self._host.api, self._channel) From 3f7a58786f9b8576a112c80428918d86054361e8 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 5 Mar 2023 13:34:07 +0100 Subject: [PATCH 0638/2810] Bump reolink-aio to 0.5.3 (#89145) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 62b2b5a038..978b38daea 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.5.1"] + "requirements": ["reolink-aio==0.5.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index b49ebeca2a..2e92100dad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2237,7 +2237,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.1 +reolink-aio==0.5.3 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dc58276e32..09cc93bdcd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1591,7 +1591,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.1 +reolink-aio==0.5.3 # homeassistant.components.python_script restrictedpython==6.0 From 08b3945d9b9d83093ff2ded4e8acc7039c2f9248 Mon Sep 17 00:00:00 2001 From: Greg Dowling Date: Sun, 5 Mar 2023 12:35:32 +0000 Subject: [PATCH 0639/2810] Bump pyroon to 0.1.4 (#89124) --- homeassistant/components/roon/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roon/manifest.json b/homeassistant/components/roon/manifest.json index f1d26af190..4fa527d076 100644 --- a/homeassistant/components/roon/manifest.json +++ b/homeassistant/components/roon/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/roon", "iot_class": "local_push", "loggers": ["roonapi"], - "requirements": ["roonapi==0.1.3"] + "requirements": ["roonapi==0.1.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2e92100dad..ad423c2a3e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2267,7 +2267,7 @@ rokuecp==0.17.1 roombapy==1.6.5 # homeassistant.components.roon -roonapi==0.1.3 +roonapi==0.1.4 # homeassistant.components.rova rova==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 09cc93bdcd..fa53828960 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1609,7 +1609,7 @@ rokuecp==0.17.1 roombapy==1.6.5 # homeassistant.components.roon -roonapi==0.1.3 +roonapi==0.1.4 # homeassistant.components.rpi_power rpi-bad-power==0.1.0 From 3614114a8fd7cf7da07c89444c61ca2e634e28ff Mon Sep 17 00:00:00 2001 From: Carlos Cristobal <87995947+sw-carlos-cristobal@users.noreply.github.com> Date: Sun, 5 Mar 2023 05:36:25 -0700 Subject: [PATCH 0640/2810] Revert "Replace Fitbit weight SensorStateClass measurement with total" (#89126) --- homeassistant/components/fitbit/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/fitbit/const.py b/homeassistant/components/fitbit/const.py index 8a80ac610f..d746e63ca5 100644 --- a/homeassistant/components/fitbit/const.py +++ b/homeassistant/components/fitbit/const.py @@ -220,7 +220,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( name="Weight", unit_type="weight", icon="mdi:human", - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.WEIGHT, ), FitbitSensorEntityDescription( From 189c6121008b1e73d48c1772a39293822831ce2a Mon Sep 17 00:00:00 2001 From: Felix Rotthowe Date: Sun, 5 Mar 2023 13:36:56 +0100 Subject: [PATCH 0641/2810] Add support for Livisi PSSO, ISS and ISS2 switch devices (#89140) --- homeassistant/components/livisi/const.py | 2 +- homeassistant/components/livisi/switch.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/livisi/const.py b/homeassistant/components/livisi/const.py index 684510cf7e..98e0b7816c 100644 --- a/homeassistant/components/livisi/const.py +++ b/homeassistant/components/livisi/const.py @@ -14,7 +14,7 @@ DEVICE_POLLING_DELAY: Final = 60 LIVISI_STATE_CHANGE: Final = "livisi_state_change" LIVISI_REACHABILITY_CHANGE: Final = "livisi_reachability_change" -PSS_DEVICE_TYPE: Final = "PSS" +SWITCH_DEVICE_TYPES: Final = ["ISS", "ISS2", "PSS", "PSSO"] VRCC_DEVICE_TYPE: Final = "VRCC" MAX_TEMPERATURE: Final = 30.0 diff --git a/homeassistant/components/livisi/switch.py b/homeassistant/components/livisi/switch.py index f5201ab8fa..1a5789ea24 100644 --- a/homeassistant/components/livisi/switch.py +++ b/homeassistant/components/livisi/switch.py @@ -10,7 +10,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, LIVISI_STATE_CHANGE, LOGGER, PSS_DEVICE_TYPE +from .const import DOMAIN, LIVISI_STATE_CHANGE, LOGGER, SWITCH_DEVICE_TYPES from .coordinator import LivisiDataUpdateCoordinator from .entity import LivisiEntity @@ -30,7 +30,7 @@ async def async_setup_entry( entities: list[SwitchEntity] = [] for device in shc_devices: if ( - device["type"] == PSS_DEVICE_TYPE + device["type"] in SWITCH_DEVICE_TYPES and device["id"] not in coordinator.devices ): livisi_switch: SwitchEntity = LivisiSwitch( From cf369ff1a5965dd86cb02770fc624a7b148b4428 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 5 Mar 2023 13:40:10 +0100 Subject: [PATCH 0642/2810] Unpin pandas for Python 3.11 (#89033) --- homeassistant/package_constraints.txt | 3 ++- script/gen_requirements_all.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b6d109fbfb..d72bea1983 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -137,7 +137,8 @@ pubnub!=6.4.0 iso4217!=1.10.20220401 # Pandas 1.4.4 has issues with wheels om armhf + Py3.10 -pandas==1.4.3 +# Limit this to Python 3.10, to be able to install Python 3.11 wheels for now +pandas==1.4.3;python_version<'3.11' # Matplotlib 3.6.2 has issues building wheels on armhf/armv7 # We need at least >=2.1.0 (tensorflow integration -> pycocotools) diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 9f08f0b628..cd53635d96 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -144,7 +144,8 @@ pubnub!=6.4.0 iso4217!=1.10.20220401 # Pandas 1.4.4 has issues with wheels om armhf + Py3.10 -pandas==1.4.3 +# Limit this to Python 3.10, to be able to install Python 3.11 wheels for now +pandas==1.4.3;python_version<'3.11' # Matplotlib 3.6.2 has issues building wheels on armhf/armv7 # We need at least >=2.1.0 (tensorflow integration -> pycocotools) From b51dadbfe68a42b04e89b465840781cd0fed3d42 Mon Sep 17 00:00:00 2001 From: Geoff Date: Mon, 6 Mar 2023 01:49:51 +1300 Subject: [PATCH 0643/2810] Update link to opencv in image_process log message (#89008) --- homeassistant/components/opencv/image_processing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/opencv/image_processing.py b/homeassistant/components/opencv/image_processing.py index 7c3a881edf..41738100ca 100644 --- a/homeassistant/components/opencv/image_processing.py +++ b/homeassistant/components/opencv/image_processing.py @@ -104,7 +104,7 @@ def setup_platform( if not CV2_IMPORTED: _LOGGER.error( "No OpenCV library found! Install or compile for your system " - "following instructions here: http://opencv.org/releases.html" + "following instructions here: https://opencv.org/?s=releases" ) return From cc6721c06bcc373e0b25883bc9cece5d0fbdbfef Mon Sep 17 00:00:00 2001 From: Andrew Westrope Date: Sun, 5 Mar 2023 12:51:02 +0000 Subject: [PATCH 0644/2810] 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 21ef280936..c2b32582ce 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 cf29d0ea80..79ba418d50 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 ea8b1a4396..f8cf7288e5 100644 --- a/homeassistant/components/geniushub/water_heater.py +++ b/homeassistant/components/geniushub/water_heater.py @@ -48,7 +48,7 @@ async def async_setup_platform( [ GeniusWaterHeater(broker, z) for z in broker.client.zone_objs - if z.data["type"] in GH_HEATERS + if z.data.get("type") in GH_HEATERS ] ) From 680f3c27a57ce7783114bd9cb1573ed8f53ed545 Mon Sep 17 00:00:00 2001 From: Ben Morton Date: Sun, 5 Mar 2023 13:02:38 +0000 Subject: [PATCH 0645/2810] Add support for Spotify podcasts (#87671) --- .../components/spotify/media_player.py | 49 ++++++++++++++----- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index 1145686efe..51dd645ec0 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -104,7 +104,6 @@ class SpotifyMediaPlayer(MediaPlayerEntity): _attr_has_entity_name = True _attr_icon = "mdi:spotify" - _attr_media_content_type = MediaType.MUSIC _attr_media_image_remotely_accessible = False def __init__( @@ -161,6 +160,15 @@ class SpotifyMediaPlayer(MediaPlayerEntity): item = self._currently_playing.get("item") or {} return item.get("uri") + @property + def media_content_type(self) -> str | None: + """Return the media type.""" + if not self._currently_playing: + return None + item = self._currently_playing.get("item") or {} + is_episode = item.get("type") == MediaType.EPISODE + return MediaType.PODCAST if is_episode else MediaType.MUSIC + @property def media_duration(self) -> int | None: """Duration of current playing media in seconds.""" @@ -191,13 +199,20 @@ class SpotifyMediaPlayer(MediaPlayerEntity): @property def media_image_url(self) -> str | None: """Return the media image URL.""" - if ( - not self._currently_playing - or self._currently_playing.get("item") is None - or not self._currently_playing["item"]["album"]["images"] - ): + if not self._currently_playing or self._currently_playing.get("item") is None: return None - return fetch_image_url(self._currently_playing["item"]["album"]) + + item = self._currently_playing["item"] + if item["type"] == MediaType.EPISODE: + if item["images"]: + return fetch_image_url(item) + if item["show"]["images"]: + return fetch_image_url(item["show"]) + return None + + if not item["album"]["images"]: + return None + return fetch_image_url(item["album"]) @property def media_title(self) -> str | None: @@ -212,16 +227,24 @@ class SpotifyMediaPlayer(MediaPlayerEntity): """Return the media artist.""" if not self._currently_playing or self._currently_playing.get("item") is None: return None - return ", ".join( - artist["name"] for artist in self._currently_playing["item"]["artists"] - ) + + item = self._currently_playing["item"] + if item["type"] == MediaType.EPISODE: + return item["show"]["publisher"] + + return ", ".join(artist["name"] for artist in item["artists"]) @property def media_album_name(self) -> str | None: """Return the media album.""" if not self._currently_playing or self._currently_playing.get("item") is None: return None - return self._currently_playing["item"]["album"]["name"] + + item = self._currently_playing["item"] + if item["type"] == MediaType.EPISODE: + return item["show"]["name"] + + return item["album"]["name"] @property def media_track(self) -> int | None: @@ -359,7 +382,9 @@ class SpotifyMediaPlayer(MediaPlayerEntity): ).result() self.data.client.set_auth(auth=self.data.session.token["access_token"]) - current = self.data.client.current_playback() + current = self.data.client.current_playback( + additional_types=[MediaType.EPISODE] + ) self._currently_playing = current or {} context = self._currently_playing.get("context") From 2e1f6cad96db2a4eca20c95fe36c1673b9739982 Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Sun, 5 Mar 2023 08:00:16 -0600 Subject: [PATCH 0646/2810] Detect newly connected clients in jellyfin (#89168) --- homeassistant/components/jellyfin/__init__.py | 4 +- .../components/jellyfin/coordinator.py | 11 +- .../components/jellyfin/media_player.py | 27 +- .../fixtures/sessions-new-client.json | 4846 +++++++++++++++++ .../components/jellyfin/test_media_player.py | 23 + 5 files changed, 4899 insertions(+), 12 deletions(-) create mode 100644 tests/components/jellyfin/fixtures/sessions-new-client.json diff --git a/homeassistant/components/jellyfin/__init__.py b/homeassistant/components/jellyfin/__init__.py index 39085317a5..565c106f6a 100644 --- a/homeassistant/components/jellyfin/__init__.py +++ b/homeassistant/components/jellyfin/__init__.py @@ -36,7 +36,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: server_info: dict[str, Any] = connect_result["Servers"][0] coordinators: dict[str, JellyfinDataUpdateCoordinator[Any]] = { - "sessions": SessionsDataUpdateCoordinator(hass, client, server_info, user_id), + "sessions": SessionsDataUpdateCoordinator( + hass, client, server_info, entry.data[CONF_CLIENT_DEVICE_ID], user_id + ), } for coordinator in coordinators.values(): diff --git a/homeassistant/components/jellyfin/coordinator.py b/homeassistant/components/jellyfin/coordinator.py index b7563dcd86..3d5b150f39 100644 --- a/homeassistant/components/jellyfin/coordinator.py +++ b/homeassistant/components/jellyfin/coordinator.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN, LOGGER +from .const import DOMAIN, LOGGER, USER_APP_NAME JellyfinDataT = TypeVar( "JellyfinDataT", @@ -29,6 +29,7 @@ class JellyfinDataUpdateCoordinator(DataUpdateCoordinator[JellyfinDataT], ABC): hass: HomeAssistant, api_client: JellyfinClient, system_info: dict[str, Any], + client_device_id: str, user_id: str, ) -> None: """Initialize the coordinator.""" @@ -42,8 +43,11 @@ class JellyfinDataUpdateCoordinator(DataUpdateCoordinator[JellyfinDataT], ABC): self.server_id: str = system_info["Id"] self.server_name: str = system_info["Name"] self.server_version: str | None = system_info.get("Version") + self.client_device_id: str = client_device_id self.user_id: str = user_id + self.session_ids: set[str] = set() + async def _async_update_data(self) -> JellyfinDataT: """Get the latest data from Jellyfin.""" return await self._fetch_data() @@ -65,7 +69,10 @@ class SessionsDataUpdateCoordinator( ) sessions_by_id: dict[str, dict[str, Any]] = { - session["Id"]: session for session in sessions + session["Id"]: session + for session in sessions + if session["DeviceId"] != self.client_device_id + and session["Client"] != USER_APP_NAME } return sessions_by_id diff --git a/homeassistant/components/jellyfin/media_player.py b/homeassistant/components/jellyfin/media_player.py index 60fae2caac..3b3c8fbdf5 100644 --- a/homeassistant/components/jellyfin/media_player.py +++ b/homeassistant/components/jellyfin/media_player.py @@ -19,7 +19,7 @@ from homeassistant.util.dt import parse_datetime from .browse_media import build_item_response, build_root_response from .client_wrapper import get_artwork_url -from .const import CONTENT_TYPE_MAP, DOMAIN, USER_APP_NAME +from .const import CONTENT_TYPE_MAP, DOMAIN, LOGGER from .coordinator import JellyfinDataUpdateCoordinator from .entity import JellyfinEntity from .models import JellyfinData @@ -34,14 +34,23 @@ async def async_setup_entry( jellyfin_data: JellyfinData = hass.data[DOMAIN][entry.entry_id] coordinator = jellyfin_data.coordinators["sessions"] - async_add_entities( - ( - JellyfinMediaPlayer(coordinator, session_id, session_data) - for session_id, session_data in coordinator.data.items() - if session_data["DeviceId"] != jellyfin_data.client_device_id - and session_data["Client"] != USER_APP_NAME - ), - ) + @callback + def handle_coordinator_update() -> None: + """Add media player per session.""" + entities: list[MediaPlayerEntity] = [] + for session_id, session_data in coordinator.data.items(): + if session_id not in coordinator.session_ids: + entity: MediaPlayerEntity = JellyfinMediaPlayer( + coordinator, session_id, session_data + ) + LOGGER.debug("Creating media player for session: %s", session_id) + coordinator.session_ids.add(session_id) + entities.append(entity) + async_add_entities(entities) + + handle_coordinator_update() + + entry.async_on_unload(coordinator.async_add_listener(handle_coordinator_update)) class JellyfinMediaPlayer(JellyfinEntity, MediaPlayerEntity): diff --git a/tests/components/jellyfin/fixtures/sessions-new-client.json b/tests/components/jellyfin/fixtures/sessions-new-client.json new file mode 100644 index 0000000000..ff8ab8885a --- /dev/null +++ b/tests/components/jellyfin/fixtures/sessions-new-client.json @@ -0,0 +1,4846 @@ +[ + { + "PlayState": { + "PositionTicks": 100000000, + "CanSeek": true, + "IsPaused": true, + "IsMuted": true, + "VolumeLevel": 0, + "AudioStreamIndex": 0, + "SubtitleStreamIndex": 0, + "MediaSourceId": "string", + "PlayMethod": "Transcode", + "RepeatMode": "RepeatNone", + "LiveStreamId": "string" + }, + "AdditionalUsers": [ + { + "UserId": "08ba1929-681e-4b24-929b-9245852f65c0", + "UserName": "string" + } + ], + "Capabilities": { + "PlayableMediaTypes": ["Video"], + "SupportedCommands": ["VolumeSet", "Mute"], + "SupportsMediaControl": true, + "SupportsContentUploading": true, + "MessageCallbackUrl": "string", + "SupportsPersistentIdentifier": true, + "SupportsSync": true, + "DeviceProfile": { + "Name": "string", + "Id": "string", + "Identification": { + "FriendlyName": "string", + "ModelNumber": "string", + "SerialNumber": "string", + "ModelName": "string", + "ModelDescription": "string", + "ModelUrl": "string", + "Manufacturer": "string", + "ManufacturerUrl": "string", + "Headers": [ + { + "Name": "string", + "Value": "string", + "Match": "Equals" + } + ] + }, + "FriendlyName": "string", + "Manufacturer": "string", + "ManufacturerUrl": "string", + "ModelName": "string", + "ModelDescription": "string", + "ModelNumber": "string", + "ModelUrl": "string", + "SerialNumber": "string", + "EnableAlbumArtInDidl": false, + "EnableSingleAlbumArtLimit": false, + "EnableSingleSubtitleLimit": false, + "SupportedMediaTypes": "string", + "UserId": "string", + "AlbumArtPn": "string", + "MaxAlbumArtWidth": 0, + "MaxAlbumArtHeight": 0, + "MaxIconWidth": 0, + "MaxIconHeight": 0, + "MaxStreamingBitrate": 0, + "MaxStaticBitrate": 0, + "MusicStreamingTranscodingBitrate": 0, + "MaxStaticMusicBitrate": 0, + "SonyAggregationFlags": "string", + "ProtocolInfo": "string", + "TimelineOffsetSeconds": 0, + "RequiresPlainVideoItems": false, + "RequiresPlainFolders": false, + "EnableMSMediaReceiverRegistrar": false, + "IgnoreTranscodeByteRangeRequests": false, + "XmlRootAttributes": [ + { + "Name": "string", + "Value": "string" + } + ], + "DirectPlayProfiles": [ + { + "Container": "string", + "AudioCodec": "string", + "VideoCodec": "string", + "Type": "Audio" + } + ], + "TranscodingProfiles": [ + { + "Container": "string", + "Type": "Audio", + "VideoCodec": "string", + "AudioCodec": "string", + "Protocol": "string", + "EstimateContentLength": false, + "EnableMpegtsM2TsMode": false, + "TranscodeSeekInfo": "Auto", + "CopyTimestamps": false, + "Context": "Streaming", + "EnableSubtitlesInManifest": false, + "MaxAudioChannels": "string", + "MinSegments": 0, + "SegmentLength": 0, + "BreakOnNonKeyFrames": false, + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ] + } + ], + "ContainerProfiles": [ + { + "Type": "Audio", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "Container": "string" + } + ], + "CodecProfiles": [ + { + "Type": "Video", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "ApplyConditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "Codec": "string", + "Container": "string" + } + ], + "ResponseProfiles": [ + { + "Container": "string", + "AudioCodec": "string", + "VideoCodec": "string", + "Type": "Audio", + "OrgPn": "string", + "MimeType": "string", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ] + } + ], + "SubtitleProfiles": [ + { + "Format": "string", + "Method": "Encode", + "DidlMode": "string", + "Language": "string", + "Container": "string" + } + ] + }, + "AppStoreUrl": "string", + "IconUrl": "string" + }, + "RemoteEndPoint": "string", + "PlayableMediaTypes": ["Video"], + "Id": "SESSION-UUID", + "UserId": "08ba1929-681e-4b24-929b-9245852f65c0", + "UserName": "string", + "Client": "Jellyfin for Developers", + "LastActivityDate": "2019-08-24T14:15:22Z", + "LastPlaybackCheckIn": "2019-08-24T14:15:22Z", + "DeviceName": "JELLYFIN-DEVICE", + "DeviceType": "string", + "NowPlayingItem": { + "Name": "EPISODE", + "OriginalTitle": "string", + "ServerId": "SERVER-UUID", + "Id": "EPISODE-UUID", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 600000000, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 3, + "IndexNumberEnd": 0, + "ParentIndexNumber": 1, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": false, + "ParentId": "PARENT-UUID", + "Type": "Episode", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "SERIES", + "SeriesId": "SERIES-UUID", + "SeasonId": "SEASON-UUID", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "SEASON", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "HASS", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + }, + "FullNowPlayingItem": { + "Size": 0, + "Container": "string", + "IsHD": true, + "IsShortcut": true, + "ShortcutPath": "string", + "Width": 0, + "Height": 0, + "ExtraIds": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "DateLastSaved": "2019-08-24T14:15:22Z", + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "SupportsExternalTransfer": true + }, + "NowViewingItem": { + "Name": "string", + "OriginalTitle": "string", + "ServerId": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": true, + "ParentId": "c54e2d15-b5eb-48b7-9b04-53f376904b1e", + "Type": "AggregateFolder", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + }, + "DeviceId": "DEVICE-UUID", + "ApplicationVersion": "1.0.0", + "TranscodingInfo": { + "AudioCodec": "string", + "VideoCodec": "string", + "Container": "string", + "IsVideoDirect": true, + "IsAudioDirect": true, + "Bitrate": 0, + "Framerate": 0, + "CompletionPercentage": 0, + "Width": 0, + "Height": 0, + "AudioChannels": 0, + "HardwareAccelerationType": "AMF", + "TranscodeReasons": "ContainerNotSupported" + }, + "IsActive": true, + "SupportsMediaControl": true, + "SupportsRemoteControl": true, + "NowPlayingQueue": [ + { + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "PlaylistItemId": "string" + } + ], + "NowPlayingQueueFullItems": [ + { + "Name": "string", + "OriginalTitle": "string", + "ServerId": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": true, + "ParentId": "c54e2d15-b5eb-48b7-9b04-53f376904b1e", + "Type": "AggregateFolder", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + } + ], + "HasCustomDeviceName": true, + "PlaylistItemId": "string", + "ServerId": "SERVER-UUID", + "UserPrimaryImageTag": "string", + "SupportedCommands": ["MoveUp"] + }, + { + "PlayState": { + "PositionTicks": 230000000, + "CanSeek": true, + "IsPaused": false, + "IsMuted": false, + "VolumeLevel": 55, + "AudioStreamIndex": 0, + "SubtitleStreamIndex": 0, + "MediaSourceId": "string", + "PlayMethod": "Transcode", + "RepeatMode": "RepeatNone", + "LiveStreamId": "string" + }, + "AdditionalUsers": [ + { + "UserId": "08ba1929-681e-4b24-929b-9245852f65c0", + "UserName": "string" + } + ], + "Capabilities": { + "PlayableMediaTypes": ["Video"], + "SupportedCommands": ["VolumeSet", "Mute"], + "SupportsMediaControl": true, + "SupportsContentUploading": true, + "MessageCallbackUrl": "string", + "SupportsPersistentIdentifier": true, + "SupportsSync": true, + "DeviceProfile": { + "Name": "string", + "Id": "string", + "Identification": { + "FriendlyName": "string", + "ModelNumber": "string", + "SerialNumber": "string", + "ModelName": "string", + "ModelDescription": "string", + "ModelUrl": "string", + "Manufacturer": "string", + "ManufacturerUrl": "string", + "Headers": [ + { + "Name": "string", + "Value": "string", + "Match": "Equals" + } + ] + }, + "FriendlyName": "string", + "Manufacturer": "string", + "ManufacturerUrl": "string", + "ModelName": "string", + "ModelDescription": "string", + "ModelNumber": "string", + "ModelUrl": "string", + "SerialNumber": "string", + "EnableAlbumArtInDidl": false, + "EnableSingleAlbumArtLimit": false, + "EnableSingleSubtitleLimit": false, + "SupportedMediaTypes": "string", + "UserId": "string", + "AlbumArtPn": "string", + "MaxAlbumArtWidth": 0, + "MaxAlbumArtHeight": 0, + "MaxIconWidth": 0, + "MaxIconHeight": 0, + "MaxStreamingBitrate": 0, + "MaxStaticBitrate": 0, + "MusicStreamingTranscodingBitrate": 0, + "MaxStaticMusicBitrate": 0, + "SonyAggregationFlags": "string", + "ProtocolInfo": "string", + "TimelineOffsetSeconds": 0, + "RequiresPlainVideoItems": false, + "RequiresPlainFolders": false, + "EnableMSMediaReceiverRegistrar": false, + "IgnoreTranscodeByteRangeRequests": false, + "XmlRootAttributes": [ + { + "Name": "string", + "Value": "string" + } + ], + "DirectPlayProfiles": [ + { + "Container": "string", + "AudioCodec": "string", + "VideoCodec": "string", + "Type": "Audio" + } + ], + "TranscodingProfiles": [ + { + "Container": "string", + "Type": "Audio", + "VideoCodec": "string", + "AudioCodec": "string", + "Protocol": "string", + "EstimateContentLength": false, + "EnableMpegtsM2TsMode": false, + "TranscodeSeekInfo": "Auto", + "CopyTimestamps": false, + "Context": "Streaming", + "EnableSubtitlesInManifest": false, + "MaxAudioChannels": "string", + "MinSegments": 0, + "SegmentLength": 0, + "BreakOnNonKeyFrames": false, + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ] + } + ], + "ContainerProfiles": [ + { + "Type": "Audio", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "Container": "string" + } + ], + "CodecProfiles": [ + { + "Type": "Video", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "ApplyConditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "Codec": "string", + "Container": "string" + } + ], + "ResponseProfiles": [ + { + "Container": "string", + "AudioCodec": "string", + "VideoCodec": "string", + "Type": "Audio", + "OrgPn": "string", + "MimeType": "string", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ] + } + ], + "SubtitleProfiles": [ + { + "Format": "string", + "Method": "Encode", + "DidlMode": "string", + "Language": "string", + "Container": "string" + } + ] + }, + "AppStoreUrl": "string", + "IconUrl": "string" + }, + "RemoteEndPoint": "string", + "PlayableMediaTypes": ["Video"], + "Id": "SESSION-UUID-TWO", + "UserId": "USER-UUID-TWO", + "UserName": "string", + "Client": "Jellyfin for Developers", + "LastActivityDate": "2019-08-24T14:15:22Z", + "LastPlaybackCheckIn": "2019-08-24T14:15:22Z", + "DeviceName": "JELLYFIN-DEVICE-TWO", + "DeviceType": "string", + "NowPlayingItem": { + "Name": "MOVIE", + "OriginalTitle": "string", + "ServerId": "SERVER-UUID", + "Id": "EPISODE-UUID", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 2000000000, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": false, + "ParentId": "", + "Type": "Movie", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "SERIES", + "SeriesId": "SERIES-UUID", + "SeasonId": "SEASON-UUID", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "SEASON", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "Backdrop": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "HASS", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + }, + "FullNowPlayingItem": { + "Size": 0, + "Container": "string", + "IsHD": true, + "IsShortcut": true, + "ShortcutPath": "string", + "Width": 0, + "Height": 0, + "ExtraIds": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "DateLastSaved": "2019-08-24T14:15:22Z", + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "SupportsExternalTransfer": true + }, + "NowViewingItem": { + "Name": "string", + "OriginalTitle": "string", + "ServerId": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": true, + "ParentId": "c54e2d15-b5eb-48b7-9b04-53f376904b1e", + "Type": "AggregateFolder", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + }, + "DeviceId": "DEVICE-UUID-TWO", + "ApplicationVersion": "1.0.0", + "TranscodingInfo": { + "AudioCodec": "string", + "VideoCodec": "string", + "Container": "string", + "IsVideoDirect": true, + "IsAudioDirect": true, + "Bitrate": 0, + "Framerate": 0, + "CompletionPercentage": 0, + "Width": 0, + "Height": 0, + "AudioChannels": 0, + "HardwareAccelerationType": "AMF", + "TranscodeReasons": "ContainerNotSupported" + }, + "IsActive": true, + "SupportsMediaControl": true, + "SupportsRemoteControl": true, + "NowPlayingQueue": [ + { + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "PlaylistItemId": "string" + } + ], + "NowPlayingQueueFullItems": [ + { + "Name": "string", + "OriginalTitle": "string", + "ServerId": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": true, + "ParentId": "c54e2d15-b5eb-48b7-9b04-53f376904b1e", + "Type": "AggregateFolder", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + } + ], + "HasCustomDeviceName": true, + "PlaylistItemId": "string", + "ServerId": "SERVER-UUID", + "UserPrimaryImageTag": "string", + "SupportedCommands": ["MoveUp"] + }, + { + "PlayState": { + "PositionTicks": 0, + "CanSeek": true, + "IsPaused": false, + "IsMuted": true, + "VolumeLevel": 0, + "AudioStreamIndex": 0, + "SubtitleStreamIndex": 0, + "MediaSourceId": "string", + "PlayMethod": "Transcode", + "RepeatMode": "RepeatNone", + "LiveStreamId": "string" + }, + "AdditionalUsers": [ + { + "UserId": "08ba1929-681e-4b24-929b-9245852f65c0", + "UserName": "string" + } + ], + "Capabilities": { + "PlayableMediaTypes": ["Video"], + "SupportedCommands": ["MoveUp"], + "SupportsMediaControl": false, + "SupportsContentUploading": false, + "MessageCallbackUrl": "string", + "SupportsPersistentIdentifier": false, + "SupportsSync": true, + "DeviceProfile": { + "Name": "string", + "Id": "string", + "Identification": { + "FriendlyName": "string", + "ModelNumber": "string", + "SerialNumber": "string", + "ModelName": "string", + "ModelDescription": "string", + "ModelUrl": "string", + "Manufacturer": "string", + "ManufacturerUrl": "string", + "Headers": [ + { + "Name": "string", + "Value": "string", + "Match": "Equals" + } + ] + }, + "FriendlyName": "string", + "Manufacturer": "string", + "ManufacturerUrl": "string", + "ModelName": "string", + "ModelDescription": "string", + "ModelNumber": "string", + "ModelUrl": "string", + "SerialNumber": "string", + "EnableAlbumArtInDidl": false, + "EnableSingleAlbumArtLimit": false, + "EnableSingleSubtitleLimit": false, + "SupportedMediaTypes": "string", + "UserId": "string", + "AlbumArtPn": "string", + "MaxAlbumArtWidth": 0, + "MaxAlbumArtHeight": 0, + "MaxIconWidth": 0, + "MaxIconHeight": 0, + "MaxStreamingBitrate": 0, + "MaxStaticBitrate": 0, + "MusicStreamingTranscodingBitrate": 0, + "MaxStaticMusicBitrate": 0, + "SonyAggregationFlags": "string", + "ProtocolInfo": "string", + "TimelineOffsetSeconds": 0, + "RequiresPlainVideoItems": false, + "RequiresPlainFolders": false, + "EnableMSMediaReceiverRegistrar": false, + "IgnoreTranscodeByteRangeRequests": false, + "XmlRootAttributes": [ + { + "Name": "string", + "Value": "string" + } + ], + "DirectPlayProfiles": [ + { + "Container": "string", + "AudioCodec": "string", + "VideoCodec": "string", + "Type": "Audio" + } + ], + "TranscodingProfiles": [ + { + "Container": "string", + "Type": "Audio", + "VideoCodec": "string", + "AudioCodec": "string", + "Protocol": "string", + "EstimateContentLength": false, + "EnableMpegtsM2TsMode": false, + "TranscodeSeekInfo": "Auto", + "CopyTimestamps": false, + "Context": "Streaming", + "EnableSubtitlesInManifest": false, + "MaxAudioChannels": "string", + "MinSegments": 0, + "SegmentLength": 0, + "BreakOnNonKeyFrames": false, + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ] + } + ], + "ContainerProfiles": [ + { + "Type": "Audio", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "Container": "string" + } + ], + "CodecProfiles": [ + { + "Type": "Video", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "ApplyConditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "Codec": "string", + "Container": "string" + } + ], + "ResponseProfiles": [ + { + "Container": "string", + "AudioCodec": "string", + "VideoCodec": "string", + "Type": "Audio", + "OrgPn": "string", + "MimeType": "string", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ] + } + ], + "SubtitleProfiles": [ + { + "Format": "string", + "Method": "Encode", + "DidlMode": "string", + "Language": "string", + "Container": "string" + } + ] + }, + "AppStoreUrl": "string", + "IconUrl": "string" + }, + "RemoteEndPoint": "string", + "PlayableMediaTypes": ["Video"], + "Id": "SESSION-UUID-THREE", + "UserId": "USER-UUID", + "UserName": "string", + "Client": "Jellyfin for Developers", + "LastActivityDate": "2019-08-24T14:15:22Z", + "LastPlaybackCheckIn": "2019-08-24T14:15:22Z", + "DeviceName": "JELLYFIN-DEVICE-THREE", + "DeviceType": "string", + "DeviceId": "DEVICE-UUID-THREE", + "ApplicationVersion": "2.0.0", + "TranscodingInfo": { + "AudioCodec": "string", + "VideoCodec": "string", + "Container": "string", + "IsVideoDirect": true, + "IsAudioDirect": true, + "Bitrate": 0, + "Framerate": 0, + "CompletionPercentage": 0, + "Width": 0, + "Height": 0, + "AudioChannels": 0, + "HardwareAccelerationType": "AMF", + "TranscodeReasons": "ContainerNotSupported" + }, + "IsActive": true, + "SupportsMediaControl": false, + "SupportsRemoteControl": false, + "NowPlayingQueue": [ + { + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "PlaylistItemId": "string" + } + ], + "NowPlayingQueueFullItems": [ + { + "Name": "string", + "OriginalTitle": "string", + "ServerId": "SERVER-UUID", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": true, + "ParentId": "c54e2d15-b5eb-48b7-9b04-53f376904b1e", + "Type": "AggregateFolder", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + } + ], + "HasCustomDeviceName": true, + "PlaylistItemId": "string", + "ServerId": "SERVER-UUID", + "UserPrimaryImageTag": "string", + "SupportedCommands": ["MoveUp"] + }, + { + "PlayState": { + "PositionTicks": 220246970, + "CanSeek": true, + "IsPaused": false, + "IsMuted": false, + "VolumeLevel": 100, + "MediaSourceId": "a744119f757f88858f95aab1628708c4", + "PlayMethod": "DirectPlay", + "RepeatMode": "RepeatNone" + }, + "AdditionalUsers": [], + "Capabilities": { + "PlayableMediaTypes": ["Audio", "Video"], + "SupportedCommands": [ + "MoveUp", + "MoveDown", + "MoveLeft", + "MoveRight", + "PageUp", + "PageDown", + "PreviousLetter", + "NextLetter", + "ToggleOsd", + "ToggleContextMenu", + "Select", + "Back", + "SendKey", + "SendString", + "GoHome", + "GoToSettings", + "VolumeUp", + "VolumeDown", + "Mute", + "Unmute", + "ToggleMute", + "SetVolume", + "SetAudioStreamIndex", + "SetSubtitleStreamIndex", + "DisplayContent", + "GoToSearch", + "DisplayMessage", + "SetRepeatMode", + "SetShuffleQueue", + "ChannelUp", + "ChannelDown", + "PlayMediaSource", + "PlayTrailers" + ], + "SupportsMediaControl": true, + "SupportsContentUploading": false, + "SupportsPersistentIdentifier": false, + "SupportsSync": false + }, + "RemoteEndPoint": "192.168.1.254", + "PlayableMediaTypes": ["Audio", "Video"], + "Id": "SESSION-UUID-FOUR", + "UserId": "USER-UUID-TWO", + "UserName": "USER", + "Client": "Jellyfin Android", + "LastActivityDate": "2022-10-19T03:20:20.1214274Z", + "LastPlaybackCheckIn": "2022-10-19T03:20:18.0973168Z", + "DeviceName": "JELLYFIN DEVICE FOUR", + "NowPlayingItem": { + "Name": "MUSIC FILE", + "ServerId": "SERVER-UUID", + "Id": "MUSIC-UUID", + "DateCreated": "2022-10-19T03:09:11.392057Z", + "ExternalUrls": [], + "Path": "string", + "EnableMediaSourceDisplay": true, + "ChannelId": null, + "Taglines": [], + "Genres": [], + "RunTimeTicks": 736391552, + "IndexNumber": 1, + "ProviderIds": {}, + "IsFolder": false, + "ParentId": "4c0343ed1bbcda094178076230051b7e", + "Type": "Audio", + "Studios": [], + "GenreItems": [], + "LocalTrailerCount": 0, + "SpecialFeatureCount": 0, + "Artists": ["Contributing Artist"], + "ArtistItems": [ + { + "Name": "Contributing Artist", + "Id": "1d864900526d9a9513b489f1cc28f8ca" + } + ], + "Album": "ALBUM", + "AlbumId": "ALBUM-UUID", + "AlbumArtist": "Album Artist", + "AlbumArtists": [ + { "Name": "Album Artist", "Id": "9a65b2c222ddb34e51f5cae360fad3a1" } + ], + "MediaStreams": [ + { + "Codec": "mp3", + "TimeBase": "1/14112000", + "DisplayTitle": "MP3 - Stereo", + "IsInterlaced": false, + "ChannelLayout": "stereo", + "BitRate": 256000, + "Channels": 2, + "SampleRate": 44100, + "IsDefault": false, + "IsForced": false, + "Type": "Audio", + "Index": 0, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + } + ], + "ImageTags": {}, + "BackdropImageTags": [], + "ImageBlurHashes": {}, + "LocationType": "FileSystem", + "MediaType": "Audio" + }, + "FullNowPlayingItem": { + "Size": 2356453, + "IsHD": false, + "IsShortcut": false, + "Width": 0, + "Height": 0, + "ExtraIds": [], + "DateLastSaved": "2022-10-19T03:10:11.9765475Z", + "RemoteTrailers": [], + "SupportsExternalTransfer": false + }, + "DeviceId": "DEVICE-UUID-FOUR", + "ApplicationVersion": "2.4.4", + "IsActive": true, + "SupportsMediaControl": true, + "SupportsRemoteControl": true, + "NowPlayingQueue": [ + { + "Id": "a744119f757f88858f95aab1628708c4", + "PlaylistItemId": "playlistItem2" + } + ], + "NowPlayingQueueFullItems": [ + { + "Name": "string", + "ServerId": "e1012aa74e1b40c8ac50f3af79e9e83f", + "Id": "a744119f757f88858f95aab1628708c4", + "Etag": "64ed7b4ce1127c5d41e685de30090383", + "DateCreated": "2022-10-19T03:09:11.392057Z", + "CanDelete": true, + "CanDownload": true, + "SortName": "string", + "ExternalUrls": [], + "MediaSources": [ + { + "Protocol": "File", + "Id": "a744119f757f88858f95aab1628708c4", + "Path": "string", + "Type": "Default", + "Container": "mp3", + "Size": 2356453, + "Name": "string", + "IsRemote": false, + "ETag": "83b0e0ece75386b479a2c3a09f71d695", + "RunTimeTicks": 736391552, + "ReadAtNativeFramerate": false, + "IgnoreDts": false, + "IgnoreIndex": false, + "GenPtsInput": false, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": false, + "RequiresOpening": false, + "RequiresClosing": false, + "RequiresLooping": false, + "SupportsProbing": true, + "MediaStreams": [ + { + "Codec": "mp3", + "TimeBase": "1/14112000", + "DisplayTitle": "MP3 - Stereo", + "IsInterlaced": false, + "ChannelLayout": "stereo", + "BitRate": 256000, + "Channels": 2, + "SampleRate": 44100, + "IsDefault": false, + "IsForced": false, + "Type": "Audio", + "Index": 0, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + } + ], + "MediaAttachments": [], + "Formats": [], + "Bitrate": 256000, + "RequiredHttpHeaders": {} + } + ], + "Path": "string", + "EnableMediaSourceDisplay": true, + "ChannelId": null, + "Taglines": [], + "Genres": [], + "RunTimeTicks": 736391552, + "RemoteTrailers": [], + "ProviderIds": {}, + "IsFolder": false, + "ParentId": "4c0343ed1bbcda094178076230051b7e", + "Type": "Audio", + "People": [], + "Studios": [], + "GenreItems": [], + "LocalTrailerCount": 0, + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "61bba315f137702baa296a1c417faada", + "Tags": [], + "Artists": [], + "ArtistItems": [], + "AlbumArtists": [], + "MediaStreams": [ + { + "Codec": "mp3", + "TimeBase": "1/14112000", + "DisplayTitle": "MP3 - Stereo", + "IsInterlaced": false, + "ChannelLayout": "stereo", + "BitRate": 256000, + "Channels": 2, + "SampleRate": 44100, + "IsDefault": false, + "IsForced": false, + "Type": "Audio", + "Index": 0, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + } + ], + "ImageTags": {}, + "BackdropImageTags": [], + "ImageBlurHashes": {}, + "LocationType": "FileSystem", + "MediaType": "Audio", + "LockedFields": [], + "LockData": false + } + ], + "HasCustomDeviceName": false, + "PlaylistItemId": "playlistItem2", + "ServerId": "SERVER-UUID", + "SupportedCommands": [ + "MoveUp", + "MoveDown", + "MoveLeft", + "MoveRight", + "PageUp", + "PageDown", + "PreviousLetter", + "NextLetter", + "ToggleOsd", + "ToggleContextMenu", + "Select", + "Back", + "SendKey", + "SendString", + "GoHome", + "GoToSettings", + "VolumeUp", + "VolumeDown", + "Mute", + "Unmute", + "ToggleMute", + "SetVolume", + "SetAudioStreamIndex", + "SetSubtitleStreamIndex", + "DisplayContent", + "GoToSearch", + "DisplayMessage", + "SetRepeatMode", + "SetShuffleQueue", + "ChannelUp", + "ChannelDown", + "PlayMediaSource", + "PlayTrailers" + ] + }, + { + "PlayState": { + "PositionTicks": 220246970, + "CanSeek": true, + "IsPaused": false, + "IsMuted": false, + "VolumeLevel": 100, + "MediaSourceId": "a744119f757f88858f95aab1628708c4", + "PlayMethod": "DirectPlay", + "RepeatMode": "RepeatNone" + }, + "AdditionalUsers": [], + "Capabilities": { + "PlayableMediaTypes": ["Audio", "Video"], + "SupportedCommands": [ + "MoveUp", + "MoveDown", + "MoveLeft", + "MoveRight", + "PageUp", + "PageDown", + "PreviousLetter", + "NextLetter", + "ToggleOsd", + "ToggleContextMenu", + "Select", + "Back", + "SendKey", + "SendString", + "GoHome", + "GoToSettings", + "VolumeUp", + "VolumeDown", + "Mute", + "Unmute", + "ToggleMute", + "SetVolume", + "SetAudioStreamIndex", + "SetSubtitleStreamIndex", + "DisplayContent", + "GoToSearch", + "DisplayMessage", + "SetRepeatMode", + "SetShuffleQueue", + "ChannelUp", + "ChannelDown", + "PlayMediaSource", + "PlayTrailers" + ], + "SupportsMediaControl": true, + "SupportsContentUploading": false, + "SupportsPersistentIdentifier": false, + "SupportsSync": false + }, + "RemoteEndPoint": "192.168.1.253", + "PlayableMediaTypes": ["Audio", "Video"], + "Id": "SESSION-UUID-FIVE", + "UserId": "USER-UUID-THREE", + "UserName": "USER", + "Client": "Jellyfin Android", + "LastActivityDate": "2022-10-19T03:20:20.1214274Z", + "LastPlaybackCheckIn": "2022-10-19T03:20:18.0973168Z", + "DeviceName": "JELLYFIN DEVICE FIVE", + "NowPlayingItem": { + "Name": "MUSIC FILE", + "ServerId": "SERVER-UUID", + "Id": "MUSIC-UUID", + "DateCreated": "2022-10-19T03:09:11.392057Z", + "ExternalUrls": [], + "Path": "string", + "EnableMediaSourceDisplay": true, + "ChannelId": null, + "Taglines": [], + "Genres": [], + "RunTimeTicks": 736391552, + "IndexNumber": 1, + "ProviderIds": {}, + "IsFolder": false, + "ParentId": "4c0343ed1bbcda094178076230051b7e", + "Type": "Audio", + "Studios": [], + "GenreItems": [], + "LocalTrailerCount": 0, + "SpecialFeatureCount": 0, + "Artists": ["Contributing Artist"], + "ArtistItems": [ + { + "Name": "Contributing Artist", + "Id": "1d864900526d9a9513b489f1cc28f8ca" + } + ], + "Album": "ALBUM", + "AlbumId": "ALBUM-UUID", + "AlbumArtist": "Album Artist", + "AlbumArtists": [ + { "Name": "Album Artist", "Id": "9a65b2c222ddb34e51f5cae360fad3a1" } + ], + "MediaStreams": [ + { + "Codec": "mp3", + "TimeBase": "1/14112000", + "DisplayTitle": "MP3 - Stereo", + "IsInterlaced": false, + "ChannelLayout": "stereo", + "BitRate": 256000, + "Channels": 2, + "SampleRate": 44100, + "IsDefault": false, + "IsForced": false, + "Type": "Audio", + "Index": 0, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + } + ], + "ImageTags": {}, + "BackdropImageTags": [], + "ImageBlurHashes": {}, + "LocationType": "FileSystem", + "MediaType": "Audio" + }, + "FullNowPlayingItem": { + "Size": 2356453, + "IsHD": false, + "IsShortcut": false, + "Width": 0, + "Height": 0, + "ExtraIds": [], + "DateLastSaved": "2022-10-19T03:10:11.9765475Z", + "RemoteTrailers": [], + "SupportsExternalTransfer": false + }, + "DeviceId": "DEVICE-UUID-FIVE", + "ApplicationVersion": "2.4.4", + "IsActive": true, + "SupportsMediaControl": true, + "SupportsRemoteControl": true, + "NowPlayingQueue": [ + { + "Id": "a744119f757f88858f95aab1628708c4", + "PlaylistItemId": "playlistItem2" + } + ], + "NowPlayingQueueFullItems": [ + { + "Name": "string", + "ServerId": "e1012aa74e1b40c8ac50f3af79e9e83f", + "Id": "a744119f757f88858f95aab1628708c4", + "Etag": "64ed7b4ce1127c5d41e685de30090383", + "DateCreated": "2022-10-19T03:09:11.392057Z", + "CanDelete": true, + "CanDownload": true, + "SortName": "string", + "ExternalUrls": [], + "MediaSources": [ + { + "Protocol": "File", + "Id": "a744119f757f88858f95aab1628708c4", + "Path": "string", + "Type": "Default", + "Container": "mp3", + "Size": 2356453, + "Name": "string", + "IsRemote": false, + "ETag": "83b0e0ece75386b479a2c3a09f71d695", + "RunTimeTicks": 736391552, + "ReadAtNativeFramerate": false, + "IgnoreDts": false, + "IgnoreIndex": false, + "GenPtsInput": false, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": false, + "RequiresOpening": false, + "RequiresClosing": false, + "RequiresLooping": false, + "SupportsProbing": true, + "MediaStreams": [ + { + "Codec": "mp3", + "TimeBase": "1/14112000", + "DisplayTitle": "MP3 - Stereo", + "IsInterlaced": false, + "ChannelLayout": "stereo", + "BitRate": 256000, + "Channels": 2, + "SampleRate": 44100, + "IsDefault": false, + "IsForced": false, + "Type": "Audio", + "Index": 0, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + } + ], + "MediaAttachments": [], + "Formats": [], + "Bitrate": 256000, + "RequiredHttpHeaders": {} + } + ], + "Path": "string", + "EnableMediaSourceDisplay": true, + "ChannelId": null, + "Taglines": [], + "Genres": [], + "RunTimeTicks": 736391552, + "RemoteTrailers": [], + "ProviderIds": {}, + "IsFolder": false, + "ParentId": "4c0343ed1bbcda094178076230051b7e", + "Type": "Audio", + "People": [], + "Studios": [], + "GenreItems": [], + "LocalTrailerCount": 0, + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "61bba315f137702baa296a1c417faada", + "Tags": [], + "Artists": [], + "ArtistItems": [], + "AlbumArtists": [], + "MediaStreams": [ + { + "Codec": "mp3", + "TimeBase": "1/14112000", + "DisplayTitle": "MP3 - Stereo", + "IsInterlaced": false, + "ChannelLayout": "stereo", + "BitRate": 256000, + "Channels": 2, + "SampleRate": 44100, + "IsDefault": false, + "IsForced": false, + "Type": "Audio", + "Index": 0, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + } + ], + "ImageTags": {}, + "BackdropImageTags": [], + "ImageBlurHashes": {}, + "LocationType": "FileSystem", + "MediaType": "Audio", + "LockedFields": [], + "LockData": false + } + ], + "HasCustomDeviceName": false, + "PlaylistItemId": "playlistItem2", + "ServerId": "SERVER-UUID", + "SupportedCommands": [ + "MoveUp", + "MoveDown", + "MoveLeft", + "MoveRight", + "PageUp", + "PageDown", + "PreviousLetter", + "NextLetter", + "ToggleOsd", + "ToggleContextMenu", + "Select", + "Back", + "SendKey", + "SendString", + "GoHome", + "GoToSettings", + "VolumeUp", + "VolumeDown", + "Mute", + "Unmute", + "ToggleMute", + "SetVolume", + "SetAudioStreamIndex", + "SetSubtitleStreamIndex", + "DisplayContent", + "GoToSearch", + "DisplayMessage", + "SetRepeatMode", + "SetShuffleQueue", + "ChannelUp", + "ChannelDown", + "PlayMediaSource", + "PlayTrailers" + ] + } +] diff --git a/tests/components/jellyfin/test_media_player.py b/tests/components/jellyfin/test_media_player.py index 80a2bf3eae..64ed41ffdf 100644 --- a/tests/components/jellyfin/test_media_player.py +++ b/tests/components/jellyfin/test_media_player.py @@ -33,6 +33,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util.dt import utcnow +from . import async_load_json_fixture + from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import WebSocketGenerator @@ -353,3 +355,24 @@ async def test_browse_media( response["error"]["message"] == "Media not found: collection / COLLECTION-UUID-404" ) + + +async def test_new_client_connected( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_api: MagicMock, +) -> None: + """Test Jellyfin media player reacts to new clients connecting.""" + mock_api.sessions.return_value = await async_load_json_fixture( + hass, + "sessions-new-client.json", + ) + + assert len(mock_api.sessions.mock_calls) == 1 + async_fire_time_changed(hass, utcnow() + timedelta(seconds=10)) + await hass.async_block_till_done() + assert len(mock_api.sessions.mock_calls) == 2 + + state = hass.states.get("media_player.jellyfin_device_five") + assert state From 7b54061ab75e9f891e653e93ce609c25624406d2 Mon Sep 17 00:00:00 2001 From: Greg Dowling Date: Sun, 5 Mar 2023 14:43:52 +0000 Subject: [PATCH 0647/2810] Add repeat to roon media player (#88851) --- homeassistant/components/roon/media_player.py | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index 09ecc3cec9..d87c6f3137 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -12,6 +12,7 @@ from homeassistant.components.media_player import ( MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, + RepeatMode, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEVICE_DEFAULT_NAME @@ -35,6 +36,16 @@ SERVICE_TRANSFER = "transfer" ATTR_TRANSFER = "transfer_id" +REPEAT_MODE_MAPPING_TO_HA = { + "loop": RepeatMode.ALL, + "disabled": RepeatMode.OFF, + "loop_one": RepeatMode.ONE, +} + +REPEAT_MODE_MAPPING_TO_ROON = { + value: key for key, value in REPEAT_MODE_MAPPING_TO_HA.items() +} + async def async_setup_entry( hass: HomeAssistant, @@ -84,6 +95,7 @@ class RoonDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.REPEAT_SET | MediaPlayerEntityFeature.SHUFFLE_SET | MediaPlayerEntityFeature.SEEK | MediaPlayerEntityFeature.TURN_ON @@ -262,6 +274,9 @@ class RoonDevice(MediaPlayerEntity): self._attr_unique_id = self.player_data["dev_id"] self._zone_id = self.player_data["zone_id"] self._output_id = self.player_data["output_id"] + self._attr_repeat = REPEAT_MODE_MAPPING_TO_HA.get( + self.player_data["settings"]["loop"] + ) self._attr_shuffle = self.player_data["settings"]["shuffle"] self._attr_name = self.player_data["display_name"] @@ -331,7 +346,7 @@ class RoonDevice(MediaPlayerEntity): def set_volume_level(self, volume: float) -> None: """Send new volume_level to device.""" - volume = int(volume * 100) + volume = volume * 100 self._server.roonapi.set_volume_percent(self.output_id, volume) def mute_volume(self, mute=True): @@ -373,6 +388,12 @@ class RoonDevice(MediaPlayerEntity): """Set shuffle state.""" self._server.roonapi.shuffle(self.output_id, shuffle) + def set_repeat(self, repeat: RepeatMode) -> None: + """Set repeat mode.""" + if repeat not in REPEAT_MODE_MAPPING_TO_ROON: + raise ValueError(f"Unsupported repeat mode: {repeat}") + self._server.roonapi.repeat(self.output_id, REPEAT_MODE_MAPPING_TO_ROON[repeat]) + def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: """Send the play_media command to the media player.""" From 2fc2c2efbeb80d92713423ec473fd9b8d96b6357 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 5 Mar 2023 17:05:32 +0100 Subject: [PATCH 0648/2810] Remove deprecated Moon YAML configuration (#89161) * Remove deprecated Moon YAML configuration * Restore old title defaults --- homeassistant/components/moon/config_flow.py | 10 +---- homeassistant/components/moon/sensor.py | 44 ++------------------ tests/components/moon/test_config_flow.py | 25 +---------- tests/components/moon/test_init.py | 30 ------------- 4 files changed, 6 insertions(+), 103 deletions(-) diff --git a/homeassistant/components/moon/config_flow.py b/homeassistant/components/moon/config_flow.py index abdd60c7b6..08b2a4995f 100644 --- a/homeassistant/components/moon/config_flow.py +++ b/homeassistant/components/moon/config_flow.py @@ -4,7 +4,6 @@ from __future__ import annotations from typing import Any from homeassistant.config_entries import ConfigFlow -from homeassistant.const import CONF_NAME from homeassistant.data_entry_flow import FlowResult from .const import DEFAULT_NAME, DOMAIN @@ -23,13 +22,6 @@ class MoonConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="single_instance_allowed") if user_input is not None: - return self.async_create_entry( - title=user_input.get(CONF_NAME, DEFAULT_NAME), - data={}, - ) + return self.async_create_entry(title=DEFAULT_NAME, data={}) return self.async_show_form(step_id="user") - - async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: - """Handle import from configuration.yaml.""" - return await self.async_step_user(user_input) diff --git a/homeassistant/components/moon/sensor.py b/homeassistant/components/moon/sensor.py index c244f16147..f8e1cd24ab 100644 --- a/homeassistant/components/moon/sensor.py +++ b/homeassistant/components/moon/sensor.py @@ -2,25 +2,16 @@ from __future__ import annotations from astral import moon -import voluptuous as vol -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, - SensorDeviceClass, - SensorEntity, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_NAME +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util -from .const import DEFAULT_NAME, DOMAIN +from .const import DOMAIN STATE_FIRST_QUARTER = "first_quarter" STATE_FULL_MOON = "full_moon" @@ -42,35 +33,6 @@ MOON_ICONS = { STATE_WAXING_GIBBOUS: "mdi:moon-waxing-gibbous", } -PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( - {vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string} -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Moon sensor.""" - async_create_issue( - hass, - DOMAIN, - "removed_yaml", - breaks_in_ha_version="2022.12.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="removed_yaml", - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - async def async_setup_entry( hass: HomeAssistant, diff --git a/tests/components/moon/test_config_flow.py b/tests/components/moon/test_config_flow.py index 2ef01b4f89..e7ee1cceef 100644 --- a/tests/components/moon/test_config_flow.py +++ b/tests/components/moon/test_config_flow.py @@ -1,11 +1,8 @@ """Tests for the Moon config flow.""" from unittest.mock import MagicMock -import pytest - from homeassistant.components.moon.const import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER -from homeassistant.const import CONF_NAME +from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -34,34 +31,16 @@ async def test_full_user_flow( assert result2.get("data") == {} -@pytest.mark.parametrize("source", [SOURCE_USER, SOURCE_IMPORT]) async def test_single_instance_allowed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - source: str, ) -> None: """Test we abort if already setup.""" mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": source} + DOMAIN, context={"source": SOURCE_USER} ) assert result.get("type") == FlowResultType.ABORT assert result.get("reason") == "single_instance_allowed" - - -async def test_import_flow( - hass: HomeAssistant, - mock_setup_entry: MagicMock, -) -> None: - """Test the import configuration flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_NAME: "My Moon"}, - ) - - assert result.get("type") == FlowResultType.CREATE_ENTRY - assert result.get("title") == "My Moon" - assert result.get("data") == {} diff --git a/tests/components/moon/test_init.py b/tests/components/moon/test_init.py index f0f7e59354..1b483db33d 100644 --- a/tests/components/moon/test_init.py +++ b/tests/components/moon/test_init.py @@ -1,12 +1,8 @@ """Tests for the Moon integration.""" -from unittest.mock import AsyncMock from homeassistant.components.moon.const import DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -27,29 +23,3 @@ async def test_load_unload_config_entry( assert not hass.data.get(DOMAIN) assert mock_config_entry.state is ConfigEntryState.NOT_LOADED - - -async def test_import_config( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, -) -> None: - """Test Moon being set up from config via import.""" - assert await async_setup_component( - hass, - SENSOR_DOMAIN, - { - SENSOR_DOMAIN: { - "platform": DOMAIN, - CONF_NAME: "My Moon", - } - }, - ) - await hass.async_block_till_done() - - config_entries = hass.config_entries.async_entries(DOMAIN) - assert len(config_entries) == 1 - - entry = config_entries[0] - assert entry.title == "My Moon" - assert entry.unique_id is None - assert entry.data == {} From a9becd8e0ed9926fd2ef50bbb7d8a1f06e349e2b Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 5 Mar 2023 17:06:48 +0100 Subject: [PATCH 0649/2810] Raise ValueError on date parsing of MQTT sensor with invalid date format (#89036) * Suppress ValueError on date parsing of MQTT sensor * Simplify, but not update state on invalid payload * Still raise an an invalid date * Make datetime state unknown on invalid format * remove unrelated added new line --- homeassistant/components/mqtt/sensor.py | 5 ++++- tests/components/mqtt/test_sensor.py | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index df51dd60a1..934f736958 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -284,7 +284,10 @@ class MqttSensor(MqttEntity, RestoreSensor): if self.device_class is None: self._attr_native_value = new_value return - if (payload_datetime := dt_util.parse_datetime(new_value)) is None: + try: + if (payload_datetime := dt_util.parse_datetime(new_value)) is None: + raise ValueError + except ValueError: _LOGGER.warning( "Invalid state message '%s' from '%s'", msg.payload, msg.topic ) diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 09944b56c0..66836a16ee 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -134,6 +134,12 @@ async def test_setting_sensor_value_via_mqtt_message( "2021-11-18T19:25:00+00:00", False, ), + ( + sensor.SensorDeviceClass.TIMESTAMP, + "2021-13-18T35:25:00+00:00", + STATE_UNKNOWN, + True, + ), (sensor.SensorDeviceClass.TIMESTAMP, "invalid", STATE_UNKNOWN, True), ], ) From 84402a9ae0fd3dce38ee0752b73bc612be985920 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 5 Mar 2023 17:07:32 +0100 Subject: [PATCH 0650/2810] Remove deprecated Season YAML configuration (#89166) * Remove deprecated Season YAML configuration * Restore old title defaults --- .../components/season/config_flow.py | 8 +-- homeassistant/components/season/sensor.py | 51 ++----------------- tests/components/season/test_config_flow.py | 32 ++---------- tests/components/season/test_init.py | 33 +----------- 4 files changed, 12 insertions(+), 112 deletions(-) diff --git a/homeassistant/components/season/config_flow.py b/homeassistant/components/season/config_flow.py index 854c015843..39a52e57b1 100644 --- a/homeassistant/components/season/config_flow.py +++ b/homeassistant/components/season/config_flow.py @@ -6,7 +6,7 @@ from typing import Any import voluptuous as vol from homeassistant.config_entries import ConfigFlow -from homeassistant.const import CONF_NAME, CONF_TYPE +from homeassistant.const import CONF_TYPE from homeassistant.data_entry_flow import FlowResult from .const import DEFAULT_NAME, DOMAIN, TYPE_ASTRONOMICAL, TYPE_METEOROLOGICAL @@ -25,7 +25,7 @@ class SeasonConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(user_input[CONF_TYPE]) self._abort_if_unique_id_configured() return self.async_create_entry( - title=user_input.get(CONF_NAME, DEFAULT_NAME), + title=DEFAULT_NAME, data={CONF_TYPE: user_input[CONF_TYPE]}, ) @@ -42,7 +42,3 @@ class SeasonConfigFlow(ConfigFlow, domain=DOMAIN): }, ), ) - - async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: - """Handle import from configuration.yaml.""" - return await self.async_step_user(user_input) diff --git a/homeassistant/components/season/sensor.py b/homeassistant/components/season/sensor.py index a568e51ed9..27a46943bb 100644 --- a/homeassistant/components/season/sensor.py +++ b/homeassistant/components/season/sensor.py @@ -4,25 +4,17 @@ from __future__ import annotations from datetime import date, datetime import ephem -import voluptuous as vol -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, - SensorDeviceClass, - SensorEntity, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_NAME, CONF_TYPE +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TYPE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.dt import utcnow -from .const import DEFAULT_NAME, DOMAIN, TYPE_ASTRONOMICAL, VALID_TYPES +from .const import DOMAIN, TYPE_ASTRONOMICAL EQUATOR = "equator" @@ -49,39 +41,6 @@ SEASON_ICONS = { } -PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_TYPE, default=TYPE_ASTRONOMICAL): vol.In(VALID_TYPES), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the season sensor platform.""" - async_create_issue( - hass, - DOMAIN, - "removed_yaml", - breaks_in_ha_version="2022.12.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="removed_yaml", - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, @@ -144,7 +103,7 @@ class SeasonSensorEntity(SensorEntity): self.hemisphere = hemisphere self.type = entry.data[CONF_TYPE] self._attr_device_info = DeviceInfo( - name=entry.title, + name="Season", identifiers={(DOMAIN, entry.entry_id)}, entry_type=DeviceEntryType.SERVICE, ) diff --git a/tests/components/season/test_config_flow.py b/tests/components/season/test_config_flow.py index 2cf9e46b66..6579bb53a9 100644 --- a/tests/components/season/test_config_flow.py +++ b/tests/components/season/test_config_flow.py @@ -1,15 +1,9 @@ """Tests for the Season config flow.""" from unittest.mock import MagicMock -import pytest - -from homeassistant.components.season.const import ( - DOMAIN, - TYPE_ASTRONOMICAL, - TYPE_METEOROLOGICAL, -) -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER -from homeassistant.const import CONF_NAME, CONF_TYPE +from homeassistant.components.season.const import DOMAIN, TYPE_ASTRONOMICAL +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -38,34 +32,16 @@ async def test_full_user_flow( assert result2.get("data") == {CONF_TYPE: TYPE_ASTRONOMICAL} -@pytest.mark.parametrize("source", [SOURCE_USER, SOURCE_IMPORT]) async def test_single_instance_allowed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - source: str, ) -> None: """Test we abort if already setup.""" mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": source}, data={CONF_TYPE: TYPE_ASTRONOMICAL} + DOMAIN, context={"source": SOURCE_USER}, data={CONF_TYPE: TYPE_ASTRONOMICAL} ) assert result.get("type") == FlowResultType.ABORT assert result.get("reason") == "already_configured" - - -async def test_import_flow( - hass: HomeAssistant, - mock_setup_entry: MagicMock, -) -> None: - """Test the import configuration flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_NAME: "My Seasons", CONF_TYPE: TYPE_METEOROLOGICAL}, - ) - - assert result.get("type") == FlowResultType.CREATE_ENTRY - assert result.get("title") == "My Seasons" - assert result.get("data") == {CONF_TYPE: TYPE_METEOROLOGICAL} diff --git a/tests/components/season/test_init.py b/tests/components/season/test_init.py index 94012ba16d..9d96451216 100644 --- a/tests/components/season/test_init.py +++ b/tests/components/season/test_init.py @@ -1,12 +1,7 @@ """Tests for the Season integration.""" -from unittest.mock import AsyncMock - -from homeassistant.components.season.const import DOMAIN, TYPE_ASTRONOMICAL -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.season.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_NAME, CONF_TYPE from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -27,29 +22,3 @@ async def test_load_unload_config_entry( assert not hass.data.get(DOMAIN) assert mock_config_entry.state is ConfigEntryState.NOT_LOADED - - -async def test_import_config( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, -) -> None: - """Test Season being set up from config via import.""" - assert await async_setup_component( - hass, - SENSOR_DOMAIN, - { - SENSOR_DOMAIN: { - "platform": DOMAIN, - CONF_NAME: "My Season", - } - }, - ) - await hass.async_block_till_done() - - config_entries = hass.config_entries.async_entries(DOMAIN) - assert len(config_entries) == 1 - - entry = config_entries[0] - assert entry.title == "My Season" - assert entry.unique_id == TYPE_ASTRONOMICAL - assert entry.data == {CONF_TYPE: TYPE_ASTRONOMICAL} From f4cda2dfda93296c3d90bb992068077290812271 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 5 Mar 2023 20:30:42 +0100 Subject: [PATCH 0651/2810] Add device_class and state_class to sql (#85418) --- homeassistant/components/sql/__init__.py | 8 ++++++++ homeassistant/components/sql/sensor.py | 22 +++++++++++++++++++++- tests/components/sql/__init__.py | 8 ++++++++ tests/components/sql/test_sensor.py | 17 +++++++++++++++++ 4 files changed, 54 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sql/__init__.py b/homeassistant/components/sql/__init__.py index bba49c415f..c0ec2dfab7 100644 --- a/homeassistant/components/sql/__init__.py +++ b/homeassistant/components/sql/__init__.py @@ -4,8 +4,14 @@ from __future__ import annotations import voluptuous as vol from homeassistant.components.recorder import CONF_DB_URL +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + DEVICE_CLASSES_SCHEMA, + STATE_CLASSES_SCHEMA, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_NAME, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, @@ -36,6 +42,8 @@ QUERY_SCHEMA = vol.Schema( vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_DB_URL): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, } ) diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 5d51087a9d..27cf798db3 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -11,9 +11,15 @@ from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session, scoped_session, sessionmaker from homeassistant.components.recorder import CONF_DB_URL, DEFAULT_DB_FILE, DEFAULT_URL -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_NAME, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, @@ -54,6 +60,8 @@ async def async_setup_platform( column_name: str = conf[CONF_COLUMN_NAME] unique_id: str | None = conf.get(CONF_UNIQUE_ID) db_url: str | None = conf.get(CONF_DB_URL) + device_class: SensorDeviceClass | None = conf.get(CONF_DEVICE_CLASS) + state_class: SensorStateClass | None = conf.get(CONF_STATE_CLASS) if value_template is not None: value_template.hass = hass @@ -68,6 +76,8 @@ async def async_setup_platform( unique_id, db_url, True, + device_class, + state_class, async_add_entities, ) @@ -104,6 +114,8 @@ async def async_setup_entry( entry.entry_id, db_url, False, + None, + None, async_add_entities, ) @@ -118,6 +130,8 @@ async def async_setup_sensor( unique_id: str | None, db_url: str | None, yaml: bool, + device_class: SensorDeviceClass | None, + state_class: SensorStateClass | None, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the SQL sensor.""" @@ -163,6 +177,8 @@ async def async_setup_sensor( value_template, unique_id, yaml, + device_class, + state_class, ) ], True, @@ -185,11 +201,15 @@ class SQLSensor(SensorEntity): value_template: Template | None, unique_id: str | None, yaml: bool, + device_class: SensorDeviceClass | None, + state_class: SensorStateClass | None, ) -> None: """Initialize the SQL sensor.""" self._query = query self._attr_name = name if yaml else None self._attr_native_unit_of_measurement = unit + self._attr_device_class = device_class + self._attr_state_class = state_class self._template = value_template self._column_name = column self.sessionmaker = sessmaker diff --git a/tests/components/sql/__init__.py b/tests/components/sql/__init__.py index 1c096dfef6..f6cfba01e3 100644 --- a/tests/components/sql/__init__.py +++ b/tests/components/sql/__init__.py @@ -4,9 +4,15 @@ from __future__ import annotations from typing import Any from homeassistant.components.recorder import CONF_DB_URL +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) from homeassistant.components.sql.const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_NAME, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, @@ -56,6 +62,8 @@ YAML_CONFIG = { CONF_UNIT_OF_MEASUREMENT: "MiB", CONF_UNIQUE_ID: "unique_id_12345", CONF_VALUE_TEMPLATE: "{{ value }}", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_RATE, + CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, } } diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index d7a12795ed..bc3143347b 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -9,6 +9,7 @@ from sqlalchemy import text as sql_text from sqlalchemy.exc import SQLAlchemyError from homeassistant.components.recorder import Recorder +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.components.sql.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import STATE_UNKNOWN @@ -300,3 +301,19 @@ async def test_invalid_url_setup_from_yaml( assert pattern not in caplog.text for pattern in expected_patterns: assert pattern in caplog.text + + +async def test_attributes_from_yaml_setup( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test attributes from yaml config.""" + + assert await async_setup_component(hass, DOMAIN, YAML_CONFIG) + await hass.async_block_till_done() + + state = hass.states.get("sensor.get_value") + + assert state.state == "5" + assert state.attributes["device_class"] == SensorDeviceClass.DATA_RATE + assert state.attributes["state_class"] == SensorStateClass.MEASUREMENT + assert state.attributes["unit_of_measurement"] == "MiB" From da100040a55e91339c37f1e41ce4dc52ae2dbcd8 Mon Sep 17 00:00:00 2001 From: Khole Date: Sun, 5 Mar 2023 19:43:33 +0000 Subject: [PATCH 0652/2810] Hive add ability to delete device (#80838) --- homeassistant/components/hive/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py index 4d309fe684..76d75e5172 100644 --- a/homeassistant/components/hive/__init__.py +++ b/homeassistant/components/hive/__init__.py @@ -17,6 +17,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -122,6 +123,13 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: ) +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry +) -> bool: + """Remove a config entry from a device.""" + return True + + def refresh_system( func: Callable[Concatenate[_HiveEntityT, _P], Awaitable[Any]] ) -> Callable[Concatenate[_HiveEntityT, _P], Coroutine[Any, Any, None]]: From b2c9208dd0b6e2b5ea08012b861110d2677fef7b Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 5 Mar 2023 21:00:51 +0100 Subject: [PATCH 0653/2810] Reolink add switch platform (#87943) Co-authored-by: Franck Nijhof --- .coveragerc | 1 + homeassistant/components/reolink/__init__.py | 1 + homeassistant/components/reolink/switch.py | 239 +++++++++++++++++++ 3 files changed, 241 insertions(+) create mode 100644 homeassistant/components/reolink/switch.py diff --git a/.coveragerc b/.coveragerc index e9c34e4d8f..6ef51578a5 100644 --- a/.coveragerc +++ b/.coveragerc @@ -982,6 +982,7 @@ omit = homeassistant/components/reolink/entity.py homeassistant/components/reolink/host.py homeassistant/components/reolink/number.py + homeassistant/components/reolink/switch.py homeassistant/components/reolink/update.py homeassistant/components/repetier/__init__.py homeassistant/components/repetier/sensor.py diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 7de1123956..f6a5e19764 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -28,6 +28,7 @@ PLATFORMS = [ Platform.BUTTON, Platform.CAMERA, Platform.NUMBER, + Platform.SWITCH, Platform.UPDATE, ] DEVICE_UPDATE_INTERVAL = timedelta(seconds=60) diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py new file mode 100644 index 0000000000..64d6155485 --- /dev/null +++ b/homeassistant/components/reolink/switch.py @@ -0,0 +1,239 @@ +"""Component providing support for Reolink switch entities.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from reolink_aio.api import Host + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ReolinkData +from .const import DOMAIN +from .entity import ReolinkBaseCoordinatorEntity, ReolinkCoordinatorEntity + + +@dataclass +class ReolinkSwitchEntityDescriptionMixin: + """Mixin values for Reolink switch entities.""" + + value: Callable[[Host, int], bool] + method: Callable[[Host, int, bool], Any] + + +@dataclass +class ReolinkSwitchEntityDescription( + SwitchEntityDescription, ReolinkSwitchEntityDescriptionMixin +): + """A class that describes switch entities.""" + + supported: Callable[[Host, int], bool] = lambda api, ch: True + + +@dataclass +class ReolinkNVRSwitchEntityDescriptionMixin: + """Mixin values for Reolink NVR switch entities.""" + + value: Callable[[Host], bool] + method: Callable[[Host, bool], Any] + + +@dataclass +class ReolinkNVRSwitchEntityDescription( + SwitchEntityDescription, ReolinkNVRSwitchEntityDescriptionMixin +): + """A class that describes NVR switch entities.""" + + supported: Callable[[Host], bool] = lambda api: True + + +SWITCH_ENTITIES = ( + ReolinkSwitchEntityDescription( + key="record_audio", + name="Record audio", + icon="mdi:microphone", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "audio"), + value=lambda api, ch: api.audio_record(ch), + method=lambda api, ch, value: api.set_audio(ch, value), + ), + ReolinkSwitchEntityDescription( + key="siren_on_event", + name="Siren on event", + icon="mdi:alarm-light", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "siren"), + value=lambda api, ch: api.audio_alarm_enabled(ch), + method=lambda api, ch, value: api.set_audio_alarm(ch, value), + ), + ReolinkSwitchEntityDescription( + key="auto_tracking", + name="Auto tracking", + icon="mdi:target-account", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "auto_track"), + value=lambda api, ch: api.auto_track_enabled(ch), + method=lambda api, ch, value: api.set_auto_tracking(ch, value), + ), + ReolinkSwitchEntityDescription( + key="auto_focus", + name="Auto focus", + icon="mdi:focus-field", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "auto_focus"), + value=lambda api, ch: api.autofocus_enabled(ch), + method=lambda api, ch, value: api.set_autofocus(ch, value), + ), + ReolinkSwitchEntityDescription( + key="gaurd_return", + name="Guard return", + icon="mdi:crosshairs-gps", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "ptz_guard"), + value=lambda api, ch: api.ptz_guard_enabled(ch), + method=lambda api, ch, value: api.set_ptz_guard(ch, enable=value), + ), +) + +NVR_SWITCH_ENTITIES = ( + ReolinkNVRSwitchEntityDescription( + key="email", + name="Email on event", + icon="mdi:email", + entity_category=EntityCategory.CONFIG, + supported=lambda api: api.supported(None, "email"), + value=lambda api: api.email_enabled(), + method=lambda api, value: api.set_email(None, value), + ), + ReolinkNVRSwitchEntityDescription( + key="ftp_upload", + name="FTP upload", + icon="mdi:swap-horizontal", + entity_category=EntityCategory.CONFIG, + supported=lambda api: api.supported(None, "ftp"), + value=lambda api: api.ftp_enabled(), + method=lambda api, value: api.set_ftp(None, value), + ), + ReolinkNVRSwitchEntityDescription( + key="push_notifications", + name="Push notifications", + icon="mdi:message-badge", + entity_category=EntityCategory.CONFIG, + supported=lambda api: api.supported(None, "push"), + value=lambda api: api.push_enabled(), + method=lambda api, value: api.set_push(None, value), + ), + ReolinkNVRSwitchEntityDescription( + key="record", + name="Record", + icon="mdi:record-rec", + supported=lambda api: api.supported(None, "recording"), + value=lambda api: api.recording_enabled(), + method=lambda api, value: api.set_recording(None, value), + ), + ReolinkNVRSwitchEntityDescription( + key="buzzer", + name="Buzzer on event", + icon="mdi:room-service", + entity_category=EntityCategory.CONFIG, + supported=lambda api: api.supported(None, "buzzer"), + value=lambda api: api.buzzer_enabled(), + method=lambda api, value: api.set_buzzer(None, value), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a Reolink switch entities.""" + reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[ReolinkSwitchEntity | ReolinkNVRSwitchEntity] = [ + ReolinkSwitchEntity(reolink_data, channel, entity_description) + for entity_description in SWITCH_ENTITIES + for channel in reolink_data.host.api.channels + if entity_description.supported(reolink_data.host.api, channel) + ] + entities.extend( + [ + ReolinkNVRSwitchEntity(reolink_data, entity_description) + for entity_description in NVR_SWITCH_ENTITIES + if entity_description.supported(reolink_data.host.api) + ] + ) + async_add_entities(entities) + + +class ReolinkSwitchEntity(ReolinkCoordinatorEntity, SwitchEntity): + """Base switch entity class for Reolink IP cameras.""" + + entity_description: ReolinkSwitchEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + channel: int, + entity_description: ReolinkSwitchEntityDescription, + ) -> None: + """Initialize Reolink switch entity.""" + super().__init__(reolink_data, channel) + self.entity_description = entity_description + + self._attr_unique_id = ( + f"{self._host.unique_id}_{channel}_{entity_description.key}" + ) + + @property + def is_on(self) -> bool: + """Return true if switch is on.""" + return self.entity_description.value(self._host.api, self._channel) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.entity_description.method(self._host.api, self._channel, True) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self.entity_description.method(self._host.api, self._channel, False) + self.async_write_ha_state() + + +class ReolinkNVRSwitchEntity(ReolinkBaseCoordinatorEntity, SwitchEntity): + """Switch entity class for Reolink NVR features.""" + + entity_description: ReolinkNVRSwitchEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + entity_description: ReolinkNVRSwitchEntityDescription, + ) -> None: + """Initialize Reolink switch entity.""" + super().__init__(reolink_data) + self.entity_description = entity_description + + self._attr_unique_id = f"{self._host.unique_id}_{entity_description.key}" + + @property + def is_on(self) -> bool: + """Return true if switch is on.""" + return self.entity_description.value(self._host.api) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.entity_description.method(self._host.api, True) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self.entity_description.method(self._host.api, False) + self.async_write_ha_state() From c792631f152395e079d4d0b382e5b1fc8a6732b0 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 5 Mar 2023 21:21:22 +0100 Subject: [PATCH 0654/2810] Add Reolink siren platform (#88217) Co-authored-by: Franck Nijhof Co-authored-by: Franck Nijhof --- .coveragerc | 1 + homeassistant/components/reolink/__init__.py | 1 + homeassistant/components/reolink/siren.py | 93 ++++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 homeassistant/components/reolink/siren.py diff --git a/.coveragerc b/.coveragerc index 6ef51578a5..69cd7bf4ca 100644 --- a/.coveragerc +++ b/.coveragerc @@ -982,6 +982,7 @@ omit = homeassistant/components/reolink/entity.py homeassistant/components/reolink/host.py homeassistant/components/reolink/number.py + homeassistant/components/reolink/siren.py homeassistant/components/reolink/switch.py homeassistant/components/reolink/update.py homeassistant/components/repetier/__init__.py diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index f6a5e19764..6bc2874285 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -28,6 +28,7 @@ PLATFORMS = [ Platform.BUTTON, Platform.CAMERA, Platform.NUMBER, + Platform.SIREN, Platform.SWITCH, Platform.UPDATE, ] diff --git a/homeassistant/components/reolink/siren.py b/homeassistant/components/reolink/siren.py new file mode 100644 index 0000000000..f2b27dda4d --- /dev/null +++ b/homeassistant/components/reolink/siren.py @@ -0,0 +1,93 @@ +"""Component providing support for Reolink siren entities.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from reolink_aio.api import Host + +from homeassistant.components.siren import ( + ATTR_DURATION, + ATTR_VOLUME_LEVEL, + SirenEntity, + SirenEntityDescription, + SirenEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ReolinkData +from .const import DOMAIN +from .entity import ReolinkCoordinatorEntity + + +@dataclass +class ReolinkSirenEntityDescription(SirenEntityDescription): + """A class that describes siren entities.""" + + supported: Callable[[Host, int], bool] = lambda api, ch: True + + +SIREN_ENTITIES = ( + ReolinkSirenEntityDescription( + key="siren", + name="Siren", + icon="mdi:alarm-light", + supported=lambda api, ch: api.supported(ch, "siren"), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a Reolink siren entities.""" + reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + ReolinkSirenEntity(reolink_data, channel, entity_description) + for entity_description in SIREN_ENTITIES + for channel in reolink_data.host.api.channels + if entity_description.supported(reolink_data.host.api, channel) + ) + + +class ReolinkSirenEntity(ReolinkCoordinatorEntity, SirenEntity): + """Base siren entity class for Reolink IP cameras.""" + + _attr_supported_features = ( + SirenEntityFeature.TURN_ON + | SirenEntityFeature.TURN_OFF + | SirenEntityFeature.DURATION + | SirenEntityFeature.VOLUME_SET + ) + entity_description: ReolinkSirenEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + channel: int, + entity_description: ReolinkSirenEntityDescription, + ) -> None: + """Initialize Reolink siren entity.""" + super().__init__(reolink_data, channel) + self.entity_description = entity_description + + self._attr_unique_id = ( + f"{self._host.unique_id}_{channel}_{entity_description.key}" + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the siren.""" + if (volume := kwargs.get(ATTR_VOLUME_LEVEL)) is not None: + await self._host.api.set_volume(self._channel, int(volume * 100)) + duration = kwargs.get(ATTR_DURATION) + await self._host.api.set_siren(self._channel, True, duration) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the siren.""" + await self._host.api.set_siren(self._channel, False, None) From 37ec442ffb175aa32fb9d3a6f92fbe505c0290da Mon Sep 17 00:00:00 2001 From: Tucker Kern Date: Sun, 5 Mar 2023 14:12:30 -0700 Subject: [PATCH 0655/2810] Use title case for Transmission status sensor (#88578 * Use title case for Transmission status sensor * Use localizations for transmission status sensor * Assign device class and options as requested by review. * Don't use title case for entity names --- .../components/transmission/const.py | 6 ++++- .../components/transmission/sensor.py | 27 ++++++++++++------- .../components/transmission/strings.json | 12 +++++++++ 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/transmission/const.py b/homeassistant/components/transmission/const.py index 742ef874a3..94296f53a6 100644 --- a/homeassistant/components/transmission/const.py +++ b/homeassistant/components/transmission/const.py @@ -1,7 +1,7 @@ """Constants for the Transmission Bittorent Client component.""" DOMAIN = "transmission" -SWITCH_TYPES = {"on_off": "Switch", "turtle_mode": "Turtle Mode"} +SWITCH_TYPES = {"on_off": "Switch", "turtle_mode": "Turtle mode"} ORDER_NEWEST_FIRST = "newest_first" ORDER_OLDEST_FIRST = "oldest_first" @@ -44,3 +44,7 @@ DATA_UPDATED = "transmission_data_updated" EVENT_STARTED_TORRENT = "transmission_started_torrent" EVENT_REMOVED_TORRENT = "transmission_removed_torrent" EVENT_DOWNLOADED_TORRENT = "transmission_downloaded_torrent" + +STATE_UP_DOWN = "up_down" +STATE_SEEDING = "seeding" +STATE_DOWNLOADING = "downloading" diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index b1ff20627e..46d12d6798 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -20,6 +20,9 @@ from .const import ( CONF_ORDER, DOMAIN, STATE_ATTR_TORRENT_INFO, + STATE_DOWNLOADING, + STATE_SEEDING, + STATE_UP_DOWN, SUPPORTED_ORDER_MODES, ) @@ -35,14 +38,14 @@ async def async_setup_entry( name = config_entry.data[CONF_NAME] dev = [ - TransmissionSpeedSensor(tm_client, name, "Down Speed", "download"), - TransmissionSpeedSensor(tm_client, name, "Up Speed", "upload"), + TransmissionSpeedSensor(tm_client, name, "Down speed", "download"), + TransmissionSpeedSensor(tm_client, name, "Up speed", "upload"), TransmissionStatusSensor(tm_client, name, "Status"), - TransmissionTorrentsSensor(tm_client, name, "Active Torrents", "active"), - TransmissionTorrentsSensor(tm_client, name, "Paused Torrents", "paused"), - TransmissionTorrentsSensor(tm_client, name, "Total Torrents", "total"), - TransmissionTorrentsSensor(tm_client, name, "Completed Torrents", "completed"), - TransmissionTorrentsSensor(tm_client, name, "Started Torrents", "started"), + TransmissionTorrentsSensor(tm_client, name, "Active torrents", "active"), + TransmissionTorrentsSensor(tm_client, name, "Paused torrents", "paused"), + TransmissionTorrentsSensor(tm_client, name, "Total torrents", "total"), + TransmissionTorrentsSensor(tm_client, name, "Completed torrents", "completed"), + TransmissionTorrentsSensor(tm_client, name, "Started torrents", "started"), ] async_add_entities(dev, True) @@ -123,17 +126,21 @@ class TransmissionSpeedSensor(TransmissionSensor): class TransmissionStatusSensor(TransmissionSensor): """Representation of a Transmission status sensor.""" + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = [STATE_IDLE, STATE_UP_DOWN, STATE_SEEDING, STATE_DOWNLOADING] + _attr_translation_key = "transmission_status" + def update(self) -> None: """Get the latest data from Transmission and updates the state.""" if data := self._tm_client.api.data: upload = data.uploadSpeed download = data.downloadSpeed if upload > 0 and download > 0: - self._state = "Up/Down" + self._state = STATE_UP_DOWN elif upload > 0 and download == 0: - self._state = "Seeding" + self._state = STATE_SEEDING elif upload == 0 and download > 0: - self._state = "Downloading" + self._state = STATE_DOWNLOADING else: self._state = STATE_IDLE else: diff --git a/homeassistant/components/transmission/strings.json b/homeassistant/components/transmission/strings.json index 2cf9fafff4..ed1b2f185a 100644 --- a/homeassistant/components/transmission/strings.json +++ b/homeassistant/components/transmission/strings.json @@ -40,5 +40,17 @@ } } } + }, + "entity": { + "sensor": { + "transmission_status": { + "state": { + "idle": "Idle", + "up_down": "Up/Down", + "seeding": "Seeding", + "downloading": "Downloading" + } + } + } } } From d9fc932253072af1c39e00b684133c98105a55de Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 5 Mar 2023 22:19:40 +0100 Subject: [PATCH 0656/2810] 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 1a2d0c526d..ffc00e6f92 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -1,7 +1,7 @@ """Support for the Tuya lights.""" from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field import json from typing import Any, cast @@ -59,7 +59,9 @@ class TuyaLightEntityDescription(LightEntityDescription): color_data: DPCode | tuple[DPCode, ...] | None = None color_mode: DPCode | None = None color_temp: DPCode | tuple[DPCode, ...] | None = None - default_color_type: ColorTypeData = DEFAULT_COLOR_TYPE_DATA + default_color_type: ColorTypeData = field( + default_factory=lambda: DEFAULT_COLOR_TYPE_DATA + ) LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { From 497e3cf744c7625f2c17141d426ea4b6d2d3f9f0 Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Sun, 5 Mar 2023 23:35:48 +0100 Subject: [PATCH 0657/2810] Bump bthome to 2.8.0 (#89192) --- homeassistant/components/bthome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index f875074a9f..da8f719bf7 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", "iot_class": "local_push", - "requirements": ["bthome-ble==2.7.0"] + "requirements": ["bthome-ble==2.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ad423c2a3e..2bd1992333 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -492,7 +492,7 @@ brunt==1.2.0 bt_proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==2.7.0 +bthome-ble==2.8.0 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fa53828960..5adbd10f70 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -399,7 +399,7 @@ brother==2.3.0 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==2.7.0 +bthome-ble==2.8.0 # homeassistant.components.buienradar buienradar==1.0.5 From b14c5046e2e7172197a557f612b8a2916c198a01 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 5 Mar 2023 23:43:58 +0100 Subject: [PATCH 0658/2810] Reolink add select platform (#87946) Co-authored-by: Franck Nijhof --- .coveragerc | 1 + homeassistant/components/reolink/__init__.py | 1 + homeassistant/components/reolink/select.py | 123 ++++++++++++++++++ homeassistant/components/reolink/strings.json | 18 +++ 4 files changed, 143 insertions(+) create mode 100644 homeassistant/components/reolink/select.py diff --git a/.coveragerc b/.coveragerc index 69cd7bf4ca..e44ca0d70d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -982,6 +982,7 @@ omit = homeassistant/components/reolink/entity.py homeassistant/components/reolink/host.py homeassistant/components/reolink/number.py + homeassistant/components/reolink/select.py homeassistant/components/reolink/siren.py homeassistant/components/reolink/switch.py homeassistant/components/reolink/update.py diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 6bc2874285..bed286c3bf 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -28,6 +28,7 @@ PLATFORMS = [ Platform.BUTTON, Platform.CAMERA, Platform.NUMBER, + Platform.SELECT, Platform.SIREN, Platform.SWITCH, Platform.UPDATE, diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py new file mode 100644 index 0000000000..8df4afba73 --- /dev/null +++ b/homeassistant/components/reolink/select.py @@ -0,0 +1,123 @@ +"""Component providing support for Reolink select entities.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from reolink_aio.api import DayNightEnum, Host, SpotlightModeEnum + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ReolinkData +from .const import DOMAIN +from .entity import ReolinkCoordinatorEntity + + +@dataclass +class ReolinkSelectEntityDescriptionMixin: + """Mixin values for Reolink select entities.""" + + method: Callable[[Host, int, str], Any] + get_options: list[str] | Callable[[Host, int], list[str]] + + +@dataclass +class ReolinkSelectEntityDescription( + SelectEntityDescription, ReolinkSelectEntityDescriptionMixin +): + """A class that describes select entities.""" + + supported: Callable[[Host, int], bool] = lambda api, ch: True + value: Callable[[Host, int], str] | None = None + + +SELECT_ENTITIES = ( + ReolinkSelectEntityDescription( + key="floodlight_mode", + name="Floodlight mode", + icon="mdi:spotlight-beam", + entity_category=EntityCategory.CONFIG, + translation_key="floodlight_mode", + get_options=[mode.name for mode in SpotlightModeEnum], + supported=lambda api, ch: api.supported(ch, "floodLight"), + value=lambda api, ch: SpotlightModeEnum(api.whiteled_mode(ch)).name, + method=lambda api, ch, name: api.set_whiteled(ch, mode=name), + ), + ReolinkSelectEntityDescription( + key="day_night_mode", + name="Day night mode", + icon="mdi:theme-light-dark", + entity_category=EntityCategory.CONFIG, + translation_key="day_night_mode", + get_options=[mode.name for mode in DayNightEnum], + supported=lambda api, ch: api.supported(ch, "dayNight"), + value=lambda api, ch: DayNightEnum(api.daynight_state(ch)).name, + method=lambda api, ch, name: api.set_daynight(ch, DayNightEnum[name].value), + ), + ReolinkSelectEntityDescription( + key="ptz_preset", + name="PTZ preset", + icon="mdi:pan", + get_options=lambda api, ch: list(api.ptz_presets(ch)), + supported=lambda api, ch: api.supported(ch, "ptz_presets"), + method=lambda api, ch, name: api.set_ptz_command(ch, preset=name), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a Reolink select entities.""" + reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + ReolinkSelectEntity(reolink_data, channel, entity_description) + for entity_description in SELECT_ENTITIES + for channel in reolink_data.host.api.channels + if entity_description.supported(reolink_data.host.api, channel) + ) + + +class ReolinkSelectEntity(ReolinkCoordinatorEntity, SelectEntity): + """Base select entity class for Reolink IP cameras.""" + + entity_description: ReolinkSelectEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + channel: int, + entity_description: ReolinkSelectEntityDescription, + ) -> None: + """Initialize Reolink select entity.""" + super().__init__(reolink_data, channel) + self.entity_description = entity_description + + self._attr_unique_id = ( + f"{self._host.unique_id}_{channel}_{entity_description.key}" + ) + + if callable(entity_description.get_options): + self._attr_options = entity_description.get_options(self._host.api, channel) + else: + self._attr_options = entity_description.get_options + + @property + def current_option(self) -> str | None: + """Return the current option.""" + if self.entity_description.value is None: + return None + + return self.entity_description.value(self._host.api, self._channel) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.entity_description.method(self._host.api, self._channel, option) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index cc60948876..f4cb8a904f 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -43,5 +43,23 @@ "title": "Reolink webhook URL uses HTTPS (SSL)", "description": "Reolink products can not push motion events to an HTTPS address (SSL), please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}). The current (local) address is: `{base_url}`" } + }, + "entity": { + "select": { + "floodlight_mode": { + "state": { + "off": "Off", + "auto": "Auto", + "schedule": "Schedule" + } + }, + "day_night_mode": { + "state": { + "auto": "Auto", + "color": "Color", + "blackwhite": "Black&White" + } + } + } } } From 216864d8f009d2497d2893aa5640b6e583e5961b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 6 Mar 2023 01:46:53 +0100 Subject: [PATCH 0659/2810] Refactor WLED switch tests (#89197) --- .../wled/snapshots/test_switch.ambr | 302 ++++++++++++++++++ tests/components/wled/test_switch.py | 217 +++++-------- 2 files changed, 376 insertions(+), 143 deletions(-) create mode 100644 tests/components/wled/snapshots/test_switch.ambr diff --git a/tests/components/wled/snapshots/test_switch.ambr b/tests/components/wled/snapshots/test_switch.ambr new file mode 100644 index 0000000000..f89bde6ee1 --- /dev/null +++ b/tests/components/wled/snapshots/test_switch.ambr @@ -0,0 +1,302 @@ +# serializer version: 1 +# name: test_switch_state[switch.wled_rgb_light_nightlight-nightlight-called_with_on0-called_with_off0] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'duration': 60, + 'fade': True, + 'friendly_name': 'WLED RGB Light Nightlight', + 'icon': 'mdi:weather-night', + 'target_brightness': 0, + }), + 'context': , + 'entity_id': 'switch.wled_rgb_light_nightlight', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_state[switch.wled_rgb_light_nightlight-nightlight-called_with_on0-called_with_off0].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.wled_rgb_light_nightlight', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-night', + 'original_name': 'Nightlight', + 'platform': 'wled', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_nightlight', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_state[switch.wled_rgb_light_nightlight-nightlight-called_with_on0-called_with_off0].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://127.0.0.1', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'esp8266', + 'id': , + 'identifiers': set({ + tuple( + 'wled', + 'aabbccddeeff', + ), + }), + 'is_new': False, + 'manufacturer': 'WLED', + 'model': 'DIY light', + 'name': 'WLED RGB Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.8.5', + 'via_device_id': None, + }) +# --- +# name: test_switch_state[switch.wled_rgb_light_reverse-segment-called_with_on1-called_with_off1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WLED RGB Light Reverse', + 'icon': 'mdi:swap-horizontal-bold', + }), + 'context': , + 'entity_id': 'switch.wled_rgb_light_reverse', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_state[switch.wled_rgb_light_reverse-segment-called_with_on1-called_with_off1].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.wled_rgb_light_reverse', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:swap-horizontal-bold', + 'original_name': 'Reverse', + 'platform': 'wled', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_reverse_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_state[switch.wled_rgb_light_reverse-segment-called_with_on1-called_with_off1].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://127.0.0.1', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'esp8266', + 'id': , + 'identifiers': set({ + tuple( + 'wled', + 'aabbccddeeff', + ), + }), + 'is_new': False, + 'manufacturer': 'WLED', + 'model': 'DIY light', + 'name': 'WLED RGB Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.8.5', + 'via_device_id': None, + }) +# --- +# name: test_switch_state[switch.wled_rgb_light_sync_receive-sync-called_with_on2-called_with_off2] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WLED RGB Light Sync receive', + 'icon': 'mdi:download-network-outline', + 'udp_port': 21324, + }), + 'context': , + 'entity_id': 'switch.wled_rgb_light_sync_receive', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_state[switch.wled_rgb_light_sync_receive-sync-called_with_on2-called_with_off2].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.wled_rgb_light_sync_receive', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:download-network-outline', + 'original_name': 'Sync receive', + 'platform': 'wled', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_sync_receive', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_state[switch.wled_rgb_light_sync_receive-sync-called_with_on2-called_with_off2].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://127.0.0.1', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'esp8266', + 'id': , + 'identifiers': set({ + tuple( + 'wled', + 'aabbccddeeff', + ), + }), + 'is_new': False, + 'manufacturer': 'WLED', + 'model': 'DIY light', + 'name': 'WLED RGB Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.8.5', + 'via_device_id': None, + }) +# --- +# name: test_switch_state[switch.wled_rgb_light_sync_send-sync-called_with_on3-called_with_off3] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WLED RGB Light Sync send', + 'icon': 'mdi:upload-network-outline', + 'udp_port': 21324, + }), + 'context': , + 'entity_id': 'switch.wled_rgb_light_sync_send', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_state[switch.wled_rgb_light_sync_send-sync-called_with_on3-called_with_off3].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.wled_rgb_light_sync_send', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:upload-network-outline', + 'original_name': 'Sync send', + 'platform': 'wled', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_sync_send', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_state[switch.wled_rgb_light_sync_send-sync-called_with_on3-called_with_off3].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://127.0.0.1', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'esp8266', + 'id': , + 'identifiers': set({ + tuple( + 'wled', + 'aabbccddeeff', + ), + }), + 'is_new': False, + 'manufacturer': 'WLED', + 'model': 'DIY light', + 'name': 'WLED RGB Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.8.5', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/wled/test_switch.py b/tests/components/wled/test_switch.py index ef2d3f3ac5..70804e07eb 100644 --- a/tests/components/wled/test_switch.py +++ b/tests/components/wled/test_switch.py @@ -3,29 +3,22 @@ import json from unittest.mock import MagicMock import pytest +from syrupy.assertion import SnapshotAssertion from wled import Device as WLEDDevice, WLEDConnectionError, WLEDError from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.components.wled.const import ( - ATTR_DURATION, - ATTR_FADE, - ATTR_TARGET_BRIGHTNESS, - ATTR_UDP_PORT, - SCAN_INTERVAL, -) +from homeassistant.components.wled.const import SCAN_INTERVAL from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_ICON, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, - EntityCategory, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed, load_fixture @@ -33,163 +26,106 @@ from tests.common import async_fire_time_changed, load_fixture pytestmark = pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + ("entity_id", "method", "called_with_on", "called_with_off"), + [ + ( + "switch.wled_rgb_light_nightlight", + "nightlight", + {"on": True}, + {"on": False}, + ), + ( + "switch.wled_rgb_light_reverse", + "segment", + {"segment_id": 0, "reverse": True}, + {"segment_id": 0, "reverse": False}, + ), + ( + "switch.wled_rgb_light_sync_receive", + "sync", + {"receive": True}, + {"receive": False}, + ), + ( + "switch.wled_rgb_light_sync_send", + "sync", + {"send": True}, + {"send": False}, + ), + ], +) async def test_switch_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_wled: MagicMock, + entity_id: str, + method: str, + called_with_on: dict[str, bool | int], + called_with_off: dict[str, bool | int], ) -> None: """Test the creation and values of the WLED switches.""" - assert (state := hass.states.get("switch.wled_rgb_light_nightlight")) - assert state.attributes.get(ATTR_DURATION) == 60 - assert state.attributes.get(ATTR_ICON) == "mdi:weather-night" - assert state.attributes.get(ATTR_TARGET_BRIGHTNESS) == 0 - assert state.attributes.get(ATTR_FADE) - assert state.state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state == snapshot - assert (entry := entity_registry.async_get("switch.wled_rgb_light_nightlight")) - assert entry.unique_id == "aabbccddeeff_nightlight" - assert entry.entity_category is EntityCategory.CONFIG + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert entity_entry == snapshot - assert (state := hass.states.get("switch.wled_rgb_light_sync_send")) - assert state.attributes.get(ATTR_ICON) == "mdi:upload-network-outline" - assert state.attributes.get(ATTR_UDP_PORT) == 21324 - assert state.state == STATE_OFF + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry == snapshot - assert (entry := entity_registry.async_get("switch.wled_rgb_light_sync_send")) - assert entry.unique_id == "aabbccddeeff_sync_send" - assert entry.entity_category is EntityCategory.CONFIG - - assert (state := hass.states.get("switch.wled_rgb_light_sync_receive")) - assert state.attributes.get(ATTR_ICON) == "mdi:download-network-outline" - assert state.attributes.get(ATTR_UDP_PORT) == 21324 - assert state.state == STATE_ON - - assert (entry := entity_registry.async_get("switch.wled_rgb_light_sync_receive")) - assert entry.unique_id == "aabbccddeeff_sync_receive" - assert entry.entity_category is EntityCategory.CONFIG - - assert (state := hass.states.get("switch.wled_rgb_light_reverse")) - assert state.attributes.get(ATTR_ICON) == "mdi:swap-horizontal-bold" - assert state.state == STATE_OFF - - assert (entry := entity_registry.async_get("switch.wled_rgb_light_reverse")) - assert entry.unique_id == "aabbccddeeff_reverse_0" - assert entry.entity_category is EntityCategory.CONFIG - - -async def test_switch_change_state(hass: HomeAssistant, mock_wled: MagicMock) -> None: - """Test the change of state of the WLED switches.""" - - # Nightlight - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.wled_rgb_light_nightlight"}, - blocking=True, - ) - assert mock_wled.nightlight.call_count == 1 - mock_wled.nightlight.assert_called_with(on=True) - - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.wled_rgb_light_nightlight"}, - blocking=True, - ) - assert mock_wled.nightlight.call_count == 2 - mock_wled.nightlight.assert_called_with(on=False) - - # Sync send - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.wled_rgb_light_sync_send"}, - blocking=True, - ) - assert mock_wled.sync.call_count == 1 - mock_wled.sync.assert_called_with(send=True) - - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.wled_rgb_light_sync_send"}, - blocking=True, - ) - assert mock_wled.sync.call_count == 2 - mock_wled.sync.assert_called_with(send=False) - - # Sync receive - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.wled_rgb_light_sync_receive"}, - blocking=True, - ) - assert mock_wled.sync.call_count == 3 - mock_wled.sync.assert_called_with(receive=False) + # Test on/off services + method_mock = getattr(mock_wled, method) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.wled_rgb_light_sync_receive"}, + {ATTR_ENTITY_ID: state.entity_id}, blocking=True, ) - assert mock_wled.sync.call_count == 4 - mock_wled.sync.assert_called_with(receive=True) - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.wled_rgb_light_reverse"}, - blocking=True, - ) - assert mock_wled.segment.call_count == 1 - mock_wled.segment.assert_called_with(segment_id=0, reverse=True) + assert method_mock.call_count == 1 + method_mock.assert_called_with(**called_with_on) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.wled_rgb_light_reverse"}, + {ATTR_ENTITY_ID: state.entity_id}, blocking=True, ) - assert mock_wled.segment.call_count == 2 - mock_wled.segment.assert_called_with(segment_id=0, reverse=False) + assert method_mock.call_count == 2 + method_mock.assert_called_with(**called_with_off) -async def test_switch_error( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test error handling of the WLED switches.""" - mock_wled.nightlight.side_effect = WLEDError - + # Test invalid response, not becoming unavailable + method_mock.side_effect = WLEDError with pytest.raises(HomeAssistantError, match="Invalid response from WLED API"): await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.wled_rgb_light_nightlight"}, + {ATTR_ENTITY_ID: state.entity_id}, blocking=True, ) - state = hass.states.get("switch.wled_rgb_light_nightlight") - assert state - assert state.state == STATE_OFF - - -async def test_switch_connection_error( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test error handling of the WLED switches.""" - mock_wled.nightlight.side_effect = WLEDConnectionError + assert method_mock.call_count == 3 + assert (state := hass.states.get(state.entity_id)) + assert state.state != STATE_UNAVAILABLE + # Test connection error, leading to becoming unavailable + method_mock.side_effect = WLEDConnectionError with pytest.raises(HomeAssistantError, match="Error communicating with WLED API"): await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.wled_rgb_light_nightlight"}, + {ATTR_ENTITY_ID: state.entity_id}, blocking=True, ) - assert (state := hass.states.get("switch.wled_rgb_light_nightlight")) + assert method_mock.call_count == 4 + assert (state := hass.states.get(state.entity_id)) assert state.state == STATE_UNAVAILABLE @@ -199,11 +135,10 @@ async def test_switch_dynamically_handle_segments( mock_wled: MagicMock, ) -> None: """Test if a new/deleted segment is dynamically added/removed.""" - segment0 = hass.states.get("switch.wled_rgb_light_reverse") - segment1 = hass.states.get("switch.wled_rgb_light_segment_1_reverse") - assert segment0 + + assert (segment0 := hass.states.get("switch.wled_rgb_light_reverse")) assert segment0.state == STATE_OFF - assert not segment1 + assert not hass.states.get("switch.wled_rgb_light_segment_1_reverse") # Test adding a segment dynamically... return_value = mock_wled.update.return_value @@ -214,11 +149,9 @@ async def test_switch_dynamically_handle_segments( async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() - segment0 = hass.states.get("switch.wled_rgb_light_reverse") - segment1 = hass.states.get("switch.wled_rgb_light_segment_1_reverse") - assert segment0 + assert (segment0 := hass.states.get("switch.wled_rgb_light_reverse")) assert segment0.state == STATE_OFF - assert segment1 + assert (segment1 := hass.states.get("switch.wled_rgb_light_segment_1_reverse")) assert segment1.state == STATE_ON # Test remove segment again... @@ -226,9 +159,7 @@ async def test_switch_dynamically_handle_segments( async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() - segment0 = hass.states.get("switch.wled_rgb_light_reverse") - segment1 = hass.states.get("switch.wled_rgb_light_segment_1_reverse") - assert segment0 + assert (segment0 := hass.states.get("switch.wled_rgb_light_reverse")) assert segment0.state == STATE_OFF - assert segment1 + assert (segment1 := hass.states.get("switch.wled_rgb_light_segment_1_reverse")) assert segment1.state == STATE_UNAVAILABLE From 3e1d9deb2970af6f5e3b68da5042de922b3aa3fa Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 6 Mar 2023 01:47:07 +0100 Subject: [PATCH 0660/2810] Update coverage to 7.2.1 (#89196) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 75153ea6a0..0093240e0c 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -9,7 +9,7 @@ -r requirements_test_pre_commit.txt astroid==2.14.1 codecov==2.1.12 -coverage==7.1.0 +coverage==7.2.1 freezegun==1.2.2 mock-open==1.4.0 mypy==1.0.1 From 570db2a0af9aad8cded4ec6302758a6fe05a6b40 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 6 Mar 2023 01:47:19 +0100 Subject: [PATCH 0661/2810] Update sentry-sdk to 1.16.0 (#89193) --- homeassistant/components/sentry/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index 91da032099..95eff4e7a5 100644 --- a/homeassistant/components/sentry/manifest.json +++ b/homeassistant/components/sentry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sentry", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["sentry-sdk==1.13.0"] + "requirements": ["sentry-sdk==1.16.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2bd1992333..08455e47a5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2328,7 +2328,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.13.0 +sentry-sdk==1.16.0 # homeassistant.components.sfr_box sfrbox-api==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5adbd10f70..bc825bd3b2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1652,7 +1652,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.13.0 +sentry-sdk==1.16.0 # homeassistant.components.sfr_box sfrbox-api==0.0.6 From 74566258ba7e721d728a5d2a087c73c54158ccc5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 6 Mar 2023 01:47:31 +0100 Subject: [PATCH 0662/2810] Update watchdog to 2.3.1 (#89190) --- homeassistant/components/folder_watcher/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/folder_watcher/manifest.json b/homeassistant/components/folder_watcher/manifest.json index 31a199ab88..96decd0b8c 100644 --- a/homeassistant/components/folder_watcher/manifest.json +++ b/homeassistant/components/folder_watcher/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["watchdog"], "quality_scale": "internal", - "requirements": ["watchdog==2.2.1"] + "requirements": ["watchdog==2.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 08455e47a5..cc83b2eab0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2614,7 +2614,7 @@ wallbox==0.4.12 waqiasync==1.0.0 # homeassistant.components.folder_watcher -watchdog==2.2.1 +watchdog==2.3.1 # homeassistant.components.waterfurnace waterfurnace==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bc825bd3b2..1f4b0c06bc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1860,7 +1860,7 @@ wakeonlan==2.1.0 wallbox==0.4.12 # homeassistant.components.folder_watcher -watchdog==2.2.1 +watchdog==2.3.1 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.2 From a0ff95cef828ac0bea32015de5cdca3a53aebb12 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 6 Mar 2023 01:47:52 +0100 Subject: [PATCH 0663/2810] Update pytest to 7.2.2 (#89179) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 0093240e0c..7f2e51a0c0 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -29,7 +29,7 @@ pytest-timeout==2.1.0 pytest-unordered==0.5.2 pytest-picked==0.4.6 pytest-xdist==2.5.0 -pytest==7.2.1 +pytest==7.2.2 requests_mock==1.10.0 respx==0.20.1 syrupy==4.0.0 From ff485d4648ecf058a33e2cb7f5d7bd31be5f6845 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 6 Mar 2023 01:49:01 +0100 Subject: [PATCH 0664/2810] Refactor WLED number tests (#88582) --- .../wled/snapshots/test_number.ambr | 335 ++++++++++++++++++ tests/components/wled/test_number.py | 300 +++++----------- 2 files changed, 426 insertions(+), 209 deletions(-) create mode 100644 tests/components/wled/snapshots/test_number.ambr diff --git a/tests/components/wled/snapshots/test_number.ambr b/tests/components/wled/snapshots/test_number.ambr new file mode 100644 index 0000000000..96b465616c --- /dev/null +++ b/tests/components/wled/snapshots/test_number.ambr @@ -0,0 +1,335 @@ +# serializer version: 1 +# name: test_numbers[number.wled_rgb_light_segment_1_intensity-42-intensity] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WLED RGB Light Segment 1 Intensity', + 'max': 255, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.wled_rgb_light_segment_1_intensity', + 'last_changed': , + 'last_updated': , + 'state': '64', + }) +# --- +# name: test_numbers[number.wled_rgb_light_segment_1_intensity-42-intensity].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 255, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.wled_rgb_light_segment_1_intensity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Segment 1 Intensity', + 'platform': 'wled', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_intensity_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[number.wled_rgb_light_segment_1_intensity-42-intensity].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://127.0.0.1', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'esp8266', + 'id': , + 'identifiers': set({ + tuple( + 'wled', + 'aabbccddeeff', + ), + }), + 'is_new': False, + 'manufacturer': 'WLED', + 'model': 'DIY light', + 'name': 'WLED RGB Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.8.5', + 'via_device_id': None, + }) +# --- +# name: test_numbers[number.wled_rgb_light_segment_1_speed-42-speed] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WLED RGB Light Segment 1 Speed', + 'icon': 'mdi:speedometer', + 'max': 255, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.wled_rgb_light_segment_1_speed', + 'last_changed': , + 'last_updated': , + 'state': '16', + }) +# --- +# name: test_numbers[number.wled_rgb_light_segment_1_speed-42-speed].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 255, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.wled_rgb_light_segment_1_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:speedometer', + 'original_name': 'Segment 1 Speed', + 'platform': 'wled', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_speed_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[number.wled_rgb_light_segment_1_speed-42-speed].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://127.0.0.1', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'esp8266', + 'id': , + 'identifiers': set({ + tuple( + 'wled', + 'aabbccddeeff', + ), + }), + 'is_new': False, + 'manufacturer': 'WLED', + 'model': 'DIY light', + 'name': 'WLED RGB Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.8.5', + 'via_device_id': None, + }) +# --- +# name: test_speed_state[number.wled_rgb_light_segment_1_intensity-42-intensity] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WLED RGB Light Segment 1 Intensity', + 'max': 255, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.wled_rgb_light_segment_1_intensity', + 'last_changed': , + 'last_updated': , + 'state': '64', + }) +# --- +# name: test_speed_state[number.wled_rgb_light_segment_1_intensity-42-intensity].1 + EntityRegistryEntrySnapshot({ + '_display_repr': , + '_partial_repr': , + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 255, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.wled_rgb_light_segment_1_intensity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Segment 1 Intensity', + 'platform': 'wled', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_intensity_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_speed_state[number.wled_rgb_light_segment_1_intensity-42-intensity].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://127.0.0.1', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'esp8266', + 'id': , + 'identifiers': set({ + tuple( + 'wled', + 'aabbccddeeff', + ), + }), + 'is_new': False, + 'manufacturer': 'WLED', + 'model': 'DIY light', + 'name': 'WLED RGB Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.8.5', + 'via_device_id': None, + }) +# --- +# name: test_speed_state[number.wled_rgb_light_segment_1_speed-42-speed] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WLED RGB Light Segment 1 Speed', + 'icon': 'mdi:speedometer', + 'max': 255, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.wled_rgb_light_segment_1_speed', + 'last_changed': , + 'last_updated': , + 'state': '16', + }) +# --- +# name: test_speed_state[number.wled_rgb_light_segment_1_speed-42-speed].1 + EntityRegistryEntrySnapshot({ + '_display_repr': , + '_partial_repr': , + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 255, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.wled_rgb_light_segment_1_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:speedometer', + 'original_name': 'Segment 1 Speed', + 'platform': 'wled', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_speed_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_speed_state[number.wled_rgb_light_segment_1_speed-42-speed].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://127.0.0.1', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'esp8266', + 'id': , + 'identifiers': set({ + tuple( + 'wled', + 'aabbccddeeff', + ), + }), + 'is_new': False, + 'manufacturer': 'WLED', + 'model': 'DIY light', + 'name': 'WLED RGB Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.8.5', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/wled/test_number.py b/tests/components/wled/test_number.py index 150db49515..59f2fb1233 100644 --- a/tests/components/wled/test_number.py +++ b/tests/components/wled/test_number.py @@ -3,21 +3,19 @@ import json from unittest.mock import MagicMock import pytest +from syrupy.assertion import SnapshotAssertion from wled import Device as WLEDDevice, WLEDConnectionError, WLEDError from homeassistant.components.number import ( - ATTR_MAX, - ATTR_MIN, - ATTR_STEP, ATTR_VALUE, DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, ) from homeassistant.components.wled.const import SCAN_INTERVAL -from homeassistant.const import ATTR_ENTITY_ID, ATTR_ICON, STATE_UNAVAILABLE +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed, load_fixture @@ -25,52 +23,106 @@ from tests.common import async_fire_time_changed, load_fixture pytestmark = pytest.mark.usefixtures("init_integration") -async def test_speed_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry +@pytest.mark.parametrize( + ("entity_id", "value", "called_arg"), + [ + ("number.wled_rgb_light_segment_1_speed", 42, "speed"), + ("number.wled_rgb_light_segment_1_intensity", 42, "intensity"), + ], +) +async def test_numbers( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_wled: MagicMock, + entity_id: str, + value: int, + called_arg: str, ) -> None: """Test the creation and values of the WLED numbers.""" - # First segment of the strip - assert (state := hass.states.get("number.wled_rgb_light_segment_1_speed")) - assert state.attributes.get(ATTR_ICON) == "mdi:speedometer" - assert state.attributes.get(ATTR_MAX) == 255 - assert state.attributes.get(ATTR_MIN) == 0 - assert state.attributes.get(ATTR_STEP) == 1 - assert state.state == "16" + assert (state := hass.states.get(entity_id)) + assert state == snapshot - assert (entry := entity_registry.async_get("number.wled_rgb_light_segment_1_speed")) - assert entry.unique_id == "aabbccddeeff_speed_1" + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert entity_entry == snapshot + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry == snapshot -async def test_speed_segment_change_state( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test the value change of the WLED segments.""" + # Test a regular state change service call await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: "number.wled_rgb_light_segment_1_speed", - ATTR_VALUE: 42, - }, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, blocking=True, ) + assert mock_wled.segment.call_count == 1 - mock_wled.segment.assert_called_with( - segment_id=1, - speed=42, - ) + mock_wled.segment.assert_called_with(segment_id=1, **{called_arg: value}) + + # Test with WLED error + mock_wled.segment.side_effect = WLEDError + with pytest.raises(HomeAssistantError, match="Invalid response from WLED API"): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, + blocking=True, + ) + assert mock_wled.segment.call_count == 2 + + # Ensure the entity is still available + assert (state := hass.states.get(entity_id)) + assert state.state != STATE_UNAVAILABLE + + # Test when a connection error occurs + mock_wled.segment.side_effect = WLEDConnectionError + with pytest.raises(HomeAssistantError, match="Error communicating with WLED API"): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, + blocking=True, + ) + assert mock_wled.segment.call_count == 3 + + # Ensure the entity became unavailable after the connection error + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE @pytest.mark.parametrize("device_fixture", ["rgb_single_segment"]) +@pytest.mark.parametrize( + ("entity_id_segment0", "state_segment0", "entity_id_segment1", "state_segment1"), + [ + ( + "number.wled_rgb_light_speed", + "32", + "number.wled_rgb_light_segment_1_speed", + "16", + ), + ( + "number.wled_rgb_light_intensity", + "128", + "number.wled_rgb_light_segment_1_intensity", + "64", + ), + ], +) async def test_speed_dynamically_handle_segments( hass: HomeAssistant, mock_wled: MagicMock, + entity_id_segment0: str, + entity_id_segment1: str, + state_segment0: str, + state_segment1: str, ) -> None: """Test if a new/deleted segment is dynamically added/removed.""" - assert (segment0 := hass.states.get("number.wled_rgb_light_speed")) - assert segment0.state == "32" - assert not hass.states.get("number.wled_rgb_light_segment_1_speed") + assert (segment0 := hass.states.get(entity_id_segment0)) + assert segment0.state == state_segment0 + assert not hass.states.get(entity_id_segment1) # Test adding a segment dynamically... return_value = mock_wled.update.return_value @@ -81,187 +133,17 @@ async def test_speed_dynamically_handle_segments( async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() - assert (segment0 := hass.states.get("number.wled_rgb_light_speed")) - assert segment0.state == "32" - assert (segment1 := hass.states.get("number.wled_rgb_light_segment_1_speed")) - assert segment1.state == "16" + assert (segment0 := hass.states.get(entity_id_segment0)) + assert segment0.state == state_segment0 + assert (segment1 := hass.states.get(entity_id_segment1)) + assert segment1.state == state_segment1 # Test remove segment again... mock_wled.update.return_value = return_value async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() - assert (segment0 := hass.states.get("number.wled_rgb_light_speed")) - assert segment0.state == "32" - assert (segment1 := hass.states.get("number.wled_rgb_light_segment_1_speed")) + assert (segment0 := hass.states.get(entity_id_segment0)) + assert segment0.state == state_segment0 + assert (segment1 := hass.states.get(entity_id_segment1)) assert segment1.state == STATE_UNAVAILABLE - - -async def test_speed_error( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test error handling of the WLED numbers.""" - mock_wled.segment.side_effect = WLEDError - - with pytest.raises(HomeAssistantError, match="Invalid response from WLED API"): - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: "number.wled_rgb_light_segment_1_speed", - ATTR_VALUE: 42, - }, - blocking=True, - ) - - assert (state := hass.states.get("number.wled_rgb_light_segment_1_speed")) - assert state.state == "16" - assert mock_wled.segment.call_count == 1 - mock_wled.segment.assert_called_with(segment_id=1, speed=42) - - -async def test_speed_connection_error( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test error handling of the WLED numbers.""" - mock_wled.segment.side_effect = WLEDConnectionError - - with pytest.raises(HomeAssistantError, match="Error communicating with WLED API"): - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: "number.wled_rgb_light_segment_1_speed", - ATTR_VALUE: 42, - }, - blocking=True, - ) - - assert (state := hass.states.get("number.wled_rgb_light_segment_1_speed")) - assert state.state == STATE_UNAVAILABLE - assert mock_wled.segment.call_count == 1 - mock_wled.segment.assert_called_with(segment_id=1, speed=42) - - -async def test_intensity_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry -) -> None: - """Test the creation and values of the WLED numbers.""" - # First segment of the strip - assert (state := hass.states.get("number.wled_rgb_light_segment_1_intensity")) - assert state.attributes.get(ATTR_ICON) is None - assert state.attributes.get(ATTR_MAX) == 255 - assert state.attributes.get(ATTR_MIN) == 0 - assert state.attributes.get(ATTR_STEP) == 1 - assert state.state == "64" - - assert ( - entry := entity_registry.async_get("number.wled_rgb_light_segment_1_intensity") - ) - assert entry.unique_id == "aabbccddeeff_intensity_1" - - -async def test_intensity_segment_change_state( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test the value change of the WLED segments.""" - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: "number.wled_rgb_light_segment_1_intensity", - ATTR_VALUE: 128, - }, - blocking=True, - ) - assert mock_wled.segment.call_count == 1 - mock_wled.segment.assert_called_with( - segment_id=1, - intensity=128, - ) - - -@pytest.mark.parametrize("device_fixture", ["rgb_single_segment"]) -async def test_intensity_dynamically_handle_segments( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test if a new/deleted segment is dynamically added/removed.""" - assert (segment0 := hass.states.get("number.wled_rgb_light_intensity")) - assert segment0.state == "128" - assert not hass.states.get("number.wled_rgb_light_segment_1_intensity") - - # Test adding a segment dynamically... - return_value = mock_wled.update.return_value - mock_wled.update.return_value = WLEDDevice( - json.loads(load_fixture("wled/rgb.json")) - ) - - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) - await hass.async_block_till_done() - - assert (segment0 := hass.states.get("number.wled_rgb_light_intensity")) - assert segment0.state == "128" - assert (segment1 := hass.states.get("number.wled_rgb_light_segment_1_intensity")) - assert segment1.state == "64" - - # Test remove segment again... - mock_wled.update.return_value = return_value - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) - await hass.async_block_till_done() - - assert (segment0 := hass.states.get("number.wled_rgb_light_intensity")) - assert segment0.state == "128" - assert (segment1 := hass.states.get("number.wled_rgb_light_segment_1_intensity")) - assert segment1.state == STATE_UNAVAILABLE - - -async def test_intensity_error( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test error handling of the WLED numbers.""" - mock_wled.segment.side_effect = WLEDError - - with pytest.raises(HomeAssistantError, match="Invalid response from WLED API"): - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: "number.wled_rgb_light_segment_1_intensity", - ATTR_VALUE: 21, - }, - blocking=True, - ) - - assert (state := hass.states.get("number.wled_rgb_light_segment_1_intensity")) - assert state.state == "64" - assert mock_wled.segment.call_count == 1 - mock_wled.segment.assert_called_with(segment_id=1, intensity=21) - - -async def test_intensity_connection_error( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test error handling of the WLED numbers.""" - mock_wled.segment.side_effect = WLEDConnectionError - - with pytest.raises(HomeAssistantError, match="Error communicating with WLED API"): - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: "number.wled_rgb_light_segment_1_intensity", - ATTR_VALUE: 128, - }, - blocking=True, - ) - - assert (state := hass.states.get("number.wled_rgb_light_segment_1_intensity")) - assert state.state == STATE_UNAVAILABLE - assert mock_wled.segment.call_count == 1 - mock_wled.segment.assert_called_with(segment_id=1, intensity=128) From 36dabaaea64d0062bf136bf507f7108713cbd527 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Mon, 6 Mar 2023 02:19:42 +0100 Subject: [PATCH 0665/2810] Fix lingering tasks in KNX tests (#89201) --- tests/components/knx/README.md | 1 + tests/components/knx/test_climate.py | 39 ++++++++++++++++------------ tests/components/knx/test_cover.py | 4 +++ 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/tests/components/knx/README.md b/tests/components/knx/README.md index 4b5886200c..930b9e71c2 100644 --- a/tests/components/knx/README.md +++ b/tests/components/knx/README.md @@ -69,3 +69,4 @@ Receive some telegrams and assert state. - For `payload` in `assert_*` and `receive_*` use `int` for DPT 1, 2 and 3 payload values (DPTBinary) and `tuple` for other DPTs (DPTArray). - `await self.hass.async_block_till_done()` is called before `KNXTestKit.assert_*` and after `KNXTestKit.receive_*` so you don't have to explicitly call it. - Make sure to assert every outgoing telegram that was created in a test. `assert_no_telegram` is automatically called on teardown. +- Make sure to `knx.receive_response()` for every Read-request sent form StateUpdater, or to pass its timeout, to not have lingering tasks when finishing the tests. diff --git a/tests/components/knx/test_climate.py b/tests/components/knx/test_climate.py index e10ac76cb4..ac6f5e7e97 100644 --- a/tests/components/knx/test_climate.py +++ b/tests/components/knx/test_climate.py @@ -10,6 +10,10 @@ from .conftest import KNXTestKit from tests.common import async_capture_events +RAW_FLOAT_20_0 = (0x07, 0xD0) +RAW_FLOAT_21_0 = (0x0C, 0x1A) +RAW_FLOAT_22_0 = (0x0C, 0x4C) + async def test_climate_basic_temperature_set( hass: HomeAssistant, knx: KNXTestKit @@ -34,6 +38,10 @@ async def test_climate_basic_temperature_set( await knx.assert_read("1/2/3") # read target temperature await knx.assert_read("1/2/5") + # StateUpdater initialize state + await knx.receive_response("1/2/3", RAW_FLOAT_21_0) + await knx.receive_response("1/2/5", RAW_FLOAT_22_0) + events.clear() # set new temperature await hass.services.async_call( @@ -42,9 +50,8 @@ async def test_climate_basic_temperature_set( {"entity_id": "climate.test", "temperature": 20}, blocking=True, ) - await knx.assert_write("1/2/4", (7, 208)) + await knx.assert_write("1/2/4", RAW_FLOAT_20_0) assert len(events) == 1 - events.pop() async def test_climate_hvac_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: @@ -73,11 +80,12 @@ async def test_climate_hvac_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: await knx.assert_read("1/2/7") await knx.assert_read("1/2/3") # StateUpdater initialize state - await knx.receive_response("1/2/7", True) - await knx.receive_response("1/2/3", (0x21,)) + await knx.receive_response("1/2/7", (0x01,)) + await knx.receive_response("1/2/3", RAW_FLOAT_20_0) # StateUpdater semaphore allows 2 concurrent requests # read target temperature state await knx.assert_read("1/2/5") + await knx.receive_response("1/2/5", RAW_FLOAT_22_0) # turn hvac off await hass.services.async_call( @@ -125,11 +133,13 @@ async def test_climate_preset_mode( await knx.assert_read("1/2/7") await knx.assert_read("1/2/3") # StateUpdater initialize state - await knx.receive_response("1/2/7", True) - await knx.receive_response("1/2/3", (0x01,)) + await knx.receive_response("1/2/7", (0x01,)) + await knx.receive_response("1/2/3", RAW_FLOAT_21_0) # StateUpdater semaphore allows 2 concurrent requests # read target temperature state await knx.assert_read("1/2/5") + await knx.receive_response("1/2/5", RAW_FLOAT_22_0) + events.clear() # set preset mode await hass.services.async_call( @@ -190,10 +200,11 @@ async def test_update_entity(hass: HomeAssistant, knx: KNXTestKit) -> None: await knx.assert_read("1/2/7") await knx.assert_read("1/2/3") # StateUpdater initialize state - await knx.receive_response("1/2/7", True) - await knx.receive_response("1/2/3", (0x01,)) + await knx.receive_response("1/2/7", (0x01,)) + await knx.receive_response("1/2/3", RAW_FLOAT_21_0) # StateUpdater semaphore allows 2 concurrent requests await knx.assert_read("1/2/5") + await knx.receive_response("1/2/5", RAW_FLOAT_22_0) # verify update entity retriggers group value reads to the bus await hass.services.async_call( @@ -210,7 +221,6 @@ async def test_update_entity(hass: HomeAssistant, knx: KNXTestKit) -> None: async def test_command_value_idle_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test KNX climate command_value.""" - events = async_capture_events(hass, "state_changed") await knx.setup_integration( { ClimateSchema.PLATFORM: { @@ -222,20 +232,17 @@ async def test_command_value_idle_mode(hass: HomeAssistant, knx: KNXTestKit) -> } } ) - assert len(hass.states.async_all()) == 1 - assert len(events) == 1 - events.pop() await hass.async_block_till_done() # read states state updater await knx.assert_read("1/2/3") await knx.assert_read("1/2/5") # StateUpdater initialize state + await knx.receive_response("1/2/5", RAW_FLOAT_22_0) + await knx.receive_response("1/2/3", RAW_FLOAT_21_0) + # StateUpdater semaphore allows 2 concurrent requests + await knx.assert_read("1/2/6") await knx.receive_response("1/2/6", (0x32,)) - await knx.receive_response("1/2/3", (0x0C, 0x1A)) - - assert len(events) == 2 - events.pop() knx.assert_state("climate.test", HVACMode.HEAT, command_value=20) diff --git a/tests/components/knx/test_cover.py b/tests/components/knx/test_cover.py index e2c462e74e..ed82968b55 100644 --- a/tests/components/knx/test_cover.py +++ b/tests/components/knx/test_cover.py @@ -31,6 +31,10 @@ async def test_cover_basic(hass: HomeAssistant, knx: KNXTestKit) -> None: # read position state address and angle state address await knx.assert_read("1/0/2") await knx.assert_read("1/0/4") + # StateUpdater initialize state + await knx.receive_response("1/0/2", (0x0F,)) + await knx.receive_response("1/0/4", (0x30,)) + events.clear() # open cover await hass.services.async_call( From 811e286f0fb0f53351875e86cac84a9ecc8c7074 Mon Sep 17 00:00:00 2001 From: ztamas83 <71548739+ztamas83@users.noreply.github.com> Date: Mon, 6 Mar 2023 02:38:47 +0100 Subject: [PATCH 0666/2810] Test coverage for Tibber config flow (#89088) * Test coverage for Tibber config flow * Fix isort and ruff errors --- .../components/tibber/config_flow.py | 9 ++-- tests/components/tibber/test_config_flow.py | 52 ++++++++++++++++--- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/tibber/config_flow.py b/homeassistant/components/tibber/config_flow.py index b5cb4486cc..fbd2345fb8 100644 --- a/homeassistant/components/tibber/config_flow.py +++ b/homeassistant/components/tibber/config_flow.py @@ -16,6 +16,9 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN DATA_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}) +ERR_TIMEOUT = "timeout" +ERR_CLIENT = "cannot_connect" +ERR_TOKEN = "invalid_access_token" class TibberConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -43,15 +46,15 @@ class TibberConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: await tibber_connection.update_info() except asyncio.TimeoutError: - errors[CONF_ACCESS_TOKEN] = "timeout" + errors[CONF_ACCESS_TOKEN] = ERR_TIMEOUT except tibber.InvalidLogin: - errors[CONF_ACCESS_TOKEN] = "invalid_access_token" + errors[CONF_ACCESS_TOKEN] = ERR_TOKEN except ( aiohttp.ClientError, tibber.RetryableHttpException, tibber.FatalHttpException, ): - errors[CONF_ACCESS_TOKEN] = "cannot_connect" + errors[CONF_ACCESS_TOKEN] = ERR_CLIENT if errors: return self.async_show_form( diff --git a/tests/components/tibber/test_config_flow.py b/tests/components/tibber/test_config_flow.py index d50e60b158..e07e4d66cd 100644 --- a/tests/components/tibber/test_config_flow.py +++ b/tests/components/tibber/test_config_flow.py @@ -1,13 +1,22 @@ """Tests for Tibber config flow.""" +from asyncio import TimeoutError from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch +from aiohttp import ClientError import pytest +from tibber import FatalHttpException, InvalidLogin, RetryableHttpException from homeassistant import config_entries from homeassistant.components.recorder import Recorder +from homeassistant.components.tibber.config_flow import ( + ERR_CLIENT, + ERR_TIMEOUT, + ERR_TOKEN, +) from homeassistant.components.tibber.const import DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType @pytest.fixture(name="tibber_setup", autouse=True) @@ -23,7 +32,7 @@ async def test_show_config_form(recorder_mock: Recorder, hass: HomeAssistant) -> DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" @@ -46,14 +55,45 @@ async def test_create_entry(recorder_mock: Recorder, hass: HomeAssistant) -> Non DOMAIN, context={"source": config_entries.SOURCE_USER}, data=test_data ) - assert result["type"] == "create_entry" + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == title assert result["data"] == test_data -async def test_flow_entry_already_exists( - recorder_mock: Recorder, hass: HomeAssistant, config_entry -) -> None: +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (TimeoutError, ERR_TIMEOUT), + (ClientError, ERR_CLIENT), + (InvalidLogin(401), ERR_TOKEN), + (RetryableHttpException(503), ERR_CLIENT), + (FatalHttpException(404), ERR_CLIENT), + ], +) +async def test_create_entry_exceptions(recorder_mock, hass, exception, expected_error): + """Test create entry from user input.""" + test_data = { + CONF_ACCESS_TOKEN: "valid", + } + + unique_user_id = "unique_user_id" + title = "title" + + tibber_mock = MagicMock() + type(tibber_mock).update_info = AsyncMock(side_effect=exception) + type(tibber_mock).user_id = PropertyMock(return_value=unique_user_id) + type(tibber_mock).name = PropertyMock(return_value=title) + + with patch("tibber.Tibber", return_value=tibber_mock): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=test_data + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"][CONF_ACCESS_TOKEN] == expected_error + + +async def test_flow_entry_already_exists(recorder_mock, hass, config_entry): """Test user input for config_entry that already exists.""" test_data = { CONF_ACCESS_TOKEN: "valid", @@ -64,5 +104,5 @@ async def test_flow_entry_already_exists( DOMAIN, context={"source": config_entries.SOURCE_USER}, data=test_data ) - assert result["type"] == "abort" + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" From 876776e2915c8c1d18b6bdfd82a0f25dc3d6b2ea Mon Sep 17 00:00:00 2001 From: MarkGodwin Date: Mon, 6 Mar 2023 04:47:45 +0000 Subject: [PATCH 0667/2810] 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 6b958b7d25..f6a75abe6d 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 fd32b357b7..cf3fddf594 100644 --- a/tests/components/tplink_omada/test_config_flow.py +++ b/tests/components/tplink_omada/test_config_flow.py @@ -22,14 +22,14 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry MOCK_USER_DATA = { - "host": "1.1.1.1", + "host": "https://fake.omada.host", "verify_ssl": True, "username": "test-username", "password": "test-password", } MOCK_ENTRY_DATA = { - "host": "1.1.1.1", + "host": "https://fake.omada.host", "verify_ssl": True, "site": "SiteId", "username": "test-username", @@ -111,7 +111,7 @@ async def test_form_multiple_sites(hass: HomeAssistant) -> None: assert result3["type"] == FlowResultType.CREATE_ENTRY assert result3["title"] == "OC200 (Site 2)" assert result3["data"] == { - "host": "1.1.1.1", + "host": "https://fake.omada.host", "verify_ssl": True, "site": "second", "username": "test-username", @@ -272,7 +272,7 @@ async def test_async_step_reauth_success(hass: HomeAssistant) -> None: mocked_validate.assert_called_once_with( hass, { - "host": "1.1.1.1", + "host": "https://fake.omada.host", "verify_ssl": True, "site": "SiteId", "username": "new_uname", @@ -353,6 +353,64 @@ async def test_create_omada_client_parses_args(hass: HomeAssistant) -> None: assert result is not None mock_client.assert_called_once_with( - "1.1.1.1", "test-username", "test-password", "ws" + "https://fake.omada.host", "test-username", "test-password", "ws" ) mock_clientsession.assert_called_once_with(hass, verify_ssl=True) + + +async def test_create_omada_client_adds_missing_scheme(hass: HomeAssistant) -> None: + """Test config arguments are passed to Omada client.""" + + with patch( + "homeassistant.components.tplink_omada.config_flow.OmadaClient", autospec=True + ) as mock_client, patch( + "homeassistant.components.tplink_omada.config_flow.async_get_clientsession", + return_value="ws", + ) as mock_clientsession: + result = await create_omada_client( + hass, + { + "host": "fake.omada.host", + "verify_ssl": True, + "username": "test-username", + "password": "test-password", + }, + ) + + assert result is not None + mock_client.assert_called_once_with( + "https://fake.omada.host", "test-username", "test-password", "ws" + ) + mock_clientsession.assert_called_once_with(hass, verify_ssl=True) + + +async def test_create_omada_client_with_ip_creates_clientsession( + hass: HomeAssistant, +) -> None: + """Test config arguments are passed to Omada client.""" + + with patch( + "homeassistant.components.tplink_omada.config_flow.OmadaClient", autospec=True + ) as mock_client, patch( + "homeassistant.components.tplink_omada.config_flow.CookieJar", autospec=True + ) as mock_jar, patch( + "homeassistant.components.tplink_omada.config_flow.async_create_clientsession", + return_value="ws", + ) as mock_create_clientsession: + result = await create_omada_client( + hass, + { + "host": "10.10.10.10", + "verify_ssl": True, # Verify is meaningless for IP + "username": "test-username", + "password": "test-password", + }, + ) + + assert result is not None + mock_client.assert_called_once_with( + "https://10.10.10.10", "test-username", "test-password", "ws" + ) + mock_create_clientsession.assert_called_once_with( + hass, cookie_jar=mock_jar.return_value + ) From fc673139cd191779a66b4170f969e0e451704c50 Mon Sep 17 00:00:00 2001 From: Stephan Uhle Date: Mon, 6 Mar 2023 08:29:41 +0100 Subject: [PATCH 0668/2810] Add device info to edl21 (#89070) * Added device info for edl21. * Apply suggestions from code review --- homeassistant/components/edl21/sensor.py | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index 355d448e30..5b1a677edd 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -32,6 +32,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -283,7 +284,7 @@ async def async_setup_platform( hass, DOMAIN, "deprecated_yaml", - breaks_in_ha_version="2023.2.0", + breaks_in_ha_version="2023.5.0", is_fixable=False, severity=IssueSeverity.WARNING, translation_key="deprecated_yaml", @@ -397,7 +398,7 @@ class EDL21: old_entity_id = registry.async_get_entity_id( "sensor", DOMAIN, entity.old_unique_id ) - if old_entity_id is not None: + if old_entity_id is not None and entity.unique_id is not None: LOGGER.debug( "Migrating unique_id from [%s] to [%s]", entity.old_unique_id, @@ -422,8 +423,6 @@ class EDL21Entity(SensorEntity): """Initialize an EDL21Entity.""" self._electricity_id = electricity_id self._obis = obis - self._name = name - self._unique_id = f"{electricity_id}_{obis}" self._telegram = telegram self._min_time = MIN_TIME_BETWEEN_UPDATES self._last_update = utcnow() @@ -435,6 +434,12 @@ class EDL21Entity(SensorEntity): } self._async_remove_dispatcher = None self.entity_description = entity_description + self._attr_name = name + self._attr_unique_id = f"{electricity_id}_{obis}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._electricity_id)}, + name=self._electricity_id, + ) async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" @@ -466,21 +471,11 @@ class EDL21Entity(SensorEntity): if self._async_remove_dispatcher: self._async_remove_dispatcher() - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id - @property def old_unique_id(self) -> str: """Return a less unique ID as used in the first version of edl21.""" return self._obis - @property - def name(self) -> str | None: - """Return a name.""" - return self._name - @property def native_value(self) -> str: """Return the value of the last received telegram.""" From c7b30b61de68ebd5a098c8c4aa334e887f790d6d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 6 Mar 2023 08:51:33 +0100 Subject: [PATCH 0669/2810] Revert "Add device info to edl21" (#89217) --- homeassistant/components/edl21/sensor.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index 5b1a677edd..355d448e30 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -32,7 +32,6 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -284,7 +283,7 @@ async def async_setup_platform( hass, DOMAIN, "deprecated_yaml", - breaks_in_ha_version="2023.5.0", + breaks_in_ha_version="2023.2.0", is_fixable=False, severity=IssueSeverity.WARNING, translation_key="deprecated_yaml", @@ -398,7 +397,7 @@ class EDL21: old_entity_id = registry.async_get_entity_id( "sensor", DOMAIN, entity.old_unique_id ) - if old_entity_id is not None and entity.unique_id is not None: + if old_entity_id is not None: LOGGER.debug( "Migrating unique_id from [%s] to [%s]", entity.old_unique_id, @@ -423,6 +422,8 @@ class EDL21Entity(SensorEntity): """Initialize an EDL21Entity.""" self._electricity_id = electricity_id self._obis = obis + self._name = name + self._unique_id = f"{electricity_id}_{obis}" self._telegram = telegram self._min_time = MIN_TIME_BETWEEN_UPDATES self._last_update = utcnow() @@ -434,12 +435,6 @@ class EDL21Entity(SensorEntity): } self._async_remove_dispatcher = None self.entity_description = entity_description - self._attr_name = name - self._attr_unique_id = f"{electricity_id}_{obis}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._electricity_id)}, - name=self._electricity_id, - ) async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" @@ -471,11 +466,21 @@ class EDL21Entity(SensorEntity): if self._async_remove_dispatcher: self._async_remove_dispatcher() + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._unique_id + @property def old_unique_id(self) -> str: """Return a less unique ID as used in the first version of edl21.""" return self._obis + @property + def name(self) -> str | None: + """Return a name.""" + return self._name + @property def native_value(self) -> str: """Return the value of the last received telegram.""" From 0c65af93af7dc6236e0be9816b33b9f370c3d4ea Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 6 Mar 2023 10:02:32 +0100 Subject: [PATCH 0670/2810] Split reauth tests in plex (#89212) --- tests/components/plex/conftest.py | 12 +++- tests/components/plex/test_config_flow.py | 69 +++++++++-------------- tests/components/plex/test_init.py | 26 ++++++++- 3 files changed, 64 insertions(+), 43 deletions(-) diff --git a/tests/components/plex/conftest.py b/tests/components/plex/conftest.py index 506aadcce6..e4bf61ccd9 100644 --- a/tests/components/plex/conftest.py +++ b/tests/components/plex/conftest.py @@ -1,5 +1,6 @@ """Fixtures for Plex tests.""" -from unittest.mock import patch +from collections.abc import Generator +from unittest.mock import AsyncMock, patch import pytest @@ -18,6 +19,15 @@ def plex_server_url(entry): return entry.data[PLEX_SERVER_CONFIG][CONF_URL].split(":", 1)[-1] +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.plex.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(name="album", scope="session") def album_fixture(): """Load album payload and return it.""" diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index 36c9ab614f..2f3e268177 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -2,7 +2,7 @@ import copy from http import HTTPStatus import ssl -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import plexapi.exceptions import pytest @@ -42,7 +42,6 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from .const import DEFAULT_OPTIONS, MOCK_SERVERS, MOCK_TOKEN, PLEX_DIRECT_URL -from .helpers import trigger_plex_update, wait_for_debouncer from .mock_classes import MockGDM from tests.common import MockConfigEntry @@ -718,31 +717,22 @@ async def test_integration_discovery(hass: HomeAssistant) -> None: assert flow["step_id"] == "user" -async def test_trigger_reauth( +async def test_reauth( hass: HomeAssistant, - entry, - mock_plex_server, - mock_websocket, + entry: MockConfigEntry, + mock_plex_calls: None, current_request_with_host: None, + mock_setup_entry: AsyncMock, ) -> None: """Test setup and reauthorization of a Plex token.""" + entry.add_to_hass(hass) - assert entry.state is ConfigEntryState.LOADED - - with patch( - "plexapi.server.PlexServer.clients", side_effect=plexapi.exceptions.Unauthorized - ), patch("plexapi.server.PlexServer", side_effect=plexapi.exceptions.Unauthorized): - trigger_plex_update(mock_websocket) - await wait_for_debouncer(hass) - - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert entry.state is not ConfigEntryState.LOADED - - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - assert flows[0]["context"]["source"] == SOURCE_REAUTH - - flow_id = flows[0]["flow_id"] + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH}, + data=entry.data, + ) + flow_id = result["flow_id"] with patch("plexauth.PlexAuth.initiate_auth"), patch( "plexauth.PlexAuth.token", return_value="BRAND_NEW_TOKEN" @@ -767,38 +757,33 @@ async def test_trigger_reauth( assert entry.data[PLEX_SERVER_CONFIG][CONF_URL] == PLEX_DIRECT_URL assert entry.data[PLEX_SERVER_CONFIG][CONF_TOKEN] == "BRAND_NEW_TOKEN" + mock_setup_entry.assert_called_once() -async def test_trigger_reauth_multiple_servers_available( + +async def test_reauth_multiple_servers_available( hass: HomeAssistant, - entry, - mock_plex_server, - mock_websocket, + entry: MockConfigEntry, + mock_plex_calls: None, current_request_with_host: None, requests_mock: requests_mock.Mocker, - plextv_resources_two_servers, + plextv_resources_two_servers: str, + mock_setup_entry: AsyncMock, ) -> None: """Test setup and reauthorization of a Plex token when multiple servers are available.""" - assert entry.state is ConfigEntryState.LOADED - requests_mock.get( "https://plex.tv/api/resources", text=plextv_resources_two_servers, ) - with patch( - "plexapi.server.PlexServer.clients", side_effect=plexapi.exceptions.Unauthorized - ), patch("plexapi.server.PlexServer", side_effect=plexapi.exceptions.Unauthorized): - trigger_plex_update(mock_websocket) - await wait_for_debouncer(hass) + entry.add_to_hass(hass) - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert entry.state is not ConfigEntryState.LOADED + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH}, + data=entry.data, + ) - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - assert flows[0]["context"]["source"] == SOURCE_REAUTH - - flow_id = flows[0]["flow_id"] + flow_id = result["flow_id"] with patch("plexauth.PlexAuth.initiate_auth"), patch( "plexauth.PlexAuth.token", return_value="BRAND_NEW_TOKEN" @@ -823,6 +808,8 @@ async def test_trigger_reauth_multiple_servers_available( assert entry.data[PLEX_SERVER_CONFIG][CONF_URL] == PLEX_DIRECT_URL assert entry.data[PLEX_SERVER_CONFIG][CONF_TOKEN] == "BRAND_NEW_TOKEN" + mock_setup_entry.assert_called_once() + async def test_client_request_missing(hass: HomeAssistant) -> None: """Test when client headers are not set properly.""" diff --git a/tests/components/plex/test_init.py b/tests/components/plex/test_init.py index cdfa409237..08b8635829 100644 --- a/tests/components/plex/test_init.py +++ b/tests/components/plex/test_init.py @@ -15,7 +15,7 @@ from homeassistant.components.plex.models import ( TRANSIENT_SECTION, UNKNOWN_SECTION, ) -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( CONF_TOKEN, CONF_URL, @@ -336,3 +336,27 @@ async def test_setup_with_limited_credentials( assert len(hass.config_entries.async_entries(const.DOMAIN)) == 1 assert entry.state is ConfigEntryState.LOADED + + +async def test_trigger_reauth( + hass: HomeAssistant, + entry: MockConfigEntry, + mock_plex_server, + mock_websocket, +) -> None: + """Test setup and reauthorization of a Plex token.""" + + assert entry.state is ConfigEntryState.LOADED + + with patch( + "plexapi.server.PlexServer.clients", side_effect=plexapi.exceptions.Unauthorized + ), patch("plexapi.server.PlexServer", side_effect=plexapi.exceptions.Unauthorized): + trigger_plex_update(mock_websocket) + await wait_for_debouncer(hass) + + assert len(hass.config_entries.async_entries(const.DOMAIN)) == 1 + assert entry.state is not ConfigEntryState.LOADED + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["source"] == SOURCE_REAUTH From ab1df8065c510015fd2021ad93eae2c3eaa92227 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 6 Mar 2023 10:26:37 +0100 Subject: [PATCH 0671/2810] Refresh homeassistant_alerts when components are loaded (#76049) --- .../homeassistant_alerts/__init__.py | 18 +- .../homeassistant_alerts/test_init.py | 233 +++++++++++++++++- 2 files changed, 247 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homeassistant_alerts/__init__.py b/homeassistant/components/homeassistant_alerts/__init__.py index 7012111ed6..ffc0594baf 100644 --- a/homeassistant/components/homeassistant_alerts/__init__.py +++ b/homeassistant/components/homeassistant_alerts/__init__.py @@ -10,9 +10,10 @@ import aiohttp from awesomeversion import AwesomeVersion, AwesomeVersionStrategy from homeassistant.components.hassio import get_supervisor_info, is_hassio -from homeassistant.const import __version__ -from homeassistant.core import HomeAssistant, callback +from homeassistant.const import EVENT_COMPONENT_LOADED, __version__ +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.issue_registry import ( IssueSeverity, async_create_issue, @@ -22,6 +23,7 @@ from homeassistant.helpers.start import async_at_start from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +COMPONENT_LOADED_COOLDOWN = 30 DOMAIN = "homeassistant_alerts" UPDATE_INTERVAL = timedelta(hours=3) _LOGGER = logging.getLogger(__name__) @@ -85,7 +87,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: coordinator.async_add_listener(async_schedule_update_alerts) async def initial_refresh(hass: HomeAssistant) -> None: + refresh_debouncer = Debouncer( + hass, + _LOGGER, + cooldown=COMPONENT_LOADED_COOLDOWN, + immediate=False, + function=coordinator.async_refresh, + ) + + async def _component_loaded(_: Event) -> None: + await refresh_debouncer.async_call() + await coordinator.async_refresh() + hass.bus.async_listen(EVENT_COMPONENT_LOADED, _component_loaded) async_at_start(hass, initial_refresh) diff --git a/tests/components/homeassistant_alerts/test_init.py b/tests/components/homeassistant_alerts/test_init.py index 9bb54bd56a..f5e040aa38 100644 --- a/tests/components/homeassistant_alerts/test_init.py +++ b/tests/components/homeassistant_alerts/test_init.py @@ -7,10 +7,15 @@ from unittest.mock import ANY, patch import pytest -from homeassistant.components.homeassistant_alerts import DOMAIN, UPDATE_INTERVAL +from homeassistant.components.homeassistant_alerts import ( + COMPONENT_LOADED_COOLDOWN, + DOMAIN, + UPDATE_INTERVAL, +) from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN +from homeassistant.const import EVENT_COMPONENT_LOADED from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component +from homeassistant.setup import ATTR_COMPONENT, async_setup_component from homeassistant.util import dt as dt_util from tests.common import assert_lists_same, async_fire_time_changed, load_fixture @@ -165,6 +170,230 @@ async def test_alerts( } +@pytest.mark.parametrize( + ( + "ha_version", + "supervisor_info", + "initial_components", + "late_components", + "initial_alerts", + "late_alerts", + ), + ( + ( + "2022.7.0", + {"version": "2022.11.0"}, + ["aladdin_connect", "darksky"], + [ + "hassio", + "hikvision", + "hikvisioncam", + "hive", + "homematicip_cloud", + "logi_circle", + "neato", + "nest", + "senseme", + "sochain", + ], + [ + ("aladdin_connect", "aladdin_connect"), + ("dark_sky", "darksky"), + ], + [ + ("aladdin_connect", "aladdin_connect"), + ("dark_sky", "darksky"), + ("hassio", "hassio"), + ("hikvision", "hikvision"), + ("hikvision", "hikvisioncam"), + ("hive_us", "hive"), + ("homematicip_cloud", "homematicip_cloud"), + ("logi_circle", "logi_circle"), + ("neato", "neato"), + ("nest", "nest"), + ("senseme", "senseme"), + ("sochain", "sochain"), + ], + ), + ( + "2022.8.0", + {"version": "2022.11.1"}, + ["aladdin_connect", "darksky"], + [ + "hassio", + "hikvision", + "hikvisioncam", + "hive", + "homematicip_cloud", + "logi_circle", + "neato", + "nest", + "senseme", + "sochain", + ], + [ + ("dark_sky", "darksky"), + ], + [ + ("dark_sky", "darksky"), + ("hikvision", "hikvision"), + ("hikvision", "hikvisioncam"), + ("hive_us", "hive"), + ("homematicip_cloud", "homematicip_cloud"), + ("logi_circle", "logi_circle"), + ("neato", "neato"), + ("nest", "nest"), + ("senseme", "senseme"), + ("sochain", "sochain"), + ], + ), + ( + "2021.10.0", + None, + ["aladdin_connect", "darksky"], + [ + "hikvision", + "hikvisioncam", + "hive", + "homematicip_cloud", + "logi_circle", + "neato", + "nest", + "senseme", + "sochain", + ], + [ + ("aladdin_connect", "aladdin_connect"), + ("dark_sky", "darksky"), + ], + [ + ("aladdin_connect", "aladdin_connect"), + ("dark_sky", "darksky"), + ("hikvision", "hikvision"), + ("hikvision", "hikvisioncam"), + ("homematicip_cloud", "homematicip_cloud"), + ("logi_circle", "logi_circle"), + ("neato", "neato"), + ("nest", "nest"), + ("senseme", "senseme"), + ("sochain", "sochain"), + ], + ), + ), +) +async def test_alerts_refreshed_on_component_load( + hass: HomeAssistant, + hass_ws_client, + aioclient_mock: AiohttpClientMocker, + ha_version, + supervisor_info, + initial_components, + late_components, + initial_alerts, + late_alerts, + freezer, +) -> None: + """Test alerts are refreshed when components are loaded.""" + + aioclient_mock.clear_requests() + aioclient_mock.get( + "https://alerts.home-assistant.io/alerts.json", + text=load_fixture("alerts_1.json", "homeassistant_alerts"), + ) + for alert in initial_alerts: + stub_alert(aioclient_mock, alert[0]) + for alert in late_alerts: + stub_alert(aioclient_mock, alert[0]) + + for domain in initial_components: + hass.config.components.add(domain) + + with patch( + "homeassistant.components.homeassistant_alerts.__version__", + ha_version, + ), patch( + "homeassistant.components.homeassistant_alerts.is_hassio", + return_value=supervisor_info is not None, + ), patch( + "homeassistant.components.homeassistant_alerts.get_supervisor_info", + return_value=supervisor_info, + ): + assert await async_setup_component(hass, DOMAIN, {}) + + client = await hass_ws_client(hass) + + await client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "issues": [ + { + "breaks_in_ha_version": None, + "created": ANY, + "dismissed_version": None, + "domain": "homeassistant_alerts", + "ignored": False, + "is_fixable": False, + "issue_id": f"{alert}.markdown_{integration}", + "issue_domain": integration, + "learn_more_url": None, + "severity": "warning", + "translation_key": "alert", + "translation_placeholders": { + "title": f"Title for {alert}", + "description": f"Content for {alert}", + }, + } + for alert, integration in initial_alerts + ] + } + + with patch( + "homeassistant.components.homeassistant_alerts.__version__", + ha_version, + ), patch( + "homeassistant.components.homeassistant_alerts.is_hassio", + return_value=supervisor_info is not None, + ), patch( + "homeassistant.components.homeassistant_alerts.get_supervisor_info", + return_value=supervisor_info, + ): + # Fake component_loaded events and wait for debounce + for domain in late_components: + hass.config.components.add(domain) + hass.bus.async_fire(EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: domain}) + freezer.tick(COMPONENT_LOADED_COOLDOWN + 1) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + await client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "issues": [ + { + "breaks_in_ha_version": None, + "created": ANY, + "dismissed_version": None, + "domain": "homeassistant_alerts", + "ignored": False, + "is_fixable": False, + "issue_id": f"{alert}.markdown_{integration}", + "issue_domain": integration, + "learn_more_url": None, + "severity": "warning", + "translation_key": "alert", + "translation_placeholders": { + "title": f"Title for {alert}", + "description": f"Content for {alert}", + }, + } + for alert, integration in late_alerts + ] + } + + @pytest.mark.parametrize( ("ha_version", "fixture", "expected_alerts"), ( From ea4d2bd1e86fa9afcdeae84c9db6f9815373ed39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Pf=C3=BCtsch?= <54020707+fpfuetsch@users.noreply.github.com> Date: Mon, 6 Mar 2023 10:40:34 +0100 Subject: [PATCH 0672/2810] Sync tado zones after updating climate preset (#79715) --- homeassistant/components/tado/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 9146d4d83d..691ca63965 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -268,6 +268,7 @@ class TadoConnector: self.tado.setAway() elif presence == PRESET_HOME: self.tado.setHome() + self.update_zones() def set_zone_overlay( self, From bf5f7c53d8ac56e57e81e5cddf86640df0847c1d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 6 Mar 2023 11:31:50 +0100 Subject: [PATCH 0673/2810] Move mock_setup_entry to conftest (#88484) --- .../templates/config_flow/tests/conftest.py | 14 ++++++++++++++ .../config_flow/tests/test_config_flow.py | 10 +--------- .../templates/config_flow_helper/tests/conftest.py | 14 ++++++++++++++ .../config_flow_helper/tests/test_config_flow.py | 12 ++---------- tests/components/nibe_heatpump/conftest.py | 11 ++++++++++- tests/components/nibe_heatpump/test_config_flow.py | 10 ++-------- tests/components/onewire/conftest.py | 12 +++++++++++- tests/components/onewire/test_config_flow.py | 10 +--------- tests/components/philips_js/conftest.py | 14 +++++++++++++- tests/components/philips_js/test_config_flow.py | 13 ++----------- tests/components/renault/conftest.py | 12 +++++++++++- tests/components/renault/test_config_flow.py | 9 +-------- tests/components/samsungtv/conftest.py | 11 ++++++++++- tests/components/samsungtv/test_config_flow.py | 10 +--------- tests/components/sfr_box/conftest.py | 11 ++++++++++- tests/components/sfr_box/test_config_flow.py | 10 +--------- tests/components/sleepiq/conftest.py | 11 ++++++++++- tests/components/sleepiq/test_config_flow.py | 10 +--------- 18 files changed, 115 insertions(+), 89 deletions(-) create mode 100644 script/scaffold/templates/config_flow/tests/conftest.py create mode 100644 script/scaffold/templates/config_flow_helper/tests/conftest.py diff --git a/script/scaffold/templates/config_flow/tests/conftest.py b/script/scaffold/templates/config_flow/tests/conftest.py new file mode 100644 index 0000000000..dab3d971a3 --- /dev/null +++ b/script/scaffold/templates/config_flow/tests/conftest.py @@ -0,0 +1,14 @@ +"""Test the NEW_NAME config flow.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.NEW_DOMAIN.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/script/scaffold/templates/config_flow/tests/test_config_flow.py b/script/scaffold/templates/config_flow/tests/test_config_flow.py index 6af4bf1e66..cbc1449378 100644 --- a/script/scaffold/templates/config_flow/tests/test_config_flow.py +++ b/script/scaffold/templates/config_flow/tests/test_config_flow.py @@ -1,5 +1,4 @@ """Test the NEW_NAME config flow.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest @@ -10,14 +9,7 @@ from homeassistant.components.NEW_DOMAIN.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType - -@pytest.fixture(autouse=True, name="mock_setup_entry") -def override_async_setup_entry() -> Generator[AsyncMock, None, None]: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.NEW_DOMAIN.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: diff --git a/script/scaffold/templates/config_flow_helper/tests/conftest.py b/script/scaffold/templates/config_flow_helper/tests/conftest.py new file mode 100644 index 0000000000..dab3d971a3 --- /dev/null +++ b/script/scaffold/templates/config_flow_helper/tests/conftest.py @@ -0,0 +1,14 @@ +"""Test the NEW_NAME config flow.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.NEW_DOMAIN.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/script/scaffold/templates/config_flow_helper/tests/test_config_flow.py b/script/scaffold/templates/config_flow_helper/tests/test_config_flow.py index ba7efff5b6..d21c66797d 100644 --- a/script/scaffold/templates/config_flow_helper/tests/test_config_flow.py +++ b/script/scaffold/templates/config_flow_helper/tests/test_config_flow.py @@ -1,6 +1,5 @@ """Test the NEW_NAME config flow.""" -from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock import pytest @@ -11,14 +10,7 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry - -@pytest.fixture(autouse=True, name="mock_setup_entry") -def override_async_setup_entry() -> Generator[AsyncMock, None, None]: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.NEW_DOMAIN.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") @pytest.mark.parametrize("platform", ("sensor",)) diff --git a/tests/components/nibe_heatpump/conftest.py b/tests/components/nibe_heatpump/conftest.py index b75c49b2b7..2a4e2f80ff 100644 --- a/tests/components/nibe_heatpump/conftest.py +++ b/tests/components/nibe_heatpump/conftest.py @@ -1,5 +1,5 @@ """Test configuration for Nibe Heat Pump.""" -from collections.abc import AsyncIterator, Iterable +from collections.abc import AsyncIterator, Generator, Iterable from contextlib import ExitStack from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -10,6 +10,15 @@ from nibe.exceptions import ReadException import pytest +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Make sure we never actually run setup.""" + with patch( + "homeassistant.components.nibe_heatpump.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(autouse=True, name="mock_connection_constructor") async def fixture_mock_connection_constructor(): """Make sure we have a dummy connection.""" diff --git a/tests/components/nibe_heatpump/test_config_flow.py b/tests/components/nibe_heatpump/test_config_flow.py index 3360c82577..22dca1fa2f 100644 --- a/tests/components/nibe_heatpump/test_config_flow.py +++ b/tests/components/nibe_heatpump/test_config_flow.py @@ -1,5 +1,5 @@ """Test the Nibe Heat Pump config flow.""" -from unittest.mock import Mock, patch +from unittest.mock import Mock from nibe.coil import Coil from nibe.exceptions import ( @@ -32,13 +32,7 @@ MOCK_FLOW_MODBUS_USERDATA = { } -@pytest.fixture(autouse=True, name="mock_setup_entry") -async def fixture_mock_setup(): - """Make sure we never actually run setup.""" - with patch( - "homeassistant.components.nibe_heatpump.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") async def _get_connection_form( diff --git a/tests/components/onewire/conftest.py b/tests/components/onewire/conftest.py index 9b8b53859e..031b29d47a 100644 --- a/tests/components/onewire/conftest.py +++ b/tests/components/onewire/conftest.py @@ -1,5 +1,6 @@ """Provide common 1-Wire fixtures.""" -from unittest.mock import MagicMock, patch +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch from pyownet.protocol import ConnError import pytest @@ -14,6 +15,15 @@ from .const import MOCK_OWPROXY_DEVICES from tests.common import MockConfigEntry +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.onewire.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(name="device_id", params=MOCK_OWPROXY_DEVICES.keys()) def get_device_id(request: pytest.FixtureRequest) -> str: """Parametrize device id.""" diff --git a/tests/components/onewire/test_config_flow.py b/tests/components/onewire/test_config_flow.py index eab44bc5d4..63e53627e0 100644 --- a/tests/components/onewire/test_config_flow.py +++ b/tests/components/onewire/test_config_flow.py @@ -1,5 +1,4 @@ """Tests for 1-Wire config flow.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch from pyownet import protocol @@ -19,14 +18,7 @@ from homeassistant.helpers.config_validation import ensure_list from .const import MOCK_OWPROXY_DEVICES - -@pytest.fixture(autouse=True, name="mock_setup_entry") -def override_async_setup_entry() -> Generator[AsyncMock, None, None]: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.onewire.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") @pytest.fixture diff --git a/tests/components/philips_js/conftest.py b/tests/components/philips_js/conftest.py index 0aaa76001c..bc94d721cc 100644 --- a/tests/components/philips_js/conftest.py +++ b/tests/components/philips_js/conftest.py @@ -1,5 +1,6 @@ """Standard setup for tests.""" -from unittest.mock import create_autospec, patch +from collections.abc import Generator +from unittest.mock import AsyncMock, create_autospec, patch from haphilipsjs import PhilipsTV import pytest @@ -11,6 +12,17 @@ from . import MOCK_CONFIG, MOCK_ENTITY_ID, MOCK_NAME, MOCK_SERIAL_NO, MOCK_SYSTE from tests.common import MockConfigEntry, mock_device_registry +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Disable component setup.""" + with patch( + "homeassistant.components.philips_js.async_setup_entry", return_value=True + ) as mock_setup_entry, patch( + "homeassistant.components.philips_js.async_unload_entry", return_value=True + ): + yield mock_setup_entry + + @pytest.fixture(autouse=True) async def setup_notification(hass): """Configure notification system.""" diff --git a/tests/components/philips_js/test_config_flow.py b/tests/components/philips_js/test_config_flow.py index d4a08aa688..1662a2a3fc 100644 --- a/tests/components/philips_js/test_config_flow.py +++ b/tests/components/philips_js/test_config_flow.py @@ -1,5 +1,5 @@ """Test the Philips TV config flow.""" -from unittest.mock import ANY, patch +from unittest.mock import ANY from haphilipsjs import PairingFailure import pytest @@ -19,16 +19,7 @@ from . import ( from tests.common import MockConfigEntry - -@pytest.fixture(autouse=True, name="mock_setup_entry") -def mock_setup_entry_fixture(): - """Disable component setup.""" - with patch( - "homeassistant.components.philips_js.async_setup_entry", return_value=True - ) as mock_setup_entry, patch( - "homeassistant.components.philips_js.async_unload_entry", return_value=True - ): - yield mock_setup_entry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") @pytest.fixture diff --git a/tests/components/renault/conftest.py b/tests/components/renault/conftest.py index 6c62e5d22e..312ddbf609 100644 --- a/tests/components/renault/conftest.py +++ b/tests/components/renault/conftest.py @@ -1,8 +1,9 @@ """Provide common Renault fixtures.""" +from collections.abc import Generator import contextlib from types import MappingProxyType from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest from renault_api.kamereon import exceptions, schemas @@ -18,6 +19,15 @@ from .const import MOCK_ACCOUNT_ID, MOCK_CONFIG, MOCK_VEHICLES from tests.common import MockConfigEntry, load_fixture +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.renault.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(name="vehicle_type", params=MOCK_VEHICLES.keys()) def get_vehicle_type(request: pytest.FixtureRequest) -> str: """Parametrize vehicle type.""" diff --git a/tests/components/renault/test_config_flow.py b/tests/components/renault/test_config_flow.py index 39d4add062..5d933c03c6 100644 --- a/tests/components/renault/test_config_flow.py +++ b/tests/components/renault/test_config_flow.py @@ -21,14 +21,7 @@ from .const import MOCK_CONFIG from tests.common import load_fixture - -@pytest.fixture(autouse=True, name="mock_setup_entry") -def override_async_setup_entry() -> AsyncMock: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.renault.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") async def test_config_flow_single_account( diff --git a/tests/components/samsungtv/conftest.py b/tests/components/samsungtv/conftest.py index 73ad642f7e..0e95dfa28a 100644 --- a/tests/components/samsungtv/conftest.py +++ b/tests/components/samsungtv/conftest.py @@ -1,7 +1,7 @@ """Fixtures for Samsung TV.""" from __future__ import annotations -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Generator from datetime import datetime from socket import AddressFamily from typing import Any @@ -25,6 +25,15 @@ import homeassistant.util.dt as dt_util from .const import SAMPLE_DEVICE_INFO_UE48JU6400, SAMPLE_DEVICE_INFO_WIFI +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.samsungtv.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(autouse=True) async def silent_ssdp_scanner(hass): """Start SSDP component and get Scanner, prevent actual SSDP traffic.""" diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index e84b3d30cb..ac0072c88c 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -1,5 +1,4 @@ """Tests for Samsung TV config flow.""" -from collections.abc import Generator import socket from unittest.mock import ANY, AsyncMock, Mock, call, patch @@ -216,14 +215,7 @@ DEVICEINFO_WEBSOCKET_NO_SSL = { "timeout": TIMEOUT_WEBSOCKET, } - -@pytest.fixture(autouse=True, name="mock_setup_entry") -def override_async_setup_entry() -> Generator[AsyncMock, None, None]: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.samsungtv.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") @pytest.mark.usefixtures("remote", "rest_api_failing") diff --git a/tests/components/sfr_box/conftest.py b/tests/components/sfr_box/conftest.py index 207c2939f1..1857ffeec3 100644 --- a/tests/components/sfr_box/conftest.py +++ b/tests/components/sfr_box/conftest.py @@ -1,7 +1,7 @@ """Provide common SFR Box fixtures.""" from collections.abc import Generator import json -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest from sfrbox_api.models import DslInfo, SystemInfo @@ -14,6 +14,15 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.sfr_box.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(name="config_entry") def get_config_entry(hass: HomeAssistant) -> ConfigEntry: """Create and register mock config entry.""" diff --git a/tests/components/sfr_box/test_config_flow.py b/tests/components/sfr_box/test_config_flow.py index e75e5a42c7..c8130d5d61 100644 --- a/tests/components/sfr_box/test_config_flow.py +++ b/tests/components/sfr_box/test_config_flow.py @@ -1,5 +1,4 @@ """Test the SFR Box config flow.""" -from collections.abc import Generator import json from unittest.mock import AsyncMock, patch @@ -15,14 +14,7 @@ from homeassistant.core import HomeAssistant from tests.common import load_fixture - -@pytest.fixture(autouse=True, name="mock_setup_entry") -def override_async_setup_entry() -> Generator[AsyncMock, None, None]: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.sfr_box.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") async def test_config_flow_skip_auth( diff --git a/tests/components/sleepiq/conftest.py b/tests/components/sleepiq/conftest.py index 23c42ee7f6..9932b75ebd 100644 --- a/tests/components/sleepiq/conftest.py +++ b/tests/components/sleepiq/conftest.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Generator -from unittest.mock import MagicMock, create_autospec, patch +from unittest.mock import AsyncMock, MagicMock, create_autospec, patch from asyncsleepiq import ( Side, @@ -40,6 +40,15 @@ SLEEPIQ_CONFIG = { } +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.sleepiq.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture def mock_bed() -> MagicMock: """Mock a SleepIQBed object with sleepers and lights.""" diff --git a/tests/components/sleepiq/test_config_flow.py b/tests/components/sleepiq/test_config_flow.py index a2191b5658..0f25167589 100644 --- a/tests/components/sleepiq/test_config_flow.py +++ b/tests/components/sleepiq/test_config_flow.py @@ -1,5 +1,4 @@ """Tests for the SleepIQ config flow.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch from asyncsleepiq import SleepIQLoginException, SleepIQTimeoutException @@ -12,14 +11,7 @@ from homeassistant.core import HomeAssistant from .conftest import SLEEPIQ_CONFIG, setup_platform - -@pytest.fixture(autouse=True, name="mock_setup_entry") -def override_async_setup_entry() -> Generator[AsyncMock, None, None]: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.sleepiq.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") async def test_import(hass: HomeAssistant) -> None: From b572ecc62dc427375a6f55d4741df87a411744eb Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 6 Mar 2023 06:06:53 -0500 Subject: [PATCH 0674/2810] Update zwave_js README with contributor instructions (#89158) --- homeassistant/components/zwave_js/README.md | 28 ++++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zwave_js/README.md b/homeassistant/components/zwave_js/README.md index 920fc4a6a0..f82f421f75 100644 --- a/homeassistant/components/zwave_js/README.md +++ b/homeassistant/components/zwave_js/README.md @@ -1,9 +1,29 @@ -# Z-Wave JS Architecture +# Z-Wave Integration -This document describes the architecture of Z-Wave JS in Home Assistant and how the integration is connected all the way to the Z-Wave USB stick controller. +This document covers details that new contributors may find helpful when getting started. + +## Improving device support + +This section can help new contributors learn how to improve Z-Wave device support within Home Assistant. + +The Z-Wave integration uses a discovery mechanism to create the necessary entities for each of your Z-Wave nodes. To perform this discovery, the integration iterates through each node's [Values](https://zwave-js.github.io/node-zwave-js/#/api/valueid) and compares them to a list of [discovery rules](./discovery.py). If there is a match between a particular discovery rule and the given Value, the integration creates an entity for that value using information sent from the discovery logic to indicate entity platform and instance type. + +In cases where an entity's functionality requires interaction with multiple Values, the discovery rule for that particular entity type is based on the primary Value, or the Value that must be there to indicate that this entity needs to be created, and then the rest of the Values required are discovered by the class instance for that entity. A good example of this is the discovery logic for the `climate` entity. Currently, the discovery logic is tied to the discovery of a Value with a property of `mode` and a command class of `Thermostat Mode`, but the actual entity uses many more Values than that to be fully functional as evident in the [code](./climate.py). + +There are several ways that device support can be improved within Home Assistant, but regardless of the reason, it is important to add device specific tests in these use cases. To do so, add the device's data (from device diagnostics) to the [fixtures folder](../../../tests/components/zwave_js/fixtures) and then define the new fixtures in [conftest.py](../../../tests/components/zwave_js/conftest.py). Use existing tests as the model but the tests can go in the [test_discovery.py module](../../../tests/components/zwave_js/test_discovery.py). + +### Switching HA support for a device from one entity type to another. + +Sometimes manufacturers don't follow the spec properly and implement functionality using the wrong command class, resulting in HA discovering the feature as the wrong entity type. There is a section in the [discovery rules](./discovery.py) for device specific discovery. This can be used to override the type of entity that HA discovers for that particular device's primary Value. + +### Adding feature support to complex entity types + +Sometimes the generic Z-Wave entity logic does not provide all of the features a device is capable of. A great example of this is a climate entity where the current temperature is determined by one of multiple sensors that is configurable by a configuration parameter. In these cases, there is a section in the [discovery rules](./discovery.py) for device specific discovery. By leveraging [discovery_data_template.py](./discovery_data_template.py), it is possible to create the same entity type but with different logic. Generally, we don't like to create entity classes that are device specific, so this mechanism allows us to generalize the implementation. ## Architecture +This section describes the architecture of Z-Wave JS in Home Assistant and how the integration is connected all the way to the Z-Wave USB stick controller. + ### Connection diagram ![alt text][connection_diagram] @@ -24,7 +44,7 @@ Forward the state of Z-Wave JS over a WebSocket connection. Consumes the WebSocket connection and makes the Z-Wave JS state available in Python. -#### Z-Wave JS integration +#### Z-Wave integration Represents Z-Wave devices in Home Assistant and allows control. @@ -38,7 +58,7 @@ Best home automation platform in the world. Z-Wave JS Server can be run as a standalone Node app. -It can also run as part of Z-Wave JS 2 MQTT, which is also a standalone Node app. +It can also run as part of Z-Wave JS UI, which is also a standalone Node app. Both apps are available as Home Assistant add-ons. There are also Docker containers etc. From 14a17b102866397c61d0a02c510c8e0597ce6b36 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 6 Mar 2023 12:28:40 +0100 Subject: [PATCH 0675/2810] Use mock_setup_entry fixture in melnor (#89226) --- tests/components/melnor/conftest.py | 12 +-- tests/components/melnor/test_config_flow.py | 93 +++++++++++---------- 2 files changed, 57 insertions(+), 48 deletions(-) diff --git a/tests/components/melnor/conftest.py b/tests/components/melnor/conftest.py index e030e19878..943018fae8 100644 --- a/tests/components/melnor/conftest.py +++ b/tests/components/melnor/conftest.py @@ -1,6 +1,7 @@ """Tests for the melnor integration.""" from __future__ import annotations +from collections.abc import Generator from unittest.mock import AsyncMock, patch from bleak.backends.device import BLEDevice @@ -141,12 +142,13 @@ def mock_melnor_device(): return device -def patch_async_setup_entry(return_value=True): +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: """Patch async setup entry to return True.""" - return patch( - "homeassistant.components.melnor.async_setup_entry", - return_value=return_value, - ) + with patch( + "homeassistant.components.melnor.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup # pylint: disable=dangerous-default-value diff --git a/tests/components/melnor/test_config_flow.py b/tests/components/melnor/test_config_flow.py index e0f7a21bff..95a6764460 100644 --- a/tests/components/melnor/test_config_flow.py +++ b/tests/components/melnor/test_config_flow.py @@ -1,4 +1,6 @@ """Test the melnor config flow.""" +from unittest.mock import AsyncMock + import pytest import voluptuous as vol @@ -13,15 +15,14 @@ from .conftest import ( FAKE_SERVICE_INFO_1, FAKE_SERVICE_INFO_2, patch_async_discovered_service_info, - patch_async_setup_entry, ) -async def test_user_step_no_devices(hass: HomeAssistant) -> None: +async def test_user_step_no_devices( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test we handle no devices found.""" - with patch_async_setup_entry() as mock_setup_entry, patch_async_discovered_service_info( - [] - ): + with patch_async_discovered_service_info([]): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, @@ -30,13 +31,15 @@ async def test_user_step_no_devices(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.ABORT assert result["reason"] == "no_devices_found" - assert len(mock_setup_entry.mock_calls) == 0 + mock_setup_entry.assert_not_called() -async def test_user_step_discovered_devices(hass: HomeAssistant) -> None: +async def test_user_step_discovered_devices( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test we properly handle device picking.""" - with patch_async_setup_entry() as mock_setup_entry, patch_async_discovered_service_info(): + with patch_async_discovered_service_info(): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, @@ -57,13 +60,15 @@ async def test_user_step_discovered_devices(hass: HomeAssistant) -> None: assert result2["type"] == FlowResultType.CREATE_ENTRY assert result2["data"] == {CONF_ADDRESS: FAKE_ADDRESS_1} - assert len(mock_setup_entry.mock_calls) == 1 + mock_setup_entry.assert_called_once() -async def test_user_step_with_existing_device(hass: HomeAssistant) -> None: +async def test_user_step_with_existing_device( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test we properly handle device picking.""" - with patch_async_setup_entry() as mock_setup_entry, patch_async_discovered_service_info( + with patch_async_discovered_service_info( [FAKE_SERVICE_INFO_1, FAKE_SERVICE_INFO_2] ): # Create the config flow @@ -95,48 +100,50 @@ async def test_user_step_with_existing_device(hass: HomeAssistant) -> None: result["flow_id"], user_input={CONF_ADDRESS: FAKE_ADDRESS_1} ) - assert len(mock_setup_entry.mock_calls) == 0 + mock_setup_entry.assert_not_called() -async def test_bluetooth_discovered(hass: HomeAssistant) -> None: +async def test_bluetooth_discovered( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test we short circuit to config entry creation.""" - with patch_async_setup_entry() as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_BLUETOOTH}, - data=FAKE_SERVICE_INFO_1, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=FAKE_SERVICE_INFO_1, + ) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "bluetooth_confirm" - assert result["description_placeholders"] == {"name": FAKE_ADDRESS_1} + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["description_placeholders"] == {"name": FAKE_ADDRESS_1} - assert len(mock_setup_entry.mock_calls) == 0 + mock_setup_entry.assert_not_called() -async def test_bluetooth_confirm(hass: HomeAssistant) -> None: +async def test_bluetooth_confirm( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test we short circuit to config entry creation.""" - with patch_async_setup_entry() as mock_setup_entry: - # Create the config flow - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={ - "source": config_entries.SOURCE_BLUETOOTH, - "step_id": "bluetooth_confirm", - "user_input": {CONF_MAC: FAKE_ADDRESS_1}, - }, - data=FAKE_SERVICE_INFO_1, - ) + # Create the config flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_BLUETOOTH, + "step_id": "bluetooth_confirm", + "user_input": {CONF_MAC: FAKE_ADDRESS_1}, + }, + data=FAKE_SERVICE_INFO_1, + ) - # Interact with it like a user would - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) + # Interact with it like a user would + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == FAKE_ADDRESS_1 - assert result2["data"] == {CONF_ADDRESS: FAKE_ADDRESS_1} + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == FAKE_ADDRESS_1 + assert result2["data"] == {CONF_ADDRESS: FAKE_ADDRESS_1} - assert len(mock_setup_entry.mock_calls) == 1 + mock_setup_entry.assert_called_once() From b2166c3117031b3865ed85255d00bf73b09420d2 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 6 Mar 2023 12:42:34 +0100 Subject: [PATCH 0676/2810] Reolink add new number entities (#87932) Co-authored-by: Franck Nijhof --- homeassistant/components/reolink/number.py | 137 ++++++++++++++++++--- 1 file changed, 123 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index c1baf4b156..05956aff35 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -13,6 +13,7 @@ from homeassistant.components.number import ( NumberMode, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -25,10 +26,8 @@ from .entity import ReolinkCoordinatorEntity class ReolinkNumberEntityDescriptionMixin: """Mixin values for Reolink number entities.""" - value: Callable[[Host, int | None], bool] - get_min_value: Callable[[Host, int | None], float] - get_max_value: Callable[[Host, int | None], float] - method: Callable[[Host, int | None, float], Any] + value: Callable[[Host, int], float] + method: Callable[[Host, int, float], Any] @dataclass @@ -38,7 +37,9 @@ class ReolinkNumberEntityDescription( """A class that describes number entities.""" mode: NumberMode = NumberMode.AUTO - supported: Callable[[Host, int | None], bool] = lambda api, ch: True + supported: Callable[[Host, int], bool] = lambda api, ch: True + get_min_value: Callable[[Host, int], float] | None = None + get_max_value: Callable[[Host, int], float] | None = None NUMBER_ENTITIES = ( @@ -50,7 +51,7 @@ NUMBER_ENTITIES = ( native_step=1, get_min_value=lambda api, ch: api.zoom_range(ch)["zoom"]["pos"]["min"], get_max_value=lambda api, ch: api.zoom_range(ch)["zoom"]["pos"]["max"], - supported=lambda api, ch: api.zoom_supported(ch), + supported=lambda api, ch: api.supported(ch, "zoom"), value=lambda api, ch: api.get_zoom(ch), method=lambda api, ch, value: api.set_zoom(ch, int(value)), ), @@ -62,10 +63,115 @@ NUMBER_ENTITIES = ( native_step=1, get_min_value=lambda api, ch: api.zoom_range(ch)["focus"]["pos"]["min"], get_max_value=lambda api, ch: api.zoom_range(ch)["focus"]["pos"]["max"], - supported=lambda api, ch: api.zoom_supported(ch), + supported=lambda api, ch: api.supported(ch, "zoom"), value=lambda api, ch: api.get_focus(ch), method=lambda api, ch, value: api.set_zoom(ch, int(value)), ), + ReolinkNumberEntityDescription( + key="floodlight_brightness", + name="Floodlight turn on brightness", + icon="mdi:spotlight-beam", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=1, + native_max_value=100, + supported=lambda api, ch: api.supported(ch, "floodLight"), + value=lambda api, ch: api.whiteled_brightness(ch), + method=lambda api, ch, value: api.set_whiteled(ch, brightness=int(value)), + ), + ReolinkNumberEntityDescription( + key="volume", + name="Volume", + icon="mdi:volume-high", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: api.supported(ch, "volume"), + value=lambda api, ch: api.volume(ch), + method=lambda api, ch, value: api.set_volume(ch, volume=int(value)), + ), + ReolinkNumberEntityDescription( + key="guard_return_time", + name="Guard return time", + icon="mdi:crosshairs-gps", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=10, + native_max_value=300, + supported=lambda api, ch: api.supported(ch, "ptz_guard"), + value=lambda api, ch: api.ptz_guard_time(ch), + method=lambda api, ch, value: api.set_ptz_guard(ch, time=int(value)), + ), + ReolinkNumberEntityDescription( + key="motion_sensitivity", + name="Motion sensitivity", + icon="mdi:motion-sensor", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=1, + native_max_value=50, + supported=lambda api, ch: api.supported(ch, "md_sensitivity"), + value=lambda api, ch: api.md_sensitivity(ch), + method=lambda api, ch, value: api.set_md_sensitivity(ch, int(value)), + ), + ReolinkNumberEntityDescription( + key="ai_face_sensititvity", + name="AI face sensitivity", + icon="mdi:face-recognition", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: ( + api.supported(ch, "ai_sensitivity") and api.ai_supported(ch, "face") + ), + value=lambda api, ch: api.ai_sensitivity(ch, "face"), + method=lambda api, ch, value: api.set_ai_sensitivity(ch, int(value), "face"), + ), + ReolinkNumberEntityDescription( + key="ai_person_sensititvity", + name="AI person sensitivity", + icon="mdi:account", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: ( + api.supported(ch, "ai_sensitivity") and api.ai_supported(ch, "people") + ), + value=lambda api, ch: api.ai_sensitivity(ch, "people"), + method=lambda api, ch, value: api.set_ai_sensitivity(ch, int(value), "people"), + ), + ReolinkNumberEntityDescription( + key="ai_vehicle_sensititvity", + name="AI vehicle sensitivity", + icon="mdi:car", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: ( + api.supported(ch, "ai_sensitivity") and api.ai_supported(ch, "vehicle") + ), + value=lambda api, ch: api.ai_sensitivity(ch, "vehicle"), + method=lambda api, ch, value: api.set_ai_sensitivity(ch, int(value), "vehicle"), + ), + ReolinkNumberEntityDescription( + key="ai_pet_sensititvity", + name="AI pet sensitivity", + icon="mdi:dog-side", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: ( + api.supported(ch, "ai_sensitivity") and api.ai_supported(ch, "dog_cat") + ), + value=lambda api, ch: api.ai_sensitivity(ch, "dog_cat"), + method=lambda api, ch, value: api.set_ai_sensitivity(ch, int(value), "dog_cat"), + ), ) @@ -100,15 +206,17 @@ class ReolinkNumberEntity(ReolinkCoordinatorEntity, NumberEntity): super().__init__(reolink_data, channel) self.entity_description = entity_description - self._attr_native_min_value = self.entity_description.get_min_value( - self._host.api, self._channel - ) - self._attr_native_max_value = self.entity_description.get_max_value( - self._host.api, self._channel - ) + if entity_description.get_min_value is not None: + self._attr_native_min_value = entity_description.get_min_value( + self._host.api, channel + ) + if entity_description.get_max_value is not None: + self._attr_native_max_value = entity_description.get_max_value( + self._host.api, channel + ) self._attr_mode = entity_description.mode self._attr_unique_id = ( - f"{self._host.unique_id}_{self._channel}_{entity_description.key}" + f"{self._host.unique_id}_{channel}_{entity_description.key}" ) @property @@ -119,3 +227,4 @@ class ReolinkNumberEntity(ReolinkCoordinatorEntity, NumberEntity): async def async_set_native_value(self, value: float) -> None: """Update the current value.""" await self.entity_description.method(self._host.api, self._channel, value) + self.async_write_ha_state() From 0ce9c6293a0bd2e8d557ce86ad7de56e7ee1a7eb Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 6 Mar 2023 12:47:01 +0100 Subject: [PATCH 0677/2810] 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 c09f2d501c..da68e48cc0 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 d72bea1983..5ce5203364 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 cc83b2eab0..1a948b2d7d 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 1f4b0c06bc..ebcac90494 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -690,7 +690,7 @@ hole==0.8.0 holidays==0.18.0 # homeassistant.components.frontend -home-assistant-frontend==20230302.0 +home-assistant-frontend==20230306.0 # homeassistant.components.conversation home-assistant-intents==2023.2.28 From 76cc4c9c086da9dcd813fe1bc02a6fe1bbcee62f Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 6 Mar 2023 12:48:36 +0100 Subject: [PATCH 0678/2810] Add Reolink light platform (#88619) Co-authored-by: Franck Nijhof --- .coveragerc | 1 + homeassistant/components/reolink/__init__.py | 1 + homeassistant/components/reolink/light.py | 157 +++++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 homeassistant/components/reolink/light.py diff --git a/.coveragerc b/.coveragerc index e44ca0d70d..f38c6226ac 100644 --- a/.coveragerc +++ b/.coveragerc @@ -981,6 +981,7 @@ omit = homeassistant/components/reolink/camera.py homeassistant/components/reolink/entity.py homeassistant/components/reolink/host.py + homeassistant/components/reolink/light.py homeassistant/components/reolink/number.py homeassistant/components/reolink/select.py homeassistant/components/reolink/siren.py diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index bed286c3bf..94d4c1561a 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -27,6 +27,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CAMERA, + Platform.LIGHT, Platform.NUMBER, Platform.SELECT, Platform.SIREN, diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py new file mode 100644 index 0000000000..dd71f91bb0 --- /dev/null +++ b/homeassistant/components/reolink/light.py @@ -0,0 +1,157 @@ +"""Component providing support for Reolink light entities.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from reolink_aio.api import Host + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ColorMode, + LightEntity, + LightEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ReolinkData +from .const import DOMAIN +from .entity import ReolinkCoordinatorEntity + + +@dataclass +class ReolinkLightEntityDescriptionMixin: + """Mixin values for Reolink light entities.""" + + is_on_fn: Callable[[Host, int], bool] + turn_on_off_fn: Callable[[Host, int, bool], Any] + + +@dataclass +class ReolinkLightEntityDescription( + LightEntityDescription, ReolinkLightEntityDescriptionMixin +): + """A class that describes light entities.""" + + supported_fn: Callable[[Host, int], bool] = lambda api, ch: True + get_brightness_fn: Callable[[Host, int], int] | None = None + set_brightness_fn: Callable[[Host, int, float], Any] | None = None + + +LIGHT_ENTITIES = ( + ReolinkLightEntityDescription( + key="floodlight", + name="Floodlight", + icon="mdi:spotlight-beam", + supported_fn=lambda api, ch: api.supported(ch, "floodLight"), + is_on_fn=lambda api, ch: api.whiteled_state(ch), + turn_on_off_fn=lambda api, ch, value: api.set_whiteled(ch, state=value), + get_brightness_fn=lambda api, ch: api.whiteled_brightness(ch), + set_brightness_fn=lambda api, ch, value: api.set_whiteled(ch, brightness=value), + ), + ReolinkLightEntityDescription( + key="ir_lights", + name="Infra red lights in night mode", + icon="mdi:led-off", + supported_fn=lambda api, ch: api.supported(ch, "ir_lights"), + is_on_fn=lambda api, ch: api.ir_enabled(ch), + turn_on_off_fn=lambda api, ch, value: api.set_ir_lights(ch, value), + ), + ReolinkLightEntityDescription( + key="status_led", + name="Status LED", + icon="mdi:lightning-bolt-circle", + entity_category=EntityCategory.CONFIG, + supported_fn=lambda api, ch: api.supported(ch, "status_led"), + is_on_fn=lambda api, ch: api.status_led_enabled(ch), + turn_on_off_fn=lambda api, ch, value: api.set_status_led(ch, value), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a Reolink light entities.""" + reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + ReolinkLightEntity(reolink_data, channel, entity_description) + for entity_description in LIGHT_ENTITIES + for channel in reolink_data.host.api.channels + if entity_description.supported_fn(reolink_data.host.api, channel) + ) + + +class ReolinkLightEntity(ReolinkCoordinatorEntity, LightEntity): + """Base light entity class for Reolink IP cameras.""" + + entity_description: ReolinkLightEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + channel: int, + entity_description: ReolinkLightEntityDescription, + ) -> None: + """Initialize Reolink light entity.""" + super().__init__(reolink_data, channel) + self.entity_description = entity_description + + self._attr_unique_id = ( + f"{self._host.unique_id}_{channel}_{entity_description.key}" + ) + + if entity_description.set_brightness_fn is None: + self._attr_supported_color_modes = {ColorMode.ONOFF} + self._attr_color_mode = ColorMode.ONOFF + else: + self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} + self._attr_color_mode = ColorMode.BRIGHTNESS + + @property + def is_on(self) -> bool: + """Return true if light is on.""" + return self.entity_description.is_on_fn(self._host.api, self._channel) + + @property + def brightness(self) -> int | None: + """Return the brightness of this light between 0.255.""" + if self.entity_description.get_brightness_fn is None: + return None + + return round( + 255 + * ( + self.entity_description.get_brightness_fn(self._host.api, self._channel) + / 100.0 + ) + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn light off.""" + await self.entity_description.turn_on_off_fn( + self._host.api, self._channel, False + ) + self.async_write_ha_state() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn light on.""" + if ( + brightness := kwargs.get(ATTR_BRIGHTNESS) + ) is not None and self.entity_description.set_brightness_fn is not None: + brightness_pct = int(brightness / 255.0 * 100) + await self.entity_description.set_brightness_fn( + self._host.api, self._channel, brightness_pct + ) + + await self.entity_description.turn_on_off_fn( + self._host.api, self._channel, True + ) + self.async_write_ha_state() From 0c042e8f7253becf47728a830ca75e32511d9603 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 6 Mar 2023 14:04:36 +0100 Subject: [PATCH 0679/2810] 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 bd629d53fc..119c7c946a 100644 --- a/homeassistant/components/konnected/__init__.py +++ b/homeassistant/components/konnected/__init__.py @@ -84,7 +84,7 @@ def ensure_zone(value): if value is None: raise vol.Invalid("zone value is None") - if str(value) not in ZONES is None: + if str(value) not in ZONES: raise vol.Invalid("zone not valid") return str(value) From 5ee383456f67b38e8efacfe383cac2876ef77b24 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 6 Mar 2023 15:34:47 +0100 Subject: [PATCH 0680/2810] Catch exceptions and add logging when writing states on MQTT entities (#89091) * Catch exceptions when writing states * Do not use wrapper for logging and adjust tests * Catch logging directly on async_write_ha_state() * Update homeassistant/components/mqtt/models.py Co-authored-by: Erik Montnemery * Fix test --------- Co-authored-by: Erik Montnemery --- homeassistant/components/mqtt/client.py | 2 +- homeassistant/components/mqtt/models.py | 16 +++++++-- tests/components/mqtt/test_init.py | 44 +++++++++++++++++++++++++ tests/components/mqtt/test_text.py | 35 ++++++++++++++------ 4 files changed, 84 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index ad89a35ec0..e717da5144 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -719,7 +719,7 @@ class MQTT: timestamp, ), ) - self._mqtt_data.state_write_requests.process_write_state_requests() + self._mqtt_data.state_write_requests.process_write_state_requests(msg) def _mqtt_on_callback( self, diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index a88fb97b83..84735c55e0 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -21,6 +21,8 @@ from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, TemplateVarsType if TYPE_CHECKING: + from paho.mqtt.client import MQTTMessage + from .client import MQTT, Subscription from .debug_info import TimestampedPublishMessage from .device_trigger import Trigger @@ -260,11 +262,21 @@ class EntityTopicState: self.subscribe_calls: dict[str, Entity] = {} @callback - def process_write_state_requests(self) -> None: + def process_write_state_requests(self, msg: MQTTMessage) -> None: """Process the write state requests.""" while self.subscribe_calls: _, entity = self.subscribe_calls.popitem() - entity.async_write_ha_state() + try: + entity.async_write_ha_state() + except Exception: # pylint: disable=broad-except + _LOGGER.error( + "Exception raised when updating state of %s, topic: " + "'%s' with payload: %s", + entity.entity_id, + msg.topic, + msg.payload, + exc_info=True, + ) @callback def write_state_request(self, entity: Entity) -> None: diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 47f8743f50..8c6ee21c93 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -817,6 +817,50 @@ def test_entity_device_info_schema() -> None: ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + "sensor": [ + { + "name": "test-sensor", + "unique_id": "test-sensor", + "state_topic": "test/state", + } + ] + } + } + ], +) +async def test_handle_logging_on_writing_the_entity_state( + hass: HomeAssistant, + mock_hass_config: None, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test on log handling when an error occurs writing the state.""" + await mqtt_mock_entry_no_yaml_config() + await hass.async_block_till_done() + async_fire_mqtt_message(hass, "test/state", b"initial_state") + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_sensor") + assert state is not None + assert state.state == "initial_state" + with patch( + "homeassistant.helpers.entity.Entity.async_write_ha_state", + side_effect=ValueError("Invalid value for sensor"), + ): + async_fire_mqtt_message(hass, "test/state", b"payload causing errors") + await hass.async_block_till_done() + state = hass.states.get("sensor.test_sensor") + assert state is not None + assert state.state == "initial_state" + assert "Invalid value for sensor" in caplog.text + assert "Exception raised when updating state of" in caplog.text + + async def test_receiving_non_utf8_message_gets_logged( hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, diff --git a/tests/components/mqtt/test_text.py b/tests/components/mqtt/test_text.py index 93b605d4a3..a5209e3f5f 100644 --- a/tests/components/mqtt/test_text.py +++ b/tests/components/mqtt/test_text.py @@ -116,7 +116,9 @@ async def test_controlling_state_via_topic( async def test_controlling_validation_state_via_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test the validation of a received state.""" assert await async_setup_component( @@ -148,26 +150,39 @@ async def test_controlling_validation_state_via_topic( assert state.state == "yes" # test pattern error - with pytest.raises(ValueError): - async_fire_mqtt_message(hass, "state-topic", "other") - await hass.async_block_till_done() + caplog.clear() + async_fire_mqtt_message(hass, "state-topic", "other") + await hass.async_block_till_done() + assert ( + "ValueError: Entity text.test provides state other which does not match expected pattern (y|n)" + in caplog.text + ) state = hass.states.get("text.test") assert state.state == "yes" # test text size to large - with pytest.raises(ValueError): - async_fire_mqtt_message(hass, "state-topic", "yesyesyesyes") - await hass.async_block_till_done() + caplog.clear() + async_fire_mqtt_message(hass, "state-topic", "yesyesyesyes") + await hass.async_block_till_done() + assert ( + "ValueError: Entity text.test provides state yesyesyesyes which is too long (maximum length 10)" + in caplog.text + ) state = hass.states.get("text.test") assert state.state == "yes" # test text size to small - with pytest.raises(ValueError): - async_fire_mqtt_message(hass, "state-topic", "y") - await hass.async_block_till_done() + caplog.clear() + async_fire_mqtt_message(hass, "state-topic", "y") + await hass.async_block_till_done() + assert ( + "ValueError: Entity text.test provides state y which is too short (minimum length 2)" + in caplog.text + ) state = hass.states.get("text.test") assert state.state == "yes" + # test with valid text async_fire_mqtt_message(hass, "state-topic", "no") await hass.async_block_till_done() state = hass.states.get("text.test") From ee6f969c2a4675ccedd04afda36f841bb652b99b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 6 Mar 2023 15:56:34 +0100 Subject: [PATCH 0681/2810] Add type hints to ps4 media player (#89236) --- homeassistant/components/ps4/media_player.py | 94 ++++++++++++-------- 1 file changed, 58 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py index 3e6a15df34..8799aad65d 100644 --- a/homeassistant/components/ps4/media_player.py +++ b/homeassistant/components/ps4/media_player.py @@ -2,6 +2,7 @@ import asyncio from contextlib import suppress import logging +from typing import Any, cast from pyps4_2ndscreen.errors import NotReady, PSDataIncomplete from pyps4_2ndscreen.media_art import TYPE_APP as PS_TYPE_APP @@ -27,6 +28,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.json import JsonObjectType from . import format_unique_id, load_games, save_games from .const import ( @@ -52,12 +54,12 @@ async def async_setup_entry( ) -> None: """Set up PS4 from a config entry.""" config = config_entry - creds = config.data[CONF_TOKEN] + creds: str = config.data[CONF_TOKEN] device_list = [] for device in config.data["devices"]: - host = device[CONF_HOST] - region = device[CONF_REGION] - name = device[CONF_NAME] + host: str = device[CONF_HOST] + region: str = device[CONF_REGION] + name: str = device[CONF_NAME] ps4 = pyps4.Ps4Async(host, creds, device_name=DEFAULT_ALIAS) device_list.append(PS4Device(config, name, host, region, ps4, creds)) async_add_entities(device_list, update_before_add=True) @@ -75,7 +77,15 @@ class PS4Device(MediaPlayerEntity): | MediaPlayerEntityFeature.SELECT_SOURCE ) - def __init__(self, config, name, host, region, ps4, creds): + def __init__( + self, + config: ConfigEntry, + name: str, + host: str, + region: str, + ps4: pyps4.Ps4Async, + creds: str, + ) -> None: """Initialize the ps4 device.""" self._entry_id = config.entry_id self._ps4 = ps4 @@ -83,30 +93,30 @@ class PS4Device(MediaPlayerEntity): self._attr_name = name self._region = region self._creds = creds - self._media_image = None - self._games = {} + self._media_image: str | None = None + self._games: JsonObjectType = {} self._retry = 0 self._disconnected = False @callback - def status_callback(self): + def status_callback(self) -> None: """Handle status callback. Parse status.""" self._parse_status() self.async_write_ha_state() @callback - def subscribe_to_protocol(self): + def subscribe_to_protocol(self) -> None: """Notify protocol to callback with update changes.""" self.hass.data[PS4_DATA].protocol.add_callback(self._ps4, self.status_callback) @callback - def unsubscribe_to_protocol(self): + def unsubscribe_to_protocol(self) -> None: """Notify protocol to remove callback.""" self.hass.data[PS4_DATA].protocol.remove_callback( self._ps4, self.status_callback ) - def check_region(self): + def check_region(self) -> None: """Display logger msg if region is deprecated.""" # Non-Breaking although data returned may be inaccurate. if self._region in deprecated_regions: @@ -151,10 +161,11 @@ class PS4Device(MediaPlayerEntity): self._parse_status() - def _parse_status(self): + def _parse_status(self) -> None: """Parse status.""" - if (status := self._ps4.status) is not None: - self._games = load_games(self.hass, self.unique_id) + status: dict[str, Any] | None = self._ps4.status + if status is not None: + self._games = load_games(self.hass, cast(str, self.unique_id)) if self._games: self.get_source_list() @@ -193,28 +204,30 @@ class PS4Device(MediaPlayerEntity): def _use_saved(self) -> bool: """Return True, Set media attrs if data is locked.""" if self.media_content_id in self._games: - store = self._games[self.media_content_id] + store = cast(JsonObjectType, self._games[self.media_content_id]) # If locked get attributes from file. if store.get(ATTR_LOCKED): - self._attr_media_title = store.get(ATTR_MEDIA_TITLE) + self._attr_media_title = cast(str | None, store.get(ATTR_MEDIA_TITLE)) self._attr_source = self._attr_media_title - self._media_image = store.get(ATTR_MEDIA_IMAGE_URL) - self._attr_media_content_type = store.get(ATTR_MEDIA_CONTENT_TYPE) + self._media_image = cast(str | None, store.get(ATTR_MEDIA_IMAGE_URL)) + self._attr_media_content_type = cast( + str | None, store.get(ATTR_MEDIA_CONTENT_TYPE) + ) return True return False - def idle(self): + def idle(self) -> None: """Set states for state idle.""" self.reset_title() self._attr_state = MediaPlayerState.IDLE - def state_standby(self): + def state_standby(self) -> None: """Set states for state standby.""" self.reset_title() self._attr_state = MediaPlayerState.STANDBY - def state_unknown(self): + def state_unknown(self) -> None: """Set states for state unknown.""" self.reset_title() self._attr_state = None @@ -223,14 +236,14 @@ class PS4Device(MediaPlayerEntity): self._disconnected = True self._retry = 0 - def reset_title(self): + def reset_title(self) -> None: """Update if there is no title.""" self._attr_media_title = None self._attr_media_content_id = None self._attr_media_content_type = None self._attr_source = None - async def async_get_title_data(self, title_id, name): + async def async_get_title_data(self, title_id: str, name: str) -> None: """Get PS Store Data.""" app_name = None @@ -272,10 +285,10 @@ class PS4Device(MediaPlayerEntity): await self.hass.async_add_executor_job(self.update_list) self.async_write_ha_state() - def update_list(self): + def update_list(self) -> None: """Update Game List, Correct data if different.""" if self.media_content_id in self._games: - store = self._games[self.media_content_id] + store = cast(JsonObjectType, self._games[self.media_content_id]) if ( store.get(ATTR_MEDIA_TITLE) != self.media_title @@ -290,7 +303,7 @@ class PS4Device(MediaPlayerEntity): self._media_image, self._attr_media_content_type, ) - self._games = load_games(self.hass, self.unique_id) + self._games = load_games(self.hass, cast(str, self.unique_id)) self.get_source_list() @@ -298,14 +311,22 @@ class PS4Device(MediaPlayerEntity): """Parse data entry and update source list.""" games = [] for data in self._games.values(): - games.append(data[ATTR_MEDIA_TITLE]) + data = cast(JsonObjectType, data) + games.append(cast(str, data[ATTR_MEDIA_TITLE])) self._attr_source_list = sorted(games) - def add_games(self, title_id, app_name, image, g_type, is_locked=False): + def add_games( + self, + title_id: str | None, + app_name: str | None, + image: str | None, + g_type: str | None, + is_locked: bool = False, + ) -> None: """Add games to list.""" games = self._games if title_id is not None and title_id not in games: - game = { + game: JsonObjectType = { title_id: { ATTR_MEDIA_TITLE: app_name, ATTR_MEDIA_IMAGE_URL: image, @@ -314,9 +335,9 @@ class PS4Device(MediaPlayerEntity): } } games.update(game) - save_games(self.hass, games, self.unique_id) + save_games(self.hass, games, cast(str, self.unique_id)) - async def async_get_device_info(self, status): + async def async_get_device_info(self, status: dict[str, Any] | None) -> None: """Set device info for registry.""" # If cannot get status on startup, assume info from registry. if status is None: @@ -362,7 +383,7 @@ class PS4Device(MediaPlayerEntity): self.hass.data[PS4_DATA].devices.remove(self) @property - def entity_picture(self): + def entity_picture(self) -> str | None: """Return picture.""" if ( self.state == MediaPlayerState.PLAYING @@ -376,7 +397,7 @@ class PS4Device(MediaPlayerEntity): return None @property - def media_image_url(self): + def media_image_url(self) -> str | None: """Image url of current playing media.""" if self.media_content_id is None: return None @@ -405,7 +426,8 @@ class PS4Device(MediaPlayerEntity): async def async_select_source(self, source: str) -> None: """Select input source.""" for title_id, data in self._games.items(): - game = data[ATTR_MEDIA_TITLE] + data = cast(JsonObjectType, data) + game = cast(str, data[ATTR_MEDIA_TITLE]) if ( source.lower().encode(encoding="utf-8") == game.lower().encode(encoding="utf-8") @@ -421,10 +443,10 @@ class PS4Device(MediaPlayerEntity): _LOGGER.warning("Could not start title. '%s' is not in source list", source) return - async def async_send_command(self, command): + async def async_send_command(self, command: str) -> None: """Send Button Command.""" await self.async_send_remote_control(command) - async def async_send_remote_control(self, command): + async def async_send_remote_control(self, command: str) -> None: """Send RC command.""" await self._ps4.remote_control(command) From 9ff45ca01340804da331bb5629a5e17d4e2cce26 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Mon, 6 Mar 2023 16:08:14 +0100 Subject: [PATCH 0682/2810] Allow loading UniFi entities on config options change (#88762) Co-authored-by: Franck Nijhof --- homeassistant/components/unifi/controller.py | 18 +++++- homeassistant/components/unifi/entity.py | 9 +++ tests/components/unifi/test_device_tracker.py | 21 ++++++- tests/components/unifi/test_sensor.py | 62 +++++++++++++++++++ 4 files changed, 108 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 31d0727892..69c3cc7805 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -31,7 +31,10 @@ from homeassistant.helpers import ( entity_registry as er, ) from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import async_entries_for_config_entry from homeassistant.helpers.event import async_track_time_interval @@ -108,6 +111,7 @@ class UniFiController: self.load_config_entry_options() self.entities = {} + self.known_objects: set[tuple[str, str]] = set() def load_config_entry_options(self): """Store attributes to avoid property call overhead since they are called frequently.""" @@ -207,6 +211,7 @@ class UniFiController: [ unifi_platform_entity(obj_id, self, description) for obj_id in obj_ids + if (description.key, obj_id) not in self.known_objects if description.allowed_fn(self, obj_id) if description.supported_fn(self, obj_id) ] @@ -221,6 +226,17 @@ class UniFiController: api_handler.subscribe(async_create_entity, ItemEvent.ADDED) + @callback + def async_options_updated() -> None: + """Load new entities based on changed options.""" + async_add_unifi_entity(list(api_handler)) + + self.config_entry.async_on_unload( + async_dispatcher_connect( + self.hass, self.signal_options_update, async_options_updated + ) + ) + for description in descriptions: async_load_entities(description) diff --git a/homeassistant/components/unifi/entity.py b/homeassistant/components/unifi/entity.py index 783950310e..5d763ecfe8 100644 --- a/homeassistant/components/unifi/entity.py +++ b/homeassistant/components/unifi/entity.py @@ -103,6 +103,8 @@ class UnifiEntity(Entity, Generic[HandlerT, DataT]): self.controller = controller self.entity_description = description + controller.known_objects.add((description.key, obj_id)) + self._removed = False self._attr_available = description.available_fn(controller, obj_id) @@ -118,6 +120,13 @@ class UnifiEntity(Entity, Generic[HandlerT, DataT]): description = self.entity_description handler = description.api_handler_fn(self.controller.api) + @callback + def unregister_object() -> None: + """Remove object ID from known_objects when unloaded.""" + self.controller.known_objects.discard((description.key, self._obj_id)) + + self.async_on_remove(unregister_object) + # New data from handler self.async_on_remove( handler.subscribe( diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index f271394df1..5dcf1fc693 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -635,6 +635,15 @@ async def test_option_track_devices( assert hass.states.get("device_tracker.client") assert not hass.states.get("device_tracker.device") + hass.config_entries.async_update_entry( + config_entry, + options={CONF_TRACK_DEVICES: True}, + ) + await hass.async_block_till_done() + + assert hass.states.get("device_tracker.client") + assert hass.states.get("device_tracker.device") + async def test_option_ssid_filter( hass: HomeAssistant, @@ -1041,7 +1050,7 @@ async def test_dont_track_devices( "version": "4.0.42.10433", } - await setup_unifi_integration( + config_entry = await setup_unifi_integration( hass, aioclient_mock, options={CONF_TRACK_DEVICES: False}, @@ -1053,6 +1062,16 @@ async def test_dont_track_devices( assert hass.states.get("device_tracker.client") assert not hass.states.get("device_tracker.device") + hass.config_entries.async_update_entry( + config_entry, + options={CONF_TRACK_DEVICES: True}, + ) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 2 + assert hass.states.get("device_tracker.client") + assert hass.states.get("device_tracker.device") + async def test_dont_track_wired_clients( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_device_registry diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index b5546b72bd..18007998eb 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -14,11 +14,13 @@ from homeassistant.components.unifi.const import ( CONF_ALLOW_UPTIME_SENSORS, CONF_TRACK_CLIENTS, CONF_TRACK_DEVICES, + DOMAIN as UNIFI_DOMAIN, ) from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity_registry import RegistryEntryDisabler import homeassistant.util.dt as dt_util @@ -183,6 +185,37 @@ async def test_bandwidth_sensors( assert hass.states.get("sensor.wired_client_rx") is None assert hass.states.get("sensor.wired_client_tx") is None + # Enable option + + options[CONF_ALLOW_BANDWIDTH_SENSORS] = True + hass.config_entries.async_update_entry(config_entry, options=options.copy()) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 5 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 4 + assert hass.states.get("sensor.wireless_client_rx") + assert hass.states.get("sensor.wireless_client_tx") + assert hass.states.get("sensor.wired_client_rx") + assert hass.states.get("sensor.wired_client_tx") + + # Try to add the sensors again, using a signal + + clients_connected = {wired_client["mac"], wireless_client["mac"]} + devices_connected = set() + + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + + async_dispatcher_send( + hass, + controller.signal_update, + clients_connected, + devices_connected, + ) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 5 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 4 + @pytest.mark.parametrize( ("initial_uptime", "event_uptime", "new_uptime"), @@ -267,6 +300,35 @@ async def test_uptime_sensors( assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 0 assert hass.states.get("sensor.client1_uptime") is None + # Enable option + + options[CONF_ALLOW_UPTIME_SENSORS] = True + with patch("homeassistant.util.dt.now", return_value=now): + hass.config_entries.async_update_entry(config_entry, options=options.copy()) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 + assert hass.states.get("sensor.client1_uptime") + + # Try to add the sensors again, using a signal + + clients_connected = {uptime_client["mac"]} + devices_connected = set() + + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + + async_dispatcher_send( + hass, + controller.signal_update, + clients_connected, + devices_connected, + ) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 + async def test_remove_sensors( hass: HomeAssistant, From e8bdaaacd9e893b226b6bb7057ab88d83c8b920d Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 6 Mar 2023 16:08:53 +0100 Subject: [PATCH 0683/2810] Add comment about Reolink Floodlight turn on brightness (#89234) Co-authored-by: Martin Hjelmare --- homeassistant/components/reolink/number.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 05956aff35..82c1924e27 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -67,6 +67,9 @@ NUMBER_ENTITIES = ( value=lambda api, ch: api.get_focus(ch), method=lambda api, ch, value: api.set_zoom(ch, int(value)), ), + # "Floodlight turn on brightness" controls the brightness of the floodlight when + # it is turned on internally by the camera (see "select.floodlight_mode" entity) + # or when using the "light.floodlight" entity. ReolinkNumberEntityDescription( key="floodlight_brightness", name="Floodlight turn on brightness", From 91e389c58d0bc2d2846773369eeaf3433160729f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 6 Mar 2023 16:16:31 +0100 Subject: [PATCH 0684/2810] Bump ruff to 0.0.253 (#89211) Co-authored-by: Paulus Schoutsen --- .pre-commit-config.yaml | 2 +- homeassistant/components/ps4/media_player.py | 5 ++++- homeassistant/components/zha/core/gateway.py | 5 ++++- requirements_test_pre_commit.txt | 2 +- tests/test_runner.py | 13 +++++-------- 5 files changed, 15 insertions(+), 12 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ab481ac4ea..357e2663fc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.247 + rev: v0.0.253 hooks: - id: ruff args: diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py index 8799aad65d..23438dd80c 100644 --- a/homeassistant/components/ps4/media_player.py +++ b/homeassistant/components/ps4/media_player.py @@ -188,7 +188,10 @@ class PS4Device(MediaPlayerEntity): self._attr_source = self._attr_media_title self._attr_media_content_type = None # Get data from PS Store. - asyncio.ensure_future(self.async_get_title_data(title_id, name)) + self.hass.async_create_background_task( + self.async_get_title_data(title_id, name), + "ps4.media_player-get_title_data", + ) else: if self.state != MediaPlayerState.IDLE: self.idle() diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 2f1b22e0ea..1bc77d3f36 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -393,7 +393,10 @@ class ZHAGateway: device_info = zha_device.zha_device_info zha_device.async_cleanup_handles() async_dispatcher_send(self._hass, f"{SIGNAL_REMOVE}_{str(zha_device.ieee)}") - asyncio.ensure_future(self._async_remove_device(zha_device, entity_refs)) + self._hass.async_create_task( + self._async_remove_device(zha_device, entity_refs), + "ZHAGateway._async_remove_device", + ) if device_info is not None: async_dispatcher_send( self._hass, diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 863b61afe5..e2deb067d7 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -14,5 +14,5 @@ pycodestyle==2.10.0 pydocstyle==6.2.3 pyflakes==3.0.1 pyupgrade==3.3.1 -ruff==0.0.247 +ruff==0.0.253 yamllint==1.28.0 diff --git a/tests/test_runner.py b/tests/test_runner.py index 25b75b94c3..e4af1df2b8 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -84,11 +84,9 @@ def test_run_does_not_block_forever_with_shielded_task( """Test we can shutdown and not block forever.""" test_dir = tmpdir.mkdir("config") default_config = runner.RuntimeConfig(test_dir) - created_tasks = False + tasks = [] async def _async_create_tasks(*_): - nonlocal created_tasks - async def async_raise(*_): try: await asyncio.sleep(2) @@ -101,11 +99,10 @@ def test_run_does_not_block_forever_with_shielded_task( except asyncio.CancelledError: await asyncio.sleep(2) - asyncio.ensure_future(asyncio.shield(async_shielded())) - asyncio.ensure_future(asyncio.sleep(2)) - asyncio.ensure_future(async_raise()) + tasks.append(asyncio.ensure_future(asyncio.shield(async_shielded()))) + tasks.append(asyncio.ensure_future(asyncio.sleep(2))) + tasks.append(asyncio.ensure_future(async_raise())) await asyncio.sleep(0.1) - created_tasks = True return 0 with patch.object(runner, "TASK_CANCELATION_TIMEOUT", 1), patch( @@ -115,7 +112,7 @@ def test_run_does_not_block_forever_with_shielded_task( ): runner.run(default_config) - assert created_tasks is True + assert len(tasks) == 3 assert ( "Task could not be canceled and was still running after shutdown" in caplog.text ) From f9be796ca3483ba6bf44c1c82e08c1a4eda48ab0 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 6 Mar 2023 17:23:24 +0100 Subject: [PATCH 0685/2810] Reolink extend DHCP discovery (#89238) --- homeassistant/components/reolink/manifest.json | 7 ++++++- homeassistant/generated/dhcp.py | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 978b38daea..5cb7530ec8 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -6,8 +6,13 @@ "dependencies": ["webhook"], "dhcp": [ { - "hostname": "reolink*", + "hostname": "reolink*" + }, + { "macaddress": "EC71DB*" + }, + { + "registered_devices": true } ], "documentation": "https://www.home-assistant.io/integrations/reolink", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 5be29e022f..333db76d4f 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -387,8 +387,15 @@ DHCP: list[dict[str, str | bool]] = [ { "domain": "reolink", "hostname": "reolink*", + }, + { + "domain": "reolink", "macaddress": "EC71DB*", }, + { + "domain": "reolink", + "registered_devices": True, + }, { "domain": "ring", "hostname": "ring*", From b407227d4a735d5337f3cf7a8d04a13fd82b8def Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 6 Mar 2023 17:50:42 +0100 Subject: [PATCH 0686/2810] Update pylint to 2.16.4 (#89240) --- requirements_test.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index 7f2e51a0c0..9e6ded1f39 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,7 +7,7 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt -astroid==2.14.1 +astroid==2.14.2 codecov==2.1.12 coverage==7.2.1 freezegun==1.2.2 @@ -15,7 +15,7 @@ mock-open==1.4.0 mypy==1.0.1 pre-commit==3.1.0 pydantic==1.10.5 -pylint==2.16.0 +pylint==2.16.4 pylint-per-file-ignores==1.1.0 pipdeptree==2.5.0 pytest-asyncio==0.20.3 From 1538f639ae8082067548bc0ecb5311cd7ad8e6d1 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 6 Mar 2023 18:12:19 +0100 Subject: [PATCH 0687/2810] Bump `gios` library to version 3.1.0 (#89044) --- homeassistant/components/gios/__init__.py | 10 ++----- homeassistant/components/gios/config_flow.py | 4 +-- homeassistant/components/gios/manifest.json | 2 +- homeassistant/components/gios/sensor.py | 4 +++ homeassistant/components/gios/strings.json | 14 +++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../gios/fixtures/diagnostics_data.json | 30 +++++++++---------- tests/components/gios/fixtures/indexes.json | 16 +++++----- tests/components/gios/test_sensor.py | 27 +++++++++++------ 10 files changed, 67 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/gios/__init__.py b/homeassistant/components/gios/__init__.py index 1ade1a83cc..4aad3b0537 100644 --- a/homeassistant/components/gios/__init__.py +++ b/homeassistant/components/gios/__init__.py @@ -7,7 +7,8 @@ from typing import Any, cast from aiohttp import ClientSession from aiohttp.client_exceptions import ClientConnectorError from async_timeout import timeout -from gios import ApiError, Gios, InvalidSensorsData, NoStationError +from gios import Gios +from gios.exceptions import GiosError from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM from homeassistant.config_entries import ConfigEntry @@ -89,10 +90,5 @@ class GiosDataUpdateCoordinator(DataUpdateCoordinator): try: async with timeout(API_TIMEOUT): return cast(dict[str, Any], await self.gios.async_update()) - except ( - ApiError, - NoStationError, - ClientConnectorError, - InvalidSensorsData, - ) as error: + except (GiosError, ClientConnectorError) as error: raise UpdateFailed(error) from error diff --git a/homeassistant/components/gios/config_flow.py b/homeassistant/components/gios/config_flow.py index 0fa5052e12..a1b4abd2dc 100644 --- a/homeassistant/components/gios/config_flow.py +++ b/homeassistant/components/gios/config_flow.py @@ -6,7 +6,7 @@ from typing import Any from aiohttp.client_exceptions import ClientConnectorError from async_timeout import timeout -from gios import ApiError, Gios, InvalidSensorsData, NoStationError +from gios import ApiError, Gios, InvalidSensorsDataError, NoStationError import voluptuous as vol from homeassistant import config_entries @@ -50,7 +50,7 @@ class GiosFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except NoStationError: errors[CONF_STATION_ID] = "wrong_station_id" - except InvalidSensorsData: + except InvalidSensorsDataError: errors[CONF_STATION_ID] = "invalid_sensors_data" return self.async_show_form( diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json index 6b3051a4bd..41954645f5 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["dacite", "gios"], "quality_scale": "platinum", - "requirements": ["gios==2.3.0"] + "requirements": ["gios==3.1.0"] } diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index cabbb671ae..9c73b35889 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -60,6 +60,10 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( key=ATTR_AQI, name="AQI", value=None, + icon="mdi:air-filter", + device_class=SensorDeviceClass.ENUM, + options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], + translation_key="aqi", ), GiosSensorEntityDescription( key=ATTR_C6H6, diff --git a/homeassistant/components/gios/strings.json b/homeassistant/components/gios/strings.json index 18db42b69c..a76bd3f612 100644 --- a/homeassistant/components/gios/strings.json +++ b/homeassistant/components/gios/strings.json @@ -22,5 +22,19 @@ "info": { "can_reach_server": "Reach GIO\u015a server" } + }, + "entity": { + "sensor": { + "aqi": { + "state": { + "very_bad": "Very bad", + "bad": "Bad", + "sufficient": "Sufficient", + "moderate": "Moderate", + "good": "Good", + "very_good": "Very good" + } + } + } } } diff --git a/requirements_all.txt b/requirements_all.txt index 1a948b2d7d..1c7d0f4687 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -786,7 +786,7 @@ georss_qld_bushfire_alert_client==0.5 getmac==0.8.2 # homeassistant.components.gios -gios==2.3.0 +gios==3.1.0 # homeassistant.components.gitter gitterpy==0.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ebcac90494..b103783ce9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -602,7 +602,7 @@ georss_qld_bushfire_alert_client==0.5 getmac==0.8.2 # homeassistant.components.gios -gios==2.3.0 +gios==3.1.0 # homeassistant.components.glances glances_api==0.4.1 diff --git a/tests/components/gios/fixtures/diagnostics_data.json b/tests/components/gios/fixtures/diagnostics_data.json index 1044c9887b..feee534ec3 100644 --- a/tests/components/gios/fixtures/diagnostics_data.json +++ b/tests/components/gios/fixtures/diagnostics_data.json @@ -3,48 +3,48 @@ "name": "AQI", "id": null, "index": null, - "value": "dobry" + "value": "good" }, "c6h6": { - "name": "benzen", + "name": "benzene", "id": 658, - "index": "bardzo dobry", + "index": "very_good", "value": 0.23789 }, "co": { - "name": "tlenek węgla", + "name": "carbon monoxide", "id": 660, - "index": "dobry", + "index": "good", "value": 251.874 }, "no2": { - "name": "dwutlenek azotu", + "name": "nitrogen dioxide", "id": 665, - "index": "dobry", + "index": "good", "value": 7.13411 }, "o3": { - "name": "ozon", + "name": "ozone", "id": 667, - "index": "dobry", + "index": "good", "value": 95.7768 }, "pm10": { - "name": "py\u0142 zawieszony PM10", + "name": "particulate matter 10", "id": 14395, - "index": "dobry", + "index": "good", "value": 16.8344 }, "pm25": { - "name": "py\u0142 zawieszony PM2.5", + "name": "particulate matter 2.5", "id": 670, - "index": "dobry", + "index": "good", "value": 4 }, "so2": { - "name": "dwutlenek siarki", + "name": "sulfur dioxide", "id": 672, - "index": "bardzo dobry", + "index": "very_good", "value": 4.35478 } } diff --git a/tests/components/gios/fixtures/indexes.json b/tests/components/gios/fixtures/indexes.json index cee504b0cc..c53d1c78f6 100644 --- a/tests/components/gios/fixtures/indexes.json +++ b/tests/components/gios/fixtures/indexes.json @@ -1,28 +1,28 @@ { "id": 123, "stCalcDate": "2020-07-31 15:10:17", - "stIndexLevel": { "id": 1, "indexLevelName": "dobry" }, + "stIndexLevel": { "id": 1, "indexLevelName": "Dobry" }, "stSourceDataDate": "2020-07-31 14:00:00", "so2CalcDate": "2020-07-31 15:10:17", - "so2IndexLevel": { "id": 0, "indexLevelName": "bardzo dobry" }, + "so2IndexLevel": { "id": 0, "indexLevelName": "Bardzo dobry" }, "so2SourceDataDate": "2020-07-31 14:00:00", "no2CalcDate": 1596201017000, - "no2IndexLevel": { "id": 0, "indexLevelName": "dobry" }, + "no2IndexLevel": { "id": 0, "indexLevelName": "Dobry" }, "no2SourceDataDate": "2020-07-31 14:00:00", "coCalcDate": "2020-07-31 15:10:17", - "coIndexLevel": { "id": 0, "indexLevelName": "dobry" }, + "coIndexLevel": { "id": 0, "indexLevelName": "Dobry" }, "coSourceDataDate": "2020-07-31 14:00:00", "pm10CalcDate": "2020-07-31 15:10:17", - "pm10IndexLevel": { "id": 0, "indexLevelName": "dobry" }, + "pm10IndexLevel": { "id": 0, "indexLevelName": "Dobry" }, "pm10SourceDataDate": "2020-07-31 14:00:00", "pm25CalcDate": "2020-07-31 15:10:17", - "pm25IndexLevel": { "id": 0, "indexLevelName": "dobry" }, + "pm25IndexLevel": { "id": 0, "indexLevelName": "Dobry" }, "pm25SourceDataDate": "2020-07-31 14:00:00", "o3CalcDate": "2020-07-31 15:10:17", - "o3IndexLevel": { "id": 1, "indexLevelName": "dobry" }, + "o3IndexLevel": { "id": 1, "indexLevelName": "Dobry" }, "o3SourceDataDate": "2020-07-31 14:00:00", "c6h6CalcDate": "2020-07-31 15:10:17", - "c6h6IndexLevel": { "id": 0, "indexLevelName": "bardzo dobry" }, + "c6h6IndexLevel": { "id": 0, "indexLevelName": "Bardzo dobry" }, "c6h6SourceDataDate": "2020-07-31 14:00:00", "stIndexStatus": true, "stIndexCrParam": "OZON" diff --git a/tests/components/gios/test_sensor.py b/tests/components/gios/test_sensor.py index 9dbdda7d2e..c5b19502a0 100644 --- a/tests/components/gios/test_sensor.py +++ b/tests/components/gios/test_sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.gios.const import ( DOMAIN, ) from homeassistant.components.sensor import ( + ATTR_OPTIONS, ATTR_STATE_CLASS, DOMAIN as PLATFORM, SensorDeviceClass, @@ -50,7 +51,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) assert state.attributes.get(ATTR_ICON) == "mdi:molecule" - assert state.attributes.get(ATTR_INDEX) == "bardzo dobry" + assert state.attributes.get(ATTR_INDEX) == "very_good" entry = registry.async_get("sensor.home_c6h6") assert entry @@ -67,7 +68,7 @@ async def test_sensor(hass: HomeAssistant) -> None: state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_INDEX) == "dobry" + assert state.attributes.get(ATTR_INDEX) == "good" entry = registry.async_get("sensor.home_co") assert entry @@ -84,7 +85,7 @@ async def test_sensor(hass: HomeAssistant) -> None: state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_INDEX) == "dobry" + assert state.attributes.get(ATTR_INDEX) == "good" entry = registry.async_get("sensor.home_no2") assert entry @@ -101,7 +102,7 @@ async def test_sensor(hass: HomeAssistant) -> None: state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_INDEX) == "dobry" + assert state.attributes.get(ATTR_INDEX) == "good" entry = registry.async_get("sensor.home_o3") assert entry @@ -118,7 +119,7 @@ async def test_sensor(hass: HomeAssistant) -> None: state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_INDEX) == "dobry" + assert state.attributes.get(ATTR_INDEX) == "good" entry = registry.async_get("sensor.home_pm10") assert entry @@ -135,7 +136,7 @@ async def test_sensor(hass: HomeAssistant) -> None: state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_INDEX) == "dobry" + assert state.attributes.get(ATTR_INDEX) == "good" entry = registry.async_get("sensor.home_pm2_5") assert entry @@ -152,7 +153,7 @@ async def test_sensor(hass: HomeAssistant) -> None: state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_INDEX) == "bardzo dobry" + assert state.attributes.get(ATTR_INDEX) == "very_good" entry = registry.async_get("sensor.home_so2") assert entry @@ -160,11 +161,19 @@ async def test_sensor(hass: HomeAssistant) -> None: state = hass.states.get("sensor.home_aqi") assert state - assert state.state == "dobry" + assert state.state == "good" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_STATION) == "Test Name 1" assert state.attributes.get(ATTR_STATE_CLASS) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert state.attributes.get(ATTR_OPTIONS) == [ + "very_bad", + "bad", + "sufficient", + "moderate", + "good", + "very_good", + ] entry = registry.async_get("sensor.home_aqi") assert entry @@ -342,7 +351,7 @@ async def test_aqi_sensor_availability(hass: HomeAssistant) -> None: state = hass.states.get("sensor.home_aqi") assert state assert state.state != STATE_UNAVAILABLE - assert state.state == "dobry" + assert state.state == "good" future = utcnow() + timedelta(minutes=60) with patch( From 83fa4c6c600135de7106b8d247e6b78912c4aa8c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Mar 2023 07:49:54 -1000 Subject: [PATCH 0688/2810] 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 fde8c26ba5..e8e4e4876f 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 1c7d0f4687..327edb7c99 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 b103783ce9..6f46d5e74a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -143,7 +143,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.4.1 +aioesphomeapi==13.4.2 # homeassistant.components.flo aioflo==2021.11.0 From 84034959baa69cdc03b74e0bda1186b5121bd580 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 6 Mar 2023 21:54:34 +0100 Subject: [PATCH 0689/2810] Improve reolink generic typing (#88786) Co-authored-by: starkillerOG --- homeassistant/components/reolink/__init__.py | 9 ++--- .../components/reolink/binary_sensor.py | 4 +-- homeassistant/components/reolink/button.py | 4 +-- homeassistant/components/reolink/camera.py | 6 ++-- homeassistant/components/reolink/entity.py | 35 ++++++++++++------- homeassistant/components/reolink/light.py | 4 +-- homeassistant/components/reolink/number.py | 4 +-- homeassistant/components/reolink/select.py | 4 +-- homeassistant/components/reolink/siren.py | 4 +-- homeassistant/components/reolink/switch.py | 6 ++-- homeassistant/components/reolink/update.py | 9 +++-- 11 files changed, 49 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 94d4c1561a..c3d8df61f5 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -6,6 +6,7 @@ import asyncio from dataclasses import dataclass from datetime import timedelta import logging +from typing import Literal from aiohttp import ClientConnectorError import async_timeout @@ -43,8 +44,8 @@ class ReolinkData: """Data for the Reolink integration.""" host: ReolinkHost - device_coordinator: DataUpdateCoordinator - firmware_coordinator: DataUpdateCoordinator + device_coordinator: DataUpdateCoordinator[None] + firmware_coordinator: DataUpdateCoordinator[str | Literal[False]] async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: @@ -74,7 +75,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, host.stop) ) - async def async_device_config_update(): + async def async_device_config_update() -> None: """Update the host state cache and renew the ONVIF-subscription.""" async with async_timeout.timeout(host.api.timeout): try: @@ -87,7 +88,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async with async_timeout.timeout(host.api.timeout): await host.renew() - async def async_check_firmware_update(): + async def async_check_firmware_update() -> str | Literal[False]: """Check for firmware updates.""" if not host.api.supported(None, "update"): return False diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 3c97087c89..1a7649f367 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -24,7 +24,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity @dataclass @@ -113,7 +113,7 @@ async def async_setup_entry( async_add_entities(entities) -class ReolinkBinarySensorEntity(ReolinkCoordinatorEntity, BinarySensorEntity): +class ReolinkBinarySensorEntity(ReolinkChannelCoordinatorEntity, BinarySensorEntity): """Base binary-sensor class for Reolink IP camera motion sensors.""" entity_description: ReolinkBinarySensorEntityDescription diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index 528eb8c740..65bb8036c0 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -15,7 +15,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity @dataclass @@ -112,7 +112,7 @@ async def async_setup_entry( ) -class ReolinkButtonEntity(ReolinkCoordinatorEntity, ButtonEntity): +class ReolinkButtonEntity(ReolinkChannelCoordinatorEntity, ButtonEntity): """Base button entity class for Reolink IP cameras.""" entity_description: ReolinkButtonEntityDescription diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index 4a270d6f5a..13471df339 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -10,7 +10,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity _LOGGER = logging.getLogger(__name__) @@ -39,7 +39,7 @@ async def async_setup_entry( async_add_entities(cameras) -class ReolinkCamera(ReolinkCoordinatorEntity, Camera): +class ReolinkCamera(ReolinkChannelCoordinatorEntity, Camera): """An implementation of a Reolink IP camera.""" _attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM @@ -51,7 +51,7 @@ class ReolinkCamera(ReolinkCoordinatorEntity, Camera): stream: str, ) -> None: """Initialize Reolink camera stream.""" - ReolinkCoordinatorEntity.__init__(self, reolink_data, channel) + ReolinkChannelCoordinatorEntity.__init__(self, reolink_data, channel) Camera.__init__(self) self._stream = stream diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 5f983ab349..3a962d099d 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -1,6 +1,8 @@ """Reolink parent entity class.""" from __future__ import annotations +from typing import TypeVar + from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( @@ -11,24 +13,20 @@ from homeassistant.helpers.update_coordinator import ( from . import ReolinkData from .const import DOMAIN +_T = TypeVar("_T") -class ReolinkBaseCoordinatorEntity(CoordinatorEntity): - """Parent class for entities that control the Reolink NVR itself, without a channel. - A camera connected directly to HomeAssistant without using a NVR is in the reolink API - basically a NVR with a single channel that has the camera connected to that channel. - """ +class ReolinkBaseCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[_T]]): + """Parent class fo Reolink entities.""" _attr_has_entity_name = True def __init__( self, reolink_data: ReolinkData, - coordinator: DataUpdateCoordinator | None = None, + coordinator: DataUpdateCoordinator[_T], ) -> None: - """Initialize ReolinkBaseCoordinatorEntity for a NVR entity without a channel.""" - if coordinator is None: - coordinator = reolink_data.device_coordinator + """Initialize ReolinkBaseCoordinatorEntity.""" super().__init__(coordinator) self._host = reolink_data.host @@ -52,17 +50,28 @@ class ReolinkBaseCoordinatorEntity(CoordinatorEntity): return self._host.api.session_active and super().available -class ReolinkCoordinatorEntity(ReolinkBaseCoordinatorEntity): +class ReolinkHostCoordinatorEntity(ReolinkBaseCoordinatorEntity[None]): + """Parent class for entities that control the Reolink NVR itself, without a channel. + + A camera connected directly to HomeAssistant without using a NVR is in the reolink API + basically a NVR with a single channel that has the camera connected to that channel. + """ + + def __init__(self, reolink_data: ReolinkData) -> None: + """Initialize ReolinkHostCoordinatorEntity.""" + super().__init__(reolink_data, reolink_data.device_coordinator) + + +class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): """Parent class for Reolink hardware camera entities connected to a channel of the NVR.""" def __init__( self, reolink_data: ReolinkData, channel: int, - coordinator: DataUpdateCoordinator | None = None, ) -> None: - """Initialize ReolinkCoordinatorEntity for a hardware camera connected to a channel of the NVR.""" - super().__init__(reolink_data, coordinator) + """Initialize ReolinkChannelCoordinatorEntity for a hardware camera connected to a channel of the NVR.""" + super().__init__(reolink_data) self._channel = channel diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index dd71f91bb0..c4923c0088 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity @dataclass @@ -89,7 +89,7 @@ async def async_setup_entry( ) -class ReolinkLightEntity(ReolinkCoordinatorEntity, LightEntity): +class ReolinkLightEntity(ReolinkChannelCoordinatorEntity, LightEntity): """Base light entity class for Reolink IP cameras.""" entity_description: ReolinkLightEntityDescription diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 82c1924e27..7c50bfa9f0 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity @dataclass @@ -194,7 +194,7 @@ async def async_setup_entry( ) -class ReolinkNumberEntity(ReolinkCoordinatorEntity, NumberEntity): +class ReolinkNumberEntity(ReolinkChannelCoordinatorEntity, NumberEntity): """Base number entity class for Reolink IP cameras.""" entity_description: ReolinkNumberEntityDescription diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 8df4afba73..c7bd621a4b 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -15,7 +15,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity @dataclass @@ -86,7 +86,7 @@ async def async_setup_entry( ) -class ReolinkSelectEntity(ReolinkCoordinatorEntity, SelectEntity): +class ReolinkSelectEntity(ReolinkChannelCoordinatorEntity, SelectEntity): """Base select entity class for Reolink IP cameras.""" entity_description: ReolinkSelectEntityDescription diff --git a/homeassistant/components/reolink/siren.py b/homeassistant/components/reolink/siren.py index f2b27dda4d..405c3e2716 100644 --- a/homeassistant/components/reolink/siren.py +++ b/homeassistant/components/reolink/siren.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity @dataclass @@ -56,7 +56,7 @@ async def async_setup_entry( ) -class ReolinkSirenEntity(ReolinkCoordinatorEntity, SirenEntity): +class ReolinkSirenEntity(ReolinkChannelCoordinatorEntity, SirenEntity): """Base siren entity class for Reolink IP cameras.""" _attr_supported_features = ( diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index 64d6155485..a7ed9b6a98 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -15,7 +15,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkBaseCoordinatorEntity, ReolinkCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkHostCoordinatorEntity @dataclass @@ -172,7 +172,7 @@ async def async_setup_entry( async_add_entities(entities) -class ReolinkSwitchEntity(ReolinkCoordinatorEntity, SwitchEntity): +class ReolinkSwitchEntity(ReolinkChannelCoordinatorEntity, SwitchEntity): """Base switch entity class for Reolink IP cameras.""" entity_description: ReolinkSwitchEntityDescription @@ -207,7 +207,7 @@ class ReolinkSwitchEntity(ReolinkCoordinatorEntity, SwitchEntity): self.async_write_ha_state() -class ReolinkNVRSwitchEntity(ReolinkBaseCoordinatorEntity, SwitchEntity): +class ReolinkNVRSwitchEntity(ReolinkHostCoordinatorEntity, SwitchEntity): """Switch entity class for Reolink NVR features.""" entity_description: ReolinkNVRSwitchEntityDescription diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py index 5752afc92a..aeb44cb774 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import Any, Literal from reolink_aio.exceptions import ReolinkError @@ -34,7 +34,9 @@ async def async_setup_entry( async_add_entities([ReolinkUpdateEntity(reolink_data)]) -class ReolinkUpdateEntity(ReolinkBaseCoordinatorEntity, UpdateEntity): +class ReolinkUpdateEntity( + ReolinkBaseCoordinatorEntity[str | Literal[False]], UpdateEntity +): """Update entity for a Netgear device.""" _attr_device_class = UpdateDeviceClass.FIRMWARE @@ -59,9 +61,6 @@ class ReolinkUpdateEntity(ReolinkBaseCoordinatorEntity, UpdateEntity): @property def latest_version(self) -> str | None: """Latest version available for install.""" - if self.coordinator.data is None: - return None - if not self.coordinator.data: return self.installed_version From 7972dbf9fb1666e35b2ea92725170aeee07bc97e Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Mon, 6 Mar 2023 22:48:40 +0100 Subject: [PATCH 0690/2810] 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 fb122a5a0e..46d78113ab 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1101,6 +1101,7 @@ build.json @home-assistant/supervisor /homeassistant/components/smhi/ @gjohansson-ST /tests/components/smhi/ @gjohansson-ST /homeassistant/components/sms/ @ocalvo +/homeassistant/components/snapcast/ @luar123 /homeassistant/components/snooz/ @AustinBrunkhorst /tests/components/snooz/ @AustinBrunkhorst /homeassistant/components/solaredge/ @frenck diff --git a/homeassistant/components/snapcast/manifest.json b/homeassistant/components/snapcast/manifest.json index d69f06f698..bdcadc84e7 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 327edb7c99..355cd9c4b1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2367,7 +2367,7 @@ smart-meter-texas==0.4.7 smhi-pkg==1.0.16 # homeassistant.components.snapcast -snapcast==2.3.0 +snapcast==2.3.2 # homeassistant.components.sonos soco==0.29.1 From 5ccaa549d10d5529fe5af9d92f6102e2a1322e73 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Mar 2023 14:04:10 -1000 Subject: [PATCH 0691/2810] Bump aioesphomeapi to 13.5.0 (#89262) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index e8e4e4876f..54cdc23355 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -14,6 +14,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], - "requirements": ["aioesphomeapi==13.4.2", "esphome-dashboard-api==1.2.3"], + "requirements": ["aioesphomeapi==13.5.0", "esphome-dashboard-api==1.2.3"], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 355cd9c4b1..cc8fa98d2f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -156,7 +156,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.4.2 +aioesphomeapi==13.5.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6f46d5e74a..1c5ed55bc6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -143,7 +143,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.4.2 +aioesphomeapi==13.5.0 # homeassistant.components.flo aioflo==2021.11.0 From ee89922c1b8c86786e8d0fe0993b63975586b82c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Mar 2023 14:24:35 -1000 Subject: [PATCH 0692/2810] Add support for bluetooth pairing in esphome (#88603) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- .../components/esphome/bluetooth/client.py | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index 545a436ee8..6ec51cabeb 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -43,6 +43,7 @@ CCCD_NOTIFY_BYTES = b"\x01\x00" CCCD_INDICATE_BYTES = b"\x02\x00" MIN_BLUETOOTH_PROXY_VERSION_HAS_CACHE = 3 +MIN_BLUETOOTH_PROXY_HAS_PAIRING = 4 DEFAULT_MAX_WRITE_WITHOUT_RESPONSE = DEFAULT_MTU - GATT_HEADER_SIZE _LOGGER = logging.getLogger(__name__) @@ -386,13 +387,33 @@ class ESPHomeClient(BaseBleakClient): @api_error_as_bleak_error async def pair(self, *args: Any, **kwargs: Any) -> bool: """Attempt to pair.""" - raise NotImplementedError("Pairing is not available in ESPHome.") + if self._connection_version < MIN_BLUETOOTH_PROXY_HAS_PAIRING: + raise NotImplementedError( + "Pairing is not available in ESPHome with version {self._connection_version}." + ) + response = await self._client.bluetooth_device_pair(self._address_as_int) + if response.paired: + return True + _LOGGER.error( + "Pairing with %s failed due to error: %s", self.address, response.error + ) + return False @verify_connected @api_error_as_bleak_error async def unpair(self) -> bool: """Attempt to unpair.""" - raise NotImplementedError("Pairing is not available in ESPHome.") + if self._connection_version < MIN_BLUETOOTH_PROXY_HAS_PAIRING: + raise NotImplementedError( + "Unpairing is not available in ESPHome with version {self._connection_version}." + ) + response = await self._client.bluetooth_device_unpair(self._address_as_int) + if response.success: + return True + _LOGGER.error( + "Unpairing with %s failed due to error: %s", self.address, response.error + ) + return False @api_error_as_bleak_error async def get_services( From 9672b5f02cd453f0ea4b1f6a6a32f47098ad87ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Mar 2023 15:20:37 -1000 Subject: [PATCH 0693/2810] 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 f40f866808..ed885127b1 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 e3efa81e44..bdedbb9b20 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 5ce5203364..915364219d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -42,7 +42,7 @@ pyudev==0.23.2 pyyaml==6.0 requests==2.28.2 scapy==2.5.0 -sqlalchemy==2.0.4 +sqlalchemy==2.0.5.post1 typing-extensions>=4.5.0,<5.0 ulid-transform==0.4.0 voluptuous-serialize==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index cc8fa98d2f..5a9e407710 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 1c5ed55bc6..1821c6b239 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1704,7 +1704,7 @@ spotipy==2.22.1 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==2.0.4 +sqlalchemy==2.0.5.post1 # homeassistant.components.srp_energy srpenergy==1.3.6 From 3c70dd9b425fdab979795c68bd86f6bff4b4e8f7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Mar 2023 15:44:11 -1000 Subject: [PATCH 0694/2810] 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 fb1a55cebf..b67790f9a4 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 fee7be443b..4cc6e40fa6 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 dd51946c86..e6ae291264 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -8,7 +8,7 @@ import sys from unittest.mock import ANY, DEFAULT, MagicMock, patch, sentinel import pytest -from sqlalchemy import create_engine +from sqlalchemy import create_engine, select from sqlalchemy.exc import OperationalError from sqlalchemy.orm import Session @@ -22,6 +22,10 @@ from homeassistant.components.recorder.models import ( ) from homeassistant.components.recorder.statistics import ( STATISTIC_UNIT_TO_UNIT_CONVERTER, + _generate_get_metadata_stmt, + _generate_max_mean_min_statistic_in_sub_period_stmt, + _generate_statistics_at_time_stmt, + _generate_statistics_during_period_stmt, _statistics_during_period_with_session, _update_or_add_metadata, async_add_external_statistics, @@ -1799,3 +1803,100 @@ def record_states(hass): states[sns4].append(set_state(sns4, "20", attributes=sns4_attr)) return zero, four, states + + +def test_cache_key_for_generate_statistics_during_period_stmt(): + """Test cache key for _generate_statistics_during_period_stmt.""" + columns = select(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start_ts) + stmt = _generate_statistics_during_period_stmt( + columns, dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm, {} + ) + cache_key_1 = stmt._generate_cache_key() + stmt2 = _generate_statistics_during_period_stmt( + columns, dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm, {} + ) + cache_key_2 = stmt2._generate_cache_key() + assert cache_key_1 == cache_key_2 + columns2 = select( + StatisticsShortTerm.metadata_id, + StatisticsShortTerm.start_ts, + StatisticsShortTerm.sum, + StatisticsShortTerm.mean, + ) + stmt3 = _generate_statistics_during_period_stmt( + columns2, + dt_util.utcnow(), + dt_util.utcnow(), + [0], + StatisticsShortTerm, + {"max", "mean"}, + ) + cache_key_3 = stmt3._generate_cache_key() + assert cache_key_1 != cache_key_3 + + +def test_cache_key_for_generate_get_metadata_stmt(): + """Test cache key for _generate_get_metadata_stmt.""" + stmt_mean = _generate_get_metadata_stmt([0], "mean") + stmt_mean2 = _generate_get_metadata_stmt([1], "mean") + stmt_sum = _generate_get_metadata_stmt([0], "sum") + stmt_none = _generate_get_metadata_stmt() + assert stmt_mean._generate_cache_key() == stmt_mean2._generate_cache_key() + assert stmt_mean._generate_cache_key() != stmt_sum._generate_cache_key() + assert stmt_mean._generate_cache_key() != stmt_none._generate_cache_key() + + +def test_cache_key_for_generate_max_mean_min_statistic_in_sub_period_stmt(): + """Test cache key for _generate_max_mean_min_statistic_in_sub_period_stmt.""" + columns = select(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start_ts) + stmt = _generate_max_mean_min_statistic_in_sub_period_stmt( + columns, + dt_util.utcnow(), + dt_util.utcnow(), + StatisticsShortTerm, + [0], + ) + cache_key_1 = stmt._generate_cache_key() + stmt2 = _generate_max_mean_min_statistic_in_sub_period_stmt( + columns, + dt_util.utcnow(), + dt_util.utcnow(), + StatisticsShortTerm, + [0], + ) + cache_key_2 = stmt2._generate_cache_key() + assert cache_key_1 == cache_key_2 + columns2 = select( + StatisticsShortTerm.metadata_id, + StatisticsShortTerm.start_ts, + StatisticsShortTerm.sum, + StatisticsShortTerm.mean, + ) + stmt3 = _generate_max_mean_min_statistic_in_sub_period_stmt( + columns2, + dt_util.utcnow(), + dt_util.utcnow(), + StatisticsShortTerm, + [0], + ) + cache_key_3 = stmt3._generate_cache_key() + assert cache_key_1 != cache_key_3 + + +def test_cache_key_for_generate_statistics_at_time_stmt(): + """Test cache key for _generate_statistics_at_time_stmt.""" + columns = select(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start_ts) + stmt = _generate_statistics_at_time_stmt(columns, StatisticsShortTerm, {0}, 0.0) + cache_key_1 = stmt._generate_cache_key() + stmt2 = _generate_statistics_at_time_stmt(columns, StatisticsShortTerm, {0}, 0.0) + cache_key_2 = stmt2._generate_cache_key() + assert cache_key_1 == cache_key_2 + columns2 = select( + StatisticsShortTerm.metadata_id, + StatisticsShortTerm.start_ts, + StatisticsShortTerm.sum, + StatisticsShortTerm.mean, + ) + stmt3 = _generate_statistics_at_time_stmt(columns2, StatisticsShortTerm, {0}, 0.0) + cache_key_3 = stmt3._generate_cache_key() + assert cache_key_1 != cache_key_3 From 755c44d1525ab8fe185e5f068513f3f8205d9ef8 Mon Sep 17 00:00:00 2001 From: Doney den Ouden Date: Tue, 7 Mar 2023 05:07:43 +0100 Subject: [PATCH 0695/2810] Add HomeKit Door accessory type (#80741) Co-authored-by: Jason Redd Co-authored-by: J. Nick Koston --- .../components/homekit/accessories.py | 5 ++ homeassistant/components/homekit/const.py | 1 + .../components/homekit/type_covers.py | 14 +++++ .../homekit/test_get_accessories.py | 9 ++++ tests/components/homekit/test_type_covers.py | 53 +++++++++++++++++++ 5 files changed, 82 insertions(+) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index adab539fb3..dc8a2a7c63 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -148,6 +148,11 @@ def get_accessory( # noqa: C901 and features & CoverEntityFeature.SET_POSITION ): a_type = "Window" + elif ( + device_class == CoverDeviceClass.DOOR + and features & CoverEntityFeature.SET_POSITION + ): + a_type = "Door" elif features & CoverEntityFeature.SET_POSITION: a_type = "WindowCovering" elif features & (CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE): diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 58e1e13a3f..4517f9c5a5 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -119,6 +119,7 @@ SERV_CAMERA_RTP_STREAM_MANAGEMENT = "CameraRTPStreamManagement" SERV_CARBON_DIOXIDE_SENSOR = "CarbonDioxideSensor" SERV_CARBON_MONOXIDE_SENSOR = "CarbonMonoxideSensor" SERV_CONTACT_SENSOR = "ContactSensor" +SERV_DOOR = "Door" SERV_DOORBELL = "Doorbell" SERV_FANV2 = "Fanv2" SERV_GARAGE_DOOR_OPENER = "GarageDoorOpener" diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index 4b21bfb77d..05feb58057 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -2,6 +2,7 @@ import logging from pyhap.const import ( + CATEGORY_DOOR, CATEGORY_GARAGE_DOOR_OPENER, CATEGORY_WINDOW, CATEGORY_WINDOW_COVERING, @@ -54,6 +55,7 @@ from .const import ( HK_POSITION_STOPPED, PROP_MAX_VALUE, PROP_MIN_VALUE, + SERV_DOOR, SERV_GARAGE_DOOR_OPENER, SERV_WINDOW, SERV_WINDOW_COVERING, @@ -323,6 +325,18 @@ class OpeningDevice(OpeningDeviceBase, HomeAccessory): super().async_update_state(new_state) +@TYPES.register("Door") +class Door(OpeningDevice): + """Generate a Door accessory for a cover entity. + + The entity must support: set_cover_position. + """ + + def __init__(self, *args): + """Initialize a Door accessory object.""" + super().__init__(*args, category=CATEGORY_DOOR, service=SERV_DOOR) + + @TYPES.register("Window") class Window(OpeningDevice): """Generate a Window accessory for a cover entity with WINDOW device class. diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index b5d65993a8..08a7f8a220 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -160,6 +160,15 @@ def test_types(type_name, entity_id, state, attrs, config) -> None: ) }, ), + ( + "Door", + "cover.door", + "open", + { + ATTR_DEVICE_CLASS: "door", + ATTR_SUPPORTED_FEATURES: cover.SUPPORT_SET_POSITION, + }, + ), ], ) def test_type_covers(type_name, entity_id, state, attrs) -> None: diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index e1c547315c..9da576b6a0 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -19,6 +19,7 @@ from homeassistant.components.homekit.const import ( PROP_MIN_VALUE, ) from homeassistant.components.homekit.type_covers import ( + Door, GarageDoorOpener, Window, WindowCovering, @@ -128,6 +129,58 @@ async def test_garage_door_open_close(hass: HomeAssistant, hk_driver, events) -> assert events[-1].data[ATTR_VALUE] is None +async def test_door_instantiate_set_position( + hass: HomeAssistant, hk_driver, events +) -> None: + """Test if Door accessory is instantiated correctly and can set position.""" + entity_id = "cover.door" + + hass.states.async_set( + entity_id, + STATE_OPEN, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION, + ATTR_CURRENT_POSITION: 0, + }, + ) + await hass.async_block_till_done() + acc = Door(hass, hk_driver, "Door", entity_id, 2, None) + await acc.run() + await hass.async_block_till_done() + + assert acc.aid == 2 + assert acc.category == 12 # Door + + assert acc.char_current_position.value == 0 + assert acc.char_target_position.value == 0 + + hass.states.async_set( + entity_id, + STATE_OPEN, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION, + ATTR_CURRENT_POSITION: 50, + }, + ) + await hass.async_block_till_done() + assert acc.char_current_position.value == 50 + assert acc.char_target_position.value == 50 + assert acc.char_position_state.value == 2 + + hass.states.async_set( + entity_id, + STATE_OPEN, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION, + ATTR_CURRENT_POSITION: "GARBAGE", + }, + ) + await hass.async_block_till_done() + assert acc.char_current_position.value == 50 + assert acc.char_target_position.value == 50 + assert acc.char_position_state.value == 2 + + async def test_windowcovering_set_cover_position( hass: HomeAssistant, hk_driver, events ) -> None: From 85bcf11aeb8e7af8045ca2081411b548c8e8a7a1 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Tue, 7 Mar 2023 09:22:31 +0000 Subject: [PATCH 0696/2810] Update systembridgeconnector to 3.4.8 (#79732) Co-authored-by: Franck Nijhof --- .../components/system_bridge/manifest.json | 2 +- .../components/system_bridge/sensor.py | 69 ++++++++++--------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 39 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json index 6146a32fd8..7462966ae3 100644 --- a/homeassistant/components/system_bridge/manifest.json +++ b/homeassistant/components/system_bridge/manifest.json @@ -10,6 +10,6 @@ "iot_class": "local_push", "loggers": ["systembridgeconnector"], "quality_scale": "silver", - "requirements": ["systembridgeconnector==3.4.4"], + "requirements": ["systembridgeconnector==3.4.8"], "zeroconf": ["_system-bridge._tcp.local."] } diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index bc02c9f1cd..eb835b2c95 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -51,8 +51,8 @@ class SystemBridgeSensorEntityDescription(SensorEntityDescription): def battery_time_remaining(data: SystemBridgeCoordinatorData) -> datetime | None: """Return the battery time remaining.""" - if data.battery.sensors_secsleft is not None: - return utcnow() + timedelta(seconds=data.battery.sensors_secsleft) + if (value := getattr(data.battery, "sensors_secsleft", None)) is not None: + return utcnow() + timedelta(seconds=value) return None @@ -65,29 +65,29 @@ def cpu_speed(data: SystemBridgeCoordinatorData) -> float | None: def gpu_core_clock_speed(data: SystemBridgeCoordinatorData, key: str) -> float | None: """Return the GPU core clock speed.""" - if getattr(data.gpu, f"{key}_core_clock") is not None: - return round(getattr(data.gpu, f"{key}_core_clock")) + if (value := getattr(data.gpu, f"{key}_core_clock", None)) is not None: + return round(value) return None def gpu_memory_clock_speed(data: SystemBridgeCoordinatorData, key: str) -> float | None: """Return the GPU memory clock speed.""" - if getattr(data.gpu, f"{key}_memory_clock") is not None: - return round(getattr(data.gpu, f"{key}_memory_clock")) + if (value := getattr(data.gpu, f"{key}_memory_clock", None)) is not None: + return round(value) return None def gpu_memory_free(data: SystemBridgeCoordinatorData, key: str) -> float | None: """Return the free GPU memory.""" - if getattr(data.gpu, f"{key}_memory_free") is not None: - return round(getattr(data.gpu, f"{key}_memory_free") / 10**3, 2) + if (value := getattr(data.gpu, f"{key}_memory_free", None)) is not None: + return round(value) return None def gpu_memory_used(data: SystemBridgeCoordinatorData, key: str) -> float | None: """Return the used GPU memory.""" - if getattr(data.gpu, f"{key}_memory_used") is not None: - return round(getattr(data.gpu, f"{key}_memory_used") / 10**3, 2) + if (value := getattr(data.gpu, f"{key}_memory_used", None)) is not None: + return round(value) return None @@ -95,14 +95,11 @@ def gpu_memory_used_percentage( data: SystemBridgeCoordinatorData, key: str ) -> float | None: """Return the used GPU memory percentage.""" - if ( - getattr(data.gpu, f"{key}_memory_used") is not None - and getattr(data.gpu, f"{key}_memory_total") is not None + if ((used := getattr(data.gpu, f"{key}_memory_used", None)) is not None) and ( + (total := getattr(data.gpu, f"{key}_memory_total", None)) is not None ): return round( - getattr(data.gpu, f"{key}_memory_used") - / getattr(data.gpu, f"{key}_memory_total") - * 100, + used / total * 100, 2, ) return None @@ -266,7 +263,7 @@ async def async_setup_entry( native_unit_of_measurement=PERCENTAGE, icon="mdi:harddisk", value=lambda data, p=partition: getattr( - data.disk, f"usage_{p}_percent" + data.disk, f"usage_{p}_percent", None ), ), entry.data[CONF_PORT], @@ -283,15 +280,17 @@ async def async_setup_entry( SystemBridgeSensor(coordinator, description, entry.data[CONF_PORT]) ) - displays = [] - for display in coordinator.data.display.displays: - displays.append( + displays: list[dict[str, str]] = [] + if coordinator.data.display.displays is not None: + displays.extend( { "key": display, "name": getattr(coordinator.data.display, f"{display}_name").replace( "Display ", "" ), - }, + } + for display in coordinator.data.display.displays + if hasattr(coordinator.data.display, f"{display}_name") ) display_count = len(displays) @@ -321,7 +320,7 @@ async def async_setup_entry( native_unit_of_measurement=PIXELS, icon="mdi:monitor", value=lambda data, k=display["key"]: getattr( - data.display, f"{k}_resolution_horizontal" + data.display, f"{k}_resolution_horizontal", None ), ), entry.data[CONF_PORT], @@ -335,7 +334,7 @@ async def async_setup_entry( native_unit_of_measurement=PIXELS, icon="mdi:monitor", value=lambda data, k=display["key"]: getattr( - data.display, f"{k}_resolution_vertical" + data.display, f"{k}_resolution_vertical", None ), ), entry.data[CONF_PORT], @@ -350,20 +349,22 @@ async def async_setup_entry( device_class=SensorDeviceClass.FREQUENCY, icon="mdi:monitor", value=lambda data, k=display["key"]: getattr( - data.display, f"{k}_refresh_rate" + data.display, f"{k}_refresh_rate", None ), ), entry.data[CONF_PORT], ), ] - gpus = [] - for gpu in coordinator.data.gpu.gpus: - gpus.append( + gpus: list[dict[str, str]] = [] + if coordinator.data.gpu.gpus is not None: + gpus.extend( { "key": gpu, "name": getattr(coordinator.data.gpu, f"{gpu}_name"), - }, + } + for gpu in coordinator.data.gpu.gpus + if hasattr(coordinator.data.gpu, f"{gpu}_name") ) for index, gpu in enumerate(gpus): @@ -448,7 +449,7 @@ async def async_setup_entry( native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, icon="mdi:fan", value=lambda data, k=gpu["key"]: getattr( - data.gpu, f"{k}_fan_speed" + data.gpu, f"{k}_fan_speed", None ), ), entry.data[CONF_PORT], @@ -462,7 +463,9 @@ async def async_setup_entry( device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, - value=lambda data, k=gpu["key"]: getattr(data.gpu, f"{k}_power"), + value=lambda data, k=gpu["key"]: getattr( + data.gpu, f"{k}_power", None + ), ), entry.data[CONF_PORT], ), @@ -476,7 +479,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, value=lambda data, k=gpu["key"]: getattr( - data.gpu, f"{k}_temperature" + data.gpu, f"{k}_temperature", None ), ), entry.data[CONF_PORT], @@ -490,7 +493,7 @@ async def async_setup_entry( native_unit_of_measurement=PERCENTAGE, icon="mdi:percent", value=lambda data, k=gpu["key"]: getattr( - data.gpu, f"{k}_core_load" + data.gpu, f"{k}_core_load", None ), ), entry.data[CONF_PORT], @@ -509,7 +512,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:percent", - value=lambda data, k=index: getattr(data.cpu, f"usage_{k}"), + value=lambda data, k=index: getattr(data.cpu, f"usage_{k}", None), ), entry.data[CONF_PORT], ), diff --git a/requirements_all.txt b/requirements_all.txt index 5a9e407710..0ca58159d0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2449,7 +2449,7 @@ swisshydrodata==0.1.0 synology-srm==0.2.0 # homeassistant.components.system_bridge -systembridgeconnector==3.4.4 +systembridgeconnector==3.4.8 # homeassistant.components.tailscale tailscale==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1821c6b239..7d86bf8a0a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1743,7 +1743,7 @@ sunwatcher==0.2.1 surepy==0.8.0 # homeassistant.components.system_bridge -systembridgeconnector==3.4.4 +systembridgeconnector==3.4.8 # homeassistant.components.tailscale tailscale==0.2.0 From c51bde9a26ec3a596cdd926a2c9d5ed148944dfa Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Mar 2023 11:35:48 +0100 Subject: [PATCH 0697/2810] Fail CI on lingering tasks (#88905) --- tests/components/github/test_diagnostics.py | 5 +++++ tests/components/github/test_init.py | 6 ++++++ tests/components/github/test_sensor.py | 4 ++++ tests/components/insteon/test_api_aldb.py | 20 +++++++++++++++++++ tests/components/insteon/test_api_device.py | 3 +++ tests/components/insteon/test_api_scenes.py | 12 +++++++++++ tests/components/knx/test_climate.py | 12 +++++++++++ tests/components/knx/test_cover.py | 4 ++++ tests/components/lcn/test_events.py | 3 +++ tests/components/matter/test_adapter.py | 6 ++++++ tests/components/matter/test_api.py | 9 +++++++++ tests/components/matter/test_binary_sensor.py | 4 ++++ tests/components/matter/test_diagnostics.py | 4 ++++ tests/components/matter/test_helpers.py | 4 ++++ tests/components/matter/test_init.py | 8 ++++++++ tests/components/matter/test_light.py | 8 ++++++++ tests/components/matter/test_sensor.py | 12 +++++++++++ tests/components/matter/test_switch.py | 4 ++++ tests/components/nest/test_api.py | 4 ++++ tests/components/opentherm_gw/test_init.py | 5 +++++ tests/components/plex/test_config_flow.py | 6 ++++++ tests/components/snooz/test_fan.py | 4 ++++ tests/components/snooz/test_init.py | 6 ++++++ tests/conftest.py | 19 ++++++++++++++++-- 24 files changed, 170 insertions(+), 2 deletions(-) diff --git a/tests/components/github/test_diagnostics.py b/tests/components/github/test_diagnostics.py index f1c40a1fd1..4bd7563e74 100644 --- a/tests/components/github/test_diagnostics.py +++ b/tests/components/github/test_diagnostics.py @@ -3,6 +3,7 @@ import json from aiogithubapi import GitHubException +import pytest from homeassistant.components.github.const import CONF_REPOSITORIES, DOMAIN from homeassistant.core import HomeAssistant @@ -15,6 +16,8 @@ from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -54,6 +57,8 @@ async def test_entry_diagnostics( ) +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_entry_diagnostics_exception( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/github/test_init.py b/tests/components/github/test_init.py index 6cb9539bbd..f4557632d6 100644 --- a/tests/components/github/test_init.py +++ b/tests/components/github/test_init.py @@ -11,6 +11,8 @@ from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_device_registry_cleanup( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -46,6 +48,8 @@ async def test_device_registry_cleanup( assert len(devices) == 0 +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_subscription_setup( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -61,6 +65,8 @@ async def test_subscription_setup( ) +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_subscription_setup_polling_disabled( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/github/test_sensor.py b/tests/components/github/test_sensor.py index 892fb8956e..60574c5b60 100644 --- a/tests/components/github/test_sensor.py +++ b/tests/components/github/test_sensor.py @@ -1,6 +1,8 @@ """Test GitHub sensor.""" import json +import pytest + from homeassistant.components.github.const import DOMAIN, FALLBACK_UPDATE_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.util import dt @@ -13,6 +15,8 @@ from tests.test_util.aiohttp import AiohttpClientMocker TEST_SENSOR_ENTITY = "sensor.octocat_hello_world_latest_release" +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_sensor_updates_with_empty_release_array( hass: HomeAssistant, init_integration: MockConfigEntry, diff --git a/tests/components/insteon/test_api_aldb.py b/tests/components/insteon/test_api_aldb.py index 3d8c2ef809..4bd299d3d0 100644 --- a/tests/components/insteon/test_api_aldb.py +++ b/tests/components/insteon/test_api_aldb.py @@ -69,6 +69,8 @@ def _aldb_dict(mem_addr): } +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_get_aldb( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, aldb_data ) -> None: @@ -85,6 +87,8 @@ async def test_get_aldb( assert len(result) == 5 +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_change_aldb_record( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, aldb_data ) -> None: @@ -108,6 +112,8 @@ async def test_change_aldb_record( _compare_records(rec, change_rec) +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_create_aldb_record( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, aldb_data ) -> None: @@ -131,6 +137,8 @@ async def test_create_aldb_record( _compare_records(rec, new_rec) +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_write_aldb( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, aldb_data ) -> None: @@ -152,6 +160,8 @@ async def test_write_aldb( assert devices.async_save.call_count == 1 +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_load_aldb( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, aldb_data ) -> None: @@ -172,6 +182,8 @@ async def test_load_aldb( assert devices.async_save.call_count == 1 +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_reset_aldb( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, aldb_data ) -> None: @@ -203,6 +215,8 @@ async def test_reset_aldb( assert not devices["33.33.33"].aldb.pending_changes +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_default_links( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, aldb_data ) -> None: @@ -224,6 +238,8 @@ async def test_default_links( assert devices.async_save.call_count == 1 +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_notify_on_aldb_status( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, aldb_data ) -> None: @@ -247,6 +263,8 @@ async def test_notify_on_aldb_status( assert not msg["event"]["is_loading"] +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_notify_on_aldb_record_added( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, aldb_data ) -> None: @@ -274,6 +292,8 @@ async def test_notify_on_aldb_record_added( assert msg["event"]["type"] == "record_loaded" +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_bad_address( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, aldb_data ) -> None: diff --git a/tests/components/insteon/test_api_device.py b/tests/components/insteon/test_api_device.py index 9a4e6eba37..ce061e47c3 100644 --- a/tests/components/insteon/test_api_device.py +++ b/tests/components/insteon/test_api_device.py @@ -5,6 +5,7 @@ from unittest.mock import patch from pyinsteon.constants import DeviceAction from pyinsteon.topics import DEVICE_LIST_CHANGED from pyinsteon.utils import publish_topic +import pytest from homeassistant.components import insteon from homeassistant.components.insteon.api import async_load_api @@ -154,6 +155,8 @@ async def test_get_ha_device_name( assert name == "" +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_add_device_api( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: diff --git a/tests/components/insteon/test_api_scenes.py b/tests/components/insteon/test_api_scenes.py index 9730f2427f..cc9b11f463 100644 --- a/tests/components/insteon/test_api_scenes.py +++ b/tests/components/insteon/test_api_scenes.py @@ -59,6 +59,8 @@ async def _setup(hass, hass_ws_client, scene_data): return ws_client, devices +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_get_scenes( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, scene_data ) -> None: @@ -73,6 +75,8 @@ async def test_get_scenes( assert len(result["20"]) == 3 +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_get_scene( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, scene_data ) -> None: @@ -86,6 +90,8 @@ async def test_get_scene( assert len(result["devices"]) == 3 +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_save_scene( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, scene_data, remove_json ) -> None: @@ -115,6 +121,8 @@ async def test_save_scene( assert result["scene_id"] == 20 +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_save_new_scene( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, scene_data, remove_json ) -> None: @@ -144,6 +152,8 @@ async def test_save_new_scene( assert result["scene_id"] == 21 +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_save_scene_error( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, scene_data, remove_json ) -> None: @@ -173,6 +183,8 @@ async def test_save_scene_error( assert result["scene_id"] == 20 +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_delete_scene( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, scene_data, remove_json ) -> None: diff --git a/tests/components/knx/test_climate.py b/tests/components/knx/test_climate.py index ac6f5e7e97..1784d912be 100644 --- a/tests/components/knx/test_climate.py +++ b/tests/components/knx/test_climate.py @@ -1,4 +1,6 @@ """Test KNX climate.""" +import pytest + from homeassistant.components.climate import PRESET_ECO, PRESET_SLEEP, HVACMode from homeassistant.components.knx.schema import ClimateSchema from homeassistant.const import CONF_NAME, STATE_IDLE @@ -15,6 +17,8 @@ RAW_FLOAT_21_0 = (0x0C, 0x1A) RAW_FLOAT_22_0 = (0x0C, 0x4C) +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_climate_basic_temperature_set( hass: HomeAssistant, knx: KNXTestKit ) -> None: @@ -54,6 +58,8 @@ async def test_climate_basic_temperature_set( assert len(events) == 1 +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_climate_hvac_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test KNX climate hvac mode.""" events = async_capture_events(hass, "state_changed") @@ -107,6 +113,8 @@ async def test_climate_hvac_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: await knx.assert_write("1/2/6", (0x01,)) +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_climate_preset_mode( hass: HomeAssistant, knx: KNXTestKit, entity_registry: er.EntityRegistry ) -> None: @@ -174,6 +182,8 @@ async def test_climate_preset_mode( assert len(knx.xknx.devices) == 0 +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_update_entity(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test update climate entity for KNX.""" events = async_capture_events(hass, "state_changed") @@ -219,6 +229,8 @@ async def test_update_entity(hass: HomeAssistant, knx: KNXTestKit) -> None: await knx.assert_read("1/2/7") +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_command_value_idle_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test KNX climate command_value.""" await knx.setup_integration( diff --git a/tests/components/knx/test_cover.py b/tests/components/knx/test_cover.py index ed82968b55..066429a884 100644 --- a/tests/components/knx/test_cover.py +++ b/tests/components/knx/test_cover.py @@ -1,4 +1,6 @@ """Test KNX cover.""" +import pytest + from homeassistant.components.knx.schema import CoverSchema from homeassistant.const import CONF_NAME, STATE_CLOSING from homeassistant.core import HomeAssistant @@ -8,6 +10,8 @@ from .conftest import KNXTestKit from tests.common import async_capture_events +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_cover_basic(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test KNX cover basic.""" events = async_capture_events(hass, "state_changed") diff --git a/tests/components/lcn/test_events.py b/tests/components/lcn/test_events.py index 0b0eefffac..4e20e202ff 100644 --- a/tests/components/lcn/test_events.py +++ b/tests/components/lcn/test_events.py @@ -2,6 +2,7 @@ from pypck.inputs import Input, ModSendKeysHost, ModStatusAccessControl from pypck.lcn_addr import LcnAddr from pypck.lcn_defs import AccessControlPeriphery, KeyAction, SendKeyCommand +import pytest from homeassistant.core import HomeAssistant @@ -137,6 +138,8 @@ async def test_dont_fire_on_non_module_input( assert len(events) == 0 +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_dont_fire_on_unknown_module(hass: HomeAssistant, lcn_connection) -> None: """Test for no event is fired if an input from an unknown module is received.""" inp = ModStatusAccessControl( diff --git a/tests/components/matter/test_adapter.py b/tests/components/matter/test_adapter.py index 4d434c3c0e..8eadb76894 100644 --- a/tests/components/matter/test_adapter.py +++ b/tests/components/matter/test_adapter.py @@ -15,6 +15,8 @@ from homeassistant.helpers import device_registry as dr from .common import load_and_parse_node_fixture, setup_integration_with_node_fixture +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_device_registry_single_node_device( hass: HomeAssistant, matter_client: MagicMock, @@ -42,6 +44,8 @@ async def test_device_registry_single_node_device( assert entry.sw_version == "v1.0" +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_device_registry_single_node_device_alt( hass: HomeAssistant, matter_client: MagicMock, @@ -113,6 +117,8 @@ async def test_device_registry_bridge( assert device2_entry.sw_version == "1.49.1" +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_node_added_subscription( hass: HomeAssistant, matter_client: MagicMock, diff --git a/tests/components/matter/test_api.py b/tests/components/matter/test_api.py index fcc4ed28ce..6575cb8fdb 100644 --- a/tests/components/matter/test_api.py +++ b/tests/components/matter/test_api.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, call from aiohttp import ClientWebSocketResponse from matter_server.common.errors import InvalidCommand, NodeCommissionFailed +import pytest from homeassistant.components.matter.api import ID, TYPE from homeassistant.core import HomeAssistant @@ -11,6 +12,8 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_commission( hass: HomeAssistant, hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], @@ -51,6 +54,8 @@ async def test_commission( matter_client.commission_with_code.assert_called_once_with("12345678") +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_commission_on_network( hass: HomeAssistant, hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], @@ -91,6 +96,8 @@ async def test_commission_on_network( matter_client.commission_on_network.assert_called_once_with(1234) +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_set_thread_dataset( hass: HomeAssistant, hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], @@ -131,6 +138,8 @@ async def test_set_thread_dataset( matter_client.set_thread_operational_dataset.assert_called_once_with("test_dataset") +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_set_wifi_credentials( hass: HomeAssistant, hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], diff --git a/tests/components/matter/test_binary_sensor.py b/tests/components/matter/test_binary_sensor.py index 172290125b..743619ddde 100644 --- a/tests/components/matter/test_binary_sensor.py +++ b/tests/components/matter/test_binary_sensor.py @@ -23,6 +23,8 @@ async def contact_sensor_node_fixture( ) +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_contact_sensor( hass: HomeAssistant, matter_client: MagicMock, @@ -53,6 +55,8 @@ async def occupancy_sensor_node_fixture( ) +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_occupancy_sensor( hass: HomeAssistant, matter_client: MagicMock, diff --git a/tests/components/matter/test_diagnostics.py b/tests/components/matter/test_diagnostics.py index c79d6814df..303e9879c5 100644 --- a/tests/components/matter/test_diagnostics.py +++ b/tests/components/matter/test_diagnostics.py @@ -56,6 +56,8 @@ async def test_matter_attribute_redact(device_diagnostics: dict[str, Any]) -> No assert redacted_device_diagnostics == device_diagnostics +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_config_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -74,6 +76,8 @@ async def test_config_entry_diagnostics( assert diagnostics == config_entry_diagnostics_redacted +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_device_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/matter/test_helpers.py b/tests/components/matter/test_helpers.py index 2ccb818b33..28f4479432 100644 --- a/tests/components/matter/test_helpers.py +++ b/tests/components/matter/test_helpers.py @@ -18,6 +18,8 @@ from .common import setup_integration_with_node_fixture from tests.common import MockConfigEntry +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_get_device_id( hass: HomeAssistant, matter_client: MagicMock, @@ -31,6 +33,8 @@ async def test_get_device_id( assert device_id == "00000000000004D2-0000000000000005-MatterNodeDevice" +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_get_node_from_device_entry( hass: HomeAssistant, matter_client: MagicMock, diff --git a/tests/components/matter/test_init.py b/tests/components/matter/test_init.py index fac5653f86..aea52cc307 100644 --- a/tests/components/matter/test_init.py +++ b/tests/components/matter/test_init.py @@ -81,6 +81,8 @@ async def test_entry_setup_unload( assert entity_state.state == STATE_UNAVAILABLE +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_home_assistant_stop( hass: HomeAssistant, matter_client: MagicMock, @@ -408,6 +410,8 @@ async def test_update_addon( assert update_addon.call_count == update_calls +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_issue_registry_invalid_version( hass: HomeAssistant, matter_client: MagicMock, @@ -604,6 +608,8 @@ async def test_remove_entry( assert "Failed to uninstall the Matter Server add-on" in caplog.text +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_remove_config_entry_device( hass: HomeAssistant, matter_client: MagicMock, @@ -644,6 +650,8 @@ async def test_remove_config_entry_device( assert not hass.states.get(entity_id) +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_remove_config_entry_device_no_node( hass: HomeAssistant, matter_client: MagicMock, diff --git a/tests/components/matter/test_light.py b/tests/components/matter/test_light.py index 226b22670e..ef85411200 100644 --- a/tests/components/matter/test_light.py +++ b/tests/components/matter/test_light.py @@ -14,6 +14,8 @@ from .common import ( ) +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize( ("fixture", "entity_id"), [ @@ -90,6 +92,8 @@ async def test_on_off_light( matter_client.send_device_command.reset_mock() +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize( ("fixture", "entity_id"), [ @@ -144,6 +148,8 @@ async def test_dimmable_light( matter_client.send_device_command.reset_mock() +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize( ("fixture", "entity_id"), [ @@ -208,6 +214,8 @@ async def test_color_temperature_light( matter_client.send_device_command.reset_mock() +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize( ("fixture", "entity_id"), [ diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 24b6662108..a2e97e188f 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -61,6 +61,8 @@ async def temperature_sensor_node_fixture( ) +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_sensor_null_value( hass: HomeAssistant, matter_client: MagicMock, @@ -79,6 +81,8 @@ async def test_sensor_null_value( assert state.state == "unknown" +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_flow_sensor( hass: HomeAssistant, matter_client: MagicMock, @@ -97,6 +101,8 @@ async def test_flow_sensor( assert state.state == "2.0" +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_humidity_sensor( hass: HomeAssistant, matter_client: MagicMock, @@ -115,6 +121,8 @@ async def test_humidity_sensor( assert state.state == "40.0" +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_light_sensor( hass: HomeAssistant, matter_client: MagicMock, @@ -133,6 +141,8 @@ async def test_light_sensor( assert state.state == "2.0" +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_pressure_sensor( hass: HomeAssistant, matter_client: MagicMock, @@ -151,6 +161,8 @@ async def test_pressure_sensor( assert state.state == "101.0" +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_temperature_sensor( hass: HomeAssistant, matter_client: MagicMock, diff --git a/tests/components/matter/test_switch.py b/tests/components/matter/test_switch.py index 524ea54815..6fbe5d58f2 100644 --- a/tests/components/matter/test_switch.py +++ b/tests/components/matter/test_switch.py @@ -24,6 +24,8 @@ async def switch_node_fixture( ) +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_turn_on( hass: HomeAssistant, matter_client: MagicMock, @@ -58,6 +60,8 @@ async def test_turn_on( assert state.state == "on" +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_turn_off( hass: HomeAssistant, matter_client: MagicMock, diff --git a/tests/components/nest/test_api.py b/tests/components/nest/test_api.py index dcbd927249..0be71be1de 100644 --- a/tests/components/nest/test_api.py +++ b/tests/components/nest/test_api.py @@ -40,6 +40,8 @@ async def async_setup_sdm(hass): await hass.async_block_till_done() +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_YAML_ONLY]) async def test_auth(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: """Exercise authentication library creates valid credentials.""" @@ -92,6 +94,8 @@ async def test_auth(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> assert creds.scopes == SDM_SCOPES +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_YAML_ONLY]) async def test_auth_expired_token( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker diff --git a/tests/components/opentherm_gw/test_init.py b/tests/components/opentherm_gw/test_init.py index 097f8a1c9f..bd2c772bac 100644 --- a/tests/components/opentherm_gw/test_init.py +++ b/tests/components/opentherm_gw/test_init.py @@ -2,6 +2,7 @@ from unittest.mock import patch from pyotgw.vars import OTGW, OTGW_ABOUT +import pytest from homeassistant import setup from homeassistant.components.opentherm_gw.const import DOMAIN @@ -28,6 +29,8 @@ MOCK_CONFIG_ENTRY = MockConfigEntry( ) +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_device_registry_insert(hass: HomeAssistant) -> None: """Test that the device registry is initialized correctly.""" MOCK_CONFIG_ENTRY.add_to_hass(hass) @@ -46,6 +49,8 @@ async def test_device_registry_insert(hass: HomeAssistant) -> None: assert gw_dev.sw_version == VERSION_OLD +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_device_registry_update( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index 2f3e268177..0bc2d04b41 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -168,6 +168,8 @@ async def test_no_servers_found( assert result["errors"]["base"] == "no_servers" +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_single_available_server( hass: HomeAssistant, mock_plex_calls, current_request_with_host: None ) -> None: @@ -206,6 +208,8 @@ async def test_single_available_server( await hass.config_entries.async_unload(result["result"].entry_id) +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_multiple_servers_with_selection( hass: HomeAssistant, mock_plex_calls, @@ -261,6 +265,8 @@ async def test_multiple_servers_with_selection( await hass.config_entries.async_unload(result["result"].entry_id) +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_adding_last_unconfigured_server( hass: HomeAssistant, mock_plex_calls, diff --git a/tests/components/snooz/test_fan.py b/tests/components/snooz/test_fan.py index cda4ce24db..795525fdf7 100644 --- a/tests/components/snooz/test_fan.py +++ b/tests/components/snooz/test_fan.py @@ -148,6 +148,8 @@ async def test_transition_off(hass: HomeAssistant, snooz_fan_entity_id: str) -> assert ATTR_ASSUMED_STATE not in state.attributes +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_push_events( hass: HomeAssistant, mock_connected_snooz: SnoozFixture, snooz_fan_entity_id: str ) -> None: @@ -172,6 +174,8 @@ async def test_push_events( assert state.attributes[ATTR_ASSUMED_STATE] is True +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_restore_state( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: diff --git a/tests/components/snooz/test_init.py b/tests/components/snooz/test_init.py index 821bd3a95c..a7a0566d7c 100644 --- a/tests/components/snooz/test_init.py +++ b/tests/components/snooz/test_init.py @@ -1,11 +1,15 @@ """Test Snooz configuration.""" from __future__ import annotations +import pytest + from homeassistant.core import HomeAssistant from . import SnoozFixture +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_removing_entry_cleans_up_connections( hass: HomeAssistant, mock_connected_snooz: SnoozFixture ) -> None: @@ -16,6 +20,8 @@ async def test_removing_entry_cleans_up_connections( assert not mock_connected_snooz.device.is_connected +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_reloading_entry_cleans_up_connections( hass: HomeAssistant, mock_connected_snooz: SnoozFixture ) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 61d5a70d99..9c1eef3ffb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -257,9 +257,21 @@ def garbage_collection() -> None: gc.collect() +@pytest.fixture(autouse=True) +def expected_lingering_tasks() -> bool: + """Temporary ability to bypass test failures. + + Parametrize to True to bypass the pytest failure. + @pytest.mark.parametrize("expected_lingering_tasks", [True]) + + This should be removed when all lingering tasks have been cleaned up. + """ + return False + + @pytest.fixture(autouse=True) def verify_cleanup( - event_loop: asyncio.AbstractEventLoop, + event_loop: asyncio.AbstractEventLoop, expected_lingering_tasks: bool ) -> Generator[None, None, None]: """Verify that the test has cleaned up resources correctly.""" threads_before = frozenset(threading.enumerate()) @@ -278,7 +290,10 @@ def verify_cleanup( # before moving on to the next test. tasks = asyncio.all_tasks(event_loop) - tasks_before for task in tasks: - _LOGGER.warning("Linger task after test %r", task) + if expected_lingering_tasks: + _LOGGER.warning("Linger task after test %r", task) + else: + pytest.fail(f"Linger task after test {repr(task)}") task.cancel() if tasks: event_loop.run_until_complete(asyncio.wait(tasks)) From 14bf68ad035ec0a50a752b3aaa616f0c646667de Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Mar 2023 11:59:31 +0100 Subject: [PATCH 0698/2810] Cleanup expected_lingering_tasks in knx (#89279) --- tests/components/knx/test_climate.py | 11 ----------- tests/components/knx/test_cover.py | 3 --- 2 files changed, 14 deletions(-) diff --git a/tests/components/knx/test_climate.py b/tests/components/knx/test_climate.py index 1784d912be..582f082eb9 100644 --- a/tests/components/knx/test_climate.py +++ b/tests/components/knx/test_climate.py @@ -1,5 +1,4 @@ """Test KNX climate.""" -import pytest from homeassistant.components.climate import PRESET_ECO, PRESET_SLEEP, HVACMode from homeassistant.components.knx.schema import ClimateSchema @@ -17,8 +16,6 @@ RAW_FLOAT_21_0 = (0x0C, 0x1A) RAW_FLOAT_22_0 = (0x0C, 0x4C) -# This tests needs to be adjusted to remove lingering tasks -@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_climate_basic_temperature_set( hass: HomeAssistant, knx: KNXTestKit ) -> None: @@ -58,8 +55,6 @@ async def test_climate_basic_temperature_set( assert len(events) == 1 -# This tests needs to be adjusted to remove lingering tasks -@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_climate_hvac_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test KNX climate hvac mode.""" events = async_capture_events(hass, "state_changed") @@ -113,8 +108,6 @@ async def test_climate_hvac_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: await knx.assert_write("1/2/6", (0x01,)) -# This tests needs to be adjusted to remove lingering tasks -@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_climate_preset_mode( hass: HomeAssistant, knx: KNXTestKit, entity_registry: er.EntityRegistry ) -> None: @@ -182,8 +175,6 @@ async def test_climate_preset_mode( assert len(knx.xknx.devices) == 0 -# This tests needs to be adjusted to remove lingering tasks -@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_update_entity(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test update climate entity for KNX.""" events = async_capture_events(hass, "state_changed") @@ -229,8 +220,6 @@ async def test_update_entity(hass: HomeAssistant, knx: KNXTestKit) -> None: await knx.assert_read("1/2/7") -# This tests needs to be adjusted to remove lingering tasks -@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_command_value_idle_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test KNX climate command_value.""" await knx.setup_integration( diff --git a/tests/components/knx/test_cover.py b/tests/components/knx/test_cover.py index 066429a884..5aef38ea00 100644 --- a/tests/components/knx/test_cover.py +++ b/tests/components/knx/test_cover.py @@ -1,5 +1,4 @@ """Test KNX cover.""" -import pytest from homeassistant.components.knx.schema import CoverSchema from homeassistant.const import CONF_NAME, STATE_CLOSING @@ -10,8 +9,6 @@ from .conftest import KNXTestKit from tests.common import async_capture_events -# This tests needs to be adjusted to remove lingering tasks -@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_cover_basic(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test KNX cover basic.""" events = async_capture_events(hass, "state_changed") From 8c282e2b0d2de389523463299b34bae9f28617cd Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 7 Mar 2023 13:24:41 +0100 Subject: [PATCH 0699/2810] Remove deprecated DSMR Reader YAML configuration (#89239) --- .../components/dsmr_reader/config_flow.py | 3 -- .../components/dsmr_reader/sensor.py | 30 +------------------ .../components/dsmr_reader/strings.json | 6 ---- .../dsmr_reader/test_config_flow.py | 19 +----------- 4 files changed, 2 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/dsmr_reader/config_flow.py b/homeassistant/components/dsmr_reader/config_flow.py index 2f08894d12..44ff666365 100644 --- a/homeassistant/components/dsmr_reader/config_flow.py +++ b/homeassistant/components/dsmr_reader/config_flow.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Awaitable -import logging from typing import Any from homeassistant.core import HomeAssistant @@ -11,8 +10,6 @@ from homeassistant.helpers.config_entry_flow import DiscoveryFlowHandler from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) - async def _async_has_devices(_: HomeAssistant) -> bool: """MQTT is set as dependency, so that should be sufficient.""" diff --git a/homeassistant/components/dsmr_reader/sensor.py b/homeassistant/components/dsmr_reader/sensor.py index 72e24c5272..28dc0abb2d 100644 --- a/homeassistant/components/dsmr_reader/sensor.py +++ b/homeassistant/components/dsmr_reader/sensor.py @@ -3,42 +3,14 @@ from __future__ import annotations from homeassistant.components import mqtt from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import slugify -from .const import DOMAIN from .definitions import SENSORS, DSMRReaderSensorEntityDescription -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up DSMR Reader sensors via configuration.yaml and show deprecation warning.""" - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2022.12.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - - async def async_setup_entry( _: HomeAssistant, config_entry: ConfigEntry, diff --git a/homeassistant/components/dsmr_reader/strings.json b/homeassistant/components/dsmr_reader/strings.json index 17e28cca88..73c4ac0440 100644 --- a/homeassistant/components/dsmr_reader/strings.json +++ b/homeassistant/components/dsmr_reader/strings.json @@ -8,11 +8,5 @@ "description": "Make sure to configure the 'split topic' data sources in DSMR Reader." } } - }, - "issues": { - "deprecated_yaml": { - "title": "The DSMR Reader configuration is being removed", - "description": "Configuring DSMR Reader using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the DSMR Reader YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/tests/components/dsmr_reader/test_config_flow.py b/tests/components/dsmr_reader/test_config_flow.py index edf5518e07..42d18d866a 100644 --- a/tests/components/dsmr_reader/test_config_flow.py +++ b/tests/components/dsmr_reader/test_config_flow.py @@ -1,27 +1,10 @@ """Tests for the config flow.""" from homeassistant.components.dsmr_reader.const import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -async def test_import_step(hass: HomeAssistant) -> None: - """Test the import step.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - ) - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "DSMR Reader" - - second_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - ) - assert second_result["type"] == FlowResultType.ABORT - assert second_result["reason"] == "single_instance_allowed" - - async def test_user_step(hass: HomeAssistant) -> None: """Test the user step call.""" result = await hass.config_entries.flow.async_init( From ff2a88b42663ce26d286e348d859b6e59ddb4e5d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Mar 2023 13:25:31 +0100 Subject: [PATCH 0700/2810] Bump ruff to 0.0.254 (#89273) --- .pre-commit-config.yaml | 2 +- homeassistant/components/esphome/bluetooth/client.py | 2 ++ pyproject.toml | 2 ++ requirements_test_pre_commit.txt | 2 +- 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 357e2663fc..269d786ab2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.253 + rev: v0.0.254 hooks: - id: ruff args: diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index 6ec51cabeb..343847f55f 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -526,6 +526,7 @@ class ESPHomeClient(BaseBleakClient): The characteristic to read from, specified by either integer handle, UUID or directly by the BleakGATTCharacteristic object representing it. + **kwargs: Unused Returns: (bytearray) The read data. @@ -542,6 +543,7 @@ class ESPHomeClient(BaseBleakClient): Args: handle (int): The handle of the descriptor to read from. + **kwargs: Unused Returns: (bytearray) The read data. diff --git a/pyproject.toml b/pyproject.toml index 850995273b..bc7a603e5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -272,6 +272,8 @@ ignore = [ "D407", # Section name underlining "E501", # line too long "E731", # do not assign a lambda expression, use a def + # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923 + "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` ] [tool.ruff.flake8-import-conventions.extend-aliases] diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index e2deb067d7..65913a3159 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -14,5 +14,5 @@ pycodestyle==2.10.0 pydocstyle==6.2.3 pyflakes==3.0.1 pyupgrade==3.3.1 -ruff==0.0.253 +ruff==0.0.254 yamllint==1.28.0 From 3f061e91015826a1b9146b830407633863c5ca10 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 7 Mar 2023 16:15:26 +0100 Subject: [PATCH 0701/2810] Drop deepcopy of manual mqtt alarm control panel config (#89287) --- .../components/manual_mqtt/alarm_control_panel.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/manual_mqtt/alarm_control_panel.py b/homeassistant/components/manual_mqtt/alarm_control_panel.py index 3857dd1954..d6b4a58c41 100644 --- a/homeassistant/components/manual_mqtt/alarm_control_panel.py +++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py @@ -1,7 +1,6 @@ """Support for manual alarms controllable via MQTT.""" from __future__ import annotations -import copy import datetime import logging import re @@ -87,15 +86,18 @@ ATTR_POST_PENDING_STATE = "post_pending_state" def _state_validator(config): """Validate the state.""" - config = copy.deepcopy(config) for state in SUPPORTED_PRETRIGGER_STATES: if CONF_DELAY_TIME not in config[state]: - config[state][CONF_DELAY_TIME] = config[CONF_DELAY_TIME] + config[state] = config[state] | {CONF_DELAY_TIME: config[CONF_DELAY_TIME]} if CONF_TRIGGER_TIME not in config[state]: - config[state][CONF_TRIGGER_TIME] = config[CONF_TRIGGER_TIME] + config[state] = config[state] | { + CONF_TRIGGER_TIME: config[CONF_TRIGGER_TIME] + } for state in SUPPORTED_PENDING_STATES: if CONF_PENDING_TIME not in config[state]: - config[state][CONF_PENDING_TIME] = config[CONF_PENDING_TIME] + config[state] = config[state] | { + CONF_PENDING_TIME: config[CONF_PENDING_TIME] + } return config From f48b535d9d9a4fbb5e21aed2e0047d67f940fcf3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 7 Mar 2023 16:15:48 +0100 Subject: [PATCH 0702/2810] Drop deepcopy of manual alarm control panel config (#89286) --- homeassistant/components/manual/alarm_control_panel.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py index d35f9b73ef..f0436ba1d6 100644 --- a/homeassistant/components/manual/alarm_control_panel.py +++ b/homeassistant/components/manual/alarm_control_panel.py @@ -1,7 +1,6 @@ """Support for manual alarms.""" from __future__ import annotations -import copy import datetime import logging import re @@ -74,15 +73,16 @@ ATTR_NEXT_STATE = "next_state" def _state_validator(config): """Validate the state.""" - config = copy.deepcopy(config) for state in SUPPORTED_PRETRIGGER_STATES: if CONF_DELAY_TIME not in config[state]: - config[state][CONF_DELAY_TIME] = config[CONF_DELAY_TIME] + config[state] = config[state] | {CONF_DELAY_TIME: config[CONF_DELAY_TIME]} if CONF_TRIGGER_TIME not in config[state]: - config[state][CONF_TRIGGER_TIME] = config[CONF_TRIGGER_TIME] + config[state] = config[state] | { + CONF_TRIGGER_TIME: config[CONF_TRIGGER_TIME] + } for state in SUPPORTED_ARMING_STATES: if CONF_ARMING_TIME not in config[state]: - config[state][CONF_ARMING_TIME] = config[CONF_ARMING_TIME] + config[state] = config[state] | {CONF_ARMING_TIME: config[CONF_ARMING_TIME]} return config From f5a3c4f7f55406eb9dc52a73e6b2e94b3f4d5182 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 7 Mar 2023 16:16:24 +0100 Subject: [PATCH 0703/2810] Drop deepcopy of intent_script config (#89285) --- homeassistant/components/intent_script/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/intent_script/__init__.py b/homeassistant/components/intent_script/__init__.py index 128c9332ae..2ec898bfb0 100644 --- a/homeassistant/components/intent_script/__init__.py +++ b/homeassistant/components/intent_script/__init__.py @@ -1,7 +1,6 @@ """Handle intents with scripts.""" from __future__ import annotations -import copy import logging import voluptuous as vol @@ -57,8 +56,8 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Activate Alexa component.""" - intents = copy.deepcopy(config[DOMAIN]) + """Set up the intent script component.""" + intents = config[DOMAIN] template.attach(hass, intents) for intent_type, conf in intents.items(): From 058bb4c3e668b15b0b45249664de15a0827b4e53 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 7 Mar 2023 16:16:46 +0100 Subject: [PATCH 0704/2810] Drop deepcopy of Alexa config (#89284) --- homeassistant/components/alexa/flash_briefings.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/alexa/flash_briefings.py b/homeassistant/components/alexa/flash_briefings.py index 1521afcae5..6f53d86d44 100644 --- a/homeassistant/components/alexa/flash_briefings.py +++ b/homeassistant/components/alexa/flash_briefings.py @@ -1,5 +1,4 @@ """Support for Alexa skill service end point.""" -import copy import hmac from http import HTTPStatus import logging @@ -48,7 +47,7 @@ class AlexaFlashBriefingView(http.HomeAssistantView): def __init__(self, hass, flash_briefings): """Initialize Alexa view.""" super().__init__() - self.flash_briefings = copy.deepcopy(flash_briefings) + self.flash_briefings = flash_briefings template.attach(hass, self.flash_briefings) @callback From bc0b3abb0104509efe17f66e7f21f900dd389750 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 7 Mar 2023 16:54:35 +0100 Subject: [PATCH 0705/2810] Remove unittest.TestCase from service helper tests (#89283) * Remove unittest.TestCase from service helper tests * Update * Improve tests --- tests/helpers/test_service.py | 344 +++++++++++++++++----------------- 1 file changed, 177 insertions(+), 167 deletions(-) diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index b93562621c..c3b5165bb1 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -1,7 +1,6 @@ """Test service helpers.""" from collections import OrderedDict from copy import deepcopy -import unittest from unittest.mock import AsyncMock, Mock, patch import pytest @@ -33,10 +32,8 @@ from tests.common import ( MockEntity, MockUser, async_mock_service, - get_test_home_assistant, mock_device_registry, mock_registry, - mock_service, ) SUPPORT_A = 1 @@ -226,181 +223,194 @@ def area_mock(hass): ) -class TestServiceHelpers(unittest.TestCase): - """Test the Home Assistant service helpers.""" +async def test_call_from_config(hass: HomeAssistant): + """Test the sync wrapper of service.async_call_from_config.""" + calls = async_mock_service(hass, "test_domain", "test_service") + config = { + "service_template": "{{ 'test_domain.test_service' }}", + "entity_id": "hello.world", + "data": {"hello": "goodbye"}, + } - def setUp(self): # pylint: disable=invalid-name - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.calls = mock_service(self.hass, "test_domain", "test_service") + await hass.async_add_executor_job(service.call_from_config, hass, config) + await hass.async_block_till_done() - def tearDown(self): # pylint: disable=invalid-name - """Stop down everything that was started.""" - self.hass.stop() + assert calls[0].data == {"hello": "goodbye", "entity_id": ["hello.world"]} - def test_service_call(self): - """Test service call with templating.""" - config = { - "service": "{{ 'test_domain.test_service' }}", - "entity_id": "hello.world", - "data": { - "hello": "{{ 'goodbye' }}", - "effect": {"value": "{{ 'complex' }}", "simple": "simple"}, + +async def test_service_call(hass: HomeAssistant): + """Test service call with templating.""" + calls = async_mock_service(hass, "test_domain", "test_service") + config = { + "service": "{{ 'test_domain.test_service' }}", + "entity_id": "hello.world", + "data": { + "hello": "{{ 'goodbye' }}", + "effect": {"value": "{{ 'complex' }}", "simple": "simple"}, + }, + "data_template": {"list": ["{{ 'list' }}", "2"]}, + "target": {"area_id": "test-area-id", "entity_id": "will.be_overridden"}, + } + + await service.async_call_from_config(hass, config) + await hass.async_block_till_done() + + assert dict(calls[0].data) == { + "hello": "goodbye", + "effect": { + "value": "complex", + "simple": "simple", + }, + "list": ["list", "2"], + "entity_id": ["hello.world"], + "area_id": ["test-area-id"], + } + + config = { + "service": "{{ 'test_domain.test_service' }}", + "target": { + "area_id": ["area-42", "{{ 'area-51' }}"], + "device_id": ["abcdef", "{{ 'fedcba' }}"], + "entity_id": ["light.static", "{{ 'light.dynamic' }}"], + }, + } + + await service.async_call_from_config(hass, config) + await hass.async_block_till_done() + + assert dict(calls[1].data) == { + "area_id": ["area-42", "area-51"], + "device_id": ["abcdef", "fedcba"], + "entity_id": ["light.static", "light.dynamic"], + } + + config = { + "service": "{{ 'test_domain.test_service' }}", + "target": "{{ var_target }}", + } + + await service.async_call_from_config( + hass, + config, + variables={ + "var_target": { + "entity_id": "light.static", + "area_id": ["area-42", "area-51"], }, - "data_template": {"list": ["{{ 'list' }}", "2"]}, - "target": {"area_id": "test-area-id", "entity_id": "will.be_overridden"}, + }, + ) + await hass.async_block_till_done() + + assert dict(calls[2].data) == { + "area_id": ["area-42", "area-51"], + "entity_id": ["light.static"], + } + + +async def test_service_template_service_call(hass: HomeAssistant): + """Test legacy service_template call with templating.""" + calls = async_mock_service(hass, "test_domain", "test_service") + config = { + "service_template": "{{ 'test_domain.test_service' }}", + "entity_id": "hello.world", + "data": {"hello": "goodbye"}, + } + + await service.async_call_from_config(hass, config) + await hass.async_block_till_done() + + assert calls[0].data == {"hello": "goodbye", "entity_id": ["hello.world"]} + + +async def test_passing_variables_to_templates(hass: HomeAssistant): + """Test passing variables to templates.""" + calls = async_mock_service(hass, "test_domain", "test_service") + config = { + "service_template": "{{ var_service }}", + "entity_id": "hello.world", + "data_template": {"hello": "{{ var_data }}"}, + } + + await service.async_call_from_config( + hass, + config, + variables={ + "var_service": "test_domain.test_service", + "var_data": "goodbye", + }, + ) + await hass.async_block_till_done() + + assert calls[0].data == {"hello": "goodbye", "entity_id": ["hello.world"]} + + +async def test_bad_template(hass: HomeAssistant): + """Test passing bad template.""" + calls = async_mock_service(hass, "test_domain", "test_service") + config = { + "service_template": "{{ var_service }}", + "entity_id": "hello.world", + "data_template": {"hello": "{{ states + unknown_var }}"}, + } + + await service.async_call_from_config( + hass, + config, + variables={ + "var_service": "test_domain.test_service", + "var_data": "goodbye", + }, + ) + await hass.async_block_till_done() + + assert len(calls) == 0 + + +async def test_split_entity_string(hass: HomeAssistant): + """Test splitting of entity string.""" + calls = async_mock_service(hass, "test_domain", "test_service") + await service.async_call_from_config( + hass, + { + "service": "test_domain.test_service", + "entity_id": "hello.world, sensor.beer", + }, + ) + await hass.async_block_till_done() + assert ["hello.world", "sensor.beer"] == calls[-1].data.get("entity_id") + + +async def test_not_mutate_input(hass: HomeAssistant): + """Test for immutable input.""" + async_mock_service(hass, "test_domain", "test_service") + config = cv.SERVICE_SCHEMA( + { + "service": "test_domain.test_service", + "entity_id": "hello.world, sensor.beer", + "data": {"hello": 1}, + "data_template": {"nested": {"value": "{{ 1 + 1 }}"}}, } + ) + orig = deepcopy(config) - service.call_from_config(self.hass, config) - self.hass.block_till_done() + # Only change after call is each template getting hass attached + template.attach(hass, orig) - assert dict(self.calls[0].data) == { - "hello": "goodbye", - "effect": { - "value": "complex", - "simple": "simple", - }, - "list": ["list", "2"], - "entity_id": ["hello.world"], - "area_id": ["test-area-id"], - } + await service.async_call_from_config(hass, config, validate_config=False) + assert orig == config - config = { - "service": "{{ 'test_domain.test_service' }}", - "target": { - "area_id": ["area-42", "{{ 'area-51' }}"], - "device_id": ["abcdef", "{{ 'fedcba' }}"], - "entity_id": ["light.static", "{{ 'light.dynamic' }}"], - }, - } - service.call_from_config(self.hass, config) - self.hass.block_till_done() +@patch("homeassistant.helpers.service._LOGGER.error") +async def test_fail_silently_if_no_service(mock_log, hass: HomeAssistant): + """Test failing if service is missing.""" + await service.async_call_from_config(hass, None) + assert mock_log.call_count == 1 - assert dict(self.calls[1].data) == { - "area_id": ["area-42", "area-51"], - "device_id": ["abcdef", "fedcba"], - "entity_id": ["light.static", "light.dynamic"], - } + await service.async_call_from_config(hass, {}) + assert mock_log.call_count == 2 - config = { - "service": "{{ 'test_domain.test_service' }}", - "target": "{{ var_target }}", - } - - service.call_from_config( - self.hass, - config, - variables={ - "var_target": { - "entity_id": "light.static", - "area_id": ["area-42", "area-51"], - }, - }, - ) - - service.call_from_config(self.hass, config) - self.hass.block_till_done() - - assert dict(self.calls[2].data) == { - "area_id": ["area-42", "area-51"], - "entity_id": ["light.static"], - } - - def test_service_template_service_call(self): - """Test legacy service_template call with templating.""" - config = { - "service_template": "{{ 'test_domain.test_service' }}", - "entity_id": "hello.world", - "data": {"hello": "goodbye"}, - } - - service.call_from_config(self.hass, config) - self.hass.block_till_done() - - assert self.calls[0].data["hello"] == "goodbye" - - def test_passing_variables_to_templates(self): - """Test passing variables to templates.""" - config = { - "service_template": "{{ var_service }}", - "entity_id": "hello.world", - "data_template": {"hello": "{{ var_data }}"}, - } - - service.call_from_config( - self.hass, - config, - variables={ - "var_service": "test_domain.test_service", - "var_data": "goodbye", - }, - ) - self.hass.block_till_done() - - assert self.calls[0].data["hello"] == "goodbye" - - def test_bad_template(self): - """Test passing bad template.""" - config = { - "service_template": "{{ var_service }}", - "entity_id": "hello.world", - "data_template": {"hello": "{{ states + unknown_var }}"}, - } - - service.call_from_config( - self.hass, - config, - variables={ - "var_service": "test_domain.test_service", - "var_data": "goodbye", - }, - ) - self.hass.block_till_done() - - assert len(self.calls) == 0 - - def test_split_entity_string(self): - """Test splitting of entity string.""" - service.call_from_config( - self.hass, - { - "service": "test_domain.test_service", - "entity_id": "hello.world, sensor.beer", - }, - ) - self.hass.block_till_done() - assert ["hello.world", "sensor.beer"] == self.calls[-1].data.get("entity_id") - - def test_not_mutate_input(self): - """Test for immutable input.""" - config = cv.SERVICE_SCHEMA( - { - "service": "test_domain.test_service", - "entity_id": "hello.world, sensor.beer", - "data": {"hello": 1}, - "data_template": {"nested": {"value": "{{ 1 + 1 }}"}}, - } - ) - orig = deepcopy(config) - - # Only change after call is each template getting hass attached - template.attach(self.hass, orig) - - service.call_from_config(self.hass, config, validate_config=False) - assert orig == config - - @patch("homeassistant.helpers.service._LOGGER.error") - def test_fail_silently_if_no_service(self, mock_log): - """Test failing if service is missing.""" - service.call_from_config(self.hass, None) - assert mock_log.call_count == 1 - - service.call_from_config(self.hass, {}) - assert mock_log.call_count == 2 - - service.call_from_config(self.hass, {"service": "invalid"}) - assert mock_log.call_count == 3 + await service.async_call_from_config(hass, {"service": "invalid"}) + assert mock_log.call_count == 3 async def test_service_call_entry_id( From f9a59c0839a3e8c56ad7f34a40144bce4bd49b23 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Mar 2023 19:04:50 +0100 Subject: [PATCH 0706/2810] 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 07f122fa4b..4873acf753 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 f84441d249..5f4aadce7e 100644 --- a/homeassistant/components/sfr_box/sensor.py +++ b/homeassistant/components/sfr_box/sensor.py @@ -1,7 +1,6 @@ """SFR Box sensor platform.""" -from collections.abc import Callable, Iterable +from collections.abc import Callable from dataclasses import dataclass -from itertools import chain from typing import Generic, TypeVar from sfrbox_api.models import DslInfo, SystemInfo @@ -204,16 +203,15 @@ async def async_setup_entry( """Set up the sensors.""" data: DomainData = hass.data[DOMAIN][entry.entry_id] - entities: Iterable[SFRBoxSensor] = chain( - ( + entities: list[SFRBoxSensor] = [ + SFRBoxSensor(data.system, description, data.system.data) + for description in SYSTEM_SENSOR_TYPES + ] + if data.system.data.net_infra == "adsl": + entities.extend( SFRBoxSensor(data.dsl, description, data.system.data) for description in DSL_SENSOR_TYPES - ), - ( - SFRBoxSensor(data.system, description, data.system.data) - for description in SYSTEM_SENSOR_TYPES - ), - ) + ) async_add_entities(entities) From 2d3c5cf8eef7f2b653098fe0b90a73feb2b02052 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 7 Mar 2023 20:29:38 +0100 Subject: [PATCH 0707/2810] Reolink test init 100% (#89112) * Split out reolink tests * Bring __init__ coverage to 100% * Improve docstrings * Use patching and autospec=True for ReolinkHost * Use fixture * fix styling * Parametrize tests * Update tests/components/reolink/conftest.py Co-authored-by: Franck Nijhof * Apply suggestions from code review Co-authored-by: Franck Nijhof * Update test_config_flow.py * convert to fixture * review comments * Update tests/components/reolink/conftest.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/reolink/conftest.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/reolink/conftest.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * fix tests * fix imports * Update test_init.py * Check if host is logout on reload --------- Co-authored-by: Franck Nijhof Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .coveragerc | 1 - tests/components/reolink/conftest.py | 92 +++++++ tests/components/reolink/test_config_flow.py | 240 ++++++------------- tests/components/reolink/test_init.py | 116 +++++++++ 4 files changed, 282 insertions(+), 167 deletions(-) create mode 100644 tests/components/reolink/conftest.py create mode 100644 tests/components/reolink/test_init.py diff --git a/.coveragerc b/.coveragerc index f38c6226ac..fa6ae5ba0d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -975,7 +975,6 @@ omit = homeassistant/components/rejseplanen/sensor.py homeassistant/components/remember_the_milk/__init__.py homeassistant/components/remote_rpi_gpio/* - homeassistant/components/reolink/__init__.py homeassistant/components/reolink/binary_sensor.py homeassistant/components/reolink/button.py homeassistant/components/reolink/camera.py diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py new file mode 100644 index 0000000000..941a1ca7c8 --- /dev/null +++ b/tests/components/reolink/conftest.py @@ -0,0 +1,92 @@ +"""Setup the Reolink tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.reolink import const +from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import format_mac + +from tests.common import MockConfigEntry + +TEST_HOST = "1.2.3.4" +TEST_HOST2 = "4.5.6.7" +TEST_USERNAME = "admin" +TEST_USERNAME2 = "username" +TEST_PASSWORD = "password" +TEST_PASSWORD2 = "new_password" +TEST_MAC = "ab:cd:ef:gh:ij:kl" +TEST_PORT = 1234 +TEST_NVR_NAME = "test_reolink_name" +TEST_USE_HTTPS = True + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.reolink.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def reolink_connect(mock_get_source_ip: None) -> Generator[MagicMock, None, None]: + """Mock reolink connection.""" + with patch( + "homeassistant.components.reolink.host.webhook.async_register", + return_value=True, + ), patch( + "homeassistant.components.reolink.host.Host", autospec=True + ) as host_mock_class: + host_mock = host_mock_class.return_value + host_mock.get_host_data.return_value = None + host_mock.get_states.return_value = None + host_mock.check_new_firmware.return_value = False + host_mock.unsubscribe.return_value = True + host_mock.logout.return_value = True + host_mock.mac_address = TEST_MAC + host_mock.onvif_enabled = True + host_mock.rtmp_enabled = True + host_mock.rtsp_enabled = True + host_mock.nvr_name = TEST_NVR_NAME + host_mock.port = TEST_PORT + host_mock.use_https = TEST_USE_HTTPS + host_mock.is_admin = True + host_mock.user_level = "admin" + host_mock.sw_version_update_required = False + host_mock.timeout = 60 + host_mock.renewtimer = 600 + yield host_mock + + +@pytest.fixture +def reolink_platforms(mock_get_source_ip: None) -> Generator[None, None, None]: + """Mock reolink entry setup.""" + with patch("homeassistant.components.reolink.PLATFORMS", return_value=[]): + yield + + +@pytest.fixture +def config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Add the reolink mock config entry to hass.""" + config_entry = MockConfigEntry( + domain=const.DOMAIN, + unique_id=format_mac(TEST_MAC), + data={ + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_PORT: TEST_PORT, + const.CONF_USE_HTTPS: TEST_USE_HTTPS, + }, + options={ + const.CONF_PROTOCOL: DEFAULT_PROTOCOL, + }, + title=TEST_NVR_NAME, + ) + config_entry.add_to_hass(hass) + return config_entry diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index a5de5d5acb..b3abb793a9 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -1,6 +1,6 @@ """Test the Reolink config flow.""" import json -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import MagicMock import pytest from reolink_aio.exceptions import ApiError, CredentialsInvalidError, ReolinkError @@ -9,61 +9,26 @@ from homeassistant import config_entries, data_entry_flow from homeassistant.components import dhcp from homeassistant.components.reolink import const from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL -from homeassistant.config import async_process_ha_core_config from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.device_registry import format_mac +from .conftest import ( + TEST_HOST, + TEST_HOST2, + TEST_MAC, + TEST_NVR_NAME, + TEST_PASSWORD, + TEST_PASSWORD2, + TEST_PORT, + TEST_USE_HTTPS, + TEST_USERNAME, + TEST_USERNAME2, +) + from tests.common import MockConfigEntry -TEST_HOST = "1.2.3.4" -TEST_HOST2 = "4.5.6.7" -TEST_USERNAME = "admin" -TEST_USERNAME2 = "username" -TEST_PASSWORD = "password" -TEST_PASSWORD2 = "new_password" -TEST_MAC = "ab:cd:ef:gh:ij:kl" -TEST_PORT = 1234 -TEST_NVR_NAME = "test_reolink_name" -TEST_USE_HTTPS = True - - -def get_mock_info(error=None, user_level="admin"): - """Return a mock gateway info instance.""" - host_mock = Mock() - if error is None: - host_mock.get_host_data = AsyncMock(return_value=None) - else: - host_mock.get_host_data = AsyncMock(side_effect=error) - host_mock.check_new_firmware = AsyncMock(return_value=False) - host_mock.unsubscribe = AsyncMock(return_value=True) - host_mock.logout = AsyncMock(return_value=True) - host_mock.mac_address = TEST_MAC - host_mock.onvif_enabled = True - host_mock.rtmp_enabled = True - host_mock.rtsp_enabled = True - host_mock.nvr_name = TEST_NVR_NAME - host_mock.port = TEST_PORT - host_mock.use_https = TEST_USE_HTTPS - host_mock.is_admin = user_level == "admin" - host_mock.user_level = user_level - host_mock.timeout = 60 - host_mock.renewtimer = 600 - host_mock.get_states = AsyncMock(return_value=None) - return host_mock - - -@pytest.fixture(name="reolink_connect", autouse=True) -def reolink_connect_fixture(mock_get_source_ip): - """Mock reolink connection and entry setup.""" - with patch( - "homeassistant.components.reolink.host.webhook.async_register", - return_value=True, - ), patch("homeassistant.components.reolink.PLATFORMS", return_value=[]), patch( - "homeassistant.components.reolink.host.Host", return_value=get_mock_info() - ): - yield +pytestmark = pytest.mark.usefixtures("mock_setup_entry", "reolink_connect") async def test_config_flow_manual_success(hass: HomeAssistant) -> None: @@ -99,7 +64,9 @@ async def test_config_flow_manual_success(hass: HomeAssistant) -> None: } -async def test_config_flow_errors(hass: HomeAssistant) -> None: +async def test_config_flow_errors( + hass: HomeAssistant, reolink_connect: MagicMock +) -> None: """Successful flow manually initialized by the user after some errors.""" result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -109,81 +76,82 @@ async def test_config_flow_errors(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert result["errors"] == {} - host_mock = get_mock_info(error=ReolinkError("Test error")) - with patch("homeassistant.components.reolink.host.Host", return_value=host_mock): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - CONF_HOST: TEST_HOST, - }, - ) - - assert result["type"] is data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {CONF_HOST: "cannot_connect"} - - host_mock = get_mock_info(user_level="guest") - with patch("homeassistant.components.reolink.host.Host", return_value=host_mock): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - CONF_HOST: TEST_HOST, - }, - ) + reolink_connect.is_admin = False + reolink_connect.user_level = "guest" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_HOST: TEST_HOST, + }, + ) assert result["type"] is data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {CONF_USERNAME: "not_admin"} - host_mock = get_mock_info(error=json.JSONDecodeError("test_error", "test", 1)) - with patch("homeassistant.components.reolink.host.Host", return_value=host_mock): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - CONF_HOST: TEST_HOST, - }, - ) + reolink_connect.is_admin = True + reolink_connect.user_level = "admin" + reolink_connect.get_host_data.side_effect = ReolinkError("Test error") + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_HOST: TEST_HOST, + }, + ) + + assert result["type"] is data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {CONF_HOST: "cannot_connect"} + + reolink_connect.get_host_data.side_effect = json.JSONDecodeError( + "test_error", "test", 1 + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_HOST: TEST_HOST, + }, + ) assert result["type"] is data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {CONF_HOST: "unknown"} - host_mock = get_mock_info(error=CredentialsInvalidError("Test error")) - with patch("homeassistant.components.reolink.host.Host", return_value=host_mock): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - CONF_HOST: TEST_HOST, - }, - ) + reolink_connect.get_host_data.side_effect = CredentialsInvalidError("Test error") + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_HOST: TEST_HOST, + }, + ) assert result["type"] is data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {CONF_HOST: "invalid_auth"} - host_mock = get_mock_info(error=ApiError("Test error")) - with patch("homeassistant.components.reolink.host.Host", return_value=host_mock): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - CONF_HOST: TEST_HOST, - }, - ) + reolink_connect.get_host_data.side_effect = ApiError("Test error") + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_HOST: TEST_HOST, + }, + ) assert result["type"] is data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {CONF_HOST: "api_error"} + reolink_connect.get_host_data.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -422,63 +390,3 @@ async def test_dhcp_abort_flow(hass: HomeAssistant) -> None: assert result["type"] is data_entry_flow.FlowResultType.ABORT assert result["reason"] == "already_configured" - - -async def test_http_no_repair_issue(hass: HomeAssistant) -> None: - """Test no repairs issue is raised when http local url is used.""" - config_entry = MockConfigEntry( - domain=const.DOMAIN, - unique_id=format_mac(TEST_MAC), - data={ - CONF_HOST: TEST_HOST, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - CONF_PORT: TEST_PORT, - const.CONF_USE_HTTPS: TEST_USE_HTTPS, - }, - options={ - const.CONF_PROTOCOL: DEFAULT_PROTOCOL, - }, - title=TEST_NVR_NAME, - ) - config_entry.add_to_hass(hass) - - await async_process_ha_core_config( - hass, {"country": "GB", "internal_url": "http://test_homeassistant_address"} - ) - - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - issue_registry = ir.async_get(hass) - assert len(issue_registry.issues) == 0 - - -async def test_https_repair_issue(hass: HomeAssistant) -> None: - """Test repairs issue is raised when https local url is used.""" - config_entry = MockConfigEntry( - domain=const.DOMAIN, - unique_id=format_mac(TEST_MAC), - data={ - CONF_HOST: TEST_HOST, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - CONF_PORT: TEST_PORT, - const.CONF_USE_HTTPS: TEST_USE_HTTPS, - }, - options={ - const.CONF_PROTOCOL: DEFAULT_PROTOCOL, - }, - title=TEST_NVR_NAME, - ) - config_entry.add_to_hass(hass) - - await async_process_ha_core_config( - hass, {"country": "GB", "internal_url": "https://test_homeassistant_address"} - ) - - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - issue_registry = ir.async_get(hass) - assert len(issue_registry.issues) == 1 diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py new file mode 100644 index 0000000000..035bfa6e53 --- /dev/null +++ b/tests/components/reolink/test_init.py @@ -0,0 +1,116 @@ +"""Test the Reolink init.""" +from typing import Any +from unittest.mock import AsyncMock, MagicMock, Mock + +import pytest +from reolink_aio.exceptions import ReolinkError + +from homeassistant.components.reolink import const +from homeassistant.config import async_process_ha_core_config +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("reolink_connect", "reolink_platforms") + + +@pytest.mark.parametrize( + ("attr", "value", "expected"), + [ + ( + "is_admin", + False, + ConfigEntryState.SETUP_ERROR, + ), + ( + "get_host_data", + AsyncMock(side_effect=ReolinkError("Test error")), + ConfigEntryState.SETUP_RETRY, + ), + ( + "get_host_data", + AsyncMock(side_effect=ValueError("Test error")), + ConfigEntryState.SETUP_ERROR, + ), + ( + "get_states", + AsyncMock(side_effect=ReolinkError("Test error")), + ConfigEntryState.SETUP_RETRY, + ), + ( + "supported", + Mock(return_value=False), + ConfigEntryState.LOADED, + ), + ( + "check_new_firmware", + AsyncMock(side_effect=ReolinkError("Test error")), + ConfigEntryState.LOADED, + ), + ], +) +async def test_failures_parametrized( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, + attr: str, + value: Any, + expected: ConfigEntryState, +) -> None: + """Test outcomes when changing errors.""" + setattr(reolink_connect, attr, value) + assert await hass.config_entries.async_setup(config_entry.entry_id) is ( + expected == ConfigEntryState.LOADED + ) + await hass.async_block_till_done() + + assert config_entry.state == expected + + +async def test_entry_reloading( + hass: HomeAssistant, config_entry: MockConfigEntry, reolink_connect: MagicMock +) -> None: + """Test the entry is reloaded correctly when settings change.""" + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert reolink_connect.logout.call_count == 0 + assert config_entry.title == "test_reolink_name" + + hass.config_entries.async_update_entry(config_entry, title="New Name") + await hass.async_block_till_done() + + assert reolink_connect.logout.call_count == 1 + assert config_entry.title == "New Name" + + +async def test_http_no_repair_issue( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test no repairs issue is raised when http local url is used.""" + await async_process_ha_core_config( + hass, {"country": "GB", "internal_url": "http://test_homeassistant_address"} + ) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + issue_registry = ir.async_get(hass) + assert (const.DOMAIN, "https_webhook") not in issue_registry.issues + + +async def test_https_repair_issue( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test repairs issue is raised when https local url is used.""" + await async_process_ha_core_config( + hass, {"country": "GB", "internal_url": "https://test_homeassistant_address"} + ) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + issue_registry = ir.async_get(hass) + assert (const.DOMAIN, "https_webhook") in issue_registry.issues From aa2267d68ef3e975a6fb911abb372652b8356067 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 7 Mar 2023 23:21:47 +0100 Subject: [PATCH 0708/2810] Rename hass context variable (#89302) --- homeassistant/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 8650b9a9e3..3c34209cfb 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -149,7 +149,7 @@ MAX_EXPECTED_ENTITY_IDS = 16384 _LOGGER = logging.getLogger(__name__) -_cv_hass: ContextVar[HomeAssistant] = ContextVar("current_entry") +_cv_hass: ContextVar[HomeAssistant] = ContextVar("hass") @functools.lru_cache(MAX_EXPECTED_ENTITY_IDS) From 099f16f6b87d9be775787856e9a8a2e439dd5ea8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Mar 2023 15:19:08 -1000 Subject: [PATCH 0709/2810] Fix missing f-string in async_listen (#89336) --- homeassistant/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 3c34209cfb..e8fb41be32 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1042,7 +1042,7 @@ class EventBus: return self._async_listen_filterable_job( event_type, _FilterableJob( - HassJob(listener, "listen {event_type}"), event_filter, run_immediately + HassJob(listener, f"listen {event_type}"), event_filter, run_immediately ), ) From bde40cde48b96b7bba2484387fdf651821cf9b7a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Mar 2023 15:21:26 -1000 Subject: [PATCH 0710/2810] 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 b945f818d0..eb1e2a5ef6 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 1006fa374c..a551315205 100644 --- a/tests/components/thread/test_diagnostics.py +++ b/tests/components/thread/test_diagnostics.py @@ -133,9 +133,7 @@ class MockNeighbour: @pytest.fixture def ndb() -> Mock: """Prevent NDB poking the OS route tables.""" - with patch( - "homeassistant.components.thread.diagnostics.NDB" - ) as ndb, ndb() as instance: + with patch("pyroute2.NDB") as ndb, ndb() as instance: instance.neighbours = [] instance.routes = [] yield instance From fa128fbcec46c711d38aad5718c4d31bffca1e09 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 7 Mar 2023 20:24:08 -0500 Subject: [PATCH 0711/2810] 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 d32dcf0bda..d0496fe7b6 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 e580242a67..a92631f6da 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -1,9 +1,10 @@ """Tests for ZHA integration init.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH +from homeassistant.components.zha import async_setup_entry from homeassistant.components.zha.core.const import ( CONF_BAUDRATE, CONF_RADIO_TYPE, @@ -108,3 +109,41 @@ async def test_config_depreciation(hass: HomeAssistant, zha_config) -> None: ) as setup_mock: assert await async_setup_component(hass, DOMAIN, {DOMAIN: zha_config}) assert setup_mock.call_count == 1 + + +@pytest.mark.parametrize( + ("path", "cleaned_path"), + [ + ("/dev/path1", "/dev/path1"), + ("/dev/path1 ", "/dev/path1 "), + ("socket://dev/path1 ", "socket://dev/path1"), + ], +) +@patch("homeassistant.components.zha.setup_quirks", Mock(return_value=True)) +@patch("homeassistant.components.zha.api.async_load_api", Mock(return_value=True)) +async def test_setup_with_v3_spaces_in_uri( + hass: HomeAssistant, path: str, cleaned_path: str +) -> None: + """Test migration of config entry from v3 with spaces after `socket://` URI.""" + config_entry_v3 = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_RADIO_TYPE: DATA_RADIO_TYPE, + CONF_DEVICE: {CONF_DEVICE_PATH: path, CONF_BAUDRATE: 115200}, + }, + version=3, + ) + config_entry_v3.add_to_hass(hass) + + with patch( + "homeassistant.components.zha.ZHAGateway", return_value=AsyncMock() + ) as mock_gateway: + mock_gateway.return_value.coordinator_ieee = "mock_ieee" + mock_gateway.return_value.radio_description = "mock_radio" + + assert await async_setup_entry(hass, config_entry_v3) + hass.data[DOMAIN]["zha_gateway"] = mock_gateway.return_value + + assert config_entry_v3.data[CONF_RADIO_TYPE] == DATA_RADIO_TYPE + assert config_entry_v3.data[CONF_DEVICE][CONF_DEVICE_PATH] == cleaned_path + assert config_entry_v3.version == 3 From 008a30618c5d316e04ec80609cce327fc3af2356 Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Tue, 7 Mar 2023 21:06:29 -0500 Subject: [PATCH 0712/2810] 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 40316a6ba3..743e7e4fa1 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 c5dbba9c25..0df823e49b 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 0ca58159d0..055e8881ec 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 7d86bf8a0a..e5486a4581 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -738,7 +738,7 @@ influxdb==5.3.1 inkbird-ble==0.5.6 # homeassistant.components.insteon -insteon-frontend-home-assistant==0.3.2 +insteon-frontend-home-assistant==0.3.3 # homeassistant.components.intellifire intellifire4py==2.2.2 @@ -1212,7 +1212,7 @@ pyialarm==2.2.0 pyicloud==1.0.0 # homeassistant.components.insteon -pyinsteon==1.3.3 +pyinsteon==1.3.4 # homeassistant.components.ipma pyipma==3.0.6 From 3e5e937541dd368f3d70c7557ceaa7a07fd284e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Mar 2023 16:07:24 -1000 Subject: [PATCH 0713/2810] Use a filter for the PersonStorageCollection EVENT_ENTITY_REGISTRY_UPDATED listener (#89335) Avoids creating a task unless a device_tracker is removed --- homeassistant/components/person/__init__.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index e3b719d166..a5e56d0073 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -226,19 +226,22 @@ class PersonStorageCollection(collection.StorageCollection): """Load the Storage collection.""" await super().async_load() self.hass.bus.async_listen( - er.EVENT_ENTITY_REGISTRY_UPDATED, self._entity_registry_updated + er.EVENT_ENTITY_REGISTRY_UPDATED, + self._entity_registry_updated, + event_filter=self._entity_registry_filter, ) - async def _entity_registry_updated(self, event) -> None: + @callback + def _entity_registry_filter(self, event: Event) -> bool: + """Filter entity registry events.""" + return ( + event.data["action"] == "remove" + and split_entity_id(event.data[ATTR_ENTITY_ID])[0] == "device_tracker" + ) + + async def _entity_registry_updated(self, event: Event) -> None: """Handle entity registry updated.""" - if event.data["action"] != "remove": - return - entity_id = event.data[ATTR_ENTITY_ID] - - if split_entity_id(entity_id)[0] != "device_tracker": - return - for person in list(self.data.values()): if entity_id not in person[CONF_DEVICE_TRACKERS]: continue From ff83b8adb8dfecb361e630aabdd50c35e52d1207 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Wed, 8 Mar 2023 00:26:34 -0700 Subject: [PATCH 0714/2810] Bump pybalboa to 1.0.1 (#89310) --- homeassistant/components/balboa/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/balboa/manifest.json b/homeassistant/components/balboa/manifest.json index b81c681f82..152a89bde3 100644 --- a/homeassistant/components/balboa/manifest.json +++ b/homeassistant/components/balboa/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/balboa", "iot_class": "local_push", "loggers": ["pybalboa"], - "requirements": ["pybalboa==1.0.0"] + "requirements": ["pybalboa==1.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 055e8881ec..78badcd39f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1516,7 +1516,7 @@ pyatv==0.10.3 pyaussiebb==0.0.15 # homeassistant.components.balboa -pybalboa==1.0.0 +pybalboa==1.0.1 # homeassistant.components.bbox pybbox==0.0.5-alpha diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e5486a4581..7176b10418 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1107,7 +1107,7 @@ pyatv==0.10.3 pyaussiebb==0.0.15 # homeassistant.components.balboa -pybalboa==1.0.0 +pybalboa==1.0.1 # homeassistant.components.blackbird pyblackbird==0.5 From 58280dc2ec056f9f7cfea5d3c98053fde9b2fe0b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 8 Mar 2023 08:39:15 +0100 Subject: [PATCH 0715/2810] Improve gios generic typing (#89321) --- homeassistant/components/gios/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/gios/__init__.py b/homeassistant/components/gios/__init__.py index 4aad3b0537..213fabc911 100644 --- a/homeassistant/components/gios/__init__.py +++ b/homeassistant/components/gios/__init__.py @@ -2,13 +2,13 @@ from __future__ import annotations import logging -from typing import Any, cast from aiohttp import ClientSession from aiohttp.client_exceptions import ClientConnectorError from async_timeout import timeout from gios import Gios from gios.exceptions import GiosError +from gios.model import GiosSensors from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM from homeassistant.config_entries import ConfigEntry @@ -74,7 +74,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class GiosDataUpdateCoordinator(DataUpdateCoordinator): +class GiosDataUpdateCoordinator(DataUpdateCoordinator[GiosSensors]): """Define an object to hold GIOS data.""" def __init__( @@ -85,10 +85,10 @@ class GiosDataUpdateCoordinator(DataUpdateCoordinator): super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) - async def _async_update_data(self) -> dict[str, Any]: + async def _async_update_data(self) -> GiosSensors: """Update data via library.""" try: async with timeout(API_TIMEOUT): - return cast(dict[str, Any], await self.gios.async_update()) + return await self.gios.async_update() except (GiosError, ClientConnectorError) as error: raise UpdateFailed(error) from error From adb4414440632613dd614a0fd3fc0bee7071747a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Mar 2023 08:43:07 +0100 Subject: [PATCH 0716/2810] Add missing mock in brother config flow tests (#89354) --- tests/components/brother/conftest.py | 14 ++++++++++++++ tests/components/brother/test_config_flow.py | 3 +++ 2 files changed, 17 insertions(+) create mode 100644 tests/components/brother/conftest.py diff --git a/tests/components/brother/conftest.py b/tests/components/brother/conftest.py new file mode 100644 index 0000000000..9e81cce9d1 --- /dev/null +++ b/tests/components/brother/conftest.py @@ -0,0 +1,14 @@ +"""Test fixtures for brother.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.brother.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/brother/test_config_flow.py b/tests/components/brother/test_config_flow.py index 9dc871bb4c..0e1db72dda 100644 --- a/tests/components/brother/test_config_flow.py +++ b/tests/components/brother/test_config_flow.py @@ -3,6 +3,7 @@ import json from unittest.mock import patch from brother import SnmpError, UnsupportedModelError +import pytest from homeassistant import data_entry_flow from homeassistant.components import zeroconf @@ -15,6 +16,8 @@ from tests.common import MockConfigEntry, load_fixture CONFIG = {CONF_HOST: "127.0.0.1", CONF_TYPE: "laser"} +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + async def test_show_form(hass: HomeAssistant) -> None: """Test that the form is served with no input.""" From 30884f6d178ff4b86432325e2a9e76638db82ff6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Mar 2023 09:38:55 +0100 Subject: [PATCH 0717/2810] Add missing mock in axis config flow tests (#89365) --- tests/components/axis/conftest.py | 13 ++++++++++++- tests/components/axis/test_config_flow.py | 19 +++++++++++-------- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/tests/components/axis/conftest.py b/tests/components/axis/conftest.py index 6be1793cc2..5c9c4e5a25 100644 --- a/tests/components/axis/conftest.py +++ b/tests/components/axis/conftest.py @@ -1,8 +1,9 @@ """Axis conftest.""" from __future__ import annotations +from collections.abc import Generator from copy import deepcopy -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from axis.rtsp import Signal, State import pytest @@ -41,6 +42,16 @@ from .const import ( from tests.common import MockConfigEntry from tests.components.light.conftest import mock_light_profiles # noqa: F401 + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.axis.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + # Config entry fixtures diff --git a/tests/components/axis/test_config_flow.py b/tests/components/axis/test_config_flow.py index 2f2b4b52fb..2c5b3a5051 100644 --- a/tests/components/axis/test_config_flow.py +++ b/tests/components/axis/test_config_flow.py @@ -38,16 +38,15 @@ from tests.common import MockConfigEntry @pytest.fixture(name="mock_config_entry") -async def mock_config_entry_fixture(hass, config_entry): +async def mock_config_entry_fixture(hass, config_entry, mock_setup_entry): """Mock config entry and setup entry.""" - with patch("homeassistant.components.axis.async_setup_entry", return_value=True): - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - yield config_entry + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + return config_entry async def test_flow_manual_configuration( - hass: HomeAssistant, setup_default_vapix_requests + hass: HomeAssistant, setup_default_vapix_requests, mock_setup_entry ) -> None: """Test that config flow works.""" MockConfigEntry(domain=AXIS_DOMAIN, source=SOURCE_IGNORE).add_to_hass(hass) @@ -164,7 +163,7 @@ async def test_flow_fails_cannot_connect(hass: HomeAssistant) -> None: async def test_flow_create_entry_multiple_existing_entries_of_same_model( - hass: HomeAssistant, setup_default_vapix_requests + hass: HomeAssistant, setup_default_vapix_requests, mock_setup_entry ) -> None: """Test that create entry can generate a name with other entries.""" entry = MockConfigEntry( @@ -310,7 +309,11 @@ async def test_reauth_flow_update_configuration( ], ) async def test_discovery_flow( - hass: HomeAssistant, setup_default_vapix_requests, source: str, discovery_info: dict + hass: HomeAssistant, + setup_default_vapix_requests, + source: str, + discovery_info: dict, + mock_setup_entry, ) -> None: """Test the different discovery flows for new devices work.""" result = await hass.config_entries.flow.async_init( From 452e1d341de76c27ebaf04eb3039d5bd05de2988 Mon Sep 17 00:00:00 2001 From: Renat Sibgatulin Date: Wed, 8 Mar 2023 09:00:40 +0000 Subject: [PATCH 0718/2810] 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 e46893e8d7..a47c308279 100644 --- a/homeassistant/components/airq/sensor.py +++ b/homeassistant/components/airq/sensor.py @@ -68,7 +68,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ AirQEntityDescription( key="co", name="CO", - device_class=SensorDeviceClass.CO, native_unit_of_measurement=CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("co"), @@ -289,7 +288,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ AirQEntityDescription( key="tvoc", name="VOC", - device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("tvoc"), @@ -297,7 +295,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ AirQEntityDescription( key="tvoc_ionsc", name="VOC (Industrial)", - device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("tvoc_ionsc"), From 9381865f1ce7e78a1d4a3fdf56d11e542de5f24f Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 8 Mar 2023 12:25:51 +0100 Subject: [PATCH 0719/2810] 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 7c50bfa9f0..616fc8b74c 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -65,7 +65,7 @@ NUMBER_ENTITIES = ( get_max_value=lambda api, ch: api.zoom_range(ch)["focus"]["pos"]["max"], supported=lambda api, ch: api.supported(ch, "zoom"), value=lambda api, ch: api.get_focus(ch), - method=lambda api, ch, value: api.set_zoom(ch, int(value)), + method=lambda api, ch, value: api.set_focus(ch, int(value)), ), # "Floodlight turn on brightness" controls the brightness of the floodlight when # it is turned on internally by the camera (see "select.floodlight_mode" entity) From feb3f543bec3f190bd152beea99d09aa5b168ad7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 8 Mar 2023 15:21:11 +0100 Subject: [PATCH 0720/2810] 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 64ef7a718a..2710e14654 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 0d923075bf..762df4f79c 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 2b7145bdca..8a8583a7da 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 dceff75bca..334c7cf719 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 3670d5ca1f..8a9a145f2d 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 a6cd956c95..78ae9643d6 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 ee23d5d350..64e9e1c31c 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 8ef6fa4001..cb1dd639ec 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 52ca535516..67548a19c2 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 611ada6181..b2f9e06cb4 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -153,6 +153,11 @@ async def test_websocket_supervisor_api( msg = await websocket_client.receive_json() assert msg["result"]["version_latest"] == "1.0.0" + assert aioclient_mock.mock_calls[-1][3] == { + "X-Hass-Source": "core.websocket_api", + "Authorization": "Bearer 123456", + } + async def test_websocket_supervisor_api_error( hassio_env, From 2626dd2c83182fa238f078f51f8cb123b7fe1011 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Mar 2023 15:24:19 +0100 Subject: [PATCH 0721/2810] 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 4c63f1c3fa..e7aed366fa 100644 --- a/homeassistant/components/litterrobot/sensor.py +++ b/homeassistant/components/litterrobot/sensor.py @@ -140,7 +140,7 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { name="Pet weight", native_unit_of_measurement=UnitOfMass.POUNDS, device_class=SensorDeviceClass.WEIGHT, - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.MEASUREMENT, ), ], FeederRobot: [ From 5374c70c978ca30c9ea62b586f39bfde73fea5a1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Mar 2023 04:27:34 -1000 Subject: [PATCH 0722/2810] 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 00cc9fff0f..903f14a922 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -227,20 +227,21 @@ class BaseHaRemoteScanner(BaseHaScanner): self.hass, self._async_expire_devices, timedelta(seconds=30) ) cancel_stop = self.hass.bus.async_listen( - EVENT_HOMEASSISTANT_STOP, self._save_history + EVENT_HOMEASSISTANT_STOP, self._async_save_history ) self._async_setup_scanner_watchdog() @hass_callback def _cancel() -> None: - self._save_history() + self._async_save_history() self._async_stop_scanner_watchdog() cancel_track() cancel_stop() return _cancel - def _save_history(self, event: Event | None = None) -> None: + @hass_callback + def _async_save_history(self, event: Event | None = None) -> None: """Save the history.""" self._storage.async_set_advertisement_history( self.source, @@ -252,6 +253,7 @@ class BaseHaRemoteScanner(BaseHaScanner): ), ) + @hass_callback def _async_expire_devices(self, _datetime: datetime.datetime) -> None: """Expire old devices.""" now = MONOTONIC_TIME() From 2ec78ae70e7d7ee029fd0690d9073507a1979a9a Mon Sep 17 00:00:00 2001 From: Florent Thoumie Date: Wed, 8 Mar 2023 06:37:24 -0800 Subject: [PATCH 0723/2810] 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 cbdf909001..225953035a 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 b047af5869..87bc863a7f 100644 --- a/homeassistant/components/iaqualink/utils.py +++ b/homeassistant/components/iaqualink/utils.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Awaitable +import httpx from iaqualink.exception import AqualinkServiceException from homeassistant.exceptions import HomeAssistantError @@ -12,5 +13,5 @@ async def await_or_reraise(awaitable: Awaitable) -> None: """Execute API call while catching service exceptions.""" try: await awaitable - except AqualinkServiceException as svc_exception: + except (AqualinkServiceException, httpx.HTTPError) as svc_exception: raise HomeAssistantError(f"Aqualink error: {svc_exception}") from svc_exception From ea6a95176de6dfbd85da61a772a80cb7aaca5876 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Mar 2023 16:16:28 +0100 Subject: [PATCH 0724/2810] Add missing mock in azure event hub config flow tests (#89355) --- tests/components/azure_event_hub/conftest.py | 2 +- tests/components/azure_event_hub/test_config_flow.py | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/components/azure_event_hub/conftest.py b/tests/components/azure_event_hub/conftest.py index 18f44b1048..1af24d4a6f 100644 --- a/tests/components/azure_event_hub/conftest.py +++ b/tests/components/azure_event_hub/conftest.py @@ -117,7 +117,7 @@ def mock_from_connection_string_fixture(): yield from_conn_string -@pytest.fixture(name="mock_setup_entry") +@pytest.fixture def mock_setup_entry(): """Mock the setup entry call, used for config flow tests.""" with patch( diff --git a/tests/components/azure_event_hub/test_config_flow.py b/tests/components/azure_event_hub/test_config_flow.py index f49f6470a9..8cebbe6fbd 100644 --- a/tests/components/azure_event_hub/test_config_flow.py +++ b/tests/components/azure_event_hub/test_config_flow.py @@ -1,5 +1,6 @@ """Test the AEH config flow.""" import logging +from unittest.mock import AsyncMock from azure.eventhub.exceptions import EventHubError import pytest @@ -29,6 +30,8 @@ from tests.common import MockConfigEntry _LOGGER = logging.getLogger(__name__) +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + @pytest.mark.parametrize( ("step1_config", "step_id", "step2_config", "data_config"), @@ -40,7 +43,7 @@ _LOGGER = logging.getLogger(__name__) ) async def test_form( hass: HomeAssistant, - mock_setup_entry, + mock_setup_entry: AsyncMock, mock_from_connection_string, step1_config, step_id, @@ -70,7 +73,7 @@ async def test_form( mock_setup_entry.assert_called_once() -async def test_import(hass: HomeAssistant, mock_setup_entry) -> None: +async def test_import(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" import_config = IMPORT_CONFIG.copy() From f4572a2e1c5dc8e76d87e63e16a61d192793f044 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Mar 2023 16:16:51 +0100 Subject: [PATCH 0725/2810] Add missing mock in atag config flow tests (#89356) --- tests/components/atag/conftest.py | 12 +++++++++++- tests/components/atag/test_config_flow.py | 8 +++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/components/atag/conftest.py b/tests/components/atag/conftest.py index 7df3bfecf1..a127069654 100644 --- a/tests/components/atag/conftest.py +++ b/tests/components/atag/conftest.py @@ -1,10 +1,20 @@ """Provide common Atag fixtures.""" import asyncio -from unittest.mock import patch +from collections.abc import Generator +from unittest.mock import AsyncMock, patch import pytest +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.atag.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(autouse=True) async def mock_pyatag_sleep(): """Mock out pyatag sleeps.""" diff --git a/tests/components/atag/test_config_flow.py b/tests/components/atag/test_config_flow.py index 87435b66a7..8dc73741e9 100644 --- a/tests/components/atag/test_config_flow.py +++ b/tests/components/atag/test_config_flow.py @@ -1,6 +1,8 @@ """Tests for the Atag config flow.""" from unittest.mock import PropertyMock, patch +import pytest + from homeassistant import config_entries, data_entry_flow from homeassistant.components.atag import DOMAIN from homeassistant.core import HomeAssistant @@ -9,6 +11,8 @@ from . import UID, USER_INPUT, init_integration, mock_connection from tests.test_util.aiohttp import AiohttpClientMocker +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + async def test_show_form( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker @@ -27,7 +31,9 @@ async def test_adding_second_device( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test that only one Atag configuration is allowed.""" - await init_integration(hass, aioclient_mock) + entry = await init_integration(hass, aioclient_mock) + entry.unique_id = UID + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, data=USER_INPUT ) From b61ad43144d86f3518fff513f5ccdaa2bb11cf7e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Mar 2023 16:17:45 +0100 Subject: [PATCH 0726/2810] Add missing mock in amber config flow tests (#89358) --- tests/components/amberelectric/conftest.py | 14 ++++++++++++++ tests/components/amberelectric/test_config_flow.py | 2 ++ 2 files changed, 16 insertions(+) create mode 100644 tests/components/amberelectric/conftest.py diff --git a/tests/components/amberelectric/conftest.py b/tests/components/amberelectric/conftest.py new file mode 100644 index 0000000000..f7d7d6623e --- /dev/null +++ b/tests/components/amberelectric/conftest.py @@ -0,0 +1,14 @@ +"""Provide common Amber fixtures.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.amberelectric.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/amberelectric/test_config_flow.py b/tests/components/amberelectric/test_config_flow.py index 2be77f19bf..6325282aff 100644 --- a/tests/components/amberelectric/test_config_flow.py +++ b/tests/components/amberelectric/test_config_flow.py @@ -20,6 +20,8 @@ from homeassistant.core import HomeAssistant API_KEY = "psk_123456789" +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + @pytest.fixture(name="invalid_key_api") def mock_invalid_key_api() -> Generator: From 959c2205d511aa7991ee209cf6e9d7549595b363 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Mar 2023 16:18:07 +0100 Subject: [PATCH 0727/2810] Add missing mock in airvisual config flow tests (#89359) --- tests/components/airvisual/conftest.py | 10 ++++++++++ tests/components/airvisual/test_config_flow.py | 2 ++ 2 files changed, 12 insertions(+) diff --git a/tests/components/airvisual/conftest.py b/tests/components/airvisual/conftest.py index f6c1619d6d..bdd325d473 100644 --- a/tests/components/airvisual/conftest.py +++ b/tests/components/airvisual/conftest.py @@ -1,4 +1,5 @@ """Define test fixtures for AirVisual.""" +from collections.abc import Generator import json from unittest.mock import AsyncMock, Mock, patch @@ -141,3 +142,12 @@ async def setup_config_entry_fixture(hass, config_entry, mock_pyairvisual): """Define a fixture to set up airvisual.""" assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.airvisual.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/airvisual/test_config_flow.py b/tests/components/airvisual/test_config_flow.py index b07a17972f..1761f55d17 100644 --- a/tests/components/airvisual/test_config_flow.py +++ b/tests/components/airvisual/test_config_flow.py @@ -32,6 +32,8 @@ from .conftest import ( TEST_STATE, ) +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + @pytest.mark.parametrize( ("integration_type", "input_form_step", "patched_method", "config", "entry_title"), From 23698eb99fcbf091bab82cbfb22cb3dfea9e17d9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Mar 2023 16:18:17 +0100 Subject: [PATCH 0728/2810] Add missing mock in agent_dvr config flow tests (#89361) --- tests/components/agent_dvr/conftest.py | 14 ++++++++++++++ tests/components/agent_dvr/test_config_flow.py | 4 ++++ 2 files changed, 18 insertions(+) create mode 100644 tests/components/agent_dvr/conftest.py diff --git a/tests/components/agent_dvr/conftest.py b/tests/components/agent_dvr/conftest.py new file mode 100644 index 0000000000..da2cd90ed1 --- /dev/null +++ b/tests/components/agent_dvr/conftest.py @@ -0,0 +1,14 @@ +"""Test fixtures for Agent DVR.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.agent_dvr.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/agent_dvr/test_config_flow.py b/tests/components/agent_dvr/test_config_flow.py index b36044e45b..9d1be278ad 100644 --- a/tests/components/agent_dvr/test_config_flow.py +++ b/tests/components/agent_dvr/test_config_flow.py @@ -1,4 +1,6 @@ """Tests for the Agent DVR config flow.""" +import pytest + from homeassistant import data_entry_flow from homeassistant.components.agent_dvr import config_flow from homeassistant.components.agent_dvr.const import SERVER_URL @@ -11,6 +13,8 @@ from . import init_integration from tests.common import load_fixture from tests.test_util.aiohttp import AiohttpClientMocker +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + async def test_show_user_form(hass: HomeAssistant) -> None: """Test that the user set up form is served.""" From 3a40f5f35b691c728b16ca5486139b9c59d5aef9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Mar 2023 16:18:24 +0100 Subject: [PATCH 0729/2810] Add missing mock in airvisual_pro config flow tests (#89362) --- tests/components/airvisual_pro/conftest.py | 10 ++++++++++ tests/components/airvisual_pro/test_config_flow.py | 2 ++ 2 files changed, 12 insertions(+) diff --git a/tests/components/airvisual_pro/conftest.py b/tests/components/airvisual_pro/conftest.py index 5846a98868..caff957181 100644 --- a/tests/components/airvisual_pro/conftest.py +++ b/tests/components/airvisual_pro/conftest.py @@ -1,4 +1,5 @@ """Define test fixtures for AirVisual Pro.""" +from collections.abc import Generator import json from unittest.mock import AsyncMock, Mock, patch @@ -11,6 +12,15 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, load_fixture +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.airvisual_pro.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(name="config_entry") def config_entry_fixture(hass, config): """Define a config entry fixture.""" diff --git a/tests/components/airvisual_pro/test_config_flow.py b/tests/components/airvisual_pro/test_config_flow.py index 8ffc3d4d5b..f1c6d93e35 100644 --- a/tests/components/airvisual_pro/test_config_flow.py +++ b/tests/components/airvisual_pro/test_config_flow.py @@ -14,6 +14,8 @@ from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH, SOURCE_US from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + @pytest.mark.parametrize( ("connect_mock", "connect_errors"), From 33906059d361d9c20eb4e97e937dc552428e76f8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Mar 2023 16:18:31 +0100 Subject: [PATCH 0730/2810] Add missing mock in airq config flow tests (#89364) --- tests/components/airq/conftest.py | 14 ++++++++++++++ tests/components/airq/test_config_flow.py | 3 +++ 2 files changed, 17 insertions(+) create mode 100644 tests/components/airq/conftest.py diff --git a/tests/components/airq/conftest.py b/tests/components/airq/conftest.py new file mode 100644 index 0000000000..28053c9c20 --- /dev/null +++ b/tests/components/airq/conftest.py @@ -0,0 +1,14 @@ +"""Test fixtures for air-Q.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.airq.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/airq/test_config_flow.py b/tests/components/airq/test_config_flow.py index 38fc15fdae..52bd5cd37f 100644 --- a/tests/components/airq/test_config_flow.py +++ b/tests/components/airq/test_config_flow.py @@ -3,6 +3,7 @@ from unittest.mock import patch from aioairq.core import DeviceInfo, InvalidAuth, InvalidInput from aiohttp.client_exceptions import ClientConnectionError +import pytest from homeassistant import config_entries from homeassistant.components.airq.const import DOMAIN @@ -10,6 +11,8 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + TEST_USER_DATA = { CONF_IP_ADDRESS: "192.168.0.0", CONF_PASSWORD: "password", From 3e2ee7cd11022a9fa2adf24c2d0f4951b1893a8c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Mar 2023 16:18:44 +0100 Subject: [PATCH 0731/2810] Add missing mock in aemet config flow tests (#89360) --- tests/components/aemet/conftest.py | 14 ++++++++++++++ tests/components/aemet/test_config_flow.py | 12 ++++++------ 2 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 tests/components/aemet/conftest.py diff --git a/tests/components/aemet/conftest.py b/tests/components/aemet/conftest.py new file mode 100644 index 0000000000..606f01c540 --- /dev/null +++ b/tests/components/aemet/conftest.py @@ -0,0 +1,14 @@ +"""Test fixtures for aemet.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.aemet.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/aemet/test_config_flow.py b/tests/components/aemet/test_config_flow.py index 9abf626a5d..8ec16d313f 100644 --- a/tests/components/aemet/test_config_flow.py +++ b/tests/components/aemet/test_config_flow.py @@ -1,6 +1,7 @@ """Define tests for the AEMET OpenData config flow.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch +import pytest import requests_mock from homeassistant import data_entry_flow @@ -14,6 +15,8 @@ from .util import aemet_requests_mock from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + CONFIG = { CONF_NAME: "aemet", CONF_API_KEY: "foo", @@ -22,13 +25,10 @@ CONFIG = { } -async def test_form(hass: HomeAssistant) -> None: +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test that the form is served with valid input.""" - with patch( - "homeassistant.components.aemet.async_setup_entry", - return_value=True, - ) as mock_setup_entry, requests_mock.mock() as _m: + with requests_mock.mock() as _m: aemet_requests_mock(_m) result = await hass.config_entries.flow.async_init( From 4ce36366c3d9a21c5a39590294ba1671e58c813a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Mar 2023 05:19:36 -1000 Subject: [PATCH 0732/2810] Add names to the config entry setup and shutdown tasks (#89309) * name the entry setup tasks * name a few more tasks * Update homeassistant/config_entries.py * Update homeassistant/setup.py --- homeassistant/config_entries.py | 29 +++++++++++++++++++++++++---- homeassistant/setup.py | 5 ++++- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 29788a678a..41cccdf996 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1131,7 +1131,13 @@ class ConfigEntries: async def _async_shutdown(self, event: Event) -> None: """Call when Home Assistant is stopping.""" await asyncio.gather( - *(entry.async_shutdown() for entry in self._entries.values()) + *( + asyncio.create_task( + entry.async_shutdown(), + name=f"config entry shutdown {entry.title} {entry.domain} {entry.entry_id}", + ) + for entry in self._entries.values() + ) ) await self.flow.async_shutdown() @@ -1390,7 +1396,13 @@ class ConfigEntries: ) -> None: """Forward the setup of an entry to platforms.""" await asyncio.gather( - *(self.async_forward_entry_setup(entry, platform) for platform in platforms) + *( + asyncio.create_task( + self.async_forward_entry_setup(entry, platform), + name=f"config entry forward setup {entry.title} {entry.domain} {entry.entry_id} {platform}", + ) + for platform in platforms + ) ) async def async_forward_entry_setup( @@ -1421,7 +1433,10 @@ class ConfigEntries: return all( await asyncio.gather( *( - self.async_forward_entry_unload(entry, platform) + asyncio.create_task( + self.async_forward_entry_unload(entry, platform), + name=f"config entry forward unload {entry.title} {entry.domain} {entry.entry_id} {platform}", + ) for platform in platforms ) ) @@ -1952,7 +1967,13 @@ class EntityRegistryDisabledHandler: ) await asyncio.gather( - *(self.hass.config_entries.async_reload(entry_id) for entry_id in to_reload) + *( + asyncio.create_task( + self.hass.config_entries.async_reload(entry_id), + name="config entry reload {entry.title} {entry.domain} {entry.entry_id}", + ) + for entry_id in to_reload + ) ) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index df5d825708..ce502116cf 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -295,7 +295,10 @@ async def _async_setup_component( await asyncio.gather( *( - entry.async_setup(hass, integration=integration) + asyncio.create_task( + entry.async_setup(hass, integration=integration), + name=f"config entry setup {entry.title} {entry.domain} {entry.entry_id}", + ) for entry in hass.config_entries.async_entries(domain) ) ) From 322eb4bd8315cae4a11f9fa5b43a21c12602ea57 Mon Sep 17 00:00:00 2001 From: Andrew Westrope Date: Sun, 5 Mar 2023 12:51:02 +0000 Subject: [PATCH 0733/2810] 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 21ef280936..c2b32582ce 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 cf29d0ea80..79ba418d50 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 ea8b1a4396..f8cf7288e5 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 0734/2810] 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 56a07a6bcf..5c0a60ecef 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 c7fc375008..90e244aaf0 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 e8008eb49b..fa5c6aac29 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 570bb980ab..61da000fc0 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 0735/2810] 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 431bc78ba8..0b8fe9243b 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 294c521762..c90447f1c9 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 3ff6b62b21..bfdd8ff5b1 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 44c3ffac99..19c7e6c695 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 c5ce8d272c..07c935129e 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 8685985def..dd51946c86 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 0736/2810] 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 6522d3b06e..6dd2104bd9 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 d11e4d6602..35de4ee44b 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 22d6a64361..27147b8fc3 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 0737/2810] 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 6b958b7d25..f6a75abe6d 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 fd32b357b7..cf3fddf594 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 0738/2810] 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 1a2d0c526d..ffc00e6f92 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 0739/2810] 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 fde8c26ba5..e8e4e4876f 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 35de4ee44b..b7b3d5ac6f 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 27147b8fc3..b9110a0bed 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 0740/2810] 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 c09f2d501c..da68e48cc0 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 3a19985363..2c6712aa75 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 b7b3d5ac6f..5c1f91cd73 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 b9110a0bed..a18d25652e 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 0741/2810] 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 bd629d53fc..119c7c946a 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 0742/2810] 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 f40f866808..ed885127b1 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 e3efa81e44..bdedbb9b20 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 2c6712aa75..4e4786e7ed 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 5c1f91cd73..ac63cec4b4 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 a18d25652e..d090030192 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 0743/2810] 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 fb1a55cebf..b67790f9a4 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 c90447f1c9..bd11744ab0 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 dd51946c86..e6ae291264 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 0744/2810] 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 cb559a7d7b..e782f05092 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 d69f06f698..bdcadc84e7 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 ac63cec4b4..1df6d46467 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 0745/2810] 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 07f122fa4b..4873acf753 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 f84441d249..5f4aadce7e 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 0746/2810] 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 d32dcf0bda..d0496fe7b6 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 e580242a67..a92631f6da 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 0747/2810] 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 40316a6ba3..743e7e4fa1 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 c5dbba9c25..0df823e49b 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 1df6d46467..fb17dc6d4d 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 d090030192..bd5fe240e3 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 0748/2810] 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 b945f818d0..eb1e2a5ef6 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 1006fa374c..a551315205 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 0749/2810] 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 e46893e8d7..a47c308279 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 0750/2810] 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 cbdf909001..225953035a 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 b047af5869..87bc863a7f 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 614a1b03c12547912c245887e8e6f9d15e2be94c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Mar 2023 05:23:13 -1000 Subject: [PATCH 0751/2810] Use an event filter for event triggers (#89339) We avoid the overhead of call_soon and event loop scheduling if the event does not match the schema --- .../components/homeassistant/triggers/event.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/homeassistant/triggers/event.py b/homeassistant/components/homeassistant/triggers/event.py index 0796d49d77..d84b04c365 100644 --- a/homeassistant/components/homeassistant/triggers/event.py +++ b/homeassistant/components/homeassistant/triggers/event.py @@ -80,11 +80,11 @@ async def async_attach_trigger( extra=vol.ALLOW_EXTRA, ) - job = HassJob(action) + job = HassJob(action, f"event trigger {trigger_info}") @callback - def handle_event(event: Event) -> None: - """Listen for events and calls the action when data matches.""" + def filter_event(event: Event) -> bool: + """Filter events.""" try: # Check that the event data and context match the configured # schema if one was provided @@ -94,8 +94,12 @@ async def async_attach_trigger( event_context_schema(event.context.as_dict()) except vol.Invalid: # If event doesn't match, skip event - return + return False + return True + @callback + def handle_event(event: Event) -> None: + """Listen for events and calls the action when data matches.""" hass.async_run_hass_job( job, { @@ -110,7 +114,8 @@ async def async_attach_trigger( ) removes = [ - hass.bus.async_listen(event_type, handle_event) for event_type in event_types + hass.bus.async_listen(event_type, handle_event, event_filter=filter_event) + for event_type in event_types ] @callback From 563bd4a0dd6e19f43026f8352514a55cb6fd2326 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Mar 2023 04:27:34 -1000 Subject: [PATCH 0752/2810] 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 00cc9fff0f..903f14a922 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 0753/2810] 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 e9b692fffe..7c807ddadc 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 0754/2810] 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 64ef7a718a..2710e14654 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 0d923075bf..762df4f79c 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 2b7145bdca..8a8583a7da 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 dceff75bca..334c7cf719 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 3670d5ca1f..8a9a145f2d 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 a6cd956c95..78ae9643d6 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 ee23d5d350..64e9e1c31c 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 8ef6fa4001..cb1dd639ec 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 52ca535516..67548a19c2 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 611ada6181..b2f9e06cb4 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 0755/2810] 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 4c63f1c3fa..e7aed366fa 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 0756/2810] 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 1ec896a415..e90ccad63e 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 1a81cc5f50..34e8826764 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 aff7345ea09d2a0e7ed6e5be11ae97b8af01f2b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Mar 2023 05:25:42 -1000 Subject: [PATCH 0757/2810] Improve event filters to reject earlier (#89337) * Improve event filters to reject earlier - Avoid running the callbacks for state added/removed from a domain if there are no listeners that care about the domain - Remove some impossible checks in the listeners that will never match since they were already rejected by the filter * leave one guard since there is a race when we return control via await --- homeassistant/helpers/event.py | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index a924d9cb88..edbb5fa735 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -412,18 +412,21 @@ def async_track_entity_registry_updated_event( return remove_listener +@callback +def _async_domain_has_listeners( + domain: str, callbacks: dict[str, list[HassJob[[Event], Any]]] +) -> bool: + """Check if the domain has any listeners.""" + return domain in callbacks or MATCH_ALL in callbacks + + @callback def _async_dispatch_domain_event( hass: HomeAssistant, event: Event, callbacks: dict[str, list[HassJob[[Event], Any]]] ) -> None: + """Dispatch domain event listeners.""" domain = split_entity_id(event.data["entity_id"])[0] - - if domain not in callbacks and MATCH_ALL not in callbacks: - return - - listeners = callbacks.get(domain, []) + callbacks.get(MATCH_ALL, []) - - for job in listeners: + for job in callbacks.get(domain, []) + callbacks.get(MATCH_ALL, []): try: hass.async_run_hass_job(job, event) except Exception: # pylint: disable=broad-except @@ -460,14 +463,13 @@ def _async_track_state_added_domain( @callback def _async_state_change_filter(event: Event) -> bool: """Filter state changes by entity_id.""" - return event.data.get("old_state") is None + return event.data.get("old_state") is None and _async_domain_has_listeners( + split_entity_id(event.data["entity_id"])[0], domain_callbacks + ) @callback def _async_state_change_dispatcher(event: Event) -> None: """Dispatch state changes by entity_id.""" - if event.data.get("old_state") is not None: - return - _async_dispatch_domain_event(hass, event, domain_callbacks) hass.data[TRACK_STATE_ADDED_DOMAIN_LISTENER] = hass.bus.async_listen( @@ -514,14 +516,13 @@ def async_track_state_removed_domain( @callback def _async_state_change_filter(event: Event) -> bool: """Filter state changes by entity_id.""" - return event.data.get("new_state") is None + return event.data.get("new_state") is None and _async_domain_has_listeners( + split_entity_id(event.data["entity_id"])[0], domain_callbacks + ) @callback def _async_state_change_dispatcher(event: Event) -> None: """Dispatch state changes by entity_id.""" - if event.data.get("new_state") is not None: - return - _async_dispatch_domain_event(hass, event, domain_callbacks) hass.data[TRACK_STATE_REMOVED_DOMAIN_LISTENER] = hass.bus.async_listen( From 7982f713e156a44439f35edaf87a536305e59997 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Mar 2023 16:53:08 +0100 Subject: [PATCH 0758/2810] Fix lingering tasks in plex (#89282) * Cleanup expected_lingering_tasks in plex * Adjust --- tests/components/plex/test_config_flow.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index 0bc2d04b41..beb454e2e9 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -168,10 +168,11 @@ async def test_no_servers_found( assert result["errors"]["base"] == "no_servers" -# This tests needs to be adjusted to remove lingering tasks -@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_single_available_server( - hass: HomeAssistant, mock_plex_calls, current_request_with_host: None + hass: HomeAssistant, + mock_plex_calls, + current_request_with_host: None, + mock_setup_entry: AsyncMock, ) -> None: """Test creating an entry with one server available.""" result = await hass.config_entries.flow.async_init( @@ -205,17 +206,16 @@ async def test_single_available_server( ) assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN - await hass.config_entries.async_unload(result["result"].entry_id) + mock_setup_entry.assert_called_once() -# This tests needs to be adjusted to remove lingering tasks -@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_multiple_servers_with_selection( hass: HomeAssistant, mock_plex_calls, requests_mock: requests_mock.Mocker, plextv_resources_two_servers, current_request_with_host: None, + mock_setup_entry: AsyncMock, ) -> None: """Test creating an entry with multiple servers available.""" result = await hass.config_entries.flow.async_init( @@ -262,17 +262,16 @@ async def test_multiple_servers_with_selection( ) assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN - await hass.config_entries.async_unload(result["result"].entry_id) + mock_setup_entry.assert_called_once() -# This tests needs to be adjusted to remove lingering tasks -@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_adding_last_unconfigured_server( hass: HomeAssistant, mock_plex_calls, requests_mock: requests_mock.Mocker, plextv_resources_two_servers, current_request_with_host: None, + mock_setup_entry: AsyncMock, ) -> None: """Test automatically adding last unconfigured server when multiple servers on account.""" MockConfigEntry( @@ -319,7 +318,7 @@ async def test_adding_last_unconfigured_server( ) assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN - await hass.config_entries.async_unload(result["result"].entry_id) + assert mock_setup_entry.call_count == 2 async def test_all_available_servers_configured( From bfb89fd8f2cb440380d7814eeed2817817dbf3e4 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 8 Mar 2023 16:54:19 +0100 Subject: [PATCH 0759/2810] Update pylint to 2.17.0 (#89377) * Update pylint to 2.17.0 * Remove unused pylint disable comments --- homeassistant/components/recorder/statistics.py | 5 +---- homeassistant/components/reolink/__init__.py | 2 +- homeassistant/components/thread/diagnostics.py | 4 ++-- homeassistant/runner.py | 2 +- requirements_test.txt | 4 ++-- 5 files changed, 7 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 4cc6e40fa6..48bab4b11f 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -1940,10 +1940,7 @@ def _latest_short_term_statistics_stmt( .group_by(StatisticsShortTerm.metadata_id) ).subquery() ), - ( - StatisticsShortTerm.metadata_id # pylint: disable=comparison-with-callable - == most_recent_statistic_row.c.metadata_id - ) + (StatisticsShortTerm.metadata_id == most_recent_statistic_row.c.metadata_id) & (StatisticsShortTerm.start_ts == most_recent_statistic_row.c.start_max), ) return stmt diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index c3d8df61f5..76c0963e2c 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -67,7 +67,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b raise ConfigEntryNotReady( f"Error while trying to setup {host.api.host}:{host.api.port}: {str(err)}" ) from err - except Exception: # pylint: disable=broad-except + except Exception: await host.stop() raise diff --git a/homeassistant/components/thread/diagnostics.py b/homeassistant/components/thread/diagnostics.py index eb1e2a5ef6..8dc5dd4304 100644 --- a/homeassistant/components/thread/diagnostics.py +++ b/homeassistant/components/thread/diagnostics.py @@ -29,7 +29,7 @@ from .dataset_store import async_get_store from .discovery import async_read_zeroconf_cache if TYPE_CHECKING: - from pyroute2 import NDB # pylint: disable=no-name-in-module + from pyroute2 import NDB class Neighbour(TypedDict): @@ -121,7 +121,7 @@ def _get_routes_and_neighbors(): NDB, ) - with NDB() as ndb: # pylint: disable=not-callable + with NDB() as ndb: routes, reverse_routes = _get_possible_thread_routes(ndb) neighbours = _get_neighbours(ndb) diff --git a/homeassistant/runner.py b/homeassistant/runner.py index 8c5766cbb2..0926fb6745 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -62,7 +62,7 @@ def can_use_pidfd() -> bool: return False try: pid = os.getpid() - os.close(os.pidfd_open(pid, 0)) # pylint: disable=no-member + os.close(os.pidfd_open(pid, 0)) except OSError: # blocked by security policy like SECCOMP return False diff --git a/requirements_test.txt b/requirements_test.txt index 9e6ded1f39..f5be4d0759 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,7 +7,7 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt -astroid==2.14.2 +astroid==2.15.0 codecov==2.1.12 coverage==7.2.1 freezegun==1.2.2 @@ -15,7 +15,7 @@ mock-open==1.4.0 mypy==1.0.1 pre-commit==3.1.0 pydantic==1.10.5 -pylint==2.16.4 +pylint==2.17.0 pylint-per-file-ignores==1.1.0 pipdeptree==2.5.0 pytest-asyncio==0.20.3 From b0013247ff5e00874706a90f69e95568c0cee821 Mon Sep 17 00:00:00 2001 From: Vincent Knoop Pathuis <48653141+vpathuis@users.noreply.github.com> Date: Wed, 8 Mar 2023 16:56:04 +0100 Subject: [PATCH 0760/2810] Move Landis+Gyr sensor descriptions to sensor platform (#89382) Move HEAT_METER_SENSOR_TYPES to sensor platform --- .../components/landisgyr_heat_meter/const.py | 206 ------------------ .../components/landisgyr_heat_meter/sensor.py | 204 ++++++++++++++++- 2 files changed, 203 insertions(+), 207 deletions(-) diff --git a/homeassistant/components/landisgyr_heat_meter/const.py b/homeassistant/components/landisgyr_heat_meter/const.py index bded296f3f..5d27a8a170 100644 --- a/homeassistant/components/landisgyr_heat_meter/const.py +++ b/homeassistant/components/landisgyr_heat_meter/const.py @@ -1,212 +1,6 @@ """Constants for the Landis+Gyr Heat Meter integration.""" -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntityDescription, - SensorStateClass, -) -from homeassistant.const import ( - EntityCategory, - UnitOfEnergy, - UnitOfPower, - UnitOfTemperature, - UnitOfTime, - UnitOfVolume, - UnitOfVolumeFlowRate, -) - DOMAIN = "landisgyr_heat_meter" GJ_TO_MWH = 0.277778 # conversion factor ULTRAHEAT_TIMEOUT = 30 # reading the IR port can take some time - -HEAT_METER_SENSOR_TYPES = ( - SensorEntityDescription( - key="heat_usage", - icon="mdi:fire", - name="Heat usage", - native_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, - ), - SensorEntityDescription( - key="volume_usage_m3", - icon="mdi:fire", - name="Volume usage", - device_class=SensorDeviceClass.VOLUME, - native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, - state_class=SensorStateClass.TOTAL, - ), - # Diagnostic entity for debugging, this will match the value in GJ indicated on the meter's display - SensorEntityDescription( - key="heat_usage_gj", - icon="mdi:fire", - name="Heat usage GJ", - native_unit_of_measurement="GJ", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="heat_previous_year", - icon="mdi:fire", - name="Heat usage previous year", - native_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - entity_category=EntityCategory.DIAGNOSTIC, - ), - # Diagnostic entity for debugging, this will match the value in GJ of previous year indicated on the meter's display - SensorEntityDescription( - key="heat_previous_year_gj", - icon="mdi:fire", - name="Heat previous year GJ", - native_unit_of_measurement="GJ", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="volume_previous_year_m3", - icon="mdi:fire", - name="Volume usage previous year", - device_class=SensorDeviceClass.VOLUME, - native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="ownership_number", - name="Ownership number", - icon="mdi:identifier", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="error_number", - name="Error number", - icon="mdi:home-alert", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="device_number", - name="Device number", - icon="mdi:identifier", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="measurement_period_minutes", - name="Measurement period minutes", - device_class=SensorDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.MINUTES, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="power_max_kw", - name="Power max", - native_unit_of_measurement=UnitOfPower.KILO_WATT, - device_class=SensorDeviceClass.POWER, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="power_max_previous_year_kw", - name="Power max previous year", - native_unit_of_measurement=UnitOfPower.KILO_WATT, - device_class=SensorDeviceClass.POWER, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="flowrate_max_m3ph", - name="Flowrate max", - native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, - icon="mdi:water-outline", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="flowrate_max_previous_year_m3ph", - name="Flowrate max previous year", - native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, - icon="mdi:water-outline", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="return_temperature_max_c", - name="Return temperature max", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="return_temperature_max_previous_year_c", - name="Return temperature max previous year", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="flow_temperature_max_c", - name="Flow temperature max", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="flow_temperature_max_previous_year_c", - name="Flow temperature max previous year", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="operating_hours", - name="Operating hours", - device_class=SensorDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.HOURS, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="flow_hours", - name="Flow hours", - device_class=SensorDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.HOURS, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="fault_hours", - name="Fault hours", - device_class=SensorDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.HOURS, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="fault_hours_previous_year", - name="Fault hours previous year", - device_class=SensorDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.HOURS, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="yearly_set_day", - name="Yearly set day", - icon="mdi:clock-outline", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="monthly_set_day", - name="Monthly set day", - icon="mdi:clock-outline", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="meter_date_time", - name="Meter date time", - icon="mdi:clock-outline", - device_class=SensorDeviceClass.TIMESTAMP, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="measuring_range_m3ph", - name="Measuring range", - native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, - icon="mdi:water-outline", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="settings_and_firmware", - name="Settings and firmware", - entity_category=EntityCategory.DIAGNOSTIC, - ), -) diff --git a/homeassistant/components/landisgyr_heat_meter/sensor.py b/homeassistant/components/landisgyr_heat_meter/sensor.py index 284fb5b7f3..8ded9e4d72 100644 --- a/homeassistant/components/landisgyr_heat_meter/sensor.py +++ b/homeassistant/components/landisgyr_heat_meter/sensor.py @@ -10,8 +10,18 @@ from homeassistant.components.sensor import ( RestoreSensor, SensorDeviceClass, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + EntityCategory, + UnitOfEnergy, + UnitOfPower, + UnitOfTemperature, + UnitOfTime, + UnitOfVolume, + UnitOfVolumeFlowRate, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -22,11 +32,203 @@ from homeassistant.helpers.update_coordinator import ( from homeassistant.util import dt as dt_util from . import DOMAIN -from .const import GJ_TO_MWH, HEAT_METER_SENSOR_TYPES +from .const import GJ_TO_MWH _LOGGER = logging.getLogger(__name__) +HEAT_METER_SENSOR_TYPES = ( + SensorEntityDescription( + key="heat_usage", + icon="mdi:fire", + name="Heat usage", + native_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + key="volume_usage_m3", + icon="mdi:fire", + name="Volume usage", + device_class=SensorDeviceClass.VOLUME, + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + state_class=SensorStateClass.TOTAL, + ), + # Diagnostic entity for debugging, this will match the value in GJ indicated on the meter's display + SensorEntityDescription( + key="heat_usage_gj", + icon="mdi:fire", + name="Heat usage GJ", + native_unit_of_measurement="GJ", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="heat_previous_year", + icon="mdi:fire", + name="Heat usage previous year", + native_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + entity_category=EntityCategory.DIAGNOSTIC, + ), + # Diagnostic entity for debugging, this will match the value in GJ of previous year indicated on the meter's display + SensorEntityDescription( + key="heat_previous_year_gj", + icon="mdi:fire", + name="Heat previous year GJ", + native_unit_of_measurement="GJ", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="volume_previous_year_m3", + icon="mdi:fire", + name="Volume usage previous year", + device_class=SensorDeviceClass.VOLUME, + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="ownership_number", + name="Ownership number", + icon="mdi:identifier", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="error_number", + name="Error number", + icon="mdi:home-alert", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="device_number", + name="Device number", + icon="mdi:identifier", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="measurement_period_minutes", + name="Measurement period minutes", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="power_max_kw", + name="Power max", + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="power_max_previous_year_kw", + name="Power max previous year", + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="flowrate_max_m3ph", + name="Flowrate max", + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + icon="mdi:water-outline", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="flowrate_max_previous_year_m3ph", + name="Flowrate max previous year", + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + icon="mdi:water-outline", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="return_temperature_max_c", + name="Return temperature max", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="return_temperature_max_previous_year_c", + name="Return temperature max previous year", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="flow_temperature_max_c", + name="Flow temperature max", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="flow_temperature_max_previous_year_c", + name="Flow temperature max previous year", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="operating_hours", + name="Operating hours", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="flow_hours", + name="Flow hours", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="fault_hours", + name="Fault hours", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="fault_hours_previous_year", + name="Fault hours previous year", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="yearly_set_day", + name="Yearly set day", + icon="mdi:clock-outline", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="monthly_set_day", + name="Monthly set day", + icon="mdi:clock-outline", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="meter_date_time", + name="Meter date time", + icon="mdi:clock-outline", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="measuring_range_m3ph", + name="Measuring range", + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + icon="mdi:water-outline", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="settings_and_firmware", + name="Settings and firmware", + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: From 18cb53a35c733bad4f31b64b6ee2f1397e6d9db1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 8 Mar 2023 17:28:53 +0100 Subject: [PATCH 0761/2810] Pass hass instance when validating templates (#89242) * Pass hass instance when validating templates * Update tests * Fix validating templates without hass * Update service tests --- homeassistant/helpers/config_validation.py | 20 +++++- tests/helpers/test_config_validation.py | 73 +++++++++++++++++++++- tests/helpers/test_service.py | 18 +++--- 3 files changed, 97 insertions(+), 14 deletions(-) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 42e1927e09..0f53c9108c 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -85,7 +85,12 @@ from homeassistant.const import ( WEEKDAYS, UnitOfTemperature, ) -from homeassistant.core import split_entity_id, valid_entity_id +from homeassistant.core import ( + HomeAssistant, + async_get_hass, + split_entity_id, + valid_entity_id, +) from homeassistant.exceptions import TemplateError from homeassistant.generated import currencies from homeassistant.generated.countries import COUNTRIES @@ -597,7 +602,11 @@ def template(value: Any | None) -> template_helper.Template: if isinstance(value, (list, dict, template_helper.Template)): raise vol.Invalid("template value should be a string") - template_value = template_helper.Template(str(value)) + hass: HomeAssistant | None = None + with contextlib.suppress(LookupError): + hass = async_get_hass() + + template_value = template_helper.Template(str(value), hass) try: template_value.ensure_valid() @@ -615,7 +624,12 @@ def dynamic_template(value: Any | None) -> template_helper.Template: if not template_helper.is_template_string(str(value)): raise vol.Invalid("template value does not contain a dynamic template") - template_value = template_helper.Template(str(value)) + hass: HomeAssistant | None = None + with contextlib.suppress(LookupError): + hass = async_get_hass() + + template_value = template_helper.Template(str(value), hass) + try: template_value.ensure_valid() return template_value diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 6823e9655b..f1f644a36a 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -561,11 +561,16 @@ def test_x10_address() -> None: schema("C11") -def test_template() -> None: +def test_template(hass: HomeAssistant) -> None: """Test template validator.""" schema = vol.Schema(cv.template) - for value in (None, "{{ partial_print }", "{% if True %}Hello", ["test"]): + for value in ( + None, + "{{ partial_print }", + "{% if True %}Hello", + ["test"], + ): with pytest.raises(vol.Invalid): schema(value) @@ -574,12 +579,43 @@ def test_template() -> None: "Hello", "{{ beer }}", "{% if 1 == 1 %}Hello{% else %}World{% endif %}", + # Function added as an extension by Home Assistant + "{{ expand('group.foo')|map(attribute='entity_id')|list }}", + # Filter added as an extension by Home Assistant + "{{ ['group.foo']|expand|map(attribute='entity_id')|list }}", ) for value in options: schema(value) -def test_dynamic_template() -> None: +async def test_template_no_hass(hass: HomeAssistant) -> None: + """Test template validator.""" + schema = vol.Schema(cv.template) + + for value in ( + None, + "{{ partial_print }", + "{% if True %}Hello", + ["test"], + # Filter added as an extension by Home Assistant + "{{ ['group.foo']|expand|map(attribute='entity_id')|list }}", + ): + with pytest.raises(vol.Invalid): + await hass.async_add_executor_job(schema, value) + + options = ( + 1, + "Hello", + "{{ beer }}", + "{% if 1 == 1 %}Hello{% else %}World{% endif %}", + # Function added as an extension by Home Assistant + "{{ expand('group.foo')|map(attribute='entity_id')|list }}", + ) + for value in options: + await hass.async_add_executor_job(schema, value) + + +def test_dynamic_template(hass: HomeAssistant) -> None: """Test dynamic template validator.""" schema = vol.Schema(cv.dynamic_template) @@ -597,11 +633,42 @@ def test_dynamic_template() -> None: options = ( "{{ beer }}", "{% if 1 == 1 %}Hello{% else %}World{% endif %}", + # Function added as an extension by Home Assistant + "{{ expand('group.foo')|map(attribute='entity_id')|list }}", + # Filter added as an extension by Home Assistant + "{{ ['group.foo']|expand|map(attribute='entity_id')|list }}", ) for value in options: schema(value) +async def test_dynamic_template_no_hass(hass: HomeAssistant) -> None: + """Test dynamic template validator.""" + schema = vol.Schema(cv.dynamic_template) + + for value in ( + None, + 1, + "{{ partial_print }", + "{% if True %}Hello", + ["test"], + "just a string", + # Filter added as an extension by Home Assistant + "{{ ['group.foo']|expand|map(attribute='entity_id')|list }}", + ): + with pytest.raises(vol.Invalid): + await hass.async_add_executor_job(schema, value) + + options = ( + "{{ beer }}", + "{% if 1 == 1 %}Hello{% else %}World{% endif %}", + # Function added as an extension by Home Assistant + "{{ expand('group.foo')|map(attribute='entity_id')|list }}", + ) + for value in options: + await hass.async_add_executor_job(schema, value) + + def test_template_complex() -> None: """Test template_complex validator.""" schema = vol.Schema(cv.template_complex) diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index c3b5165bb1..43bbf85b06 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -383,16 +383,18 @@ async def test_split_entity_string(hass: HomeAssistant): async def test_not_mutate_input(hass: HomeAssistant): """Test for immutable input.""" async_mock_service(hass, "test_domain", "test_service") - config = cv.SERVICE_SCHEMA( - { - "service": "test_domain.test_service", - "entity_id": "hello.world, sensor.beer", - "data": {"hello": 1}, - "data_template": {"nested": {"value": "{{ 1 + 1 }}"}}, - } - ) + config = { + "service": "test_domain.test_service", + "entity_id": "hello.world, sensor.beer", + "data": {"hello": 1}, + "data_template": {"nested": {"value": "{{ 1 + 1 }}"}}, + } orig = deepcopy(config) + # Validate both the original and the copy + config = cv.SERVICE_SCHEMA(config) + orig = cv.SERVICE_SCHEMA(orig) + # Only change after call is each template getting hass attached template.attach(hass, orig) From 7d976538951ac8acf4138cbfa2271be9b3545953 Mon Sep 17 00:00:00 2001 From: parliament119 <34025831+parliament119@users.noreply.github.com> Date: Wed, 8 Mar 2023 18:24:37 +0100 Subject: [PATCH 0762/2810] Bump pyfritzhome to 0.6.8 and add support for Non-Color-Bulbs (#89141) --- homeassistant/components/fritzbox/light.py | 12 ++++-- .../components/fritzbox/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/fritzbox/__init__.py | 2 + tests/components/fritzbox/test_light.py | 42 +++++++++++++++++++ 6 files changed, 56 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/fritzbox/light.py b/homeassistant/components/fritzbox/light.py index 24431f78ac..f83dd45459 100644 --- a/homeassistant/components/fritzbox/light.py +++ b/homeassistant/components/fritzbox/light.py @@ -72,8 +72,10 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity): """Initialize the FritzboxLight entity.""" super().__init__(coordinator, ain, None) - self._attr_max_color_temp_kelvin = int(max(supported_color_temps)) - self._attr_min_color_temp_kelvin = int(min(supported_color_temps)) + if supported_color_temps: + # only available for color bulbs + self._attr_max_color_temp_kelvin = int(max(supported_color_temps)) + self._attr_min_color_temp_kelvin = int(min(supported_color_temps)) # Fritz!DECT 500 only supports 12 values for hue, with 3 saturations each. # Map supported colors to dict {hue: [sat1, sat2, sat3]} for easier lookup @@ -125,7 +127,11 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity): @property def supported_color_modes(self) -> set[ColorMode]: """Flag supported color modes.""" - return SUPPORTED_COLOR_MODES + if self.data.has_color: + return SUPPORTED_COLOR_MODES + if self.data.has_level: + return {ColorMode.BRIGHTNESS} + return {ColorMode.ONOFF} async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" diff --git a/homeassistant/components/fritzbox/manifest.json b/homeassistant/components/fritzbox/manifest.json index e604f1d37b..29df2f51a3 100644 --- a/homeassistant/components/fritzbox/manifest.json +++ b/homeassistant/components/fritzbox/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyfritzhome"], - "requirements": ["pyfritzhome==0.6.7"], + "requirements": ["pyfritzhome==0.6.8"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:fritzbox:1" diff --git a/requirements_all.txt b/requirements_all.txt index 78badcd39f..1821af2227 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1645,7 +1645,7 @@ pyforked-daapd==0.1.14 pyfreedompro==1.1.0 # homeassistant.components.fritzbox -pyfritzhome==0.6.7 +pyfritzhome==0.6.8 # homeassistant.components.fronius pyfronius==0.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7176b10418..f74895e3c6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1182,7 +1182,7 @@ pyforked-daapd==0.1.14 pyfreedompro==1.1.0 # homeassistant.components.fritzbox -pyfritzhome==0.6.7 +pyfritzhome==0.6.8 # homeassistant.components.fronius pyfronius==0.7.1 diff --git a/tests/components/fritzbox/__init__.py b/tests/components/fritzbox/__init__.py index 34311c0aa5..6cf60a9065 100644 --- a/tests/components/fritzbox/__init__.py +++ b/tests/components/fritzbox/__init__.py @@ -151,6 +151,8 @@ class FritzDeviceLightMock(FritzEntityBaseMock): has_alarm = False has_powermeter = False has_lightbulb = True + has_color = True + has_level = True has_switch = False has_temperature_sensor = False has_thermostat = False diff --git a/tests/components/fritzbox/test_light.py b/tests/components/fritzbox/test_light.py index 10c9835556..074dd902fa 100644 --- a/tests/components/fritzbox/test_light.py +++ b/tests/components/fritzbox/test_light.py @@ -15,6 +15,7 @@ from homeassistant.components.light import ( ATTR_HS_COLOR, ATTR_MAX_COLOR_TEMP_KELVIN, ATTR_MIN_COLOR_TEMP_KELVIN, + ATTR_SUPPORTED_COLOR_MODES, DOMAIN, ) from homeassistant.const import ( @@ -57,6 +58,46 @@ async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 2700 assert state.attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 2700 assert state.attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 6500 + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] + + +async def test_setup_non_color(hass: HomeAssistant, fritz: Mock) -> None: + """Test setup of platform of non color bulb.""" + device = FritzDeviceLightMock() + device.has_color = False + device.get_color_temps.return_value = [] + device.get_colors.return_value = {} + + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + state = hass.states.get(ENTITY_ID) + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" + assert state.attributes[ATTR_BRIGHTNESS] == 100 + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["brightness"] + + +async def test_setup_non_color_non_level(hass: HomeAssistant, fritz: Mock) -> None: + """Test setup of platform of non color and non level bulb.""" + device = FritzDeviceLightMock() + device.has_color = False + device.has_level = False + device.get_color_temps.return_value = [] + device.get_colors.return_value = {} + + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + state = hass.states.get(ENTITY_ID) + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" + assert state.attributes[ATTR_BRIGHTNESS] == 100 + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["onoff"] async def test_setup_color(hass: HomeAssistant, fritz: Mock) -> None: @@ -80,6 +121,7 @@ async def test_setup_color(hass: HomeAssistant, fritz: Mock) -> None: assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" assert state.attributes[ATTR_BRIGHTNESS] == 100 assert state.attributes[ATTR_HS_COLOR] == (100, 70) + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] async def test_turn_on(hass: HomeAssistant, fritz: Mock) -> None: From 7232a0a786b6e598e85c43772259ef1f9ce09741 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 8 Mar 2023 19:21:04 +0100 Subject: [PATCH 0763/2810] Add require_admin decorator to otbr WS API (#89385) * Add require_admin decorator to otbr WS API * Add require_admin decorator to forgotten otbr WS API --- .../components/otbr/websocket_api.py | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py index 7c69a8d0a2..497fc285d5 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -3,12 +3,7 @@ from typing import TYPE_CHECKING import python_otbr_api -from homeassistant.components.websocket_api import ( - ActiveConnection, - async_register_command, - async_response, - websocket_command, -) +from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -21,18 +16,19 @@ if TYPE_CHECKING: @callback def async_setup(hass: HomeAssistant) -> None: """Set up the OTBR Websocket API.""" - async_register_command(hass, websocket_info) - async_register_command(hass, websocket_create_network) + websocket_api.async_register_command(hass, websocket_info) + websocket_api.async_register_command(hass, websocket_create_network) -@websocket_command( +@websocket_api.websocket_command( { "type": "otbr/info", } ) -@async_response +@websocket_api.require_admin +@websocket_api.async_response async def websocket_info( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: """Get OTBR info.""" if DOMAIN not in hass.data: @@ -56,14 +52,15 @@ async def websocket_info( ) -@websocket_command( +@websocket_api.websocket_command( { "type": "otbr/create_network", } ) -@async_response +@websocket_api.require_admin +@websocket_api.async_response async def websocket_create_network( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: """Create a new Thread network.""" if DOMAIN not in hass.data: From 84b5ea8ac0c14cb0c96448803f092eb2cb69e648 Mon Sep 17 00:00:00 2001 From: Mark Adkins Date: Wed, 8 Mar 2023 15:31:32 -0500 Subject: [PATCH 0764/2810] Bump SharkIQ to 1.0.2 (#89346) * SharkIQ Dep & Codeowner Update * Update code owners * Revert code owner changes --- homeassistant/components/sharkiq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sharkiq/manifest.json b/homeassistant/components/sharkiq/manifest.json index 1457f8f8a6..5b8656cefe 100644 --- a/homeassistant/components/sharkiq/manifest.json +++ b/homeassistant/components/sharkiq/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sharkiq", "iot_class": "cloud_polling", "loggers": ["sharkiq"], - "requirements": ["sharkiq==0.0.1"] + "requirements": ["sharkiq==1.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1821af2227..7c3e0c3f2a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2334,7 +2334,7 @@ sentry-sdk==1.16.0 sfrbox-api==0.0.6 # homeassistant.components.sharkiq -sharkiq==0.0.1 +sharkiq==1.0.2 # homeassistant.components.aquostv sharp_aquos_rc==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f74895e3c6..3817372237 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1658,7 +1658,7 @@ sentry-sdk==1.16.0 sfrbox-api==0.0.6 # homeassistant.components.sharkiq -sharkiq==0.0.1 +sharkiq==1.0.2 # homeassistant.components.sighthound simplehound==0.3 From cefba7c638badc3bb37e4f2db34c044c923edf41 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Mar 2023 10:50:34 -1000 Subject: [PATCH 0765/2810] Avoid falling back to listening for all states when a template render raises an exception (#89392) When a template render raised an exception we would start listening for all states until the template did not raise an exception anymore. This was not needed since the entity that is causing the exception was already in the tracker. Re-rendering on all state changes can be extremely expensive and can bring an instance into a sluggish or unresponsive state when updating from a much older version that did not raise ValueError when a default was missing. --- homeassistant/helpers/event.py | 10 +++--- homeassistant/helpers/template.py | 2 ++ tests/helpers/test_event.py | 52 ++++++++++++++++++++++++++++++- tests/helpers/test_template.py | 11 +++++++ 4 files changed, 68 insertions(+), 7 deletions(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index edbb5fa735..3ac715426e 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -838,6 +838,10 @@ class TrackTemplateResultInfo: self._track_state_changes: _TrackStateChangeFiltered | None = None self._time_listeners: dict[Template, Callable[[], None]] = {} + def __repr__(self) -> str: + """Return the representation.""" + return f"" + def async_setup(self, raise_on_template_error: bool, strict: bool = False) -> None: """Activation of template tracking.""" block_render = False @@ -1651,12 +1655,6 @@ def _render_infos_needs_all_listener(render_infos: Iterable[RenderInfo]) -> bool if render_info.all_states or render_info.all_states_lifecycle: return True - # Previous call had an exception - # so we do not know which states - # to track - if render_info.exception: - return True - return False diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 2e112706fb..c923bd2d84 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -274,6 +274,8 @@ class RenderInfo: f" entities={self.entities}" f" rate_limit={self.rate_limit}" f" has_time={self.has_time}" + f" exception={self.exception}" + f" is_static={self.is_static}" ">" ) diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index fb0925c15d..066460c90d 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -2209,7 +2209,7 @@ async def test_track_template_result_errors( hass.states.async_set("switch.not_exist", "on") await hass.async_block_till_done() - assert len(syntax_error_runs) == 1 + assert len(syntax_error_runs) == 0 assert len(not_exist_runs) == 2 assert not_exist_runs[1][0].data.get("entity_id") == "switch.not_exist" assert not_exist_runs[1][1] == template_not_exist @@ -2229,6 +2229,56 @@ async def test_track_template_result_errors( assert isinstance(not_exist_runs[2][3], TemplateError) +async def test_track_template_result_transient_errors( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test tracking template with transient errors in the template.""" + hass.states.async_set("sensor.error", "unknown") + template_that_raises_sometimes = Template( + "{{ states('sensor.error') | float }}", hass + ) + + sometimes_error_runs = [] + + @ha.callback + def sometimes_error_listener(event, updates): + track_result = updates.pop() + sometimes_error_runs.append( + ( + event, + track_result.template, + track_result.last_result, + track_result.result, + ) + ) + + info = async_track_template_result( + hass, + [TrackTemplate(template_that_raises_sometimes, None)], + sometimes_error_listener, + ) + await hass.async_block_till_done() + + assert sometimes_error_runs == [] + assert "ValueError" in caplog.text + assert "ValueError" in repr(info) + caplog.clear() + + hass.states.async_set("sensor.error", "unavailable") + await hass.async_block_till_done() + assert len(sometimes_error_runs) == 1 + assert isinstance(sometimes_error_runs[0][3], TemplateError) + sometimes_error_runs.clear() + assert "ValueError" in repr(info) + + hass.states.async_set("sensor.error", "4") + await hass.async_block_till_done() + assert len(sometimes_error_runs) == 1 + assert sometimes_error_runs[0][3] == 4.0 + sometimes_error_runs.clear() + assert "ValueError" not in repr(info) + + async def test_static_string(hass: HomeAssistant) -> None: """Test a static string.""" template_refresh = Template("{{ 'static' }}", hass) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 2434b2aed1..f97f0a4b9c 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -4323,3 +4323,14 @@ def test_contains(hass: HomeAssistant, seq, value, expected) -> None: ) == expected ) + + +async def test_render_to_info_with_exception(hass: HomeAssistant) -> None: + """Test info is still available if the template has an exception.""" + hass.states.async_set("test_domain.object", "dog") + info = render_to_info(hass, '{{ states("test_domain.object") | float }}') + with pytest.raises(TemplateError, match="no default was specified"): + info.result() + + assert info.all_states is False + assert info.entities == {"test_domain.object"} From 5a499050f24b608bee08a9754e45fdac9d3ea54a Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Wed, 8 Mar 2023 21:52:01 +0100 Subject: [PATCH 0766/2810] Remove lingering timer related to camera (#89394) --- homeassistant/components/camera/__init__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 11e75c50cf..e368779e94 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -41,6 +41,7 @@ from homeassistant.const import ( CONF_FILENAME, CONTENT_TYPE_MULTIPART, EVENT_HOMEASSISTANT_STARTED, + EVENT_HOMEASSISTANT_STOP, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) @@ -378,7 +379,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: entity.async_update_token() entity.async_write_ha_state() - async_track_time_interval(hass, update_tokens, TOKEN_CHANGE_INTERVAL) + unsub = async_track_time_interval(hass, update_tokens, TOKEN_CHANGE_INTERVAL) + + @callback + def unsub_track_time_interval(_event: Event) -> None: + """Unsubscribe track time interval timer.""" + unsub() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unsub_track_time_interval) component.async_register_entity_service( SERVICE_ENABLE_MOTION, {}, "async_enable_motion_detection" From 09915f80477984cea47cd2397586d71158594d30 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 8 Mar 2023 21:52:53 +0100 Subject: [PATCH 0767/2810] Add WS API for getting an OTBR's extended address (#89384) * Add WS API for getting an OTBR's extended address * Bump python-otbr-api to 1.0.8 * Really add require_admin decorator to otbr WS API --- homeassistant/components/otbr/__init__.py | 5 ++ homeassistant/components/otbr/manifest.json | 2 +- .../components/otbr/websocket_api.py | 27 +++++++ homeassistant/components/thread/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/otbr/test_websocket_api.py | 72 +++++++++++++++++++ 7 files changed, 108 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index 78c5893c88..ca977e774f 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -78,6 +78,11 @@ class OTBRData: """Create an active operational dataset.""" return await self.api.create_active_dataset(dataset) + @_handle_otbr_error + async def get_extended_address(self) -> bytes: + """Get extended address (EUI-64).""" + return await self.api.get_extended_address() + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Open Thread Border Router component.""" diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index 0a6482b040..7efe5fefc3 100644 --- a/homeassistant/components/otbr/manifest.json +++ b/homeassistant/components/otbr/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/otbr", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==1.0.5"] + "requirements": ["python-otbr-api==1.0.8"] } diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py index 497fc285d5..506a8cad1b 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -18,6 +18,7 @@ def async_setup(hass: HomeAssistant) -> None: """Set up the OTBR Websocket API.""" websocket_api.async_register_command(hass, websocket_info) websocket_api.async_register_command(hass, websocket_create_network) + websocket_api.async_register_command(hass, websocket_get_extended_address) @websocket_api.websocket_command( @@ -96,3 +97,29 @@ async def websocket_create_network( return connection.send_result(msg["id"]) + + +@websocket_api.websocket_command( + { + "type": "otbr/get_extended_address", + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_get_extended_address( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Get extended address (EUI-64).""" + if DOMAIN not in hass.data: + connection.send_error(msg["id"], "not_loaded", "No OTBR API loaded") + return + + data: OTBRData = hass.data[DOMAIN] + + try: + extended_address = await data.get_extended_address() + except HomeAssistantError as exc: + connection.send_error(msg["id"], "get_extended_address_failed", str(exc)) + return + + connection.send_result(msg["id"], {"extended_address": extended_address.hex()}) diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index 547def8345..5fcb287796 100644 --- a/homeassistant/components/thread/manifest.json +++ b/homeassistant/components/thread/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/thread", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==1.0.5", "pyroute2==0.7.5"], + "requirements": ["python-otbr-api==1.0.8", "pyroute2==0.7.5"], "zeroconf": ["_meshcop._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 7c3e0c3f2a..bc91824850 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2097,7 +2097,7 @@ python-nest==4.2.0 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==1.0.5 +python-otbr-api==1.0.8 # homeassistant.components.picnic python-picnic-api==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3817372237..d984049ca3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1496,7 +1496,7 @@ python-nest==4.2.0 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==1.0.5 +python-otbr-api==1.0.8 # homeassistant.components.picnic python-picnic-api==1.1.0 diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index 7893565743..1c44091ae5 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -234,3 +234,75 @@ async def test_get_info_fetch_fails_3( assert msg["id"] == 5 assert not msg["success"] assert msg["error"]["code"] == "set_enabled_failed" + + +async def test_get_extended_address( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry, + websocket_client, +) -> None: + """Test get extended address.""" + + with patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=bytes.fromhex("4EF6C4F3FF750626"), + ): + await websocket_client.send_json( + { + "id": 5, + "type": "otbr/get_extended_address", + } + ) + msg = await websocket_client.receive_json() + + assert msg["id"] == 5 + assert msg["success"] + assert msg["result"] == {"extended_address": "4EF6C4F3FF750626".lower()} + + +async def test_get_extended_address_no_entry( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test get extended address.""" + await async_setup_component(hass, "otbr", {}) + websocket_client = await hass_ws_client(hass) + await websocket_client.send_json( + { + "id": 5, + "type": "otbr/get_extended_address", + } + ) + + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + assert not msg["success"] + assert msg["error"]["code"] == "not_loaded" + + +async def test_get_extended_address_fetch_fails( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry, + websocket_client, +) -> None: + """Test get extended address.""" + await async_setup_component(hass, "otbr", {}) + + with patch( + "python_otbr_api.OTBR.get_extended_address", + side_effect=python_otbr_api.OTBRError, + ): + await websocket_client.send_json( + { + "id": 5, + "type": "otbr/get_extended_address", + } + ) + msg = await websocket_client.receive_json() + + assert msg["id"] == 5 + assert not msg["success"] + assert msg["error"]["code"] == "get_extended_address_failed" From b07f614cf59e6c8a5238ff8e9375dc580e43b2d1 Mon Sep 17 00:00:00 2001 From: Malte Franken Date: Thu, 9 Mar 2023 07:53:12 +1100 Subject: [PATCH 0768/2810] Add loggers to gdacs manifest file (#89338) define loggers --- homeassistant/components/gdacs/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/gdacs/manifest.json b/homeassistant/components/gdacs/manifest.json index 4db9d2fc89..86904e3e9b 100644 --- a/homeassistant/components/gdacs/manifest.json +++ b/homeassistant/components/gdacs/manifest.json @@ -6,6 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/gdacs", "integration_type": "service", "iot_class": "cloud_polling", + "loggers": ["aio_georss_gdacs", "aio_georss_client"], "quality_scale": "platinum", "requirements": ["aio_georss_gdacs==0.8"] } From 5dbab21f9aa57d5c541b2e03f677b109adabd607 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Mar 2023 10:53:48 -1000 Subject: [PATCH 0769/2810] Fix missing f-string in filterable_job (#89340) * Fix missing f-string in filterable_job * remove bad test --- homeassistant/core.py | 2 +- tests/components/homematicip_cloud/test_init.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index e8fb41be32..bfccb721d8 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1118,7 +1118,7 @@ class EventBus: ) filterable_job = _FilterableJob( - HassJob(_onetime_listener, "onetime listen {event_type} {listener}"), + HassJob(_onetime_listener, f"onetime listen {event_type} {listener}"), None, False, ) diff --git a/tests/components/homematicip_cloud/test_init.py b/tests/components/homematicip_cloud/test_init.py index 7f293b3876..5b9472d329 100644 --- a/tests/components/homematicip_cloud/test_init.py +++ b/tests/components/homematicip_cloud/test_init.py @@ -157,7 +157,6 @@ async def test_unload_entry(hass: HomeAssistant) -> None: assert config_entries[0].state is ConfigEntryState.LOADED await hass.config_entries.async_unload(config_entries[0].entry_id) assert config_entries[0].state is ConfigEntryState.NOT_LOADED - assert mock_hap.return_value.mock_calls[2][0] == "async_reset" # entry is unloaded assert hass.data[HMIPC_DOMAIN] == {} From 4f11344bc3b9e6bf4c159e7cbfedfdd078b539ce Mon Sep 17 00:00:00 2001 From: Brandon Rothweiler Date: Wed, 8 Mar 2023 15:56:40 -0500 Subject: [PATCH 0770/2810] 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 64bb8bef0c..2c2aafa960 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 bc91824850..b25b637d68 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 d984049ca3..2b2fcf56f2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1275,7 +1275,7 @@ pymailgunner==1.4 pymata-express==1.19 # homeassistant.components.mazda -pymazda==0.3.7 +pymazda==0.3.8 # homeassistant.components.melcloud pymelcloud==2.5.8 From e1d62b554a00482f8d43a5c2d4c318a8a16adcab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Mar 2023 11:01:47 -1000 Subject: [PATCH 0771/2810] Migrate integration_platform helper to use async_get_integrations (#89303) * Migrate integration_platform helper to use async_get_integrations We were fetching integrations inside the gather one at a time. This is inefficent. * cleanup * cleanup * add task name * small tweaks * gather only if we have tasks --- homeassistant/helpers/integration_platform.py | 80 +++++++++++++------ tests/helpers/test_integration_platform.py | 26 ++++++ 2 files changed, 82 insertions(+), 24 deletions(-) diff --git a/homeassistant/helpers/integration_platform.py b/homeassistant/helpers/integration_platform.py index 9255824cdd..ef05dae518 100644 --- a/homeassistant/helpers/integration_platform.py +++ b/homeassistant/helpers/integration_platform.py @@ -8,8 +8,8 @@ import logging from typing import Any from homeassistant.const import EVENT_COMPONENT_LOADED -from homeassistant.core import Event, HomeAssistant -from homeassistant.loader import async_get_integration, bind_hass +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.loader import Integration, async_get_integrations, bind_hass from homeassistant.setup import ATTR_COMPONENT _LOGGER = logging.getLogger(__name__) @@ -26,14 +26,24 @@ class IntegrationPlatform: async def _async_process_single_integration_platform_component( - hass: HomeAssistant, component_name: str, integration_platform: IntegrationPlatform + hass: HomeAssistant, + component_name: str, + integration: Integration | Exception, + integration_platform: IntegrationPlatform, ) -> None: """Process a single integration platform.""" if component_name in integration_platform.seen_components: return integration_platform.seen_components.add(component_name) - integration = await async_get_integration(hass, component_name) + if isinstance(integration, Exception): + _LOGGER.exception( + "Error importing integration %s for %s", + component_name, + integration_platform.platform_name, + ) + return + platform_name = integration_platform.platform_name try: @@ -75,14 +85,22 @@ async def async_process_integration_platform_for_component( integration_platforms: list[IntegrationPlatform] = hass.data[ DATA_INTEGRATION_PLATFORMS ] - await asyncio.gather( - *[ + integrations = await async_get_integrations(hass, (component_name,)) + tasks = [ + asyncio.create_task( _async_process_single_integration_platform_component( - hass, component_name, integration_platform - ) - for integration_platform in integration_platforms - ] - ) + hass, + component_name, + integrations[component_name], + integration_platform, + ), + name=f"process integration platform {integration_platform.platform_name} for {component_name}", + ) + for integration_platform in integration_platforms + if component_name not in integration_platform.seen_components + ] + if tasks: + await asyncio.gather(*tasks) @bind_hass @@ -98,25 +116,39 @@ async def async_process_integration_platforms( async def _async_component_loaded(event: Event) -> None: """Handle a new component loaded.""" - comp = event.data[ATTR_COMPONENT] - if "." not in comp: - await async_process_integration_platform_for_component(hass, comp) + await async_process_integration_platform_for_component( + hass, event.data[ATTR_COMPONENT] + ) - hass.bus.async_listen(EVENT_COMPONENT_LOADED, _async_component_loaded) + @callback + def _async_component_loaded_filter(event: Event) -> bool: + """Handle integration platforms loaded.""" + return "." not in event.data[ATTR_COMPONENT] + + hass.bus.async_listen( + EVENT_COMPONENT_LOADED, + _async_component_loaded, + event_filter=_async_component_loaded_filter, + ) integration_platforms: list[IntegrationPlatform] = hass.data[ DATA_INTEGRATION_PLATFORMS ] integration_platform = IntegrationPlatform(platform_name, process_platform, set()) integration_platforms.append(integration_platform) - if top_level_components := ( + if top_level_components := [ comp for comp in hass.config.components if "." not in comp - ): - await asyncio.gather( - *[ + ]: + integrations = await async_get_integrations(hass, top_level_components) + tasks = [ + asyncio.create_task( _async_process_single_integration_platform_component( - hass, comp, integration_platform - ) - for comp in top_level_components - ] - ) + hass, comp, integrations[comp], integration_platform + ), + name=f"process integration platform {platform_name} for {comp}", + ) + for comp in top_level_components + if comp not in integration_platform.seen_components + ] + if tasks: + await asyncio.gather(*tasks) diff --git a/tests/helpers/test_integration_platform.py b/tests/helpers/test_integration_platform.py index 5848c6f954..2dfc0742e2 100644 --- a/tests/helpers/test_integration_platform.py +++ b/tests/helpers/test_integration_platform.py @@ -1,6 +1,8 @@ """Test integration platform helpers.""" from unittest.mock import Mock +import pytest + from homeassistant.core import HomeAssistant from homeassistant.helpers.integration_platform import ( async_process_integration_platform_for_component, @@ -51,3 +53,27 @@ async def test_process_integration_platforms_none_loaded(hass: HomeAssistant) -> # Verify we can call async_process_integration_platform_for_component # when there are none loaded and it does not throw await async_process_integration_platform_for_component(hass, "any") + + +async def test_broken_integration( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test handling an integration with a broken or missing manifest.""" + Mock() + hass.config.components.add("loaded") + + event_platform = Mock() + mock_platform(hass, "event.platform_to_check", event_platform) + + processed = [] + + async def _process_platform(hass, domain, platform): + """Process platform.""" + processed.append((domain, platform)) + + await async_process_integration_platforms( + hass, "platform_to_check", _process_platform + ) + + assert len(processed) == 0 + assert "Error importing integration loaded for platform_to_check" in caplog.text From 5c768c3f89d65d86a71780a9543a890191d5183c Mon Sep 17 00:00:00 2001 From: mkmer Date: Wed, 8 Mar 2023 16:02:18 -0500 Subject: [PATCH 0772/2810] Bump aiosomecomfort to 0.0.14 (#89393) --- homeassistant/components/honeywell/climate.py | 23 +++++++++---------- .../components/honeywell/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 14 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 9184b8c3d6..e9dae1e207 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -292,21 +292,22 @@ class HoneywellUSThermostat(ClimateEntity): hour_cool, minute_cool = divmod( self._device.raw_ui_data["CoolNextPeriod"] * 15, 60 ) - # Set hold time + # Set temporary hold time and temperature if mode in COOLING_MODES: await self._device.set_hold_cool( - datetime.time(hour_cool, minute_cool) + datetime.time(hour_cool, minute_cool), temperature ) if mode in HEATING_MODES: await self._device.set_hold_heat( - datetime.time(hour_heat, minute_heat) + datetime.time(hour_heat, minute_heat), temperature ) - # Set temperature if not in auto - if mode == "cool": - await self._device.set_setpoint_cool(temperature) - if mode == "heat": - await self._device.set_setpoint_heat(temperature) + # Set temperature if not in auto - set the temperature + else: + if mode == "cool": + await self._device.set_setpoint_cool(temperature) + if mode == "heat": + await self._device.set_setpoint_heat(temperature) except aiosomecomfort.SomeComfortError as err: _LOGGER.error("Invalid temperature %.1f: %s", temperature, err) @@ -350,11 +351,9 @@ class HoneywellUSThermostat(ClimateEntity): # Set permanent hold # and Set temperature if mode in COOLING_MODES: - await self._device.set_hold_cool(True) - await self._device.set_setpoint_cool(self._cool_away_temp) + await self._device.set_hold_cool(True, self._cool_away_temp) if mode in HEATING_MODES: - await self._device.set_hold_heat(True) - await self._device.set_setpoint_heat(self._heat_away_temp) + await self._device.set_hold_heat(True, self._heat_away_temp) except aiosomecomfort.SomeComfortError: _LOGGER.error( diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index 989e605749..8f3b66ddea 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/honeywell", "iot_class": "cloud_polling", "loggers": ["somecomfort"], - "requirements": ["aiosomecomfort==0.0.11"] + "requirements": ["aiosomecomfort==0.0.14"] } diff --git a/requirements_all.txt b/requirements_all.txt index b25b637d68..f0b72b981e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -276,7 +276,7 @@ aioskybell==22.7.0 aioslimproto==2.1.1 # homeassistant.components.honeywell -aiosomecomfort==0.0.11 +aiosomecomfort==0.0.14 # homeassistant.components.steamist aiosteamist==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2b2fcf56f2..fa524b6aa8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -254,7 +254,7 @@ aioskybell==22.7.0 aioslimproto==2.1.1 # homeassistant.components.honeywell -aiosomecomfort==0.0.11 +aiosomecomfort==0.0.14 # homeassistant.components.steamist aiosteamist==0.3.2 From 8af37f7fee7930f0ab7cd905952288b7cea10873 Mon Sep 17 00:00:00 2001 From: Mark Adkins Date: Wed, 8 Mar 2023 16:05:23 -0500 Subject: [PATCH 0773/2810] Update SharkIQ code owners (#89388) --- CODEOWNERS | 4 ++-- homeassistant/components/sharkiq/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 46d78113ab..9020d229fe 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1056,8 +1056,8 @@ build.json @home-assistant/supervisor /homeassistant/components/seven_segments/ @fabaff /homeassistant/components/sfr_box/ @epenet /tests/components/sfr_box/ @epenet -/homeassistant/components/sharkiq/ @JeffResc @funkybunch @AritroSaha10 -/tests/components/sharkiq/ @JeffResc @funkybunch @AritroSaha10 +/homeassistant/components/sharkiq/ @JeffResc @funkybunch +/tests/components/sharkiq/ @JeffResc @funkybunch /homeassistant/components/shell_command/ @home-assistant/core /tests/components/shell_command/ @home-assistant/core /homeassistant/components/shelly/ @balloob @bieniu @thecode @chemelli74 @bdraco diff --git a/homeassistant/components/sharkiq/manifest.json b/homeassistant/components/sharkiq/manifest.json index 5b8656cefe..0e07dd9690 100644 --- a/homeassistant/components/sharkiq/manifest.json +++ b/homeassistant/components/sharkiq/manifest.json @@ -1,7 +1,7 @@ { "domain": "sharkiq", "name": "Shark IQ", - "codeowners": ["@JeffResc", "@funkybunch", "@AritroSaha10"], + "codeowners": ["@JeffResc", "@funkybunch"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sharkiq", "iot_class": "cloud_polling", From 366baef7f629d4216b02ba9d34b51692d3967c9b Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 8 Mar 2023 22:35:06 +0100 Subject: [PATCH 0774/2810] 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 934f736958..aea357bea6 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -281,7 +281,7 @@ class MqttSensor(MqttEntity, RestoreSensor): else: self._attr_native_value = new_value return - if self.device_class is None: + if self.device_class in {None, SensorDeviceClass.ENUM}: self._attr_native_value = new_value return try: diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 66836a16ee..3112564cb8 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -141,6 +141,8 @@ async def test_setting_sensor_value_via_mqtt_message( True, ), (sensor.SensorDeviceClass.TIMESTAMP, "invalid", STATE_UNKNOWN, True), + (sensor.SensorDeviceClass.ENUM, "some_value", "some_value", False), + (None, "some_value", "some_value", False), ], ) async def test_setting_sensor_native_value_handling_via_mqtt_message( From 0d948a0f114e2a497290d24621e7faffdb09f0e5 Mon Sep 17 00:00:00 2001 From: Dillon Fearns Date: Wed, 8 Mar 2023 21:39:33 +0000 Subject: [PATCH 0775/2810] 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 5aa630df5d..08815cae9f 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 f0b72b981e..9bbcccb1ac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2264,7 +2264,7 @@ rocketchat-API==0.6.1 rokuecp==0.17.1 # homeassistant.components.roomba -roombapy==1.6.5 +roombapy==1.6.6 # homeassistant.components.roon roonapi==0.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fa524b6aa8..cbc7961352 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1606,7 +1606,7 @@ ring_doorbell==0.7.2 rokuecp==0.17.1 # homeassistant.components.roomba -roombapy==1.6.5 +roombapy==1.6.6 # homeassistant.components.roon roonapi==0.1.4 From bfae8992a931e50103473924ce4fb2f3af2fab6d Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Wed, 8 Mar 2023 10:42:07 -1100 Subject: [PATCH 0776/2810] Better log message for KNX expose conversion error (#89400) --- homeassistant/components/knx/expose.py | 10 ++++++++-- tests/components/knx/test_expose.py | 9 +++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/knx/expose.py b/homeassistant/components/knx/expose.py index 05e367faee..308fc4eacd 100644 --- a/homeassistant/components/knx/expose.py +++ b/homeassistant/components/knx/expose.py @@ -161,8 +161,14 @@ class KNXExposeSensor: """Set new value on xknx ExposeSensor.""" try: await self.device.set(value) - except ConversionError: - _LOGGER.exception("Error during sending of expose sensor value") + except ConversionError as err: + _LOGGER.warning( + 'Could not expose %s %s value "%s" to KNX: %s', + self.entity_id, + self.expose_attribute or "state", + value, + err, + ) class KNXExposeTime: diff --git a/tests/components/knx/test_expose.py b/tests/components/knx/test_expose.py index 1fca793b8c..9bb6f22470 100644 --- a/tests/components/knx/test_expose.py +++ b/tests/components/knx/test_expose.py @@ -3,6 +3,8 @@ from datetime import timedelta import time from unittest.mock import patch +import pytest + from homeassistant.components.knx import CONF_KNX_EXPOSE, DOMAIN, KNX_ADDRESS from homeassistant.components.knx.schema import ExposeSchema from homeassistant.const import CONF_ATTRIBUTE, CONF_ENTITY_ID, CONF_TYPE @@ -201,7 +203,7 @@ async def test_expose_cooldown(hass: HomeAssistant, knx: KNXTestKit) -> None: async def test_expose_conversion_exception( - hass: HomeAssistant, knx: KNXTestKit + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, knx: KNXTestKit ) -> None: """Test expose throws exception.""" @@ -230,8 +232,11 @@ async def test_expose_conversion_exception( "on", {attribute: 101}, ) - await knx.assert_no_telegram() + assert ( + 'Could not expose fake.entity fake_attribute value "101.0" to KNX:' + in caplog.text + ) @patch("time.localtime") From 386533a16f34224e7cb90aa01645e2e8401a6481 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 8 Mar 2023 22:57:54 +0100 Subject: [PATCH 0777/2810] Update mypy to 1.1.1 (#89268) * Update mypy to 1.1.1 * Update pydantic to 1.10.6 --- homeassistant/block_async_io.py | 2 +- homeassistant/components/http/__init__.py | 2 +- homeassistant/components/matter/models.py | 8 ++++++-- homeassistant/components/p1_monitor/diagnostics.py | 9 +++++++-- homeassistant/components/zeroconf/usage.py | 2 +- homeassistant/components/zwave_js/discovery.py | 7 +++++-- homeassistant/helpers/aiohttp_client.py | 2 +- homeassistant/helpers/httpx_client.py | 2 +- homeassistant/helpers/schema_config_entry_flow.py | 2 +- homeassistant/runner.py | 2 +- requirements_test.txt | 4 ++-- 11 files changed, 27 insertions(+), 15 deletions(-) diff --git a/homeassistant/block_async_io.py b/homeassistant/block_async_io.py index 753fda5ae9..d7c1a7c9ee 100644 --- a/homeassistant/block_async_io.py +++ b/homeassistant/block_async_io.py @@ -8,7 +8,7 @@ from .util.async_ import protect_loop def enable() -> None: """Enable the detection of blocking calls in the event loop.""" # Prevent urllib3 and requests doing I/O in event loop - HTTPConnection.putrequest = protect_loop( # type: ignore[assignment] + HTTPConnection.putrequest = protect_loop( # type: ignore[method-assign] HTTPConnection.putrequest ) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 1c201725c0..04b94dc3b8 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -460,7 +460,7 @@ class HomeAssistantHTTP: # This will now raise a RunTimeError. # To work around this we now prevent the router from getting frozen # pylint: disable-next=protected-access - self.app._router.freeze = lambda: None # type: ignore[assignment] + self.app._router.freeze = lambda: None # type: ignore[method-assign] self.runner = web.AppRunner(self.app) await self.runner.setup() diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py index 3ce5f18467..2575b16e8b 100644 --- a/homeassistant/components/matter/models.py +++ b/homeassistant/components/matter/models.py @@ -1,8 +1,9 @@ """Models used for the Matter integration.""" +from __future__ import annotations from collections.abc import Callable from dataclasses import asdict, dataclass -from typing import Any +from typing import TYPE_CHECKING, Any from chip.clusters import Objects as clusters from chip.clusters.Objects import ClusterAttributeDescriptor @@ -12,11 +13,14 @@ from matter_server.client.models.node import MatterEndpoint from homeassistant.const import Platform from homeassistant.helpers.entity import EntityDescription +if TYPE_CHECKING: + from _typeshed import DataclassInstance + class DataclassMustHaveAtLeastOne: """A dataclass that must have at least one input parameter that is not None.""" - def __post_init__(self) -> None: + def __post_init__(self: DataclassInstance) -> None: """Post dataclass initialization.""" if all(val is None for val in asdict(self).values()): raise ValueError("At least one input parameter must not be None") diff --git a/homeassistant/components/p1_monitor/diagnostics.py b/homeassistant/components/p1_monitor/diagnostics.py index 29f48d47cd..b2668f060a 100644 --- a/homeassistant/components/p1_monitor/diagnostics.py +++ b/homeassistant/components/p1_monitor/diagnostics.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import asdict -from typing import Any +from typing import TYPE_CHECKING, Any, cast from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry @@ -18,6 +18,9 @@ from .const import ( SERVICE_WATERMETER, ) +if TYPE_CHECKING: + from _typeshed import DataclassInstance + TO_REDACT = { CONF_HOST, } @@ -42,6 +45,8 @@ async def async_get_config_entry_diagnostics( } if coordinator.has_water_meter: - data["data"]["watermeter"] = asdict(coordinator.data[SERVICE_WATERMETER]) + data["data"]["watermeter"] = asdict( + cast("DataclassInstance", coordinator.data[SERVICE_WATERMETER]) + ) return data diff --git a/homeassistant/components/zeroconf/usage.py b/homeassistant/components/zeroconf/usage.py index 0c452149bf..b9d51cd3c3 100644 --- a/homeassistant/components/zeroconf/usage.py +++ b/homeassistant/components/zeroconf/usage.py @@ -31,4 +31,4 @@ def install_multiple_zeroconf_catcher(hass_zc: HaZeroconf) -> None: return zeroconf.Zeroconf.__new__ = new_zeroconf_new # type: ignore[assignment] - zeroconf.Zeroconf.__init__ = new_zeroconf_init # type: ignore[assignment] + zeroconf.Zeroconf.__init__ = new_zeroconf_init # type: ignore[method-assign] diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 5dfab3077e..36295a6455 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Generator from dataclasses import asdict, dataclass, field -from typing import Any +from typing import TYPE_CHECKING, Any from awesomeversion import AwesomeVersion from zwave_js_server.const import ( @@ -60,6 +60,9 @@ from .discovery_data_template import ( ) from .helpers import ZwaveValueID +if TYPE_CHECKING: + from _typeshed import DataclassInstance + class ValueType(StrEnum): """Enum with all value types.""" @@ -73,7 +76,7 @@ class ValueType(StrEnum): class DataclassMustHaveAtLeastOne: """A dataclass that must have at least one input parameter that is not None.""" - def __post_init__(self) -> None: + def __post_init__(self: DataclassInstance) -> None: """Post dataclass initialization.""" if all(val is None for val in asdict(self).values()): raise ValueError("At least one input parameter must not be None") diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index d623de5e81..f75b8e3aa4 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -129,7 +129,7 @@ def _async_create_clientsession( {USER_AGENT: SERVER_SOFTWARE}, ) - clientsession.close = warn_use( # type: ignore[assignment] + clientsession.close = warn_use( # type: ignore[method-assign] clientsession.close, WARN_CLOSE_MSG, ) diff --git a/homeassistant/helpers/httpx_client.py b/homeassistant/helpers/httpx_client.py index e02759b09f..2475469a7d 100644 --- a/homeassistant/helpers/httpx_client.py +++ b/homeassistant/helpers/httpx_client.py @@ -72,7 +72,7 @@ def create_async_httpx_client( original_aclose = client.aclose - client.aclose = warn_use( # type: ignore[assignment] + client.aclose = warn_use( # type: ignore[method-assign] client.aclose, "closes the Home Assistant httpx client" ) diff --git a/homeassistant/helpers/schema_config_entry_flow.py b/homeassistant/helpers/schema_config_entry_flow.py index 9f76a639e0..5101e5c69a 100644 --- a/homeassistant/helpers/schema_config_entry_flow.py +++ b/homeassistant/helpers/schema_config_entry_flow.py @@ -275,7 +275,7 @@ class SchemaConfigFlowHandler(config_entries.ConfigFlow, ABC): ) # Create an async_get_options_flow method - cls.async_get_options_flow = _async_get_options_flow # type: ignore[assignment] + cls.async_get_options_flow = _async_get_options_flow # type: ignore[method-assign] # Create flow step methods for each step defined in the flow schema for step in cls.config_flow: diff --git a/homeassistant/runner.py b/homeassistant/runner.py index 0926fb6745..e5a87a4b09 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -110,7 +110,7 @@ class HassEventLoopPolicy(asyncio.DefaultEventLoopPolicy): thread_name_prefix="SyncWorker", max_workers=MAX_EXECUTOR_WORKERS ) loop.set_default_executor(executor) - loop.set_default_executor = warn_use( # type: ignore[assignment] + loop.set_default_executor = warn_use( # type: ignore[method-assign] loop.set_default_executor, "sets default executor on the event loop" ) return loop diff --git a/requirements_test.txt b/requirements_test.txt index f5be4d0759..10ceb81365 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -12,9 +12,9 @@ codecov==2.1.12 coverage==7.2.1 freezegun==1.2.2 mock-open==1.4.0 -mypy==1.0.1 +mypy==1.1.1 pre-commit==3.1.0 -pydantic==1.10.5 +pydantic==1.10.6 pylint==2.17.0 pylint-per-file-ignores==1.1.0 pipdeptree==2.5.0 From 170a13302c8fdf2285615126bbf80b946a9a666b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Mar 2023 14:51:45 -1000 Subject: [PATCH 0778/2810] Reduce overhead to store context ids in the database (#88942) --- homeassistant/components/logbook/models.py | 58 +++++-- homeassistant/components/logbook/processor.py | 28 ++-- .../components/logbook/queries/__init__.py | 4 +- .../components/logbook/queries/all.py | 16 +- .../components/logbook/queries/common.py | 30 ++-- .../components/logbook/queries/devices.py | 6 +- .../components/logbook/queries/entities.py | 12 +- .../logbook/queries/entities_and_devices.py | 12 +- homeassistant/components/recorder/core.py | 6 + .../components/recorder/db_schema.py | 84 +++++++--- .../components/recorder/migration.py | 155 +++++++++++++---- homeassistant/components/recorder/models.py | 38 +++++ homeassistant/components/recorder/queries.py | 28 ++++ homeassistant/components/recorder/tasks.py | 17 ++ homeassistant/util/ulid.py | 4 +- tests/components/logbook/common.py | 16 +- tests/components/logbook/test_init.py | 60 +++---- .../components/logbook/test_websocket_api.py | 18 +- .../db_schema_23_with_newer_columns.py | 35 ++++ tests/components/recorder/db_schema_30.py | 34 ++++ tests/components/recorder/test_migrate.py | 157 +++++++++++++++++- tests/conftest.py | 18 ++ 22 files changed, 676 insertions(+), 160 deletions(-) diff --git a/homeassistant/components/logbook/models.py b/homeassistant/components/logbook/models.py index 3fc4b5dac8..a5ce9eddce 100644 --- a/homeassistant/components/logbook/models.py +++ b/homeassistant/components/logbook/models.py @@ -2,14 +2,21 @@ from __future__ import annotations from dataclasses import dataclass -import json from typing import Any, cast from sqlalchemy.engine.row import Row +from homeassistant.components.recorder.models import ( + bytes_to_ulid_or_none, + bytes_to_uuid_hex_or_none, + ulid_to_bytes_or_none, + uuid_hex_to_bytes_or_none, +) from homeassistant.const import ATTR_ICON, EVENT_STATE_CHANGED from homeassistant.core import Context, Event, State, callback import homeassistant.util.dt as dt_util +from homeassistant.util.json import json_loads +from homeassistant.util.ulid import ulid_to_bytes class LazyEventPartialState: @@ -22,9 +29,9 @@ class LazyEventPartialState: "event_type", "entity_id", "state", - "context_id", - "context_user_id", - "context_parent_id", + "context_id_bin", + "context_user_id_bin", + "context_parent_id_bin", "data", ] @@ -40,9 +47,9 @@ class LazyEventPartialState: self.event_type: str | None = self.row.event_type self.entity_id: str | None = self.row.entity_id self.state = self.row.state - self.context_id: str | None = self.row.context_id - self.context_user_id: str | None = self.row.context_user_id - self.context_parent_id: str | None = self.row.context_parent_id + self.context_id_bin: bytes | None = self.row.context_id_bin + self.context_user_id_bin: bytes | None = self.row.context_user_id_bin + self.context_parent_id_bin: bytes | None = self.row.context_parent_id_bin if data := getattr(row, "data", None): # If its an EventAsRow we can avoid the whole # json decode process as we already have the data @@ -55,9 +62,24 @@ class LazyEventPartialState: self.data = event_data else: self.data = self._event_data_cache[source] = cast( - dict[str, Any], json.loads(source) + dict[str, Any], json_loads(source) ) + @property + def context_id(self) -> str | None: + """Return the context id.""" + return bytes_to_ulid_or_none(self.context_id_bin) + + @property + def context_user_id(self) -> str | None: + """Return the context user id.""" + return bytes_to_uuid_hex_or_none(self.context_user_id_bin) + + @property + def context_parent_id(self) -> str | None: + """Return the context parent id.""" + return bytes_to_ulid_or_none(self.context_parent_id_bin) + @dataclass(frozen=True) class EventAsRow: @@ -65,7 +87,7 @@ class EventAsRow: data: dict[str, Any] context: Context - context_id: str + context_id_bin: bytes time_fired_ts: float state_id: int event_data: str | None = None @@ -73,8 +95,8 @@ class EventAsRow: event_id: None = None entity_id: str | None = None icon: str | None = None - context_user_id: str | None = None - context_parent_id: str | None = None + context_user_id_bin: bytes | None = None + context_parent_id_bin: bytes | None = None event_type: str | None = None state: str | None = None shared_data: str | None = None @@ -85,13 +107,14 @@ class EventAsRow: def async_event_to_row(event: Event) -> EventAsRow: """Convert an event to a row.""" if event.event_type != EVENT_STATE_CHANGED: + context = event.context return EventAsRow( data=event.data, context=event.context, event_type=event.event_type, - context_id=event.context.id, - context_user_id=event.context.user_id, - context_parent_id=event.context.parent_id, + context_id_bin=ulid_to_bytes(context.id), + context_user_id_bin=uuid_hex_to_bytes_or_none(context.user_id), + context_parent_id_bin=ulid_to_bytes_or_none(context.parent_id), time_fired_ts=dt_util.utc_to_timestamp(event.time_fired), state_id=hash(event), ) @@ -99,14 +122,15 @@ def async_event_to_row(event: Event) -> EventAsRow: # that are missing new_state or old_state # since the logbook does not show these new_state: State = event.data["new_state"] + context = new_state.context return EventAsRow( data=event.data, context=event.context, entity_id=new_state.entity_id, state=new_state.state, - context_id=new_state.context.id, - context_user_id=new_state.context.user_id, - context_parent_id=new_state.context.parent_id, + context_id_bin=ulid_to_bytes(context.id), + context_user_id_bin=uuid_hex_to_bytes_or_none(context.user_id), + context_parent_id_bin=ulid_to_bytes_or_none(context.parent_id), time_fired_ts=dt_util.utc_to_timestamp(new_state.last_updated), state_id=hash(event), icon=new_state.attributes.get(ATTR_ICON), diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index 289ee677a2..39d8e920aa 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -12,6 +12,7 @@ from sqlalchemy.engine.row import Row from homeassistant.components.recorder.filters import Filters from homeassistant.components.recorder.models import ( + bytes_to_uuid_hex_or_none, process_datetime_to_timestamp, process_timestamp_to_utc_isoformat, ) @@ -261,14 +262,14 @@ class ContextLookup: """Memorize context origin.""" self.hass = hass self._memorize_new = True - self._lookup: dict[str | None, Row | EventAsRow | None] = {None: None} + self._lookup: dict[bytes | None, Row | EventAsRow | None] = {None: None} - def memorize(self, row: Row | EventAsRow) -> str | None: + def memorize(self, row: Row | EventAsRow) -> bytes | None: """Memorize a context from the database.""" if self._memorize_new: - context_id: str = row.context_id - self._lookup.setdefault(context_id, row) - return context_id + context_id_bin: bytes = row.context_id_bin + self._lookup.setdefault(context_id_bin, row) + return context_id_bin return None def clear(self) -> None: @@ -276,9 +277,9 @@ class ContextLookup: self._lookup.clear() self._memorize_new = False - def get(self, context_id: str) -> Row | EventAsRow | None: + def get(self, context_id_bin: bytes) -> Row | EventAsRow | None: """Get the context origin.""" - return self._lookup.get(context_id) + return self._lookup.get(context_id_bin) class ContextAugmenter: @@ -293,7 +294,7 @@ class ContextAugmenter: self.include_entity_name = logbook_run.include_entity_name def _get_context_row( - self, context_id: str | None, row: Row | EventAsRow + self, context_id: bytes | None, row: Row | EventAsRow ) -> Row | EventAsRow | None: """Get the context row from the id or row context.""" if context_id: @@ -305,11 +306,11 @@ class ContextAugmenter: return None def augment( - self, data: dict[str, Any], row: Row | EventAsRow, context_id: str | None + self, data: dict[str, Any], row: Row | EventAsRow, context_id: bytes | None ) -> None: """Augment data from the row and cache.""" - if context_user_id := row.context_user_id: - data[CONTEXT_USER_ID] = context_user_id + if context_user_id_bin := row.context_user_id_bin: + data[CONTEXT_USER_ID] = bytes_to_uuid_hex_or_none(context_user_id_bin) if not (context_row := self._get_context_row(context_id, row)): return @@ -317,11 +318,12 @@ class ContextAugmenter: if _rows_match(row, context_row): # This is the first event with the given ID. Was it directly caused by # a parent event? + context_parent_id_bin = row.context_parent_id_bin if ( - not row.context_parent_id + not context_parent_id_bin or ( context_row := self._get_context_row( - row.context_parent_id, context_row + context_parent_id_bin, context_row ) ) is None diff --git a/homeassistant/components/logbook/queries/__init__.py b/homeassistant/components/logbook/queries/__init__.py index 8a2ee40de4..b88fd4842c 100644 --- a/homeassistant/components/logbook/queries/__init__.py +++ b/homeassistant/components/logbook/queries/__init__.py @@ -6,6 +6,7 @@ from datetime import datetime as dt from sqlalchemy.sql.lambdas import StatementLambdaElement from homeassistant.components.recorder.filters import Filters +from homeassistant.components.recorder.models import ulid_to_bytes_or_none from homeassistant.helpers.json import json_dumps from homeassistant.util import dt as dt_util @@ -27,6 +28,7 @@ def statement_for_request( """Generate the logbook statement for a logbook request.""" start_day = dt_util.utc_to_timestamp(start_day_dt) end_day = dt_util.utc_to_timestamp(end_day_dt) + context_id_bin = ulid_to_bytes_or_none(context_id) # No entities: logbook sends everything for the timeframe # limited by the context_id and the yaml configured filter if not entity_ids and not device_ids: @@ -38,7 +40,7 @@ def statement_for_request( event_types, states_entity_filter, events_entity_filter, - context_id, + context_id_bin, ) # sqlalchemy caches object quoting, the diff --git a/homeassistant/components/logbook/queries/all.py b/homeassistant/components/logbook/queries/all.py index 729a4d2195..5311d5a9d6 100644 --- a/homeassistant/components/logbook/queries/all.py +++ b/homeassistant/components/logbook/queries/all.py @@ -26,28 +26,28 @@ def all_stmt( event_types: tuple[str, ...], states_entity_filter: ColumnElement | None = None, events_entity_filter: ColumnElement | None = None, - context_id: str | None = None, + context_id_bin: bytes | None = None, ) -> StatementLambdaElement: """Generate a logbook query for all entities.""" stmt = lambda_stmt( lambda: select_events_without_states(start_day, end_day, event_types) ) - if context_id is not None: + if context_id_bin is not None: # Once all the old `state_changed` events # are gone from the database remove the # _legacy_select_events_context_id() - stmt += lambda s: s.where(Events.context_id == context_id).union_all( + stmt += lambda s: s.where(Events.context_id_bin == context_id_bin).union_all( _states_query_for_context_id( start_day, end_day, # https://github.com/python/mypy/issues/2608 - context_id, # type:ignore[arg-type] + context_id_bin, # type:ignore[arg-type] ), legacy_select_events_context_id( start_day, end_day, # https://github.com/python/mypy/issues/2608 - context_id, # type:ignore[arg-type] + context_id_bin, # type:ignore[arg-type] ), ) else: @@ -76,12 +76,14 @@ def _apply_all_hints(sel: Select) -> Select: """Force mysql to use the right index on large selects.""" return sel.with_hint( States, f"FORCE INDEX ({LAST_UPDATED_INDEX_TS})", dialect_name="mysql" + ).with_hint( + States, f"FORCE INDEX ({LAST_UPDATED_INDEX_TS})", dialect_name="mariadb" ) def _states_query_for_context_id( - start_day: float, end_day: float, context_id: str + start_day: float, end_day: float, context_id_bin: bytes ) -> Select: return apply_states_filters(select_states(), start_day, end_day).where( - States.context_id == context_id + States.context_id_bin == context_id_bin ) diff --git a/homeassistant/components/logbook/queries/common.py b/homeassistant/components/logbook/queries/common.py index ca00f31615..a0c8ddbdda 100644 --- a/homeassistant/components/logbook/queries/common.py +++ b/homeassistant/components/logbook/queries/common.py @@ -10,11 +10,11 @@ from sqlalchemy.sql.expression import literal from sqlalchemy.sql.selectable import Select from homeassistant.components.recorder.db_schema import ( - EVENTS_CONTEXT_ID_INDEX, + EVENTS_CONTEXT_ID_BIN_INDEX, OLD_FORMAT_ATTRS_JSON, OLD_STATE, SHARED_ATTRS_JSON, - STATES_CONTEXT_ID_INDEX, + STATES_CONTEXT_ID_BIN_INDEX, EventData, Events, StateAttributes, @@ -47,9 +47,9 @@ EVENT_COLUMNS = ( Events.event_type.label("event_type"), Events.event_data.label("event_data"), Events.time_fired_ts.label("time_fired_ts"), - Events.context_id.label("context_id"), - Events.context_user_id.label("context_user_id"), - Events.context_parent_id.label("context_parent_id"), + Events.context_id_bin.label("context_id_bin"), + Events.context_user_id_bin.label("context_user_id_bin"), + Events.context_parent_id_bin.label("context_parent_id_bin"), ) STATE_COLUMNS = ( @@ -79,9 +79,9 @@ EVENT_COLUMNS_FOR_STATE_SELECT = ( ), literal(value=None, type_=sqlalchemy.Text).label("event_data"), States.last_updated_ts.label("time_fired_ts"), - States.context_id.label("context_id"), - States.context_user_id.label("context_user_id"), - States.context_parent_id.label("context_parent_id"), + States.context_id_bin.label("context_id_bin"), + States.context_user_id_bin.label("context_user_id_bin"), + States.context_parent_id_bin.label("context_parent_id_bin"), literal(value=None, type_=sqlalchemy.Text).label("shared_data"), ) @@ -113,7 +113,7 @@ def select_events_context_id_subquery( ) -> Select: """Generate the select for a context_id subquery.""" return ( - select(Events.context_id) + select(Events.context_id_bin) .where((Events.time_fired_ts > start_day) & (Events.time_fired_ts < end_day)) .where(Events.event_type.in_(event_types)) .outerjoin(EventData, (Events.data_id == EventData.data_id)) @@ -162,7 +162,7 @@ def select_states() -> Select: def legacy_select_events_context_id( - start_day: float, end_day: float, context_id: str + start_day: float, end_day: float, context_id_bin: bytes ) -> Select: """Generate a legacy events context id select that also joins states.""" # This can be removed once we no longer have event_ids in the states table @@ -183,7 +183,7 @@ def legacy_select_events_context_id( StateAttributes, (States.attributes_id == StateAttributes.attributes_id) ) .where((Events.time_fired_ts > start_day) & (Events.time_fired_ts < end_day)) - .where(Events.context_id == context_id) + .where(Events.context_id_bin == context_id_bin) ) @@ -277,12 +277,16 @@ def _not_uom_attributes_matcher() -> BooleanClauseList: def apply_states_context_hints(sel: Select) -> Select: """Force mysql to use the right index on large context_id selects.""" return sel.with_hint( - States, f"FORCE INDEX ({STATES_CONTEXT_ID_INDEX})", dialect_name="mysql" + States, f"FORCE INDEX ({STATES_CONTEXT_ID_BIN_INDEX})", dialect_name="mysql" + ).with_hint( + States, f"FORCE INDEX ({STATES_CONTEXT_ID_BIN_INDEX})", dialect_name="mariadb" ) def apply_events_context_hints(sel: Select) -> Select: """Force mysql to use the right index on large context_id selects.""" return sel.with_hint( - Events, f"FORCE INDEX ({EVENTS_CONTEXT_ID_INDEX})", dialect_name="mysql" + Events, f"FORCE INDEX ({EVENTS_CONTEXT_ID_BIN_INDEX})", dialect_name="mysql" + ).with_hint( + Events, f"FORCE INDEX ({EVENTS_CONTEXT_ID_BIN_INDEX})", dialect_name="mariadb" ) diff --git a/homeassistant/components/logbook/queries/devices.py b/homeassistant/components/logbook/queries/devices.py index fa2deaf4c0..303313602d 100644 --- a/homeassistant/components/logbook/queries/devices.py +++ b/homeassistant/components/logbook/queries/devices.py @@ -36,7 +36,7 @@ def _select_device_id_context_ids_sub_query( inner = select_events_context_id_subquery(start_day, end_day, event_types).where( apply_event_device_id_matchers(json_quotable_device_ids) ) - return select(inner.c.context_id).group_by(inner.c.context_id) + return select(inner.c.context_id_bin).group_by(inner.c.context_id_bin) def _apply_devices_context_union( @@ -57,12 +57,12 @@ def _apply_devices_context_union( apply_events_context_hints( select_events_context_only() .select_from(devices_cte) - .outerjoin(Events, devices_cte.c.context_id == Events.context_id) + .outerjoin(Events, devices_cte.c.context_id_bin == Events.context_id_bin) ).outerjoin(EventData, (Events.data_id == EventData.data_id)), apply_states_context_hints( select_states_context_only() .select_from(devices_cte) - .outerjoin(States, devices_cte.c.context_id == States.context_id) + .outerjoin(States, devices_cte.c.context_id_bin == States.context_id_bin) ), ) diff --git a/homeassistant/components/logbook/queries/entities.py b/homeassistant/components/logbook/queries/entities.py index 3d26443ce9..2c095d1b05 100644 --- a/homeassistant/components/logbook/queries/entities.py +++ b/homeassistant/components/logbook/queries/entities.py @@ -42,13 +42,13 @@ def _select_entities_context_ids_sub_query( select_events_context_id_subquery(start_day, end_day, event_types).where( apply_event_entity_id_matchers(json_quoted_entity_ids) ), - apply_entities_hints(select(States.context_id)) + apply_entities_hints(select(States.context_id_bin)) .filter( (States.last_updated_ts > start_day) & (States.last_updated_ts < end_day) ) .where(States.entity_id.in_(entity_ids)), ) - return select(union.c.context_id).group_by(union.c.context_id) + return select(union.c.context_id_bin).group_by(union.c.context_id_bin) def _apply_entities_context_union( @@ -77,12 +77,12 @@ def _apply_entities_context_union( apply_events_context_hints( select_events_context_only() .select_from(entities_cte) - .outerjoin(Events, entities_cte.c.context_id == Events.context_id) + .outerjoin(Events, entities_cte.c.context_id_bin == Events.context_id_bin) ).outerjoin(EventData, (Events.data_id == EventData.data_id)), apply_states_context_hints( select_states_context_only() .select_from(entities_cte) - .outerjoin(States, entities_cte.c.context_id == States.context_id) + .outerjoin(States, entities_cte.c.context_id_bin == States.context_id_bin) ), ) @@ -138,4 +138,8 @@ def apply_entities_hints(sel: Select) -> Select: """Force mysql to use the right index on large selects.""" return sel.with_hint( States, f"FORCE INDEX ({ENTITY_ID_LAST_UPDATED_INDEX_TS})", dialect_name="mysql" + ).with_hint( + States, + f"FORCE INDEX ({ENTITY_ID_LAST_UPDATED_INDEX_TS})", + dialect_name="mariadb", ) diff --git a/homeassistant/components/logbook/queries/entities_and_devices.py b/homeassistant/components/logbook/queries/entities_and_devices.py index 43d11d0bdf..ec38dc7b6d 100644 --- a/homeassistant/components/logbook/queries/entities_and_devices.py +++ b/homeassistant/components/logbook/queries/entities_and_devices.py @@ -41,13 +41,13 @@ def _select_entities_device_id_context_ids_sub_query( json_quoted_entity_ids, json_quoted_device_ids ) ), - apply_entities_hints(select(States.context_id)) + apply_entities_hints(select(States.context_id_bin)) .filter( (States.last_updated_ts > start_day) & (States.last_updated_ts < end_day) ) .where(States.entity_id.in_(entity_ids)), ) - return select(union.c.context_id).group_by(union.c.context_id) + return select(union.c.context_id_bin).group_by(union.c.context_id_bin) def _apply_entities_devices_context_union( @@ -77,12 +77,16 @@ def _apply_entities_devices_context_union( apply_events_context_hints( select_events_context_only() .select_from(devices_entities_cte) - .outerjoin(Events, devices_entities_cte.c.context_id == Events.context_id) + .outerjoin( + Events, devices_entities_cte.c.context_id_bin == Events.context_id_bin + ) ).outerjoin(EventData, (Events.data_id == EventData.data_id)), apply_states_context_hints( select_states_context_only() .select_from(devices_entities_cte) - .outerjoin(States, devices_entities_cte.c.context_id == States.context_id) + .outerjoin( + States, devices_entities_cte.c.context_id_bin == States.context_id_bin + ) ), ) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 4e099a3b17..7e3f08d7ab 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -89,6 +89,7 @@ from .tasks import ( ChangeStatisticsUnitTask, ClearStatisticsTask, CommitTask, + ContextIDMigrationTask, DatabaseLockTask, EventTask, ImportStatisticsTask, @@ -687,6 +688,7 @@ class Recorder(threading.Thread): _LOGGER.debug("Recorder processing the queue") self._adjust_lru_size() self.hass.add_job(self._async_set_recorder_ready_migration_done) + self.queue_task(ContextIDMigrationTask()) self._run_event_loop() self._shutdown() @@ -1146,6 +1148,10 @@ class Recorder(threading.Thread): """Run post schema migration tasks.""" migration.post_schema_migration(self, old_version, new_version) + def _migrate_context_ids(self) -> bool: + """Migrate context ids if needed.""" + return migration.migrate_context_ids(self) + def _send_keep_alive(self) -> None: """Send a keep alive to keep the db connection open.""" assert self.event_session is not None diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 9a059c570c..f794c64714 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -21,6 +21,7 @@ from sqlalchemy import ( Identity, Index, Integer, + LargeBinary, SmallInteger, String, Text, @@ -55,8 +56,12 @@ from .models import ( StatisticData, StatisticDataTimestamp, StatisticMetaData, + bytes_to_ulid_or_none, + bytes_to_uuid_hex_or_none, datetime_to_timestamp_or_none, process_timestamp, + ulid_to_bytes_or_none, + uuid_hex_to_bytes_or_none, ) @@ -66,7 +71,7 @@ class Base(DeclarativeBase): """Base class for tables.""" -SCHEMA_VERSION = 35 +SCHEMA_VERSION = 36 _LOGGER = logging.getLogger(__name__) @@ -108,8 +113,9 @@ TABLES_TO_CHECK = [ LAST_UPDATED_INDEX_TS = "ix_states_last_updated_ts" ENTITY_ID_LAST_UPDATED_INDEX_TS = "ix_states_entity_id_last_updated_ts" -EVENTS_CONTEXT_ID_INDEX = "ix_events_context_id" -STATES_CONTEXT_ID_INDEX = "ix_states_context_id" +EVENTS_CONTEXT_ID_BIN_INDEX = "ix_events_context_id_bin" +STATES_CONTEXT_ID_BIN_INDEX = "ix_states_context_id_bin" +CONTEXT_ID_BIN_MAX_LENGTH = 16 _DEFAULT_TABLE_ARGS = { "mysql_default_charset": "utf8mb4", @@ -174,6 +180,12 @@ class Events(Base): # Used for fetching events at a specific time # see logbook Index("ix_events_event_type_time_fired_ts", "event_type", "time_fired_ts"), + Index( + EVENTS_CONTEXT_ID_BIN_INDEX, + "context_id_bin", + mysql_length=CONTEXT_ID_BIN_MAX_LENGTH, + mariadb_length=CONTEXT_ID_BIN_MAX_LENGTH, + ), _DEFAULT_TABLE_ARGS, ) __tablename__ = TABLE_EVENTS @@ -190,18 +202,27 @@ class Events(Base): DATETIME_TYPE ) # no longer used for new rows time_fired_ts: Mapped[float | None] = mapped_column(TIMESTAMP_TYPE, index=True) - context_id: Mapped[str | None] = mapped_column( + context_id: Mapped[str | None] = mapped_column( # no longer used String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True ) - context_user_id: Mapped[str | None] = mapped_column( + context_user_id: Mapped[str | None] = mapped_column( # no longer used String(MAX_LENGTH_EVENT_CONTEXT_ID) ) - context_parent_id: Mapped[str | None] = mapped_column( + context_parent_id: Mapped[str | None] = mapped_column( # no longer used String(MAX_LENGTH_EVENT_CONTEXT_ID) ) data_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("event_data.data_id"), index=True ) + context_id_bin: Mapped[bytes | None] = mapped_column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH), + ) + context_user_id_bin: Mapped[bytes | None] = mapped_column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH), + ) + context_parent_id_bin: Mapped[bytes | None] = mapped_column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) event_data_rel: Mapped[EventData | None] = relationship("EventData") def __repr__(self) -> str: @@ -234,17 +255,20 @@ class Events(Base): origin_idx=EVENT_ORIGIN_TO_IDX.get(event.origin), time_fired=None, time_fired_ts=dt_util.utc_to_timestamp(event.time_fired), - context_id=event.context.id, - context_user_id=event.context.user_id, - context_parent_id=event.context.parent_id, + context_id=None, + context_id_bin=ulid_to_bytes_or_none(event.context.id), + context_user_id=None, + context_user_id_bin=uuid_hex_to_bytes_or_none(event.context.user_id), + context_parent_id=None, + context_parent_id_bin=ulid_to_bytes_or_none(event.context.parent_id), ) def to_native(self, validate_entity_id: bool = True) -> Event | None: """Convert to a native HA Event.""" context = Context( - id=self.context_id, - user_id=self.context_user_id, - parent_id=self.context_parent_id, + id=bytes_to_ulid_or_none(self.context_id_bin), + user_id=bytes_to_uuid_hex_or_none(self.context_user_id), + parent_id=bytes_to_ulid_or_none(self.context_parent_id_bin), ) try: return Event( @@ -316,6 +340,12 @@ class States(Base): # Used for fetching the state of entities at a specific time # (get_states in history.py) Index(ENTITY_ID_LAST_UPDATED_INDEX_TS, "entity_id", "last_updated_ts"), + Index( + STATES_CONTEXT_ID_BIN_INDEX, + "context_id_bin", + mysql_length=CONTEXT_ID_BIN_MAX_LENGTH, + mariadb_length=CONTEXT_ID_BIN_MAX_LENGTH, + ), _DEFAULT_TABLE_ARGS, ) __tablename__ = TABLE_STATES @@ -344,13 +374,13 @@ class States(Base): attributes_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("state_attributes.attributes_id"), index=True ) - context_id: Mapped[str | None] = mapped_column( + context_id: Mapped[str | None] = mapped_column( # no longer used String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True ) - context_user_id: Mapped[str | None] = mapped_column( + context_user_id: Mapped[str | None] = mapped_column( # no longer used String(MAX_LENGTH_EVENT_CONTEXT_ID) ) - context_parent_id: Mapped[str | None] = mapped_column( + context_parent_id: Mapped[str | None] = mapped_column( # no longer used String(MAX_LENGTH_EVENT_CONTEXT_ID) ) origin_idx: Mapped[int | None] = mapped_column( @@ -358,6 +388,15 @@ class States(Base): ) # 0 is local, 1 is remote old_state: Mapped[States | None] = relationship("States", remote_side=[state_id]) state_attributes: Mapped[StateAttributes | None] = relationship("StateAttributes") + context_id_bin: Mapped[bytes | None] = mapped_column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH), + ) + context_user_id_bin: Mapped[bytes | None] = mapped_column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH), + ) + context_parent_id_bin: Mapped[bytes | None] = mapped_column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) def __repr__(self) -> str: """Return string representation of instance for debugging.""" @@ -388,9 +427,12 @@ class States(Base): dbstate = States( entity_id=entity_id, attributes=None, - context_id=event.context.id, - context_user_id=event.context.user_id, - context_parent_id=event.context.parent_id, + context_id=None, + context_id_bin=ulid_to_bytes_or_none(event.context.id), + context_user_id=None, + context_user_id_bin=uuid_hex_to_bytes_or_none(event.context.user_id), + context_parent_id=None, + context_parent_id_bin=ulid_to_bytes_or_none(event.context.parent_id), origin_idx=EVENT_ORIGIN_TO_IDX.get(event.origin), last_updated=None, last_changed=None, @@ -414,9 +456,9 @@ class States(Base): def to_native(self, validate_entity_id: bool = True) -> State | None: """Convert to an HA state object.""" context = Context( - id=self.context_id, - user_id=self.context_user_id, - parent_id=self.context_parent_id, + id=bytes_to_ulid_or_none(self.context_id_bin), + user_id=bytes_to_uuid_hex_or_none(self.context_user_id), + parent_id=bytes_to_ulid_or_none(self.context_parent_id_bin), ) try: attrs = json_loads_object(self.attributes) if self.attributes else {} diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 0b8fe9243b..e0f1163491 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -7,9 +7,10 @@ from dataclasses import dataclass, replace as dataclass_replace from datetime import timedelta import logging from typing import TYPE_CHECKING, cast +from uuid import UUID import sqlalchemy -from sqlalchemy import ForeignKeyConstraint, MetaData, Table, func, text +from sqlalchemy import ForeignKeyConstraint, MetaData, Table, func, text, update from sqlalchemy.engine import CursorResult, Engine from sqlalchemy.exc import ( DatabaseError, @@ -24,20 +25,28 @@ from sqlalchemy.schema import AddConstraint, DropConstraint from sqlalchemy.sql.expression import true from homeassistant.core import HomeAssistant +from homeassistant.util.ulid import ulid_to_bytes from .const import SupportedDialect from .db_schema import ( + CONTEXT_ID_BIN_MAX_LENGTH, SCHEMA_VERSION, STATISTICS_TABLES, TABLE_STATES, Base, + Events, SchemaChanges, + States, Statistics, StatisticsMeta, StatisticsRuns, StatisticsShortTerm, ) from .models import process_timestamp +from .queries import ( + find_events_context_ids_to_migrate, + find_states_context_ids_to_migrate, +) from .statistics import ( correct_db_schema as statistics_correct_db_schema, delete_statistics_duplicates, @@ -56,7 +65,7 @@ if TYPE_CHECKING: from . import Recorder LIVE_MIGRATION_MIN_SCHEMA_VERSION = 0 - +_EMPTY_CONTEXT_ID = b"\x00" * 16 _LOGGER = logging.getLogger(__name__) @@ -219,7 +228,10 @@ def _create_index( def _drop_index( - session_maker: Callable[[], Session], table_name: str, index_name: str + session_maker: Callable[[], Session], + table_name: str, + index_name: str, + quiet: bool | None = None, ) -> None: """Drop an index from a specified table. @@ -282,33 +294,37 @@ def _drop_index( _LOGGER.debug( "Finished dropping index %s from table %s", index_name, table_name ) - else: - if index_name in ( - "ix_states_entity_id", - "ix_states_context_parent_id", - "ix_statistics_short_term_statistic_id_start", - "ix_statistics_statistic_id_start", - ): - # ix_states_context_parent_id was only there on nightly so we do not want - # to generate log noise or issues about it. - # - # ix_states_entity_id was only there for users who upgraded from schema - # version 8 or earlier. Newer installs will not have it so we do not - # want to generate log noise or issues about it. - # - # ix_statistics_short_term_statistic_id_start and ix_statistics_statistic_id_start - # were only there for users who upgraded from schema version 23 or earlier. - return + return - _LOGGER.warning( - ( - "Failed to drop index %s from table %s. Schema " - "Migration will continue; this is not a " - "critical operation" - ), - index_name, - table_name, - ) + if quiet: + return + + if index_name in ( + "ix_states_entity_id", + "ix_states_context_parent_id", + "ix_statistics_short_term_statistic_id_start", + "ix_statistics_statistic_id_start", + ): + # ix_states_context_parent_id was only there on nightly so we do not want + # to generate log noise or issues about it. + # + # ix_states_entity_id was only there for users who upgraded from schema + # version 8 or earlier. Newer installs will not have it so we do not + # want to generate log noise or issues about it. + # + # ix_statistics_short_term_statistic_id_start and ix_statistics_statistic_id_start + # were only there for users who upgraded from schema version 23 or earlier. + return + + _LOGGER.warning( + ( + "Failed to drop index %s from table %s. Schema " + "Migration will continue; this is not a " + "critical operation" + ), + index_name, + table_name, + ) def _add_columns( @@ -522,10 +538,15 @@ def _apply_update( # noqa: C901 """Perform operations to bring schema up to date.""" dialect = engine.dialect.name big_int = "INTEGER(20)" if dialect == SupportedDialect.MYSQL else "INTEGER" - if dialect in (SupportedDialect.MYSQL, SupportedDialect.POSTGRESQL): + if dialect == SupportedDialect.MYSQL: timestamp_type = "DOUBLE PRECISION" + context_bin_type = f"BLOB({CONTEXT_ID_BIN_MAX_LENGTH})" + if dialect == SupportedDialect.POSTGRESQL: + timestamp_type = "DOUBLE PRECISION" + context_bin_type = "BYTEA" else: timestamp_type = "FLOAT" + context_bin_type = "BLOB" if new_version == 1: # This used to create ix_events_time_fired, but it was removed in version 32 @@ -944,6 +965,19 @@ def _apply_update( # noqa: C901 ) # ix_statistics_start and ix_statistics_statistic_id_start are still used # for the post migration cleanup and can be removed in a future version. + elif new_version == 36: + for table in ("states", "events"): + _add_columns( + session_maker, + table, + [ + f"context_id_bin {context_bin_type}", + f"context_user_id_bin {context_bin_type}", + f"context_parent_id_bin {context_bin_type}", + ], + ) + _create_index(session_maker, "events", "ix_events_context_id_bin") + _create_index(session_maker, "states", "ix_states_context_id_bin") else: raise ValueError(f"No schema migration defined for version {new_version}") @@ -1193,6 +1227,67 @@ def _migrate_statistics_columns_to_timestamp( ) +def _context_id_to_bytes(context_id: str | None) -> bytes | None: + """Convert a context_id to bytes.""" + if context_id is None: + return None + if len(context_id) == 32: + return UUID(context_id).bytes + if len(context_id) == 26: + return ulid_to_bytes(context_id) + return None + + +def migrate_context_ids(instance: Recorder) -> bool: + """Migrate context_ids to use binary format.""" + _to_bytes = _context_id_to_bytes + session_maker = instance.get_session + _LOGGER.debug("Migrating context_ids to binary format") + with session_scope(session=session_maker()) as session: + if events := session.execute(find_events_context_ids_to_migrate()).all(): + session.execute( + update(Events), + [ + { + "event_id": event_id, + "context_id": None, + "context_id_bin": _to_bytes(context_id) or _EMPTY_CONTEXT_ID, + "context_user_id": None, + "context_user_id_bin": _to_bytes(context_user_id), + "context_parent_id": None, + "context_parent_id_bin": _to_bytes(context_parent_id), + } + for event_id, context_id, context_user_id, context_parent_id in events + ], + ) + if states := session.execute(find_states_context_ids_to_migrate()).all(): + session.execute( + update(States), + [ + { + "state_id": state_id, + "context_id": None, + "context_id_bin": _to_bytes(context_id) or _EMPTY_CONTEXT_ID, + "context_user_id": None, + "context_user_id_bin": _to_bytes(context_user_id), + "context_parent_id": None, + "context_parent_id_bin": _to_bytes(context_parent_id), + } + for state_id, context_id, context_user_id, context_parent_id in states + ], + ) + # If there is more work to do return False + # so that we can be called again + is_done = not (events or states) + + if is_done: + _drop_index(session_maker, "events", "ix_events_context_id", quiet=True) + _drop_index(session_maker, "states", "ix_states_context_id", quiet=True) + + _LOGGER.debug("Migrating context_ids to binary format: done=%s", is_done) + return is_done + + def _initialize_database(session: Session) -> bool: """Initialize a new database. diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index acdf61743f..053c870d8a 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -1,10 +1,13 @@ """Models for Recorder.""" from __future__ import annotations +from contextlib import suppress from dataclasses import dataclass from datetime import datetime, timedelta +from functools import lru_cache import logging from typing import Any, Literal, TypedDict, overload +from uuid import UUID from awesomeversion import AwesomeVersion from sqlalchemy.engine.row import Row @@ -18,6 +21,7 @@ from homeassistant.const import ( from homeassistant.core import Context, State import homeassistant.util.dt as dt_util from homeassistant.util.json import json_loads_object +from homeassistant.util.ulid import bytes_to_ulid, ulid_to_bytes from .const import SupportedDialect @@ -155,6 +159,40 @@ def timestamp_to_datetime_or_none(ts: float | None) -> datetime | None: return dt_util.utc_from_timestamp(ts) +def ulid_to_bytes_or_none(ulid: str | None) -> bytes | None: + """Convert an ulid to bytes.""" + if ulid is None: + return None + return ulid_to_bytes(ulid) + + +def bytes_to_ulid_or_none(_bytes: bytes | None) -> str | None: + """Convert bytes to a ulid.""" + if _bytes is None: + return None + return bytes_to_ulid(_bytes) + + +@lru_cache(maxsize=16) +def uuid_hex_to_bytes_or_none(uuid_hex: str | None) -> bytes | None: + """Convert a uuid hex to bytes.""" + if uuid_hex is None: + return None + with suppress(ValueError): + return UUID(hex=uuid_hex).bytes + return None + + +@lru_cache(maxsize=16) +def bytes_to_uuid_hex_or_none(_bytes: bytes | None) -> str | None: + """Convert bytes to a uuid hex.""" + if _bytes is None: + return None + with suppress(ValueError): + return UUID(bytes=_bytes).hex + return None + + class LazyStatePreSchema31(State): """A lazy version of core State before schema 31.""" diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py index d93a6b0d62..217b7ed11b 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -667,3 +667,31 @@ def find_legacy_row() -> StatementLambdaElement: # https://github.com/sqlalchemy/sqlalchemy/issues/9189 # pylint: disable-next=not-callable return lambda_stmt(lambda: select(func.max(States.event_id))) + + +def find_events_context_ids_to_migrate() -> StatementLambdaElement: + """Find events context_ids to migrate.""" + return lambda_stmt( + lambda: select( + Events.event_id, + Events.context_id, + Events.context_user_id, + Events.context_parent_id, + ) + .filter(Events.context_id_bin.is_(None)) + .limit(SQLITE_MAX_BIND_VARS) + ) + + +def find_states_context_ids_to_migrate() -> StatementLambdaElement: + """Find events context_ids to migrate.""" + return lambda_stmt( + lambda: select( + States.state_id, + States.context_id, + States.context_user_id, + States.context_parent_id, + ) + .filter(States.context_id_bin.is_(None)) + .limit(SQLITE_MAX_BIND_VARS) + ) diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index c8ad1aeb89..37a0277257 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -6,6 +6,7 @@ import asyncio from collections.abc import Callable, Iterable from dataclasses import dataclass from datetime import datetime +import logging import threading from typing import TYPE_CHECKING, Any @@ -18,6 +19,9 @@ from .db_schema import Statistics, StatisticsShortTerm from .models import StatisticData, StatisticMetaData from .util import periodic_db_cleanups +_LOGGER = logging.getLogger(__name__) + + if TYPE_CHECKING: from .core import Recorder @@ -339,3 +343,16 @@ class AdjustLRUSizeTask(RecorderTask): def run(self, instance: Recorder) -> None: """Handle the task to adjust the size.""" instance._adjust_lru_size() # pylint: disable=[protected-access] + + +@dataclass +class ContextIDMigrationTask(RecorderTask): + """An object to insert into the recorder queue to migrate context ids.""" + + commit_before = False + + def run(self, instance: Recorder) -> None: + """Run context id migration task.""" + if not instance._migrate_context_ids(): # pylint: disable=[protected-access] + # Schedule a new migration task if this one didn't finish + instance.queue_task(ContextIDMigrationTask()) diff --git a/homeassistant/util/ulid.py b/homeassistant/util/ulid.py index 304a42ec61..643286cedb 100644 --- a/homeassistant/util/ulid.py +++ b/homeassistant/util/ulid.py @@ -3,9 +3,9 @@ from __future__ import annotations import time -from ulid_transform import ulid_at_time, ulid_hex +from ulid_transform import bytes_to_ulid, ulid_at_time, ulid_hex, ulid_to_bytes -__all__ = ["ulid", "ulid_hex", "ulid_at_time"] +__all__ = ["ulid", "ulid_hex", "ulid_at_time", "ulid_to_bytes", "bytes_to_ulid"] def ulid(timestamp: float | None = None) -> str: diff --git a/tests/components/logbook/common.py b/tests/components/logbook/common.py index e6bce9e6fb..d08366e2f1 100644 --- a/tests/components/logbook/common.py +++ b/tests/components/logbook/common.py @@ -6,7 +6,11 @@ from typing import Any from homeassistant.components import logbook from homeassistant.components.logbook import processor -from homeassistant.components.recorder.models import process_timestamp_to_utc_isoformat +from homeassistant.components.recorder.models import ( + process_timestamp_to_utc_isoformat, + ulid_to_bytes_or_none, + uuid_hex_to_bytes_or_none, +) from homeassistant.core import Context from homeassistant.helpers import entity_registry as er from homeassistant.helpers.json import JSONEncoder @@ -28,9 +32,13 @@ class MockRow: self.data = data self.time_fired = dt_util.utcnow() self.time_fired_ts = dt_util.utc_to_timestamp(self.time_fired) - self.context_parent_id = context.parent_id if context else None - self.context_user_id = context.user_id if context else None - self.context_id = context.id if context else None + self.context_parent_id_bin = ( + ulid_to_bytes_or_none(context.parent_id) if context else None + ) + self.context_user_id_bin = ( + uuid_hex_to_bytes_or_none(context.user_id) if context else None + ) + self.context_id_bin = ulid_to_bytes_or_none(context.id) if context else None self.state = None self.entity_id = None self.state_id = None diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index bb83c1fdb5..a3e240f682 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -323,9 +323,9 @@ def create_state_changed_event_from_old_new( "event_data", "time_fired", "time_fired_ts", - "context_id", - "context_user_id", - "context_parent_id", + "context_id_bin", + "context_user_id_bin", + "context_parent_id_bin", "state", "entity_id", "domain", @@ -349,12 +349,12 @@ def create_state_changed_event_from_old_new( row.entity_id = entity_id row.domain = entity_id and ha.split_entity_id(entity_id)[0] row.context_only = False - row.context_id = None + row.context_id_bin = None row.friendly_name = None row.icon = None row.old_format_icon = None - row.context_user_id = None - row.context_parent_id = None + row.context_user_id_bin = None + row.context_parent_id_bin = None row.old_state_id = old_state and 1 row.state_id = new_state and 1 return LazyEventPartialState(row, {}) @@ -966,7 +966,7 @@ async def test_logbook_entity_context_id( await async_recorder_block_till_done(hass) context = ha.Context( - id="ac5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X476W5TVAAA", user_id="b400facee45711eaa9308bfd3d19e474", ) @@ -1027,7 +1027,7 @@ async def test_logbook_entity_context_id( # A service call light_turn_off_service_context = ha.Context( - id="9c5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X476W5TVBFC", user_id="9400facee45711eaa9308bfd3d19e474", ) hass.states.async_set("light.switch", STATE_ON) @@ -1120,7 +1120,7 @@ async def test_logbook_context_id_automation_script_started_manually( # An Automation automation_entity_id_test = "automation.alarm" automation_context = ha.Context( - id="fc5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X476W5TVCCC", user_id="f400facee45711eaa9308bfd3d19e474", ) hass.bus.async_fire( @@ -1129,7 +1129,7 @@ async def test_logbook_context_id_automation_script_started_manually( context=automation_context, ) script_context = ha.Context( - id="ac5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X476W5TVAAA", user_id="b400facee45711eaa9308bfd3d19e474", ) hass.bus.async_fire( @@ -1141,7 +1141,7 @@ async def test_logbook_context_id_automation_script_started_manually( hass.bus.async_fire(EVENT_HOMEASSISTANT_START) script_2_context = ha.Context( - id="1234", + id="01GTDGKBCH00GW0X476W5TVEEE", user_id="b400facee45711eaa9308bfd3d19e474", ) hass.bus.async_fire( @@ -1172,12 +1172,12 @@ async def test_logbook_context_id_automation_script_started_manually( assert json_dict[0]["entity_id"] == "automation.alarm" assert "context_entity_id" not in json_dict[0] assert json_dict[0]["context_user_id"] == "f400facee45711eaa9308bfd3d19e474" - assert json_dict[0]["context_id"] == "fc5bd62de45711eaaeb351041eec8dd9" + assert json_dict[0]["context_id"] == "01GTDGKBCH00GW0X476W5TVCCC" assert json_dict[1]["entity_id"] == "script.mock_script" assert "context_entity_id" not in json_dict[1] assert json_dict[1]["context_user_id"] == "b400facee45711eaa9308bfd3d19e474" - assert json_dict[1]["context_id"] == "ac5bd62de45711eaaeb351041eec8dd9" + assert json_dict[1]["context_id"] == "01GTDGKBCH00GW0X476W5TVAAA" assert json_dict[2]["domain"] == "homeassistant" @@ -1185,7 +1185,7 @@ async def test_logbook_context_id_automation_script_started_manually( assert json_dict[3]["name"] == "Mock script" assert "context_entity_id" not in json_dict[1] assert json_dict[3]["context_user_id"] == "b400facee45711eaa9308bfd3d19e474" - assert json_dict[3]["context_id"] == "1234" + assert json_dict[3]["context_id"] == "01GTDGKBCH00GW0X476W5TVEEE" assert json_dict[4]["entity_id"] == "switch.new" assert json_dict[4]["state"] == "off" @@ -1209,7 +1209,7 @@ async def test_logbook_entity_context_parent_id( await async_recorder_block_till_done(hass) context = ha.Context( - id="ac5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X476W5TVAAA", user_id="b400facee45711eaa9308bfd3d19e474", ) @@ -1222,8 +1222,8 @@ async def test_logbook_entity_context_parent_id( ) child_context = ha.Context( - id="2798bfedf8234b5e9f4009c91f48f30c", - parent_id="ac5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X476W5TVDDD", + parent_id="01GTDGKBCH00GW0X476W5TVAAA", user_id="b400facee45711eaa9308bfd3d19e474", ) hass.bus.async_fire( @@ -1274,8 +1274,8 @@ async def test_logbook_entity_context_parent_id( # A state change via service call with the script as the parent light_turn_off_service_context = ha.Context( - id="9c5bd62de45711eaaeb351041eec8dd9", - parent_id="2798bfedf8234b5e9f4009c91f48f30c", + id="01GTDGKBCH00GW0X476W5TVBFC", + parent_id="01GTDGKBCH00GW0X476W5TVDDD", user_id="9400facee45711eaa9308bfd3d19e474", ) hass.states.async_set("light.switch", STATE_ON) @@ -1299,8 +1299,8 @@ async def test_logbook_entity_context_parent_id( # An event with a parent event, but the parent event isn't available missing_parent_context = ha.Context( - id="fc40b9a0d1f246f98c34b33c76228ee6", - parent_id="c8ce515fe58e442f8664246c65ed964f", + id="01GTDGKBCH00GW0X476W5TEDDD", + parent_id="01GTDGKBCH00GW0X276W5TEDDD", user_id="485cacf93ef84d25a99ced3126b921d2", ) logbook.async_log_entry( @@ -1423,7 +1423,7 @@ async def test_logbook_context_from_template( await hass.async_block_till_done() switch_turn_off_context = ha.Context( - id="9c5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X476W5TVBFC", user_id="9400facee45711eaa9308bfd3d19e474", ) hass.states.async_set( @@ -1506,7 +1506,7 @@ async def test_logbook_( await hass.async_block_till_done() switch_turn_off_context = ha.Context( - id="9c5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X476W5TVBFC", user_id="9400facee45711eaa9308bfd3d19e474", ) hass.states.async_set( @@ -1692,7 +1692,7 @@ async def test_logbook_multiple_entities( await hass.async_block_till_done() switch_turn_off_context = ha.Context( - id="9c5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X476W5TVBFC", user_id="9400facee45711eaa9308bfd3d19e474", ) hass.states.async_set( @@ -2394,7 +2394,7 @@ async def test_get_events( hass.states.async_set("light.kitchen", STATE_ON, {"brightness": 400}) await hass.async_block_till_done() context = ha.Context( - id="ac5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X476W5TVAAA", user_id="b400facee45711eaa9308bfd3d19e474", ) @@ -2474,7 +2474,7 @@ async def test_get_events( "id": 5, "type": "logbook/get_events", "start_time": now.isoformat(), - "context_id": "ac5bd62de45711eaaeb351041eec8dd9", + "context_id": "01GTDGKBCH00GW0X476W5TVAAA", } ) response = await client.receive_json() @@ -2651,7 +2651,7 @@ async def test_get_events_with_device_ids( hass.states.async_set("light.kitchen", STATE_ON, {"brightness": 400}) await hass.async_block_till_done() context = ha.Context( - id="ac5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X476W5TVAAA", user_id="b400facee45711eaa9308bfd3d19e474", ) @@ -2740,7 +2740,7 @@ async def test_logbook_select_entities_context_id( await async_recorder_block_till_done(hass) context = ha.Context( - id="ac5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X476W5TVAAA", user_id="b400facee45711eaa9308bfd3d19e474", ) @@ -2799,7 +2799,7 @@ async def test_logbook_select_entities_context_id( # A service call light_turn_off_service_context = ha.Context( - id="9c5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X476W5TVBFC", user_id="9400facee45711eaa9308bfd3d19e474", ) hass.states.async_set("light.switch", STATE_ON) @@ -2880,7 +2880,7 @@ async def test_get_events_with_context_state( hass.states.async_set("light.kitchen2", STATE_OFF) context = ha.Context( - id="ac5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X476W5TVAAA", user_id="b400facee45711eaa9308bfd3d19e474", ) hass.states.async_set("binary_sensor.is_light", STATE_OFF, context=context) diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index 6b21c66de8..9b0b3f2221 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -159,7 +159,7 @@ async def test_get_events( hass.states.async_set("light.kitchen", STATE_ON, {"brightness": 400}) await hass.async_block_till_done() context = core.Context( - id="ac5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X276W5TEDDD", user_id="b400facee45711eaa9308bfd3d19e474", ) @@ -239,7 +239,7 @@ async def test_get_events( "id": 5, "type": "logbook/get_events", "start_time": now.isoformat(), - "context_id": "ac5bd62de45711eaaeb351041eec8dd9", + "context_id": "01GTDGKBCH00GW0X276W5TEDDD", } ) response = await client.receive_json() @@ -448,7 +448,7 @@ async def test_get_events_with_device_ids( hass.states.async_set("light.kitchen", STATE_ON, {"brightness": 400}) await hass.async_block_till_done() context = core.Context( - id="ac5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X276W5TEDDD", user_id="b400facee45711eaa9308bfd3d19e474", ) @@ -1262,7 +1262,7 @@ async def test_subscribe_unsubscribe_logbook_stream( ] context = core.Context( - id="ac5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X276W5TEDDD", user_id="b400facee45711eaa9308bfd3d19e474", ) automation_entity_id_test = "automation.alarm" @@ -1300,7 +1300,7 @@ async def test_subscribe_unsubscribe_logbook_stream( assert msg["type"] == "event" assert msg["event"]["events"] == [ { - "context_id": "ac5bd62de45711eaaeb351041eec8dd9", + "context_id": "01GTDGKBCH00GW0X276W5TEDDD", "context_user_id": "b400facee45711eaa9308bfd3d19e474", "domain": "automation", "entity_id": "automation.alarm", @@ -1313,7 +1313,7 @@ async def test_subscribe_unsubscribe_logbook_stream( "context_domain": "automation", "context_entity_id": "automation.alarm", "context_event_type": "automation_triggered", - "context_id": "ac5bd62de45711eaaeb351041eec8dd9", + "context_id": "01GTDGKBCH00GW0X276W5TEDDD", "context_message": "triggered by state of binary_sensor.dog_food_ready", "context_name": "Mock automation", "context_source": "state of binary_sensor.dog_food_ready", @@ -1365,7 +1365,7 @@ async def test_subscribe_unsubscribe_logbook_stream( "context_domain": "automation", "context_entity_id": "automation.alarm", "context_event_type": "automation_triggered", - "context_id": "ac5bd62de45711eaaeb351041eec8dd9", + "context_id": "01GTDGKBCH00GW0X276W5TEDDD", "context_message": "triggered by state of binary_sensor.dog_food_ready", "context_name": "Mock automation", "context_source": "state of binary_sensor.dog_food_ready", @@ -1395,7 +1395,7 @@ async def test_subscribe_unsubscribe_logbook_stream( "context_domain": "automation", "context_entity_id": "automation.alarm", "context_event_type": "automation_triggered", - "context_id": "ac5bd62de45711eaaeb351041eec8dd9", + "context_id": "01GTDGKBCH00GW0X276W5TEDDD", "context_message": "triggered by state of binary_sensor.dog_food_ready", "context_name": "Mock automation", "context_source": "state of binary_sensor.dog_food_ready", @@ -1990,7 +1990,7 @@ async def test_logbook_stream_match_multiple_entities( hass.states.async_set("binary_sensor.should_not_appear", STATE_ON) hass.states.async_set("binary_sensor.should_not_appear", STATE_OFF) context = core.Context( - id="ac5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X276W5TEDDD", user_id="b400facee45711eaa9308bfd3d19e474", ) hass.bus.async_fire( diff --git a/tests/components/recorder/db_schema_23_with_newer_columns.py b/tests/components/recorder/db_schema_23_with_newer_columns.py index d63e8d59d2..0cd3f41490 100644 --- a/tests/components/recorder/db_schema_23_with_newer_columns.py +++ b/tests/components/recorder/db_schema_23_with_newer_columns.py @@ -27,6 +27,7 @@ from sqlalchemy import ( Identity, Index, Integer, + LargeBinary, SmallInteger, String, Text, @@ -92,6 +93,10 @@ DOUBLE_TYPE = ( TIMESTAMP_TYPE = DOUBLE_TYPE +CONTEXT_ID_BIN_MAX_LENGTH = 16 +EVENTS_CONTEXT_ID_BIN_INDEX = "ix_events_context_id_bin" +STATES_CONTEXT_ID_BIN_INDEX = "ix_states_context_id_bin" + class Events(Base): # type: ignore """Event history data.""" @@ -100,6 +105,12 @@ class Events(Base): # type: ignore # Used for fetching events at a specific time # see logbook Index("ix_events_event_type_time_fired", "event_type", "time_fired"), + Index( + EVENTS_CONTEXT_ID_BIN_INDEX, + "context_id_bin", + mysql_length=CONTEXT_ID_BIN_MAX_LENGTH, + mariadb_length=CONTEXT_ID_BIN_MAX_LENGTH, + ), {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, ) __tablename__ = TABLE_EVENTS @@ -121,6 +132,15 @@ class Events(Base): # type: ignore data_id = Column( Integer, ForeignKey("event_data.data_id"), index=True ) # *** Not originally in v23, only added for recorder to startup ok + context_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v23, only added for recorder to startup ok + context_user_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v23, only added for recorder to startup ok + context_parent_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v23, only added for recorder to startup ok event_data_rel = relationship( "EventData" ) # *** Not originally in v23, only added for recorder to startup ok @@ -191,6 +211,12 @@ class States(Base): # type: ignore # Used for fetching the state of entities at a specific time # (get_states in history.py) Index("ix_states_entity_id_last_updated", "entity_id", "last_updated"), + Index( + STATES_CONTEXT_ID_BIN_INDEX, + "context_id_bin", + mysql_length=CONTEXT_ID_BIN_MAX_LENGTH, + mariadb_length=CONTEXT_ID_BIN_MAX_LENGTH, + ), {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, ) __tablename__ = TABLE_STATES @@ -212,6 +238,15 @@ class States(Base): # type: ignore ) # *** Not originally in v23, only added for recorder to startup ok created = Column(DATETIME_TYPE, default=dt_util.utcnow) old_state_id = Column(Integer, ForeignKey("states.state_id"), index=True) + context_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v23, only added for recorder to startup ok + context_user_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v23, only added for recorder to startup ok + context_parent_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v23, only added for recorder to startup ok event = relationship("Events", uselist=False) old_state = relationship("States", remote_side=[state_id]) diff --git a/tests/components/recorder/db_schema_30.py b/tests/components/recorder/db_schema_30.py index 91f7593969..7862ad0614 100644 --- a/tests/components/recorder/db_schema_30.py +++ b/tests/components/recorder/db_schema_30.py @@ -23,6 +23,7 @@ from sqlalchemy import ( Identity, Index, Integer, + LargeBinary, SmallInteger, String, Text, @@ -96,6 +97,9 @@ LAST_UPDATED_INDEX = "ix_states_last_updated" ENTITY_ID_LAST_UPDATED_INDEX = "ix_states_entity_id_last_updated" EVENTS_CONTEXT_ID_INDEX = "ix_events_context_id" STATES_CONTEXT_ID_INDEX = "ix_states_context_id" +CONTEXT_ID_BIN_MAX_LENGTH = 16 +EVENTS_CONTEXT_ID_BIN_INDEX = "ix_events_context_id_bin" +STATES_CONTEXT_ID_BIN_INDEX = "ix_states_context_id_bin" class FAST_PYSQLITE_DATETIME(sqlite.DATETIME): # type: ignore[misc] @@ -193,6 +197,12 @@ class Events(Base): # type: ignore[misc,valid-type] # Used for fetching events at a specific time # see logbook Index("ix_events_event_type_time_fired", "event_type", "time_fired"), + Index( + EVENTS_CONTEXT_ID_BIN_INDEX, + "context_id_bin", + mysql_length=CONTEXT_ID_BIN_MAX_LENGTH, + mariadb_length=CONTEXT_ID_BIN_MAX_LENGTH, + ), {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, ) __tablename__ = TABLE_EVENTS @@ -206,6 +216,15 @@ class Events(Base): # type: ignore[misc,valid-type] context_user_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) context_parent_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) data_id = Column(Integer, ForeignKey("event_data.data_id"), index=True) + context_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v30, only added for recorder to startup ok + context_user_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v23, only added for recorder to startup ok + context_parent_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v30, only added for recorder to startup ok event_data_rel = relationship("EventData") def __repr__(self) -> str: @@ -310,6 +329,12 @@ class States(Base): # type: ignore[misc,valid-type] # Used for fetching the state of entities at a specific time # (get_states in history.py) Index(ENTITY_ID_LAST_UPDATED_INDEX, "entity_id", "last_updated"), + Index( + STATES_CONTEXT_ID_BIN_INDEX, + "context_id_bin", + mysql_length=CONTEXT_ID_BIN_MAX_LENGTH, + mariadb_length=CONTEXT_ID_BIN_MAX_LENGTH, + ), {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, ) __tablename__ = TABLE_STATES @@ -332,6 +357,15 @@ class States(Base): # type: ignore[misc,valid-type] context_user_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) context_parent_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) origin_idx = Column(SmallInteger) # 0 is local, 1 is remote + context_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v30, only added for recorder to startup ok + context_user_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v23, only added for recorder to startup ok + context_parent_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v30, only added for recorder to startup ok old_state = relationship("States", remote_side=[state_id]) state_attributes = relationship("StateAttributes") diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index 19c7e6c695..730d90e14b 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -6,9 +6,10 @@ import sqlite3 import sys import threading from unittest.mock import Mock, PropertyMock, call, patch +import uuid import pytest -from sqlalchemy import create_engine, text +from sqlalchemy import create_engine, inspect, text from sqlalchemy.exc import ( DatabaseError, InternalError, @@ -23,17 +24,25 @@ from homeassistant.components import persistent_notification as pn, recorder from homeassistant.components.recorder import db_schema, migration from homeassistant.components.recorder.db_schema import ( SCHEMA_VERSION, + Events, RecorderRuns, States, ) +from homeassistant.components.recorder.tasks import ContextIDMigrationTask from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant from homeassistant.helpers import recorder as recorder_helper import homeassistant.util.dt as dt_util +from homeassistant.util.ulid import bytes_to_ulid -from .common import async_wait_recording_done, create_engine_test +from .common import ( + async_recorder_block_till_done, + async_wait_recording_done, + create_engine_test, +) from tests.common import async_fire_time_changed +from tests.typing import RecorderInstanceGenerator ORIG_TZ = dt_util.DEFAULT_TIME_ZONE @@ -535,3 +544,147 @@ def test_raise_if_exception_missing_empty_cause_str() -> None: with pytest.raises(ProgrammingError): migration.raise_if_exception_missing_str(programming_exc, ["not present"]) + + +@pytest.mark.parametrize("enable_migrate_context_ids", [True]) +async def test_migrate_context_ids( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test we can migrate old uuid context ids and ulid context ids to binary format.""" + instance = await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + test_uuid = uuid.uuid4() + uuid_hex = test_uuid.hex + uuid_bin = test_uuid.bytes + + def _insert_events(): + with session_scope(hass=hass) as session: + session.add_all( + ( + Events( + event_type="old_uuid_context_id_event", + event_data=None, + origin_idx=0, + time_fired=None, + time_fired_ts=1677721632.452529, + context_id=uuid_hex, + context_id_bin=None, + context_user_id=None, + context_user_id_bin=None, + context_parent_id=None, + context_parent_id_bin=None, + ), + Events( + event_type="empty_context_id_event", + event_data=None, + origin_idx=0, + time_fired=None, + time_fired_ts=1677721632.552529, + context_id=None, + context_id_bin=None, + context_user_id=None, + context_user_id_bin=None, + context_parent_id=None, + context_parent_id_bin=None, + ), + Events( + event_type="ulid_context_id_event", + event_data=None, + origin_idx=0, + time_fired=None, + time_fired_ts=1677721632.552529, + context_id="01ARZ3NDEKTSV4RRFFQ69G5FAV", + context_id_bin=None, + context_user_id="9400facee45711eaa9308bfd3d19e474", + context_user_id_bin=None, + context_parent_id="01ARZ3NDEKTSV4RRFFQ69G5FA2", + context_parent_id_bin=None, + ), + Events( + event_type="invalid_context_id_event", + event_data=None, + origin_idx=0, + time_fired=None, + time_fired_ts=1677721632.552529, + context_id="invalid", + context_id_bin=None, + context_user_id=None, + context_user_id_bin=None, + context_parent_id=None, + context_parent_id_bin=None, + ), + ) + ) + + await instance.async_add_executor_job(_insert_events) + + await async_wait_recording_done(hass) + # This is a threadsafe way to add a task to the recorder + instance.queue_task(ContextIDMigrationTask()) + await async_recorder_block_till_done(hass) + + def _object_as_dict(obj): + return {c.key: getattr(obj, c.key) for c in inspect(obj).mapper.column_attrs} + + def _fetch_migrated_events(): + with session_scope(hass=hass) as session: + events = ( + session.query(Events) + .filter( + Events.event_type.in_( + [ + "old_uuid_context_id_event", + "empty_context_id_event", + "ulid_context_id_event", + "invalid_context_id_event", + ] + ) + ) + .all() + ) + assert len(events) == 4 + return {event.event_type: _object_as_dict(event) for event in events} + + events_by_type = await instance.async_add_executor_job(_fetch_migrated_events) + + old_uuid_context_id_event = events_by_type["old_uuid_context_id_event"] + assert old_uuid_context_id_event["context_id"] is None + assert old_uuid_context_id_event["context_user_id"] is None + assert old_uuid_context_id_event["context_parent_id"] is None + assert old_uuid_context_id_event["context_id_bin"] == uuid_bin + assert old_uuid_context_id_event["context_user_id_bin"] is None + assert old_uuid_context_id_event["context_parent_id_bin"] is None + + empty_context_id_event = events_by_type["empty_context_id_event"] + assert empty_context_id_event["context_id"] is None + assert empty_context_id_event["context_user_id"] is None + assert empty_context_id_event["context_parent_id"] is None + assert empty_context_id_event["context_id_bin"] == b"\x00" * 16 + assert empty_context_id_event["context_user_id_bin"] is None + assert empty_context_id_event["context_parent_id_bin"] is None + + ulid_context_id_event = events_by_type["ulid_context_id_event"] + assert ulid_context_id_event["context_id"] is None + assert ulid_context_id_event["context_user_id"] is None + assert ulid_context_id_event["context_parent_id"] is None + assert ( + bytes_to_ulid(ulid_context_id_event["context_id_bin"]) + == "01ARZ3NDEKTSV4RRFFQ69G5FAV" + ) + assert ( + ulid_context_id_event["context_user_id_bin"] + == b"\x94\x00\xfa\xce\xe4W\x11\xea\xa90\x8b\xfd=\x19\xe4t" + ) + assert ( + bytes_to_ulid(ulid_context_id_event["context_parent_id_bin"]) + == "01ARZ3NDEKTSV4RRFFQ69G5FA2" + ) + + invalid_context_id_event = events_by_type["invalid_context_id_event"] + assert invalid_context_id_event["context_id"] is None + assert invalid_context_id_event["context_user_id"] is None + assert invalid_context_id_event["context_parent_id"] is None + assert invalid_context_id_event["context_id_bin"] == b"\x00" * 16 + assert invalid_context_id_event["context_user_id_bin"] is None + assert invalid_context_id_event["context_parent_id_bin"] is None diff --git a/tests/conftest.py b/tests/conftest.py index 9c1eef3ffb..ed5a95f1b2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1138,6 +1138,16 @@ def enable_nightly_purge() -> bool: return False +@pytest.fixture +def enable_migrate_context_ids() -> bool: + """Fixture to control enabling of recorder's context id migration. + + To enable context id migration, tests can be marked with: + @pytest.mark.parametrize("enable_migrate_context_ids", [True]) + """ + return False + + @pytest.fixture def recorder_config() -> dict[str, Any] | None: """Fixture to override recorder config. @@ -1280,6 +1290,7 @@ async def async_setup_recorder_instance( enable_nightly_purge: bool, enable_statistics: bool, enable_statistics_table_validation: bool, + enable_migrate_context_ids: bool, ) -> AsyncGenerator[RecorderInstanceGenerator, None]: """Yield callable to setup recorder instance.""" # pylint: disable-next=import-outside-toplevel @@ -1295,6 +1306,9 @@ async def async_setup_recorder_instance( if enable_statistics_table_validation else itertools.repeat(set()) ) + migrate_context_ids = ( + recorder.Recorder._migrate_context_ids if enable_migrate_context_ids else None + ) with patch( "homeassistant.components.recorder.Recorder.async_nightly_tasks", side_effect=nightly, @@ -1307,6 +1321,10 @@ async def async_setup_recorder_instance( "homeassistant.components.recorder.migration.statistics_validate_db_schema", side_effect=stats_validate, autospec=True, + ), patch( + "homeassistant.components.recorder.Recorder._migrate_context_ids", + side_effect=migrate_context_ids, + autospec=True, ): async def async_setup_recorder( From e5ce8e920dca371c3e16009935184b60276b13ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 9 Mar 2023 02:23:33 +0100 Subject: [PATCH 0779/2810] 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 8a8583a7da..fecf05f74b 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 cb1dd639ec..e659fbe4b8 100644 --- a/tests/components/hassio/test_http.py +++ b/tests/components/hassio/test_http.py @@ -288,6 +288,8 @@ async def test_forward_request_not_onboarded_unallowed_paths( ("backups/1234abcd/info", True), ("supervisor/logs", True), ("addons/bl_b392/logs", True), + ("addons/bl_b392/changelog", True), + ("addons/bl_b392/documentation", True), ], ) async def test_forward_request_admin_get( From 1a4b14c2171fe4aee01fad87d0da8e47b4082c89 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 9 Mar 2023 08:02:59 +0100 Subject: [PATCH 0780/2810] 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 153726a89e..358a97ed30 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 1e486a3492..fcdec1fbfe 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -636,8 +636,8 @@ async def test_brightness_from_rgb_controlling_scale( } }, ) + mqtt_mock = await mqtt_mock_entry_with_yaml_config() await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -650,10 +650,29 @@ async def test_brightness_from_rgb_controlling_scale( state = hass.states.get("light.test") assert state.attributes.get("brightness") == 255 - async_fire_mqtt_message(hass, "test_scale_rgb/rgb/status", "127,0,0") + async_fire_mqtt_message(hass, "test_scale_rgb/rgb/status", "128,64,32") state = hass.states.get("light.test") - assert state.attributes.get("brightness") == 127 + assert state.attributes.get("brightness") == 128 + assert state.attributes.get("rgb_color") == (255, 128, 64) + + mqtt_mock.async_publish.reset_mock() + await common.async_turn_on(hass, "light.test", brightness=191) + await hass.async_block_till_done() + + mqtt_mock.async_publish.assert_has_calls( + [ + call("test_scale_rgb/set", "on", 0, False), + call("test_scale_rgb/rgb/set", "191,95,47", 0, False), + ], + any_order=True, + ) + async_fire_mqtt_message(hass, "test_scale_rgb/rgb/status", "191,95,47") + await hass.async_block_till_done() + + state = hass.states.get("light.test") + assert state.attributes.get("brightness") == 191 + assert state.attributes.get("rgb_color") == (255, 127, 63) async def test_controlling_state_via_topic_with_templates( From 5828e9a8d231d426e68141c70ff01339c8e18c80 Mon Sep 17 00:00:00 2001 From: Felix Rotthowe Date: Thu, 9 Mar 2023 09:27:53 +0100 Subject: [PATCH 0781/2810] Simplify LivisiEntity inheritance (#89424) * We don't need to inherit Entity. The CoordinatorEntity already does that. * update imports --- homeassistant/components/livisi/entity.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/livisi/entity.py b/homeassistant/components/livisi/entity.py index 613f55d1b7..ebd2b81385 100644 --- a/homeassistant/components/livisi/entity.py +++ b/homeassistant/components/livisi/entity.py @@ -9,14 +9,14 @@ from aiolivisi.const import CAPABILITY_MAP from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, LIVISI_REACHABILITY_CHANGE from .coordinator import LivisiDataUpdateCoordinator -class LivisiEntity(CoordinatorEntity[LivisiDataUpdateCoordinator], Entity): +class LivisiEntity(CoordinatorEntity[LivisiDataUpdateCoordinator]): """Represents a base livisi entity.""" _attr_has_entity_name = True From c9d5baca75f51c81e5dbdd127eecaad95bc4dc10 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 9 Mar 2023 11:12:29 +0100 Subject: [PATCH 0782/2810] Add hostname to DHCP discovery title (#89426) --- homeassistant/components/reolink/config_flow.py | 3 +-- homeassistant/components/reolink/strings.json | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index e4bc98cc0f..15f3dfa613 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -95,10 +95,9 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(mac_address) self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) - short_mac = mac_address[-8:].upper() self.context["title_placeholders"] = { - "short_mac": short_mac, "ip_address": discovery_info.ip, + "hostname": discovery_info.hostname, } self._host = discovery_info.ip diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index f4cb8a904f..3ab77d2b8f 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "{short_mac} ({ip_address})", + "flow_title": "{hostname} ({ip_address})", "step": { "user": { "description": "{error}", From dbebe57d51a666628b61d251369e5ff26bc79fba Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Mar 2023 11:41:59 +0100 Subject: [PATCH 0783/2810] Avoid unnecessary Task in debouncer (#89370) --- homeassistant/helpers/debounce.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/helpers/debounce.py b/homeassistant/helpers/debounce.py index b4a4cde0c1..dd536956a8 100644 --- a/homeassistant/helpers/debounce.py +++ b/homeassistant/helpers/debounce.py @@ -94,11 +94,6 @@ class Debouncer(Generic[_R_co]): """Handle a finished timer.""" assert self._job is not None - self._timer_task = None - - if not self._execute_at_end_of_timer: - return - self._execute_at_end_of_timer = False # Locked means a call is in progress. Any call is good, so abort. @@ -108,7 +103,7 @@ class Debouncer(Generic[_R_co]): async with self._execute_lock: # Abort if timer got set while we're waiting for the lock. if self._timer_task: - return # type: ignore[unreachable] + return try: task = self.hass.async_run_hass_job(self._job) @@ -117,6 +112,7 @@ class Debouncer(Generic[_R_co]): except Exception: # pylint: disable=broad-except self.logger.exception("Unexpected exception from %s", self.function) + # Schedule a new timer to prevent new runs during cooldown self._schedule_timer() @callback @@ -129,12 +125,16 @@ class Debouncer(Generic[_R_co]): self._execute_at_end_of_timer = False @callback - def _schedule_timer(self) -> None: - """Schedule a timer.""" - self._timer_task = self.hass.loop.call_later( - self.cooldown, - lambda: self.hass.async_create_task( + def _on_debounce(self) -> None: + """Create job task, but only if pending.""" + self._timer_task = None + if self._execute_at_end_of_timer: + self.hass.async_create_task( self._handle_timer_finish(), f"debouncer {self._job} finish cooldown={self.cooldown}, immediate={self.immediate}", - ), - ) + ) + + @callback + def _schedule_timer(self) -> None: + """Schedule a timer.""" + self._timer_task = self.hass.loop.call_later(self.cooldown, self._on_debounce) From c5ff3e99143c0b85f64540acd4687e077ec3d5c0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Mar 2023 11:52:20 +0100 Subject: [PATCH 0784/2810] Add review-process link to PR template (#89430) --- .github/PULL_REQUEST_TEMPLATE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 23b355a223..c64efda390 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -59,6 +59,7 @@ - [ ] Local tests pass. **Your PR cannot be merged unless tests pass** - [ ] There is no commented out code in this PR. - [ ] I have followed the [development checklist][dev-checklist] +- [ ] I have followed the [perfect PR recommendations][perfect-pr] - [ ] The code has been formatted using Black (`black --fast homeassistant tests`) - [ ] Tests have been added to verify that the new code works. @@ -107,3 +108,4 @@ To help with the load of incoming pull requests: [manifest-docs]: https://developers.home-assistant.io/docs/en/creating_integration_manifest.html [quality-scale]: https://developers.home-assistant.io/docs/en/next/integration_quality_scale_index.html [docs-repository]: https://github.com/home-assistant/home-assistant.io +[perfect-pr]: https://developers.home-assistant.io/docs/review-process/#creating-the-perfect-pr From ead3662b7abebe8464767a0b71b9f87e4a29572d Mon Sep 17 00:00:00 2001 From: Jeef Date: Thu, 9 Mar 2023 05:00:31 -0700 Subject: [PATCH 0785/2810] Add quadrafire virtual integration for Intellifire (#89316) --- homeassistant/components/quadrafire/__init__.py | 1 + homeassistant/components/quadrafire/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/quadrafire/__init__.py create mode 100644 homeassistant/components/quadrafire/manifest.json diff --git a/homeassistant/components/quadrafire/__init__.py b/homeassistant/components/quadrafire/__init__.py new file mode 100644 index 0000000000..662e9d0887 --- /dev/null +++ b/homeassistant/components/quadrafire/__init__.py @@ -0,0 +1 @@ +"""Virtual integration for quadrafire.""" diff --git a/homeassistant/components/quadrafire/manifest.json b/homeassistant/components/quadrafire/manifest.json new file mode 100644 index 0000000000..fcd263e321 --- /dev/null +++ b/homeassistant/components/quadrafire/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "quadrafire", + "name": "Quadra-Fire", + "integration_type": "virtual", + "supported_by": "intellifire" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a79f06bbd3..cc53cf430d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4349,6 +4349,11 @@ "config_flow": false, "iot_class": "calculated" }, + "quadrafire": { + "name": "Quadra-Fire", + "integration_type": "virtual", + "supported_by": "intellifire" + }, "quantum_gateway": { "name": "Quantum Gateway", "integration_type": "hub", From 3989ef88630909ea4dbd7a3201cead74a3760a1f Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 9 Mar 2023 13:01:18 +0100 Subject: [PATCH 0786/2810] Parse attribute reports for ZHA select entity (#89418) * Parse attribute reports for ZHA select entity * Add test for checking that select entity attribute reports are parsed --- homeassistant/components/zha/select.py | 13 ++++ tests/components/zha/test_select.py | 90 +++++++++++++++++++++++++- 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index d9074acecf..b4cbce5540 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -26,6 +26,7 @@ from .core.const import ( CHANNEL_ON_OFF, DATA_ZHA, SIGNAL_ADD_ENTITIES, + SIGNAL_ATTR_UPDATED, Strobe, ) from .core.registries import ZHA_ENTITIES @@ -212,6 +213,18 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): ) self.async_write_ha_state() + async def async_added_to_hass(self) -> None: + """Run when about to be added to hass.""" + await super().async_added_to_hass() + self.async_accept_signal( + self._channel, SIGNAL_ATTR_UPDATED, self.async_set_state + ) + + @callback + def async_set_state(self, attr_id: int, attr_name: str, value: Any): + """Handle state update from channel.""" + self.async_write_ha_state() + @CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_ON_OFF) class ZHAStartupOnOffSelectEntity( diff --git a/tests/components/zha/test_select.py b/tests/components/zha/test_select.py index 37738e0fd4..714e27147b 100644 --- a/tests/components/zha/test_select.py +++ b/tests/components/zha/test_select.py @@ -2,17 +2,28 @@ from unittest.mock import call, patch import pytest +from zhaquirks import ( + DEVICE_TYPE, + ENDPOINTS, + INPUT_CLUSTERS, + OUTPUT_CLUSTERS, + PROFILE_ID, +) from zigpy.const import SIG_EP_PROFILE import zigpy.profiles.zha as zha +from zigpy.quirks import CustomCluster, CustomDevice +import zigpy.types as t import zigpy.zcl.clusters.general as general +from zigpy.zcl.clusters.manufacturer_specific import ManufacturerSpecificCluster import zigpy.zcl.clusters.security as security +from homeassistant.components.zha.select import AqaraMotionSensitivities from homeassistant.const import STATE_UNKNOWN, EntityCategory, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, restore_state from homeassistant.util import dt as dt_util -from .common import find_entity_id +from .common import async_enable_traffic, find_entity_id, send_attributes_report from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE @@ -29,6 +40,7 @@ def select_select_only(): Platform.NUMBER, Platform.SELECT, Platform.SENSOR, + Platform.SWITCH, ), ): yield @@ -323,3 +335,79 @@ async def test_on_off_select_unsupported( qualifier=select_name.lower(), ) assert entity_id is None + + +class MotionSensitivityQuirk(CustomDevice): + """Quirk with motion sensitivity attribute.""" + + class OppleCluster(CustomCluster, ManufacturerSpecificCluster): + """Aqara manufacturer specific cluster.""" + + cluster_id = 0xFCC0 + ep_attribute = "opple_cluster" + attributes = { + 0x010C: ("motion_sensitivity", t.uint8_t, True), + } + + def __init__(self, *args, **kwargs): + """Initialize.""" + super().__init__(*args, **kwargs) + # populate cache to create config entity + self._attr_cache.update({0x010C: AqaraMotionSensitivities.Medium}) + + replacement = { + ENDPOINTS: { + 1: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.OCCUPANCY_SENSOR, + INPUT_CLUSTERS: [general.Basic.cluster_id, OppleCluster], + OUTPUT_CLUSTERS: [], + }, + } + } + + +@pytest.fixture +async def zigpy_device_aqara_sensor(hass, zigpy_device_mock, zha_device_joined): + """Device tracker zigpy Aqara motion sensor device.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.OCCUPANCY_SENSOR, + } + }, + manufacturer="LUMI", + model="lumi.motion.ac02", + quirk=MotionSensitivityQuirk, + ) + + zha_device = await zha_device_joined(zigpy_device) + zha_device.available = True + await hass.async_block_till_done() + return zigpy_device + + +async def test_on_off_select_attribute_report( + hass: HomeAssistant, light, zha_device_restored, zigpy_device_aqara_sensor +) -> None: + """Test ZHA attribute report parsing for select platform.""" + + zha_device = await zha_device_restored(zigpy_device_aqara_sensor) + cluster = zigpy_device_aqara_sensor.endpoints.get(1).opple_cluster + entity_id = await find_entity_id(Platform.SELECT, zha_device, hass) + assert entity_id is not None + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [zha_device]) + + # test that the state is in default medium state + assert hass.states.get(entity_id).state == AqaraMotionSensitivities.Medium.name + + # send attribute report from device + await send_attributes_report( + hass, cluster, {"motion_sensitivity": AqaraMotionSensitivities.Low} + ) + assert hass.states.get(entity_id).state == AqaraMotionSensitivities.Low.name From c2f69dc59d8e98ac014a4ad489e35f649f909ffe Mon Sep 17 00:00:00 2001 From: avee87 <6134677+avee87@users.noreply.github.com> Date: Thu, 9 Mar 2023 12:02:12 +0000 Subject: [PATCH 0787/2810] Revert Transmission entities name changes (#89409) --- homeassistant/components/transmission/const.py | 2 +- homeassistant/components/transmission/sensor.py | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/transmission/const.py b/homeassistant/components/transmission/const.py index 94296f53a6..517ef0a853 100644 --- a/homeassistant/components/transmission/const.py +++ b/homeassistant/components/transmission/const.py @@ -1,7 +1,7 @@ """Constants for the Transmission Bittorent Client component.""" DOMAIN = "transmission" -SWITCH_TYPES = {"on_off": "Switch", "turtle_mode": "Turtle mode"} +SWITCH_TYPES = {"on_off": "Switch", "turtle_mode": "Turtle Mode"} ORDER_NEWEST_FIRST = "newest_first" ORDER_OLDEST_FIRST = "oldest_first" diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index 46d12d6798..9147773131 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -38,14 +38,14 @@ async def async_setup_entry( name = config_entry.data[CONF_NAME] dev = [ - TransmissionSpeedSensor(tm_client, name, "Down speed", "download"), - TransmissionSpeedSensor(tm_client, name, "Up speed", "upload"), + TransmissionSpeedSensor(tm_client, name, "Down Speed", "download"), + TransmissionSpeedSensor(tm_client, name, "Up Speed", "upload"), TransmissionStatusSensor(tm_client, name, "Status"), - TransmissionTorrentsSensor(tm_client, name, "Active torrents", "active"), - TransmissionTorrentsSensor(tm_client, name, "Paused torrents", "paused"), - TransmissionTorrentsSensor(tm_client, name, "Total torrents", "total"), - TransmissionTorrentsSensor(tm_client, name, "Completed torrents", "completed"), - TransmissionTorrentsSensor(tm_client, name, "Started torrents", "started"), + TransmissionTorrentsSensor(tm_client, name, "Active Torrents", "active"), + TransmissionTorrentsSensor(tm_client, name, "Paused Torrents", "paused"), + TransmissionTorrentsSensor(tm_client, name, "Total Torrents", "total"), + TransmissionTorrentsSensor(tm_client, name, "Completed Torrents", "completed"), + TransmissionTorrentsSensor(tm_client, name, "Started Torrents", "started"), ] async_add_entities(dev, True) From 86ad8261d8a0172dac92b6d76628752db271a744 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Mar 2023 02:03:08 -1000 Subject: [PATCH 0788/2810] Update logbook queries for SADeprecationWarning (#87108) --- homeassistant/components/logbook/queries/devices.py | 6 ++++-- homeassistant/components/logbook/queries/entities.py | 2 +- .../components/logbook/queries/entities_and_devices.py | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/logbook/queries/devices.py b/homeassistant/components/logbook/queries/devices.py index 303313602d..d84a534310 100644 --- a/homeassistant/components/logbook/queries/devices.py +++ b/homeassistant/components/logbook/queries/devices.py @@ -33,8 +33,10 @@ def _select_device_id_context_ids_sub_query( json_quotable_device_ids: list[str], ) -> Select: """Generate a subquery to find context ids for multiple devices.""" - inner = select_events_context_id_subquery(start_day, end_day, event_types).where( - apply_event_device_id_matchers(json_quotable_device_ids) + inner = ( + select_events_context_id_subquery(start_day, end_day, event_types) + .where(apply_event_device_id_matchers(json_quotable_device_ids)) + .subquery() ) return select(inner.c.context_id_bin).group_by(inner.c.context_id_bin) diff --git a/homeassistant/components/logbook/queries/entities.py b/homeassistant/components/logbook/queries/entities.py index 2c095d1b05..10ca6fad13 100644 --- a/homeassistant/components/logbook/queries/entities.py +++ b/homeassistant/components/logbook/queries/entities.py @@ -47,7 +47,7 @@ def _select_entities_context_ids_sub_query( (States.last_updated_ts > start_day) & (States.last_updated_ts < end_day) ) .where(States.entity_id.in_(entity_ids)), - ) + ).subquery() return select(union.c.context_id_bin).group_by(union.c.context_id_bin) diff --git a/homeassistant/components/logbook/queries/entities_and_devices.py b/homeassistant/components/logbook/queries/entities_and_devices.py index ec38dc7b6d..b4a1c7bc9f 100644 --- a/homeassistant/components/logbook/queries/entities_and_devices.py +++ b/homeassistant/components/logbook/queries/entities_and_devices.py @@ -46,7 +46,7 @@ def _select_entities_device_id_context_ids_sub_query( (States.last_updated_ts > start_day) & (States.last_updated_ts < end_day) ) .where(States.entity_id.in_(entity_ids)), - ) + ).subquery() return select(union.c.context_id_bin).group_by(union.c.context_id_bin) From 9faf25132182d0c6bcd987ecfa9b7648283e6239 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Mar 2023 13:05:04 +0100 Subject: [PATCH 0789/2810] Add missing mock in buienradar config flow tests (#89420) --- tests/components/buienradar/conftest.py | 14 +++++++++++ .../components/buienradar/test_config_flow.py | 25 ++++++++----------- 2 files changed, 24 insertions(+), 15 deletions(-) create mode 100644 tests/components/buienradar/conftest.py diff --git a/tests/components/buienradar/conftest.py b/tests/components/buienradar/conftest.py new file mode 100644 index 0000000000..b896e54e62 --- /dev/null +++ b/tests/components/buienradar/conftest.py @@ -0,0 +1,14 @@ +"""Test fixtures for buienradar2.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.buienradar.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/buienradar/test_config_flow.py b/tests/components/buienradar/test_config_flow.py index 955a7b85dc..6a7db5e906 100644 --- a/tests/components/buienradar/test_config_flow.py +++ b/tests/components/buienradar/test_config_flow.py @@ -1,5 +1,6 @@ """Test the buienradar2 config flow.""" -from unittest.mock import patch + +import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components.buienradar.const import DOMAIN @@ -11,6 +12,8 @@ from tests.common import MockConfigEntry TEST_LATITUDE = 51.5288504 TEST_LONGITUDE = 5.4002156 +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + async def test_config_flow_setup_(hass: HomeAssistant) -> None: """Test setup of camera.""" @@ -22,13 +25,10 @@ async def test_config_flow_setup_(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert result["errors"] == {} - with patch( - "homeassistant.components.buienradar.async_setup_entry", return_value=True - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_LATITUDE: TEST_LATITUDE, CONF_LONGITUDE: TEST_LONGITUDE}, - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_LATITUDE: TEST_LATITUDE, CONF_LONGITUDE: TEST_LONGITUDE}, + ) assert result["type"] == "create_entry" assert result["title"] == f"{TEST_LATITUDE},{TEST_LONGITUDE}" @@ -92,13 +92,8 @@ async def test_options_flow(hass: HomeAssistant) -> None: user_input={"country_code": "BE", "delta": 450, "timeframe": 30}, ) - with patch( - "homeassistant.components.buienradar.async_setup_entry", return_value=True - ), patch( - "homeassistant.components.buienradar.async_unload_entry", return_value=True - ): - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - await hass.async_block_till_done() + await hass.async_block_till_done() assert entry.options == {"country_code": "BE", "delta": 450, "timeframe": 30} From b0631fed1d829242e4aedc7fee7385969e08c229 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Mar 2023 13:05:32 +0100 Subject: [PATCH 0790/2810] Add missing mock in braviatv config flow tests (#89419) --- tests/components/braviatv/conftest.py | 14 ++++++++++++++ tests/components/braviatv/test_config_flow.py | 8 ++------ 2 files changed, 16 insertions(+), 6 deletions(-) create mode 100644 tests/components/braviatv/conftest.py diff --git a/tests/components/braviatv/conftest.py b/tests/components/braviatv/conftest.py new file mode 100644 index 0000000000..e4ee2ebc86 --- /dev/null +++ b/tests/components/braviatv/conftest.py @@ -0,0 +1,14 @@ +"""Test fixtures for Bravia TV.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.braviatv.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/braviatv/test_config_flow.py b/tests/components/braviatv/test_config_flow.py index e7dd0046ec..3dffeaf527 100644 --- a/tests/components/braviatv/test_config_flow.py +++ b/tests/components/braviatv/test_config_flow.py @@ -84,6 +84,8 @@ FAKE_BRAVIA_SSDP = ssdp.SsdpServiceInfo( }, ) +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + async def test_show_form(hass: HomeAssistant) -> None: """Test that the form is served with no input.""" @@ -111,8 +113,6 @@ async def test_ssdp_discovery(hass: HomeAssistant) -> None: ), patch("pybravia.BraviaClient.set_wol_mode"), patch( "pybravia.BraviaClient.get_system_info", return_value=BRAVIA_SYSTEM_INFO, - ), patch( - "homeassistant.components.braviatv.async_setup_entry", return_value=True ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} @@ -302,8 +302,6 @@ async def test_create_entry(hass: HomeAssistant) -> None: ), patch("pybravia.BraviaClient.set_wol_mode"), patch( "pybravia.BraviaClient.get_system_info", return_value=BRAVIA_SYSTEM_INFO, - ), patch( - "homeassistant.components.braviatv.async_setup_entry", return_value=True ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "bravia-host"} @@ -343,8 +341,6 @@ async def test_create_entry_psk(hass: HomeAssistant) -> None: ), patch( "pybravia.BraviaClient.get_system_info", return_value=BRAVIA_SYSTEM_INFO, - ), patch( - "homeassistant.components.braviatv.async_setup_entry", return_value=True ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "bravia-host"} From c6d2824afef9b8af9be923030c1b0c405a498b15 Mon Sep 17 00:00:00 2001 From: Jack Boswell Date: Fri, 10 Mar 2023 01:06:27 +1300 Subject: [PATCH 0791/2810] Disable some less commonly used starlink entities by default (#87869) Co-authored-by: J. Nick Koston --- homeassistant/components/starlink/sensor.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/starlink/sensor.py b/homeassistant/components/starlink/sensor.py index af745c6f15..bb84f03224 100644 --- a/homeassistant/components/starlink/sensor.py +++ b/homeassistant/components/starlink/sensor.py @@ -75,6 +75,7 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=DEGREE, + entity_registry_enabled_default=False, value_fn=lambda data: round(data.status["direction_azimuth"]), ), StarlinkSensorEntityDescription( @@ -84,6 +85,7 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=DEGREE, + entity_registry_enabled_default=False, value_fn=lambda data: round(data.status["direction_elevation"]), ), StarlinkSensorEntityDescription( @@ -91,6 +93,7 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( name="Uplink throughput", icon="mdi:upload", state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.BITS_PER_SECOND, value_fn=lambda data: round(data.status["uplink_throughput_bps"]), ), @@ -99,6 +102,7 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( name="Downlink throughput", icon="mdi:download", state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.BITS_PER_SECOND, value_fn=lambda data: round(data.status["downlink_throughput_bps"]), ), From 4a082403eb9886ba2df42c4d9cd3d014265a0b82 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Mar 2023 13:16:36 +0100 Subject: [PATCH 0792/2810] Add missing mock in coronavirus config flow tests (#89428) --- tests/components/coronavirus/conftest.py | 13 +++++++++++-- tests/components/coronavirus/test_config_flow.py | 9 ++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/tests/components/coronavirus/conftest.py b/tests/components/coronavirus/conftest.py index 57128268fd..227d9fa212 100644 --- a/tests/components/coronavirus/conftest.py +++ b/tests/components/coronavirus/conftest.py @@ -1,10 +1,19 @@ """Test helpers.""" - -from unittest.mock import Mock, patch +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, patch import pytest +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.coronavirus.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(autouse=True) def mock_cases(): """Mock coronavirus cases.""" diff --git a/tests/components/coronavirus/test_config_flow.py b/tests/components/coronavirus/test_config_flow.py index e641c0e001..2fe7ed370e 100644 --- a/tests/components/coronavirus/test_config_flow.py +++ b/tests/components/coronavirus/test_config_flow.py @@ -1,14 +1,17 @@ """Test the Coronavirus config flow.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from aiohttp import ClientError +import pytest from homeassistant import config_entries from homeassistant.components.coronavirus.const import DOMAIN, OPTION_WORLDWIDE from homeassistant.core import HomeAssistant +pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_form(hass: HomeAssistant) -> None: + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -28,7 +31,7 @@ async def test_form(hass: HomeAssistant) -> None: "country": OPTION_WORLDWIDE, } await hass.async_block_till_done() - assert len(hass.states.async_all()) == 4 + mock_setup_entry.assert_called_once() @patch( From 3545209355bffdf0bbf68bc76f9d230ce526713e Mon Sep 17 00:00:00 2001 From: Jeef Date: Thu, 9 Mar 2023 05:17:50 -0700 Subject: [PATCH 0793/2810] Add vermont_castings virtual integration for Intellifire (#89317) --- homeassistant/components/vermont_castings/__init__.py | 1 + homeassistant/components/vermont_castings/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/vermont_castings/__init__.py create mode 100644 homeassistant/components/vermont_castings/manifest.json diff --git a/homeassistant/components/vermont_castings/__init__.py b/homeassistant/components/vermont_castings/__init__.py new file mode 100644 index 0000000000..d2c0b77514 --- /dev/null +++ b/homeassistant/components/vermont_castings/__init__.py @@ -0,0 +1 @@ +"""Virtual integration for Vermont Castings fireplace.""" diff --git a/homeassistant/components/vermont_castings/manifest.json b/homeassistant/components/vermont_castings/manifest.json new file mode 100644 index 0000000000..301db38c8b --- /dev/null +++ b/homeassistant/components/vermont_castings/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "vermont_castings", + "name": "Vermont Castings", + "integration_type": "virtual", + "supported_by": "intellifire" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index cc53cf430d..e8350284f1 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5993,6 +5993,11 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "vermont_castings": { + "name": "Vermont Castings", + "integration_type": "virtual", + "supported_by": "intellifire" + }, "versasense": { "name": "VersaSense", "integration_type": "hub", From dcff2f37f789c94c28de1ab17bcb9527e694ad55 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Mar 2023 13:19:22 +0100 Subject: [PATCH 0794/2810] Add DSL prefix to SFRBox ADSL sensors (#89276) --- .../components/sfr_box/binary_sensor.py | 2 +- homeassistant/components/sfr_box/sensor.py | 22 ++++++++--------- tests/components/sfr_box/const.py | 24 +++++++++---------- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/sfr_box/binary_sensor.py b/homeassistant/components/sfr_box/binary_sensor.py index d90c1944aa..8b3883c6ee 100644 --- a/homeassistant/components/sfr_box/binary_sensor.py +++ b/homeassistant/components/sfr_box/binary_sensor.py @@ -42,7 +42,7 @@ class SFRBoxBinarySensorEntityDescription( DSL_SENSOR_TYPES: tuple[SFRBoxBinarySensorEntityDescription[DslInfo], ...] = ( SFRBoxBinarySensorEntityDescription[DslInfo]( key="status", - name="Status", + name="DSL status", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda x: x.status == "up", diff --git a/homeassistant/components/sfr_box/sensor.py b/homeassistant/components/sfr_box/sensor.py index 5f4aadce7e..48d1c16d0f 100644 --- a/homeassistant/components/sfr_box/sensor.py +++ b/homeassistant/components/sfr_box/sensor.py @@ -46,28 +46,28 @@ class SFRBoxSensorEntityDescription(SensorEntityDescription, SFRBoxSensorMixin[_ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = ( SFRBoxSensorEntityDescription[DslInfo]( key="linemode", - name="Line mode", + name="DSL line mode", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda x: x.linemode, ), SFRBoxSensorEntityDescription[DslInfo]( key="counter", - name="Counter", + name="DSL counter", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda x: x.counter, ), SFRBoxSensorEntityDescription[DslInfo]( key="crc", - name="CRC", + name="DSL CRC", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda x: x.crc, ), SFRBoxSensorEntityDescription[DslInfo]( key="noise_down", - name="Noise down", + name="DSL noise down", device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -77,7 +77,7 @@ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = ( ), SFRBoxSensorEntityDescription[DslInfo]( key="noise_up", - name="Noise up", + name="DSL noise up", device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -87,7 +87,7 @@ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = ( ), SFRBoxSensorEntityDescription[DslInfo]( key="attenuation_down", - name="Attenuation down", + name="DSL attenuation down", device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -97,7 +97,7 @@ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = ( ), SFRBoxSensorEntityDescription[DslInfo]( key="attenuation_up", - name="Attenuation up", + name="DSL attenuation up", device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -107,7 +107,7 @@ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = ( ), SFRBoxSensorEntityDescription[DslInfo]( key="rate_down", - name="Rate down", + name="DSL rate down", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, @@ -115,7 +115,7 @@ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = ( ), SFRBoxSensorEntityDescription[DslInfo]( key="rate_up", - name="Rate up", + name="DSL rate up", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, @@ -123,7 +123,7 @@ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = ( ), SFRBoxSensorEntityDescription[DslInfo]( key="line_status", - name="Line status", + name="DSL line status", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -140,7 +140,7 @@ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = ( ), SFRBoxSensorEntityDescription[DslInfo]( key="training", - name="Training", + name="DSL training", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, diff --git a/tests/components/sfr_box/const.py b/tests/components/sfr_box/const.py index 8b7513aaf8..fb1694ebef 100644 --- a/tests/components/sfr_box/const.py +++ b/tests/components/sfr_box/const.py @@ -45,7 +45,7 @@ EXPECTED_ENTITIES = { Platform.BINARY_SENSOR: [ { ATTR_DEVICE_CLASS: BinarySensorDeviceClass.CONNECTIVITY, - ATTR_ENTITY_ID: "binary_sensor.sfr_box_status", + ATTR_ENTITY_ID: "binary_sensor.sfr_box_dsl_status", ATTR_STATE: STATE_ON, ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_status", }, @@ -85,26 +85,26 @@ EXPECTED_ENTITIES = { }, { ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "sensor.sfr_box_line_mode", + ATTR_ENTITY_ID: "sensor.sfr_box_dsl_line_mode", ATTR_STATE: "ADSL2+", ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_linemode", }, { ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "sensor.sfr_box_counter", + ATTR_ENTITY_ID: "sensor.sfr_box_dsl_counter", ATTR_STATE: "16", ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_counter", }, { ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "sensor.sfr_box_crc", + ATTR_ENTITY_ID: "sensor.sfr_box_dsl_crc", ATTR_STATE: "0", ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_crc", }, { ATTR_DEFAULT_DISABLED: True, ATTR_DEVICE_CLASS: SensorDeviceClass.SIGNAL_STRENGTH, - ATTR_ENTITY_ID: "sensor.sfr_box_noise_down", + ATTR_ENTITY_ID: "sensor.sfr_box_dsl_noise_down", ATTR_STATE: "5.8", ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_noise_down", @@ -113,7 +113,7 @@ EXPECTED_ENTITIES = { { ATTR_DEFAULT_DISABLED: True, ATTR_DEVICE_CLASS: SensorDeviceClass.SIGNAL_STRENGTH, - ATTR_ENTITY_ID: "sensor.sfr_box_noise_up", + ATTR_ENTITY_ID: "sensor.sfr_box_dsl_noise_up", ATTR_STATE: "6.0", ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_noise_up", @@ -122,7 +122,7 @@ EXPECTED_ENTITIES = { { ATTR_DEFAULT_DISABLED: True, ATTR_DEVICE_CLASS: SensorDeviceClass.SIGNAL_STRENGTH, - ATTR_ENTITY_ID: "sensor.sfr_box_attenuation_down", + ATTR_ENTITY_ID: "sensor.sfr_box_dsl_attenuation_down", ATTR_STATE: "28.5", ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_attenuation_down", @@ -131,7 +131,7 @@ EXPECTED_ENTITIES = { { ATTR_DEFAULT_DISABLED: True, ATTR_DEVICE_CLASS: SensorDeviceClass.SIGNAL_STRENGTH, - ATTR_ENTITY_ID: "sensor.sfr_box_attenuation_up", + ATTR_ENTITY_ID: "sensor.sfr_box_dsl_attenuation_up", ATTR_STATE: "20.8", ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_attenuation_up", @@ -139,7 +139,7 @@ EXPECTED_ENTITIES = { }, { ATTR_DEVICE_CLASS: SensorDeviceClass.DATA_RATE, - ATTR_ENTITY_ID: "sensor.sfr_box_rate_down", + ATTR_ENTITY_ID: "sensor.sfr_box_dsl_rate_down", ATTR_STATE: "5549", ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_rate_down", @@ -147,7 +147,7 @@ EXPECTED_ENTITIES = { }, { ATTR_DEVICE_CLASS: SensorDeviceClass.DATA_RATE, - ATTR_ENTITY_ID: "sensor.sfr_box_rate_up", + ATTR_ENTITY_ID: "sensor.sfr_box_dsl_rate_up", ATTR_STATE: "187", ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_rate_up", @@ -156,7 +156,7 @@ EXPECTED_ENTITIES = { { ATTR_DEFAULT_DISABLED: True, ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM, - ATTR_ENTITY_ID: "sensor.sfr_box_line_status", + ATTR_ENTITY_ID: "sensor.sfr_box_dsl_line_status", ATTR_OPTIONS: [ "no_defect", "of_frame", @@ -171,7 +171,7 @@ EXPECTED_ENTITIES = { { ATTR_DEFAULT_DISABLED: True, ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM, - ATTR_ENTITY_ID: "sensor.sfr_box_training", + ATTR_ENTITY_ID: "sensor.sfr_box_dsl_training", ATTR_OPTIONS: [ "idle", "g_994_training", From f903c536fbae7b207c42a01db35968b10cfc3619 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Thu, 9 Mar 2023 14:18:19 +0100 Subject: [PATCH 0795/2810] Add Hardkernel ODROID-M1 (#89431) * Add Hardkernel ODROID-M1 Add Hardkernel ODROID-M1 machine. ODROID-M1 is a Rockchip RK3568B2 SoC based single board computer with 4xCortex-A55, NVMe support and up to 8GB of RAM. * Update homeassistant/components/hardkernel/hardware.py Co-authored-by: Franck Nijhof * Fix tests Co-authored-by: Franck Nijhof --- .github/workflows/builder.yml | 1 + homeassistant/components/hardkernel/hardware.py | 9 +++++---- homeassistant/components/hassio/__init__.py | 1 + homeassistant/components/version/const.py | 2 ++ machine/odroid-m1 | 5 +++++ tests/components/hardkernel/test_hardware.py | 2 +- 6 files changed, 15 insertions(+), 5 deletions(-) create mode 100644 machine/odroid-m1 diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 531efe5674..cc87e4708c 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -232,6 +232,7 @@ jobs: - khadas-vim3 - odroid-c2 - odroid-c4 + - odroid-m1 - odroid-n2 - odroid-xu - qemuarm diff --git a/homeassistant/components/hardkernel/hardware.py b/homeassistant/components/hardkernel/hardware.py index cd83f684ea..3d4a87b040 100644 --- a/homeassistant/components/hardkernel/hardware.py +++ b/homeassistant/components/hardkernel/hardware.py @@ -9,10 +9,11 @@ from homeassistant.exceptions import HomeAssistantError from .const import DOMAIN BOARD_NAMES = { - "odroid-c2": "Hardkernel Odroid-C2", - "odroid-c4": "Hardkernel Odroid-C4", - "odroid-n2": "Home Assistant Blue / Hardkernel Odroid-N2", - "odroid-xu4": "Hardkernel Odroid-XU4", + "odroid-c2": "Hardkernel ODROID-C2", + "odroid-c4": "Hardkernel ODROID-C4", + "odroid-m1": "Hardkernel ODROID-M1", + "odroid-n2": "Home Assistant Blue / Hardkernel ODROID-N2/N2+", + "odroid-xu4": "Hardkernel ODROID-XU4", } diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 4f5d8e9d31..23936f6576 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -229,6 +229,7 @@ MAP_SERVICE_API = { HARDWARE_INTEGRATIONS = { "odroid-c2": "hardkernel", "odroid-c4": "hardkernel", + "odroid-m1": "hardkernel", "odroid-n2": "hardkernel", "odroid-xu4": "hardkernel", "rpi2": "raspberry_pi", diff --git a/homeassistant/components/version/const.py b/homeassistant/components/version/const.py index 1693f79ec6..bdebf9f025 100644 --- a/homeassistant/components/version/const.py +++ b/homeassistant/components/version/const.py @@ -69,6 +69,7 @@ BOARD_MAP: Final[dict[str, str]] = { "ASUS Tinkerboard": "tinker", "ODROID C2": "odroid-c2", "ODROID C4": "odroid-c4", + "ODROID M1": "odroid-m1", "ODROID N2": "odroid-n2", "ODROID XU4": "odroid-xu4", "Generic AArch64": "generic-aarch64", @@ -97,6 +98,7 @@ VALID_IMAGES: Final = [ "generic-x86-64", "intel-nuc", "odroid-c2", + "odroid-m1", "odroid-n2", "odroid-xu", "qemuarm-64", diff --git a/machine/odroid-m1 b/machine/odroid-m1 new file mode 100644 index 0000000000..be07d6c8ab --- /dev/null +++ b/machine/odroid-m1 @@ -0,0 +1,5 @@ +ARG BUILD_VERSION +FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION + +RUN apk --no-cache add \ + usbutils diff --git a/tests/components/hardkernel/test_hardware.py b/tests/components/hardkernel/test_hardware.py index 7b5a531cbf..7e063a9f07 100644 --- a/tests/components/hardkernel/test_hardware.py +++ b/tests/components/hardkernel/test_hardware.py @@ -53,7 +53,7 @@ async def test_hardware_info( }, "config_entries": [config_entry.entry_id], "dongle": None, - "name": "Home Assistant Blue / Hardkernel Odroid-N2", + "name": "Home Assistant Blue / Hardkernel ODROID-N2/N2+", "url": None, } ] From 3c27f9ea7de63edf2ede90751c06be23f7cbe45e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 9 Mar 2023 17:58:03 +0100 Subject: [PATCH 0796/2810] Update actions/cache to 3.3.0 (#89438) --- .github/workflows/ci.yaml | 48 ++++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 117b64022c..b65039c42b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -212,7 +212,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.2.6 + uses: actions/cache@v3.3.0 with: path: venv key: >- @@ -227,9 +227,10 @@ jobs: pip install "$(cat requirements_test.txt | grep pre-commit)" - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v3.2.6 + uses: actions/cache@v3.3.0 with: path: ${{ env.PRE_COMMIT_CACHE }} + lookup-only: true key: >- ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.pre-commit_cache_key }} @@ -256,7 +257,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: venv fail-on-cache-miss: true @@ -265,7 +266,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -302,7 +303,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: venv fail-on-cache-miss: true @@ -311,7 +312,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -351,7 +352,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: venv fail-on-cache-miss: true @@ -360,7 +361,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -400,7 +401,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: venv fail-on-cache-miss: true @@ -409,7 +410,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -438,7 +439,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: venv fail-on-cache-miss: true @@ -447,7 +448,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -562,15 +563,16 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.2.6 + uses: actions/cache@v3.3.0 with: path: venv + lookup-only: true key: >- ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore pip wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@v3.2.6 + uses: actions/cache@v3.3.0 with: path: ${{ env.PIP_CACHE }} key: >- @@ -624,7 +626,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: venv fail-on-cache-miss: true @@ -656,7 +658,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: venv fail-on-cache-miss: true @@ -689,7 +691,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: venv fail-on-cache-miss: true @@ -740,7 +742,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: venv fail-on-cache-miss: true @@ -748,7 +750,7 @@ jobs: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore mypy cache - uses: actions/cache@v3.2.6 + uses: actions/cache@v3.3.0 with: path: .mypy_cache key: >- @@ -799,7 +801,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: venv fail-on-cache-miss: true @@ -852,7 +854,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: venv fail-on-cache-miss: true @@ -978,7 +980,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: venv fail-on-cache-miss: true @@ -1082,7 +1084,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: venv fail-on-cache-miss: true From adfd26363539b9f5d8ef3b1d06d7d094edc610eb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Mar 2023 18:12:22 +0100 Subject: [PATCH 0797/2810] Add missing mock in gree config flow tests (#89450) --- tests/components/gree/conftest.py | 12 +++++++++- tests/components/gree/test_config_flow.py | 29 +++++++++++------------ 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/tests/components/gree/conftest.py b/tests/components/gree/conftest.py index 6aabb95a1b..8ef5f7bb38 100644 --- a/tests/components/gree/conftest.py +++ b/tests/components/gree/conftest.py @@ -1,11 +1,21 @@ """Pytest module configuration.""" -from unittest.mock import patch +from collections.abc import Generator +from unittest.mock import AsyncMock, patch import pytest from .common import FakeDiscovery, build_device_mock +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.gree.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(autouse=True, name="discovery") def discovery_fixture(): """Patch the discovery object.""" diff --git a/tests/components/gree/test_config_flow.py b/tests/components/gree/test_config_flow.py index 97820b8928..d4a922be44 100644 --- a/tests/components/gree/test_config_flow.py +++ b/tests/components/gree/test_config_flow.py @@ -1,5 +1,7 @@ """Tests for the Gree Integration.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch + +import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components.gree.const import DOMAIN as GREE_DOMAIN @@ -7,15 +9,15 @@ from homeassistant.core import HomeAssistant from .common import FakeDiscovery +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + @patch("homeassistant.components.gree.config_flow.DISCOVERY_TIMEOUT", 0) -async def test_creating_entry_sets_up_climate(hass: HomeAssistant) -> None: +async def test_creating_entry_sets_up_climate( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test setting up Gree creates the climate components.""" with patch( - "homeassistant.components.gree.climate.async_setup_entry", return_value=True - ) as setup, patch( - "homeassistant.components.gree.bridge.Discovery", return_value=FakeDiscovery() - ), patch( "homeassistant.components.gree.config_flow.Discovery", return_value=FakeDiscovery(), ): @@ -31,22 +33,19 @@ async def test_creating_entry_sets_up_climate(hass: HomeAssistant) -> None: await hass.async_block_till_done() - assert len(setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 @patch("homeassistant.components.gree.config_flow.DISCOVERY_TIMEOUT", 0) -async def test_creating_entry_has_no_devices(hass: HomeAssistant) -> None: +async def test_creating_entry_has_no_devices( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test setting up Gree creates the climate components.""" with patch( - "homeassistant.components.gree.climate.async_setup_entry", return_value=True - ) as setup, patch( - "homeassistant.components.gree.bridge.Discovery", return_value=FakeDiscovery() - ) as discovery, patch( "homeassistant.components.gree.config_flow.Discovery", return_value=FakeDiscovery(), - ) as discovery2: + ) as discovery: discovery.return_value.mock_devices = [] - discovery2.return_value.mock_devices = [] result = await hass.config_entries.flow.async_init( GREE_DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -60,4 +59,4 @@ async def test_creating_entry_has_no_devices(hass: HomeAssistant) -> None: await hass.async_block_till_done() - assert len(setup.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 From 01d8eaa5b68a99115a61d7c1cc5cc4e05e0350ce Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 9 Mar 2023 18:28:02 +0100 Subject: [PATCH 0798/2810] 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 da68e48cc0..a4d97201c5 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 915364219d..b79a0a5ef7 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 9bbcccb1ac..a838266627 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 cbc7961352..a241892ff5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -690,7 +690,7 @@ hole==0.8.0 holidays==0.18.0 # homeassistant.components.frontend -home-assistant-frontend==20230306.0 +home-assistant-frontend==20230309.0 # homeassistant.components.conversation home-assistant-intents==2023.2.28 From 4e4608183e1a2618c1f997f4376a2ca8cf58735f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Mar 2023 19:04:07 +0100 Subject: [PATCH 0799/2810] Add missing mock in fibaro config flow tests (#89440) --- tests/components/fibaro/conftest.py | 14 ++++++++++++++ tests/components/fibaro/test_config_flow.py | 8 ++------ 2 files changed, 16 insertions(+), 6 deletions(-) create mode 100644 tests/components/fibaro/conftest.py diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py new file mode 100644 index 0000000000..9f1c87c1d9 --- /dev/null +++ b/tests/components/fibaro/conftest.py @@ -0,0 +1,14 @@ +"""Test helpers.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.fibaro.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/fibaro/test_config_flow.py b/tests/components/fibaro/test_config_flow.py index 0854bb532e..cb3d35d6f4 100644 --- a/tests/components/fibaro/test_config_flow.py +++ b/tests/components/fibaro/test_config_flow.py @@ -20,6 +20,8 @@ TEST_USERNAME = "user" TEST_PASSWORD = "password" TEST_VERSION = "4.360" +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + @pytest.fixture(name="fibaro_client", autouse=True) def fibaro_client_fixture(): @@ -69,9 +71,6 @@ async def test_config_flow_user_initiated_success(hass: HomeAssistant) -> None: with patch( "homeassistant.components.fibaro.FibaroClient.connect", return_value=True, - ), patch( - "homeassistant.components.fibaro.async_setup_entry", - return_value=True, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -239,9 +238,6 @@ async def test_reauth_success(hass: HomeAssistant) -> None: with patch( "homeassistant.components.fibaro.FibaroClient.connect", return_value=True - ), patch( - "homeassistant.components.fibaro.async_setup_entry", - return_value=True, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], From f3084165b10ef9eb9c4f1c321da26a200565c884 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Mar 2023 19:04:51 +0100 Subject: [PATCH 0800/2810] Add missing mock in filesize config flow tests (#89441) --- tests/components/filesize/test_config_flow.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/components/filesize/test_config_flow.py b/tests/components/filesize/test_config_flow.py index 7010a3e9a4..dd99aa2a72 100644 --- a/tests/components/filesize/test_config_flow.py +++ b/tests/components/filesize/test_config_flow.py @@ -1,6 +1,8 @@ """Tests for the Filesize config flow.""" from unittest.mock import patch +import pytest + from homeassistant.components.filesize.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_FILE_PATH @@ -11,6 +13,8 @@ from . import TEST_DIR, TEST_FILE, TEST_FILE_NAME, async_create_file from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + async def test_full_user_flow(hass: HomeAssistant) -> None: """Test the full user configuration flow.""" @@ -75,9 +79,6 @@ async def test_flow_fails_on_validation(hass: HomeAssistant) -> None: with patch( "homeassistant.components.filesize.config_flow.pathlib.Path", - ), patch( - "homeassistant.components.filesize.async_setup_entry", - return_value=True, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -91,9 +92,6 @@ async def test_flow_fails_on_validation(hass: HomeAssistant) -> None: hass.config.allowlist_external_dirs = {TEST_DIR} with patch( "homeassistant.components.filesize.config_flow.pathlib.Path", - ), patch( - "homeassistant.components.filesize.async_setup_entry", - return_value=True, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], From 3a4ce260b4b8f5ce6193eae4b6ef7780142b77b5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Mar 2023 19:05:23 +0100 Subject: [PATCH 0801/2810] Add missing mock in freedompro config flow tests (#89442) --- tests/components/freedompro/conftest.py | 12 +++++++++++- tests/components/freedompro/test_config_flow.py | 4 ++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/components/freedompro/conftest.py b/tests/components/freedompro/conftest.py index bc3d731105..80e13edb80 100644 --- a/tests/components/freedompro/conftest.py +++ b/tests/components/freedompro/conftest.py @@ -1,9 +1,10 @@ """Fixtures for Freedompro integration tests.""" from __future__ import annotations +from collections.abc import Generator from copy import deepcopy from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest @@ -14,6 +15,15 @@ from .const import DEVICES, DEVICES_STATE from tests.common import MockConfigEntry +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.freedompro.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(autouse=True) def mock_freedompro(): """Mock freedompro get_list and get_states.""" diff --git a/tests/components/freedompro/test_config_flow.py b/tests/components/freedompro/test_config_flow.py index 6fee3694ec..dc55ba037b 100644 --- a/tests/components/freedompro/test_config_flow.py +++ b/tests/components/freedompro/test_config_flow.py @@ -1,6 +1,8 @@ """Define tests for the Freedompro config flow.""" from unittest.mock import patch +import pytest + from homeassistant import data_entry_flow from homeassistant.components.freedompro.const import DOMAIN from homeassistant.config_entries import SOURCE_USER @@ -13,6 +15,8 @@ VALID_CONFIG = { CONF_API_KEY: "ksdjfgslkjdfksjdfksjgfksjd", } +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + async def test_show_form(hass: HomeAssistant) -> None: """Test that the form is served with no input.""" From 4f29e1e18015c27884d16a3ee709b951ba53a10e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 9 Mar 2023 19:06:35 +0100 Subject: [PATCH 0802/2810] Add stats sensors for core and supervisor (#89455) * Add stats sensors for core and supervisor * Update homeassistant/components/hassio/__init__.py --- homeassistant/components/hassio/__init__.py | 36 ++++++++++++- homeassistant/components/hassio/handler.py | 16 ++++++ homeassistant/components/hassio/sensor.py | 52 +++++++++++++++++-- tests/components/hassio/test_binary_sensor.py | 32 ++++++++++++ tests/components/hassio/test_diagnostics.py | 32 ++++++++++++ tests/components/hassio/test_handler.py | 28 ++++++++++ tests/components/hassio/test_init.py | 48 ++++++++++++++--- tests/components/hassio/test_sensor.py | 32 ++++++++++++ tests/components/hassio/test_update.py | 32 ++++++++++++ 9 files changed, 295 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 23936f6576..25f3477bff 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -115,11 +115,13 @@ CONFIG_SCHEMA = vol.Schema( DATA_CORE_INFO = "hassio_core_info" +DATA_CORE_STATS = "hassio_core_stats" DATA_HOST_INFO = "hassio_host_info" DATA_STORE = "hassio_store" DATA_INFO = "hassio_info" DATA_OS_INFO = "hassio_os_info" DATA_SUPERVISOR_INFO = "hassio_supervisor_info" +DATA_SUPERVISOR_STATS = "hassio_supervisor_stats" DATA_ADDONS_CHANGELOGS = "hassio_addons_changelogs" DATA_ADDONS_INFO = "hassio_addons_info" DATA_ADDONS_STATS = "hassio_addons_stats" @@ -301,6 +303,26 @@ def get_addons_stats(hass): return hass.data.get(DATA_ADDONS_STATS) +@callback +@bind_hass +def get_core_stats(hass): + """Return core stats. + + Async friendly. + """ + return hass.data.get(DATA_CORE_STATS) + + +@callback +@bind_hass +def get_supervisor_stats(hass): + """Return supervisor stats. + + Async friendly. + """ + return hass.data.get(DATA_SUPERVISOR_STATS) + + @callback @bind_hass def get_addons_changelogs(hass): @@ -747,8 +769,14 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): if self.is_hass_os: new_data[DATA_KEY_OS] = get_os_info(self.hass) - new_data[DATA_KEY_CORE] = get_core_info(self.hass) - new_data[DATA_KEY_SUPERVISOR] = supervisor_info + new_data[DATA_KEY_CORE] = { + **(get_core_info(self.hass) or {}), + **get_core_stats(self.hass), + } + new_data[DATA_KEY_SUPERVISOR] = { + **supervisor_info, + **get_supervisor_stats(self.hass), + } # If this is the initial refresh, register all addons and return the dict if not self.data: @@ -805,12 +833,16 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): ( self.hass.data[DATA_INFO], self.hass.data[DATA_CORE_INFO], + self.hass.data[DATA_CORE_STATS], self.hass.data[DATA_SUPERVISOR_INFO], + self.hass.data[DATA_SUPERVISOR_STATS], self.hass.data[DATA_OS_INFO], ) = await asyncio.gather( self.hassio.get_info(), self.hassio.get_core_info(), + self.hassio.get_core_stats(), self.hassio.get_supervisor_info(), + self.hassio.get_supervisor_stats(), self.hassio.get_os_info(), ) diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 762df4f79c..d7af26851d 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -319,6 +319,14 @@ class HassIO: """ return self.send_command(f"/addons/{addon}/info", method="get") + @api_data + def get_core_stats(self): + """Return stats for the core. + + This method returns a coroutine. + """ + return self.send_command("/core/stats", method="get") + @api_data def get_addon_stats(self, addon): """Return stats for an Add-on. @@ -327,6 +335,14 @@ class HassIO: """ return self.send_command(f"/addons/{addon}/stats", method="get") + @api_data + def get_supervisor_stats(self): + """Return stats for the supervisor. + + This method returns a coroutine. + """ + return self.send_command("/supervisor/stats", method="get") + def get_addon_changelog(self, addon): """Return changelog for an Add-on. diff --git a/homeassistant/components/hassio/sensor.py b/homeassistant/components/hassio/sensor.py index 31e728a973..a5b0b3a725 100644 --- a/homeassistant/components/hassio/sensor.py +++ b/homeassistant/components/hassio/sensor.py @@ -18,9 +18,16 @@ from .const import ( ATTR_VERSION, ATTR_VERSION_LATEST, DATA_KEY_ADDONS, + DATA_KEY_CORE, DATA_KEY_OS, + DATA_KEY_SUPERVISOR, +) +from .entity import ( + HassioAddonEntity, + HassioCoreEntity, + HassioOSEntity, + HassioSupervisorEntity, ) -from .entity import HassioAddonEntity, HassioOSEntity COMMON_ENTITY_DESCRIPTIONS = ( SensorEntityDescription( @@ -35,7 +42,7 @@ COMMON_ENTITY_DESCRIPTIONS = ( ), ) -ADDON_ENTITY_DESCRIPTIONS = COMMON_ENTITY_DESCRIPTIONS + ( +STATS_ENTITY_DESCRIPTIONS = ( SensorEntityDescription( entity_registry_enabled_default=False, key=ATTR_CPU_PERCENT, @@ -54,7 +61,10 @@ ADDON_ENTITY_DESCRIPTIONS = COMMON_ENTITY_DESCRIPTIONS + ( ), ) +ADDON_ENTITY_DESCRIPTIONS = COMMON_ENTITY_DESCRIPTIONS + STATS_ENTITY_DESCRIPTIONS +CORE_ENTITY_DESCRIPTIONS = STATS_ENTITY_DESCRIPTIONS OS_ENTITY_DESCRIPTIONS = COMMON_ENTITY_DESCRIPTIONS +SUPERVISOR_ENTITY_DESCRIPTIONS = STATS_ENTITY_DESCRIPTIONS async def async_setup_entry( @@ -65,7 +75,9 @@ async def async_setup_entry( """Sensor set up for Hass.io config entry.""" coordinator = hass.data[ADDONS_COORDINATOR] - entities: list[HassioOSSensor | HassioAddonSensor] = [] + entities: list[ + HassioOSSensor | HassioAddonSensor | CoreSensor | SupervisorSensor + ] = [] for addon in coordinator.data[DATA_KEY_ADDONS].values(): for entity_description in ADDON_ENTITY_DESCRIPTIONS: @@ -77,6 +89,22 @@ async def async_setup_entry( ) ) + for entity_description in CORE_ENTITY_DESCRIPTIONS: + entities.append( + CoreSensor( + coordinator=coordinator, + entity_description=entity_description, + ) + ) + + for entity_description in SUPERVISOR_ENTITY_DESCRIPTIONS: + entities.append( + SupervisorSensor( + coordinator=coordinator, + entity_description=entity_description, + ) + ) + if coordinator.is_hass_os: for entity_description in OS_ENTITY_DESCRIPTIONS: entities.append( @@ -107,3 +135,21 @@ class HassioOSSensor(HassioOSEntity, SensorEntity): def native_value(self) -> str: """Return native value of entity.""" return self.coordinator.data[DATA_KEY_OS][self.entity_description.key] + + +class CoreSensor(HassioCoreEntity, SensorEntity): + """Sensor to track a core attribute.""" + + @property + def native_value(self) -> str: + """Return native value of entity.""" + return self.coordinator.data[DATA_KEY_CORE][self.entity_description.key] + + +class SupervisorSensor(HassioSupervisorEntity, SensorEntity): + """Sensor to track a supervisor attribute.""" + + @property + def native_value(self) -> str: + """Return native value of entity.""" + return self.coordinator.data[DATA_KEY_SUPERVISOR][self.entity_description.key] diff --git a/tests/components/hassio/test_binary_sensor.py b/tests/components/hassio/test_binary_sensor.py index 133074d7c9..854a6782b1 100644 --- a/tests/components/hassio/test_binary_sensor.py +++ b/tests/components/hassio/test_binary_sensor.py @@ -120,6 +120,38 @@ def mock_all(aioclient_mock, request): }, }, ) + aioclient_mock.get( + "http://127.0.0.1/core/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/supervisor/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) aioclient_mock.get("http://127.0.0.1/addons/test/changelog", text="") aioclient_mock.get( "http://127.0.0.1/addons/test/info", diff --git a/tests/components/hassio/test_diagnostics.py b/tests/components/hassio/test_diagnostics.py index 7b89cb6c99..b3d47e93af 100644 --- a/tests/components/hassio/test_diagnostics.py +++ b/tests/components/hassio/test_diagnostics.py @@ -125,6 +125,38 @@ def mock_all(aioclient_mock, request): }, }, ) + aioclient_mock.get( + "http://127.0.0.1/core/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/supervisor/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) aioclient_mock.get("http://127.0.0.1/addons/test/changelog", text="") aioclient_mock.get( "http://127.0.0.1/addons/test/info", diff --git a/tests/components/hassio/test_handler.py b/tests/components/hassio/test_handler.py index 64e9e1c31c..c7075dba93 100644 --- a/tests/components/hassio/test_handler.py +++ b/tests/components/hassio/test_handler.py @@ -226,6 +226,34 @@ async def test_api_addon_stats( assert aioclient_mock.call_count == 1 +async def test_api_core_stats( + hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker +) -> None: + """Test setup with API Add-on stats.""" + aioclient_mock.get( + "http://127.0.0.1/core/stats", + json={"result": "ok", "data": {"memory_percent": 0.01}}, + ) + + data = await hassio_handler.get_core_stats() + assert data["memory_percent"] == 0.01 + assert aioclient_mock.call_count == 1 + + +async def test_api_supervisor_stats( + hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker +) -> None: + """Test setup with API Add-on stats.""" + aioclient_mock.get( + "http://127.0.0.1/supervisor/stats", + json={"result": "ok", "data": {"memory_percent": 0.01}}, + ) + + data = await hassio_handler.get_supervisor_stats() + assert data["memory_percent"] == 0.01 + assert aioclient_mock.call_count == 1 + + async def test_api_discovery_message( hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index ee7e9cfa70..a752fe1b67 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -124,6 +124,38 @@ def mock_all(aioclient_mock, request, os_info): ], }, ) + aioclient_mock.get( + "http://127.0.0.1/core/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/supervisor/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) aioclient_mock.get( "http://127.0.0.1/addons/test/stats", json={ @@ -210,7 +242,7 @@ async def test_setup_api_ping( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 16 + assert aioclient_mock.call_count == 18 assert hass.components.hassio.get_core_info()["version_latest"] == "1.0.0" assert hass.components.hassio.is_hassio() @@ -254,7 +286,7 @@ async def test_setup_api_push_api_data( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 16 + assert aioclient_mock.call_count == 18 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 9999 assert aioclient_mock.mock_calls[1][2]["watchdog"] @@ -273,7 +305,7 @@ async def test_setup_api_push_api_data_server_host( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 16 + assert aioclient_mock.call_count == 18 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 9999 assert not aioclient_mock.mock_calls[1][2]["watchdog"] @@ -290,7 +322,7 @@ async def test_setup_api_push_api_data_default( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 16 + assert aioclient_mock.call_count == 18 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 8123 refresh_token = aioclient_mock.mock_calls[1][2]["refresh_token"] @@ -370,7 +402,7 @@ async def test_setup_api_existing_hassio_user( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 16 + assert aioclient_mock.call_count == 18 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 8123 assert aioclient_mock.mock_calls[1][2]["refresh_token"] == token.token @@ -387,7 +419,7 @@ async def test_setup_core_push_timezone( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 16 + assert aioclient_mock.call_count == 18 assert aioclient_mock.mock_calls[2][2]["timezone"] == "testzone" with patch("homeassistant.util.dt.set_default_time_zone"): @@ -407,7 +439,7 @@ async def test_setup_hassio_no_additional_data( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 16 + assert aioclient_mock.call_count == 18 assert aioclient_mock.mock_calls[-1][3]["Authorization"] == "Bearer 123456" @@ -822,7 +854,7 @@ async def test_setup_hardware_integration( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 16 + assert aioclient_mock.call_count == 18 assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/hassio/test_sensor.py b/tests/components/hassio/test_sensor.py index 4088ba631f..99b1db2a99 100644 --- a/tests/components/hassio/test_sensor.py +++ b/tests/components/hassio/test_sensor.py @@ -113,6 +113,38 @@ def mock_all(aioclient_mock, request): }, }, ) + aioclient_mock.get( + "http://127.0.0.1/core/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/supervisor/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) aioclient_mock.get("http://127.0.0.1/addons/test/changelog", text="") aioclient_mock.get( "http://127.0.0.1/addons/test/info", diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 20a46da051..547e32dfdd 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -127,6 +127,38 @@ def mock_all(aioclient_mock, request): }, }, ) + aioclient_mock.get( + "http://127.0.0.1/core/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/supervisor/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) aioclient_mock.get("http://127.0.0.1/addons/test/changelog", text="") aioclient_mock.get( "http://127.0.0.1/addons/test/info", From 3796a7385642f2f42296546dd9d11d23397cb319 Mon Sep 17 00:00:00 2001 From: Stephan Uhle Date: Thu, 9 Mar 2023 19:08:55 +0100 Subject: [PATCH 0803/2810] Add device info to edl21 (#89327) --- homeassistant/components/edl21/const.py | 2 ++ homeassistant/components/edl21/sensor.py | 44 +++++++++++++----------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/edl21/const.py b/homeassistant/components/edl21/const.py index f57966a000..2bde0ff379 100644 --- a/homeassistant/components/edl21/const.py +++ b/homeassistant/components/edl21/const.py @@ -10,3 +10,5 @@ CONF_SERIAL_PORT = "serial_port" SIGNAL_EDL21_TELEGRAM = "edl21_telegram" DEFAULT_TITLE = "Smart Meter" + +DEFAULT_DEVICE_NAME = "Smart Meter" diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index 355d448e30..35992b9610 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -32,12 +32,19 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.dt import utcnow -from .const import CONF_SERIAL_PORT, DOMAIN, LOGGER, SIGNAL_EDL21_TELEGRAM +from .const import ( + CONF_SERIAL_PORT, + DEFAULT_DEVICE_NAME, + DOMAIN, + LOGGER, + SIGNAL_EDL21_TELEGRAM, +) MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) @@ -368,13 +375,16 @@ class EDL21: else: entity_description = SENSORS.get(obis) if entity_description and entity_description.name: - name = entity_description.name - if self._name: - name = f"{self._name}: {name}" - + # self._name is only used for backwards YAML compatibility + # This needs to be cleaned up when YAML support is removed + device_name = self._name or DEFAULT_DEVICE_NAME new_entities.append( EDL21Entity( - electricity_id, obis, name, entity_description, telegram + electricity_id, + obis, + device_name, + entity_description, + telegram, ) ) self._registered_obis.add((electricity_id, obis)) @@ -397,7 +407,7 @@ class EDL21: old_entity_id = registry.async_get_entity_id( "sensor", DOMAIN, entity.old_unique_id ) - if old_entity_id is not None: + if old_entity_id is not None and entity.unique_id is not None: LOGGER.debug( "Migrating unique_id from [%s] to [%s]", entity.old_unique_id, @@ -417,13 +427,12 @@ class EDL21Entity(SensorEntity): """Entity reading values from EDL21 telegram.""" _attr_should_poll = False + _attr_has_entity_name = True - def __init__(self, electricity_id, obis, name, entity_description, telegram): + def __init__(self, electricity_id, obis, device_name, entity_description, telegram): """Initialize an EDL21Entity.""" self._electricity_id = electricity_id self._obis = obis - self._name = name - self._unique_id = f"{electricity_id}_{obis}" self._telegram = telegram self._min_time = MIN_TIME_BETWEEN_UPDATES self._last_update = utcnow() @@ -435,6 +444,11 @@ class EDL21Entity(SensorEntity): } self._async_remove_dispatcher = None self.entity_description = entity_description + self._attr_unique_id = f"{electricity_id}_{obis}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._electricity_id)}, + name=device_name, + ) async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" @@ -466,21 +480,11 @@ class EDL21Entity(SensorEntity): if self._async_remove_dispatcher: self._async_remove_dispatcher() - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id - @property def old_unique_id(self) -> str: """Return a less unique ID as used in the first version of edl21.""" return self._obis - @property - def name(self) -> str | None: - """Return a name.""" - return self._name - @property def native_value(self) -> str: """Return the value of the last received telegram.""" From d1734bc0aba0248b8a53621a61e26f8440245a90 Mon Sep 17 00:00:00 2001 From: Kirill Kulakov Date: Thu, 9 Mar 2023 12:15:14 -0600 Subject: [PATCH 0804/2810] Xiaomi Air Purifier S2 illuminance sensor support (#89208) * Add sensor illuminance sensor support xiaomi s2 air purifier * Add sensor illuminance sensor support xiaomi s2 air purifier * fix sorting --- homeassistant/components/xiaomi_miio/sensor.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index bbf2764ceb..249774519d 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -64,6 +64,7 @@ from .const import ( MODEL_AIRPURIFIER_4_LITE_RMA1, MODEL_AIRPURIFIER_4_LITE_RMB1, MODEL_AIRPURIFIER_4_PRO, + MODEL_AIRPURIFIER_MA2, MODEL_AIRPURIFIER_PRO, MODEL_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V2, @@ -467,6 +468,16 @@ PURIFIER_ZA1_SENSORS = ( ATTR_HUMIDITY, ATTR_TEMPERATURE, ) +PURIFIER_MA2_SENSORS = ( + ATTR_FILTER_LIFE_REMAINING, + ATTR_FILTER_USE, + ATTR_HUMIDITY, + ATTR_MOTOR_SPEED, + ATTR_PM25, + ATTR_TEMPERATURE, + ATTR_USE_TIME, + ATTR_ILLUMINANCE, +) PURIFIER_V2_SENSORS = ( ATTR_FILTER_LIFE_REMAINING, ATTR_FILTER_USE, @@ -564,6 +575,7 @@ MODEL_TO_SENSORS_MAP: dict[str, tuple[str, ...]] = { MODEL_AIRPURIFIER_V2: PURIFIER_V2_SENSORS, MODEL_AIRPURIFIER_V3: PURIFIER_V3_SENSORS, MODEL_AIRPURIFIER_ZA1: PURIFIER_ZA1_SENSORS, + MODEL_AIRPURIFIER_MA2: PURIFIER_MA2_SENSORS, MODEL_FAN_V2: FAN_V2_V3_SENSORS, MODEL_FAN_V3: FAN_V2_V3_SENSORS, MODEL_FAN_ZA5: FAN_ZA5_SENSORS, From 7ef1c289bec69e28f5de29ccd437300499d388e9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 9 Mar 2023 19:17:29 +0100 Subject: [PATCH 0805/2810] 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 95e26a3eeb..e21e35da1e 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 ebf95feff33f98e90e5e39a0fb8c61732b371d8d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 9 Mar 2023 08:02:59 +0100 Subject: [PATCH 0806/2810] 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 153726a89e..358a97ed30 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 1e486a3492..fcdec1fbfe 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 0807/2810] 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 5aa630df5d..08815cae9f 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 fb17dc6d4d..0da8822ad1 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 bd5fe240e3..6e0e8ac331 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 0808/2810] 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 64bb8bef0c..2c2aafa960 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 0da8822ad1..ca8360f2e8 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 6e0e8ac331..853e1e0d9a 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 0809/2810] 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 df51dd60a1..1eabb509ab 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 09944b56c0..cee3aae375 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 0810/2810] 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 8a8583a7da..fecf05f74b 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 cb1dd639ec..e659fbe4b8 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 0811/2810] 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 da68e48cc0..a4d97201c5 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 4e4786e7ed..0d3be634e7 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 ca8360f2e8..33e6d70f7e 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 853e1e0d9a..e2f1b0f751 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 0812/2810] 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 95e26a3eeb..e21e35da1e 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 0813/2810] 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 e90ccad63e..82b9fd1a31 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 34e8826764..0dcc14344a 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" From 48fca3bb2727b393d29f2246bc4df98ba8c35ed6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Mar 2023 21:16:52 +0100 Subject: [PATCH 0814/2810] Fix missing debouncer cancel in update coordinator (#89383) * Fix missing debouncer cancel in update coordinator * Improve * Adjust with comment * Adjust again * Simplify PR * Adjust tests to avoid lingering timer * Improve --- homeassistant/helpers/update_coordinator.py | 4 ++++ tests/helpers/test_update_coordinator.py | 14 +++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index e8ca1a1f91..e9de580bad 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -148,6 +148,8 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_T]): self._unsub_refresh() self._unsub_refresh = None + self._debounced_refresh.async_cancel() + def async_contexts(self) -> Generator[Any, None, None]: """Return all registered contexts.""" yield from ( @@ -163,6 +165,8 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_T]): if self.config_entry and self.config_entry.pref_disable_polling: return + # We do not cancel the debouncer here. If the refresh interval is shorter + # than the debouncer cooldown, this would cause the debounce to never be called if self._unsub_refresh: self._unsub_refresh() self._unsub_refresh = None diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 6bba27af65..9f904f0802 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -146,6 +146,9 @@ async def test_request_refresh(crd) -> None: assert crd.data == 1 assert crd.last_update_success is True + # Cleanup to avoid lingering timer + crd._unschedule_refresh() + async def test_request_refresh_no_auto_update(crd_without_update_interval) -> None: """Test request refresh for update coordinator without automatic update.""" @@ -160,6 +163,9 @@ async def test_request_refresh_no_auto_update(crd_without_update_interval) -> No assert crd.data == 1 assert crd.last_update_success is True + # Cleanup to avoid lingering timer + crd._unschedule_refresh() + @pytest.mark.parametrize( "err_msg", @@ -293,7 +299,8 @@ async def test_coordinator_entity(crd: update_coordinator.DataUpdateCoordinator[ ) as mock_async_on_remove: await entity.async_added_to_hass() - assert mock_async_on_remove.called + mock_async_on_remove.assert_called_once() + _on_remove_callback = mock_async_on_remove.call_args[0][0] # Verify we do not update if the entity is disabled crd.last_update_success = False @@ -303,6 +310,11 @@ async def test_coordinator_entity(crd: update_coordinator.DataUpdateCoordinator[ assert list(crd.async_contexts()) == [context] + # Call remove callback to cleanup debouncer and avoid lingering timer + assert len(crd._listeners) == 1 + _on_remove_callback() + assert len(crd._listeners) == 0 + async def test_async_set_updated_data(crd) -> None: """Test async_set_updated_data for update coordinator.""" From eed16dc185b9b04682b7589e49c084a9aeefab36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Mind=C3=AAllo=20de=20Andrade?= Date: Thu, 9 Mar 2023 18:32:30 -0300 Subject: [PATCH 0815/2810] Add list areas function to template (#88441) --- homeassistant/helpers/template.py | 9 +++++++++ tests/helpers/test_template.py | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index c923bd2d84..5205d51273 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -1185,6 +1185,12 @@ def is_device_attr( return bool(device_attr(hass, device_or_entity_id, attr_name) == attr_value) +def areas(hass: HomeAssistant) -> Iterable[str | None]: + """Return all areas.""" + area_reg = area_registry.async_get(hass) + return [area.id for area in area_reg.async_list_areas()] + + def area_id(hass: HomeAssistant, lookup_value: str) -> str | None: """Get the area ID from an area name, device id, or entity id.""" area_reg = area_registry.async_get(hass) @@ -2183,6 +2189,9 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["device_id"] = hassfunction(device_id) self.filters["device_id"] = pass_context(self.globals["device_id"]) + self.globals["areas"] = hassfunction(areas) + self.filters["areas"] = pass_context(self.globals["areas"]) + self.globals["area_id"] = hassfunction(area_id) self.filters["area_id"] = pass_context(self.globals["area_id"]) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index f97f0a4b9c..5122a4238e 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -2851,6 +2851,26 @@ async def test_device_attr( assert info.rate_limit is None +async def test_areas(hass: HomeAssistant, area_registry: ar.AreaRegistry) -> None: + """Test areas function.""" + # Test no areas + info = render_to_info(hass, "{{ areas() }}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Test one area + area1 = area_registry.async_get_or_create("area1") + info = render_to_info(hass, "{{ areas() }}") + assert_result_info(info, [area1.id]) + assert info.rate_limit is None + + # Test multiple areas + area2 = area_registry.async_get_or_create("area2") + info = render_to_info(hass, "{{ areas() }}") + assert_result_info(info, [area1.id, area2.id]) + assert info.rate_limit is None + + async def test_area_id( hass: HomeAssistant, area_registry: ar.AreaRegistry, From f8462fd5b7e8719f034fb532600738c8178d8c3e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Mar 2023 22:57:16 +0100 Subject: [PATCH 0816/2810] Add missing mock in guardian config flow tests (#89451) --- tests/components/guardian/conftest.py | 12 +++++++++++- tests/components/guardian/test_config_flow.py | 3 +++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/components/guardian/conftest.py b/tests/components/guardian/conftest.py index c7bffee4ff..acf59aeea8 100644 --- a/tests/components/guardian/conftest.py +++ b/tests/components/guardian/conftest.py @@ -1,6 +1,7 @@ """Define fixtures for Elexa Guardian tests.""" +from collections.abc import Generator import json -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest @@ -11,6 +12,15 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, load_fixture +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.guardian.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(name="config_entry") def config_entry_fixture(hass, config, unique_id): """Define a config entry fixture.""" diff --git a/tests/components/guardian/test_config_flow.py b/tests/components/guardian/test_config_flow.py index 2fdacdf29a..cb28ea22a3 100644 --- a/tests/components/guardian/test_config_flow.py +++ b/tests/components/guardian/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import patch from aioguardian.errors import GuardianError +import pytest from homeassistant import data_entry_flow from homeassistant.components import dhcp, zeroconf @@ -16,6 +17,8 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + async def test_duplicate_error( hass: HomeAssistant, config, config_entry, setup_guardian From 4591bb1823fe947e050e3aaddb7bdd35ce231d59 Mon Sep 17 00:00:00 2001 From: Jared Szechy Date: Thu, 9 Mar 2023 19:13:56 -0500 Subject: [PATCH 0817/2810] Add Ruth and Stephen voices to AWS Polly (#89344) Add Ruth and Stephen --- .../components/amazon_polly/const.py | 68 ++++++++++--------- 1 file changed, 35 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/amazon_polly/const.py b/homeassistant/components/amazon_polly/const.py index a0250938fb..e1f7afce17 100644 --- a/homeassistant/components/amazon_polly/const.py +++ b/homeassistant/components/amazon_polly/const.py @@ -34,49 +34,49 @@ CONF_TEXT_TYPE: Final = "text_type" SUPPORTED_VOICES: Final[list[str]] = [ "Aditi", # Hindi - "Amy", - "Aria", + "Amy", # English (British) + "Aria", # English (New Zealand), Neural "Arlet", # Catalan, Neural "Arthur", # English, Neural "Astrid", # Swedish - "Ayanda", + "Ayanda", # English (South African), Neural "Bianca", # Italian - "Brian", + "Brian", # English (British) "Camila", # Portuguese, Brazilian - "Carla", + "Carla", # Italian "Carmen", # Romanian - "Celine", + "Celine", # French "Chantal", # French Canadian - "Conchita", - "Cristiano", + "Conchita", # Spanish (European) + "Cristiano", # Portuguese (European) "Daniel", # German, Neural "Dora", # Icelandic "Elin", # Swedish, Neural "Emma", # English - "Enrique", - "Ewa", + "Enrique", # Spanish (European) + "Ewa", # Polish "Filiz", # Turkish - "Gabrielle", + "Gabrielle", # French (Canadian) "Geraint", # English Welsh - "Giorgio", + "Giorgio", # Italian "Gwyneth", # Welsh "Hala", # Arabic (Gulf), Neural "Hannah", # German (Austrian), Neural - "Hans", + "Hans", # German "Hiujin", # Chinese (Cantonese), Neural "Ida", # Norwegian, Neural "Ines", # Portuguese, European - "Ivy", - "Jacek", - "Jan", - "Joanna", - "Joey", - "Justin", + "Ivy", # English + "Jacek", # Polish + "Jan", # Polish + "Joanna", # English + "Joey", # English + "Justin", # English "Kajal", # English (Indian)/Hindi (Bilingual ), Neural - "Karl", - "Kendra", - "Kevin", - "Kimberly", + "Karl", # Icelandic + "Kendra", # English + "Kevin", # English, Neural + "Kimberly", # English "Laura", # Dutch, Neural "Lea", # French "Liam", # Canadian French, Neural @@ -84,12 +84,12 @@ SUPPORTED_VOICES: Final[list[str]] = [ "Lotte", # Dutch "Lucia", # Spanish European "Lupe", # Spanish US - "Mads", + "Mads", # Danish "Maja", # Polish - "Marlene", - "Mathieu", - "Matthew", - "Maxim", + "Marlene", # German + "Mathieu", # French + "Matthew", # English + "Maxim", # Russian "Mia", # Spanish Mexican "Miguel", # Spanish US "Mizuki", # Japanese @@ -100,17 +100,19 @@ SUPPORTED_VOICES: Final[list[str]] = [ "Penelope", # Spanish US "Pedro", # Spanish US, Neural "Raveena", # English, Indian - "Ricardo", - "Ruben", - "Russell", + "Ricardo", # Portuguese (Brazilian) + "Ruben", # Dutch + "Russell", # English (Australian) + "Ruth", # English, Neural "Salli", # English "Seoyeon", # Korean + "Stephen", # English, Neural "Suvi", # Finnish - "Takumi", + "Takumi", # Japanese "Tatyana", # Russian "Vicki", # German "Vitoria", # Portuguese, Brazilian - "Zeina", + "Zeina", # Arabic "Zhiyu", # Chinese ] From d828263ee3e3b813a9d864cf1fc7081360c3a237 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Fri, 10 Mar 2023 02:15:22 +0100 Subject: [PATCH 0818/2810] Add device class to ZHA Xiaomi plug "consumer connected" sensor (#89476) Add device class to ZHA Xiaomi plug "consumer connected" --- homeassistant/components/zha/binary_sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index dc5a5eebba..b6a0af8e45 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -213,3 +213,4 @@ class XiaomiPlugConsumerConnected(BinarySensor, id_suffix="consumer_connected"): SENSOR_ATTR = "consumer_connected" _attr_name: str = "Consumer connected" + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PLUG From 9e1ba8534abfc16c46706ba31c2b1bd2d0dd1c87 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Mar 2023 16:03:41 -1000 Subject: [PATCH 0819/2810] Fix data migration never finishing when database has invalid datetimes (#89474) * Fix data migration never finishing when database has invalid datetimes If there were impossible datetime values in the database (likely from a manual sqlite to MySQL conversion) the conversion would never complete * Update homeassistant/components/recorder/migration.py --- homeassistant/components/recorder/migration.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index e0f1163491..a5ff110e57 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1106,7 +1106,7 @@ def _migrate_columns_to_timestamp( result = session.connection().execute( text( "UPDATE events set time_fired_ts=" - "IF(time_fired is NULL,0," + "IF(time_fired is NULL or UNIX_TIMESTAMP(time_fired) is NULL,0," "UNIX_TIMESTAMP(time_fired)" ") " "where time_fired_ts is NULL " @@ -1119,7 +1119,7 @@ def _migrate_columns_to_timestamp( result = session.connection().execute( text( "UPDATE states set last_updated_ts=" - "IF(last_updated is NULL,0," + "IF(last_updated is NULL or UNIX_TIMESTAMP(last_updated) is NULL,0," "UNIX_TIMESTAMP(last_updated) " "), " "last_changed_ts=" @@ -1195,7 +1195,7 @@ def _migrate_statistics_columns_to_timestamp( result = session.connection().execute( text( f"UPDATE {table} set start_ts=" - "IF(start is NULL,0," + "IF(start is NULL or UNIX_TIMESTAMP(start) is NULL,0," "UNIX_TIMESTAMP(start) " "), " "created_ts=" From fde205c158b704683650cec1d5832ca0858bbd21 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 10 Mar 2023 04:32:32 +0100 Subject: [PATCH 0820/2810] Add unconfigured flag to thread discovery data (#89230) Co-authored-by: Paulus Schoutsen --- homeassistant/components/thread/discovery.py | 27 ++++- tests/components/thread/__init__.py | 106 ++++++++++++++++++ tests/components/thread/test_discovery.py | 69 +++++++++++- tests/components/thread/test_websocket_api.py | 10 +- 4 files changed, 197 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/thread/discovery.py b/homeassistant/components/thread/discovery.py index 5a2ee54c5b..b2373ff982 100644 --- a/homeassistant/components/thread/discovery.py +++ b/homeassistant/components/thread/discovery.py @@ -6,6 +6,7 @@ import dataclasses import logging from typing import cast +from python_otbr_api.mdns import StateBitmap from zeroconf import BadTypeInNameException, DNSPointer, ServiceListener, Zeroconf from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf @@ -29,14 +30,15 @@ TYPE_PTR = 12 class ThreadRouterDiscoveryData: """Thread router discovery data.""" + addresses: list[str] | None brand: str | None extended_pan_id: str | None model_name: str | None network_name: str | None server: str | None - vendor_name: str | None - addresses: list[str] | None thread_version: str | None + unconfigured: bool | None + vendor_name: str | None def async_discovery_data_from_service( @@ -59,15 +61,30 @@ def async_discovery_data_from_service( server = service.server vendor_name = try_decode(service.properties.get(b"vn")) thread_version = try_decode(service.properties.get(b"tv")) + unconfigured = None + brand = KNOWN_BRANDS.get(vendor_name) + if brand == "homeassistant": + # Attempt to detect incomplete configuration + if (state_bitmap_b := service.properties.get(b"sb")) is not None: + try: + state_bitmap = StateBitmap.from_bytes(state_bitmap_b) + if not state_bitmap.is_active: + unconfigured = True + except ValueError: + _LOGGER.debug("Failed to decode state bitmap in service %s", service) + if service.properties.get(b"at") is None: + unconfigured = True + return ThreadRouterDiscoveryData( - brand=KNOWN_BRANDS.get(vendor_name), + addresses=service.parsed_addresses(), + brand=brand, extended_pan_id=ext_pan_id.hex() if ext_pan_id is not None else None, model_name=model_name, network_name=network_name, server=server, - vendor_name=vendor_name, - addresses=service.parsed_addresses(), thread_version=thread_version, + unconfigured=unconfigured, + vendor_name=vendor_name, ) diff --git a/tests/components/thread/__init__.py b/tests/components/thread/__init__.py index c8e4453da1..fd3cc3d9d8 100644 --- a/tests/components/thread/__init__.py +++ b/tests/components/thread/__init__.py @@ -185,3 +185,109 @@ ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA = { }, "interface_index": None, } + + +ROUTER_DISCOVERY_HASS_NO_ACTIVE_TIMESTAMP = { + "type_": "_meshcop._udp.local.", + "name": "HomeAssistant OpenThreadBorderRouter #0BBF._meshcop._udp.local.", + "addresses": [b"\xc0\xa8\x00s"], + "port": 49153, + "weight": 0, + "priority": 0, + "server": "core-silabs-multiprotocol.local.", + "properties": { + b"rv": b"1", + b"vn": b"HomeAssistant", + b"mn": b"OpenThreadBorderRouter", + b"nn": b"OpenThread HC", + b"xp": b"\xe6\x0f\xc7\xc1\x86!,\xe5", + b"tv": b"1.3.0", + b"xa": b"\xae\xeb/YKW\x0b\xbf", + b"sb": b"\x00\x00\x01\xb1", + b"pt": b"\x8f\x06Q~", + b"sq": b"3", + b"bb": b"\xf0\xbf", + b"dn": b"DefaultDomain", + }, + "interface_index": None, +} + + +ROUTER_DISCOVERY_HASS_NO_STATE_BITMAP = { + "type_": "_meshcop._udp.local.", + "name": "HomeAssistant OpenThreadBorderRouter #0BBF._meshcop._udp.local.", + "addresses": [b"\xc0\xa8\x00s"], + "port": 49153, + "weight": 0, + "priority": 0, + "server": "core-silabs-multiprotocol.local.", + "properties": { + b"rv": b"1", + b"vn": b"HomeAssistant", + b"mn": b"OpenThreadBorderRouter", + b"nn": b"OpenThread HC", + b"xp": b"\xe6\x0f\xc7\xc1\x86!,\xe5", + b"tv": b"1.3.0", + b"xa": b"\xae\xeb/YKW\x0b\xbf", + b"at": b"\x00\x00\x00\x00\x00\x01\x00\x00", + b"pt": b"\x8f\x06Q~", + b"sq": b"3", + b"bb": b"\xf0\xbf", + b"dn": b"DefaultDomain", + }, + "interface_index": None, +} + + +ROUTER_DISCOVERY_HASS_BAD_STATE_BITMAP = { + "type_": "_meshcop._udp.local.", + "name": "HomeAssistant OpenThreadBorderRouter #0BBF._meshcop._udp.local.", + "addresses": [b"\xc0\xa8\x00s"], + "port": 49153, + "weight": 0, + "priority": 0, + "server": "core-silabs-multiprotocol.local.", + "properties": { + b"rv": b"1", + b"vn": b"HomeAssistant", + b"mn": b"OpenThreadBorderRouter", + b"nn": b"OpenThread HC", + b"xp": b"\xe6\x0f\xc7\xc1\x86!,\xe5", + b"tv": b"1.3.0", + b"xa": b"\xae\xeb/YKW\x0b\xbf", + b"sb": b"\xff\x00\x01\xb1", + b"at": b"\x00\x00\x00\x00\x00\x01\x00\x00", + b"pt": b"\x8f\x06Q~", + b"sq": b"3", + b"bb": b"\xf0\xbf", + b"dn": b"DefaultDomain", + }, + "interface_index": None, +} + + +ROUTER_DISCOVERY_HASS_STATE_BITMAP_NOT_ACTIVE = { + "type_": "_meshcop._udp.local.", + "name": "HomeAssistant OpenThreadBorderRouter #0BBF._meshcop._udp.local.", + "addresses": [b"\xc0\xa8\x00s"], + "port": 49153, + "weight": 0, + "priority": 0, + "server": "core-silabs-multiprotocol.local.", + "properties": { + b"rv": b"1", + b"vn": b"HomeAssistant", + b"mn": b"OpenThreadBorderRouter", + b"nn": b"OpenThread HC", + b"xp": b"\xe6\x0f\xc7\xc1\x86!,\xe5", + b"tv": b"1.3.0", + b"xa": b"\xae\xeb/YKW\x0b\xbf", + b"sb": b"\x00\x00\x01\x31", + b"at": b"\x00\x00\x00\x00\x00\x01\x00\x00", + b"pt": b"\x8f\x06Q~", + b"sq": b"3", + b"bb": b"\xf0\xbf", + b"dn": b"DefaultDomain", + }, + "interface_index": None, +} diff --git a/tests/components/thread/test_discovery.py b/tests/components/thread/test_discovery.py index fc19b3f10a..e832f18c4e 100644 --- a/tests/components/thread/test_discovery.py +++ b/tests/components/thread/test_discovery.py @@ -14,8 +14,12 @@ from . import ( ROUTER_DISCOVERY_GOOGLE_1, ROUTER_DISCOVERY_HASS, ROUTER_DISCOVERY_HASS_BAD_DATA, + ROUTER_DISCOVERY_HASS_BAD_STATE_BITMAP, ROUTER_DISCOVERY_HASS_MISSING_DATA, ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA, + ROUTER_DISCOVERY_HASS_NO_ACTIVE_TIMESTAMP, + ROUTER_DISCOVERY_HASS_NO_STATE_BITMAP, + ROUTER_DISCOVERY_HASS_STATE_BITMAP_NOT_ACTIVE, ) @@ -67,14 +71,15 @@ async def test_discover_routers(hass: HomeAssistant, mock_async_zeroconf: None) assert discovered[-1] == ( "aeeb2f594b570bbf", discovery.ThreadRouterDiscoveryData( + addresses=["192.168.0.115"], brand="homeassistant", extended_pan_id="e60fc7c186212ce5", model_name="OpenThreadBorderRouter", network_name="OpenThread HC", server="core-silabs-multiprotocol.local.", - vendor_name="HomeAssistant", thread_version="1.3.0", - addresses=["192.168.0.115"], + unconfigured=None, + vendor_name="HomeAssistant", ), ) @@ -91,14 +96,15 @@ async def test_discover_routers(hass: HomeAssistant, mock_async_zeroconf: None) assert discovered[-1] == ( "f6a99b425a67abed", discovery.ThreadRouterDiscoveryData( + addresses=["192.168.0.124"], brand="google", extended_pan_id="9e75e256f61409a3", model_name="Google Nest Hub", network_name="NEST-PAN-E1AF", server="2d99f293-cd8e-2770-8dd2-6675de9fa000.local.", - vendor_name="Google Inc.", thread_version="1.3.0", - addresses=["192.168.0.124"], + unconfigured=None, + vendor_name="Google Inc.", ), ) @@ -130,6 +136,56 @@ async def test_discover_routers(hass: HomeAssistant, mock_async_zeroconf: None) mock_async_zeroconf.async_remove_service_listener.assert_called_once_with(listener) +@pytest.mark.parametrize( + ("data", "unconfigured"), + [ + (ROUTER_DISCOVERY_HASS_NO_ACTIVE_TIMESTAMP, True), + (ROUTER_DISCOVERY_HASS_BAD_STATE_BITMAP, None), + (ROUTER_DISCOVERY_HASS_NO_STATE_BITMAP, None), + (ROUTER_DISCOVERY_HASS_STATE_BITMAP_NOT_ACTIVE, True), + ], +) +async def test_discover_routers_unconfigured( + hass: HomeAssistant, mock_async_zeroconf: None, data, unconfigured +) -> None: + """Test discovering thread routers with bad or missing vendor mDNS data.""" + mock_async_zeroconf.async_add_service_listener = AsyncMock() + mock_async_zeroconf.async_remove_service_listener = AsyncMock() + mock_async_zeroconf.async_get_service_info = AsyncMock() + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + # Start Thread router discovery + router_discovered_removed = Mock() + thread_disovery = discovery.ThreadRouterDiscovery( + hass, router_discovered_removed, router_discovered_removed + ) + await thread_disovery.async_start() + listener: discovery.ThreadRouterDiscovery.ThreadServiceListener = ( + mock_async_zeroconf.async_add_service_listener.mock_calls[0][1][1] + ) + + # Discover a service with bad or missing data + mock_async_zeroconf.async_get_service_info.return_value = AsyncServiceInfo(**data) + listener.add_service(None, data["type_"], data["name"]) + await hass.async_block_till_done() + router_discovered_removed.assert_called_once_with( + "aeeb2f594b570bbf", + discovery.ThreadRouterDiscoveryData( + addresses=["192.168.0.115"], + brand="homeassistant", + extended_pan_id="e60fc7c186212ce5", + model_name="OpenThreadBorderRouter", + network_name="OpenThread HC", + server="core-silabs-multiprotocol.local.", + thread_version="1.3.0", + unconfigured=unconfigured, + vendor_name="HomeAssistant", + ), + ) + + @pytest.mark.parametrize( "data", (ROUTER_DISCOVERY_HASS_BAD_DATA, ROUTER_DISCOVERY_HASS_MISSING_DATA) ) @@ -161,14 +217,15 @@ async def test_discover_routers_bad_data( router_discovered_removed.assert_called_once_with( "aeeb2f594b570bbf", discovery.ThreadRouterDiscoveryData( + addresses=["192.168.0.115"], brand=None, extended_pan_id="e60fc7c186212ce5", model_name="OpenThreadBorderRouter", network_name="OpenThread HC", server="core-silabs-multiprotocol.local.", - vendor_name=None, thread_version="1.3.0", - addresses=["192.168.0.115"], + unconfigured=None, + vendor_name=None, ), ) diff --git a/tests/components/thread/test_websocket_api.py b/tests/components/thread/test_websocket_api.py index 2ebeef92c5..0f3a2ff765 100644 --- a/tests/components/thread/test_websocket_api.py +++ b/tests/components/thread/test_websocket_api.py @@ -234,14 +234,15 @@ async def test_discover_routers( assert msg == { "event": { "data": { + "addresses": ["192.168.0.115"], "brand": "homeassistant", "extended_pan_id": "e60fc7c186212ce5", "model_name": "OpenThreadBorderRouter", "network_name": "OpenThread HC", "server": "core-silabs-multiprotocol.local.", - "vendor_name": "HomeAssistant", - "addresses": ["192.168.0.115"], "thread_version": "1.3.0", + "unconfigured": None, + "vendor_name": "HomeAssistant", }, "key": "aeeb2f594b570bbf", "type": "router_discovered", @@ -261,14 +262,15 @@ async def test_discover_routers( assert msg == { "event": { "data": { + "addresses": ["192.168.0.124"], "brand": "google", "extended_pan_id": "9e75e256f61409a3", "model_name": "Google Nest Hub", "network_name": "NEST-PAN-E1AF", "server": "2d99f293-cd8e-2770-8dd2-6675de9fa000.local.", - "vendor_name": "Google Inc.", "thread_version": "1.3.0", - "addresses": ["192.168.0.124"], + "unconfigured": None, + "vendor_name": "Google Inc.", }, "key": "f6a99b425a67abed", "type": "router_discovered", From b8bda93d87f35d7862d0014f5a0e876cb2556a42 Mon Sep 17 00:00:00 2001 From: Thijs W Date: Fri, 10 Mar 2023 10:26:03 +0100 Subject: [PATCH 0821/2810] Add config flow to frontier_silicon (#64365) * Add config_flow to frontier_silicon * Add missing translation file * Delay unique_id validation until radio_id can be determined * Fix tests * Improve tests * Use FlowResultType * Bump afsapi to 0.2.6 * Fix requirements_test_all.txt * Stash ssdp, reauth and unignore flows for now * Re-introduce SSDP flow * hassfest changes * Address review comments * Small style update * Fix tests * Update integrations.json * fix order in manifest.json * fix black errors * Apply suggestions from code review Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Address review comments * fix black errors * Use async_setup_platform instead of async_setup * Address review comments on tests * parameterize tests * Remove discovery component changes from this PR * Address review comments * Apply suggestions from code review Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Add extra asserts to tests * Restructure _async_step_device_config_if_needed * Add return statement * Update homeassistant/components/frontier_silicon/media_player.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .coveragerc | 3 +- CODEOWNERS | 1 + .../components/frontier_silicon/__init__.py | 46 ++- .../frontier_silicon/config_flow.py | 178 ++++++++++++ .../components/frontier_silicon/const.py | 3 + .../components/frontier_silicon/manifest.json | 1 + .../frontier_silicon/media_player.py | 57 ++-- .../components/frontier_silicon/strings.json | 35 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/frontier_silicon/__init__.py | 1 + tests/components/frontier_silicon/conftest.py | 59 ++++ .../frontier_silicon/test_config_flow.py | 266 ++++++++++++++++++ 14 files changed, 636 insertions(+), 20 deletions(-) create mode 100644 homeassistant/components/frontier_silicon/config_flow.py create mode 100644 homeassistant/components/frontier_silicon/strings.json create mode 100644 tests/components/frontier_silicon/__init__.py create mode 100644 tests/components/frontier_silicon/conftest.py create mode 100644 tests/components/frontier_silicon/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index fa6ae5ba0d..a533343bf0 100644 --- a/.coveragerc +++ b/.coveragerc @@ -395,7 +395,8 @@ omit = homeassistant/components/fritzbox_callmonitor/__init__.py homeassistant/components/fritzbox_callmonitor/base.py homeassistant/components/fritzbox_callmonitor/sensor.py - homeassistant/components/frontier_silicon/const.py + homeassistant/components/frontier_silicon/__init__.py + homeassistant/components/frontier_silicon/browse_media.py homeassistant/components/frontier_silicon/media_player.py homeassistant/components/futurenow/light.py homeassistant/components/garadget/cover.py diff --git a/CODEOWNERS b/CODEOWNERS index 9020d229fe..f95d89fec4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -401,6 +401,7 @@ build.json @home-assistant/supervisor /homeassistant/components/frontend/ @home-assistant/frontend /tests/components/frontend/ @home-assistant/frontend /homeassistant/components/frontier_silicon/ @wlcrs +/tests/components/frontier_silicon/ @wlcrs /homeassistant/components/fully_kiosk/ @cgarwood /tests/components/fully_kiosk/ @cgarwood /homeassistant/components/garages_amsterdam/ @klaasnicolaas diff --git a/homeassistant/components/frontier_silicon/__init__.py b/homeassistant/components/frontier_silicon/__init__.py index ddd74ca8ef..4a884063f8 100644 --- a/homeassistant/components/frontier_silicon/__init__.py +++ b/homeassistant/components/frontier_silicon/__init__.py @@ -1 +1,45 @@ -"""The frontier_silicon component.""" +"""The Frontier Silicon integration.""" +from __future__ import annotations + +import logging + +from afsapi import AFSAPI, ConnectionError as FSConnectionError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady + +from .const import CONF_PIN, CONF_WEBFSAPI_URL, DOMAIN + +PLATFORMS = [Platform.MEDIA_PLAYER] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Frontier Silicon from a config entry.""" + + webfsapi_url = entry.data[CONF_WEBFSAPI_URL] + pin = entry.data[CONF_PIN] + + afsapi = AFSAPI(webfsapi_url, pin) + + try: + await afsapi.get_power() + except FSConnectionError as exception: + raise PlatformNotReady from exception + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = afsapi + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py new file mode 100644 index 0000000000..5e9472de62 --- /dev/null +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -0,0 +1,178 @@ +"""Config flow for Frontier Silicon Media Player integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from afsapi import AFSAPI, ConnectionError as FSConnectionError, InvalidPinException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.data_entry_flow import FlowResult + +from .const import CONF_PIN, CONF_WEBFSAPI_URL, DEFAULT_PIN, DEFAULT_PORT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + } +) + +STEP_DEVICE_CONFIG_DATA_SCHEMA = vol.Schema( + { + vol.Required( + CONF_PIN, + default=DEFAULT_PIN, + ): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Frontier Silicon Media Player.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize flow.""" + + self._webfsapi_url: str | None = None + self._name: str | None = None + self._unique_id: str | None = None + + async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult: + """Handle the import of legacy configuration.yaml entries.""" + + device_url = f"http://{import_info[CONF_HOST]}:{import_info[CONF_PORT]}/device" + try: + self._webfsapi_url = await AFSAPI.get_webfsapi_endpoint(device_url) + except FSConnectionError: + return self.async_abort(reason="cannot_connect") + except Exception as exception: # pylint: disable=broad-except + _LOGGER.exception(exception) + return self.async_abort(reason="unknown") + + try: + afsapi = AFSAPI(self._webfsapi_url, import_info[CONF_PIN]) + + self._unique_id = await afsapi.get_radio_id() + except FSConnectionError: + return self.async_abort(reason="cannot_connect") + except InvalidPinException: + return self.async_abort(reason="invalid_auth") + except Exception as exception: # pylint: disable=broad-except + _LOGGER.exception(exception) + return self.async_abort(reason="unknown") + + await self.async_set_unique_id(self._unique_id, raise_on_progress=False) + self._abort_if_unique_id_configured() + + self._name = import_info[CONF_NAME] or "Radio" + + return await self._create_entry(pin=import_info[CONF_PIN]) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step of manual configuration.""" + errors = {} + + if user_input: + device_url = ( + f"http://{user_input[CONF_HOST]}:{user_input[CONF_PORT]}/device" + ) + try: + self._webfsapi_url = await AFSAPI.get_webfsapi_endpoint(device_url) + except FSConnectionError: + errors["base"] = "cannot_connect" + except Exception as exception: # pylint: disable=broad-except + _LOGGER.exception(exception) + errors["base"] = "unknown" + else: + return await self._async_step_device_config_if_needed() + + data_schema = self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ) + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) + + async def _async_step_device_config_if_needed(self) -> FlowResult: + """Most users will not have changed the default PIN on their radio. + + We try to use this default PIN, and only if this fails ask for it via `async_step_device_config` + """ + + try: + # try to login with default pin + afsapi = AFSAPI(self._webfsapi_url, DEFAULT_PIN) + + self._name = await afsapi.get_friendly_name() + except InvalidPinException: + # Ask for a PIN + return await self.async_step_device_config() + + self.context["title_placeholders"] = {"name": self._name} + + self._unique_id = await afsapi.get_radio_id() + await self.async_set_unique_id(self._unique_id) + self._abort_if_unique_id_configured() + + return await self._create_entry() + + async def async_step_device_config( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle device configuration step. + + We ask for the PIN in this step. + """ + assert self._webfsapi_url is not None + + if user_input is None: + return self.async_show_form( + step_id="device_config", data_schema=STEP_DEVICE_CONFIG_DATA_SCHEMA + ) + + errors = {} + + try: + afsapi = AFSAPI(self._webfsapi_url, user_input[CONF_PIN]) + + self._name = await afsapi.get_friendly_name() + + except FSConnectionError: + errors["base"] = "cannot_connect" + except InvalidPinException: + errors["base"] = "invalid_auth" + except Exception as exception: # pylint: disable=broad-except + _LOGGER.exception(exception) + errors["base"] = "unknown" + else: + self._unique_id = await afsapi.get_radio_id() + await self.async_set_unique_id(self._unique_id) + self._abort_if_unique_id_configured() + return await self._create_entry(pin=user_input[CONF_PIN]) + + data_schema = self.add_suggested_values_to_schema( + STEP_DEVICE_CONFIG_DATA_SCHEMA, user_input + ) + return self.async_show_form( + step_id="device_config", + data_schema=data_schema, + errors=errors, + ) + + async def _create_entry(self, pin: str | None = None) -> FlowResult: + """Create the entry.""" + assert self._name is not None + assert self._webfsapi_url is not None + + data = {CONF_WEBFSAPI_URL: self._webfsapi_url, CONF_PIN: pin or DEFAULT_PIN} + + return self.async_create_entry(title=self._name, data=data) diff --git a/homeassistant/components/frontier_silicon/const.py b/homeassistant/components/frontier_silicon/const.py index 9ee17c0320..9206db8916 100644 --- a/homeassistant/components/frontier_silicon/const.py +++ b/homeassistant/components/frontier_silicon/const.py @@ -1,6 +1,9 @@ """Constants for the Frontier Silicon Media Player integration.""" DOMAIN = "frontier_silicon" +CONF_WEBFSAPI_URL = "webfsapi_url" +CONF_PIN = "pin" + DEFAULT_PIN = "1234" DEFAULT_PORT = 80 diff --git a/homeassistant/components/frontier_silicon/manifest.json b/homeassistant/components/frontier_silicon/manifest.json index 322c1b90b2..62e7e61703 100644 --- a/homeassistant/components/frontier_silicon/manifest.json +++ b/homeassistant/components/frontier_silicon/manifest.json @@ -2,6 +2,7 @@ "domain": "frontier_silicon", "name": "Frontier Silicon", "codeowners": ["@wlcrs"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/frontier_silicon", "iot_class": "local_polling", "requirements": ["afsapi==0.2.7"] diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index 0e3eb16848..b05ba272a1 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -21,15 +21,17 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .browse_media import browse_node, browse_top_level -from .const import DEFAULT_PIN, DEFAULT_PORT, DOMAIN, MEDIA_CONTENT_ID_PRESET +from .const import CONF_PIN, DEFAULT_PIN, DEFAULT_PORT, DOMAIN, MEDIA_CONTENT_ID_PRESET _LOGGER = logging.getLogger(__name__) @@ -49,7 +51,11 @@ async def async_setup_platform( async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Frontier Silicon platform.""" + """Set up the Frontier Silicon platform. + + YAML is deprecated, and imported automatically. + SSDP discovery is temporarily retained - to be refactor subsequently. + """ if discovery_info is not None: webfsapi_url = await AFSAPI.get_webfsapi_endpoint( discovery_info["ssdp_description"] @@ -61,24 +67,41 @@ async def async_setup_platform( [AFSAPIDevice(name, afsapi)], True, ) + return - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - password = config.get(CONF_PASSWORD) - name = config.get(CONF_NAME) + ir.async_create_issue( + hass, + DOMAIN, + "remove_yaml", + breaks_in_ha_version="2023.6.0", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="removed_yaml", + ) - try: - webfsapi_url = await AFSAPI.get_webfsapi_endpoint( - f"http://{host}:{port}/device" - ) - except FSConnectionError: - _LOGGER.error( - "Could not add the FSAPI device at %s:%s -> %s", host, port, password - ) - return - afsapi = AFSAPI(webfsapi_url, password) - async_add_entities([AFSAPIDevice(name, afsapi)], True) + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_NAME: config.get(CONF_NAME), + CONF_HOST: config.get(CONF_HOST), + CONF_PORT: config.get(CONF_PORT, DEFAULT_PORT), + CONF_PIN: config.get(CONF_PASSWORD, DEFAULT_PIN), + }, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Frontier Silicon entity.""" + + afsapi: AFSAPI = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities([AFSAPIDevice(config_entry.title, afsapi)], True) class AFSAPIDevice(MediaPlayerEntity): diff --git a/homeassistant/components/frontier_silicon/strings.json b/homeassistant/components/frontier_silicon/strings.json new file mode 100644 index 0000000000..85b0b6958a --- /dev/null +++ b/homeassistant/components/frontier_silicon/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "title": "Frontier Silicon Setup", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + } + }, + "device_config": { + "title": "Device Configuration", + "description": "The pin can be found via 'MENU button > Main Menu > System setting > Network > NetRemote PIN setup'", + "data": { + "pin": "[%key:common::config_flow::data::pin%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "issues": { + "removed_yaml": { + "title": "The Frontier Silicon YAML configuration has been removed", + "description": "Configuring Frontier Silicon using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 8e13dd971e..6656972f8b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -145,6 +145,7 @@ FLOWS = { "fritzbox", "fritzbox_callmonitor", "fronius", + "frontier_silicon", "fully_kiosk", "garages_amsterdam", "gdacs", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e8350284f1..9742af1edf 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1818,7 +1818,7 @@ "frontier_silicon": { "name": "Frontier Silicon", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "fully_kiosk": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a241892ff5..1dfb0466be 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -78,6 +78,9 @@ adguardhome==0.6.1 # homeassistant.components.advantage_air advantage_air==0.4.1 +# homeassistant.components.frontier_silicon +afsapi==0.2.7 + # homeassistant.components.agent_dvr agent-py==0.0.23 diff --git a/tests/components/frontier_silicon/__init__.py b/tests/components/frontier_silicon/__init__.py new file mode 100644 index 0000000000..6a039dc29a --- /dev/null +++ b/tests/components/frontier_silicon/__init__.py @@ -0,0 +1 @@ +"""Tests for the Frontier Silicon integration.""" diff --git a/tests/components/frontier_silicon/conftest.py b/tests/components/frontier_silicon/conftest.py new file mode 100644 index 0000000000..40a6df8531 --- /dev/null +++ b/tests/components/frontier_silicon/conftest.py @@ -0,0 +1,59 @@ +"""Configuration for frontier_silicon tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.frontier_silicon.const import ( + CONF_PIN, + CONF_WEBFSAPI_URL, + DOMAIN, +) + +from tests.common import MockConfigEntry + + +@pytest.fixture +def config_entry() -> MockConfigEntry: + """Create a mock Frontier Silicon config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="mock_radio_id", + data={CONF_WEBFSAPI_URL: "http://1.1.1.1:80/webfsapi", CONF_PIN: "1234"}, + ) + + +@pytest.fixture(autouse=True) +def mock_valid_device_url() -> Generator[None, None, None]: + """Return a valid webfsapi endpoint.""" + with patch( + "afsapi.AFSAPI.get_webfsapi_endpoint", + return_value="http://1.1.1.1:80/webfsapi", + ): + yield + + +@pytest.fixture(autouse=True) +def mock_valid_pin() -> Generator[None, None, None]: + """Make get_friendly_name return a value, indicating a valid pin.""" + with patch( + "afsapi.AFSAPI.get_friendly_name", + return_value="Name of the device", + ): + yield + + +@pytest.fixture(autouse=True) +def mock_radio_id() -> Generator[None, None, None]: + """Return a valid radio_id.""" + with patch("afsapi.AFSAPI.get_radio_id", return_value="mock_radio_id"): + yield + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.frontier_silicon.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/frontier_silicon/test_config_flow.py b/tests/components/frontier_silicon/test_config_flow.py new file mode 100644 index 0000000000..a643b121c7 --- /dev/null +++ b/tests/components/frontier_silicon/test_config_flow.py @@ -0,0 +1,266 @@ +"""Test the Frontier Silicon config flow.""" +from unittest.mock import AsyncMock, patch + +from afsapi import ConnectionError, InvalidPinException +import pytest + +from homeassistant import config_entries +from homeassistant.components.frontier_silicon.const import CONF_WEBFSAPI_URL, DOMAIN +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PIN, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_import_success(hass: HomeAssistant) -> None: + """Test successful import.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + CONF_PIN: "1234", + CONF_NAME: "Test name", + }, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Test name" + assert result["data"] == { + CONF_WEBFSAPI_URL: "http://1.1.1.1:80/webfsapi", + CONF_PIN: "1234", + } + + +@pytest.mark.parametrize( + ("webfsapi_endpoint_error", "result_reason"), + [ + (ConnectionError, "cannot_connect"), + (ValueError, "unknown"), + ], +) +async def test_import_webfsapi_endpoint_failures( + hass: HomeAssistant, webfsapi_endpoint_error: Exception, result_reason: str +) -> None: + """Test various failure of get_webfsapi_endpoint.""" + with patch( + "afsapi.AFSAPI.get_webfsapi_endpoint", + side_effect=webfsapi_endpoint_error, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + CONF_PIN: "1234", + CONF_NAME: "Test name", + }, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == result_reason + + +@pytest.mark.parametrize( + ("radio_id_error", "result_reason"), + [ + (ConnectionError, "cannot_connect"), + (InvalidPinException, "invalid_auth"), + (ValueError, "unknown"), + ], +) +async def test_import_radio_id_failures( + hass: HomeAssistant, radio_id_error: Exception, result_reason: str +) -> None: + """Test various failure of get_radio_id.""" + with patch( + "afsapi.AFSAPI.get_radio_id", + side_effect=radio_id_error, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + CONF_PIN: "1234", + CONF_NAME: "Test name", + }, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == result_reason + + +async def test_import_already_exists( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test import of device which already exists.""" + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + CONF_PIN: "1234", + CONF_NAME: "Test name", + }, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_form_default_pin( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test manual device add with default pin.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_PORT: 80}, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Name of the device" + assert result2["data"] == { + CONF_WEBFSAPI_URL: "http://1.1.1.1:80/webfsapi", + CONF_PIN: "1234", + } + mock_setup_entry.assert_called_once() + + +async def test_form_nondefault_pin( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "afsapi.AFSAPI.get_friendly_name", + side_effect=InvalidPinException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_PORT: 80}, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "device_config" + assert result2["errors"] is None + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_PIN: "4321"}, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "Name of the device" + assert result3["data"] == { + "webfsapi_url": "http://1.1.1.1:80/webfsapi", + "pin": "4321", + } + mock_setup_entry.assert_called_once() + + +@pytest.mark.parametrize( + ("friendly_name_error", "result_error"), + [ + (ConnectionError, "cannot_connect"), + (InvalidPinException, "invalid_auth"), + (ValueError, "unknown"), + ], +) +async def test_form_nondefault_pin_invalid( + hass: HomeAssistant, friendly_name_error: Exception, result_error: str +) -> None: + """Test we get the proper errors when trying to validate an user-provided PIN.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "afsapi.AFSAPI.get_friendly_name", + side_effect=InvalidPinException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_PORT: 80}, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "device_config" + assert result2["errors"] is None + + with patch( + "afsapi.AFSAPI.get_friendly_name", + side_effect=friendly_name_error, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_PIN: "4321"}, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.FORM + assert result2["step_id"] == "device_config" + assert result3["errors"] == {"base": result_error} + + +@pytest.mark.parametrize( + ("webfsapi_endpoint_error", "result_error"), + [ + (ConnectionError, "cannot_connect"), + (ValueError, "unknown"), + ], +) +async def test_invalid_device_url( + hass: HomeAssistant, webfsapi_endpoint_error: Exception, result_error: str +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "afsapi.AFSAPI.get_webfsapi_endpoint", + side_effect=webfsapi_endpoint_error, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_PORT: 80}, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": result_error} From a0f725dfcb1b640564f0939e3298b2936da1a930 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Mar 2023 12:06:50 +0100 Subject: [PATCH 0822/2810] Add type hints to tests (#89497) --- tests/components/ecobee/test_climate.py | 8 +++--- tests/components/energyzero/test_sensor.py | 1 - tests/components/hassio/test_ingress.py | 2 +- .../homeassistant_alerts/test_init.py | 2 +- .../homematicip_cloud/test_helpers.py | 2 +- .../components/homematicip_cloud/test_lock.py | 11 ++++++-- tests/components/pjlink/test_media_player.py | 28 +++++++++++-------- tests/components/prosegur/test_camera.py | 21 +++++++++++--- tests/components/prosegur/test_diagnostics.py | 11 ++++++-- tests/components/recorder/test_statistics.py | 8 +++--- tests/components/template/test_cover.py | 2 +- tests/components/tibber/test_config_flow.py | 8 ++++-- tests/components/todoist/test_calendar.py | 4 ++- tests/components/twentemilieu/test_sensor.py | 1 - .../components/universal/test_media_player.py | 4 +-- .../components/weather/test_websocket_api.py | 6 +++- tests/components/zha/test_gateway.py | 4 +-- tests/components/zha/test_registries.py | 4 +-- tests/components/zwave_js/test_api.py | 16 ++++++----- tests/components/zwave_js/test_discovery.py | 8 ++++-- tests/helpers/test_service.py | 16 +++++------ tests/test_core.py | 2 +- tests/util/yaml/test_init.py | 6 ++-- 23 files changed, 109 insertions(+), 66 deletions(-) diff --git a/tests/components/ecobee/test_climate.py b/tests/components/ecobee/test_climate.py index 75722d68c0..09b127432d 100644 --- a/tests/components/ecobee/test_climate.py +++ b/tests/components/ecobee/test_climate.py @@ -86,7 +86,7 @@ async def test_name(thermostat) -> None: assert thermostat.name == "Ecobee" -async def test_aux_heat_not_supported_by_default(hass): +async def test_aux_heat_not_supported_by_default(hass: HomeAssistant) -> None: """Default setup should not support Aux heat.""" await setup_platform(hass, const.Platform.CLIMATE) state = hass.states.get(ENTITY_ID) @@ -100,7 +100,7 @@ async def test_aux_heat_not_supported_by_default(hass): ) -async def test_aux_heat_supported_with_heat_pump(hass): +async def test_aux_heat_supported_with_heat_pump(hass: HomeAssistant) -> None: """Aux Heat should be supported if thermostat has heatpump.""" mock_get_thermostat = mock.Mock() mock_get_thermostat.return_value = GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP @@ -242,7 +242,7 @@ async def test_extra_state_attributes(ecobee_fixture, thermostat) -> None: } == thermostat.extra_state_attributes -async def test_is_aux_heat_on(hass): +async def test_is_aux_heat_on(hass: HomeAssistant) -> None: """Test aux heat property is only enabled for auxHeatOnly.""" mock_get_thermostat = mock.Mock() mock_get_thermostat.return_value = copy.deepcopy( @@ -255,7 +255,7 @@ async def test_is_aux_heat_on(hass): assert state.attributes[climate.ATTR_AUX_HEAT] == "on" -async def test_is_aux_heat_off(hass): +async def test_is_aux_heat_off(hass: HomeAssistant) -> None: """Test aux heat property is only enabled for auxHeatOnly.""" mock_get_thermostat = mock.Mock() mock_get_thermostat.return_value = GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP diff --git a/tests/components/energyzero/test_sensor.py b/tests/components/energyzero/test_sensor.py index 4e961d1a68..466e754df2 100644 --- a/tests/components/energyzero/test_sensor.py +++ b/tests/components/energyzero/test_sensor.py @@ -1,5 +1,4 @@ """Tests for the sensors provided by the EnergyZero integration.""" - from unittest.mock import MagicMock from energyzero import EnergyZeroNoDataError diff --git a/tests/components/hassio/test_ingress.py b/tests/components/hassio/test_ingress.py index 67548a19c2..06b7523614 100644 --- a/tests/components/hassio/test_ingress.py +++ b/tests/components/hassio/test_ingress.py @@ -335,7 +335,7 @@ async def test_ingress_missing_peername( async def test_forwarding_paths_as_requested( - hassio_noauth_client, aioclient_mock + hassio_noauth_client, aioclient_mock: AiohttpClientMocker ) -> None: """Test incomnig URLs with double encoding go out as dobule encoded.""" # This double encoded string should be forwarded double-encoded too. diff --git a/tests/components/homeassistant_alerts/test_init.py b/tests/components/homeassistant_alerts/test_init.py index f5e040aa38..36f0cad758 100644 --- a/tests/components/homeassistant_alerts/test_init.py +++ b/tests/components/homeassistant_alerts/test_init.py @@ -283,7 +283,7 @@ async def test_alerts( ) async def test_alerts_refreshed_on_component_load( hass: HomeAssistant, - hass_ws_client, + hass_ws_client: WebSocketGenerator, aioclient_mock: AiohttpClientMocker, ha_version, supervisor_info, diff --git a/tests/components/homematicip_cloud/test_helpers.py b/tests/components/homematicip_cloud/test_helpers.py index 85c16255d7..40ce5e536b 100644 --- a/tests/components/homematicip_cloud/test_helpers.py +++ b/tests/components/homematicip_cloud/test_helpers.py @@ -5,7 +5,7 @@ import json from homeassistant.components.homematicip_cloud.helpers import is_error_response -async def test_is_error_response(): +async def test_is_error_response() -> None: """Test, if an response is a normal result or an error.""" assert not is_error_response("True") assert not is_error_response(True) diff --git a/tests/components/homematicip_cloud/test_lock.py b/tests/components/homematicip_cloud/test_lock.py index 48ae02738a..61457fd511 100644 --- a/tests/components/homematicip_cloud/test_lock.py +++ b/tests/components/homematicip_cloud/test_lock.py @@ -12,13 +12,14 @@ from homeassistant.components.lock import ( LockEntityFeature, ) from homeassistant.const import ATTR_SUPPORTED_FEATURES +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from .helper import async_manipulate_test_data, get_and_check_entity_basics -async def test_manually_configured_platform(hass): +async def test_manually_configured_platform(hass: HomeAssistant) -> None: """Test that we do not set up an access point.""" assert await async_setup_component( hass, DOMAIN, {DOMAIN: {"platform": HMIPC_DOMAIN}} @@ -26,7 +27,9 @@ async def test_manually_configured_platform(hass): assert not hass.data.get(HMIPC_DOMAIN) -async def test_hmip_doorlockdrive(hass, default_mock_hap_factory): +async def test_hmip_doorlockdrive( + hass: HomeAssistant, default_mock_hap_factory +) -> None: """Test HomematicipDoorLockDrive.""" entity_id = "lock.haustuer" entity_name = "Haustuer" @@ -82,7 +85,9 @@ async def test_hmip_doorlockdrive(hass, default_mock_hap_factory): assert ha_state.state == STATE_UNLOCKING -async def test_hmip_doorlockdrive_handle_errors(hass, default_mock_hap_factory): +async def test_hmip_doorlockdrive_handle_errors( + hass: HomeAssistant, default_mock_hap_factory +) -> None: """Test HomematicipDoorLockDrive.""" entity_id = "lock.haustuer" entity_name = "Haustuer" diff --git a/tests/components/pjlink/test_media_player.py b/tests/components/pjlink/test_media_player.py index c4a923c16e..686ece5b7e 100644 --- a/tests/components/pjlink/test_media_player.py +++ b/tests/components/pjlink/test_media_player.py @@ -1,5 +1,4 @@ """Test the pjlink media player platform.""" - from datetime import timedelta import socket from unittest.mock import create_autospec, patch @@ -11,6 +10,7 @@ import pytest import homeassistant.components.media_player as media_player from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt @@ -48,7 +48,9 @@ def mocked_projector(projector_from_address): @pytest.mark.parametrize("side_effect", [socket.timeout, OSError]) -async def test_offline_initialization(projector_from_address, hass, side_effect): +async def test_offline_initialization( + projector_from_address, hass: HomeAssistant, side_effect +) -> None: """Test initialization of a device that is offline.""" with assert_setup_component(1, media_player.DOMAIN): @@ -71,7 +73,7 @@ async def test_offline_initialization(projector_from_address, hass, side_effect) assert state.state == "unavailable" -async def test_initialization(projector_from_address, hass): +async def test_initialization(projector_from_address, hass: HomeAssistant) -> None: """Test a device that is available.""" with assert_setup_component(1, media_player.DOMAIN): @@ -108,7 +110,9 @@ async def test_initialization(projector_from_address, hass): @pytest.mark.parametrize("power_state", ["on", "warm-up"]) -async def test_on_state_init(projector_from_address, hass, power_state): +async def test_on_state_init( + projector_from_address, hass: HomeAssistant, power_state +) -> None: """Test a device that is available.""" with assert_setup_component(1, media_player.DOMAIN): @@ -139,7 +143,7 @@ async def test_on_state_init(projector_from_address, hass, power_state): assert state.attributes["source"] == "HDMI 1" -async def test_api_error(projector_from_address, hass): +async def test_api_error(projector_from_address, hass: HomeAssistant) -> None: """Test invalid api responses.""" with assert_setup_component(1, media_player.DOMAIN): @@ -171,7 +175,7 @@ async def test_api_error(projector_from_address, hass): assert state.state == "off" -async def test_update_unavailable(projector_from_address, hass): +async def test_update_unavailable(projector_from_address, hass: HomeAssistant) -> None: """Test update to a device that is unavailable.""" with assert_setup_component(1, media_player.DOMAIN): @@ -209,7 +213,7 @@ async def test_update_unavailable(projector_from_address, hass): assert state.state == "unavailable" -async def test_unavailable_time(mocked_projector, hass): +async def test_unavailable_time(mocked_projector, hass: HomeAssistant) -> None: """Test unavailable time projector error.""" assert await async_setup_component( @@ -240,7 +244,7 @@ async def test_unavailable_time(mocked_projector, hass): assert "is_volume_muted" not in state.attributes -async def test_turn_off(mocked_projector, hass): +async def test_turn_off(mocked_projector, hass: HomeAssistant) -> None: """Test turning off beamer.""" assert await async_setup_component( @@ -265,7 +269,7 @@ async def test_turn_off(mocked_projector, hass): mocked_projector.set_power.assert_called_with("off") -async def test_turn_on(mocked_projector, hass): +async def test_turn_on(mocked_projector, hass: HomeAssistant) -> None: """Test turning on beamer.""" assert await async_setup_component( @@ -290,7 +294,7 @@ async def test_turn_on(mocked_projector, hass): mocked_projector.set_power.assert_called_with("on") -async def test_mute(mocked_projector, hass): +async def test_mute(mocked_projector, hass: HomeAssistant) -> None: """Test muting beamer.""" assert await async_setup_component( @@ -315,7 +319,7 @@ async def test_mute(mocked_projector, hass): mocked_projector.set_mute.assert_called_with(MUTE_AUDIO, True) -async def test_unmute(mocked_projector, hass): +async def test_unmute(mocked_projector, hass: HomeAssistant) -> None: """Test unmuting beamer.""" assert await async_setup_component( @@ -340,7 +344,7 @@ async def test_unmute(mocked_projector, hass): mocked_projector.set_mute.assert_called_with(MUTE_AUDIO, False) -async def test_select_source(mocked_projector, hass): +async def test_select_source(mocked_projector, hass: HomeAssistant) -> None: """Test selecting source.""" assert await async_setup_component( diff --git a/tests/components/prosegur/test_camera.py b/tests/components/prosegur/test_camera.py index 40ab57e088..ba2e478f5c 100644 --- a/tests/components/prosegur/test_camera.py +++ b/tests/components/prosegur/test_camera.py @@ -9,10 +9,11 @@ from homeassistant.components import camera from homeassistant.components.camera import Image from homeassistant.components.prosegur.const import DOMAIN from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -async def test_camera(hass, init_integration): +async def test_camera(hass: HomeAssistant, init_integration) -> None: """Test prosegur get_image.""" image = await camera.async_get_image(hass, "camera.test_cam") @@ -20,7 +21,12 @@ async def test_camera(hass, init_integration): assert image == Image(content_type="image/jpeg", content=b"ABC") -async def test_camera_fail(hass, init_integration, mock_install, caplog): +async def test_camera_fail( + hass: HomeAssistant, + init_integration, + mock_install, + caplog: pytest.LogCaptureFixture, +) -> None: """Test prosegur get_image fails.""" mock_install.get_image = AsyncMock( @@ -37,7 +43,9 @@ async def test_camera_fail(hass, init_integration, mock_install, caplog): assert "Image test_cam doesn't exist" in caplog.text -async def test_request_image(hass, init_integration, mock_install): +async def test_request_image( + hass: HomeAssistant, init_integration, mock_install +) -> None: """Test the camera request image service.""" await hass.services.async_call( @@ -50,7 +58,12 @@ async def test_request_image(hass, init_integration, mock_install): assert mock_install.request_image.called -async def test_request_image_fail(hass, init_integration, mock_install, caplog): +async def test_request_image_fail( + hass: HomeAssistant, + init_integration, + mock_install, + caplog: pytest.LogCaptureFixture, +) -> None: """Test the camera request image service fails.""" mock_install.request_image = AsyncMock(side_effect=ProsegurException()) diff --git a/tests/components/prosegur/test_diagnostics.py b/tests/components/prosegur/test_diagnostics.py index 85377833a7..daa92de1aa 100644 --- a/tests/components/prosegur/test_diagnostics.py +++ b/tests/components/prosegur/test_diagnostics.py @@ -1,11 +1,18 @@ """Test Prosegur diagnostics.""" - from unittest.mock import patch +from homeassistant.core import HomeAssistant + from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator -async def test_diagnostics(hass, hass_client, init_integration, mock_install): +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration, + mock_install, +) -> None: """Test generating diagnostics for a config entry.""" with patch( diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index e6ae291264..7c064a03ed 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -1805,7 +1805,7 @@ def record_states(hass): return zero, four, states -def test_cache_key_for_generate_statistics_during_period_stmt(): +def test_cache_key_for_generate_statistics_during_period_stmt() -> None: """Test cache key for _generate_statistics_during_period_stmt.""" columns = select(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start_ts) stmt = _generate_statistics_during_period_stmt( @@ -1835,7 +1835,7 @@ def test_cache_key_for_generate_statistics_during_period_stmt(): assert cache_key_1 != cache_key_3 -def test_cache_key_for_generate_get_metadata_stmt(): +def test_cache_key_for_generate_get_metadata_stmt() -> None: """Test cache key for _generate_get_metadata_stmt.""" stmt_mean = _generate_get_metadata_stmt([0], "mean") stmt_mean2 = _generate_get_metadata_stmt([1], "mean") @@ -1846,7 +1846,7 @@ def test_cache_key_for_generate_get_metadata_stmt(): assert stmt_mean._generate_cache_key() != stmt_none._generate_cache_key() -def test_cache_key_for_generate_max_mean_min_statistic_in_sub_period_stmt(): +def test_cache_key_for_generate_max_mean_min_statistic_in_sub_period_stmt() -> None: """Test cache key for _generate_max_mean_min_statistic_in_sub_period_stmt.""" columns = select(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start_ts) stmt = _generate_max_mean_min_statistic_in_sub_period_stmt( @@ -1883,7 +1883,7 @@ def test_cache_key_for_generate_max_mean_min_statistic_in_sub_period_stmt(): assert cache_key_1 != cache_key_3 -def test_cache_key_for_generate_statistics_at_time_stmt(): +def test_cache_key_for_generate_statistics_at_time_stmt() -> None: """Test cache key for _generate_statistics_at_time_stmt.""" columns = select(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start_ts) stmt = _generate_statistics_at_time_stmt(columns, StatisticsShortTerm, {0}, 0.0) diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index acf49eb546..adc41fe717 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -225,7 +225,7 @@ async def test_template_position(hass: HomeAssistant, start_ha) -> None: }, ], ) -async def test_template_not_optimistic(hass, start_ha): +async def test_template_not_optimistic(hass: HomeAssistant, start_ha) -> None: """Test the is_closed attribute.""" state = hass.states.get("cover.test_template_cover") assert state.state == STATE_UNKNOWN diff --git a/tests/components/tibber/test_config_flow.py b/tests/components/tibber/test_config_flow.py index e07e4d66cd..545a79ff56 100644 --- a/tests/components/tibber/test_config_flow.py +++ b/tests/components/tibber/test_config_flow.py @@ -70,7 +70,9 @@ async def test_create_entry(recorder_mock: Recorder, hass: HomeAssistant) -> Non (FatalHttpException(404), ERR_CLIENT), ], ) -async def test_create_entry_exceptions(recorder_mock, hass, exception, expected_error): +async def test_create_entry_exceptions( + recorder_mock: Recorder, hass: HomeAssistant, exception, expected_error +) -> None: """Test create entry from user input.""" test_data = { CONF_ACCESS_TOKEN: "valid", @@ -93,7 +95,9 @@ async def test_create_entry_exceptions(recorder_mock, hass, exception, expected_ assert result["errors"][CONF_ACCESS_TOKEN] == expected_error -async def test_flow_entry_already_exists(recorder_mock, hass, config_entry): +async def test_flow_entry_already_exists( + recorder_mock: Recorder, hass: HomeAssistant, config_entry +) -> None: """Test user input for config_entry that already exists.""" test_data = { CONF_ACCESS_TOKEN: "valid", diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index 5b5dc817d6..82eff0d755 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -91,7 +91,9 @@ async def test_calendar_entity_unique_id( @patch("homeassistant.components.todoist.calendar.TodoistAPIAsync") -async def test_update_entity_for_custom_project_with_labels_on(todoist_api, hass, api): +async def test_update_entity_for_custom_project_with_labels_on( + todoist_api, hass: HomeAssistant, api +) -> None: """Test that the calendar's state is on for a custom project using labels.""" todoist_api.return_value = api assert await setup.async_setup_component( diff --git a/tests/components/twentemilieu/test_sensor.py b/tests/components/twentemilieu/test_sensor.py index 6fd39e38d4..e4b845264d 100644 --- a/tests/components/twentemilieu/test_sensor.py +++ b/tests/components/twentemilieu/test_sensor.py @@ -1,5 +1,4 @@ """Tests for the Twente Milieu sensors.""" - import pytest from syrupy.assertion import SnapshotAssertion diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index 12d7b44409..fbf4e576dd 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -1103,7 +1103,7 @@ async def test_state_template(hass: HomeAssistant) -> None: assert hass.states.get("media_player.tv").state == STATE_OFF -async def test_browse_media(hass: HomeAssistant): +async def test_browse_media(hass: HomeAssistant) -> None: """Test browse media.""" await async_setup_component( hass, "media_player", {"media_player": {"platform": "demo"}} @@ -1133,7 +1133,7 @@ async def test_browse_media(hass: HomeAssistant): assert result == MOCK_BROWSE_MEDIA -async def test_browse_media_override(hass: HomeAssistant): +async def test_browse_media_override(hass: HomeAssistant) -> None: """Test browse media override.""" await async_setup_component( hass, "media_player", {"media_player": {"platform": "demo"}} diff --git a/tests/components/weather/test_websocket_api.py b/tests/components/weather/test_websocket_api.py index 1112d7713e..760acbb2bb 100644 --- a/tests/components/weather/test_websocket_api.py +++ b/tests/components/weather/test_websocket_api.py @@ -3,8 +3,12 @@ from homeassistant.components.weather.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.typing import WebSocketGenerator -async def test_device_class_units(hass: HomeAssistant, hass_ws_client) -> None: + +async def test_device_class_units( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: """Test we can get supported units.""" assert await async_setup_component(hass, DOMAIN, {}) diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index adff43d377..392c589ea1 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -306,8 +306,8 @@ async def test_gateway_initialize_failure_transient( ], ) async def test_gateway_initialize_bellows_thread( - device_path, thread_state, config_override, hass, coordinator -): + device_path, thread_state, config_override, hass: HomeAssistant, coordinator +) -> None: """Test ZHA disabling the UART thread when connecting to a TCP coordinator.""" zha_gateway = get_zha_gateway(hass) assert zha_gateway is not None diff --git a/tests/components/zha/test_registries.py b/tests/components/zha/test_registries.py index 24cd7a5785..6a6bf758ce 100644 --- a/tests/components/zha/test_registries.py +++ b/tests/components/zha/test_registries.py @@ -323,7 +323,7 @@ def test_weighted_match( model, quirk_class, match_name, -): +) -> None: """Test weightedd match.""" s = mock.sentinel @@ -435,7 +435,7 @@ def test_multi_sensor_match(channel, entity_registry: er.EntityRegistry) -> None } -def test_quirk_classes(): +def test_quirk_classes() -> None: """Make sure that quirk_classes in components matches are valid.""" def find_quirk_class(base_obj, quirk_mod, quirk_cls): diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 4e99d19261..43489be4cc 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -1460,8 +1460,8 @@ async def test_parse_qr_code_string( async def test_try_parse_dsk_from_qr_code_string( - hass, integration, client, hass_ws_client -): + hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator +) -> None: """Test try_parse_dsk_from_qr_code_string websocket command.""" entry = integration ws_client = await hass_ws_client(hass) @@ -1524,7 +1524,9 @@ async def test_try_parse_dsk_from_qr_code_string( assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_supports_feature(hass, integration, client, hass_ws_client): +async def test_supports_feature( + hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator +) -> None: """Test supports_feature websocket command.""" entry = integration ws_client = await hass_ws_client(hass) @@ -3888,8 +3890,8 @@ async def test_subscribe_firmware_update_status_initial_value( async def test_subscribe_controller_firmware_update_status( - hass, integration, client, hass_ws_client -): + hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator +) -> None: """Test the subscribe_firmware_update_status websocket command for a node.""" ws_client = await hass_ws_client(hass) device = get_device(hass, client.driver.controller.nodes[1]) @@ -3954,8 +3956,8 @@ async def test_subscribe_controller_firmware_update_status( async def test_subscribe_controller_firmware_update_status_initial_value( - hass, client, integration, hass_ws_client -): + hass: HomeAssistant, client, integration, hass_ws_client: WebSocketGenerator +) -> None: """Test subscribe_firmware_update_status cmd with in progress update for node.""" ws_client = await hass_ws_client(hass) device = get_device(hass, client.driver.controller.nodes[1]) diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 66969c51ff..1840e4d798 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -103,7 +103,9 @@ async def test_dynamic_climate_data_discovery_template_failure( ) -async def test_merten_507801(hass, client, merten_507801, integration): +async def test_merten_507801( + hass: HomeAssistant, client, merten_507801, integration +) -> None: """Test that Merten 507801 multilevel switch value is discovered as a cover.""" node = merten_507801 assert node.device_class.specific.label == "Unused" @@ -116,8 +118,8 @@ async def test_merten_507801(hass, client, merten_507801, integration): async def test_merten_507801_disabled_enitites( - hass, client, merten_507801, integration -): + hass: HomeAssistant, client, merten_507801, integration +) -> None: """Test that Merten 507801 entities created by endpoint 2 are disabled.""" registry = er.async_get(hass) entity_ids = [ diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 43bbf85b06..ff86f9c7e7 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -223,7 +223,7 @@ def area_mock(hass): ) -async def test_call_from_config(hass: HomeAssistant): +async def test_call_from_config(hass: HomeAssistant) -> None: """Test the sync wrapper of service.async_call_from_config.""" calls = async_mock_service(hass, "test_domain", "test_service") config = { @@ -238,7 +238,7 @@ async def test_call_from_config(hass: HomeAssistant): assert calls[0].data == {"hello": "goodbye", "entity_id": ["hello.world"]} -async def test_service_call(hass: HomeAssistant): +async def test_service_call(hass: HomeAssistant) -> None: """Test service call with templating.""" calls = async_mock_service(hass, "test_domain", "test_service") config = { @@ -307,7 +307,7 @@ async def test_service_call(hass: HomeAssistant): } -async def test_service_template_service_call(hass: HomeAssistant): +async def test_service_template_service_call(hass: HomeAssistant) -> None: """Test legacy service_template call with templating.""" calls = async_mock_service(hass, "test_domain", "test_service") config = { @@ -322,7 +322,7 @@ async def test_service_template_service_call(hass: HomeAssistant): assert calls[0].data == {"hello": "goodbye", "entity_id": ["hello.world"]} -async def test_passing_variables_to_templates(hass: HomeAssistant): +async def test_passing_variables_to_templates(hass: HomeAssistant) -> None: """Test passing variables to templates.""" calls = async_mock_service(hass, "test_domain", "test_service") config = { @@ -344,7 +344,7 @@ async def test_passing_variables_to_templates(hass: HomeAssistant): assert calls[0].data == {"hello": "goodbye", "entity_id": ["hello.world"]} -async def test_bad_template(hass: HomeAssistant): +async def test_bad_template(hass: HomeAssistant) -> None: """Test passing bad template.""" calls = async_mock_service(hass, "test_domain", "test_service") config = { @@ -366,7 +366,7 @@ async def test_bad_template(hass: HomeAssistant): assert len(calls) == 0 -async def test_split_entity_string(hass: HomeAssistant): +async def test_split_entity_string(hass: HomeAssistant) -> None: """Test splitting of entity string.""" calls = async_mock_service(hass, "test_domain", "test_service") await service.async_call_from_config( @@ -380,7 +380,7 @@ async def test_split_entity_string(hass: HomeAssistant): assert ["hello.world", "sensor.beer"] == calls[-1].data.get("entity_id") -async def test_not_mutate_input(hass: HomeAssistant): +async def test_not_mutate_input(hass: HomeAssistant) -> None: """Test for immutable input.""" async_mock_service(hass, "test_domain", "test_service") config = { @@ -403,7 +403,7 @@ async def test_not_mutate_input(hass: HomeAssistant): @patch("homeassistant.helpers.service._LOGGER.error") -async def test_fail_silently_if_no_service(mock_log, hass: HomeAssistant): +async def test_fail_silently_if_no_service(mock_log, hass: HomeAssistant) -> None: """Test failing if service is missing.""" await service.async_call_from_config(hass, None) assert mock_log.call_count == 1 diff --git a/tests/test_core.py b/tests/test_core.py index 6d67376b41..6167bf6a63 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -75,7 +75,7 @@ def test_async_add_hass_job_schedule_callback() -> None: assert len(hass.add_job.mock_calls) == 0 -def test_async_add_hass_job_coro_named(hass) -> None: +def test_async_add_hass_job_coro_named(hass: HomeAssistant) -> None: """Test that we schedule coroutines and add jobs to the job pool with a name.""" async def mycoro(): diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 28ccdcc589..bd99889234 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -492,7 +492,9 @@ def test_representing_yaml_loaded_data( @pytest.mark.parametrize("hass_config_yaml", ["key: thing1\nkey: thing2"]) -def test_duplicate_key(caplog, try_both_loaders, mock_hass_config_yaml: None) -> None: +def test_duplicate_key( + caplog: pytest.LogCaptureFixture, try_both_loaders, mock_hass_config_yaml: None +) -> None: """Test duplicate dict keys.""" load_yaml_config_file(YAML_CONFIG_FILE) assert "contains duplicate key" in caplog.text @@ -503,7 +505,7 @@ def test_duplicate_key(caplog, try_both_loaders, mock_hass_config_yaml: None) -> [{YAML_CONFIG_FILE: "key: !secret a", yaml.SECRET_YAML: "a: 1\nb: !secret a"}], ) def test_no_recursive_secrets( - caplog, try_both_loaders, mock_hass_config_yaml: None + caplog: pytest.LogCaptureFixture, try_both_loaders, mock_hass_config_yaml: None ) -> None: """Test that loading of secrets from the secrets file fails correctly.""" with pytest.raises(HomeAssistantError) as e: From 0f15f8b84b51121f4f63136d6da86e426f80fe96 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Mar 2023 12:58:29 +0100 Subject: [PATCH 0823/2810] Bump pytest-sugar to 0.9.6 (#89500) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 10ceb81365..51ce5c7d12 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -24,7 +24,7 @@ pytest-cov==3.0.0 pytest-freezer==0.4.6 pytest-socket==0.5.1 pytest-test-groups==1.0.3 -pytest-sugar==0.9.5 +pytest-sugar==0.9.6 pytest-timeout==2.1.0 pytest-unordered==0.5.2 pytest-picked==0.4.6 From b4c1c0beb7535e99cf05ee146860f3262917322b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Mar 2023 13:08:45 +0100 Subject: [PATCH 0824/2810] Bump pytest-xdist to 3.2.0 (#89501) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 51ce5c7d12..7601326cd5 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -28,7 +28,7 @@ pytest-sugar==0.9.6 pytest-timeout==2.1.0 pytest-unordered==0.5.2 pytest-picked==0.4.6 -pytest-xdist==2.5.0 +pytest-xdist==3.2.0 pytest==7.2.2 requests_mock==1.10.0 respx==0.20.1 From f4b8598979219038f98593faf0a2f09b7aad8889 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Mar 2023 13:27:07 +0100 Subject: [PATCH 0825/2810] Bump home-assistant/builder from 2022.11.0 to 2023.03.0 (#89485) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index cc87e4708c..45774642a5 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -198,7 +198,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2022.11.0 + uses: home-assistant/builder@2023.03.0 with: args: | $BUILD_ARGS \ @@ -276,7 +276,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2022.11.0 + uses: home-assistant/builder@2023.03.0 with: args: | $BUILD_ARGS \ From 029093d0b27ce6f69f131605140e61c1ce35ce78 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Mar 2023 15:48:58 +0100 Subject: [PATCH 0826/2810] Fix lingering timer in device registry (#89422) --- homeassistant/helpers/device_registry.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 9ea44db16d..b72a187865 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Any, TypeVar, cast import attr from homeassistant.backports.enum import StrEnum -from homeassistant.const import EVENT_HOMEASSISTANT_STARTED +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, RequiredParameterMissing from homeassistant.loader import bind_hass @@ -907,6 +907,13 @@ def async_setup_cleanup(hass: HomeAssistant, dev_reg: DeviceRegistry) -> None: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, startup_clean) + @callback + def _on_homeassistant_stop(event: Event) -> None: + """Cancel debounced cleanup.""" + debounced_cleanup.async_cancel() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _on_homeassistant_stop) + def _normalize_connections(connections: set[tuple[str, str]]) -> set[tuple[str, str]]: """Normalize connections to ensure we can match mac addresses.""" From 75bca76e6864b724854fd04e79fc8a3365319d92 Mon Sep 17 00:00:00 2001 From: Vincent Knoop Pathuis <48653141+vpathuis@users.noreply.github.com> Date: Fri, 10 Mar 2023 15:57:35 +0100 Subject: [PATCH 0827/2810] Landis+Gyr move coordinator to own file (#89433) * Move coordinator to own file and add test cases * Apply typing improvements from review * Remove testcase for exception during setup * Simplify unittest for failing serial connection * Readd checks in serial connection test after review --- .../landisgyr_heat_meter/__init__.py | 18 +--- .../components/landisgyr_heat_meter/const.py | 3 + .../landisgyr_heat_meter/coordinator.py | 37 +++++++ .../landisgyr_heat_meter/test_sensor.py | 96 ++++++++++++++++--- 4 files changed, 127 insertions(+), 27 deletions(-) create mode 100644 homeassistant/components/landisgyr_heat_meter/coordinator.py diff --git a/homeassistant/components/landisgyr_heat_meter/__init__.py b/homeassistant/components/landisgyr_heat_meter/__init__.py index eae5e91196..541fef017d 100644 --- a/homeassistant/components/landisgyr_heat_meter/__init__.py +++ b/homeassistant/components/landisgyr_heat_meter/__init__.py @@ -1,19 +1,17 @@ """The Landis+Gyr Heat Meter integration.""" from __future__ import annotations -from datetime import timedelta import logging import ultraheat_api -from ultraheat_api.response import HeatMeterResponse from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_registry import async_migrate_entries -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN +from .coordinator import UltraheatCoordinator _LOGGER = logging.getLogger(__name__) @@ -27,19 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: reader = ultraheat_api.UltraheatReader(entry.data[CONF_DEVICE]) api = ultraheat_api.HeatMeterService(reader) - async def async_update_data() -> HeatMeterResponse: - """Fetch data from the API.""" - _LOGGER.debug("Polling on %s", entry.data[CONF_DEVICE]) - return await hass.async_add_executor_job(api.read) - - # Polling is only daily to prevent battery drain. - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name="ultraheat_gateway", - update_method=async_update_data, - update_interval=timedelta(days=1), - ) + coordinator = UltraheatCoordinator(hass, api) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/landisgyr_heat_meter/const.py b/homeassistant/components/landisgyr_heat_meter/const.py index 5d27a8a170..56f5980a83 100644 --- a/homeassistant/components/landisgyr_heat_meter/const.py +++ b/homeassistant/components/landisgyr_heat_meter/const.py @@ -1,6 +1,9 @@ """Constants for the Landis+Gyr Heat Meter integration.""" +from datetime import timedelta + DOMAIN = "landisgyr_heat_meter" GJ_TO_MWH = 0.277778 # conversion factor ULTRAHEAT_TIMEOUT = 30 # reading the IR port can take some time +POLLING_INTERVAL = timedelta(days=1) # Polling is only daily to prevent battery drain. diff --git a/homeassistant/components/landisgyr_heat_meter/coordinator.py b/homeassistant/components/landisgyr_heat_meter/coordinator.py new file mode 100644 index 0000000000..c85c661e79 --- /dev/null +++ b/homeassistant/components/landisgyr_heat_meter/coordinator.py @@ -0,0 +1,37 @@ +"""Data update coordinator for the ultraheat api.""" + +import logging + +import async_timeout +import serial +from ultraheat_api.response import HeatMeterResponse +from ultraheat_api.service import HeatMeterService + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import POLLING_INTERVAL, ULTRAHEAT_TIMEOUT + +_LOGGER = logging.getLogger(__name__) + + +class UltraheatCoordinator(DataUpdateCoordinator[HeatMeterResponse]): + """Coordinator for getting data from the ultraheat api.""" + + def __init__(self, hass: HomeAssistant, api: HeatMeterService) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + _LOGGER, + name="ultraheat", + update_interval=POLLING_INTERVAL, + ) + self.api = api + + async def _async_update_data(self) -> HeatMeterResponse: + """Fetch data from API endpoint.""" + try: + async with async_timeout.timeout(ULTRAHEAT_TIMEOUT): + return await self.hass.async_add_executor_job(self.api.read) + except (FileNotFoundError, serial.serialutil.SerialException) as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/tests/components/landisgyr_heat_meter/test_sensor.py b/tests/components/landisgyr_heat_meter/test_sensor.py index 9a94491a94..854ead82b3 100644 --- a/tests/components/landisgyr_heat_meter/test_sensor.py +++ b/tests/components/landisgyr_heat_meter/test_sensor.py @@ -3,11 +3,13 @@ from dataclasses import dataclass import datetime from unittest.mock import patch +import serial + from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, ) -from homeassistant.components.landisgyr_heat_meter.const import DOMAIN +from homeassistant.components.landisgyr_heat_meter.const import DOMAIN, POLLING_INTERVAL from homeassistant.components.sensor import ( ATTR_LAST_RESET, ATTR_STATE_CLASS, @@ -19,6 +21,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, + STATE_UNAVAILABLE, EntityCategory, UnitOfEnergy, UnitOfVolume, @@ -28,21 +31,29 @@ from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry, mock_restore_cache_with_extra_data +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + mock_restore_cache_with_extra_data, +) + +API_HEAT_METER_SERVICE = ( + "homeassistant.components.landisgyr_heat_meter.ultraheat_api.HeatMeterService" +) @dataclass class MockHeatMeterResponse: """Mock for HeatMeterResponse.""" - heat_usage_gj: int - volume_usage_m3: int - heat_previous_year_gj: int + heat_usage_gj: float + volume_usage_m3: float + heat_previous_year_gj: float device_number: str meter_date_time: datetime.datetime -@patch("homeassistant.components.landisgyr_heat_meter.ultraheat_api.HeatMeterService") +@patch(API_HEAT_METER_SERVICE) async def test_create_sensors( mock_heat_meter, hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: @@ -57,9 +68,9 @@ async def test_create_sensors( mock_entry.add_to_hass(hass) mock_heat_meter_response = MockHeatMeterResponse( - heat_usage_gj=123, - volume_usage_m3=456, - heat_previous_year_gj=111, + heat_usage_gj=123.0, + volume_usage_m3=456.0, + heat_previous_year_gj=111.0, device_number="devicenr_789", meter_date_time=dt_util.as_utc(datetime.datetime(2022, 5, 19, 19, 41, 17)), ) @@ -89,7 +100,7 @@ async def test_create_sensors( state = hass.states.get("sensor.heat_meter_volume_usage") assert state - assert state.state == "456" + assert state.state == "456.0" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL @@ -110,7 +121,7 @@ async def test_create_sensors( assert entity_registry_entry.entity_category == EntityCategory.DIAGNOSTIC -@patch("homeassistant.components.landisgyr_heat_meter.ultraheat_api.HeatMeterService") +@patch(API_HEAT_METER_SERVICE) async def test_restore_state(mock_heat_meter, hass: HomeAssistant) -> None: """Test sensor restore state.""" # Home assistant is not running yet @@ -199,3 +210,66 @@ async def test_restore_state(mock_heat_meter, hass: HomeAssistant) -> None: assert state assert state.state == "devicenr_789" assert state.attributes.get(ATTR_STATE_CLASS) is None + + +@patch(API_HEAT_METER_SERVICE) +async def test_exception_on_polling(mock_heat_meter, hass: HomeAssistant) -> None: + """Test sensor.""" + entry_data = { + "device": "/dev/USB0", + "model": "LUGCUH50", + "device_number": "123456789", + } + mock_entry = MockConfigEntry(domain=DOMAIN, unique_id=DOMAIN, data=entry_data) + mock_entry.add_to_hass(hass) + + # First setup normally + mock_heat_meter_response = MockHeatMeterResponse( + heat_usage_gj=123.0, + volume_usage_m3=456.0, + heat_previous_year_gj=111.0, + device_number="devicenr_789", + meter_date_time=dt_util.as_utc(datetime.datetime(2022, 5, 19, 19, 41, 17)), + ) + + mock_heat_meter().read.return_value = mock_heat_meter_response + + await hass.config_entries.async_setup(mock_entry.entry_id) + await async_setup_component(hass, HA_DOMAIN, {}) + await hass.async_block_till_done() + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: "sensor.heat_meter_heat_usage"}, + blocking=True, + ) + await hass.async_block_till_done() + + # check if initial setup succeeded + state = hass.states.get("sensor.heat_meter_heat_usage") + assert state + assert state.state == "34.16669" + + # Now 'disable' the connection and wait for polling and see if it fails + mock_heat_meter().read.side_effect = serial.serialutil.SerialException + async_fire_time_changed(hass, dt_util.utcnow() + POLLING_INTERVAL) + await hass.async_block_till_done() + state = hass.states.get("sensor.heat_meter_heat_usage") + assert state.state == STATE_UNAVAILABLE + + # Now 'enable' and see if next poll succeeds + mock_heat_meter_response = MockHeatMeterResponse( + heat_usage_gj=124.0, + volume_usage_m3=457.0, + heat_previous_year_gj=112.0, + device_number="devicenr_789", + meter_date_time=dt_util.as_utc(datetime.datetime(2022, 5, 19, 20, 41, 17)), + ) + + mock_heat_meter().read.return_value = mock_heat_meter_response + mock_heat_meter().read.side_effect = None + async_fire_time_changed(hass, dt_util.utcnow() + POLLING_INTERVAL) + await hass.async_block_till_done() + state = hass.states.get("sensor.heat_meter_heat_usage") + assert state + assert state.state == "34.44447" From f674559a71e0f7cee474f594bbe3d9c4c2288454 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Mar 2023 16:04:45 +0100 Subject: [PATCH 0828/2810] Add missing mock in landisgyr config flow tests (#89513) --- .../landisgyr_heat_meter/conftest.py | 15 ++++++++++++ .../landisgyr_heat_meter/test_config_flow.py | 23 ++++++++----------- 2 files changed, 24 insertions(+), 14 deletions(-) create mode 100644 tests/components/landisgyr_heat_meter/conftest.py diff --git a/tests/components/landisgyr_heat_meter/conftest.py b/tests/components/landisgyr_heat_meter/conftest.py new file mode 100644 index 0000000000..711fa2110f --- /dev/null +++ b/tests/components/landisgyr_heat_meter/conftest.py @@ -0,0 +1,15 @@ +"""Define fixtures for Landis + Gyr Heat Meter tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.landisgyr_heat_meter.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/landisgyr_heat_meter/test_config_flow.py b/tests/components/landisgyr_heat_meter/test_config_flow.py index 5763886864..b58c91f8f1 100644 --- a/tests/components/landisgyr_heat_meter/test_config_flow.py +++ b/tests/components/landisgyr_heat_meter/test_config_flow.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from unittest.mock import patch +import pytest import serial import serial.tools.list_ports @@ -14,6 +15,8 @@ from tests.common import MockConfigEntry API_HEAT_METER_SERVICE = "homeassistant.components.landisgyr_heat_meter.config_flow.ultraheat_api.HeatMeterService" +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + def mock_serial_port(): """Mock of a serial port.""" @@ -57,13 +60,9 @@ async def test_manual_entry(mock_heat_meter, hass: HomeAssistant) -> None: assert result["step_id"] == "setup_serial_manual_path" assert result["errors"] == {} - with patch( - "homeassistant.components.landisgyr_heat_meter.async_setup_entry", - return_value=True, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"device": "/dev/ttyUSB0"} - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"device": "/dev/ttyUSB0"} + ) assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "LUGCUH50" @@ -122,13 +121,9 @@ async def test_manual_entry_fail(mock_heat_meter, hass: HomeAssistant) -> None: assert result["step_id"] == "setup_serial_manual_path" assert result["errors"] == {} - with patch( - "homeassistant.components.landisgyr_heat_meter.async_setup_entry", - return_value=True, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"device": "/dev/ttyUSB0"} - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"device": "/dev/ttyUSB0"} + ) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "setup_serial_manual_path" From 401273dcfffb44648cf05b2f2c2a44dd39683ccf Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Mar 2023 16:05:13 +0100 Subject: [PATCH 0829/2810] Add missing mock in lacrosse_view config flow tests (#89512) --- tests/components/lacrosse_view/conftest.py | 14 ++++++ .../lacrosse_view/test_config_flow.py | 49 +++++++++---------- 2 files changed, 36 insertions(+), 27 deletions(-) create mode 100644 tests/components/lacrosse_view/conftest.py diff --git a/tests/components/lacrosse_view/conftest.py b/tests/components/lacrosse_view/conftest.py new file mode 100644 index 0000000000..1ea3144e4c --- /dev/null +++ b/tests/components/lacrosse_view/conftest.py @@ -0,0 +1,14 @@ +"""Define fixtures for LaCrosse View tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.lacrosse_view.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/lacrosse_view/test_config_flow.py b/tests/components/lacrosse_view/test_config_flow.py index 67c9bf5752..075aa7a376 100644 --- a/tests/components/lacrosse_view/test_config_flow.py +++ b/tests/components/lacrosse_view/test_config_flow.py @@ -1,7 +1,8 @@ """Test the LaCrosse View config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from lacrosse_view import Location, LoginError +import pytest from homeassistant import config_entries from homeassistant.components.lacrosse_view.const import DOMAIN @@ -10,8 +11,10 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_form(hass: HomeAssistant) -> None: + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -25,8 +28,6 @@ async def test_form(hass: HomeAssistant) -> None: ), patch( "lacrosse_view.LaCrosse.get_locations", return_value=[Location(id=1, name="Test")], - ), patch( - "homeassistant.components.lacrosse_view.async_setup_entry", return_value=True ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -41,17 +42,13 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["step_id"] == "location" assert result2["errors"] is None - with patch( - "homeassistant.components.lacrosse_view.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - { - "location": "1", - }, - ) - await hass.async_block_till_done() + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + "location": "1", + }, + ) + await hass.async_block_till_done() assert result3["type"] == FlowResultType.CREATE_ENTRY assert result3["title"] == "Test" @@ -170,7 +167,9 @@ async def test_form_unexpected_error(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "unknown"} -async def test_already_configured_device(hass: HomeAssistant) -> None: +async def test_already_configured_device( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test we handle invalid auth.""" mock_config_entry = MockConfigEntry( domain=DOMAIN, @@ -212,17 +211,13 @@ async def test_already_configured_device(hass: HomeAssistant) -> None: assert result2["step_id"] == "location" assert result2["errors"] is None - with patch( - "homeassistant.components.lacrosse_view.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - { - "location": "1", - }, - ) - await hass.async_block_till_done() + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + "location": "1", + }, + ) + await hass.async_block_till_done() assert result3["type"] == FlowResultType.ABORT assert result3["reason"] == "already_configured" From f22fabdd7f9baa336f378b9086e5956e765a8bcb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Mar 2023 16:05:31 +0100 Subject: [PATCH 0830/2810] Add missing mock in kmtronic config flow tests (#89511) --- tests/components/kmtronic/conftest.py | 14 ++++++++++++++ tests/components/kmtronic/test_config_flow.py | 12 ++++++------ 2 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 tests/components/kmtronic/conftest.py diff --git a/tests/components/kmtronic/conftest.py b/tests/components/kmtronic/conftest.py new file mode 100644 index 0000000000..4310f99242 --- /dev/null +++ b/tests/components/kmtronic/conftest.py @@ -0,0 +1,14 @@ +"""Define fixtures for kmtronic tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.kmtronic.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/kmtronic/test_config_flow.py b/tests/components/kmtronic/test_config_flow.py index 37a77a0000..76d11b8451 100644 --- a/tests/components/kmtronic/test_config_flow.py +++ b/tests/components/kmtronic/test_config_flow.py @@ -1,8 +1,9 @@ """Test the kmtronic config flow.""" from http import HTTPStatus -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch from aiohttp import ClientConnectorError, ClientResponseError +import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components.kmtronic.const import CONF_REVERSE, DOMAIN @@ -12,8 +13,10 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker +pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_form(hass: HomeAssistant) -> None: + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -25,10 +28,7 @@ async def test_form(hass: HomeAssistant) -> None: with patch( "homeassistant.components.kmtronic.config_flow.KMTronicHubAPI.async_get_status", return_value=[Mock()], - ), patch( - "homeassistant.components.kmtronic.async_setup_entry", - return_value=True, - ) as mock_setup_entry: + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { From 74d4a26f9776cb218f3ff2d4c383fed51783dee2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Mar 2023 16:06:53 +0100 Subject: [PATCH 0831/2810] Add missing mock in jellyfin config flow tests (#89510) --- tests/components/jellyfin/test_config_flow.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/components/jellyfin/test_config_flow.py b/tests/components/jellyfin/test_config_flow.py index 015d44722e..51aa4bccc9 100644 --- a/tests/components/jellyfin/test_config_flow.py +++ b/tests/components/jellyfin/test_config_flow.py @@ -1,6 +1,8 @@ """Test the jellyfin config flow.""" from unittest.mock import MagicMock +import pytest + from homeassistant import config_entries, data_entry_flow from homeassistant.components.jellyfin.const import CONF_CLIENT_DEVICE_ID, DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME @@ -11,6 +13,8 @@ from .const import TEST_PASSWORD, TEST_URL, TEST_USERNAME from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: """Check flow abort when an entry already exist.""" From 96bd7143643a813689c86f6f7512008c187a4766 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Mar 2023 16:09:04 +0100 Subject: [PATCH 0832/2810] Add FTTH and WAN info to SFR box diagnostics (#89492) * Add FTTH and WAN info to SFR box diagnostics * Adjust tests * Use snapshots --- .../components/sfr_box/diagnostics.py | 6 ++ tests/components/sfr_box/conftest.py | 46 ++++++++++---- .../sfr_box/fixtures/ftth_getInfo.json | 4 ++ .../sfr_box/fixtures/wan_getInfo.json | 11 ++++ .../sfr_box/snapshots/test_diagnostics.ambr | 60 +++++++++++++++++++ tests/components/sfr_box/test_diagnostics.py | 55 ++++------------- 6 files changed, 127 insertions(+), 55 deletions(-) create mode 100644 tests/components/sfr_box/fixtures/ftth_getInfo.json create mode 100644 tests/components/sfr_box/fixtures/wan_getInfo.json create mode 100644 tests/components/sfr_box/snapshots/test_diagnostics.ambr diff --git a/homeassistant/components/sfr_box/diagnostics.py b/homeassistant/components/sfr_box/diagnostics.py index 6a7ceb0e86..60df217396 100644 --- a/homeassistant/components/sfr_box/diagnostics.py +++ b/homeassistant/components/sfr_box/diagnostics.py @@ -27,8 +27,14 @@ async def async_get_config_entry_diagnostics( }, "data": { "dsl": async_redact_data(dataclasses.asdict(data.dsl.data), TO_REDACT), + "ftth": async_redact_data( + dataclasses.asdict(await data.system.box.ftth_get_info()), TO_REDACT + ), "system": async_redact_data( dataclasses.asdict(data.system.data), TO_REDACT ), + "wan": async_redact_data( + dataclasses.asdict(await data.system.box.wan_get_info()), TO_REDACT + ), }, } diff --git a/tests/components/sfr_box/conftest.py b/tests/components/sfr_box/conftest.py index 1857ffeec3..a8cd6fd8bd 100644 --- a/tests/components/sfr_box/conftest.py +++ b/tests/components/sfr_box/conftest.py @@ -4,7 +4,7 @@ import json from unittest.mock import AsyncMock, patch import pytest -from sfrbox_api.models import DslInfo, SystemInfo +from sfrbox_api.models import DslInfo, FtthInfo, SystemInfo, WanInfo from homeassistant.components.sfr_box.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntry @@ -57,17 +57,6 @@ def get_config_entry_with_auth(hass: HomeAssistant) -> ConfigEntry: return config_entry_with_auth -@pytest.fixture -def system_get_info() -> Generator[SystemInfo, None, None]: - """Fixture for SFRBox.system_get_info.""" - system_info = SystemInfo(**json.loads(load_fixture("system_getInfo.json", DOMAIN))) - with patch( - "homeassistant.components.sfr_box.coordinator.SFRBox.system_get_info", - return_value=system_info, - ): - yield system_info - - @pytest.fixture def dsl_get_info() -> Generator[DslInfo, None, None]: """Fixture for SFRBox.dsl_get_info.""" @@ -77,3 +66,36 @@ def dsl_get_info() -> Generator[DslInfo, None, None]: return_value=dsl_info, ): yield dsl_info + + +@pytest.fixture +def ftth_get_info() -> Generator[FtthInfo, None, None]: + """Fixture for SFRBox.ftth_get_info.""" + info = FtthInfo(**json.loads(load_fixture("ftth_getInfo.json", DOMAIN))) + with patch( + "homeassistant.components.sfr_box.coordinator.SFRBox.ftth_get_info", + return_value=info, + ): + yield info + + +@pytest.fixture +def system_get_info() -> Generator[SystemInfo, None, None]: + """Fixture for SFRBox.system_get_info.""" + info = SystemInfo(**json.loads(load_fixture("system_getInfo.json", DOMAIN))) + with patch( + "homeassistant.components.sfr_box.coordinator.SFRBox.system_get_info", + return_value=info, + ): + yield info + + +@pytest.fixture +def wan_get_info() -> Generator[WanInfo, None, None]: + """Fixture for SFRBox.wan_get_info.""" + info = WanInfo(**json.loads(load_fixture("wan_getInfo.json", DOMAIN))) + with patch( + "homeassistant.components.sfr_box.coordinator.SFRBox.wan_get_info", + return_value=info, + ): + yield info diff --git a/tests/components/sfr_box/fixtures/ftth_getInfo.json b/tests/components/sfr_box/fixtures/ftth_getInfo.json new file mode 100644 index 0000000000..32f720e911 --- /dev/null +++ b/tests/components/sfr_box/fixtures/ftth_getInfo.json @@ -0,0 +1,4 @@ +{ + "status": "down", + "wanfibre": "out" +} diff --git a/tests/components/sfr_box/fixtures/wan_getInfo.json b/tests/components/sfr_box/fixtures/wan_getInfo.json new file mode 100644 index 0000000000..fdef6270f3 --- /dev/null +++ b/tests/components/sfr_box/fixtures/wan_getInfo.json @@ -0,0 +1,11 @@ +{ + "status": "up", + "uptime": 297464, + "ip_addr": "1.2.3.4", + "infra": "adsl", + "mode": "adsl/routed", + "infra6": "", + "status6": "down", + "uptime6": null, + "ipv6_addr": "" +} diff --git a/tests/components/sfr_box/snapshots/test_diagnostics.ambr b/tests/components/sfr_box/snapshots/test_diagnostics.ambr new file mode 100644 index 0000000000..2e25259268 --- /dev/null +++ b/tests/components/sfr_box/snapshots/test_diagnostics.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'data': dict({ + 'dsl': dict({ + 'attenuation_down': 28.5, + 'attenuation_up': 20.8, + 'counter': 16, + 'crc': 0, + 'line_status': 'No Defect', + 'linemode': 'ADSL2+', + 'noise_down': 5.8, + 'noise_up': 6.0, + 'rate_down': 5549, + 'rate_up': 187, + 'status': 'up', + 'training': 'Showtime', + 'uptime': 450796, + }), + 'ftth': dict({ + 'status': 'down', + 'wanfibre': 'out', + }), + 'system': dict({ + 'alimvoltage': 12251, + 'current_datetime': '202212282233', + 'idur': 'RP3P85K', + 'mac_addr': '**REDACTED**', + 'net_infra': 'adsl', + 'net_mode': 'router', + 'product_id': 'NB6VAC-FXC-r0', + 'refclient': '', + 'serial_number': '**REDACTED**', + 'temperature': 27560, + 'uptime': 2353575, + 'version_bootloader': 'NB6VAC-BOOTLOADER-R4.0.8', + 'version_dsldriver': 'NB6VAC-XDSL-A2pv6F039p', + 'version_mainfirmware': 'NB6VAC-MAIN-R4.0.44k', + 'version_rescuefirmware': 'NB6VAC-MAIN-R4.0.44k', + }), + 'wan': dict({ + 'infra': 'adsl', + 'infra6': '', + 'ip_addr': '1.2.3.4', + 'ipv6_addr': '', + 'mode': 'adsl/routed', + 'status': 'up', + 'status6': 'down', + 'uptime': 297464, + 'uptime6': None, + }), + }), + 'entry': dict({ + 'data': dict({ + 'host': '192.168.0.1', + }), + 'title': 'Mock Title', + }), + }) +# --- diff --git a/tests/components/sfr_box/test_diagnostics.py b/tests/components/sfr_box/test_diagnostics.py index 966a038b48..37e3ba9487 100644 --- a/tests/components/sfr_box/test_diagnostics.py +++ b/tests/components/sfr_box/test_diagnostics.py @@ -3,15 +3,17 @@ from collections.abc import Generator from unittest.mock import patch import pytest +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.diagnostics import REDACTED from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator -pytestmark = pytest.mark.usefixtures("system_get_info", "dsl_get_info") +pytestmark = pytest.mark.usefixtures( + "dsl_get_info", "ftth_get_info", "system_get_info", "wan_get_info" +) @pytest.fixture(autouse=True) @@ -22,49 +24,16 @@ def override_platforms() -> Generator[None, None, None]: async def test_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry, hass_client: ClientSessionGenerator + hass: HomeAssistant, + config_entry: ConfigEntry, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "entry": { - "data": {"host": "192.168.0.1"}, - "title": "Mock Title", - }, - "data": { - "dsl": { - "attenuation_down": 28.5, - "attenuation_up": 20.8, - "counter": 16, - "crc": 0, - "line_status": "No Defect", - "linemode": "ADSL2+", - "noise_down": 5.8, - "noise_up": 6.0, - "rate_down": 5549, - "rate_up": 187, - "status": "up", - "training": "Showtime", - "uptime": 450796, - }, - "system": { - "alimvoltage": 12251, - "current_datetime": "202212282233", - "idur": "RP3P85K", - "mac_addr": REDACTED, - "net_infra": "adsl", - "net_mode": "router", - "product_id": "NB6VAC-FXC-r0", - "refclient": "", - "serial_number": REDACTED, - "temperature": 27560, - "uptime": 2353575, - "version_bootloader": "NB6VAC-BOOTLOADER-R4.0.8", - "version_dsldriver": "NB6VAC-XDSL-A2pv6F039p", - "version_mainfirmware": "NB6VAC-MAIN-R4.0.44k", - "version_rescuefirmware": "NB6VAC-MAIN-R4.0.44k", - }, - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) From d6a223f0e161fbd7a1fe906351b8ffbe75fd1efa Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 10 Mar 2023 11:42:53 -0500 Subject: [PATCH 0833/2810] Await block till done inside patched config entry in tests (#89515) --- tests/components/advantage_air/test_config_flow.py | 2 +- tests/components/bond/test_config_flow.py | 2 +- tests/components/epson/test_config_flow.py | 2 +- tests/components/faa_delays/test_config_flow.py | 2 +- tests/components/kmtronic/test_config_flow.py | 2 +- tests/components/kostal_plenticore/test_config_flow.py | 2 +- tests/components/kulersky/test_config_flow.py | 4 ++-- tests/components/lutron_caseta/test_config_flow.py | 2 +- tests/components/nanoleaf/test_config_flow.py | 5 +++-- tests/components/nws/test_config_flow.py | 3 ++- tests/components/onewire/test_config_flow.py | 5 ++--- tests/components/syncthru/test_config_flow.py | 2 +- 12 files changed, 17 insertions(+), 16 deletions(-) diff --git a/tests/components/advantage_air/test_config_flow.py b/tests/components/advantage_air/test_config_flow.py index 4783e9cb63..fc74df5538 100644 --- a/tests/components/advantage_air/test_config_flow.py +++ b/tests/components/advantage_air/test_config_flow.py @@ -33,12 +33,12 @@ async def test_form(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> result1["flow_id"], USER_INPUT, ) + await hass.async_block_till_done() assert len(aioclient_mock.mock_calls) == 1 assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result2["title"] == "testname" assert result2["data"] == USER_INPUT - await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 1 # Test Duplicate Config Flow diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py index a060def1cb..fab579a81a 100644 --- a/tests/components/bond/test_config_flow.py +++ b/tests/components/bond/test_config_flow.py @@ -187,11 +187,11 @@ async def test_user_form_one_entry_per_device_allowed(hass: HomeAssistant) -> No result["flow_id"], {CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, ) + await hass.async_block_till_done() assert result2["type"] == "abort" assert result2["reason"] == "already_configured" - await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 0 diff --git a/tests/components/epson/test_config_flow.py b/tests/components/epson/test_config_flow.py index fadfb2085b..be0267a4af 100644 --- a/tests/components/epson/test_config_flow.py +++ b/tests/components/epson/test_config_flow.py @@ -33,11 +33,11 @@ async def test_form(hass: HomeAssistant) -> None: result["flow_id"], {CONF_HOST: "1.1.1.1", CONF_NAME: "test-epson"}, ) + await hass.async_block_till_done() assert result2["type"] == "create_entry" assert result2["title"] == "test-epson" assert result2["data"] == {CONF_HOST: "1.1.1.1"} - await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/faa_delays/test_config_flow.py b/tests/components/faa_delays/test_config_flow.py index ce7f86e606..9eb166d5f6 100644 --- a/tests/components/faa_delays/test_config_flow.py +++ b/tests/components/faa_delays/test_config_flow.py @@ -37,13 +37,13 @@ async def test_form(hass: HomeAssistant) -> None: "id": "test", }, ) + await hass.async_block_till_done() assert result2["type"] == "create_entry" assert result2["title"] == "Test airport" assert result2["data"] == { "id": "test", } - await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/kmtronic/test_config_flow.py b/tests/components/kmtronic/test_config_flow.py index 76d11b8451..ba8f2f5b87 100644 --- a/tests/components/kmtronic/test_config_flow.py +++ b/tests/components/kmtronic/test_config_flow.py @@ -37,6 +37,7 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: "password": "test-password", }, ) + await hass.async_block_till_done() assert result2["type"] == "create_entry" assert result2["title"] == "1.1.1.1" @@ -45,7 +46,6 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: "username": "test-username", "password": "test-password", } - await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/kostal_plenticore/test_config_flow.py b/tests/components/kostal_plenticore/test_config_flow.py index 5d67ca3ae6..3c64a48c21 100644 --- a/tests/components/kostal_plenticore/test_config_flow.py +++ b/tests/components/kostal_plenticore/test_config_flow.py @@ -47,6 +47,7 @@ async def test_formx(hass: HomeAssistant) -> None: "password": "test-password", }, ) + await hass.async_block_till_done() mock_api_class.assert_called_once_with(ANY, "1.1.1.1") mock_api.__aenter__.assert_called_once() @@ -60,7 +61,6 @@ async def test_formx(hass: HomeAssistant) -> None: "host": "1.1.1.1", "password": "test-password", } - await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/kulersky/test_config_flow.py b/tests/components/kulersky/test_config_flow.py index 3a26f16f3a..a09fc78797 100644 --- a/tests/components/kulersky/test_config_flow.py +++ b/tests/components/kulersky/test_config_flow.py @@ -60,10 +60,10 @@ async def test_flow_no_devices_found(hass: HomeAssistant) -> None: result["flow_id"], {}, ) + await hass.async_block_till_done() assert result2["type"] == "abort" assert result2["reason"] == "no_devices_found" - await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 0 @@ -87,8 +87,8 @@ async def test_flow_exceptions_caught(hass: HomeAssistant) -> None: result["flow_id"], {}, ) + await hass.async_block_till_done() assert result2["type"] == "abort" assert result2["reason"] == "no_devices_found" - await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 0 diff --git a/tests/components/lutron_caseta/test_config_flow.py b/tests/components/lutron_caseta/test_config_flow.py index 72a0d4b71a..cc71eb5910 100644 --- a/tests/components/lutron_caseta/test_config_flow.py +++ b/tests/components/lutron_caseta/test_config_flow.py @@ -67,13 +67,13 @@ async def test_bridge_import_flow(hass: HomeAssistant) -> None: context={"source": config_entries.SOURCE_IMPORT}, data=entry_mock_data, ) + await hass.async_block_till_done() assert result["type"] == "create_entry" assert result["title"] == CasetaConfigFlow.ENTRY_DEFAULT_TITLE assert result["data"] == entry_mock_data assert result["result"].unique_id == "000004d2" - await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/nanoleaf/test_config_flow.py b/tests/components/nanoleaf/test_config_flow.py index 200e2c2154..9a7f4a2bc5 100644 --- a/tests/components/nanoleaf/test_config_flow.py +++ b/tests/components/nanoleaf/test_config_flow.py @@ -381,6 +381,8 @@ async def test_import_discovery_integration( type=type_in_discovery, ), ) + await hass.async_block_till_done() + assert result["type"] == "create_entry" assert result["title"] == TEST_NAME assert result["data"] == { @@ -395,7 +397,6 @@ async def test_import_discovery_integration( mock_save_json.assert_called_once() mock_remove.assert_not_called() - await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 1 @@ -431,6 +432,7 @@ async def test_ssdp_discovery(hass: HomeAssistant) -> None: assert result["step_id"] == "link" result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() assert result2["type"] == "create_entry" assert result2["title"] == TEST_NAME @@ -439,5 +441,4 @@ async def test_ssdp_discovery(hass: HomeAssistant) -> None: CONF_TOKEN: TEST_TOKEN, } - await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/nws/test_config_flow.py b/tests/components/nws/test_config_flow.py index 28e9db2536..9c02139d67 100644 --- a/tests/components/nws/test_config_flow.py +++ b/tests/components/nws/test_config_flow.py @@ -108,7 +108,8 @@ async def test_form_already_configured( result["flow_id"], {"api_key": "test"}, ) + await hass.async_block_till_done() + assert result2["type"] == "abort" assert result2["reason"] == "already_configured" - await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 0 diff --git a/tests/components/onewire/test_config_flow.py b/tests/components/onewire/test_config_flow.py index 63e53627e0..d69f9a9320 100644 --- a/tests/components/onewire/test_config_flow.py +++ b/tests/components/onewire/test_config_flow.py @@ -76,7 +76,8 @@ async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No CONF_HOST: "1.2.3.4", CONF_PORT: 1234, } - await hass.async_block_till_done() + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 @@ -102,8 +103,6 @@ async def test_user_duplicate( ) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" - await hass.async_block_till_done() - assert len(mock_setup_entry.mock_calls) == 1 @pytest.mark.usefixtures("filled_device_registry") diff --git a/tests/components/syncthru/test_config_flow.py b/tests/components/syncthru/test_config_flow.py index e1e7b01ac0..ae6172af6d 100644 --- a/tests/components/syncthru/test_config_flow.py +++ b/tests/components/syncthru/test_config_flow.py @@ -119,10 +119,10 @@ async def test_success( context={"source": config_entries.SOURCE_USER}, data=FIXTURE_USER_INPUT, ) + await hass.async_block_till_done() assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result["data"][CONF_URL] == FIXTURE_USER_INPUT[CONF_URL] - await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 1 From 288a4203ab10a8602a1073e926d6ccad9af28911 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 11 Mar 2023 06:23:49 +0100 Subject: [PATCH 0834/2810] Make client tracker use common UniFi entity class (#84942) * Make client tracker use common UniFi entity class * Fix tests * Fix mypy * Remove legacy data * Fix comment: skip else use return * Minor change * Remove missed stuff from previous rebase * Import async_device_available_fn from entities.py rather than specifying it in device_tracker * Avoid using asserts * Keep explicit parenthesis for readability * Allow loading entities on option changes --- .../components/unifi/device_tracker.py | 380 ++++++------------ tests/components/unifi/test_device_tracker.py | 26 +- 2 files changed, 144 insertions(+), 262 deletions(-) diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index c845b6d5d3..a5b153d7f3 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -2,20 +2,21 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass from datetime import timedelta import logging -from typing import Generic, TypeVar +from typing import Any, Generic import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent +from aiounifi.interfaces.clients import Clients from aiounifi.interfaces.devices import Devices -from aiounifi.models.api import SOURCE_DATA, SOURCE_EVENT +from aiounifi.models.client import Client from aiounifi.models.device import Device -from aiounifi.models.event import EventKey +from aiounifi.models.event import Event, EventKey -from homeassistant.components.device_tracker import DOMAIN, ScannerEntity, SourceType +from homeassistant.components.device_tracker import ScannerEntity, SourceType from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -24,8 +25,13 @@ import homeassistant.util.dt as dt_util from .const import DOMAIN as UNIFI_DOMAIN from .controller import UniFiController -from .entity import UnifiEntity, UnifiEntityDescription -from .unifi_client import UniFiClientBase +from .entity import ( + DataT, + HandlerT, + UnifiEntity, + UnifiEntityDescription, + async_device_available_fn, +) LOGGER = logging.getLogger(__name__) @@ -58,6 +64,7 @@ CLIENT_STATIC_ATTRIBUTES = [ CLIENT_CONNECTED_ALL_ATTRIBUTES = CLIENT_CONNECTED_ATTRIBUTES + CLIENT_STATIC_ATTRIBUTES WIRED_CONNECTION = (EventKey.WIRED_CLIENT_CONNECTED,) +WIRED_DISCONNECTION = (EventKey.WIRED_CLIENT_DISCONNECTED,) WIRELESS_CONNECTION = ( EventKey.WIRELESS_CLIENT_CONNECTED, EventKey.WIRELESS_CLIENT_ROAM, @@ -66,17 +73,57 @@ WIRELESS_CONNECTION = ( EventKey.WIRELESS_GUEST_ROAM, EventKey.WIRELESS_GUEST_ROAM_RADIO, ) - - -_DataT = TypeVar("_DataT", bound=Device) -_HandlerT = TypeVar("_HandlerT", bound=Devices) +WIRELESS_DISCONNECTION = ( + EventKey.WIRELESS_CLIENT_DISCONNECTED, + EventKey.WIRELESS_GUEST_DISCONNECTED, +) @callback -def async_device_available_fn(controller: UniFiController, obj_id: str) -> bool: +def async_client_allowed_fn(controller: UniFiController, obj_id: str) -> bool: + """Check if client is allowed.""" + if not controller.option_track_clients: + return False + + client = controller.api.clients[obj_id] + if client.mac not in controller.wireless_clients: + if not controller.option_track_wired_clients: + return False + + elif ( + client.essid + and controller.option_ssid_filter + and client.essid not in controller.option_ssid_filter + ): + return False + + return True + + +@callback +def async_client_is_connected_fn(controller: UniFiController, obj_id: str) -> bool: """Check if device object is disabled.""" - device = controller.api.devices[obj_id] - return controller.available and not device.disabled + client = controller.api.clients[obj_id] + + if client.is_wired != (obj_id not in controller.wireless_clients): + if not controller.option_ignore_wired_bug: + return False # Wired bug in action + + if ( + not client.is_wired + and client.essid + and controller.option_ssid_filter + and client.essid not in controller.option_ssid_filter + ): + return False + + if ( + dt_util.utcnow() - dt_util.utc_from_timestamp(client.last_seen or 0) + > controller.option_detection_time + ): + return False + + return True @callback @@ -89,7 +136,7 @@ def async_device_heartbeat_timedelta_fn( @dataclass -class UnifiEntityTrackerDescriptionMixin(Generic[_HandlerT, _DataT]): +class UnifiEntityTrackerDescriptionMixin(Generic[HandlerT, DataT]): """Device tracker local functions.""" heartbeat_timedelta_fn: Callable[[UniFiController, str], timedelta] @@ -100,13 +147,36 @@ class UnifiEntityTrackerDescriptionMixin(Generic[_HandlerT, _DataT]): @dataclass class UnifiTrackerEntityDescription( - UnifiEntityDescription[_HandlerT, _DataT], - UnifiEntityTrackerDescriptionMixin[_HandlerT, _DataT], + UnifiEntityDescription[HandlerT, DataT], + UnifiEntityTrackerDescriptionMixin[HandlerT, DataT], ): """Class describing UniFi device tracker entity.""" ENTITY_DESCRIPTIONS: tuple[UnifiTrackerEntityDescription, ...] = ( + UnifiTrackerEntityDescription[Clients, Client]( + key="Client device scanner", + has_entity_name=True, + allowed_fn=async_client_allowed_fn, + api_handler_fn=lambda api: api.clients, + available_fn=lambda controller, obj_id: controller.available, + device_info_fn=lambda api, obj_id: None, + event_is_on=(WIRED_CONNECTION + WIRELESS_CONNECTION), + event_to_subscribe=( + WIRED_CONNECTION + + WIRED_DISCONNECTION + + WIRELESS_CONNECTION + + WIRELESS_DISCONNECTION + ), + heartbeat_timedelta_fn=lambda controller, _: controller.option_detection_time, + is_connected_fn=async_client_is_connected_fn, + name_fn=lambda client: client.name or client.hostname, + object_fn=lambda api, obj_id: api.clients[obj_id], + supported_fn=lambda controller, obj_id: True, + unique_id_fn=lambda controller, obj_id: f"{obj_id}-{controller.site}", + ip_address_fn=lambda api, obj_id: api.clients[obj_id].ip, + hostname_fn=lambda api, obj_id: None, + ), UnifiTrackerEntityDescription[Devices, Device]( key="Device scanner", has_entity_name=True, @@ -140,239 +210,13 @@ async def async_setup_entry( UnifiScannerEntity, ENTITY_DESCRIPTIONS, async_add_entities ) - controller.entities[DOMAIN] = {CLIENT_TRACKER: set(), DEVICE_TRACKER: set()} - @callback - def items_added( - clients: set = controller.api.clients, devices: set = controller.api.devices - ) -> None: - """Update the values of the controller.""" - if controller.option_track_clients: - add_client_entities(controller, async_add_entities, clients) - - for signal in (controller.signal_update, controller.signal_options_update): - config_entry.async_on_unload( - async_dispatcher_connect(hass, signal, items_added) - ) - - items_added() - - -@callback -def add_client_entities(controller, async_add_entities, clients): - """Add new client tracker entities from the controller.""" - trackers = [] - - for mac in clients: - if mac in controller.entities[DOMAIN][UniFiClientTracker.TYPE] or not ( - client := controller.api.clients.get(mac) - ): - continue - - if mac not in controller.wireless_clients: - if not controller.option_track_wired_clients: - continue - elif ( - client.essid - and controller.option_ssid_filter - and client.essid not in controller.option_ssid_filter - ): - continue - - trackers.append(UniFiClientTracker(client, controller)) - - async_add_entities(trackers) - - -class UniFiClientTracker(UniFiClientBase, ScannerEntity): - """Representation of a network client.""" - - DOMAIN = DOMAIN - TYPE = CLIENT_TRACKER - - def __init__(self, client, controller): - """Set up tracked client.""" - super().__init__(client, controller) - - self._controller_connection_state_changed = False - - self._only_listen_to_data_source = False - - last_seen = client.last_seen or 0 - self.schedule_update = self._is_connected = ( - self.is_wired == client.is_wired - and dt_util.utcnow() - dt_util.utc_from_timestamp(float(last_seen)) - < controller.option_detection_time - ) - - @callback - def _async_log_debug_data(self, method: str) -> None: - """Print debug data about entity.""" - if not LOGGER.isEnabledFor(logging.DEBUG): - return - last_seen = self.client.last_seen or 0 - LOGGER.debug( - "%s [%s, %s] [%s %s] [%s] %s (%s)", - method, - self.entity_id, - self.client.mac, - self.schedule_update, - self._is_connected, - dt_util.utc_from_timestamp(float(last_seen)), - dt_util.utcnow() - dt_util.utc_from_timestamp(float(last_seen)), - last_seen, - ) - - async def async_added_to_hass(self) -> None: - """Watch object when added.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{self.controller.signal_heartbeat_missed}_{self.unique_id}", - self._make_disconnected, - ) - ) - await super().async_added_to_hass() - self._async_log_debug_data("added_to_hass") - - async def async_will_remove_from_hass(self) -> None: - """Disconnect object when removed.""" - self.controller.async_heartbeat(self.unique_id) - await super().async_will_remove_from_hass() - - @callback - def async_signal_reachable_callback(self) -> None: - """Call when controller connection state change.""" - self._controller_connection_state_changed = True - super().async_signal_reachable_callback() - - @callback - def async_update_callback(self) -> None: - """Update the clients state.""" - - if self._controller_connection_state_changed: - self._controller_connection_state_changed = False - - if self.controller.available: - self.schedule_update = True - - else: - self.controller.async_heartbeat(self.unique_id) - super().async_update_callback() - - elif ( - self.client.last_updated == SOURCE_DATA - and self.is_wired == self.client.is_wired - ): - self._is_connected = True - self.schedule_update = True - self._only_listen_to_data_source = True - - elif ( - self.client.last_updated == SOURCE_EVENT - and not self._only_listen_to_data_source - ): - if (self.is_wired and self.client.event.key in WIRED_CONNECTION) or ( - not self.is_wired and self.client.event.key in WIRELESS_CONNECTION - ): - self._is_connected = True - self.schedule_update = False - self.controller.async_heartbeat(self.unique_id) - super().async_update_callback() - - else: - self.schedule_update = True - - self._async_log_debug_data("update_callback") - - if self.schedule_update: - self.schedule_update = False - self.controller.async_heartbeat( - self.unique_id, dt_util.utcnow() + self.controller.option_detection_time - ) - - super().async_update_callback() - - @callback - def _make_disconnected(self, *_): - """No heart beat by device.""" - self._is_connected = False - self.async_write_ha_state() - self._async_log_debug_data("make_disconnected") - - @property - def is_connected(self): - """Return true if the client is connected to the network.""" - if ( - not self.is_wired - and self.client.essid - and self.controller.option_ssid_filter - and self.client.essid not in self.controller.option_ssid_filter - ): - return False - - return self._is_connected - - @property - def source_type(self) -> SourceType: - """Return the source type of the client.""" - return SourceType.ROUTER - - @property - def unique_id(self) -> str: - """Return a unique identifier for this client.""" - return f"{self.client.mac}-{self.controller.site}" - - @property - def extra_state_attributes(self): - """Return the client state attributes.""" - raw = self.client.raw - - attributes_to_check = CLIENT_STATIC_ATTRIBUTES - if self.is_connected: - attributes_to_check = CLIENT_CONNECTED_ALL_ATTRIBUTES - - attributes = {k: raw[k] for k in attributes_to_check if k in raw} - attributes["is_wired"] = self.is_wired - - return attributes - - @property - def ip_address(self) -> str: - """Return the primary ip address of the device.""" - return self.client.raw.get("ip") - - @property - def mac_address(self) -> str: - """Return the mac address of the device.""" - return self.client.raw.get("mac") - - @property - def hostname(self) -> str: - """Return hostname of the device.""" - return self.client.raw.get("hostname") - - async def options_updated(self) -> None: - """Config entry options are updated, remove entity if option is disabled.""" - if not self.controller.option_track_clients: - await self.remove_item({self.client.mac}) - - elif self.is_wired: - if not self.controller.option_track_wired_clients: - await self.remove_item({self.client.mac}) - - elif ( - self.controller.option_ssid_filter - and self.client.essid not in self.controller.option_ssid_filter - ): - await self.remove_item({self.client.mac}) - - -class UnifiScannerEntity(UnifiEntity[_HandlerT, _DataT], ScannerEntity): +class UnifiScannerEntity(UnifiEntity[HandlerT, DataT], ScannerEntity): """Representation of a UniFi scanner.""" entity_description: UnifiTrackerEntityDescription + _event_is_on: tuple[EventKey, ...] _ignore_events: bool _is_connected: bool @@ -383,8 +227,15 @@ class UnifiScannerEntity(UnifiEntity[_HandlerT, _DataT], ScannerEntity): Initiate is_connected. """ description = self.entity_description + self._event_is_on = description.event_is_on or () self._ignore_events = False self._is_connected = description.is_connected_fn(self.controller, self._obj_id) + if self.is_connected: + self.controller.async_heartbeat( + self.unique_id, + dt_util.utcnow() + + description.heartbeat_timedelta_fn(self.controller, self._obj_id), + ) @property def is_connected(self) -> bool: @@ -452,13 +303,33 @@ class UnifiScannerEntity(UnifiEntity[_HandlerT, _DataT], ScannerEntity): + description.heartbeat_timedelta_fn(self.controller, self._obj_id), ) + @callback + def async_event_callback(self, event: Event) -> None: + """Event subscription callback.""" + if event.mac != self._obj_id or self._ignore_events: + return + + if event.key in self._event_is_on: + self.controller.async_heartbeat(self.unique_id) + self._is_connected = True + self.async_write_ha_state() + return + + self.controller.async_heartbeat( + self.unique_id, + dt_util.utcnow() + + self.entity_description.heartbeat_timedelta_fn( + self.controller, self._obj_id + ), + ) + async def async_added_to_hass(self) -> None: """Register callbacks.""" await super().async_added_to_hass() self.async_on_remove( async_dispatcher_connect( self.hass, - f"{self.controller.signal_heartbeat_missed}_{self._obj_id}", + f"{self.controller.signal_heartbeat_missed}_{self.unique_id}", self._make_disconnected, ) ) @@ -467,3 +338,20 @@ class UnifiScannerEntity(UnifiEntity[_HandlerT, _DataT], ScannerEntity): """Disconnect object when removed.""" await super().async_will_remove_from_hass() self.controller.async_heartbeat(self.unique_id) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return the client state attributes.""" + if self.entity_description.key != "Client device scanner": + return None + + client = self.entity_description.object_fn(self.controller.api, self._obj_id) + raw = client.raw + + attributes_to_check = CLIENT_STATIC_ATTRIBUTES + if self.is_connected: + attributes_to_check = CLIENT_CONNECTED_ALL_ATTRIBUTES + + attributes = {k: raw[k] for k in attributes_to_check if k in raw} + + return attributes diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index 5dcf1fc693..1e68b49711 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -156,7 +156,7 @@ async def test_tracked_clients( # State change signalling works - client_1["last_seen"] += 1 + client_1["last_seen"] = dt_util.as_timestamp(dt_util.utcnow()) mock_unifi_websocket(message=MessageKey.CLIENT, data=client_1) await hass.async_block_till_done() @@ -244,6 +244,7 @@ async def test_tracked_wireless_clients_event_source( # New data + client["last_seen"] = dt_util.as_timestamp(dt_util.utcnow()) mock_unifi_websocket(message=MessageKey.CLIENT, data=client) await hass.async_block_till_done() assert hass.states.get("device_tracker.client").state == STATE_HOME @@ -703,6 +704,11 @@ async def test_option_ssid_filter( mock_unifi_websocket(message=MessageKey.CLIENT, data=client_on_ssid2) await hass.async_block_till_done() + new_time = dt_util.utcnow() + controller.option_detection_time + with patch("homeassistant.util.dt.utcnow", return_value=new_time): + async_fire_time_changed(hass, new_time) + await hass.async_block_till_done() + # SSID filter marks client as away assert hass.states.get("device_tracker.client").state == STATE_NOT_HOME @@ -726,7 +732,7 @@ async def test_option_ssid_filter( # Time pass to mark client as away - new_time = dt_util.utcnow() + controller.option_detection_time + new_time += controller.option_detection_time with patch("homeassistant.util.dt.utcnow", return_value=new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() @@ -745,9 +751,7 @@ async def test_option_ssid_filter( mock_unifi_websocket(message=MessageKey.CLIENT, data=client_on_ssid2) await hass.async_block_till_done() - new_time = ( - dt_util.utcnow() + controller.option_detection_time + timedelta(seconds=1) - ) + new_time += controller.option_detection_time with patch("homeassistant.util.dt.utcnow", return_value=new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() @@ -784,10 +788,9 @@ async def test_wireless_client_go_wired_issue( # Client is wireless client_state = hass.states.get("device_tracker.client") assert client_state.state == STATE_HOME - assert client_state.attributes["is_wired"] is False # Trigger wired bug - client["last_seen"] += 1 + client["last_seen"] = dt_util.as_timestamp(dt_util.utcnow()) client["is_wired"] = True mock_unifi_websocket(message=MessageKey.CLIENT, data=client) await hass.async_block_till_done() @@ -795,7 +798,6 @@ async def test_wireless_client_go_wired_issue( # Wired bug fix keeps client marked as wireless client_state = hass.states.get("device_tracker.client") assert client_state.state == STATE_HOME - assert client_state.attributes["is_wired"] is False # Pass time new_time = dt_util.utcnow() + controller.option_detection_time @@ -806,7 +808,6 @@ async def test_wireless_client_go_wired_issue( # Marked as home according to the timer client_state = hass.states.get("device_tracker.client") assert client_state.state == STATE_NOT_HOME - assert client_state.attributes["is_wired"] is False # Try to mark client as connected client["last_seen"] += 1 @@ -816,7 +817,6 @@ async def test_wireless_client_go_wired_issue( # Make sure it don't go online again until wired bug disappears client_state = hass.states.get("device_tracker.client") assert client_state.state == STATE_NOT_HOME - assert client_state.attributes["is_wired"] is False # Make client wireless client["last_seen"] += 1 @@ -827,7 +827,6 @@ async def test_wireless_client_go_wired_issue( # Client is no longer affected by wired bug and can be marked online client_state = hass.states.get("device_tracker.client") assert client_state.state == STATE_HOME - assert client_state.attributes["is_wired"] is False async def test_option_ignore_wired_bug( @@ -859,7 +858,6 @@ async def test_option_ignore_wired_bug( # Client is wireless client_state = hass.states.get("device_tracker.client") assert client_state.state == STATE_HOME - assert client_state.attributes["is_wired"] is False # Trigger wired bug client["is_wired"] = True @@ -869,7 +867,6 @@ async def test_option_ignore_wired_bug( # Wired bug in effect client_state = hass.states.get("device_tracker.client") assert client_state.state == STATE_HOME - assert client_state.attributes["is_wired"] is True # pass time new_time = dt_util.utcnow() + controller.option_detection_time @@ -880,7 +877,6 @@ async def test_option_ignore_wired_bug( # Timer marks client as away client_state = hass.states.get("device_tracker.client") assert client_state.state == STATE_NOT_HOME - assert client_state.attributes["is_wired"] is True # Mark client as connected again client["last_seen"] += 1 @@ -890,7 +886,6 @@ async def test_option_ignore_wired_bug( # Ignoring wired bug allows client to go home again even while affected client_state = hass.states.get("device_tracker.client") assert client_state.state == STATE_HOME - assert client_state.attributes["is_wired"] is True # Make client wireless client["last_seen"] += 1 @@ -901,7 +896,6 @@ async def test_option_ignore_wired_bug( # Client is wireless and still connected client_state = hass.states.get("device_tracker.client") assert client_state.state == STATE_HOME - assert client_state.attributes["is_wired"] is False async def test_restoring_client( From fccdd7b102376622ae19ac1b45215b9184c19e73 Mon Sep 17 00:00:00 2001 From: rappenze Date: Sat, 11 Mar 2023 09:15:05 +0100 Subject: [PATCH 0835/2810] Fix bug in fibaro cover (#89502) --- homeassistant/components/fibaro/cover.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fibaro/cover.py b/homeassistant/components/fibaro/cover.py index e19c5c32e8..c73c45d254 100644 --- a/homeassistant/components/fibaro/cover.py +++ b/homeassistant/components/fibaro/cover.py @@ -94,9 +94,9 @@ class FibaroCover(FibaroDevice, CoverEntity): """Return if the cover is closed.""" if self._is_open_close_only(): state = self.fibaro_device.state - if not state.has_value or state.str_value.lower() == "unknown": + if not state.has_value or state.str_value().lower() == "unknown": return None - return state.str_value.lower() == "closed" + return state.str_value().lower() == "closed" if self.current_cover_position is None: return None From 01e12214437065e10f7e35c550d3f9c7af0589f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Mar 2023 01:45:27 -1000 Subject: [PATCH 0836/2810] Refactor logbook data to use a dataclass (#89534) --- homeassistant/components/logbook/__init__.py | 20 ++++++++++--------- homeassistant/components/logbook/const.py | 3 --- homeassistant/components/logbook/helpers.py | 7 +++---- homeassistant/components/logbook/models.py | 14 +++++++++++++ homeassistant/components/logbook/processor.py | 11 ++++------ .../components/logbook/websocket_api.py | 7 ++++--- tests/components/logbook/common.py | 4 +++- 7 files changed, 39 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index fb1b9d78b8..ee2ae3da4d 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -19,7 +19,7 @@ from homeassistant.const import ( ATTR_NAME, EVENT_LOGBOOK_ENTRY, ) -from homeassistant.core import Context, Event, HomeAssistant, ServiceCall, callback +from homeassistant.core import Context, HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entityfilter import ( INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, @@ -35,7 +35,6 @@ from . import rest_api, websocket_api from .const import ( # noqa: F401 ATTR_MESSAGE, DOMAIN, - LOGBOOK_ENTITIES_FILTER, LOGBOOK_ENTRY_CONTEXT_ID, LOGBOOK_ENTRY_DOMAIN, LOGBOOK_ENTRY_ENTITY_ID, @@ -43,9 +42,8 @@ from .const import ( # noqa: F401 LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME, LOGBOOK_ENTRY_SOURCE, - LOGBOOK_FILTERS, ) -from .models import LazyEventPartialState # noqa: F401 +from .models import LazyEventPartialState, LogbookConfig CONFIG_SCHEMA = vol.Schema( {DOMAIN: INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA}, extra=vol.ALLOW_EXTRA @@ -97,7 +95,6 @@ def async_log_entry( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Logbook setup.""" - hass.data[DOMAIN] = {} @callback def log_message(service: ServiceCall) -> None: @@ -134,8 +131,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: else: filters = None entities_filter = None - hass.data[LOGBOOK_FILTERS] = filters - hass.data[LOGBOOK_ENTITIES_FILTER] = entities_filter + + external_events: dict[ + str, tuple[str, Callable[[LazyEventPartialState], dict[str, Any]]] + ] = {} + hass.data[DOMAIN] = LogbookConfig(external_events, filters, entities_filter) websocket_api.async_setup(hass) rest_api.async_setup(hass, config, filters, entities_filter) hass.services.async_register(DOMAIN, "log", log_message, schema=LOG_MESSAGE_SCHEMA) @@ -149,14 +149,16 @@ async def _process_logbook_platform( hass: HomeAssistant, domain: str, platform: Any ) -> None: """Process a logbook platform.""" + logbook_config: LogbookConfig = hass.data[DOMAIN] + external_events = logbook_config.external_events @callback def _async_describe_event( domain: str, event_name: str, - describe_callback: Callable[[Event], dict[str, Any]], + describe_callback: Callable[[LazyEventPartialState], dict[str, Any]], ) -> None: """Teach logbook how to describe a new event.""" - hass.data[DOMAIN][event_name] = (domain, describe_callback) + external_events[event_name] = (domain, describe_callback) platform.async_describe_events(hass, _async_describe_event) diff --git a/homeassistant/components/logbook/const.py b/homeassistant/components/logbook/const.py index e1abd98765..2d9911117f 100644 --- a/homeassistant/components/logbook/const.py +++ b/homeassistant/components/logbook/const.py @@ -44,6 +44,3 @@ AUTOMATION_EVENTS = {EVENT_AUTOMATION_TRIGGERED, EVENT_SCRIPT_STARTED} # Events that are built-in to the logbook or core BUILT_IN_EVENTS = {EVENT_LOGBOOK_ENTRY, EVENT_CALL_SERVICE} - -LOGBOOK_FILTERS = "logbook_filters" -LOGBOOK_ENTITIES_FILTER = "entities_filter" diff --git a/homeassistant/components/logbook/helpers.py b/homeassistant/components/logbook/helpers.py index 221612e1e9..c8f55331de 100644 --- a/homeassistant/components/logbook/helpers.py +++ b/homeassistant/components/logbook/helpers.py @@ -27,7 +27,7 @@ from homeassistant.helpers.entityfilter import EntityFilter from homeassistant.helpers.event import async_track_state_change_event from .const import ALWAYS_CONTINUOUS_DOMAINS, AUTOMATION_EVENTS, BUILT_IN_EVENTS, DOMAIN -from .models import LazyEventPartialState +from .models import LogbookConfig def async_filter_entities(hass: HomeAssistant, entity_ids: list[str]) -> list[str]: @@ -63,9 +63,8 @@ def async_determine_event_types( hass: HomeAssistant, entity_ids: list[str] | None, device_ids: list[str] | None ) -> tuple[str, ...]: """Reduce the event types based on the entity ids and device ids.""" - external_events: dict[ - str, tuple[str, Callable[[LazyEventPartialState], dict[str, Any]]] - ] = hass.data.get(DOMAIN, {}) + logbook_config: LogbookConfig = hass.data[DOMAIN] + external_events = logbook_config.external_events if not entity_ids and not device_ids: return (*BUILT_IN_EVENTS, *external_events) diff --git a/homeassistant/components/logbook/models.py b/homeassistant/components/logbook/models.py index a5ce9eddce..ab073f296f 100644 --- a/homeassistant/components/logbook/models.py +++ b/homeassistant/components/logbook/models.py @@ -1,11 +1,13 @@ """Event parser and human readable log generator.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from typing import Any, cast from sqlalchemy.engine.row import Row +from homeassistant.components.recorder.filters import Filters from homeassistant.components.recorder.models import ( bytes_to_ulid_or_none, bytes_to_uuid_hex_or_none, @@ -14,11 +16,23 @@ from homeassistant.components.recorder.models import ( ) from homeassistant.const import ATTR_ICON, EVENT_STATE_CHANGED from homeassistant.core import Context, Event, State, callback +from homeassistant.helpers.entityfilter import EntityFilter import homeassistant.util.dt as dt_util from homeassistant.util.json import json_loads from homeassistant.util.ulid import ulid_to_bytes +@dataclass +class LogbookConfig: + """Configuration for the logbook integration.""" + + external_events: dict[ + str, tuple[str, Callable[[LazyEventPartialState], dict[str, Any]]] + ] + sqlalchemy_filter: Filters | None = None + entity_filter: EntityFilter | None = None + + class LazyEventPartialState: """A lazy version of core Event with limited State joined in.""" diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index 39d8e920aa..f816064ba6 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -52,10 +52,9 @@ from .const import ( LOGBOOK_ENTRY_SOURCE, LOGBOOK_ENTRY_STATE, LOGBOOK_ENTRY_WHEN, - LOGBOOK_FILTERS, ) from .helpers import is_sensor_continuous -from .models import EventAsRow, LazyEventPartialState, async_event_to_row +from .models import EventAsRow, LazyEventPartialState, LogbookConfig, async_event_to_row from .queries import statement_for_request from .queries.common import PSEUDO_EVENT_STATE_CHANGED @@ -97,16 +96,14 @@ class EventProcessor: self.entity_ids = entity_ids self.device_ids = device_ids self.context_id = context_id - self.filters: Filters | None = hass.data[LOGBOOK_FILTERS] + logbook_config: LogbookConfig = hass.data[DOMAIN] + self.filters: Filters | None = logbook_config.sqlalchemy_filter format_time = ( _row_time_fired_timestamp if timestamp else _row_time_fired_isoformat ) - external_events: dict[ - str, tuple[str, Callable[[LazyEventPartialState], dict[str, Any]]] - ] = hass.data.get(DOMAIN, {}) self.logbook_run = LogbookRun( context_lookup=ContextLookup(hass), - external_events=external_events, + external_events=logbook_config.external_events, event_cache=EventCache({}), entity_name_cache=EntityNameCache(self.hass), include_entity_name=include_entity_name, diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index dac0da83c3..6d24285ba1 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -20,13 +20,13 @@ from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.json import JSON_DUMP import homeassistant.util.dt as dt_util -from .const import LOGBOOK_ENTITIES_FILTER +from .const import DOMAIN from .helpers import ( async_determine_event_types, async_filter_entities, async_subscribe_events, ) -from .models import async_event_to_row +from .models import LogbookConfig, async_event_to_row from .processor import EventProcessor MAX_PENDING_LOGBOOK_EVENTS = 2048 @@ -361,7 +361,8 @@ async def ws_event_stream( entities_filter: EntityFilter | None = None if not event_processor.limited_select: - entities_filter = hass.data[LOGBOOK_ENTITIES_FILTER] + logbook_config: LogbookConfig = hass.data[DOMAIN] + entities_filter = logbook_config.entity_filter async_subscribe_events( hass, diff --git a/tests/components/logbook/common.py b/tests/components/logbook/common.py index d08366e2f1..b8d0068111 100644 --- a/tests/components/logbook/common.py +++ b/tests/components/logbook/common.py @@ -6,6 +6,7 @@ from typing import Any from homeassistant.components import logbook from homeassistant.components.logbook import processor +from homeassistant.components.logbook.models import LogbookConfig from homeassistant.components.recorder.models import ( process_timestamp_to_utc_isoformat, ulid_to_bytes_or_none, @@ -64,7 +65,8 @@ def mock_humanify(hass_, rows): ent_reg = er.async_get(hass_) event_cache = processor.EventCache({}) context_lookup = processor.ContextLookup(hass_) - external_events = hass_.data.get(logbook.DOMAIN, {}) + logbook_config = hass_.data.get(logbook.DOMAIN, LogbookConfig({}, None, None)) + external_events = logbook_config.external_events logbook_run = processor.LogbookRun( context_lookup, external_events, From 52cea16f74c54e94324e84cd17aeadc49f6fcdab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Mar 2023 01:46:12 -1000 Subject: [PATCH 0837/2810] Remove unused code in RecorderRuns.entity_ids (#89526) --- .../components/recorder/db_schema.py | 24 ------ .../recorder/table_managers/__init__.py | 1 + tests/components/recorder/test_models.py | 76 ------------------- 3 files changed, 1 insertion(+), 100 deletions(-) create mode 100644 homeassistant/components/recorder/table_managers/__init__.py diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index f794c64714..01b15dd378 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -25,14 +25,11 @@ from sqlalchemy import ( SmallInteger, String, Text, - distinct, type_coerce, ) from sqlalchemy.dialects import mysql, oracle, postgresql, sqlite from sqlalchemy.engine.interfaces import Dialect from sqlalchemy.orm import DeclarativeBase, Mapped, aliased, mapped_column, relationship -from sqlalchemy.orm.query import RowReturningQuery -from sqlalchemy.orm.session import Session from typing_extensions import Self from homeassistant.const import ( @@ -699,27 +696,6 @@ class RecorderRuns(Base): f" created='{self.created.isoformat(sep=' ', timespec='seconds')}')>" ) - def entity_ids(self, point_in_time: datetime | None = None) -> list[str]: - """Return the entity ids that existed in this run. - - Specify point_in_time if you want to know which existed at that point - in time inside the run. - """ - session = Session.object_session(self) - - assert session is not None, "RecorderRuns need to be persisted" - - query: RowReturningQuery[tuple[str]] = session.query(distinct(States.entity_id)) - - query = query.filter(States.last_updated >= self.start) - - if point_in_time is not None: - query = query.filter(States.last_updated < point_in_time) - elif self.end is not None: - query = query.filter(States.last_updated < self.end) - - return [row[0] for row in query] - def to_native(self, validate_entity_id: bool = True) -> Self: """Return self, native format is this model.""" return self diff --git a/homeassistant/components/recorder/table_managers/__init__.py b/homeassistant/components/recorder/table_managers/__init__.py new file mode 100644 index 0000000000..c011520204 --- /dev/null +++ b/homeassistant/components/recorder/table_managers/__init__.py @@ -0,0 +1 @@ +"""Managers for each table.""" diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py index a1ab450804..6f4de420b7 100644 --- a/tests/components/recorder/test_models.py +++ b/tests/components/recorder/test_models.py @@ -4,15 +4,11 @@ from unittest.mock import PropertyMock from freezegun import freeze_time import pytest -from sqlalchemy import create_engine -from sqlalchemy.orm import scoped_session, sessionmaker from homeassistant.components.recorder.const import SupportedDialect from homeassistant.components.recorder.db_schema import ( - Base, EventData, Events, - RecorderRuns, StateAttributes, States, ) @@ -151,78 +147,6 @@ def test_from_event_to_delete_state() -> None: assert db_state.last_updated_ts == event.time_fired.timestamp() -def test_entity_ids(recorder_db_url: str) -> None: - """Test if entity ids helper method works.""" - if recorder_db_url.startswith("mysql://"): - # Dropping the database after this test will fail on MySQL - # because it will create an InnoDB deadlock. - return - engine = create_engine(recorder_db_url) - Base.metadata.create_all(engine) - session_factory = sessionmaker(bind=engine) - - session = scoped_session(session_factory) - session.query(Events).delete() - session.query(States).delete() - session.query(RecorderRuns).delete() - - run = RecorderRuns( - start=datetime(2016, 7, 9, 11, 0, 0, tzinfo=dt.UTC), - end=datetime(2016, 7, 9, 23, 0, 0, tzinfo=dt.UTC), - closed_incorrect=False, - created=datetime(2016, 7, 9, 11, 0, 0, tzinfo=dt.UTC), - ) - - session.add(run) - session.commit() - - before_run = datetime(2016, 7, 9, 8, 0, 0, tzinfo=dt.UTC) - in_run = datetime(2016, 7, 9, 13, 0, 0, tzinfo=dt.UTC) - in_run2 = datetime(2016, 7, 9, 15, 0, 0, tzinfo=dt.UTC) - in_run3 = datetime(2016, 7, 9, 18, 0, 0, tzinfo=dt.UTC) - after_run = datetime(2016, 7, 9, 23, 30, 0, tzinfo=dt.UTC) - - assert run.to_native() == run - assert run.entity_ids() == [] - - session.add( - States( - entity_id="sensor.temperature", - state="20", - last_changed=before_run, - last_updated=before_run, - ) - ) - session.add( - States( - entity_id="sensor.sound", - state="10", - last_changed=after_run, - last_updated=after_run, - ) - ) - - session.add( - States( - entity_id="sensor.humidity", - state="76", - last_changed=in_run, - last_updated=in_run, - ) - ) - session.add( - States( - entity_id="sensor.lux", - state="5", - last_changed=in_run3, - last_updated=in_run3, - ) - ) - - assert sorted(run.entity_ids()) == ["sensor.humidity", "sensor.lux"] - assert run.entity_ids(in_run2) == ["sensor.humidity"] - - def test_states_from_native_invalid_entity_id() -> None: """Test loading a state from an invalid entity ID.""" state = States() From 7487a004fd8a3c0ce693009f8ab5f9eef7ee3e92 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 11 Mar 2023 20:13:27 +0100 Subject: [PATCH 0838/2810] Bump pydeconz to v110 (#89527) * Bump pydeconz to v109 * Bump pydeconz to v110 for additional color modes --- homeassistant/components/deconz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index b0ad90857b..5569f9d5e8 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["pydeconz"], "quality_scale": "platinum", - "requirements": ["pydeconz==108"], + "requirements": ["pydeconz==110"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/requirements_all.txt b/requirements_all.txt index a838266627..7cf150b811 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1573,7 +1573,7 @@ pydaikin==2.9.0 pydanfossair==0.1.0 # homeassistant.components.deconz -pydeconz==108 +pydeconz==110 # homeassistant.components.delijn pydelijn==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1dfb0466be..ac6045d58b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1137,7 +1137,7 @@ pycoolmasternet-async==0.1.5 pydaikin==2.9.0 # homeassistant.components.deconz -pydeconz==108 +pydeconz==110 # homeassistant.components.dexcom pydexcom==0.2.3 From 8564768d9ed295a695fde5aa1a36a54a22322a20 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 11 Mar 2023 20:14:39 +0100 Subject: [PATCH 0839/2810] UniFi library controls add/update signalling (#89525) * Library controls add/update signalling * Remove add/remove signalling * Remove unifi_entity_base and unifi_client to make mypy pass --- homeassistant/components/unifi/controller.py | 62 ++---------- .../components/unifi/unifi_client.py | 58 ----------- .../components/unifi/unifi_entity_base.py | 97 ------------------- tests/components/unifi/test_controller.py | 2 - tests/components/unifi/test_sensor.py | 38 -------- tests/components/unifi/test_switch.py | 6 -- 6 files changed, 8 insertions(+), 255 deletions(-) delete mode 100644 homeassistant/components/unifi/unifi_client.py delete mode 100644 homeassistant/components/unifi/unifi_entity_base.py diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 69c3cc7805..8a047606c6 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -10,7 +10,7 @@ from typing import Any from aiohttp import CookieJar import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent -from aiounifi.interfaces.messages import DATA_CLIENT_REMOVED, DATA_EVENT +from aiounifi.interfaces.messages import DATA_EVENT from aiounifi.models.event import EventKey from aiounifi.websocket import WebsocketSignal, WebsocketState import async_timeout @@ -73,17 +73,6 @@ from .errors import AuthenticationRequired, CannotConnect RETRY_TIMER = 15 CHECK_HEARTBEAT_INTERVAL = timedelta(seconds=1) -CLIENT_CONNECTED = ( - EventKey.WIRED_CLIENT_CONNECTED, - EventKey.WIRELESS_CLIENT_CONNECTED, - EventKey.WIRELESS_GUEST_CONNECTED, -) -DEVICE_CONNECTED = ( - EventKey.ACCESS_POINT_CONNECTED, - EventKey.GATEWAY_CONNECTED, - EventKey.SWITCH_CONNECTED, -) - class UniFiController: """Manages a single UniFi Network instance.""" @@ -258,55 +247,20 @@ class UniFiController: else: LOGGER.info("Connected to UniFi Network") - elif signal == WebsocketSignal.DATA and data: - if DATA_EVENT in data: - clients_connected = set() - devices_connected = set() - wireless_clients_connected = False - - for event in data[DATA_EVENT]: - if event.key in CLIENT_CONNECTED: - clients_connected.add(event.mac) - - if not wireless_clients_connected and event.key in ( - EventKey.WIRELESS_CLIENT_CONNECTED, - EventKey.WIRELESS_GUEST_CONNECTED, - ): - wireless_clients_connected = True - - elif event.key in DEVICE_CONNECTED: - devices_connected.add(event.mac) - - if wireless_clients_connected: + elif signal == WebsocketSignal.DATA and DATA_EVENT in data: + for event in data[DATA_EVENT]: + if event.key in ( + EventKey.WIRELESS_CLIENT_CONNECTED, + EventKey.WIRELESS_GUEST_CONNECTED, + ): self.update_wireless_clients() - if clients_connected or devices_connected: - async_dispatcher_send( - self.hass, - self.signal_update, - clients_connected, - devices_connected, - ) - - elif DATA_CLIENT_REMOVED in data: - async_dispatcher_send( - self.hass, self.signal_remove, data[DATA_CLIENT_REMOVED] - ) + break @property def signal_reachable(self) -> str: """Integration specific event to signal a change in connection status.""" return f"unifi-reachable-{self.config_entry.entry_id}" - @property - def signal_update(self) -> str: - """Event specific per UniFi entry to signal new data.""" - return f"unifi-update-{self.config_entry.entry_id}" - - @property - def signal_remove(self) -> str: - """Event specific per UniFi entry to signal removal of entities.""" - return f"unifi-remove-{self.config_entry.entry_id}" - @property def signal_options_update(self) -> str: """Event specific per UniFi entry to signal new options.""" diff --git a/homeassistant/components/unifi/unifi_client.py b/homeassistant/components/unifi/unifi_client.py deleted file mode 100644 index 6c13bb9785..0000000000 --- a/homeassistant/components/unifi/unifi_client.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Base class for UniFi clients.""" -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo - -from .unifi_entity_base import UniFiBase - - -class UniFiClientBase(UniFiBase): - """Base class for UniFi clients (without device info).""" - - def __init__(self, client, controller) -> None: - """Set up client.""" - super().__init__(client, controller) - - self._is_wired = client.mac not in controller.wireless_clients - self.client = self._item - - @property - def is_wired(self): - """Return if the client is wired. - - Allows disabling logic to keep track of clients affected by UniFi wired bug marking wireless devices as wired. This is useful when running a network not only containing UniFi APs. - """ - if self._is_wired and self.client.mac in self.controller.wireless_clients: - self._is_wired = False - - if self.controller.option_ignore_wired_bug: - return self.client.is_wired - - return self._is_wired - - @property - def unique_id(self): - """Return a unique identifier for this switch.""" - return f"{self.TYPE}-{self.client.mac}" - - @property - def name(self) -> str: - """Return the name of the client.""" - return self.client.name or self.client.hostname - - @property - def available(self) -> bool: - """Return if controller is available.""" - return self.controller.available - - -class UniFiClient(UniFiClientBase): - """Base class for UniFi clients (with device info).""" - - @property - def device_info(self) -> DeviceInfo: - """Return a client description for device registry.""" - return DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, self.client.mac)}, - default_manufacturer=self.client.oui, - default_name=self.client.name or self.client.hostname, - ) diff --git a/homeassistant/components/unifi/unifi_entity_base.py b/homeassistant/components/unifi/unifi_entity_base.py deleted file mode 100644 index 11b5eac2d3..0000000000 --- a/homeassistant/components/unifi/unifi_entity_base.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Base class for UniFi Network entities.""" -from __future__ import annotations - -from collections.abc import Callable -import logging -from typing import TYPE_CHECKING, Any - -from homeassistant.core import callback -from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity - -if TYPE_CHECKING: - from .controller import UniFiController - -_LOGGER = logging.getLogger(__name__) - - -class UniFiBase(Entity): - """UniFi entity base class.""" - - _attr_should_poll = False - - DOMAIN = "" - TYPE = "" - - def __init__(self, item, controller: UniFiController) -> None: - """Set up UniFi Network entity base. - - Register mac to controller entities to cover disabled entities. - """ - self._item = item - self.controller = controller - self.controller.entities[self.DOMAIN][self.TYPE].add(self.key) - - @property - def key(self) -> Any: - """Return item key.""" - return self._item.mac - - async def async_added_to_hass(self) -> None: - """Entity created.""" - _LOGGER.debug( - "New %s entity %s (%s)", - self.TYPE, - self.entity_id, - self.key, - ) - signals: tuple[tuple[str, Callable[..., Any]], ...] = ( - (self.controller.signal_reachable, self.async_signal_reachable_callback), - (self.controller.signal_options_update, self.options_updated), - (self.controller.signal_remove, self.remove_item), - ) - for signal, method in signals: - self.async_on_remove(async_dispatcher_connect(self.hass, signal, method)) - self._item.register_callback(self.async_update_callback) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect object when removed.""" - _LOGGER.debug( - "Removing %s entity %s (%s)", - self.TYPE, - self.entity_id, - self.key, - ) - self._item.remove_callback(self.async_update_callback) - self.controller.entities[self.DOMAIN][self.TYPE].remove(self.key) - - @callback - def async_signal_reachable_callback(self) -> None: - """Call when controller connection state change.""" - self.async_update_callback() - - @callback - def async_update_callback(self) -> None: - """Update the entity's state.""" - _LOGGER.debug( - "Updating %s entity %s (%s)", - self.TYPE, - self.entity_id, - self.key, - ) - self.async_write_ha_state() - - async def options_updated(self) -> None: - """Config entry options are updated, remove entity if option is disabled.""" - raise NotImplementedError - - async def remove_item(self, keys: set) -> None: - """Remove entity if key is part of set.""" - if self.key not in keys: - return - - if self.registry_entry: - er.async_get(self.hass).async_remove(self.entity_id) - else: - await self.async_remove(force_remove=True) diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py index 4a37743ac6..931c0fccdf 100644 --- a/tests/components/unifi/test_controller.py +++ b/tests/components/unifi/test_controller.py @@ -244,8 +244,6 @@ async def test_controller_setup( assert controller.mac is None assert controller.signal_reachable == "unifi-reachable-1" - assert controller.signal_update == "unifi-update-1" - assert controller.signal_remove == "unifi-remove-1" assert controller.signal_options_update == "unifi-options-1" assert controller.signal_heartbeat_missed == "unifi-heartbeat-missed" diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index 18007998eb..b4b8216626 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -14,13 +14,11 @@ from homeassistant.components.unifi.const import ( CONF_ALLOW_UPTIME_SENSORS, CONF_TRACK_CLIENTS, CONF_TRACK_DEVICES, - DOMAIN as UNIFI_DOMAIN, ) from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity_registry import RegistryEntryDisabler import homeassistant.util.dt as dt_util @@ -198,24 +196,6 @@ async def test_bandwidth_sensors( assert hass.states.get("sensor.wired_client_rx") assert hass.states.get("sensor.wired_client_tx") - # Try to add the sensors again, using a signal - - clients_connected = {wired_client["mac"], wireless_client["mac"]} - devices_connected = set() - - controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - - async_dispatcher_send( - hass, - controller.signal_update, - clients_connected, - devices_connected, - ) - await hass.async_block_till_done() - - assert len(hass.states.async_all()) == 5 - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 4 - @pytest.mark.parametrize( ("initial_uptime", "event_uptime", "new_uptime"), @@ -311,24 +291,6 @@ async def test_uptime_sensors( assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 assert hass.states.get("sensor.client1_uptime") - # Try to add the sensors again, using a signal - - clients_connected = {uptime_client["mac"]} - devices_connected = set() - - controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - - async_dispatcher_send( - hass, - controller.signal_update, - clients_connected, - devices_connected, - ) - await hass.async_block_till_done() - - assert len(hass.states.async_all()) == 2 - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 - async def test_remove_sensors( hass: HomeAssistant, diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index 76fa42ad4f..6ea52c95a9 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -30,7 +30,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity_registry import RegistryEntryDisabler from homeassistant.util import dt @@ -719,11 +718,6 @@ async def test_switches( assert aioclient_mock.call_count == 14 assert aioclient_mock.mock_calls[13][2] == {"enabled": True} - # Make sure no duplicates arise on generic signal update - async_dispatcher_send(hass, controller.signal_update) - await hass.async_block_till_done() - assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 3 - async def test_remove_switches( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket From 56454c8580739575b261fb0d65aed18a997f882f Mon Sep 17 00:00:00 2001 From: Kevin Worrel <37058192+dieselrabbit@users.noreply.github.com> Date: Sat, 11 Mar 2023 14:27:33 -0500 Subject: [PATCH 0840/2810] Reconnect on any ScreenLogic exception (#89269) Co-authored-by: J. Nick Koston --- homeassistant/components/screenlogic/__init__.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/screenlogic/__init__.py b/homeassistant/components/screenlogic/__init__.py index fad4dc6509..ad2f9c64f3 100644 --- a/homeassistant/components/screenlogic/__init__.py +++ b/homeassistant/components/screenlogic/__init__.py @@ -159,11 +159,9 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator): """Fetch data from the Screenlogic gateway.""" try: await self._async_update_configured_data() - except ScreenLogicError as error: - _LOGGER.warning("Update error - attempting reconnect: %s", error) + except (ScreenLogicError, ScreenLogicWarning) as ex: + _LOGGER.warning("Update error - attempting reconnect: %s", ex) await self._async_reconnect_update_data() - except ScreenLogicWarning as warn: - raise UpdateFailed(f"Incomplete update: {warn}") from warn return None From 8bd43760b60a29b75acdfb5b36db3097235f3484 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Mar 2023 09:54:55 -1000 Subject: [PATCH 0841/2810] Deduplicate event_types in the events table (#89465) * Deduplicate event_types in the events table * Deduplicate event_types in the events table * more fixes * adjust * adjust * fix product * fix tests * adjust * migrate * migrate * migrate * more test fixes * more test fixes * fix * migration test * adjust * speed up * fix index * fix more tests * handle db failure * preload * tweak * adjust * fix stale docs strings, remove dead code * refactor * fix slow tests * coverage * self join to resolve query performance * fix typo * no need for quiet * no need to drop index already dropped * remove index that will never be used * drop index sooner as we no longer use it * Revert "remove index that will never be used" This reverts commit 461aad2c52d7d6c2c6ca1df4cb30b77c52027d46. * typo --- .../components/logbook/queries/common.py | 11 +- .../components/logbook/queries/devices.py | 5 +- .../components/logbook/queries/entities.py | 5 +- .../logbook/queries/entities_and_devices.py | 11 +- homeassistant/components/recorder/core.py | 57 ++++++++- .../components/recorder/db_schema.py | 37 +++++- .../components/recorder/migration.py | 58 +++++++++ homeassistant/components/recorder/purge.py | 26 ++++ homeassistant/components/recorder/queries.py | 66 +++++++++++ .../recorder/table_managers/event_types.py | 87 ++++++++++++++ homeassistant/components/recorder/tasks.py | 16 +++ .../history/test_init_db_schema_30.py | 4 +- .../db_schema_23_with_newer_columns.py | 19 +++ tests/components/recorder/db_schema_28.py | 43 +++++++ tests/components/recorder/db_schema_30.py | 22 ++++ .../recorder/test_history_db_schema_30.py | 4 +- tests/components/recorder/test_init.py | 69 ++++++++--- tests/components/recorder/test_migrate.py | 78 +++++++++++- tests/components/recorder/test_models.py | 3 + tests/components/recorder/test_purge.py | 112 ++++++++++++++++++ .../components/recorder/test_v32_migration.py | 11 +- tests/conftest.py | 20 ++++ 22 files changed, 725 insertions(+), 39 deletions(-) create mode 100644 homeassistant/components/recorder/table_managers/event_types.py diff --git a/homeassistant/components/logbook/queries/common.py b/homeassistant/components/logbook/queries/common.py index a0c8ddbdda..8645c8f68c 100644 --- a/homeassistant/components/logbook/queries/common.py +++ b/homeassistant/components/logbook/queries/common.py @@ -17,10 +17,12 @@ from homeassistant.components.recorder.db_schema import ( STATES_CONTEXT_ID_BIN_INDEX, EventData, Events, + EventTypes, StateAttributes, States, ) from homeassistant.components.recorder.filters import like_domain_matchers +from homeassistant.components.recorder.queries import select_event_type_ids from ..const import ALWAYS_CONTINUOUS_DOMAINS, CONDITIONALLY_CONTINUOUS_DOMAINS @@ -44,7 +46,7 @@ PSEUDO_EVENT_STATE_CHANGED: Final = None EVENT_COLUMNS = ( Events.event_id.label("event_id"), - Events.event_type.label("event_type"), + EventTypes.event_type.label("event_type"), Events.event_data.label("event_data"), Events.time_fired_ts.label("time_fired_ts"), Events.context_id_bin.label("context_id_bin"), @@ -115,7 +117,8 @@ def select_events_context_id_subquery( return ( select(Events.context_id_bin) .where((Events.time_fired_ts > start_day) & (Events.time_fired_ts < end_day)) - .where(Events.event_type.in_(event_types)) + .where(Events.event_type_id.in_(select_event_type_ids(event_types))) + .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) .outerjoin(EventData, (Events.data_id == EventData.data_id)) ) @@ -147,7 +150,8 @@ def select_events_without_states( return ( select(*EVENT_ROWS_NO_STATES, NOT_CONTEXT_ONLY) .where((Events.time_fired_ts > start_day) & (Events.time_fired_ts < end_day)) - .where(Events.event_type.in_(event_types)) + .where(Events.event_type_id.in_(select_event_type_ids(event_types))) + .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) .outerjoin(EventData, (Events.data_id == EventData.data_id)) ) @@ -182,6 +186,7 @@ def legacy_select_events_context_id( .outerjoin( StateAttributes, (States.attributes_id == StateAttributes.attributes_id) ) + .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) .where((Events.time_fired_ts > start_day) & (Events.time_fired_ts < end_day)) .where(Events.context_id_bin == context_id_bin) ) diff --git a/homeassistant/components/logbook/queries/devices.py b/homeassistant/components/logbook/queries/devices.py index d84a534310..687c48b892 100644 --- a/homeassistant/components/logbook/queries/devices.py +++ b/homeassistant/components/logbook/queries/devices.py @@ -13,6 +13,7 @@ from homeassistant.components.recorder.db_schema import ( DEVICE_ID_IN_EVENT, EventData, Events, + EventTypes, States, ) @@ -60,7 +61,9 @@ def _apply_devices_context_union( select_events_context_only() .select_from(devices_cte) .outerjoin(Events, devices_cte.c.context_id_bin == Events.context_id_bin) - ).outerjoin(EventData, (Events.data_id == EventData.data_id)), + .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) + .outerjoin(EventData, (Events.data_id == EventData.data_id)), + ), apply_states_context_hints( select_states_context_only() .select_from(devices_cte) diff --git a/homeassistant/components/logbook/queries/entities.py b/homeassistant/components/logbook/queries/entities.py index 10ca6fad13..e0ae32b669 100644 --- a/homeassistant/components/logbook/queries/entities.py +++ b/homeassistant/components/logbook/queries/entities.py @@ -15,6 +15,7 @@ from homeassistant.components.recorder.db_schema import ( OLD_ENTITY_ID_IN_EVENT, EventData, Events, + EventTypes, States, ) @@ -78,7 +79,9 @@ def _apply_entities_context_union( select_events_context_only() .select_from(entities_cte) .outerjoin(Events, entities_cte.c.context_id_bin == Events.context_id_bin) - ).outerjoin(EventData, (Events.data_id == EventData.data_id)), + .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) + .outerjoin(EventData, (Events.data_id == EventData.data_id)) + ), apply_states_context_hints( select_states_context_only() .select_from(entities_cte) diff --git a/homeassistant/components/logbook/queries/entities_and_devices.py b/homeassistant/components/logbook/queries/entities_and_devices.py index b4a1c7bc9f..677feddda8 100644 --- a/homeassistant/components/logbook/queries/entities_and_devices.py +++ b/homeassistant/components/logbook/queries/entities_and_devices.py @@ -8,7 +8,12 @@ from sqlalchemy.sql.elements import ColumnElement from sqlalchemy.sql.lambdas import StatementLambdaElement from sqlalchemy.sql.selectable import CTE, CompoundSelect, Select -from homeassistant.components.recorder.db_schema import EventData, Events, States +from homeassistant.components.recorder.db_schema import ( + EventData, + Events, + EventTypes, + States, +) from .common import ( apply_events_context_hints, @@ -80,7 +85,9 @@ def _apply_entities_devices_context_union( .outerjoin( Events, devices_entities_cte.c.context_id_bin == Events.context_id_bin ) - ).outerjoin(EventData, (Events.data_id == EventData.data_id)), + .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) + .outerjoin(EventData, (Events.data_id == EventData.data_id)), + ), apply_states_context_hints( select_states_context_only() .select_from(devices_entities_cte) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 7e3f08d7ab..97d72c7f85 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -61,6 +61,7 @@ from .db_schema import ( Base, EventData, Events, + EventTypes, StateAttributes, States, Statistics, @@ -81,8 +82,10 @@ from .queries import ( find_shared_data_id, get_shared_attributes, get_shared_event_datas, + has_event_type_to_migrate, ) from .run_history import RunHistory +from .table_managers.event_types import EventTypeManager from .tasks import ( AdjustLRUSizeTask, AdjustStatisticsTask, @@ -92,6 +95,7 @@ from .tasks import ( ContextIDMigrationTask, DatabaseLockTask, EventTask, + EventTypeIDMigrationTask, ImportStatisticsTask, KeepAliveTask, PerodicCleanupTask, @@ -135,6 +139,7 @@ EXPIRE_AFTER_COMMITS = 120 STATE_ATTRIBUTES_ID_CACHE_SIZE = 2048 EVENT_DATA_ID_CACHE_SIZE = 2048 + SHUTDOWN_TASK = object() COMMIT_TASK = CommitTask() @@ -209,6 +214,7 @@ class Recorder(threading.Thread): self._old_states: dict[str | None, States] = {} self._state_attributes_ids: LRU = LRU(STATE_ATTRIBUTES_ID_CACHE_SIZE) self._event_data_ids: LRU = LRU(EVENT_DATA_ID_CACHE_SIZE) + self.event_type_manager = EventTypeManager() self._pending_state_attributes: dict[str, StateAttributes] = {} self._pending_event_data: dict[str, EventData] = {} self._pending_expunge: list[States] = [] @@ -688,10 +694,26 @@ class Recorder(threading.Thread): _LOGGER.debug("Recorder processing the queue") self._adjust_lru_size() self.hass.add_job(self._async_set_recorder_ready_migration_done) - self.queue_task(ContextIDMigrationTask()) + self._activate_table_managers_or_migrate() self._run_event_loop() self._shutdown() + def _activate_table_managers_or_migrate(self) -> None: + """Activate the table managers or schedule migrations.""" + # Currently we always check if context ids need to be migrated + # since there are multiple tables. This could be optimized + # to check both the states and events table to see if there + # are any missing and avoid inserting the task but it currently + # is not needed since there is no dependent code branching + # on the result of the migration. + self.queue_task(ContextIDMigrationTask()) + with session_scope(session=self.get_session()) as session: + if session.execute(has_event_type_to_migrate()).scalar(): + self.queue_task(EventTypeIDMigrationTask()) + else: + _LOGGER.debug("Activating event type manager as all data is migrated") + self.event_type_manager.active = True + def _run_event_loop(self) -> None: """Run the event loop for the recorder.""" # Use a session for the event read loop @@ -724,8 +746,10 @@ class Recorder(threading.Thread): else: non_state_change_events.append(event_) + assert self.event_session is not None self._pre_process_state_change_events(state_change_events) self._pre_process_non_state_change_events(non_state_change_events) + self.event_type_manager.load(non_state_change_events, self.event_session) def _pre_process_state_change_events(self, events: list[Event]) -> None: """Load startup state attributes from the database. @@ -944,13 +968,30 @@ class Recorder(threading.Thread): def _process_non_state_changed_event_into_session(self, event: Event) -> None: """Process any event into the session except state changed.""" - assert self.event_session is not None + event_session = self.event_session + assert event_session is not None dbevent = Events.from_event(event) + + # Map the event_type to the EventTypes table + event_type_manager = self.event_type_manager + if pending_event_types := event_type_manager.get_pending(event.event_type): + dbevent.event_type_rel = pending_event_types + elif event_type_id := event_type_manager.get(event.event_type, event_session): + dbevent.event_type_id = event_type_id + else: + event_types = EventTypes(event_type=event.event_type) + event_type_manager.add_pending(event_types) + event_session.add(event_types) + dbevent.event_type_rel = event_types + if not event.data: - self.event_session.add(dbevent) + event_session.add(dbevent) return + if not (shared_data_bytes := self._serialize_event_data_from_event(event)): return + + # Map the event data to the EventData table shared_data = shared_data_bytes.decode("utf-8") # Matching attributes found in the pending commit if pending_event_data := self._pending_event_data.get(shared_data): @@ -969,9 +1010,9 @@ class Recorder(threading.Thread): dbevent.event_data_rel = self._pending_event_data[ shared_data ] = dbevent_data - self.event_session.add(dbevent_data) + event_session.add(dbevent_data) - self.event_session.add(dbevent) + event_session.add(dbevent) def _serialize_state_attributes_from_event(self, event: Event) -> bytes | None: """Serialize state changed event data.""" @@ -1096,6 +1137,7 @@ class Recorder(threading.Thread): for event_data in self._pending_event_data.values(): self._event_data_ids[event_data.shared_data] = event_data.data_id self._pending_event_data = {} + self.event_type_manager.post_commit_pending() # Expire is an expensive operation (frequently more expensive # than the flush and commit itself) so we only @@ -1122,6 +1164,7 @@ class Recorder(threading.Thread): self._event_data_ids.clear() self._pending_state_attributes.clear() self._pending_event_data.clear() + self.event_type_manager.reset() if not self.event_session: return @@ -1152,6 +1195,10 @@ class Recorder(threading.Thread): """Migrate context ids if needed.""" return migration.migrate_context_ids(self) + def _migrate_event_type_ids(self) -> bool: + """Migrate event type ids if needed.""" + return migration.migrate_event_type_ids(self) + def _send_keep_alive(self) -> None: """Send a keep alive to keep the db connection open.""" assert self.event_session is not None diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 01b15dd378..9499e9d4e3 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -68,12 +68,13 @@ class Base(DeclarativeBase): """Base class for tables.""" -SCHEMA_VERSION = 36 +SCHEMA_VERSION = 37 _LOGGER = logging.getLogger(__name__) TABLE_EVENTS = "events" TABLE_EVENT_DATA = "event_data" +TABLE_EVENT_TYPES = "event_types" TABLE_STATES = "states" TABLE_STATE_ATTRIBUTES = "state_attributes" TABLE_RECORDER_RUNS = "recorder_runs" @@ -93,6 +94,7 @@ ALL_TABLES = [ TABLE_STATE_ATTRIBUTES, TABLE_EVENTS, TABLE_EVENT_DATA, + TABLE_EVENT_TYPES, TABLE_RECORDER_RUNS, TABLE_SCHEMA_CHANGES, TABLE_STATISTICS, @@ -176,7 +178,9 @@ class Events(Base): __table_args__ = ( # Used for fetching events at a specific time # see logbook - Index("ix_events_event_type_time_fired_ts", "event_type", "time_fired_ts"), + Index( + "ix_events_event_type_id_time_fired_ts", "event_type_id", "time_fired_ts" + ), Index( EVENTS_CONTEXT_ID_BIN_INDEX, "context_id_bin", @@ -187,7 +191,9 @@ class Events(Base): ) __tablename__ = TABLE_EVENTS event_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) - event_type: Mapped[str | None] = mapped_column(String(MAX_LENGTH_EVENT_EVENT_TYPE)) + event_type: Mapped[str | None] = mapped_column( + String(MAX_LENGTH_EVENT_EVENT_TYPE) + ) # no longer used event_data: Mapped[str | None] = mapped_column( Text().with_variant(mysql.LONGTEXT, "mysql", "mariadb") ) @@ -220,13 +226,17 @@ class Events(Base): context_parent_id_bin: Mapped[bytes | None] = mapped_column( LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) ) + event_type_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("event_types.event_type_id"), index=True + ) event_data_rel: Mapped[EventData | None] = relationship("EventData") + event_type_rel: Mapped[EventTypes | None] = relationship("EventTypes") def __repr__(self) -> str: """Return string representation of instance for debugging.""" return ( "" ) @@ -247,7 +257,7 @@ class Events(Base): def from_event(event: Event) -> Events: """Create an event database object from a native event.""" return Events( - event_type=event.event_type, + event_type=None, event_data=None, origin_idx=EVENT_ORIGIN_TO_IDX.get(event.origin), time_fired=None, @@ -330,6 +340,23 @@ class EventData(Base): return {} +class EventTypes(Base): + """Event type history.""" + + __table_args__ = (_DEFAULT_TABLE_ARGS,) + __tablename__ = TABLE_EVENT_TYPES + event_type_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) + event_type: Mapped[str | None] = mapped_column(String(MAX_LENGTH_EVENT_EVENT_TYPE)) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + "" + ) + + class States(Base): """State change history.""" diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index a5ff110e57..e7a34f22fc 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -35,6 +35,7 @@ from .db_schema import ( TABLE_STATES, Base, Events, + EventTypes, SchemaChanges, States, Statistics, @@ -44,6 +45,7 @@ from .db_schema import ( ) from .models import process_timestamp from .queries import ( + find_event_type_to_migrate, find_events_context_ids_to_migrate, find_states_context_ids_to_migrate, ) @@ -978,6 +980,11 @@ def _apply_update( # noqa: C901 ) _create_index(session_maker, "events", "ix_events_context_id_bin") _create_index(session_maker, "states", "ix_states_context_id_bin") + elif new_version == 37: + _add_columns(session_maker, "events", [f"event_type_id {big_int}"]) + _create_index(session_maker, "events", "ix_events_event_type_id") + _drop_index(session_maker, "events", "ix_events_event_type_time_fired_ts") + _create_index(session_maker, "events", "ix_events_event_type_id_time_fired_ts") else: raise ValueError(f"No schema migration defined for version {new_version}") @@ -1288,6 +1295,57 @@ def migrate_context_ids(instance: Recorder) -> bool: return is_done +def migrate_event_type_ids(instance: Recorder) -> bool: + """Migrate event_type to event_type_ids.""" + session_maker = instance.get_session + _LOGGER.debug("Migrating event_types") + event_type_manager = instance.event_type_manager + with session_scope(session=session_maker()) as session: + if events := session.execute(find_event_type_to_migrate()).all(): + event_types = {event_type for _, event_type in events} + event_type_to_id = event_type_manager.get_many(event_types, session) + if missing_event_types := { + event_type + for event_type, event_id in event_type_to_id.items() + if event_id is None + }: + missing_db_event_types = [ + EventTypes(event_type=event_type) + for event_type in missing_event_types + ] + session.add_all(missing_db_event_types) + session.flush() # Assign ids + for db_event_type in missing_db_event_types: + # We cannot add the assigned ids to the event_type_manager + # because the commit could get rolled back + assert db_event_type.event_type is not None + event_type_to_id[ + db_event_type.event_type + ] = db_event_type.event_type_id + + session.execute( + update(Events), + [ + { + "event_id": event_id, + "event_type": None, + "event_type_id": event_type_to_id[event_type], + } + for event_id, event_type in events + ], + ) + + # If there is more work to do return False + # so that we can be called again + is_done = not events + + if is_done: + instance.event_type_manager.active = True + + _LOGGER.debug("Migrating event_types done=%s", is_done) + return is_done + + def _initialize_database(session: Session) -> bool: """Initialize a new database. diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 7ae63ef026..368a6ccdf1 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -24,12 +24,14 @@ from .queries import ( data_ids_exist_in_events_with_fast_in_distinct, delete_event_data_rows, delete_event_rows, + delete_event_types_rows, delete_recorder_runs_rows, delete_states_attributes_rows, delete_states_rows, delete_statistics_runs_rows, delete_statistics_short_term_rows, disconnect_states_rows, + find_event_types_to_purge, find_events_to_purge, find_latest_statistics_runs_run_id, find_legacy_event_state_and_attributes_and_data_ids_to_purge, @@ -109,6 +111,11 @@ def purge_old_data( _LOGGER.debug("Cleanup filtered data hasn't fully completed yet") return False + # This purge cycle is finished, clean up old event types and + # recorder runs + if instance.event_type_manager.active: + _purge_old_event_types(instance, session) + _purge_old_recorder_runs(instance, session, purge_before) if repack: repack_database(instance) @@ -564,6 +571,25 @@ def _purge_old_recorder_runs( _LOGGER.debug("Deleted %s recorder_runs", deleted_rows) +def _purge_old_event_types(instance: Recorder, session: Session) -> None: + """Purge all old event types.""" + # Event types is small, no need to batch run it + purge_event_types = set() + event_type_ids = set() + for event_type_id, event_type in session.execute(find_event_types_to_purge()): + purge_event_types.add(event_type) + event_type_ids.add(event_type_id) + + if not event_type_ids: + return + + deleted_rows = session.execute(delete_event_types_rows(event_type_ids)) + _LOGGER.debug("Deleted %s event types", deleted_rows) + + # Evict any entries in the event_type cache referring to a purged state + instance.event_type_manager.evict_purged(purge_event_types) + + def _purge_filtered_data(instance: Recorder, session: Session) -> bool: """Remove filtered states and events that shouldn't be in the database.""" _LOGGER.debug("Cleanup filtered data") diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py index 217b7ed11b..d0672e6158 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -12,6 +12,7 @@ from .const import SQLITE_MAX_BIND_VARS from .db_schema import ( EventData, Events, + EventTypes, RecorderRuns, StateAttributes, States, @@ -20,6 +21,17 @@ from .db_schema import ( ) +def select_event_type_ids(event_types: tuple[str, ...]) -> Select: + """Generate a select for event type ids. + + This query is intentionally not a lambda statement as it is used inside + other lambda statements. + """ + return select(EventTypes.event_type_id).where( + EventTypes.event_type.in_(event_types) + ) + + def get_shared_attributes(hashes: list[int]) -> StatementLambdaElement: """Load shared attributes from the database.""" return lambda_stmt( @@ -38,6 +50,15 @@ def get_shared_event_datas(hashes: list[int]) -> StatementLambdaElement: ) +def find_event_type_ids(event_types: Iterable[str]) -> StatementLambdaElement: + """Find an event_type id by event_type.""" + return lambda_stmt( + lambda: select(EventTypes.event_type_id, EventTypes.event_type).filter( + EventTypes.event_type.in_(event_types) + ) + ) + + def find_shared_attributes_id( data_hash: int, shared_attrs: str ) -> StatementLambdaElement: @@ -683,6 +704,25 @@ def find_events_context_ids_to_migrate() -> StatementLambdaElement: ) +def find_event_type_to_migrate() -> StatementLambdaElement: + """Find events event_type to migrate.""" + return lambda_stmt( + lambda: select( + Events.event_id, + Events.event_type, + ) + .filter(Events.event_type_id.is_(None)) + .limit(SQLITE_MAX_BIND_VARS) + ) + + +def has_event_type_to_migrate() -> StatementLambdaElement: + """Check if there are event_types to migrate.""" + return lambda_stmt( + lambda: select(Events.event_id).filter(Events.event_type_id.is_(None)).limit(1) + ) + + def find_states_context_ids_to_migrate() -> StatementLambdaElement: """Find events context_ids to migrate.""" return lambda_stmt( @@ -695,3 +735,29 @@ def find_states_context_ids_to_migrate() -> StatementLambdaElement: .filter(States.context_id_bin.is_(None)) .limit(SQLITE_MAX_BIND_VARS) ) + + +def find_event_types_to_purge() -> StatementLambdaElement: + """Find event_type_ids to purge.""" + return lambda_stmt( + lambda: select(EventTypes.event_type_id, EventTypes.event_type).where( + EventTypes.event_type_id.not_in( + select(EventTypes.event_type_id).join( + used_event_type_ids := select( + distinct(Events.event_type_id).label("used_event_type_id") + ).subquery(), + EventTypes.event_type_id + == used_event_type_ids.c.used_event_type_id, + ) + ) + ) + ) + + +def delete_event_types_rows(event_type_ids: Iterable[int]) -> StatementLambdaElement: + """Delete EventTypes rows.""" + return lambda_stmt( + lambda: delete(EventTypes) + .where(EventTypes.event_type_id.in_(event_type_ids)) + .execution_options(synchronize_session=False) + ) diff --git a/homeassistant/components/recorder/table_managers/event_types.py b/homeassistant/components/recorder/table_managers/event_types.py new file mode 100644 index 0000000000..15dfff28b8 --- /dev/null +++ b/homeassistant/components/recorder/table_managers/event_types.py @@ -0,0 +1,87 @@ +"""Support managing EventTypes.""" +from __future__ import annotations + +from collections.abc import Iterable +from typing import cast + +from lru import LRU # pylint: disable=no-name-in-module +from sqlalchemy.orm.session import Session + +from homeassistant.core import Event + +from ..db_schema import EventTypes +from ..queries import find_event_type_ids + +CACHE_SIZE = 2048 + + +class EventTypeManager: + """Manage the EventTypes table.""" + + def __init__(self) -> None: + """Initialize the event type manager.""" + self._id_map: dict[str, int] = LRU(CACHE_SIZE) + self._pending: dict[str, EventTypes] = {} + self.active = False + + def load(self, events: list[Event], session: Session) -> None: + """Load the event_type to event_type_ids mapping into memory.""" + self.get_many( + (event.event_type for event in events if event.event_type is not None), + session, + ) + + def get(self, event_type: str, session: Session) -> int | None: + """Resolve event_type to the event_type_id.""" + return self.get_many((event_type,), session)[event_type] + + def get_many( + self, event_types: Iterable[str], session: Session + ) -> dict[str, int | None]: + """Resolve event_types to event_type_ids.""" + results: dict[str, int | None] = {} + missing: list[str] = [] + for event_type in event_types: + if (event_type_id := self._id_map.get(event_type)) is None: + missing.append(event_type) + + results[event_type] = event_type_id + + if not missing: + return results + + with session.no_autoflush: + for event_type_id, event_type in session.execute( + find_event_type_ids(missing) + ): + results[event_type] = self._id_map[event_type] = cast( + int, event_type_id + ) + + return results + + def get_pending(self, event_type: str) -> EventTypes | None: + """Get pending EventTypes that have not be assigned ids yet.""" + return self._pending.get(event_type) + + def add_pending(self, db_event_type: EventTypes) -> None: + """Add a pending EventTypes that will be committed at the next interval.""" + assert db_event_type.event_type is not None + event_type: str = db_event_type.event_type + self._pending[event_type] = db_event_type + + def post_commit_pending(self) -> None: + """Call after commit to load the event_type_ids of the new EventTypes into the LRU.""" + for event_type, db_event_types in self._pending.items(): + self._id_map[event_type] = db_event_types.event_type_id + self._pending.clear() + + def reset(self) -> None: + """Reset the event manager after the database has been reset or changed.""" + self._id_map.clear() + self._pending.clear() + + def evict_purged(self, event_types: Iterable[str]) -> None: + """Evict purged event_types from the cache when they are no longer used.""" + for event_type in event_types: + self._id_map.pop(event_type, None) diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index 37a0277257..81a105742b 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -356,3 +356,19 @@ class ContextIDMigrationTask(RecorderTask): if not instance._migrate_context_ids(): # pylint: disable=[protected-access] # Schedule a new migration task if this one didn't finish instance.queue_task(ContextIDMigrationTask()) + + +@dataclass +class EventTypeIDMigrationTask(RecorderTask): + """An object to insert into the recorder queue to migrate event type ids.""" + + commit_before = True + # We have to commit before to make sure there are + # no new pending event_types about to be added to + # the db since this happens live + + def run(self, instance: Recorder) -> None: + """Run event type id migration task.""" + if not instance._migrate_event_type_ids(): # pylint: disable=[protected-access] + # Schedule a new migration task if this one didn't finish + instance.queue_task(EventTypeIDMigrationTask()) diff --git a/tests/components/history/test_init_db_schema_30.py b/tests/components/history/test_init_db_schema_30.py index 392c06f843..7c1b7a5e97 100644 --- a/tests/components/history/test_init_db_schema_30.py +++ b/tests/components/history/test_init_db_schema_30.py @@ -69,7 +69,9 @@ def db_schema_30(): with patch.object(recorder, "db_schema", old_db_schema), patch.object( recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION - ), patch.object(core, "EventData", old_db_schema.EventData), patch.object( + ), patch.object(core, "EventTypes", old_db_schema.EventTypes), patch.object( + core, "EventData", old_db_schema.EventData + ), patch.object( core, "States", old_db_schema.States ), patch.object( core, "Events", old_db_schema.Events diff --git a/tests/components/recorder/db_schema_23_with_newer_columns.py b/tests/components/recorder/db_schema_23_with_newer_columns.py index 0cd3f41490..c8c87ca82d 100644 --- a/tests/components/recorder/db_schema_23_with_newer_columns.py +++ b/tests/components/recorder/db_schema_23_with_newer_columns.py @@ -69,10 +69,12 @@ TABLE_STATISTICS_META = "statistics_meta" TABLE_STATISTICS_RUNS = "statistics_runs" TABLE_STATISTICS_SHORT_TERM = "statistics_short_term" TABLE_EVENT_DATA = "event_data" +TABLE_EVENT_TYPES = "event_types" ALL_TABLES = [ TABLE_STATES, TABLE_EVENTS, + TABLE_EVENT_TYPES, TABLE_RECORDER_RUNS, TABLE_SCHEMA_CHANGES, TABLE_STATISTICS, @@ -141,9 +143,13 @@ class Events(Base): # type: ignore context_parent_id_bin = Column( LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) ) # *** Not originally in v23, only added for recorder to startup ok + event_type_id = Column( + Integer, ForeignKey("event_types.event_type_id"), index=True + ) # *** Not originally in v23, only added for recorder to startup ok event_data_rel = relationship( "EventData" ) # *** Not originally in v23, only added for recorder to startup ok + event_type_rel = relationship("EventTypes") def __repr__(self) -> str: """Return string representation of instance for debugging.""" @@ -204,6 +210,19 @@ class EventData(Base): # type: ignore[misc,valid-type] shared_data = Column(Text().with_variant(mysql.LONGTEXT, "mysql")) +# *** Not originally in v23, only added for recorder to startup ok +# This is not being tested by the v23 statistics migration tests +class EventTypes(Base): # type: ignore[misc,valid-type] + """Event type history.""" + + __table_args__ = ( + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + __tablename__ = TABLE_EVENT_TYPES + event_type_id = Column(Integer, Identity(), primary_key=True) + event_type = Column(String(MAX_LENGTH_EVENT_EVENT_TYPE)) + + class States(Base): # type: ignore """State change history.""" diff --git a/tests/components/recorder/db_schema_28.py b/tests/components/recorder/db_schema_28.py index 422f317a6f..f7152cec50 100644 --- a/tests/components/recorder/db_schema_28.py +++ b/tests/components/recorder/db_schema_28.py @@ -21,6 +21,7 @@ from sqlalchemy import ( Identity, Index, Integer, + LargeBinary, SmallInteger, String, Text, @@ -54,6 +55,7 @@ DB_TIMEZONE = "+00:00" TABLE_EVENTS = "events" TABLE_EVENT_DATA = "event_data" +TABLE_EVENT_TYPES = "event_types" TABLE_STATES = "states" TABLE_STATE_ATTRIBUTES = "state_attributes" TABLE_RECORDER_RUNS = "recorder_runs" @@ -68,6 +70,7 @@ ALL_TABLES = [ TABLE_STATE_ATTRIBUTES, TABLE_EVENTS, TABLE_EVENT_DATA, + TABLE_EVENT_TYPES, TABLE_RECORDER_RUNS, TABLE_SCHEMA_CHANGES, TABLE_STATISTICS, @@ -98,6 +101,11 @@ DOUBLE_TYPE = ( ) EVENT_ORIGIN_ORDER = [EventOrigin.local, EventOrigin.remote] EVENT_ORIGIN_TO_IDX = {origin: idx for idx, origin in enumerate(EVENT_ORIGIN_ORDER)} +CONTEXT_ID_BIN_MAX_LENGTH = 16 +EVENTS_CONTEXT_ID_BIN_INDEX = "ix_events_context_id_bin" +STATES_CONTEXT_ID_BIN_INDEX = "ix_states_context_id_bin" + +TIMESTAMP_TYPE = DOUBLE_TYPE class Events(Base): # type: ignore[misc,valid-type] @@ -107,6 +115,12 @@ class Events(Base): # type: ignore[misc,valid-type] # Used for fetching events at a specific time # see logbook Index("ix_events_event_type_time_fired", "event_type", "time_fired"), + Index( + EVENTS_CONTEXT_ID_BIN_INDEX, + "context_id_bin", + mysql_length=CONTEXT_ID_BIN_MAX_LENGTH, + mariadb_length=CONTEXT_ID_BIN_MAX_LENGTH, + ), {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, ) __tablename__ = TABLE_EVENTS @@ -116,11 +130,27 @@ class Events(Base): # type: ignore[misc,valid-type] origin = Column(String(MAX_LENGTH_EVENT_ORIGIN)) # no longer used origin_idx = Column(SmallInteger) time_fired = Column(DATETIME_TYPE, index=True) + time_fired_ts = Column( + TIMESTAMP_TYPE, index=True + ) # *** Not originally in v30, only added for recorder to startup ok context_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True) context_user_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) context_parent_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) data_id = Column(Integer, ForeignKey("event_data.data_id"), index=True) + context_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v28, only added for recorder to startup ok + context_user_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v28, only added for recorder to startup ok + context_parent_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v28, only added for recorder to startup ok + event_type_id = Column( + Integer, ForeignKey("event_types.event_type_id"), index=True + ) # *** Not originally in v28, only added for recorder to startup ok event_data_rel = relationship("EventData") + event_type_rel = relationship("EventTypes") def __repr__(self) -> str: """Return string representation of instance for debugging.""" @@ -214,6 +244,19 @@ class EventData(Base): # type: ignore[misc,valid-type] return {} +# *** Not originally in v28, only added for recorder to startup ok +# This is not being tested by the v28 statistics migration tests +class EventTypes(Base): # type: ignore[misc,valid-type] + """Event type history.""" + + __table_args__ = ( + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + __tablename__ = TABLE_EVENT_TYPES + event_type_id = Column(Integer, Identity(), primary_key=True) + event_type = Column(String(MAX_LENGTH_EVENT_EVENT_TYPE)) + + class States(Base): # type: ignore[misc,valid-type] """State change history.""" diff --git a/tests/components/recorder/db_schema_30.py b/tests/components/recorder/db_schema_30.py index 7862ad0614..ed9fb89e46 100644 --- a/tests/components/recorder/db_schema_30.py +++ b/tests/components/recorder/db_schema_30.py @@ -64,6 +64,7 @@ _LOGGER = logging.getLogger(__name__) TABLE_EVENTS = "events" TABLE_EVENT_DATA = "event_data" +TABLE_EVENT_TYPES = "event_types" TABLE_STATES = "states" TABLE_STATE_ATTRIBUTES = "state_attributes" TABLE_RECORDER_RUNS = "recorder_runs" @@ -78,6 +79,7 @@ ALL_TABLES = [ TABLE_STATE_ATTRIBUTES, TABLE_EVENTS, TABLE_EVENT_DATA, + TABLE_EVENT_TYPES, TABLE_RECORDER_RUNS, TABLE_SCHEMA_CHANGES, TABLE_STATISTICS, @@ -212,6 +214,9 @@ class Events(Base): # type: ignore[misc,valid-type] origin = Column(String(MAX_LENGTH_EVENT_ORIGIN)) # no longer used for new rows origin_idx = Column(SmallInteger) time_fired = Column(DATETIME_TYPE, index=True) + time_fired_ts = Column( + TIMESTAMP_TYPE, index=True + ) # *** Not originally in v30, only added for recorder to startup ok context_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True) context_user_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) context_parent_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) @@ -225,7 +230,11 @@ class Events(Base): # type: ignore[misc,valid-type] context_parent_id_bin = Column( LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) ) # *** Not originally in v30, only added for recorder to startup ok + event_type_id = Column( + Integer, ForeignKey("event_types.event_type_id"), index=True + ) # *** Not originally in v30, only added for recorder to startup ok event_data_rel = relationship("EventData") + event_type_rel = relationship("EventTypes") def __repr__(self) -> str: """Return string representation of instance for debugging.""" @@ -322,6 +331,19 @@ class EventData(Base): # type: ignore[misc,valid-type] return {} +# *** Not originally in v30, only added for recorder to startup ok +# This is not being tested by the v30 statistics migration tests +class EventTypes(Base): # type: ignore[misc,valid-type] + """Event type history.""" + + __table_args__ = ( + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + __tablename__ = TABLE_EVENT_TYPES + event_type_id = Column(Integer, Identity(), primary_key=True) + event_type = Column(String(MAX_LENGTH_EVENT_EVENT_TYPE)) + + class States(Base): # type: ignore[misc,valid-type] """State change history.""" diff --git a/tests/components/recorder/test_history_db_schema_30.py b/tests/components/recorder/test_history_db_schema_30.py index e0f24b35f9..ae37d50f03 100644 --- a/tests/components/recorder/test_history_db_schema_30.py +++ b/tests/components/recorder/test_history_db_schema_30.py @@ -65,7 +65,9 @@ def db_schema_30(): with patch.object(recorder, "db_schema", old_db_schema), patch.object( recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION - ), patch.object(core, "EventData", old_db_schema.EventData), patch.object( + ), patch.object(core, "EventTypes", old_db_schema.EventTypes), patch.object( + core, "EventData", old_db_schema.EventData + ), patch.object( core, "States", old_db_schema.States ), patch.object( core, "Events", old_db_schema.Events diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index a810d74556..c46d77677a 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -39,12 +39,14 @@ from homeassistant.components.recorder.db_schema import ( SCHEMA_VERSION, EventData, Events, + EventTypes, RecorderRuns, StateAttributes, States, StatisticsRuns, ) from homeassistant.components.recorder.models import process_timestamp +from homeassistant.components.recorder.queries import select_event_type_ids from homeassistant.components.recorder.services import ( SERVICE_DISABLE, SERVICE_ENABLE, @@ -483,16 +485,19 @@ def test_saving_event(hass_recorder: Callable[..., HomeAssistant]) -> None: events: list[Event] = [] with session_scope(hass=hass) as session: - for select_event, event_data in ( - session.query(Events, EventData) - .filter_by(event_type=event_type) + for select_event, event_data, event_types in ( + session.query(Events, EventData, EventTypes) + .filter(Events.event_type_id.in_(select_event_type_ids((event_type,)))) + .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) .outerjoin(EventData, Events.data_id == EventData.data_id) ): select_event = cast(Events, select_event) event_data = cast(EventData, event_data) + event_types = cast(EventTypes, event_types) native_event = select_event.to_native() native_event.data = event_data.to_native() + native_event.event_type = event_types.event_type events.append(native_event) db_event = events[0] @@ -555,15 +560,19 @@ def _add_events(hass, events): with session_scope(hass=hass) as session: events = [] - for event, event_data in session.query(Events, EventData).outerjoin( - EventData, Events.data_id == EventData.data_id + for event, event_data, event_types in ( + session.query(Events, EventData, EventTypes) + .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) + .outerjoin(EventData, Events.data_id == EventData.data_id) ): event = cast(Events, event) event_data = cast(EventData, event_data) + event_types = cast(EventTypes, event_types) native_event = event.to_native() if event_data: native_event.data = event_data.to_native() + native_event.event_type = event_types.event_type events.append(native_event) return events @@ -1349,7 +1358,11 @@ def test_service_disable_events_not_recording( event = events[0] with session_scope(hass=hass) as session: - db_events = list(session.query(Events).filter_by(event_type=event_type)) + db_events = list( + session.query(Events) + .filter(Events.event_type_id.in_(select_event_type_ids((event_type,)))) + .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) + ) assert len(db_events) == 0 assert hass.services.call( @@ -1369,16 +1382,19 @@ def test_service_disable_events_not_recording( db_events = [] with session_scope(hass=hass) as session: - for select_event, event_data in ( - session.query(Events, EventData) - .filter_by(event_type=event_type) + for select_event, event_data, event_types in ( + session.query(Events, EventData, EventTypes) + .filter(Events.event_type_id.in_(select_event_type_ids((event_type,)))) + .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) .outerjoin(EventData, Events.data_id == EventData.data_id) ): select_event = cast(Events, select_event) event_data = cast(EventData, event_data) + event_types = cast(EventTypes, event_types) native_event = select_event.to_native() native_event.data = event_data.to_native() + native_event.event_type = event_types.event_type db_events.append(native_event) assert len(db_events) == 1 @@ -1558,6 +1574,7 @@ def test_entity_id_filter(hass_recorder: Callable[..., HomeAssistant]) -> None: hass = hass_recorder( {"include": {"domains": "hello"}, "exclude": {"domains": "hidden_domain"}} ) + event_types = ("hello",) for idx, data in enumerate( ( @@ -1572,7 +1589,11 @@ def test_entity_id_filter(hass_recorder: Callable[..., HomeAssistant]) -> None: wait_recording_done(hass) with session_scope(hass=hass) as session: - db_events = list(session.query(Events).filter_by(event_type="hello")) + db_events = list( + session.query(Events).filter( + Events.event_type_id.in_(select_event_type_ids(event_types)) + ) + ) assert len(db_events) == idx + 1, data for data in ( @@ -1583,7 +1604,11 @@ def test_entity_id_filter(hass_recorder: Callable[..., HomeAssistant]) -> None: wait_recording_done(hass) with session_scope(hass=hass) as session: - db_events = list(session.query(Events).filter_by(event_type="hello")) + db_events = list( + session.query(Events).filter( + Events.event_type_id.in_(select_event_type_ids(event_types)) + ) + ) # Keep referring idx + 1, as no new events are being added assert len(db_events) == idx + 1, data @@ -1608,10 +1633,16 @@ async def test_database_lock_and_unlock( } await async_setup_recorder_instance(hass, config) await hass.async_block_till_done() + event_type = "EVENT_TEST" + event_types = (event_type,) def _get_db_events(): with session_scope(hass=hass) as session: - return list(session.query(Events).filter_by(event_type=event_type)) + return list( + session.query(Events).filter( + Events.event_type_id.in_(select_event_type_ids(event_types)) + ) + ) instance = get_instance(hass) @@ -1619,7 +1650,6 @@ async def test_database_lock_and_unlock( assert not await instance.lock_database() - event_type = "EVENT_TEST" event_data = {"test_attr": 5, "test_attr_10": "nice"} hass.bus.async_fire(event_type, event_data) task = asyncio.create_task(async_wait_recording_done(hass)) @@ -1658,10 +1688,16 @@ async def test_database_lock_and_overflow( } await async_setup_recorder_instance(hass, config) await hass.async_block_till_done() + event_type = "EVENT_TEST" + event_types = (event_type,) def _get_db_events(): with session_scope(hass=hass) as session: - return list(session.query(Events).filter_by(event_type=event_type)) + return list( + session.query(Events).filter( + Events.event_type_id.in_(select_event_type_ids(event_types)) + ) + ) instance = get_instance(hass) @@ -1670,7 +1706,6 @@ async def test_database_lock_and_overflow( ): await instance.lock_database() - event_type = "EVENT_TEST" event_data = {"test_attr": 5, "test_attr_10": "nice"} hass.bus.fire(event_type, event_data) @@ -1793,9 +1828,11 @@ def test_deduplication_event_data_inside_commit_interval( wait_recording_done(hass) with session_scope(hass=hass) as session: + event_types = ("this_event",) events = list( session.query(Events) - .filter(Events.event_type == "this_event") + .filter(Events.event_type_id.in_(select_event_type_ids(event_types))) + .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) .outerjoin(EventData, (Events.data_id == EventData.data_id)) ) assert len(events) == 20 diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index 730d90e14b..062013e728 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -25,10 +25,15 @@ from homeassistant.components.recorder import db_schema, migration from homeassistant.components.recorder.db_schema import ( SCHEMA_VERSION, Events, + EventTypes, RecorderRuns, States, ) -from homeassistant.components.recorder.tasks import ContextIDMigrationTask +from homeassistant.components.recorder.queries import select_event_type_ids +from homeassistant.components.recorder.tasks import ( + ContextIDMigrationTask, + EventTypeIDMigrationTask, +) from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant from homeassistant.helpers import recorder as recorder_helper @@ -688,3 +693,74 @@ async def test_migrate_context_ids( assert invalid_context_id_event["context_id_bin"] == b"\x00" * 16 assert invalid_context_id_event["context_user_id_bin"] is None assert invalid_context_id_event["context_parent_id_bin"] is None + + +@pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) +async def test_migrate_event_type_ids( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test we can migrate event_types to the EventTypes table.""" + instance = await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + def _insert_events(): + with session_scope(hass=hass) as session: + session.add_all( + ( + Events( + event_type="event_type_one", + origin_idx=0, + time_fired_ts=1677721632.452529, + ), + Events( + event_type="event_type_one", + origin_idx=0, + time_fired_ts=1677721632.552529, + ), + Events( + event_type="event_type_two", + origin_idx=0, + time_fired_ts=1677721632.552529, + ), + ) + ) + + await instance.async_add_executor_job(_insert_events) + + await async_wait_recording_done(hass) + # This is a threadsafe way to add a task to the recorder + instance.queue_task(EventTypeIDMigrationTask()) + await async_recorder_block_till_done(hass) + + def _fetch_migrated_events(): + with session_scope(hass=hass) as session: + events = ( + session.query(Events.event_id, Events.time_fired, EventTypes.event_type) + .filter( + Events.event_type_id.in_( + select_event_type_ids( + ( + "event_type_one", + "event_type_two", + ) + ) + ) + ) + .outerjoin(EventTypes, Events.event_type_id == EventTypes.event_type_id) + .all() + ) + assert len(events) == 3 + result = {} + for event in events: + result.setdefault(event.event_type, []).append( + { + "event_id": event.event_id, + "time_fired": event.time_fired, + "event_type": event.event_type, + } + ) + return result + + events_by_type = await instance.async_add_executor_job(_fetch_migrated_events) + assert len(events_by_type["event_type_one"]) == 2 + assert len(events_by_type["event_type_two"]) == 1 diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py index 6f4de420b7..df8ffa0d34 100644 --- a/tests/components/recorder/test_models.py +++ b/tests/components/recorder/test_models.py @@ -31,6 +31,7 @@ def test_from_event_to_db_event() -> None: db_event = Events.from_event(event) dialect = SupportedDialect.MYSQL db_event.event_data = EventData.shared_data_bytes_from_event(event, dialect) + db_event.event_type = event.event_type assert event.as_dict() == db_event.to_native().as_dict() @@ -232,11 +233,13 @@ async def test_event_to_db_model() -> None: db_event = Events.from_event(event) dialect = SupportedDialect.MYSQL db_event.event_data = EventData.shared_data_bytes_from_event(event, dialect) + db_event.event_type = event.event_type native = db_event.to_native() assert native.as_dict() == event.as_dict() native = Events.from_event(event).to_native() event.data = {} + native.event_type = event.event_type assert native.as_dict() == event.as_dict() diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index 07c935129e..fcabb2e83a 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -16,6 +16,7 @@ from homeassistant.components.recorder.const import ( from homeassistant.components.recorder.db_schema import ( EventData, Events, + EventTypes, RecorderRuns, StateAttributes, States, @@ -31,6 +32,7 @@ from homeassistant.components.recorder.tasks import PurgeTask from homeassistant.components.recorder.util import session_scope from homeassistant.const import EVENT_STATE_CHANGED, EVENT_THEMES_UPDATED, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.helpers.json import json_dumps from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util @@ -1684,3 +1686,113 @@ async def test_purge_can_mix_legacy_and_new_format( # does not prevent future purges. Its ignored. assert states_with_event_id.count() == 0 assert states_without_event_id.count() == 1 + + +async def test_purge_old_events_purges_the_event_type_ids( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test deleting old events purges event type ids.""" + instance = await async_setup_recorder_instance(hass) + assert instance.event_type_manager.active is True + + utcnow = dt_util.utcnow() + five_days_ago = utcnow - timedelta(days=5) + eleven_days_ago = utcnow - timedelta(days=11) + far_past = utcnow - timedelta(days=1000) + event_data = {"test_attr": 5, "test_attr_10": "nice"} + + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + def _insert_events(): + with session_scope(hass=hass) as session: + event_type_test_auto_purge = EventTypes(event_type="EVENT_TEST_AUTOPURGE") + event_type_test_purge = EventTypes(event_type="EVENT_TEST_PURGE") + event_type_test = EventTypes(event_type="EVENT_TEST") + event_type_unused = EventTypes(event_type="EVENT_TEST_UNUSED") + session.add_all( + ( + event_type_test_auto_purge, + event_type_test_purge, + event_type_test, + event_type_unused, + ) + ) + session.flush() + for _ in range(5): + for event_id in range(6): + if event_id < 2: + timestamp = eleven_days_ago + event_type = event_type_test_auto_purge + elif event_id < 4: + timestamp = five_days_ago + event_type = event_type_test_purge + else: + timestamp = utcnow + event_type = event_type_test + + session.add( + Events( + event_type=None, + event_type_id=event_type.event_type_id, + event_data=json_dumps(event_data), + origin="LOCAL", + time_fired_ts=dt_util.utc_to_timestamp(timestamp), + ) + ) + return instance.event_type_manager.get_many( + [ + "EVENT_TEST_AUTOPURGE", + "EVENT_TEST_PURGE", + "EVENT_TEST", + "EVENT_TEST_UNUSED", + ], + session, + ) + + event_type_to_id = await instance.async_add_executor_job(_insert_events) + test_event_type_ids = event_type_to_id.values() + with session_scope(hass=hass) as session: + events = session.query(Events).where( + Events.event_type_id.in_(test_event_type_ids) + ) + event_types = session.query(EventTypes).where( + EventTypes.event_type_id.in_(test_event_type_ids) + ) + + assert events.count() == 30 + assert event_types.count() == 4 + + # run purge_old_data() + finished = purge_old_data( + instance, + far_past, + repack=False, + ) + assert finished + assert events.count() == 30 + # We should remove the unused event type + assert event_types.count() == 3 + + assert "EVENT_TEST_UNUSED" not in instance.event_type_manager._id_map + + # we should only have 10 events left since + # only one event type was recorded now + finished = purge_old_data( + instance, + utcnow, + repack=False, + ) + assert finished + assert events.count() == 10 + assert event_types.count() == 1 + + # Purge everything + finished = purge_old_data( + instance, + utcnow + timedelta(seconds=1), + repack=False, + ) + assert finished + assert events.count() == 0 + assert event_types.count() == 0 diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index e31d4472aa..6fe810758f 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -11,6 +11,7 @@ from sqlalchemy.orm import Session from homeassistant.components import recorder from homeassistant.components.recorder import SQLITE_URL_PREFIX, core, statistics +from homeassistant.components.recorder.queries import select_event_type_ids from homeassistant.components.recorder.util import session_scope from homeassistant.core import EVENT_STATE_CHANGED, Event, EventOrigin, State from homeassistant.helpers import recorder as recorder_helper @@ -87,7 +88,9 @@ def test_migrate_times(caplog: pytest.LogCaptureFixture, tmpdir) -> None: with patch.object(recorder, "db_schema", old_db_schema), patch.object( recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION - ), patch.object(core, "EventData", old_db_schema.EventData), patch.object( + ), patch.object(core, "EventTypes", old_db_schema.EventTypes), patch.object( + core, "EventData", old_db_schema.EventData + ), patch.object( core, "States", old_db_schema.States ), patch.object( core, "Events", old_db_schema.Events @@ -117,8 +120,10 @@ def test_migrate_times(caplog: pytest.LogCaptureFixture, tmpdir) -> None: wait_recording_done(hass) with session_scope(hass=hass) as session: result = list( - session.query(recorder.db_schema.Events).where( - recorder.db_schema.Events.event_type == "custom_event" + session.query(recorder.db_schema.Events).filter( + recorder.db_schema.Events.event_type_id.in_( + select_event_type_ids(("custom_event",)) + ) ) ) assert len(result) == 1 diff --git a/tests/conftest.py b/tests/conftest.py index ed5a95f1b2..25ee814382 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1148,6 +1148,16 @@ def enable_migrate_context_ids() -> bool: return False +@pytest.fixture +def enable_migrate_event_type_ids() -> bool: + """Fixture to control enabling of recorder's event type id migration. + + To enable context id migration, tests can be marked with: + @pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) + """ + return False + + @pytest.fixture def recorder_config() -> dict[str, Any] | None: """Fixture to override recorder config. @@ -1291,6 +1301,7 @@ async def async_setup_recorder_instance( enable_statistics: bool, enable_statistics_table_validation: bool, enable_migrate_context_ids: bool, + enable_migrate_event_type_ids: bool, ) -> AsyncGenerator[RecorderInstanceGenerator, None]: """Yield callable to setup recorder instance.""" # pylint: disable-next=import-outside-toplevel @@ -1309,6 +1320,11 @@ async def async_setup_recorder_instance( migrate_context_ids = ( recorder.Recorder._migrate_context_ids if enable_migrate_context_ids else None ) + migrate_event_type_ids = ( + recorder.Recorder._migrate_event_type_ids + if enable_migrate_event_type_ids + else None + ) with patch( "homeassistant.components.recorder.Recorder.async_nightly_tasks", side_effect=nightly, @@ -1325,6 +1341,10 @@ async def async_setup_recorder_instance( "homeassistant.components.recorder.Recorder._migrate_context_ids", side_effect=migrate_context_ids, autospec=True, + ), patch( + "homeassistant.components.recorder.Recorder._migrate_event_type_ids", + side_effect=migrate_event_type_ids, + autospec=True, ): async def async_setup_recorder( From 16b420d660db5ec651beef95994010d351d69b98 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Mar 2023 10:37:00 -1000 Subject: [PATCH 0842/2810] Fix get_significant_states_with_session query looking at legacy columns (#89558) --- homeassistant/components/recorder/history.py | 8 +++++--- tests/components/recorder/test_history.py | 21 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/recorder/history.py b/homeassistant/components/recorder/history.py index b67790f9a4..a745716757 100644 --- a/homeassistant/components/recorder/history.py +++ b/homeassistant/components/recorder/history.py @@ -282,9 +282,11 @@ def _significant_states_stmt( (States.last_changed_ts == States.last_updated_ts) | States.last_changed_ts.is_(None) ) - stmt += lambda q: q.filter( - (States.last_changed == States.last_updated) | States.last_changed.is_(None) - ) + else: + stmt += lambda q: q.filter( + (States.last_changed == States.last_updated) + | States.last_changed.is_(None) + ) elif significant_changes_only: if schema_version >= 31: stmt += lambda q: q.filter( diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index d082806f3d..2b4bed072a 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -209,6 +209,27 @@ def test_significant_states_with_session_entity_minimal_response_no_matches( ) +def test_significant_states_with_session_single_entity( + hass_recorder: Callable[..., HomeAssistant], +) -> None: + """Test get_significant_states_with_session with a single entity.""" + hass = hass_recorder() + hass.states.set("demo.id", "any", {"attr": True}) + hass.states.set("demo.id", "any2", {"attr": True}) + wait_recording_done(hass) + now = dt_util.utcnow() + with session_scope(hass=hass) as session: + states = history.get_significant_states_with_session( + hass, + session, + now - timedelta(days=1), + now, + entity_ids=["demo.id"], + minimal_response=False, + ) + assert len(states["demo.id"]) == 2 + + @pytest.mark.parametrize( ("attributes", "no_attributes", "limit"), [ From 50c31a53555d1e1b03d88f8bd0e864a8d80acc8b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Mar 2023 11:26:30 -1000 Subject: [PATCH 0843/2810] Move legacy database queries and models to prepare for schema v38 (#89532) --- .../components/recorder/history/__init__.py | 22 + .../components/recorder/history/common.py | 10 + .../components/recorder/history/const.py | 23 + .../{history.py => history/legacy.py} | 61 +- homeassistant/components/recorder/models.py | 521 ------------------ .../components/recorder/models/__init__.py | 53 ++ .../components/recorder/models/context.py | 42 ++ .../components/recorder/models/database.py | 33 ++ .../components/recorder/models/legacy.py | 164 ++++++ .../components/recorder/models/state.py | 145 +++++ .../recorder/models/state_attributes.py | 30 + .../components/recorder/models/statistics.py | 89 +++ .../components/recorder/models/time.py | 82 +++ tests/components/recorder/test_history.py | 10 +- tests/components/recorder/test_util.py | 7 +- 15 files changed, 724 insertions(+), 568 deletions(-) create mode 100644 homeassistant/components/recorder/history/__init__.py create mode 100644 homeassistant/components/recorder/history/common.py create mode 100644 homeassistant/components/recorder/history/const.py rename homeassistant/components/recorder/{history.py => history/legacy.py} (96%) delete mode 100644 homeassistant/components/recorder/models.py create mode 100644 homeassistant/components/recorder/models/__init__.py create mode 100644 homeassistant/components/recorder/models/context.py create mode 100644 homeassistant/components/recorder/models/database.py create mode 100644 homeassistant/components/recorder/models/legacy.py create mode 100644 homeassistant/components/recorder/models/state.py create mode 100644 homeassistant/components/recorder/models/state_attributes.py create mode 100644 homeassistant/components/recorder/models/statistics.py create mode 100644 homeassistant/components/recorder/models/time.py diff --git a/homeassistant/components/recorder/history/__init__.py b/homeassistant/components/recorder/history/__init__.py new file mode 100644 index 0000000000..1b7b9065b7 --- /dev/null +++ b/homeassistant/components/recorder/history/__init__.py @@ -0,0 +1,22 @@ +"""Provide pre-made queries on top of the recorder component.""" +from __future__ import annotations + +from .const import NEED_ATTRIBUTE_DOMAINS, SIGNIFICANT_DOMAINS +from .legacy import ( + get_full_significant_states_with_session, + get_last_state_changes, + get_significant_states, + get_significant_states_with_session, + state_changes_during_period, +) + +# These are the APIs of this package +__all__ = [ + "NEED_ATTRIBUTE_DOMAINS", + "SIGNIFICANT_DOMAINS", + "get_full_significant_states_with_session", + "get_last_state_changes", + "get_significant_states", + "get_significant_states_with_session", + "state_changes_during_period", +] diff --git a/homeassistant/components/recorder/history/common.py b/homeassistant/components/recorder/history/common.py new file mode 100644 index 0000000000..6d0150925d --- /dev/null +++ b/homeassistant/components/recorder/history/common.py @@ -0,0 +1,10 @@ +"""Common functions for history.""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant + +from ... import recorder + + +def _schema_version(hass: HomeAssistant) -> int: + return recorder.get_instance(hass).schema_version diff --git a/homeassistant/components/recorder/history/const.py b/homeassistant/components/recorder/history/const.py new file mode 100644 index 0000000000..33717ca78c --- /dev/null +++ b/homeassistant/components/recorder/history/const.py @@ -0,0 +1,23 @@ +"""Constants for history.""" + + +STATE_KEY = "state" +LAST_CHANGED_KEY = "last_changed" + +SIGNIFICANT_DOMAINS = { + "climate", + "device_tracker", + "humidifier", + "thermostat", + "water_heater", +} +SIGNIFICANT_DOMAINS_ENTITY_ID_LIKE = [f"{domain}.%" for domain in SIGNIFICANT_DOMAINS] +IGNORE_DOMAINS = {"zone", "scene"} +IGNORE_DOMAINS_ENTITY_ID_LIKE = [f"{domain}.%" for domain in IGNORE_DOMAINS] +NEED_ATTRIBUTE_DOMAINS = { + "climate", + "humidifier", + "input_datetime", + "thermostat", + "water_heater", +} diff --git a/homeassistant/components/recorder/history.py b/homeassistant/components/recorder/history/legacy.py similarity index 96% rename from homeassistant/components/recorder/history.py rename to homeassistant/components/recorder/history/legacy.py index a745716757..7d7e3d9b47 100644 --- a/homeassistant/components/recorder/history.py +++ b/homeassistant/components/recorder/history/legacy.py @@ -22,43 +22,30 @@ from homeassistant.const import COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_ from homeassistant.core import HomeAssistant, State, split_entity_id import homeassistant.util.dt as dt_util -from .. import recorder -from .db_schema import RecorderRuns, StateAttributes, States -from .filters import Filters -from .models import ( +from ... import recorder +from ..db_schema import RecorderRuns, StateAttributes, States +from ..filters import Filters +from ..models import ( LazyState, - LazyStatePreSchema31, process_datetime_to_timestamp, process_timestamp, process_timestamp_to_utc_isoformat, row_to_compressed_state, - row_to_compressed_state_pre_schema_31, ) -from .util import execute_stmt_lambda_element, session_scope +from ..models.legacy import LazyStatePreSchema31, row_to_compressed_state_pre_schema_31 +from ..util import execute_stmt_lambda_element, session_scope +from .common import _schema_version +from .const import ( + IGNORE_DOMAINS_ENTITY_ID_LIKE, + LAST_CHANGED_KEY, + NEED_ATTRIBUTE_DOMAINS, + SIGNIFICANT_DOMAINS, + SIGNIFICANT_DOMAINS_ENTITY_ID_LIKE, + STATE_KEY, +) _LOGGER = logging.getLogger(__name__) -STATE_KEY = "state" -LAST_CHANGED_KEY = "last_changed" - -SIGNIFICANT_DOMAINS = { - "climate", - "device_tracker", - "humidifier", - "thermostat", - "water_heater", -} -SIGNIFICANT_DOMAINS_ENTITY_ID_LIKE = [f"{domain}.%" for domain in SIGNIFICANT_DOMAINS] -IGNORE_DOMAINS = {"zone", "scene"} -IGNORE_DOMAINS_ENTITY_ID_LIKE = [f"{domain}.%" for domain in IGNORE_DOMAINS] -NEED_ATTRIBUTE_DOMAINS = { - "climate", - "humidifier", - "input_datetime", - "thermostat", - "water_heater", -} - _BASE_STATES = ( States.entity_id, @@ -151,11 +138,7 @@ _FIELD_MAP_PRE_SCHEMA_31 = { } -def _schema_version(hass: HomeAssistant) -> int: - return recorder.get_instance(hass).schema_version - - -def lambda_stmt_and_join_attributes( +def _lambda_stmt_and_join_attributes( schema_version: int, no_attributes: bool, include_last_changed: bool = True ) -> tuple[StatementLambdaElement, bool]: """Return the lambda_stmt and if StateAttributes should be joined. @@ -268,7 +251,7 @@ def _significant_states_stmt( no_attributes: bool, ) -> StatementLambdaElement: """Query the database for significant state changes.""" - stmt, join_attributes = lambda_stmt_and_join_attributes( + stmt, join_attributes = _lambda_stmt_and_join_attributes( schema_version, no_attributes, include_last_changed=not significant_changes_only ) if ( @@ -442,7 +425,7 @@ def _state_changed_during_period_stmt( descending: bool, limit: int | None, ) -> StatementLambdaElement: - stmt, join_attributes = lambda_stmt_and_join_attributes( + stmt, join_attributes = _lambda_stmt_and_join_attributes( schema_version, no_attributes, include_last_changed=False ) if schema_version >= 31: @@ -534,7 +517,7 @@ def state_changes_during_period( def _get_last_state_changes_stmt( schema_version: int, number_of_states: int, entity_id: str ) -> StatementLambdaElement: - stmt, join_attributes = lambda_stmt_and_join_attributes( + stmt, join_attributes = _lambda_stmt_and_join_attributes( schema_version, False, include_last_changed=False ) if schema_version >= 31: @@ -601,7 +584,7 @@ def _get_states_for_entities_stmt( no_attributes: bool, ) -> StatementLambdaElement: """Baked query to get states for specific entities.""" - stmt, join_attributes = lambda_stmt_and_join_attributes( + stmt, join_attributes = _lambda_stmt_and_join_attributes( schema_version, no_attributes, include_last_changed=True ) # We got an include-list of entities, accelerate the query by filtering already @@ -673,7 +656,7 @@ def _get_states_for_all_stmt( no_attributes: bool, ) -> StatementLambdaElement: """Baked query to get states for all entities.""" - stmt, join_attributes = lambda_stmt_and_join_attributes( + stmt, join_attributes = _lambda_stmt_and_join_attributes( schema_version, no_attributes, include_last_changed=True ) # We did not get an include-list of entities, query all states in the inner @@ -787,7 +770,7 @@ def _get_single_entity_states_stmt( ) -> StatementLambdaElement: # Use an entirely different (and extremely fast) query if we only # have a single entity id - stmt, join_attributes = lambda_stmt_and_join_attributes( + stmt, join_attributes = _lambda_stmt_and_join_attributes( schema_version, no_attributes, include_last_changed=True ) if schema_version >= 31: diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py deleted file mode 100644 index 053c870d8a..0000000000 --- a/homeassistant/components/recorder/models.py +++ /dev/null @@ -1,521 +0,0 @@ -"""Models for Recorder.""" -from __future__ import annotations - -from contextlib import suppress -from dataclasses import dataclass -from datetime import datetime, timedelta -from functools import lru_cache -import logging -from typing import Any, Literal, TypedDict, overload -from uuid import UUID - -from awesomeversion import AwesomeVersion -from sqlalchemy.engine.row import Row - -from homeassistant.const import ( - COMPRESSED_STATE_ATTRIBUTES, - COMPRESSED_STATE_LAST_CHANGED, - COMPRESSED_STATE_LAST_UPDATED, - COMPRESSED_STATE_STATE, -) -from homeassistant.core import Context, State -import homeassistant.util.dt as dt_util -from homeassistant.util.json import json_loads_object -from homeassistant.util.ulid import bytes_to_ulid, ulid_to_bytes - -from .const import SupportedDialect - -# pylint: disable=invalid-name - -_LOGGER = logging.getLogger(__name__) - -DB_TIMEZONE = "+00:00" - -EMPTY_JSON_OBJECT = "{}" - - -class UnsupportedDialect(Exception): - """The dialect or its version is not supported.""" - - -class StatisticResult(TypedDict): - """Statistic result data class. - - Allows multiple datapoints for the same statistic_id. - """ - - meta: StatisticMetaData - stat: StatisticData - - -class StatisticDataTimestampBase(TypedDict): - """Mandatory fields for statistic data class with a timestamp.""" - - start_ts: float - - -class StatisticDataBase(TypedDict): - """Mandatory fields for statistic data class.""" - - start: datetime - - -class StatisticMixIn(TypedDict, total=False): - """Mandatory fields for statistic data class.""" - - state: float - sum: float - min: float - max: float - mean: float - - -class StatisticData(StatisticDataBase, StatisticMixIn, total=False): - """Statistic data class.""" - - last_reset: datetime | None - - -class StatisticDataTimestamp(StatisticDataTimestampBase, StatisticMixIn, total=False): - """Statistic data class with a timestamp.""" - - last_reset_ts: float | None - - -class StatisticMetaData(TypedDict): - """Statistic meta data class.""" - - has_mean: bool - has_sum: bool - name: str | None - source: str - statistic_id: str - unit_of_measurement: str | None - - -@overload -def process_timestamp(ts: None) -> None: - ... - - -@overload -def process_timestamp(ts: datetime) -> datetime: - ... - - -def process_timestamp(ts: datetime | None) -> datetime | None: - """Process a timestamp into datetime object.""" - if ts is None: - return None - if ts.tzinfo is None: - return ts.replace(tzinfo=dt_util.UTC) - - return dt_util.as_utc(ts) - - -@overload -def process_timestamp_to_utc_isoformat(ts: None) -> None: - ... - - -@overload -def process_timestamp_to_utc_isoformat(ts: datetime) -> str: - ... - - -def process_timestamp_to_utc_isoformat(ts: datetime | None) -> str | None: - """Process a timestamp into UTC isotime.""" - if ts is None: - return None - if ts.tzinfo == dt_util.UTC: - return ts.isoformat() - if ts.tzinfo is None: - return f"{ts.isoformat()}{DB_TIMEZONE}" - return ts.astimezone(dt_util.UTC).isoformat() - - -def process_datetime_to_timestamp(ts: datetime) -> float: - """Process a datebase datetime to epoch. - - Mirrors the behavior of process_timestamp_to_utc_isoformat - except it returns the epoch time. - """ - if ts.tzinfo is None or ts.tzinfo == dt_util.UTC: - return dt_util.utc_to_timestamp(ts) - return ts.timestamp() - - -def datetime_to_timestamp_or_none(dt: datetime | None) -> float | None: - """Convert a datetime to a timestamp.""" - if dt is None: - return None - return dt_util.utc_to_timestamp(dt) - - -def timestamp_to_datetime_or_none(ts: float | None) -> datetime | None: - """Convert a timestamp to a datetime.""" - if not ts: - return None - return dt_util.utc_from_timestamp(ts) - - -def ulid_to_bytes_or_none(ulid: str | None) -> bytes | None: - """Convert an ulid to bytes.""" - if ulid is None: - return None - return ulid_to_bytes(ulid) - - -def bytes_to_ulid_or_none(_bytes: bytes | None) -> str | None: - """Convert bytes to a ulid.""" - if _bytes is None: - return None - return bytes_to_ulid(_bytes) - - -@lru_cache(maxsize=16) -def uuid_hex_to_bytes_or_none(uuid_hex: str | None) -> bytes | None: - """Convert a uuid hex to bytes.""" - if uuid_hex is None: - return None - with suppress(ValueError): - return UUID(hex=uuid_hex).bytes - return None - - -@lru_cache(maxsize=16) -def bytes_to_uuid_hex_or_none(_bytes: bytes | None) -> str | None: - """Convert bytes to a uuid hex.""" - if _bytes is None: - return None - with suppress(ValueError): - return UUID(bytes=_bytes).hex - return None - - -class LazyStatePreSchema31(State): - """A lazy version of core State before schema 31.""" - - __slots__ = [ - "_row", - "_attributes", - "_last_changed", - "_last_updated", - "_context", - "attr_cache", - ] - - def __init__( # pylint: disable=super-init-not-called - self, - row: Row, - attr_cache: dict[str, dict[str, Any]], - start_time: datetime | None, - ) -> None: - """Init the lazy state.""" - self._row = row - self.entity_id: str = self._row.entity_id - self.state = self._row.state or "" - self._attributes: dict[str, Any] | None = None - self._last_changed: datetime | None = start_time - self._last_updated: datetime | None = start_time - self._context: Context | None = None - self.attr_cache = attr_cache - - @property # type: ignore[override] - def attributes(self) -> dict[str, Any]: - """State attributes.""" - if self._attributes is None: - self._attributes = decode_attributes_from_row(self._row, self.attr_cache) - return self._attributes - - @attributes.setter - def attributes(self, value: dict[str, Any]) -> None: - """Set attributes.""" - self._attributes = value - - @property - def context(self) -> Context: - """State context.""" - if self._context is None: - self._context = Context(id=None) - return self._context - - @context.setter - def context(self, value: Context) -> None: - """Set context.""" - self._context = value - - @property - def last_changed(self) -> datetime: - """Last changed datetime.""" - if self._last_changed is None: - if (last_changed := self._row.last_changed) is not None: - self._last_changed = process_timestamp(last_changed) - else: - self._last_changed = self.last_updated - return self._last_changed - - @last_changed.setter - def last_changed(self, value: datetime) -> None: - """Set last changed datetime.""" - self._last_changed = value - - @property - def last_updated(self) -> datetime: - """Last updated datetime.""" - if self._last_updated is None: - self._last_updated = process_timestamp(self._row.last_updated) - return self._last_updated - - @last_updated.setter - def last_updated(self, value: datetime) -> None: - """Set last updated datetime.""" - self._last_updated = value - - def as_dict(self) -> dict[str, Any]: # type: ignore[override] - """Return a dict representation of the LazyState. - - Async friendly. - - To be used for JSON serialization. - """ - if self._last_changed is None and self._last_updated is None: - last_updated_isoformat = process_timestamp_to_utc_isoformat( - self._row.last_updated - ) - if ( - self._row.last_changed is None - or self._row.last_changed == self._row.last_updated - ): - last_changed_isoformat = last_updated_isoformat - else: - last_changed_isoformat = process_timestamp_to_utc_isoformat( - self._row.last_changed - ) - else: - last_updated_isoformat = self.last_updated.isoformat() - if self.last_changed == self.last_updated: - last_changed_isoformat = last_updated_isoformat - else: - last_changed_isoformat = self.last_changed.isoformat() - return { - "entity_id": self.entity_id, - "state": self.state, - "attributes": self._attributes or self.attributes, - "last_changed": last_changed_isoformat, - "last_updated": last_updated_isoformat, - } - - -class LazyState(State): - """A lazy version of core State after schema 31.""" - - __slots__ = [ - "_row", - "_attributes", - "_last_changed_ts", - "_last_updated_ts", - "_context", - "attr_cache", - ] - - def __init__( # pylint: disable=super-init-not-called - self, - row: Row, - attr_cache: dict[str, dict[str, Any]], - start_time: datetime | None, - ) -> None: - """Init the lazy state.""" - self._row = row - self.entity_id: str = self._row.entity_id - self.state = self._row.state or "" - self._attributes: dict[str, Any] | None = None - self._last_updated_ts: float | None = self._row.last_updated_ts or ( - dt_util.utc_to_timestamp(start_time) if start_time else None - ) - self._last_changed_ts: float | None = ( - self._row.last_changed_ts or self._last_updated_ts - ) - self._context: Context | None = None - self.attr_cache = attr_cache - - @property # type: ignore[override] - def attributes(self) -> dict[str, Any]: - """State attributes.""" - if self._attributes is None: - self._attributes = decode_attributes_from_row(self._row, self.attr_cache) - return self._attributes - - @attributes.setter - def attributes(self, value: dict[str, Any]) -> None: - """Set attributes.""" - self._attributes = value - - @property - def context(self) -> Context: - """State context.""" - if self._context is None: - self._context = Context(id=None) - return self._context - - @context.setter - def context(self, value: Context) -> None: - """Set context.""" - self._context = value - - @property - def last_changed(self) -> datetime: - """Last changed datetime.""" - assert self._last_changed_ts is not None - return dt_util.utc_from_timestamp(self._last_changed_ts) - - @last_changed.setter - def last_changed(self, value: datetime) -> None: - """Set last changed datetime.""" - self._last_changed_ts = process_timestamp(value).timestamp() - - @property - def last_updated(self) -> datetime: - """Last updated datetime.""" - assert self._last_updated_ts is not None - return dt_util.utc_from_timestamp(self._last_updated_ts) - - @last_updated.setter - def last_updated(self, value: datetime) -> None: - """Set last updated datetime.""" - self._last_updated_ts = process_timestamp(value).timestamp() - - def as_dict(self) -> dict[str, Any]: # type: ignore[override] - """Return a dict representation of the LazyState. - - Async friendly. - - To be used for JSON serialization. - """ - last_updated_isoformat = self.last_updated.isoformat() - if self._last_changed_ts == self._last_updated_ts: - last_changed_isoformat = last_updated_isoformat - else: - last_changed_isoformat = self.last_changed.isoformat() - return { - "entity_id": self.entity_id, - "state": self.state, - "attributes": self._attributes or self.attributes, - "last_changed": last_changed_isoformat, - "last_updated": last_updated_isoformat, - } - - -def decode_attributes_from_row( - row: Row, attr_cache: dict[str, dict[str, Any]] -) -> dict[str, Any]: - """Decode attributes from a database row.""" - source: str = row.shared_attrs or row.attributes - if (attributes := attr_cache.get(source)) is not None: - return attributes - if not source or source == EMPTY_JSON_OBJECT: - return {} - try: - attr_cache[source] = attributes = json_loads_object(source) - except ValueError: - _LOGGER.exception("Error converting row to state attributes: %s", source) - attr_cache[source] = attributes = {} - return attributes - - -def row_to_compressed_state( - row: Row, - attr_cache: dict[str, dict[str, Any]], - start_time: datetime | None, -) -> dict[str, Any]: - """Convert a database row to a compressed state schema 31 and later.""" - comp_state = { - COMPRESSED_STATE_STATE: row.state, - COMPRESSED_STATE_ATTRIBUTES: decode_attributes_from_row(row, attr_cache), - } - if start_time: - comp_state[COMPRESSED_STATE_LAST_UPDATED] = dt_util.utc_to_timestamp(start_time) - else: - row_last_updated_ts: float = row.last_updated_ts - comp_state[COMPRESSED_STATE_LAST_UPDATED] = row_last_updated_ts - if ( - row_changed_changed_ts := row.last_changed_ts - ) and row_last_updated_ts != row_changed_changed_ts: - comp_state[COMPRESSED_STATE_LAST_CHANGED] = row_changed_changed_ts - return comp_state - - -def row_to_compressed_state_pre_schema_31( - row: Row, - attr_cache: dict[str, dict[str, Any]], - start_time: datetime | None, -) -> dict[str, Any]: - """Convert a database row to a compressed state before schema 31.""" - comp_state = { - COMPRESSED_STATE_STATE: row.state, - COMPRESSED_STATE_ATTRIBUTES: decode_attributes_from_row(row, attr_cache), - } - if start_time: - comp_state[COMPRESSED_STATE_LAST_UPDATED] = start_time.timestamp() - else: - row_last_updated: datetime = row.last_updated - comp_state[COMPRESSED_STATE_LAST_UPDATED] = process_datetime_to_timestamp( - row_last_updated - ) - if ( - row_changed_changed := row.last_changed - ) and row_last_updated != row_changed_changed: - comp_state[COMPRESSED_STATE_LAST_CHANGED] = process_datetime_to_timestamp( - row_changed_changed - ) - return comp_state - - -class CalendarStatisticPeriod(TypedDict, total=False): - """Statistic period definition.""" - - period: Literal["hour", "day", "week", "month", "year"] - offset: int - - -class FixedStatisticPeriod(TypedDict, total=False): - """Statistic period definition.""" - - end_time: datetime - start_time: datetime - - -class RollingWindowStatisticPeriod(TypedDict, total=False): - """Statistic period definition.""" - - duration: timedelta - offset: timedelta - - -class StatisticPeriod(TypedDict, total=False): - """Statistic period definition.""" - - calendar: CalendarStatisticPeriod - fixed_period: FixedStatisticPeriod - rolling_window: RollingWindowStatisticPeriod - - -@dataclass -class DatabaseEngine: - """Properties of the database engine.""" - - dialect: SupportedDialect - optimizer: DatabaseOptimizer - version: AwesomeVersion | None - - -@dataclass -class DatabaseOptimizer: - """Properties of the database optimizer for the configured database engine.""" - - # Some MariaDB versions have a bug that causes a slow query when using - # a range in a select statement with an IN clause. - # - # https://jira.mariadb.org/browse/MDEV-25020 - # - slow_range_in_select: bool diff --git a/homeassistant/components/recorder/models/__init__.py b/homeassistant/components/recorder/models/__init__.py new file mode 100644 index 0000000000..3aec02b8d4 --- /dev/null +++ b/homeassistant/components/recorder/models/__init__.py @@ -0,0 +1,53 @@ +"""Models for Recorder.""" +from __future__ import annotations + +from .context import ( + bytes_to_ulid_or_none, + bytes_to_uuid_hex_or_none, + ulid_to_bytes_or_none, + uuid_hex_to_bytes_or_none, +) +from .database import DatabaseEngine, DatabaseOptimizer, UnsupportedDialect +from .state import LazyState, row_to_compressed_state +from .statistics import ( + CalendarStatisticPeriod, + FixedStatisticPeriod, + RollingWindowStatisticPeriod, + StatisticData, + StatisticDataTimestamp, + StatisticMetaData, + StatisticPeriod, + StatisticResult, +) +from .time import ( + datetime_to_timestamp_or_none, + process_datetime_to_timestamp, + process_timestamp, + process_timestamp_to_utc_isoformat, + timestamp_to_datetime_or_none, +) + +__all__ = [ + "CalendarStatisticPeriod", + "DatabaseEngine", + "DatabaseOptimizer", + "FixedStatisticPeriod", + "LazyState", + "RollingWindowStatisticPeriod", + "StatisticData", + "StatisticDataTimestamp", + "StatisticMetaData", + "StatisticPeriod", + "StatisticResult", + "UnsupportedDialect", + "bytes_to_ulid_or_none", + "bytes_to_uuid_hex_or_none", + "datetime_to_timestamp_or_none", + "process_datetime_to_timestamp", + "process_timestamp", + "process_timestamp_to_utc_isoformat", + "row_to_compressed_state", + "timestamp_to_datetime_or_none", + "ulid_to_bytes_or_none", + "uuid_hex_to_bytes_or_none", +] diff --git a/homeassistant/components/recorder/models/context.py b/homeassistant/components/recorder/models/context.py new file mode 100644 index 0000000000..dbd9383bde --- /dev/null +++ b/homeassistant/components/recorder/models/context.py @@ -0,0 +1,42 @@ +"""Models for Recorder.""" +from __future__ import annotations + +from contextlib import suppress +from functools import lru_cache +from uuid import UUID + +from homeassistant.util.ulid import bytes_to_ulid, ulid_to_bytes + + +def ulid_to_bytes_or_none(ulid: str | None) -> bytes | None: + """Convert an ulid to bytes.""" + if ulid is None: + return None + return ulid_to_bytes(ulid) + + +def bytes_to_ulid_or_none(_bytes: bytes | None) -> str | None: + """Convert bytes to a ulid.""" + if _bytes is None: + return None + return bytes_to_ulid(_bytes) + + +@lru_cache(maxsize=16) +def uuid_hex_to_bytes_or_none(uuid_hex: str | None) -> bytes | None: + """Convert a uuid hex to bytes.""" + if uuid_hex is None: + return None + with suppress(ValueError): + return UUID(hex=uuid_hex).bytes + return None + + +@lru_cache(maxsize=16) +def bytes_to_uuid_hex_or_none(_bytes: bytes | None) -> str | None: + """Convert bytes to a uuid hex.""" + if _bytes is None: + return None + with suppress(ValueError): + return UUID(bytes=_bytes).hex + return None diff --git a/homeassistant/components/recorder/models/database.py b/homeassistant/components/recorder/models/database.py new file mode 100644 index 0000000000..e39f05cd9c --- /dev/null +++ b/homeassistant/components/recorder/models/database.py @@ -0,0 +1,33 @@ +"""Models for the database in the Recorder.""" +from __future__ import annotations + +from dataclasses import dataclass + +from awesomeversion import AwesomeVersion + +from ..const import SupportedDialect + + +class UnsupportedDialect(Exception): + """The dialect or its version is not supported.""" + + +@dataclass +class DatabaseEngine: + """Properties of the database engine.""" + + dialect: SupportedDialect + optimizer: DatabaseOptimizer + version: AwesomeVersion | None + + +@dataclass +class DatabaseOptimizer: + """Properties of the database optimizer for the configured database engine.""" + + # Some MariaDB versions have a bug that causes a slow query when using + # a range in a select statement with an IN clause. + # + # https://jira.mariadb.org/browse/MDEV-25020 + # + slow_range_in_select: bool diff --git a/homeassistant/components/recorder/models/legacy.py b/homeassistant/components/recorder/models/legacy.py new file mode 100644 index 0000000000..c26e517772 --- /dev/null +++ b/homeassistant/components/recorder/models/legacy.py @@ -0,0 +1,164 @@ +"""Models for Recorder.""" +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from sqlalchemy.engine.row import Row + +from homeassistant.const import ( + COMPRESSED_STATE_ATTRIBUTES, + COMPRESSED_STATE_LAST_CHANGED, + COMPRESSED_STATE_LAST_UPDATED, + COMPRESSED_STATE_STATE, +) +from homeassistant.core import Context, State + +from .state_attributes import decode_attributes_from_row +from .time import ( + process_datetime_to_timestamp, + process_timestamp, + process_timestamp_to_utc_isoformat, +) + +# pylint: disable=invalid-name + + +class LazyStatePreSchema31(State): + """A lazy version of core State before schema 31.""" + + __slots__ = [ + "_row", + "_attributes", + "_last_changed", + "_last_updated", + "_context", + "attr_cache", + ] + + def __init__( # pylint: disable=super-init-not-called + self, + row: Row, + attr_cache: dict[str, dict[str, Any]], + start_time: datetime | None, + ) -> None: + """Init the lazy state.""" + self._row = row + self.entity_id: str = self._row.entity_id + self.state = self._row.state or "" + self._attributes: dict[str, Any] | None = None + self._last_changed: datetime | None = start_time + self._last_updated: datetime | None = start_time + self._context: Context | None = None + self.attr_cache = attr_cache + + @property # type: ignore[override] + def attributes(self) -> dict[str, Any]: + """State attributes.""" + if self._attributes is None: + self._attributes = decode_attributes_from_row(self._row, self.attr_cache) + return self._attributes + + @attributes.setter + def attributes(self, value: dict[str, Any]) -> None: + """Set attributes.""" + self._attributes = value + + @property + def context(self) -> Context: + """State context.""" + if self._context is None: + self._context = Context(id=None) + return self._context + + @context.setter + def context(self, value: Context) -> None: + """Set context.""" + self._context = value + + @property + def last_changed(self) -> datetime: + """Last changed datetime.""" + if self._last_changed is None: + if (last_changed := self._row.last_changed) is not None: + self._last_changed = process_timestamp(last_changed) + else: + self._last_changed = self.last_updated + return self._last_changed + + @last_changed.setter + def last_changed(self, value: datetime) -> None: + """Set last changed datetime.""" + self._last_changed = value + + @property + def last_updated(self) -> datetime: + """Last updated datetime.""" + if self._last_updated is None: + self._last_updated = process_timestamp(self._row.last_updated) + return self._last_updated + + @last_updated.setter + def last_updated(self, value: datetime) -> None: + """Set last updated datetime.""" + self._last_updated = value + + def as_dict(self) -> dict[str, Any]: # type: ignore[override] + """Return a dict representation of the LazyState. + + Async friendly. + + To be used for JSON serialization. + """ + if self._last_changed is None and self._last_updated is None: + last_updated_isoformat = process_timestamp_to_utc_isoformat( + self._row.last_updated + ) + if ( + self._row.last_changed is None + or self._row.last_changed == self._row.last_updated + ): + last_changed_isoformat = last_updated_isoformat + else: + last_changed_isoformat = process_timestamp_to_utc_isoformat( + self._row.last_changed + ) + else: + last_updated_isoformat = self.last_updated.isoformat() + if self.last_changed == self.last_updated: + last_changed_isoformat = last_updated_isoformat + else: + last_changed_isoformat = self.last_changed.isoformat() + return { + "entity_id": self.entity_id, + "state": self.state, + "attributes": self._attributes or self.attributes, + "last_changed": last_changed_isoformat, + "last_updated": last_updated_isoformat, + } + + +def row_to_compressed_state_pre_schema_31( + row: Row, + attr_cache: dict[str, dict[str, Any]], + start_time: datetime | None, +) -> dict[str, Any]: + """Convert a database row to a compressed state before schema 31.""" + comp_state = { + COMPRESSED_STATE_STATE: row.state, + COMPRESSED_STATE_ATTRIBUTES: decode_attributes_from_row(row, attr_cache), + } + if start_time: + comp_state[COMPRESSED_STATE_LAST_UPDATED] = start_time.timestamp() + else: + row_last_updated: datetime = row.last_updated + comp_state[COMPRESSED_STATE_LAST_UPDATED] = process_datetime_to_timestamp( + row_last_updated + ) + if ( + row_changed_changed := row.last_changed + ) and row_last_updated != row_changed_changed: + comp_state[COMPRESSED_STATE_LAST_CHANGED] = process_datetime_to_timestamp( + row_changed_changed + ) + return comp_state diff --git a/homeassistant/components/recorder/models/state.py b/homeassistant/components/recorder/models/state.py new file mode 100644 index 0000000000..12983a3e68 --- /dev/null +++ b/homeassistant/components/recorder/models/state.py @@ -0,0 +1,145 @@ +"""Models states in for Recorder.""" +from __future__ import annotations + +from datetime import datetime +import logging +from typing import Any + +from sqlalchemy.engine.row import Row + +from homeassistant.const import ( + COMPRESSED_STATE_ATTRIBUTES, + COMPRESSED_STATE_LAST_CHANGED, + COMPRESSED_STATE_LAST_UPDATED, + COMPRESSED_STATE_STATE, +) +from homeassistant.core import Context, State +import homeassistant.util.dt as dt_util + +from .state_attributes import decode_attributes_from_row +from .time import process_timestamp + +# pylint: disable=invalid-name + +_LOGGER = logging.getLogger(__name__) + + +class LazyState(State): + """A lazy version of core State after schema 31.""" + + __slots__ = [ + "_row", + "_attributes", + "_last_changed_ts", + "_last_updated_ts", + "_context", + "attr_cache", + ] + + def __init__( # pylint: disable=super-init-not-called + self, + row: Row, + attr_cache: dict[str, dict[str, Any]], + start_time: datetime | None, + ) -> None: + """Init the lazy state.""" + self._row = row + self.entity_id: str = self._row.entity_id + self.state = self._row.state or "" + self._attributes: dict[str, Any] | None = None + self._last_updated_ts: float | None = self._row.last_updated_ts or ( + dt_util.utc_to_timestamp(start_time) if start_time else None + ) + self._last_changed_ts: float | None = ( + self._row.last_changed_ts or self._last_updated_ts + ) + self._context: Context | None = None + self.attr_cache = attr_cache + + @property # type: ignore[override] + def attributes(self) -> dict[str, Any]: + """State attributes.""" + if self._attributes is None: + self._attributes = decode_attributes_from_row(self._row, self.attr_cache) + return self._attributes + + @attributes.setter + def attributes(self, value: dict[str, Any]) -> None: + """Set attributes.""" + self._attributes = value + + @property + def context(self) -> Context: + """State context.""" + if self._context is None: + self._context = Context(id=None) + return self._context + + @context.setter + def context(self, value: Context) -> None: + """Set context.""" + self._context = value + + @property + def last_changed(self) -> datetime: + """Last changed datetime.""" + assert self._last_changed_ts is not None + return dt_util.utc_from_timestamp(self._last_changed_ts) + + @last_changed.setter + def last_changed(self, value: datetime) -> None: + """Set last changed datetime.""" + self._last_changed_ts = process_timestamp(value).timestamp() + + @property + def last_updated(self) -> datetime: + """Last updated datetime.""" + assert self._last_updated_ts is not None + return dt_util.utc_from_timestamp(self._last_updated_ts) + + @last_updated.setter + def last_updated(self, value: datetime) -> None: + """Set last updated datetime.""" + self._last_updated_ts = process_timestamp(value).timestamp() + + def as_dict(self) -> dict[str, Any]: # type: ignore[override] + """Return a dict representation of the LazyState. + + Async friendly. + + To be used for JSON serialization. + """ + last_updated_isoformat = self.last_updated.isoformat() + if self._last_changed_ts == self._last_updated_ts: + last_changed_isoformat = last_updated_isoformat + else: + last_changed_isoformat = self.last_changed.isoformat() + return { + "entity_id": self.entity_id, + "state": self.state, + "attributes": self._attributes or self.attributes, + "last_changed": last_changed_isoformat, + "last_updated": last_updated_isoformat, + } + + +def row_to_compressed_state( + row: Row, + attr_cache: dict[str, dict[str, Any]], + start_time: datetime | None, +) -> dict[str, Any]: + """Convert a database row to a compressed state schema 31 and later.""" + comp_state = { + COMPRESSED_STATE_STATE: row.state, + COMPRESSED_STATE_ATTRIBUTES: decode_attributes_from_row(row, attr_cache), + } + if start_time: + comp_state[COMPRESSED_STATE_LAST_UPDATED] = dt_util.utc_to_timestamp(start_time) + else: + row_last_updated_ts: float = row.last_updated_ts + comp_state[COMPRESSED_STATE_LAST_UPDATED] = row_last_updated_ts + if ( + row_changed_changed_ts := row.last_changed_ts + ) and row_last_updated_ts != row_changed_changed_ts: + comp_state[COMPRESSED_STATE_LAST_CHANGED] = row_changed_changed_ts + return comp_state diff --git a/homeassistant/components/recorder/models/state_attributes.py b/homeassistant/components/recorder/models/state_attributes.py new file mode 100644 index 0000000000..738684c021 --- /dev/null +++ b/homeassistant/components/recorder/models/state_attributes.py @@ -0,0 +1,30 @@ +"""State attributes models.""" + +from __future__ import annotations + +import logging +from typing import Any + +from sqlalchemy.engine.row import Row + +from homeassistant.util.json import json_loads_object + +EMPTY_JSON_OBJECT = "{}" +_LOGGER = logging.getLogger(__name__) + + +def decode_attributes_from_row( + row: Row, attr_cache: dict[str, dict[str, Any]] +) -> dict[str, Any]: + """Decode attributes from a database row.""" + source: str = row.shared_attrs or row.attributes + if (attributes := attr_cache.get(source)) is not None: + return attributes + if not source or source == EMPTY_JSON_OBJECT: + return {} + try: + attr_cache[source] = attributes = json_loads_object(source) + except ValueError: + _LOGGER.exception("Error converting row to state attributes: %s", source) + attr_cache[source] = attributes = {} + return attributes diff --git a/homeassistant/components/recorder/models/statistics.py b/homeassistant/components/recorder/models/statistics.py new file mode 100644 index 0000000000..4cf465955c --- /dev/null +++ b/homeassistant/components/recorder/models/statistics.py @@ -0,0 +1,89 @@ +"""Models for statistics in the Recorder.""" +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Literal, TypedDict + + +class StatisticResult(TypedDict): + """Statistic result data class. + + Allows multiple datapoints for the same statistic_id. + """ + + meta: StatisticMetaData + stat: StatisticData + + +class StatisticDataTimestampBase(TypedDict): + """Mandatory fields for statistic data class with a timestamp.""" + + start_ts: float + + +class StatisticDataBase(TypedDict): + """Mandatory fields for statistic data class.""" + + start: datetime + + +class StatisticMixIn(TypedDict, total=False): + """Mandatory fields for statistic data class.""" + + state: float + sum: float + min: float + max: float + mean: float + + +class StatisticData(StatisticDataBase, StatisticMixIn, total=False): + """Statistic data class.""" + + last_reset: datetime | None + + +class StatisticDataTimestamp(StatisticDataTimestampBase, StatisticMixIn, total=False): + """Statistic data class with a timestamp.""" + + last_reset_ts: float | None + + +class StatisticMetaData(TypedDict): + """Statistic meta data class.""" + + has_mean: bool + has_sum: bool + name: str | None + source: str + statistic_id: str + unit_of_measurement: str | None + + +class CalendarStatisticPeriod(TypedDict, total=False): + """Statistic period definition.""" + + period: Literal["hour", "day", "week", "month", "year"] + offset: int + + +class FixedStatisticPeriod(TypedDict, total=False): + """Statistic period definition.""" + + end_time: datetime + start_time: datetime + + +class RollingWindowStatisticPeriod(TypedDict, total=False): + """Statistic period definition.""" + + duration: timedelta + offset: timedelta + + +class StatisticPeriod(TypedDict, total=False): + """Statistic period definition.""" + + calendar: CalendarStatisticPeriod + fixed_period: FixedStatisticPeriod + rolling_window: RollingWindowStatisticPeriod diff --git a/homeassistant/components/recorder/models/time.py b/homeassistant/components/recorder/models/time.py new file mode 100644 index 0000000000..078a982d5a --- /dev/null +++ b/homeassistant/components/recorder/models/time.py @@ -0,0 +1,82 @@ +"""Models for Recorder.""" +from __future__ import annotations + +from datetime import datetime +import logging +from typing import overload + +import homeassistant.util.dt as dt_util + +# pylint: disable=invalid-name + +_LOGGER = logging.getLogger(__name__) + +DB_TIMEZONE = "+00:00" + +EMPTY_JSON_OBJECT = "{}" + + +@overload +def process_timestamp(ts: None) -> None: + ... + + +@overload +def process_timestamp(ts: datetime) -> datetime: + ... + + +def process_timestamp(ts: datetime | None) -> datetime | None: + """Process a timestamp into datetime object.""" + if ts is None: + return None + if ts.tzinfo is None: + return ts.replace(tzinfo=dt_util.UTC) + + return dt_util.as_utc(ts) + + +@overload +def process_timestamp_to_utc_isoformat(ts: None) -> None: + ... + + +@overload +def process_timestamp_to_utc_isoformat(ts: datetime) -> str: + ... + + +def process_timestamp_to_utc_isoformat(ts: datetime | None) -> str | None: + """Process a timestamp into UTC isotime.""" + if ts is None: + return None + if ts.tzinfo == dt_util.UTC: + return ts.isoformat() + if ts.tzinfo is None: + return f"{ts.isoformat()}{DB_TIMEZONE}" + return ts.astimezone(dt_util.UTC).isoformat() + + +def process_datetime_to_timestamp(ts: datetime) -> float: + """Process a datebase datetime to epoch. + + Mirrors the behavior of process_timestamp_to_utc_isoformat + except it returns the epoch time. + """ + if ts.tzinfo is None or ts.tzinfo == dt_util.UTC: + return dt_util.utc_to_timestamp(ts) + return ts.timestamp() + + +def datetime_to_timestamp_or_none(dt: datetime | None) -> float | None: + """Convert a datetime to a timestamp.""" + if dt is None: + return None + return dt_util.utc_to_timestamp(dt) + + +def timestamp_to_datetime_or_none(ts: float | None) -> datetime | None: + """Convert a timestamp to a datetime.""" + if not ts: + return None + return dt_util.utc_from_timestamp(ts) diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index 2b4bed072a..ccde8c5d18 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -20,11 +20,9 @@ from homeassistant.components.recorder.db_schema import ( StateAttributes, States, ) -from homeassistant.components.recorder.models import ( - LazyState, - LazyStatePreSchema31, - process_timestamp, -) +from homeassistant.components.recorder.history import legacy +from homeassistant.components.recorder.models import LazyState, process_timestamp +from homeassistant.components.recorder.models.legacy import LazyStatePreSchema31 from homeassistant.components.recorder.util import session_scope import homeassistant.core as ha from homeassistant.core import HomeAssistant, State @@ -63,7 +61,7 @@ async def _async_get_states( attr_cache = {} return [ klass(row, attr_cache, None) - for row in history._get_rows_with_session( + for row in legacy._get_rows_with_session( hass, session, utc_point_in_time, diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 609af6c362..78302f7427 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -15,9 +15,12 @@ from sqlalchemy.sql.elements import TextClause from sqlalchemy.sql.lambdas import StatementLambdaElement from homeassistant.components import recorder -from homeassistant.components.recorder import history, util +from homeassistant.components.recorder import util from homeassistant.components.recorder.const import DOMAIN, SQLITE_URL_PREFIX from homeassistant.components.recorder.db_schema import RecorderRuns +from homeassistant.components.recorder.history.legacy import ( + _get_single_entity_states_stmt, +) from homeassistant.components.recorder.models import ( UnsupportedDialect, process_timestamp, @@ -905,7 +908,7 @@ def test_execute_stmt_lambda_element( with session_scope(hass=hass) as session: # No time window, we always get a list - stmt = history._get_single_entity_states_stmt( + stmt = _get_single_entity_states_stmt( instance.schema_version, dt_util.utcnow(), "sensor.on", False ) rows = util.execute_stmt_lambda_element(session, stmt) From 1c57339ec3069b4a5ab7581add62eb6e3320263d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Mar 2023 16:51:16 -1000 Subject: [PATCH 0844/2810] Refactor recorder tests to use recorder history API (#89565) --- tests/components/automation/test_recorder.py | 39 ++++++---------- tests/components/calendar/test_recorder.py | 28 ++++------- tests/components/camera/test_recorder.py | 34 +++++--------- tests/components/climate/test_recorder.py | 44 +++++++----------- tests/components/fan/test_recorder.py | 28 ++++------- tests/components/group/test_recorder.py | 37 +++++---------- tests/components/humidifier/test_recorder.py | 32 ++++--------- .../components/input_boolean/test_recorder.py | 28 ++++------- .../components/input_button/test_recorder.py | 27 ++++------- .../input_datetime/test_recorder.py | 31 ++++--------- .../components/input_number/test_recorder.py | 35 +++++--------- .../components/input_select/test_recorder.py | 29 ++++-------- tests/components/input_text/test_recorder.py | 35 +++++--------- tests/components/light/test_recorder.py | 40 ++++++---------- .../components/media_player/test_recorder.py | 40 ++++++---------- tests/components/number/test_recorder.py | 36 +++++---------- tests/components/schedule/test_recorder.py | 33 +++++-------- tests/components/script/test_recorder.py | 39 ++++++---------- tests/components/select/test_recorder.py | 30 ++++-------- tests/components/siren/test_recorder.py | 30 ++++-------- tests/components/sun/test_recorder.py | 46 +++++++------------ tests/components/text/test_recorder.py | 32 ++++--------- .../components/unifiprotect/test_recorder.py | 33 ++++--------- tests/components/update/test_recorder.py | 34 +++++--------- tests/components/vacuum/test_recorder.py | 30 ++++-------- .../components/water_heater/test_recorder.py | 34 +++++--------- tests/components/weather/test_recorder.py | 28 ++++------- 27 files changed, 296 insertions(+), 616 deletions(-) diff --git a/tests/components/automation/test_recorder.py b/tests/components/automation/test_recorder.py index 7e132759a9..d4fde85f50 100644 --- a/tests/components/automation/test_recorder.py +++ b/tests/components/automation/test_recorder.py @@ -12,11 +12,11 @@ from homeassistant.components.automation import ( CONF_ID, ) from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from tests.common import async_mock_service from tests.components.recorder.common import async_wait_recording_done @@ -32,6 +32,7 @@ async def test_exclude_attributes( recorder_mock: Recorder, hass: HomeAssistant, calls ) -> None: """Test automation registered attributes to be excluded.""" + now = dt_util.utcnow() assert await async_setup_component( hass, automation.DOMAIN, @@ -49,25 +50,13 @@ async def test_exclude_attributes( assert ["hello.world"] == calls[0].data.get(ATTR_ENTITY_ID) await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) > 1 - for state in states: - assert ATTR_LAST_TRIGGERED not in state.attributes - assert ATTR_MODE not in state.attributes - assert ATTR_CUR not in state.attributes - assert CONF_ID not in state.attributes - assert ATTR_MAX not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) == 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_LAST_TRIGGERED not in state.attributes + assert ATTR_MODE not in state.attributes + assert ATTR_CUR not in state.attributes + assert CONF_ID not in state.attributes + assert ATTR_MAX not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/calendar/test_recorder.py b/tests/components/calendar/test_recorder.py index 38f84436d6..9b88977761 100644 --- a/tests/components/calendar/test_recorder.py +++ b/tests/components/calendar/test_recorder.py @@ -2,10 +2,9 @@ from datetime import timedelta from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -15,6 +14,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test sensor attributes to be excluded.""" + now = dt_util.utcnow() await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) await hass.async_block_till_done() @@ -28,21 +28,9 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) + states = await hass.async_add_executor_job(get_significant_states, hass, now) assert len(states) > 1 - for state in states: - assert ATTR_FRIENDLY_NAME in state.attributes - assert "description" not in state.attributes + for entity_states in states.values(): + for state in entity_states: + assert ATTR_FRIENDLY_NAME in state.attributes + assert "description" not in state.attributes diff --git a/tests/components/camera/test_recorder.py b/tests/components/camera/test_recorder.py index d5f72fe1c9..9230756cec 100644 --- a/tests/components/camera/test_recorder.py +++ b/tests/components/camera/test_recorder.py @@ -5,15 +5,14 @@ from datetime import timedelta from homeassistant.components import camera from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME, ATTR_SUPPORTED_FEATURES, ) -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -23,6 +22,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test camera registered attributes to be excluded.""" + now = dt_util.utcnow() await async_setup_component( hass, camera.DOMAIN, {camera.DOMAIN: {"platform": "demo"}} ) @@ -31,24 +31,12 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_camera_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_camera_states) + states = await hass.async_add_executor_job(get_significant_states, hass, now) assert len(states) > 1 - for state in states: - assert "access_token" not in state.attributes - assert ATTR_ENTITY_PICTURE not in state.attributes - assert ATTR_ATTRIBUTION not in state.attributes - assert ATTR_SUPPORTED_FEATURES not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes + for entity_states in states.values(): + for state in entity_states: + assert "access_token" not in state.attributes + assert ATTR_ENTITY_PICTURE not in state.attributes + assert ATTR_ATTRIBUTION not in state.attributes + assert ATTR_SUPPORTED_FEATURES not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/climate/test_recorder.py b/tests/components/climate/test_recorder.py index a2b3ac05a9..df9b64631b 100644 --- a/tests/components/climate/test_recorder.py +++ b/tests/components/climate/test_recorder.py @@ -16,10 +16,9 @@ from homeassistant.components.climate import ( ATTR_TARGET_TEMP_STEP, ) from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -29,6 +28,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test climate registered attributes to be excluded.""" + now = dt_util.utcnow() await async_setup_component( hass, climate.DOMAIN, {climate.DOMAIN: {"platform": "demo"}} ) @@ -37,29 +37,17 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) + states = await hass.async_add_executor_job(get_significant_states, hass, now) assert len(states) > 1 - for state in states: - assert ATTR_PRESET_MODES not in state.attributes - assert ATTR_HVAC_MODES not in state.attributes - assert ATTR_FAN_MODES not in state.attributes - assert ATTR_SWING_MODES not in state.attributes - assert ATTR_MIN_TEMP not in state.attributes - assert ATTR_MAX_TEMP not in state.attributes - assert ATTR_MIN_HUMIDITY not in state.attributes - assert ATTR_MAX_HUMIDITY not in state.attributes - assert ATTR_TARGET_TEMP_STEP not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes + for entity_states in states.values(): + for state in entity_states: + assert ATTR_PRESET_MODES not in state.attributes + assert ATTR_HVAC_MODES not in state.attributes + assert ATTR_FAN_MODES not in state.attributes + assert ATTR_SWING_MODES not in state.attributes + assert ATTR_MIN_TEMP not in state.attributes + assert ATTR_MAX_TEMP not in state.attributes + assert ATTR_MIN_HUMIDITY not in state.attributes + assert ATTR_MAX_HUMIDITY not in state.attributes + assert ATTR_TARGET_TEMP_STEP not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/fan/test_recorder.py b/tests/components/fan/test_recorder.py index fe2cf27c98..8c42e20b73 100644 --- a/tests/components/fan/test_recorder.py +++ b/tests/components/fan/test_recorder.py @@ -6,10 +6,9 @@ from datetime import timedelta from homeassistant.components import fan from homeassistant.components.fan import ATTR_PRESET_MODES from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -19,27 +18,16 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test fan registered attributes to be excluded.""" + now = dt_util.utcnow() await async_setup_component(hass, fan.DOMAIN, {fan.DOMAIN: {"platform": "demo"}}) await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) + states = await hass.async_add_executor_job(get_significant_states, hass, now) assert len(states) > 1 - for state in states: - assert ATTR_PRESET_MODES not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes + for entity_states in states.values(): + for state in entity_states: + assert ATTR_PRESET_MODES not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/group/test_recorder.py b/tests/components/group/test_recorder.py index 2831c82b31..2c100a2e3c 100644 --- a/tests/components/group/test_recorder.py +++ b/tests/components/group/test_recorder.py @@ -6,10 +6,9 @@ from datetime import timedelta from homeassistant.components import group from homeassistant.components.group import ATTR_AUTO, ATTR_ENTITY_ID, ATTR_ORDER from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_ON -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -19,6 +18,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test number registered attributes to be excluded.""" + now = dt_util.utcnow() hass.states.async_set("light.bowl", STATE_ON) assert await async_setup_component(hass, "light", {}) @@ -38,27 +38,12 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - attr_ids = {} - for db_state_attributes in session.query(StateAttributes): - attr_ids[ - db_state_attributes.attributes_id - ] = db_state_attributes.to_native() - for db_state, _ in session.query(States, StateAttributes).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = attr_ids[db_state.attributes_id] - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) + states = await hass.async_add_executor_job(get_significant_states, hass, now) assert len(states) > 1 - for state in states: - if state.domain == group.DOMAIN: - assert ATTR_AUTO not in state.attributes - assert ATTR_ENTITY_ID not in state.attributes - assert ATTR_ORDER not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes + for entity_states in states.values(): + for state in entity_states: + if split_entity_id(state.entity_id)[0] == group.DOMAIN: + assert ATTR_AUTO not in state.attributes + assert ATTR_ENTITY_ID not in state.attributes + assert ATTR_ORDER not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/humidifier/test_recorder.py b/tests/components/humidifier/test_recorder.py index e0f30b65c6..0b4947847f 100644 --- a/tests/components/humidifier/test_recorder.py +++ b/tests/components/humidifier/test_recorder.py @@ -10,10 +10,9 @@ from homeassistant.components.humidifier import ( ATTR_MIN_HUMIDITY, ) from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -23,6 +22,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test humidifier registered attributes to be excluded.""" + now = dt_util.utcnow() await async_setup_component( hass, humidifier.DOMAIN, {humidifier.DOMAIN: {"platform": "demo"}} ) @@ -31,23 +31,11 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) + states = await hass.async_add_executor_job(get_significant_states, hass, now) assert len(states) > 1 - for state in states: - assert ATTR_MIN_HUMIDITY not in state.attributes - assert ATTR_MAX_HUMIDITY not in state.attributes - assert ATTR_AVAILABLE_MODES not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes + for entity_states in states.values(): + for state in entity_states: + assert ATTR_MIN_HUMIDITY not in state.attributes + assert ATTR_MAX_HUMIDITY not in state.attributes + assert ATTR_AVAILABLE_MODES not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/input_boolean/test_recorder.py b/tests/components/input_boolean/test_recorder.py index a4cc3b998d..c2e759ec72 100644 --- a/tests/components/input_boolean/test_recorder.py +++ b/tests/components/input_boolean/test_recorder.py @@ -5,10 +5,9 @@ from datetime import timedelta from homeassistant.components.input_boolean import DOMAIN from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_EDITABLE -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -20,6 +19,7 @@ async def test_exclude_attributes( recorder_mock: Recorder, hass: HomeAssistant, enable_custom_integrations: None ) -> None: """Test attributes to be excluded.""" + now = dt_util.utcnow() assert await async_setup_component(hass, DOMAIN, {DOMAIN: {"test": {}}}) state = hass.states.get("input_boolean.test") @@ -30,20 +30,8 @@ async def test_exclude_attributes( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) await hass.async_block_till_done() await async_wait_recording_done(hass) - - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) == 1 - assert ATTR_EDITABLE not in states[0].attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_EDITABLE not in state.attributes diff --git a/tests/components/input_button/test_recorder.py b/tests/components/input_button/test_recorder.py index 0489845998..0887756ae1 100644 --- a/tests/components/input_button/test_recorder.py +++ b/tests/components/input_button/test_recorder.py @@ -5,10 +5,9 @@ from datetime import timedelta from homeassistant.components.input_button import DOMAIN from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_EDITABLE -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -20,6 +19,7 @@ async def test_exclude_attributes( recorder_mock: Recorder, hass: HomeAssistant, enable_custom_integrations: None ) -> None: """Test attributes to be excluded.""" + now = dt_util.utcnow() assert await async_setup_component(hass, DOMAIN, {DOMAIN: {"test": {}}}) state = hass.states.get("input_button.test") @@ -31,19 +31,8 @@ async def test_exclude_attributes( await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) == 1 - assert ATTR_EDITABLE not in states[0].attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_EDITABLE not in state.attributes diff --git a/tests/components/input_datetime/test_recorder.py b/tests/components/input_datetime/test_recorder.py index a59004ee06..59abefdd7d 100644 --- a/tests/components/input_datetime/test_recorder.py +++ b/tests/components/input_datetime/test_recorder.py @@ -5,10 +5,9 @@ from datetime import timedelta from homeassistant.components.input_datetime import CONF_HAS_DATE, CONF_HAS_TIME, DOMAIN from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_EDITABLE -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -20,6 +19,7 @@ async def test_exclude_attributes( recorder_mock: Recorder, hass: HomeAssistant, enable_custom_integrations: None ) -> None: """Test attributes to be excluded.""" + now = dt_util.utcnow() assert await async_setup_component( hass, DOMAIN, {DOMAIN: {"test": {CONF_HAS_TIME: True}}} ) @@ -35,21 +35,10 @@ async def test_exclude_attributes( await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) == 1 - assert ATTR_EDITABLE not in states[0].attributes - assert CONF_HAS_DATE not in states[0].attributes - assert CONF_HAS_TIME not in states[0].attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_EDITABLE not in state.attributes + assert CONF_HAS_DATE not in state.attributes + assert CONF_HAS_TIME not in state.attributes diff --git a/tests/components/input_number/test_recorder.py b/tests/components/input_number/test_recorder.py index 982e8942c4..1e489ec40c 100644 --- a/tests/components/input_number/test_recorder.py +++ b/tests/components/input_number/test_recorder.py @@ -11,10 +11,9 @@ from homeassistant.components.input_number import ( DOMAIN, ) from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_EDITABLE -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -26,6 +25,7 @@ async def test_exclude_attributes( recorder_mock: Recorder, hass: HomeAssistant, enable_custom_integrations: None ) -> None: """Test attributes to be excluded.""" + now = dt_util.utcnow() assert await async_setup_component( hass, DOMAIN, {DOMAIN: {"test": {"min": 0, "max": 100}}} ) @@ -43,23 +43,12 @@ async def test_exclude_attributes( await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) == 1 - assert ATTR_EDITABLE not in states[0].attributes - assert ATTR_MIN not in states[0].attributes - assert ATTR_MAX not in states[0].attributes - assert ATTR_STEP not in states[0].attributes - assert ATTR_MODE not in states[0].attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_EDITABLE not in state.attributes + assert ATTR_MIN not in state.attributes + assert ATTR_MAX not in state.attributes + assert ATTR_STEP not in state.attributes + assert ATTR_MODE not in state.attributes diff --git a/tests/components/input_select/test_recorder.py b/tests/components/input_select/test_recorder.py index 5013228e6f..084009b163 100644 --- a/tests/components/input_select/test_recorder.py +++ b/tests/components/input_select/test_recorder.py @@ -5,10 +5,9 @@ from datetime import timedelta from homeassistant.components.input_select import ATTR_OPTIONS, DOMAIN from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_EDITABLE -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -20,6 +19,7 @@ async def test_exclude_attributes( recorder_mock: Recorder, hass: HomeAssistant, enable_custom_integrations: None ) -> None: """Test attributes to be excluded.""" + now = dt_util.utcnow() assert await async_setup_component( hass, DOMAIN, @@ -42,20 +42,9 @@ async def test_exclude_attributes( await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) == 1 - assert ATTR_EDITABLE not in states[0].attributes - assert ATTR_OPTIONS in states[0].attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_EDITABLE not in state.attributes + assert ATTR_OPTIONS in state.attributes diff --git a/tests/components/input_text/test_recorder.py b/tests/components/input_text/test_recorder.py index 4867d45307..bc2e7d9f5a 100644 --- a/tests/components/input_text/test_recorder.py +++ b/tests/components/input_text/test_recorder.py @@ -12,10 +12,9 @@ from homeassistant.components.input_text import ( MODE_TEXT, ) from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_EDITABLE -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -27,6 +26,7 @@ async def test_exclude_attributes( recorder_mock: Recorder, hass: HomeAssistant, enable_custom_integrations: None ) -> None: """Test attributes to be excluded.""" + now = dt_util.utcnow() assert await async_setup_component(hass, DOMAIN, {DOMAIN: {"test": {}}}) state = hass.states.get("input_text.test") @@ -42,23 +42,12 @@ async def test_exclude_attributes( await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) == 1 - assert ATTR_EDITABLE not in states[0].attributes - assert ATTR_MAX not in states[0].attributes - assert ATTR_MIN not in states[0].attributes - assert ATTR_MODE not in states[0].attributes - assert ATTR_PATTERN not in states[0].attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_EDITABLE not in state.attributes + assert ATTR_MAX not in state.attributes + assert ATTR_MIN not in state.attributes + assert ATTR_MODE not in state.attributes + assert ATTR_PATTERN not in state.attributes diff --git a/tests/components/light/test_recorder.py b/tests/components/light/test_recorder.py index b4e0621d23..fd95964e55 100644 --- a/tests/components/light/test_recorder.py +++ b/tests/components/light/test_recorder.py @@ -13,10 +13,9 @@ from homeassistant.components.light import ( ATTR_SUPPORTED_COLOR_MODES, ) from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -26,6 +25,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test light registered attributes to be excluded.""" + now = dt_util.utcnow() await async_setup_component( hass, light.DOMAIN, {light.DOMAIN: {"platform": "demo"}} ) @@ -34,26 +34,14 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) > 1 - for state in states: - assert ATTR_MIN_MIREDS not in state.attributes - assert ATTR_MAX_MIREDS not in state.attributes - assert ATTR_SUPPORTED_COLOR_MODES not in state.attributes - assert ATTR_EFFECT not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes - assert ATTR_MAX_COLOR_TEMP_KELVIN not in state.attributes - assert ATTR_MIN_COLOR_TEMP_KELVIN not in state.attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_MIN_MIREDS not in state.attributes + assert ATTR_MAX_MIREDS not in state.attributes + assert ATTR_SUPPORTED_COLOR_MODES not in state.attributes + assert ATTR_EFFECT not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes + assert ATTR_MAX_COLOR_TEMP_KELVIN not in state.attributes + assert ATTR_MIN_COLOR_TEMP_KELVIN not in state.attributes diff --git a/tests/components/media_player/test_recorder.py b/tests/components/media_player/test_recorder.py index 90e74bf54b..ba6b99b2e3 100644 --- a/tests/components/media_player/test_recorder.py +++ b/tests/components/media_player/test_recorder.py @@ -12,10 +12,9 @@ from homeassistant.components.media_player import ( ATTR_SOUND_MODE_LIST, ) from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -25,6 +24,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test media_player registered attributes to be excluded.""" + now = dt_util.utcnow() await async_setup_component( hass, media_player.DOMAIN, {media_player.DOMAIN: {"platform": "demo"}} ) @@ -33,26 +33,14 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) > 1 - for state in states: - assert ATTR_ENTITY_PICTURE not in state.attributes - assert ATTR_ENTITY_PICTURE_LOCAL not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes - assert ATTR_INPUT_SOURCE_LIST not in state.attributes - assert ATTR_MEDIA_POSITION not in state.attributes - assert ATTR_MEDIA_POSITION_UPDATED_AT not in state.attributes - assert ATTR_SOUND_MODE_LIST not in state.attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_ENTITY_PICTURE not in state.attributes + assert ATTR_ENTITY_PICTURE_LOCAL not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes + assert ATTR_INPUT_SOURCE_LIST not in state.attributes + assert ATTR_MEDIA_POSITION not in state.attributes + assert ATTR_MEDIA_POSITION_UPDATED_AT not in state.attributes + assert ATTR_SOUND_MODE_LIST not in state.attributes diff --git a/tests/components/number/test_recorder.py b/tests/components/number/test_recorder.py index 447f4bd76e..1d1f7c506e 100644 --- a/tests/components/number/test_recorder.py +++ b/tests/components/number/test_recorder.py @@ -6,10 +6,9 @@ from datetime import timedelta from homeassistant.components import number from homeassistant.components.number import ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_STEP from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -23,28 +22,17 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) hass, number.DOMAIN, {number.DOMAIN: {"platform": "demo"}} ) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) + now = dt_util.utcnow() + async_fire_time_changed(hass, now + timedelta(minutes=5)) await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) + states = await hass.async_add_executor_job(get_significant_states, hass, now) assert len(states) > 1 - for state in states: - assert ATTR_MIN not in state.attributes - assert ATTR_MAX not in state.attributes - assert ATTR_STEP not in state.attributes - assert ATTR_MODE not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes + for entity_states in states.values(): + for state in entity_states: + assert ATTR_MIN not in state.attributes + assert ATTR_MAX not in state.attributes + assert ATTR_STEP not in state.attributes + assert ATTR_MODE not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/schedule/test_recorder.py b/tests/components/schedule/test_recorder.py index 75040bc1a0..ee1660653d 100644 --- a/tests/components/schedule/test_recorder.py +++ b/tests/components/schedule/test_recorder.py @@ -4,11 +4,10 @@ from __future__ import annotations from datetime import timedelta from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.schedule.const import ATTR_NEXT_EVENT, DOMAIN from homeassistant.const import ATTR_EDITABLE, ATTR_FRIENDLY_NAME, ATTR_ICON -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -22,6 +21,7 @@ async def test_exclude_attributes( enable_custom_integrations: None, ) -> None: """Test attributes to be excluded.""" + now = dt_util.utcnow() assert await async_setup_component( hass, DOMAIN, @@ -54,22 +54,11 @@ async def test_exclude_attributes( await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) == 1 - assert ATTR_EDITABLE not in states[0].attributes - assert ATTR_FRIENDLY_NAME in states[0].attributes - assert ATTR_ICON in states[0].attributes - assert ATTR_NEXT_EVENT not in states[0].attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_EDITABLE not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes + assert ATTR_ICON in state.attributes + assert ATTR_NEXT_EVENT not in state.attributes diff --git a/tests/components/script/test_recorder.py b/tests/components/script/test_recorder.py index ecda80df1d..7204fce3f4 100644 --- a/tests/components/script/test_recorder.py +++ b/tests/components/script/test_recorder.py @@ -5,8 +5,7 @@ import pytest from homeassistant.components import script from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.script import ( ATTR_CUR, ATTR_LAST_ACTION, @@ -15,8 +14,9 @@ from homeassistant.components.script import ( ATTR_MODE, ) from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.core import Context, HomeAssistant, State, callback +from homeassistant.core import Context, HomeAssistant, callback from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from tests.common import async_mock_service from tests.components.recorder.common import async_wait_recording_done @@ -32,6 +32,7 @@ async def test_exclude_attributes( recorder_mock: Recorder, hass: HomeAssistant, calls ) -> None: """Test automation registered attributes to be excluded.""" + now = dt_util.utcnow() await hass.async_block_till_done() calls = [] context = Context() @@ -65,25 +66,13 @@ async def test_exclude_attributes( await async_wait_recording_done(hass) assert len(calls) == 1 - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) > 1 - for state in states: - assert ATTR_LAST_TRIGGERED not in state.attributes - assert ATTR_MODE not in state.attributes - assert ATTR_CUR not in state.attributes - assert ATTR_LAST_ACTION not in state.attributes - assert ATTR_MAX not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_LAST_TRIGGERED not in state.attributes + assert ATTR_MODE not in state.attributes + assert ATTR_CUR not in state.attributes + assert ATTR_LAST_ACTION not in state.attributes + assert ATTR_MAX not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/select/test_recorder.py b/tests/components/select/test_recorder.py index 733949d50c..075a6e2486 100644 --- a/tests/components/select/test_recorder.py +++ b/tests/components/select/test_recorder.py @@ -5,11 +5,10 @@ from datetime import timedelta from homeassistant.components import select from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.select import ATTR_OPTIONS from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -19,6 +18,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test select registered attributes to be excluded.""" + now = dt_util.utcnow() await async_setup_component( hass, select.DOMAIN, {select.DOMAIN: {"platform": "demo"}} ) @@ -27,21 +27,9 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) > 1 - for state in states: - assert ATTR_OPTIONS not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_OPTIONS not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/siren/test_recorder.py b/tests/components/siren/test_recorder.py index 2970754f80..77b08135fa 100644 --- a/tests/components/siren/test_recorder.py +++ b/tests/components/siren/test_recorder.py @@ -5,11 +5,10 @@ from datetime import timedelta from homeassistant.components import siren from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.siren import ATTR_AVAILABLE_TONES from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -19,6 +18,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test siren registered attributes to be excluded.""" + now = dt_util.utcnow() await async_setup_component( hass, siren.DOMAIN, {siren.DOMAIN: {"platform": "demo"}} ) @@ -27,21 +27,9 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) > 1 - for state in states: - assert ATTR_AVAILABLE_TONES not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_AVAILABLE_TONES not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/sun/test_recorder.py b/tests/components/sun/test_recorder.py index def5046c97..c795a59a8e 100644 --- a/tests/components/sun/test_recorder.py +++ b/tests/components/sun/test_recorder.py @@ -4,8 +4,7 @@ from __future__ import annotations from datetime import timedelta from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.sun import ( DOMAIN, STATE_ATTR_AZIMUTH, @@ -19,7 +18,7 @@ from homeassistant.components.sun import ( STATE_ATTR_RISING, ) from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -29,35 +28,24 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test sun attributes to be excluded.""" + now = dt_util.utcnow() await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_sun_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_sun_states) - assert len(states) > 1 - for state in states: - assert STATE_ATTR_AZIMUTH not in state.attributes - assert STATE_ATTR_ELEVATION not in state.attributes - assert STATE_ATTR_NEXT_DAWN not in state.attributes - assert STATE_ATTR_NEXT_DUSK not in state.attributes - assert STATE_ATTR_NEXT_MIDNIGHT not in state.attributes - assert STATE_ATTR_NEXT_NOON not in state.attributes - assert STATE_ATTR_NEXT_RISING not in state.attributes - assert STATE_ATTR_NEXT_SETTING not in state.attributes - assert STATE_ATTR_RISING not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert STATE_ATTR_AZIMUTH not in state.attributes + assert STATE_ATTR_ELEVATION not in state.attributes + assert STATE_ATTR_NEXT_DAWN not in state.attributes + assert STATE_ATTR_NEXT_DUSK not in state.attributes + assert STATE_ATTR_NEXT_MIDNIGHT not in state.attributes + assert STATE_ATTR_NEXT_NOON not in state.attributes + assert STATE_ATTR_NEXT_RISING not in state.attributes + assert STATE_ATTR_NEXT_SETTING not in state.attributes + assert STATE_ATTR_RISING not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/text/test_recorder.py b/tests/components/text/test_recorder.py index c434a63ace..b62baaac81 100644 --- a/tests/components/text/test_recorder.py +++ b/tests/components/text/test_recorder.py @@ -5,11 +5,10 @@ from datetime import timedelta from homeassistant.components import text from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.text import ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_PATTERN from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -19,28 +18,17 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test siren registered attributes to be excluded.""" + now = dt_util.utcnow() await async_setup_component(hass, text.DOMAIN, {text.DOMAIN: {"platform": "demo"}}) await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) > 1 - for state in states: - for attr in (ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_PATTERN): - assert attr not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + for attr in (ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_PATTERN): + assert attr not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/unifiprotect/test_recorder.py b/tests/components/unifiprotect/test_recorder.py index 628f5022af..c8fc62296a 100644 --- a/tests/components/unifiprotect/test_recorder.py +++ b/tests/components/unifiprotect/test_recorder.py @@ -7,8 +7,7 @@ from unittest.mock import Mock from pyunifiprotect.data import Camera, Event, EventType from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.unifiprotect.binary_sensor import EVENT_SENSORS from homeassistant.components.unifiprotect.const import ( ATTR_EVENT_ID, @@ -16,7 +15,7 @@ from homeassistant.components.unifiprotect.const import ( DEFAULT_ATTRIBUTION, ) from homeassistant.const import ATTR_ATTRIBUTION, ATTR_FRIENDLY_NAME, STATE_ON, Platform -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from .utils import MockUFPFixture, ids_from_device_description, init_entry @@ -32,7 +31,7 @@ async def test_exclude_attributes( fixed_now: datetime, ) -> None: """Test binary_sensor has event_id and event_score excluded from recording.""" - + now = fixed_now await init_entry(hass, ufp, [doorbell, unadopted_camera]) _, entity_id = ids_from_device_description( @@ -70,22 +69,10 @@ async def test_exclude_attributes( assert state.attributes[ATTR_EVENT_SCORE] == 100 await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) > 1 - for state in states: - assert ATTR_EVENT_SCORE not in state.attributes - assert ATTR_EVENT_ID not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_EVENT_SCORE not in state.attributes + assert ATTR_EVENT_ID not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/update/test_recorder.py b/tests/components/update/test_recorder.py index 52158b0cc1..200cb4b459 100644 --- a/tests/components/update/test_recorder.py +++ b/tests/components/update/test_recorder.py @@ -4,8 +4,7 @@ from __future__ import annotations from datetime import timedelta from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.update.const import ( ATTR_IN_PROGRESS, ATTR_INSTALLED_VERSION, @@ -13,7 +12,7 @@ from homeassistant.components.update.const import ( DOMAIN, ) from homeassistant.const import ATTR_ENTITY_PICTURE, CONF_PLATFORM -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -25,6 +24,7 @@ async def test_exclude_attributes( recorder_mock: Recorder, hass: HomeAssistant, enable_custom_integrations: None ) -> None: """Test update attributes to be excluded.""" + now = dt_util.utcnow() platform = getattr(hass.components, f"test.{DOMAIN}") platform.init() assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) @@ -42,23 +42,11 @@ async def test_exclude_attributes( await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) > 1 - for state in states: - assert ATTR_ENTITY_PICTURE not in state.attributes - assert ATTR_IN_PROGRESS not in state.attributes - assert ATTR_RELEASE_SUMMARY not in state.attributes - assert ATTR_INSTALLED_VERSION in state.attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_ENTITY_PICTURE not in state.attributes + assert ATTR_IN_PROGRESS not in state.attributes + assert ATTR_RELEASE_SUMMARY not in state.attributes + assert ATTR_INSTALLED_VERSION in state.attributes diff --git a/tests/components/vacuum/test_recorder.py b/tests/components/vacuum/test_recorder.py index 9304cf9855..dc945f2c15 100644 --- a/tests/components/vacuum/test_recorder.py +++ b/tests/components/vacuum/test_recorder.py @@ -5,11 +5,10 @@ from datetime import timedelta from homeassistant.components import vacuum from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.vacuum import ATTR_FAN_SPEED_LIST from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -19,6 +18,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test vacuum registered attributes to be excluded.""" + now = dt_util.utcnow() await async_setup_component( hass, vacuum.DOMAIN, {vacuum.DOMAIN: {"platform": "demo"}} ) @@ -27,21 +27,9 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) > 1 - for state in states: - assert ATTR_FAN_SPEED_LIST not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_FAN_SPEED_LIST not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/water_heater/test_recorder.py b/tests/components/water_heater/test_recorder.py index c77ace9f12..febe2fd7df 100644 --- a/tests/components/water_heater/test_recorder.py +++ b/tests/components/water_heater/test_recorder.py @@ -5,15 +5,14 @@ from datetime import timedelta from homeassistant.components import water_heater from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.water_heater import ( ATTR_MAX_TEMP, ATTR_MIN_TEMP, ATTR_OPERATION_LIST, ) from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -23,6 +22,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test water_heater registered attributes to be excluded.""" + now = dt_util.utcnow() await async_setup_component( hass, water_heater.DOMAIN, {water_heater.DOMAIN: {"platform": "demo"}} ) @@ -31,23 +31,11 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) > 1 - for state in states: - assert ATTR_OPERATION_LIST not in state.attributes - assert ATTR_MIN_TEMP not in state.attributes - assert ATTR_MAX_TEMP not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_OPERATION_LIST not in state.attributes + assert ATTR_MIN_TEMP not in state.attributes + assert ATTR_MAX_TEMP not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/weather/test_recorder.py b/tests/components/weather/test_recorder.py index 4f87371042..04ae04a044 100644 --- a/tests/components/weather/test_recorder.py +++ b/tests/components/weather/test_recorder.py @@ -4,10 +4,9 @@ from __future__ import annotations from datetime import timedelta from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.weather import ATTR_FORECAST, DOMAIN -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM @@ -18,6 +17,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test weather attributes to be excluded.""" + now = dt_util.utcnow() await async_setup_component(hass, DOMAIN, {DOMAIN: {"platform": "demo"}}) hass.config.units = METRIC_SYSTEM await hass.async_block_till_done() @@ -30,20 +30,8 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) > 1 - for state in states: - assert ATTR_FORECAST not in state.attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_FORECAST not in state.attributes From 84327f203cecee4f6a01770d0bc8a64db29078d0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Mar 2023 16:52:17 -1000 Subject: [PATCH 0845/2810] Fix flux_led set time to not happen during DST switch (#89559) * Fix flux_led set time test If this test was run at the wrong time of the day it would not have been long enough for the set time to fire since it only happens at 2:40:30 in the morning local time * Revert "Fix flux_led set time test" This reverts commit 3241912eff951c8cd5fe45dbff381f0ca6b8926c. * Change time set to not be during DST switch --- homeassistant/components/flux_led/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/flux_led/__init__.py b/homeassistant/components/flux_led/__init__.py index 86b73c762f..e6f89536ba 100644 --- a/homeassistant/components/flux_led/__init__.py +++ b/homeassistant/components/flux_led/__init__.py @@ -209,7 +209,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await device.async_set_time() await _async_sync_time() # set at startup - entry.async_on_unload(async_track_time_change(hass, _async_sync_time, 2, 40, 30)) + entry.async_on_unload(async_track_time_change(hass, _async_sync_time, 3, 40, 30)) # There must not be any awaits between here and the return # to avoid a race condition where the add_update_listener is not From edb06c58fa5bb523d877135cdcf33f1d78713fb9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Mar 2023 18:04:19 -1000 Subject: [PATCH 0846/2810] Add some more typing to screenlogic (#88522) --- .../components/screenlogic/__init__.py | 29 ++++++------ .../components/screenlogic/entity.py | 45 ++++++++++++------- 2 files changed, 45 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/screenlogic/__init__.py b/homeassistant/components/screenlogic/__init__.py index ad2f9c64f3..a0accef079 100644 --- a/homeassistant/components/screenlogic/__init__.py +++ b/homeassistant/components/screenlogic/__init__.py @@ -90,23 +90,23 @@ async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None await hass.config_entries.async_reload(entry.entry_id) -async def async_get_connect_info(hass: HomeAssistant, entry: ConfigEntry): +async def async_get_connect_info( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, str | int]: """Construct connect_info from configuration entry and returns it to caller.""" mac = entry.unique_id # Attempt to rediscover gateway to follow IP changes discovered_gateways = await async_discover_gateways_by_unique_id(hass) if mac in discovered_gateways: - connect_info = discovered_gateways[mac] - else: - _LOGGER.warning("Gateway rediscovery failed") - # Static connection defined or fallback from discovery - connect_info = { - SL_GATEWAY_NAME: name_for_mac(mac), - SL_GATEWAY_IP: entry.data[CONF_IP_ADDRESS], - SL_GATEWAY_PORT: entry.data[CONF_PORT], - } + return discovered_gateways[mac] - return connect_info + _LOGGER.warning("Gateway rediscovery failed") + # Static connection defined or fallback from discovery + return { + SL_GATEWAY_NAME: name_for_mac(mac), + SL_GATEWAY_IP: entry.data[CONF_IP_ADDRESS], + SL_GATEWAY_PORT: entry.data[CONF_PORT], + } class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator): @@ -143,7 +143,7 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator): """Return the gateway data.""" return self.gateway.get_data() - async def _async_update_configured_data(self): + async def _async_update_configured_data(self) -> None: """Update data sets based on equipment config.""" equipment_flags = self.gateway.get_data()[SL_DATA.KEY_CONFIG]["equipment_flags"] if not self.gateway.is_client: @@ -155,7 +155,7 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator): if equipment_flags & EQUIPMENT.FLAG_CHLORINATOR: await self.gateway.async_get_scg() - async def _async_update_data(self): + async def _async_update_data(self) -> None: """Fetch data from the Screenlogic gateway.""" try: await self._async_update_configured_data() @@ -165,8 +165,9 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator): return None - async def _async_reconnect_update_data(self): + async def _async_reconnect_update_data(self) -> None: """Attempt to reconnect to the gateway and fetch data.""" + assert self.config_entry is not None try: # Clean up the previous connection as we're about to create a new one await self.gateway.async_disconnect() diff --git a/homeassistant/components/screenlogic/entity.py b/homeassistant/components/screenlogic/entity.py index 80b4df4d2d..4ea23395c5 100644 --- a/homeassistant/components/screenlogic/entity.py +++ b/homeassistant/components/screenlogic/entity.py @@ -1,9 +1,10 @@ """Base ScreenLogicEntity definitions.""" +from datetime import datetime import logging from typing import Any -# from screenlogicpy import ScreenLogicError, ScreenLogicGateway -from screenlogicpy.const import DATA as SL_DATA, EQUIPMENT, ON_OFF +from screenlogicpy import ScreenLogicGateway +from screenlogicpy.const import CODE, DATA as SL_DATA, EQUIPMENT, ON_OFF from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError @@ -19,7 +20,12 @@ _LOGGER = logging.getLogger(__name__) class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): """Base class for all ScreenLogic entities.""" - def __init__(self, coordinator, data_key, enabled=True): + def __init__( + self, + coordinator: ScreenlogicDataUpdateCoordinator, + data_key: str, + enabled: bool = True, + ) -> None: """Initialize of the entity.""" super().__init__(coordinator) self._data_key = data_key @@ -34,8 +40,10 @@ class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): ] except KeyError: equipment_model = f"Unknown Model C:{controller_type} H:{hardware_type}" + mac = self.mac + assert mac is not None self._attr_device_info = DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, self.mac)}, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, manufacturer="Pentair", model=equipment_model, name=self.gateway_name, @@ -43,17 +51,18 @@ class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): ) @property - def mac(self): + def mac(self) -> str | None: """Mac address.""" + assert self.coordinator.config_entry is not None return self.coordinator.config_entry.unique_id @property - def config_data(self): + def config_data(self) -> dict[str | int, Any]: """Shortcut for config data.""" return self.gateway_data[SL_DATA.KEY_CONFIG] @property - def gateway(self): + def gateway(self) -> ScreenLogicGateway: """Return the gateway.""" return self.coordinator.gateway @@ -63,18 +72,18 @@ class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): return self.gateway.get_data() @property - def gateway_name(self): + def gateway_name(self) -> str: """Return the configured name of the gateway.""" return self.gateway.name - async def _async_refresh(self): + async def _async_refresh(self) -> None: """Refresh the data from the gateway.""" await self.coordinator.async_refresh() # Second debounced refresh to catch any secondary # changes in the device await self.coordinator.async_request_refresh() - async def _async_refresh_timed(self, now): + async def _async_refresh_timed(self, now: datetime) -> None: """Refresh from a timed called.""" await self.coordinator.async_request_refresh() @@ -82,7 +91,13 @@ class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): class ScreenLogicPushEntity(ScreenlogicEntity): """Base class for all ScreenLogic push entities.""" - def __init__(self, coordinator, data_key, message_code, enabled=True): + def __init__( + self, + coordinator: ScreenlogicDataUpdateCoordinator, + data_key: str, + message_code: CODE, + enabled: bool = True, + ) -> None: """Initialize the entity.""" super().__init__(coordinator, data_key, enabled) self._update_message_code = message_code @@ -108,7 +123,7 @@ class ScreenLogicCircuitEntity(ScreenLogicPushEntity): _attr_has_entity_name = True @property - def name(self): + def name(self) -> str: """Get the name of the switch.""" return self.circuit["name"] @@ -117,15 +132,15 @@ class ScreenLogicCircuitEntity(ScreenLogicPushEntity): """Get whether the switch is in on state.""" return self.circuit["value"] == ON_OFF.ON - async def async_turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Send the ON command.""" await self._async_set_circuit(ON_OFF.ON) - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Send the OFF command.""" await self._async_set_circuit(ON_OFF.OFF) - async def _async_set_circuit(self, circuit_value) -> None: + async def _async_set_circuit(self, circuit_value: int) -> None: if not await self.gateway.async_set_circuit(self._data_key, circuit_value): raise HomeAssistantError( f"Failed to set_circuit {self._data_key} {circuit_value}" From 234610b1cc918a5a0c562cd07167459677ac9b18 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 12 Mar 2023 14:47:43 +0100 Subject: [PATCH 0847/2810] Simplify command_line sensor tests (#89576) --- tests/components/command_line/test_sensor.py | 27 ++++++++------------ 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index 347c6a7ffd..f7de3b3394 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -7,26 +7,19 @@ from unittest.mock import patch import pytest from homeassistant import setup -from homeassistant.components.sensor import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er async def setup_test_entities(hass: HomeAssistant, config_dict: dict[str, Any]) -> None: """Set up a test command line sensor entity.""" + hass.states.async_set("sensor.input_sensor", "sensor_value") assert await setup.async_setup_component( hass, - DOMAIN, + SENSOR_DOMAIN, { - DOMAIN: [ - { - "platform": "template", - "sensors": { - "template_sensor": { - "value_template": "template_value", - } - }, - }, + SENSOR_DOMAIN: [ {"platform": "command_line", "name": "Test", **config_dict}, ] }, @@ -71,12 +64,12 @@ async def test_template_render(hass: HomeAssistant) -> None: await setup_test_entities( hass, { - "command": "echo {{ states.sensor.template_sensor.state }}", + "command": "echo {{ states.sensor.input_sensor.state }}", }, ) entity_state = hass.states.get("sensor.test") assert entity_state - assert entity_state.state == "template_value" + assert entity_state.state == "sensor_value" async def test_template_render_with_quote(hass: HomeAssistant) -> None: @@ -89,12 +82,12 @@ async def test_template_render_with_quote(hass: HomeAssistant) -> None: await setup_test_entities( hass, { - "command": 'echo "{{ states.sensor.template_sensor.state }}" "3 4"', + "command": 'echo "{{ states.sensor.input_sensor.state }}" "3 4"', }, ) check_output.assert_called_once_with( - 'echo "template_value" "3 4"', + 'echo "sensor_value" "3 4"', shell=True, # nosec # shell by design timeout=15, close_fds=False, @@ -266,9 +259,9 @@ async def test_unique_id( """Test unique_id option and if it only creates one sensor per id.""" assert await setup.async_setup_component( hass, - DOMAIN, + SENSOR_DOMAIN, { - DOMAIN: [ + SENSOR_DOMAIN: [ { "platform": "command_line", "unique_id": "unique", From 376a6eb82ad8c73cbbe664d3bc2a5c70a0bbfaa1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 12 Mar 2023 14:48:46 +0100 Subject: [PATCH 0848/2810] Convert device_sun_light_trigger test fixture to async (#89578) --- .../device_sun_light_trigger/test_init.py | 23 +++++++++---------- .../custom_components/test/device_tracker.py | 2 +- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/tests/components/device_sun_light_trigger/test_init.py b/tests/components/device_sun_light_trigger/test_init.py index 0c5a9ccab1..6b563f1cb5 100644 --- a/tests/components/device_sun_light_trigger/test_init.py +++ b/tests/components/device_sun_light_trigger/test_init.py @@ -28,9 +28,11 @@ from tests.common import async_fire_time_changed @pytest.fixture -def scanner(hass, enable_custom_integrations): +async def scanner(hass, enable_custom_integrations): """Initialize components.""" - scanner = getattr(hass.components, "test.device_tracker").get_scanner(None, None) + scanner = await getattr(hass.components, "test.device_tracker").async_get_scanner( + None, None + ) scanner.reset() scanner.come_home("DEV1") @@ -56,19 +58,16 @@ def scanner(hass, enable_custom_integrations): }, }, ): - assert hass.loop.run_until_complete( - async_setup_component( - hass, - device_tracker.DOMAIN, - {device_tracker.DOMAIN: {CONF_PLATFORM: "test"}}, - ) + assert await async_setup_component( + hass, + device_tracker.DOMAIN, + {device_tracker.DOMAIN: {CONF_PLATFORM: "test"}}, ) - assert hass.loop.run_until_complete( - async_setup_component( - hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} - ) + assert await async_setup_component( + hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} ) + await hass.async_block_till_done() return scanner diff --git a/tests/testing_config/custom_components/test/device_tracker.py b/tests/testing_config/custom_components/test/device_tracker.py index d5f34f48ec..31294a48e3 100644 --- a/tests/testing_config/custom_components/test/device_tracker.py +++ b/tests/testing_config/custom_components/test/device_tracker.py @@ -5,7 +5,7 @@ from homeassistant.components.device_tracker.config_entry import ScannerEntity from homeassistant.components.device_tracker.const import SOURCE_TYPE_ROUTER -def get_scanner(hass, config): +async def async_get_scanner(hass, config): """Return a mock scanner.""" return SCANNER From cf7e500a8eee524ddafd553bfdb174a1c408a3a2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 12 Mar 2023 15:55:04 +0100 Subject: [PATCH 0849/2810] Support translating entity names (#88242) --- .github/workflows/ci.yaml | 8 ++++++++ homeassistant/components/demo/sensor.py | 9 ++++++--- homeassistant/components/demo/strings.json | 1 + homeassistant/helpers/entity.py | 9 +++++++++ homeassistant/helpers/entity_platform.py | 11 +++++++++++ script/hassfest/translations.py | 1 + tests/components/rest/test_binary_sensor.py | 1 + tests/components/rest/test_sensor.py | 1 + tests/components/sensor/test_recorder.py | 2 ++ 9 files changed, 40 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b65039c42b..8697255888 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1003,6 +1003,10 @@ jobs: run: | . venv/bin/activate pip install mysqlclient sqlalchemy_utils + - name: Compile English translations + run: | + . venv/bin/activate + python3 -m script.translations develop --all - name: Run pytest (partially) timeout-minutes: 20 shell: bash @@ -1107,6 +1111,10 @@ jobs: run: | . venv/bin/activate pip install psycopg2 sqlalchemy_utils + - name: Compile English translations + run: | + . venv/bin/activate + python3 -m script.translations develop --all - name: Run pytest (partially) timeout-minutes: 20 shell: bash diff --git a/homeassistant/components/demo/sensor.py b/homeassistant/components/demo/sensor.py index 67a7b346a3..84758f0c29 100644 --- a/homeassistant/components/demo/sensor.py +++ b/homeassistant/components/demo/sensor.py @@ -126,7 +126,7 @@ async def async_setup_platform( ), DemoSensor( unique_id="sensor_10", - name="Thermostat mode", + name=None, state="eco", device_class=SensorDeviceClass.ENUM, state_class=None, @@ -156,7 +156,7 @@ class DemoSensor(SensorEntity): def __init__( self, unique_id: str, - name: str, + name: str | None, state: StateType, device_class: SensorDeviceClass, state_class: SensorStateClass | None, @@ -167,7 +167,10 @@ class DemoSensor(SensorEntity): ) -> None: """Initialize the sensor.""" self._attr_device_class = device_class - self._attr_name = name + if name is not None: + self._attr_name = name + else: + self._attr_has_entity_name = True self._attr_native_unit_of_measurement = unit_of_measurement self._attr_native_value = state self._attr_state_class = state_class diff --git a/homeassistant/components/demo/strings.json b/homeassistant/components/demo/strings.json index cdbe8dc1bd..add04c236e 100644 --- a/homeassistant/components/demo/strings.json +++ b/homeassistant/components/demo/strings.json @@ -98,6 +98,7 @@ }, "sensor": { "thermostat_mode": { + "name": "Thermostat mode", "state": { "away": "Away", "comfort": "Comfort", diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 9c1bbe5b20..63b70aa13d 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -319,6 +319,15 @@ class Entity(ABC): """Return the name of the entity.""" if hasattr(self, "_attr_name"): return self._attr_name + if self.translation_key is not None and self.has_entity_name: + assert self.platform + name_translation_key = ( + f"component.{self.platform.platform_name}.entity.{self.platform.domain}" + f".{self.translation_key}.name" + ) + if name_translation_key in self.platform.entity_translations: + name: str = self.platform.entity_translations[name_translation_key] + return name if hasattr(self, "entity_description"): return self.entity_description.name return None diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index e085f819e3..6687af6a27 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -39,6 +39,7 @@ from . import ( device_registry as dev_reg, entity_registry as ent_reg, service, + translation, ) from .device_registry import DeviceRegistry from .entity_registry import EntityRegistry, RegistryEntryDisabler, RegistryEntryHider @@ -124,6 +125,7 @@ class EntityPlatform: self.entity_namespace = entity_namespace self.config_entry: config_entries.ConfigEntry | None = None self.entities: dict[str, Entity] = {} + self.entity_translations: dict[str, Any] = {} self._tasks: list[asyncio.Task[None]] = [] # Stop tracking tasks after setup is completed self._setup_complete = False @@ -276,6 +278,15 @@ class EntityPlatform: hass = self.hass full_name = f"{self.domain}.{self.platform_name}" + try: + self.entity_translations = await translation.async_get_translations( + hass, hass.config.language, "entity", {self.platform_name} + ) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.debug( + "Could not load translations for %s", self.platform_name, exc_info=err + ) + logger.info("Setting up %s", full_name) warn_task = hass.loop.call_later( SLOW_SETUP_WARNING, diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 5233911111..92a1047c30 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -286,6 +286,7 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: vol.Optional("entity"): { str: { str: { + vol.Optional("name"): cv.string_with_no_html, vol.Optional("state_attributes"): { str: { vol.Optional("name"): cv.string_with_no_html, diff --git a/tests/components/rest/test_binary_sensor.py b/tests/components/rest/test_binary_sensor.py index 757c331529..99d378983b 100644 --- a/tests/components/rest/test_binary_sensor.py +++ b/tests/components/rest/test_binary_sensor.py @@ -214,6 +214,7 @@ async def test_setup_get_template_headers_params(hass: HomeAssistant) -> None: }, ) await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() assert respx.calls.last.request.headers["Accept"] == CONTENT_TYPE_JSON assert respx.calls.last.request.headers["User-Agent"] == "Mozilla/5.0" diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index 5dcaed6985..46a972628e 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -318,6 +318,7 @@ async def test_setup_get_templated_headers_params(hass: HomeAssistant) -> None: }, ) await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() assert respx.calls.last.request.headers["Accept"] == CONTENT_TYPE_JSON assert respx.calls.last.request.headers["User-Agent"] == "Mozilla/5.0" diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index add7bc5e01..55eda6c03b 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -4748,5 +4748,7 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) states: list[State] = await hass.async_add_executor_job(_fetch_states) assert len(states) > 1 for state in states: + if state.domain != DOMAIN: + continue assert ATTR_OPTIONS not in state.attributes assert ATTR_FRIENDLY_NAME in state.attributes From e9321397210c853345f1c8c68c9119c9261fface Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 12 Mar 2023 17:10:00 +0100 Subject: [PATCH 0850/2810] Strict typing threshold (#82786) --- .strict-typing | 1 + .../components/threshold/binary_sensor.py | 92 +++++++++++-------- .../components/threshold/config_flow.py | 3 +- mypy.ini | 10 ++ .../threshold/test_binary_sensor.py | 18 ++++ 5 files changed, 86 insertions(+), 38 deletions(-) diff --git a/.strict-typing b/.strict-typing index b33eab5bd6..9db9500892 100644 --- a/.strict-typing +++ b/.strict-typing @@ -297,6 +297,7 @@ homeassistant.components.tag.* homeassistant.components.tailscale.* homeassistant.components.tautulli.* homeassistant.components.tcp.* +homeassistant.components.threshold.* homeassistant.components.tibber.* homeassistant.components.tile.* homeassistant.components.tilt_ble.* diff --git a/homeassistant/components/threshold/binary_sensor.py b/homeassistant/components/threshold/binary_sensor.py index 8cec85bf20..0badf7eb41 100644 --- a/homeassistant/components/threshold/binary_sensor.py +++ b/homeassistant/components/threshold/binary_sensor.py @@ -2,12 +2,14 @@ from __future__ import annotations import logging +from typing import Any import voluptuous as vol from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, + BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry @@ -19,7 +21,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event @@ -93,12 +95,15 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Threshold sensor.""" - entity_id = config.get(CONF_ENTITY_ID) - name = config.get(CONF_NAME) - lower = config.get(CONF_LOWER) - upper = config.get(CONF_UPPER) - hysteresis = config.get(CONF_HYSTERESIS) - device_class = config.get(CONF_DEVICE_CLASS) + entity_id: str = config[CONF_ENTITY_ID] + name: str = config[CONF_NAME] + lower: float | None = config.get(CONF_LOWER) + upper: float | None = config.get(CONF_UPPER) + hysteresis: float = config[CONF_HYSTERESIS] + device_class: BinarySensorDeviceClass | None = config.get(CONF_DEVICE_CLASS) + + if lower is None and upper is None: + raise ValueError("Lower or Upper thresholds not provided") async_add_entities( [ @@ -115,22 +120,29 @@ class ThresholdSensor(BinarySensorEntity): _attr_should_poll = False def __init__( - self, hass, entity_id, name, lower, upper, hysteresis, device_class, unique_id - ): + self, + hass: HomeAssistant, + entity_id: str, + name: str, + lower: float | None, + upper: float | None, + hysteresis: float, + device_class: BinarySensorDeviceClass | None, + unique_id: str | None, + ) -> None: """Initialize the Threshold sensor.""" self._attr_unique_id = unique_id self._entity_id = entity_id self._name = name self._threshold_lower = lower self._threshold_upper = upper - self._hysteresis = hysteresis + self._hysteresis: float = hysteresis self._device_class = device_class - self._state_position = POSITION_UNKNOWN - self._state = None - self.sensor_value = None + self._state: bool | None = None + self.sensor_value: float | None = None - def _update_sensor_state(): + def _update_sensor_state() -> None: """Handle sensor state changes.""" if (new_state := hass.states.get(self._entity_id)) is None: return @@ -148,7 +160,7 @@ class ThresholdSensor(BinarySensorEntity): self._update_state() @callback - def async_threshold_sensor_state_listener(event): + def async_threshold_sensor_state_listener(event: Event) -> None: """Handle sensor state changes.""" _update_sensor_state() self.async_write_ha_state() @@ -161,32 +173,31 @@ class ThresholdSensor(BinarySensorEntity): _update_sensor_state() @property - def name(self): + def name(self) -> str: """Return the name of the sensor.""" return self._name @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if sensor is on.""" return self._state @property - def device_class(self): + def device_class(self) -> BinarySensorDeviceClass | None: """Return the sensor class of the sensor.""" return self._device_class @property - def threshold_type(self): + def threshold_type(self) -> str: """Return the type of threshold this sensor represents.""" if self._threshold_lower is not None and self._threshold_upper is not None: return TYPE_RANGE if self._threshold_lower is not None: return TYPE_LOWER - if self._threshold_upper is not None: - return TYPE_UPPER + return TYPE_UPPER @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the sensor.""" return { ATTR_ENTITY_ID: self._entity_id, @@ -199,44 +210,51 @@ class ThresholdSensor(BinarySensorEntity): } @callback - def _update_state(self): + def _update_state(self) -> None: """Update the state.""" - def below(threshold): + def below(sensor_value: float, threshold: float) -> bool: """Determine if the sensor value is below a threshold.""" - return self.sensor_value < (threshold - self._hysteresis) + return sensor_value < (threshold - self._hysteresis) - def above(threshold): + def above(sensor_value: float, threshold: float) -> bool: """Determine if the sensor value is above a threshold.""" - return self.sensor_value > (threshold + self._hysteresis) + return sensor_value > (threshold + self._hysteresis) if self.sensor_value is None: self._state_position = POSITION_UNKNOWN self._state = False + return - elif self.threshold_type == TYPE_LOWER: - if below(self._threshold_lower): + if self.threshold_type == TYPE_LOWER and self._threshold_lower is not None: + if below(self.sensor_value, self._threshold_lower): self._state_position = POSITION_BELOW self._state = True - elif above(self._threshold_lower): + elif above(self.sensor_value, self._threshold_lower): self._state_position = POSITION_ABOVE self._state = False - elif self.threshold_type == TYPE_UPPER: - if above(self._threshold_upper): + if self.threshold_type == TYPE_UPPER and self._threshold_upper is not None: + if above(self.sensor_value, self._threshold_upper): self._state_position = POSITION_ABOVE self._state = True - elif below(self._threshold_upper): + elif below(self.sensor_value, self._threshold_upper): self._state_position = POSITION_BELOW self._state = False - elif self.threshold_type == TYPE_RANGE: - if below(self._threshold_lower): + if ( + self.threshold_type == TYPE_RANGE + and self._threshold_lower is not None + and self._threshold_upper is not None + ): + if below(self.sensor_value, self._threshold_lower): self._state_position = POSITION_BELOW self._state = False - if above(self._threshold_upper): + if above(self.sensor_value, self._threshold_upper): self._state_position = POSITION_ABOVE self._state = False - elif above(self._threshold_lower) and below(self._threshold_upper): + elif above(self.sensor_value, self._threshold_lower) and below( + self.sensor_value, self._threshold_upper + ): self._state_position = POSITION_IN_RANGE self._state = True diff --git a/homeassistant/components/threshold/config_flow.py b/homeassistant/components/threshold/config_flow.py index fbb1287230..31d51fee3f 100644 --- a/homeassistant/components/threshold/config_flow.py +++ b/homeassistant/components/threshold/config_flow.py @@ -76,4 +76,5 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): def async_config_entry_title(self, options: Mapping[str, Any]) -> str: """Return config entry title.""" - return options[CONF_NAME] + name: str = options[CONF_NAME] + return name diff --git a/mypy.ini b/mypy.ini index 6d32c16b96..760c7f6811 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2733,6 +2733,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.threshold.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.tibber.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/threshold/test_binary_sensor.py b/tests/components/threshold/test_binary_sensor.py index f009e4c48a..eed3a8a40e 100644 --- a/tests/components/threshold/test_binary_sensor.py +++ b/tests/components/threshold/test_binary_sensor.py @@ -1,4 +1,5 @@ """The test for the threshold sensor platform.""" + import pytest from homeassistant.const import ( @@ -567,3 +568,20 @@ async def test_sensor_upper_zero_threshold(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.state == "on" + + +async def test_sensor_no_lower_upper( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test if no lower or upper has been provided.""" + config = { + "binary_sensor": { + "platform": "threshold", + "entity_id": "sensor.test_monitored", + } + } + + await async_setup_component(hass, "binary_sensor", config) + await hass.async_block_till_done() + + assert "Lower or Upper thresholds not provided" in caplog.text From b4b7605b82892a43fc5d24bf34bc9364d57e397b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 12 Mar 2023 17:38:26 +0100 Subject: [PATCH 0851/2810] Improve screenlogic generic typing (#89587) --- homeassistant/components/screenlogic/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/screenlogic/__init__.py b/homeassistant/components/screenlogic/__init__.py index a0accef079..5838031dc6 100644 --- a/homeassistant/components/screenlogic/__init__.py +++ b/homeassistant/components/screenlogic/__init__.py @@ -109,7 +109,7 @@ async def async_get_connect_info( } -class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator): +class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): """Class to manage the data update for the Screenlogic component.""" def __init__( From 73cd62bd32055330637296c0b3991280a9596655 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Mar 2023 06:39:07 -1000 Subject: [PATCH 0852/2810] Fix lingering tasks in google_wifi tests (#89571) --- tests/components/google_wifi/test_sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/components/google_wifi/test_sensor.py b/tests/components/google_wifi/test_sensor.py index ab5b703fd7..868631e9c2 100644 --- a/tests/components/google_wifi/test_sensor.py +++ b/tests/components/google_wifi/test_sensor.py @@ -44,6 +44,7 @@ async def test_setup_minimum( "sensor", {"sensor": {"platform": "google_wifi", "monitored_conditions": ["uptime"]}}, ) + await hass.async_block_till_done() assert_setup_component(1, "sensor") @@ -72,6 +73,7 @@ async def test_setup_get( } }, ) + await hass.async_block_till_done() assert_setup_component(6, "sensor") From 8d88b02c2eaa97906efd1154b956e2702429eb5f Mon Sep 17 00:00:00 2001 From: Jan Stienstra <65826735+j-stienstra@users.noreply.github.com> Date: Sun, 12 Mar 2023 19:31:10 +0100 Subject: [PATCH 0853/2810] Recode Home Assistant instance name to ascii for Jellyfin (#87368) Recode instance name to ascii --- homeassistant/components/jellyfin/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/jellyfin/__init__.py b/homeassistant/components/jellyfin/__init__.py index 565c106f6a..4ee9702072 100644 --- a/homeassistant/components/jellyfin/__init__.py +++ b/homeassistant/components/jellyfin/__init__.py @@ -20,10 +20,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry_data[CONF_CLIENT_DEVICE_ID] = entry.entry_id hass.config_entries.async_update_entry(entry, data=entry_data) - client = create_client( - device_id=entry.data[CONF_CLIENT_DEVICE_ID], - device_name=hass.config.location_name, - ) + device_id = entry.data[CONF_CLIENT_DEVICE_ID] + device_name = ascii(hass.config.location_name) + + client = create_client(device_id=device_id, device_name=device_name) try: user_id, connect_result = await validate_input(hass, dict(entry.data), client) From c41f91be896978f2d18d2c2b4d039dc41457e9a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Mar 2023 10:01:58 -1000 Subject: [PATCH 0854/2810] Deduplicate entity_id in the states table (#89557) --- homeassistant/components/logbook/processor.py | 31 +- .../components/logbook/queries/__init__.py | 10 +- .../components/logbook/queries/common.py | 11 +- .../components/logbook/queries/devices.py | 2 + .../components/logbook/queries/entities.py | 32 +- .../logbook/queries/entities_and_devices.py | 20 +- homeassistant/components/recorder/core.py | 93 +- .../components/recorder/db_schema.py | 33 +- homeassistant/components/recorder/filters.py | 23 +- .../components/recorder/history/__init__.py | 173 ++- .../components/recorder/history/modern.py | 783 +++++++++++++ .../components/recorder/migration.py | 101 +- .../components/recorder/models/state.py | 4 +- .../recorder/models/state_attributes.py | 8 +- homeassistant/components/recorder/purge.py | 61 +- homeassistant/components/recorder/queries.py | 96 ++ .../recorder/table_managers/states_meta.py | 94 ++ homeassistant/components/recorder/tasks.py | 36 + .../history/test_init_db_schema_30.py | 1036 +++++++++-------- .../db_schema_23_with_newer_columns.py | 27 + tests/components/recorder/db_schema_28.py | 35 +- tests/components/recorder/db_schema_30.py | 34 + .../test_filters_with_entityfilter.py | 6 +- ...est_filters_with_entityfilter_schema_37.py | 670 +++++++++++ tests/components/recorder/test_history.py | 75 +- .../recorder/test_history_db_schema_30.py | 656 ++++++----- tests/components/recorder/test_init.py | 127 +- tests/components/recorder/test_migrate.py | 132 ++- tests/components/recorder/test_purge.py | 152 ++- tests/components/recorder/test_util.py | 13 +- .../components/recorder/test_v32_migration.py | 102 +- tests/components/sensor/test_recorder.py | 13 +- tests/conftest.py | 44 + 33 files changed, 3715 insertions(+), 1018 deletions(-) create mode 100644 homeassistant/components/recorder/history/modern.py create mode 100644 homeassistant/components/recorder/table_managers/states_meta.py create mode 100644 tests/components/recorder/test_filters_with_entityfilter_schema_37.py diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index f816064ba6..aa0bc74958 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -10,6 +10,7 @@ from typing import Any from sqlalchemy.engine import Result from sqlalchemy.engine.row import Row +from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.filters import Filters from homeassistant.components.recorder.models import ( bytes_to_uuid_hex_or_none, @@ -149,16 +150,28 @@ class EventProcessor: # return result.yield_per(1024) - stmt = statement_for_request( - start_day, - end_day, - self.event_types, - self.entity_ids, - self.device_ids, - self.filters, - self.context_id, - ) with session_scope(hass=self.hass) as session: + metadata_ids: list[int] | None = None + if self.entity_ids: + instance = get_instance(self.hass) + entity_id_to_metadata_id = instance.states_meta_manager.get_many( + self.entity_ids, session + ) + metadata_ids = [ + metadata_id + for metadata_id in entity_id_to_metadata_id.values() + if metadata_id is not None + ] + stmt = statement_for_request( + start_day, + end_day, + self.event_types, + self.entity_ids, + metadata_ids, + self.device_ids, + self.filters, + self.context_id, + ) return self.humanify(yield_rows(session.execute(stmt))) def humanify( diff --git a/homeassistant/components/logbook/queries/__init__.py b/homeassistant/components/logbook/queries/__init__.py index b88fd4842c..cfef16bf77 100644 --- a/homeassistant/components/logbook/queries/__init__.py +++ b/homeassistant/components/logbook/queries/__init__.py @@ -1,6 +1,7 @@ """Queries for logbook.""" from __future__ import annotations +from collections.abc import Collection from datetime import datetime as dt from sqlalchemy.sql.lambdas import StatementLambdaElement @@ -21,6 +22,7 @@ def statement_for_request( end_day_dt: dt, event_types: tuple[str, ...], entity_ids: list[str] | None = None, + states_metadata_ids: Collection[int] | None = None, device_ids: list[str] | None = None, filters: Filters | None = None, context_id: str | None = None, @@ -32,7 +34,9 @@ def statement_for_request( # No entities: logbook sends everything for the timeframe # limited by the context_id and the yaml configured filter if not entity_ids and not device_ids: - states_entity_filter = filters.states_entity_filter() if filters else None + states_entity_filter = ( + filters.states_metadata_entity_filter() if filters else None + ) events_entity_filter = filters.events_entity_filter() if filters else None return all_stmt( start_day, @@ -56,7 +60,7 @@ def statement_for_request( start_day, end_day, event_types, - entity_ids, + states_metadata_ids or [], json_quoted_entity_ids, json_quoted_device_ids, ) @@ -68,7 +72,7 @@ def statement_for_request( start_day, end_day, event_types, - entity_ids, + states_metadata_ids or [], json_quoted_entity_ids, ) diff --git a/homeassistant/components/logbook/queries/common.py b/homeassistant/components/logbook/queries/common.py index 8645c8f68c..c63bb30eb6 100644 --- a/homeassistant/components/logbook/queries/common.py +++ b/homeassistant/components/logbook/queries/common.py @@ -20,6 +20,7 @@ from homeassistant.components.recorder.db_schema import ( EventTypes, StateAttributes, States, + StatesMeta, ) from homeassistant.components.recorder.filters import like_domain_matchers from homeassistant.components.recorder.queries import select_event_type_ids @@ -57,7 +58,7 @@ EVENT_COLUMNS = ( STATE_COLUMNS = ( States.state_id.label("state_id"), States.state.label("state"), - States.entity_id.label("entity_id"), + StatesMeta.entity_id.label("entity_id"), SHARED_ATTRS_JSON["icon"].as_string().label("icon"), OLD_FORMAT_ATTRS_JSON["icon"].as_string().label("old_format_icon"), ) @@ -65,7 +66,7 @@ STATE_COLUMNS = ( STATE_CONTEXT_ONLY_COLUMNS = ( States.state_id.label("state_id"), States.state.label("state"), - States.entity_id.label("entity_id"), + StatesMeta.entity_id.label("entity_id"), literal(value=None, type_=sqlalchemy.String).label("icon"), literal(value=None, type_=sqlalchemy.String).label("old_format_icon"), ) @@ -186,6 +187,7 @@ def legacy_select_events_context_id( .outerjoin( StateAttributes, (States.attributes_id == StateAttributes.attributes_id) ) + .outerjoin(StatesMeta, (States.metadata_id == StatesMeta.metadata_id)) .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) .where((Events.time_fired_ts > start_day) & (Events.time_fired_ts < end_day)) .where(Events.context_id_bin == context_id_bin) @@ -213,6 +215,7 @@ def apply_states_filters(sel: Select, start_day: float, end_day: float) -> Selec .outerjoin( StateAttributes, (States.attributes_id == StateAttributes.attributes_id) ) + .outerjoin(StatesMeta, (States.metadata_id == StatesMeta.metadata_id)) ) @@ -249,7 +252,7 @@ def _not_possible_continuous_domain_matcher() -> ColumnElement[bool]: """ return sqlalchemy.and_( *[ - ~States.entity_id.like(entity_domain) + ~StatesMeta.entity_id.like(entity_domain) for entity_domain in ( *ALWAYS_CONTINUOUS_ENTITY_ID_LIKE, *CONDITIONALLY_CONTINUOUS_ENTITY_ID_LIKE, @@ -266,7 +269,7 @@ def _conditionally_continuous_domain_matcher() -> ColumnElement[bool]: """ return sqlalchemy.or_( *[ - States.entity_id.like(entity_domain) + StatesMeta.entity_id.like(entity_domain) for entity_domain in CONDITIONALLY_CONTINUOUS_ENTITY_ID_LIKE ], ).self_group() diff --git a/homeassistant/components/logbook/queries/devices.py b/homeassistant/components/logbook/queries/devices.py index 687c48b892..a5c06dc84c 100644 --- a/homeassistant/components/logbook/queries/devices.py +++ b/homeassistant/components/logbook/queries/devices.py @@ -15,6 +15,7 @@ from homeassistant.components.recorder.db_schema import ( Events, EventTypes, States, + StatesMeta, ) from .common import ( @@ -68,6 +69,7 @@ def _apply_devices_context_union( select_states_context_only() .select_from(devices_cte) .outerjoin(States, devices_cte.c.context_id_bin == States.context_id_bin) + .outerjoin(StatesMeta, (States.metadata_id == StatesMeta.metadata_id)) ), ) diff --git a/homeassistant/components/logbook/queries/entities.py b/homeassistant/components/logbook/queries/entities.py index e0ae32b669..ebb56befa5 100644 --- a/homeassistant/components/logbook/queries/entities.py +++ b/homeassistant/components/logbook/queries/entities.py @@ -1,7 +1,7 @@ """Entities queries for logbook.""" from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Collection, Iterable import sqlalchemy from sqlalchemy import lambda_stmt, select, union_all @@ -11,12 +11,13 @@ from sqlalchemy.sql.selectable import CTE, CompoundSelect, Select from homeassistant.components.recorder.db_schema import ( ENTITY_ID_IN_EVENT, - ENTITY_ID_LAST_UPDATED_INDEX_TS, + METADATA_ID_LAST_UPDATED_INDEX_TS, OLD_ENTITY_ID_IN_EVENT, EventData, Events, EventTypes, States, + StatesMeta, ) from .common import ( @@ -35,7 +36,7 @@ def _select_entities_context_ids_sub_query( start_day: float, end_day: float, event_types: tuple[str, ...], - entity_ids: list[str], + states_metadata_ids: Collection[int], json_quoted_entity_ids: list[str], ) -> Select: """Generate a subquery to find context ids for multiple entities.""" @@ -47,7 +48,7 @@ def _select_entities_context_ids_sub_query( .filter( (States.last_updated_ts > start_day) & (States.last_updated_ts < end_day) ) - .where(States.entity_id.in_(entity_ids)), + .where(States.metadata_id.in_(states_metadata_ids)), ).subquery() return select(union.c.context_id_bin).group_by(union.c.context_id_bin) @@ -57,7 +58,7 @@ def _apply_entities_context_union( start_day: float, end_day: float, event_types: tuple[str, ...], - entity_ids: list[str], + states_metadata_ids: Collection[int], json_quoted_entity_ids: list[str], ) -> CompoundSelect: """Generate a CTE to find the entity and device context ids and a query to find linked row.""" @@ -65,16 +66,16 @@ def _apply_entities_context_union( start_day, end_day, event_types, - entity_ids, + states_metadata_ids, json_quoted_entity_ids, ).cte() # We used to optimize this to exclude rows we already in the union with - # a States.entity_id.not_in(entity_ids) but that made the + # a StatesMeta.metadata_ids.not_in(states_metadata_ids) but that made the # query much slower on MySQL, and since we already filter them away # in the python code anyways since they will have context_only # set on them the impact is minimal. return sel.union_all( - states_select_for_entity_ids(start_day, end_day, entity_ids), + states_select_for_entity_ids(start_day, end_day, states_metadata_ids), apply_events_context_hints( select_events_context_only() .select_from(entities_cte) @@ -86,6 +87,7 @@ def _apply_entities_context_union( select_states_context_only() .select_from(entities_cte) .outerjoin(States, entities_cte.c.context_id_bin == States.context_id_bin) + .outerjoin(StatesMeta, (States.metadata_id == StatesMeta.metadata_id)) ), ) @@ -94,7 +96,7 @@ def entities_stmt( start_day: float, end_day: float, event_types: tuple[str, ...], - entity_ids: list[str], + states_metadata_ids: Collection[int], json_quoted_entity_ids: list[str], ) -> StatementLambdaElement: """Generate a logbook query for multiple entities.""" @@ -106,19 +108,19 @@ def entities_stmt( start_day, end_day, event_types, - entity_ids, + states_metadata_ids, json_quoted_entity_ids, ).order_by(Events.time_fired_ts) ) def states_select_for_entity_ids( - start_day: float, end_day: float, entity_ids: list[str] + start_day: float, end_day: float, states_metadata_ids: Collection[int] ) -> Select: """Generate a select for states from the States table for specific entities.""" return apply_states_filters( apply_entities_hints(select_states()), start_day, end_day - ).where(States.entity_id.in_(entity_ids)) + ).where(States.metadata_id.in_(states_metadata_ids)) def apply_event_entity_id_matchers( @@ -140,9 +142,11 @@ def apply_event_entity_id_matchers( def apply_entities_hints(sel: Select) -> Select: """Force mysql to use the right index on large selects.""" return sel.with_hint( - States, f"FORCE INDEX ({ENTITY_ID_LAST_UPDATED_INDEX_TS})", dialect_name="mysql" + States, + f"FORCE INDEX ({METADATA_ID_LAST_UPDATED_INDEX_TS})", + dialect_name="mysql", ).with_hint( States, - f"FORCE INDEX ({ENTITY_ID_LAST_UPDATED_INDEX_TS})", + f"FORCE INDEX ({METADATA_ID_LAST_UPDATED_INDEX_TS})", dialect_name="mariadb", ) diff --git a/homeassistant/components/logbook/queries/entities_and_devices.py b/homeassistant/components/logbook/queries/entities_and_devices.py index 677feddda8..f7ffde4f81 100644 --- a/homeassistant/components/logbook/queries/entities_and_devices.py +++ b/homeassistant/components/logbook/queries/entities_and_devices.py @@ -1,7 +1,7 @@ """Entities and Devices queries for logbook.""" from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Collection, Iterable from sqlalchemy import lambda_stmt, select, union_all from sqlalchemy.sql.elements import ColumnElement @@ -13,6 +13,7 @@ from homeassistant.components.recorder.db_schema import ( Events, EventTypes, States, + StatesMeta, ) from .common import ( @@ -35,7 +36,7 @@ def _select_entities_device_id_context_ids_sub_query( start_day: float, end_day: float, event_types: tuple[str, ...], - entity_ids: list[str], + states_metadata_ids: Collection[int], json_quoted_entity_ids: list[str], json_quoted_device_ids: list[str], ) -> Select: @@ -50,7 +51,7 @@ def _select_entities_device_id_context_ids_sub_query( .filter( (States.last_updated_ts > start_day) & (States.last_updated_ts < end_day) ) - .where(States.entity_id.in_(entity_ids)), + .where(States.metadata_id.in_(states_metadata_ids)), ).subquery() return select(union.c.context_id_bin).group_by(union.c.context_id_bin) @@ -60,7 +61,7 @@ def _apply_entities_devices_context_union( start_day: float, end_day: float, event_types: tuple[str, ...], - entity_ids: list[str], + states_metadata_ids: Collection[int], json_quoted_entity_ids: list[str], json_quoted_device_ids: list[str], ) -> CompoundSelect: @@ -68,17 +69,17 @@ def _apply_entities_devices_context_union( start_day, end_day, event_types, - entity_ids, + states_metadata_ids, json_quoted_entity_ids, json_quoted_device_ids, ).cte() # We used to optimize this to exclude rows we already in the union with - # a States.entity_id.not_in(entity_ids) but that made the + # a States.metadata_id.not_in(states_metadata_ids) but that made the # query much slower on MySQL, and since we already filter them away # in the python code anyways since they will have context_only # set on them the impact is minimal. return sel.union_all( - states_select_for_entity_ids(start_day, end_day, entity_ids), + states_select_for_entity_ids(start_day, end_day, states_metadata_ids), apply_events_context_hints( select_events_context_only() .select_from(devices_entities_cte) @@ -94,6 +95,7 @@ def _apply_entities_devices_context_union( .outerjoin( States, devices_entities_cte.c.context_id_bin == States.context_id_bin ) + .outerjoin(StatesMeta, (States.metadata_id == StatesMeta.metadata_id)) ), ) @@ -102,7 +104,7 @@ def entities_devices_stmt( start_day: float, end_day: float, event_types: tuple[str, ...], - entity_ids: list[str], + states_metadata_ids: Collection[int], json_quoted_entity_ids: list[str], json_quoted_device_ids: list[str], ) -> StatementLambdaElement: @@ -117,7 +119,7 @@ def entities_devices_stmt( start_day, end_day, event_types, - entity_ids, + states_metadata_ids, json_quoted_entity_ids, json_quoted_device_ids, ).order_by(Events.time_fired_ts) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 97d72c7f85..630efe1956 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -64,6 +64,7 @@ from .db_schema import ( EventTypes, StateAttributes, States, + StatesMeta, Statistics, StatisticsRuns, StatisticsShortTerm, @@ -82,10 +83,14 @@ from .queries import ( find_shared_data_id, get_shared_attributes, get_shared_event_datas, + has_entity_ids_to_migrate, has_event_type_to_migrate, + has_events_context_ids_to_migrate, + has_states_context_ids_to_migrate, ) from .run_history import RunHistory from .table_managers.event_types import EventTypeManager +from .table_managers.states_meta import StatesMetaManager from .tasks import ( AdjustLRUSizeTask, AdjustStatisticsTask, @@ -94,6 +99,7 @@ from .tasks import ( CommitTask, ContextIDMigrationTask, DatabaseLockTask, + EntityIDMigrationTask, EventTask, EventTypeIDMigrationTask, ImportStatisticsTask, @@ -215,6 +221,7 @@ class Recorder(threading.Thread): self._state_attributes_ids: LRU = LRU(STATE_ATTRIBUTES_ID_CACHE_SIZE) self._event_data_ids: LRU = LRU(EVENT_DATA_ID_CACHE_SIZE) self.event_type_manager = EventTypeManager() + self.states_meta_manager = StatesMetaManager() self._pending_state_attributes: dict[str, StateAttributes] = {} self._pending_event_data: dict[str, EventData] = {} self._pending_expunge: list[States] = [] @@ -652,7 +659,7 @@ class Recorder(threading.Thread): # If the migrate is live or the schema is valid, we need to # wait for startup to complete. If its not live, we need to continue # on. - self.hass.add_job(self.async_set_db_ready) + self._activate_and_set_db_ready() # We wait to start a live migration until startup has finished # since it can be cpu intensive and we do not want it to compete @@ -663,7 +670,7 @@ class Recorder(threading.Thread): # Make sure we cleanly close the run if # we restart before startup finishes self._shutdown() - self.hass.add_job(self.async_set_db_ready) + self._activate_and_set_db_ready() return if not schema_status.valid: @@ -681,11 +688,11 @@ class Recorder(threading.Thread): "Database Migration Failed", "recorder_database_migration", ) - self.hass.add_job(self.async_set_db_ready) + self._activate_and_set_db_ready() self._shutdown() return - self.hass.add_job(self.async_set_db_ready) + self._activate_and_set_db_ready() # Catch up with missed statistics with session_scope(session=self.get_session()) as session: @@ -694,26 +701,44 @@ class Recorder(threading.Thread): _LOGGER.debug("Recorder processing the queue") self._adjust_lru_size() self.hass.add_job(self._async_set_recorder_ready_migration_done) - self._activate_table_managers_or_migrate() self._run_event_loop() self._shutdown() - def _activate_table_managers_or_migrate(self) -> None: - """Activate the table managers or schedule migrations.""" - # Currently we always check if context ids need to be migrated - # since there are multiple tables. This could be optimized - # to check both the states and events table to see if there - # are any missing and avoid inserting the task but it currently - # is not needed since there is no dependent code branching - # on the result of the migration. - self.queue_task(ContextIDMigrationTask()) + def _activate_and_set_db_ready(self) -> None: + """Activate the table managers or schedule migrations and mark the db as ready.""" with session_scope(session=self.get_session()) as session: - if session.execute(has_event_type_to_migrate()).scalar(): + if ( + self.schema_version < 36 + or session.execute(has_events_context_ids_to_migrate()).scalar() + or session.execute(has_states_context_ids_to_migrate()).scalar() + ): + self.queue_task(ContextIDMigrationTask()) + + if ( + self.schema_version < 37 + or session.execute(has_event_type_to_migrate()).scalar() + ): self.queue_task(EventTypeIDMigrationTask()) else: - _LOGGER.debug("Activating event type manager as all data is migrated") + _LOGGER.debug("Activating event_types manager as all data is migrated") self.event_type_manager.active = True + if ( + self.schema_version < 38 + or session.execute(has_entity_ids_to_migrate()).scalar() + ): + self.queue_task(EntityIDMigrationTask()) + else: + _LOGGER.debug("Activating states_meta manager as all data is migrated") + self.states_meta_manager.active = True + + # We must only set the db ready after we have set the table managers + # to active if there is no data to migrate. + # + # This ensures that the history queries will use the new tables + # and not the old ones as soon as the API is available. + self.hass.add_job(self.async_set_db_ready) + def _run_event_loop(self) -> None: """Run the event loop for the recorder.""" # Use a session for the event read loop @@ -750,6 +775,7 @@ class Recorder(threading.Thread): self._pre_process_state_change_events(state_change_events) self._pre_process_non_state_change_events(non_state_change_events) self.event_type_manager.load(non_state_change_events, self.event_session) + self.states_meta_manager.load(state_change_events, self.event_session) def _pre_process_state_change_events(self, events: list[Event]) -> None: """Load startup state attributes from the database. @@ -1033,13 +1059,26 @@ class Recorder(threading.Thread): def _process_state_changed_event_into_session(self, event: Event) -> None: """Process a state_changed event into the session.""" - assert self.event_session is not None dbstate = States.from_event(event) - if not ( + if (entity_id := dbstate.entity_id) is None or not ( shared_attrs_bytes := self._serialize_state_attributes_from_event(event) ): return + assert self.event_session is not None + event_session = self.event_session + # Map the entity_id to the StatesMeta table + states_meta_manager = self.states_meta_manager + if pending_states_meta := states_meta_manager.get_pending(entity_id): + dbstate.states_meta_rel = pending_states_meta + elif metadata_id := states_meta_manager.get(entity_id, event_session): + dbstate.metadata_id = metadata_id + else: + states_meta = StatesMeta(entity_id=entity_id) + states_meta_manager.add_pending(states_meta) + event_session.add(states_meta) + dbstate.states_meta_rel = states_meta + shared_attrs = shared_attrs_bytes.decode("utf-8") dbstate.attributes = None # Matching attributes found in the pending commit @@ -1063,16 +1102,20 @@ class Recorder(threading.Thread): self._pending_state_attributes[shared_attrs] = dbstate_attributes self.event_session.add(dbstate_attributes) - if old_state := self._old_states.pop(dbstate.entity_id, None): + if old_state := self._old_states.pop(entity_id, None): if old_state.state_id: dbstate.old_state_id = old_state.state_id else: dbstate.old_state = old_state if event.data.get("new_state"): - self._old_states[dbstate.entity_id] = dbstate + self._old_states[entity_id] = dbstate self._pending_expunge.append(dbstate) else: dbstate.state = None + + if states_meta_manager.active: + dbstate.entity_id = None + self.event_session.add(dbstate) def _handle_database_error(self, err: Exception) -> bool: @@ -1138,6 +1181,7 @@ class Recorder(threading.Thread): self._event_data_ids[event_data.shared_data] = event_data.data_id self._pending_event_data = {} self.event_type_manager.post_commit_pending() + self.states_meta_manager.post_commit_pending() # Expire is an expensive operation (frequently more expensive # than the flush and commit itself) so we only @@ -1165,6 +1209,7 @@ class Recorder(threading.Thread): self._pending_state_attributes.clear() self._pending_event_data.clear() self.event_type_manager.reset() + self.states_meta_manager.reset() if not self.event_session: return @@ -1199,6 +1244,14 @@ class Recorder(threading.Thread): """Migrate event type ids if needed.""" return migration.migrate_event_type_ids(self) + def _migrate_entity_ids(self) -> bool: + """Migrate entity_ids if needed.""" + return migration.migrate_entity_ids(self) + + def _post_migrate_entity_ids(self) -> bool: + """Post migrate entity_ids if needed.""" + return migration.post_migrate_entity_ids(self) + def _send_keep_alive(self) -> None: """Send a keep alive to keep the db connection open.""" assert self.event_session is not None diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 9499e9d4e3..7aecf2a57c 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -68,7 +68,7 @@ class Base(DeclarativeBase): """Base class for tables.""" -SCHEMA_VERSION = 37 +SCHEMA_VERSION = 38 _LOGGER = logging.getLogger(__name__) @@ -77,6 +77,7 @@ TABLE_EVENT_DATA = "event_data" TABLE_EVENT_TYPES = "event_types" TABLE_STATES = "states" TABLE_STATE_ATTRIBUTES = "state_attributes" +TABLE_STATES_META = "states_meta" TABLE_RECORDER_RUNS = "recorder_runs" TABLE_SCHEMA_CHANGES = "schema_changes" TABLE_STATISTICS = "statistics" @@ -97,6 +98,7 @@ ALL_TABLES = [ TABLE_EVENT_TYPES, TABLE_RECORDER_RUNS, TABLE_SCHEMA_CHANGES, + TABLE_STATES_META, TABLE_STATISTICS, TABLE_STATISTICS_META, TABLE_STATISTICS_RUNS, @@ -111,7 +113,7 @@ TABLES_TO_CHECK = [ ] LAST_UPDATED_INDEX_TS = "ix_states_last_updated_ts" -ENTITY_ID_LAST_UPDATED_INDEX_TS = "ix_states_entity_id_last_updated_ts" +METADATA_ID_LAST_UPDATED_INDEX_TS = "ix_states_metadata_id_last_updated_ts" EVENTS_CONTEXT_ID_BIN_INDEX = "ix_events_context_id_bin" STATES_CONTEXT_ID_BIN_INDEX = "ix_states_context_id_bin" CONTEXT_ID_BIN_MAX_LENGTH = 16 @@ -363,7 +365,7 @@ class States(Base): __table_args__ = ( # Used for fetching the state of entities at a specific time # (get_states in history.py) - Index(ENTITY_ID_LAST_UPDATED_INDEX_TS, "entity_id", "last_updated_ts"), + Index(METADATA_ID_LAST_UPDATED_INDEX_TS, "metadata_id", "last_updated_ts"), Index( STATES_CONTEXT_ID_BIN_INDEX, "context_id_bin", @@ -374,7 +376,9 @@ class States(Base): ) __tablename__ = TABLE_STATES state_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) - entity_id: Mapped[str | None] = mapped_column(String(MAX_LENGTH_STATE_ENTITY_ID)) + entity_id: Mapped[str | None] = mapped_column( + String(MAX_LENGTH_STATE_ENTITY_ID) + ) # no longer used for new rows state: Mapped[str | None] = mapped_column(String(MAX_LENGTH_STATE_STATE)) attributes: Mapped[str | None] = mapped_column( Text().with_variant(mysql.LONGTEXT, "mysql", "mariadb") @@ -421,6 +425,10 @@ class States(Base): context_parent_id_bin: Mapped[bytes | None] = mapped_column( LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) ) + metadata_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("states_meta.metadata_id"), index=True + ) + states_meta_rel: Mapped[StatesMeta | None] = relationship("StatesMeta") def __repr__(self) -> str: """Return string representation of instance for debugging.""" @@ -583,6 +591,23 @@ class StateAttributes(Base): return {} +class StatesMeta(Base): + """Metadata for states.""" + + __table_args__ = (_DEFAULT_TABLE_ARGS,) + __tablename__ = TABLE_STATES_META + metadata_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) + entity_id: Mapped[str | None] = mapped_column(String(MAX_LENGTH_STATE_ENTITY_ID)) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + "" + ) + + class StatisticsBase: """Statistics base class.""" diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index 90f7d8c0a0..63eed2d145 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable, Collection, Iterable -import json from typing import Any from sqlalchemy import Column, Text, cast, not_, or_ @@ -10,13 +9,14 @@ from sqlalchemy.sql.elements import ColumnElement from homeassistant.const import CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE from homeassistant.helpers.entityfilter import CONF_ENTITY_GLOBS +from homeassistant.helpers.json import json_dumps from homeassistant.helpers.typing import ConfigType -from .db_schema import ENTITY_ID_IN_EVENT, OLD_ENTITY_ID_IN_EVENT, States +from .db_schema import ENTITY_ID_IN_EVENT, OLD_ENTITY_ID_IN_EVENT, States, StatesMeta DOMAIN = "history" HISTORY_FILTERS = "history_filters" -JSON_NULL = json.dumps(None) +JSON_NULL = json_dumps(None) GLOB_TO_SQL_CHARS = { ord("*"): "%", @@ -194,7 +194,10 @@ class Filters: return i_entities def states_entity_filter(self) -> ColumnElement | None: - """Generate the entity filter query.""" + """Generate the States.entity_id filter query. + + This is no longer used except by the legacy queries. + """ def _encoder(data: Any) -> Any: """Nothing to encode for states since there is no json.""" @@ -203,9 +206,19 @@ class Filters: # The type annotation should be improved so the type ignore can be removed return self._generate_filter_for_columns((States.entity_id,), _encoder) # type: ignore[arg-type] + def states_metadata_entity_filter(self) -> ColumnElement | None: + """Generate the StatesMeta.entity_id filter query.""" + + def _encoder(data: Any) -> Any: + """Nothing to encode for states since there is no json.""" + return data + + # The type annotation should be improved so the type ignore can be removed + return self._generate_filter_for_columns((StatesMeta.entity_id,), _encoder) # type: ignore[arg-type] + def events_entity_filter(self) -> ColumnElement: """Generate the entity filter query.""" - _encoder = json.dumps + _encoder = json_dumps return or_( # sqlalchemy's SQLite json implementation always # wraps everything with JSON_QUOTE so it resolves to 'null' diff --git a/homeassistant/components/recorder/history/__init__.py b/homeassistant/components/recorder/history/__init__.py index 1b7b9065b7..7a569e70b1 100644 --- a/homeassistant/components/recorder/history/__init__.py +++ b/homeassistant/components/recorder/history/__init__.py @@ -1,13 +1,23 @@ """Provide pre-made queries on top of the recorder component.""" from __future__ import annotations +from collections.abc import MutableMapping +from datetime import datetime +from typing import Any + +from sqlalchemy.orm.session import Session + +from homeassistant.core import HomeAssistant, State + +from ... import recorder +from ..filters import Filters from .const import NEED_ATTRIBUTE_DOMAINS, SIGNIFICANT_DOMAINS -from .legacy import ( - get_full_significant_states_with_session, - get_last_state_changes, - get_significant_states, - get_significant_states_with_session, - state_changes_during_period, +from .modern import ( + get_full_significant_states_with_session as _modern_get_full_significant_states_with_session, + get_last_state_changes as _modern_get_last_state_changes, + get_significant_states as _modern_get_significant_states, + get_significant_states_with_session as _modern_get_significant_states_with_session, + state_changes_during_period as _modern_state_changes_during_period, ) # These are the APIs of this package @@ -20,3 +30,154 @@ __all__ = [ "get_significant_states_with_session", "state_changes_during_period", ] + + +def get_full_significant_states_with_session( + hass: HomeAssistant, + session: Session, + start_time: datetime, + end_time: datetime | None = None, + entity_ids: list[str] | None = None, + filters: Filters | None = None, + include_start_time_state: bool = True, + significant_changes_only: bool = True, + no_attributes: bool = False, +) -> MutableMapping[str, list[State]]: + """Return a dict of significant states during a time period.""" + if not recorder.get_instance(hass).states_meta_manager.active: + from .legacy import ( # pylint: disable=import-outside-toplevel + get_full_significant_states_with_session as _legacy_get_full_significant_states_with_session, + ) + + _target = _legacy_get_full_significant_states_with_session + else: + _target = _modern_get_full_significant_states_with_session + return _target( + hass, + session, + start_time, + end_time, + entity_ids, + filters, + include_start_time_state, + significant_changes_only, + no_attributes, + ) + + +def get_last_state_changes( + hass: HomeAssistant, number_of_states: int, entity_id: str +) -> MutableMapping[str, list[State]]: + """Return the last number_of_states.""" + if not recorder.get_instance(hass).states_meta_manager.active: + from .legacy import ( # pylint: disable=import-outside-toplevel + get_last_state_changes as _legacy_get_last_state_changes, + ) + + _target = _legacy_get_last_state_changes + else: + _target = _modern_get_last_state_changes + return _target(hass, number_of_states, entity_id) + + +def get_significant_states( + hass: HomeAssistant, + start_time: datetime, + end_time: datetime | None = None, + entity_ids: list[str] | None = None, + filters: Filters | None = None, + include_start_time_state: bool = True, + significant_changes_only: bool = True, + minimal_response: bool = False, + no_attributes: bool = False, + compressed_state_format: bool = False, +) -> MutableMapping[str, list[State | dict[str, Any]]]: + """Return a dict of significant states during a time period.""" + if not recorder.get_instance(hass).states_meta_manager.active: + from .legacy import ( # pylint: disable=import-outside-toplevel + get_significant_states as _legacy_get_significant_states, + ) + + _target = _legacy_get_significant_states + else: + _target = _modern_get_significant_states + return _target( + hass, + start_time, + end_time, + entity_ids, + filters, + include_start_time_state, + significant_changes_only, + minimal_response, + no_attributes, + compressed_state_format, + ) + + +def get_significant_states_with_session( + hass: HomeAssistant, + session: Session, + start_time: datetime, + end_time: datetime | None = None, + entity_ids: list[str] | None = None, + filters: Filters | None = None, + include_start_time_state: bool = True, + significant_changes_only: bool = True, + minimal_response: bool = False, + no_attributes: bool = False, + compressed_state_format: bool = False, +) -> MutableMapping[str, list[State | dict[str, Any]]]: + """Return a dict of significant states during a time period.""" + if not recorder.get_instance(hass).states_meta_manager.active: + from .legacy import ( # pylint: disable=import-outside-toplevel + get_significant_states_with_session as _legacy_get_significant_states_with_session, + ) + + _target = _legacy_get_significant_states_with_session + else: + _target = _modern_get_significant_states_with_session + return _target( + hass, + session, + start_time, + end_time, + entity_ids, + filters, + include_start_time_state, + significant_changes_only, + minimal_response, + no_attributes, + compressed_state_format, + ) + + +def state_changes_during_period( + hass: HomeAssistant, + start_time: datetime, + end_time: datetime | None = None, + entity_id: str | None = None, + no_attributes: bool = False, + descending: bool = False, + limit: int | None = None, + include_start_time_state: bool = True, +) -> MutableMapping[str, list[State]]: + """Return a list of states that changed during a time period.""" + if not recorder.get_instance(hass).states_meta_manager.active: + from .legacy import ( # pylint: disable=import-outside-toplevel + state_changes_during_period as _legacy_state_changes_during_period, + ) + + _target = _legacy_state_changes_during_period + else: + _target = _modern_state_changes_during_period + return _target( + hass, + start_time, + end_time, + entity_id, + no_attributes, + descending, + limit, + include_start_time_state, + ) diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py new file mode 100644 index 0000000000..dce3b51edf --- /dev/null +++ b/homeassistant/components/recorder/history/modern.py @@ -0,0 +1,783 @@ +"""Provide pre-made queries on top of the recorder component.""" +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Callable, Iterable, Iterator, MutableMapping +from datetime import datetime +from itertools import groupby +import logging +from operator import itemgetter +from typing import Any, cast + +from sqlalchemy import Column, and_, func, lambda_stmt, or_, select +from sqlalchemy.engine.row import Row +from sqlalchemy.orm.properties import MappedColumn +from sqlalchemy.orm.query import Query +from sqlalchemy.orm.session import Session +from sqlalchemy.sql.expression import literal +from sqlalchemy.sql.lambdas import StatementLambdaElement + +from homeassistant.const import COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_STATE +from homeassistant.core import HomeAssistant, State, split_entity_id +import homeassistant.util.dt as dt_util + +from ... import recorder +from ..db_schema import RecorderRuns, StateAttributes, States, StatesMeta +from ..filters import Filters +from ..models import ( + LazyState, + process_timestamp, + process_timestamp_to_utc_isoformat, + row_to_compressed_state, +) +from ..util import execute_stmt_lambda_element, session_scope +from .const import ( + IGNORE_DOMAINS_ENTITY_ID_LIKE, + LAST_CHANGED_KEY, + NEED_ATTRIBUTE_DOMAINS, + SIGNIFICANT_DOMAINS, + SIGNIFICANT_DOMAINS_ENTITY_ID_LIKE, + STATE_KEY, +) + +_LOGGER = logging.getLogger(__name__) + + +_BASE_STATES = ( + States.metadata_id, + States.state, + States.last_changed_ts, + States.last_updated_ts, +) +_BASE_STATES_NO_LAST_CHANGED = ( # type: ignore[var-annotated] + States.metadata_id, + States.state, + literal(value=None).label("last_changed_ts"), + States.last_updated_ts, +) +_QUERY_STATE_NO_ATTR = (*_BASE_STATES,) +_QUERY_STATE_NO_ATTR_NO_LAST_CHANGED = (*_BASE_STATES_NO_LAST_CHANGED,) +_QUERY_STATES = ( + *_BASE_STATES, + # Remove States.attributes once all attributes are in StateAttributes.shared_attrs + States.attributes, + StateAttributes.shared_attrs, +) +_QUERY_STATES_NO_LAST_CHANGED = ( + *_BASE_STATES_NO_LAST_CHANGED, + # Remove States.attributes once all attributes are in StateAttributes.shared_attrs + States.attributes, + StateAttributes.shared_attrs, +) +_FIELD_MAP = { + cast(MappedColumn, field).name: idx + for idx, field in enumerate(_QUERY_STATE_NO_ATTR) +} + + +def _lambda_stmt_and_join_attributes( + no_attributes: bool, include_last_changed: bool = True +) -> tuple[StatementLambdaElement, bool]: + """Return the lambda_stmt and if StateAttributes should be joined. + + Because these are lambda_stmt the values inside the lambdas need + to be explicitly written out to avoid caching the wrong values. + """ + # If no_attributes was requested we do the query + # without the attributes fields and do not join the + # state_attributes table + if no_attributes: + if include_last_changed: + return ( + lambda_stmt(lambda: select(*_QUERY_STATE_NO_ATTR)), + False, + ) + return ( + lambda_stmt(lambda: select(*_QUERY_STATE_NO_ATTR_NO_LAST_CHANGED)), + False, + ) + + if include_last_changed: + return lambda_stmt(lambda: select(*_QUERY_STATES)), True + return lambda_stmt(lambda: select(*_QUERY_STATES_NO_LAST_CHANGED)), True + + +def get_significant_states( + hass: HomeAssistant, + start_time: datetime, + end_time: datetime | None = None, + entity_ids: list[str] | None = None, + filters: Filters | None = None, + include_start_time_state: bool = True, + significant_changes_only: bool = True, + minimal_response: bool = False, + no_attributes: bool = False, + compressed_state_format: bool = False, +) -> MutableMapping[str, list[State | dict[str, Any]]]: + """Wrap get_significant_states_with_session with an sql session.""" + with session_scope(hass=hass) as session: + return get_significant_states_with_session( + hass, + session, + start_time, + end_time, + entity_ids, + filters, + include_start_time_state, + significant_changes_only, + minimal_response, + no_attributes, + compressed_state_format, + ) + + +def _ignore_domains_filter(query: Query) -> Query: + """Add a filter to ignore domains we do not fetch history for.""" + return query.filter( + and_( + *[ + ~StatesMeta.entity_id.like(entity_domain) + for entity_domain in IGNORE_DOMAINS_ENTITY_ID_LIKE + ] + ) + ) + + +def _significant_states_stmt( + start_time: datetime, + end_time: datetime | None, + entity_ids: list[str] | None, + metadata_ids: list[int] | None, + filters: Filters | None, + significant_changes_only: bool, + no_attributes: bool, +) -> StatementLambdaElement: + """Query the database for significant state changes.""" + stmt, join_attributes = _lambda_stmt_and_join_attributes( + no_attributes, include_last_changed=not significant_changes_only + ) + join_states_meta = False + if ( + entity_ids + and len(entity_ids) == 1 + and significant_changes_only + and split_entity_id(entity_ids[0])[0] not in SIGNIFICANT_DOMAINS + ): + stmt += lambda q: q.filter( + (States.last_changed_ts == States.last_updated_ts) + | States.last_changed_ts.is_(None) + ) + elif significant_changes_only: + stmt += lambda q: q.filter( + or_( + *[ + StatesMeta.entity_id.like(entity_domain) + for entity_domain in SIGNIFICANT_DOMAINS_ENTITY_ID_LIKE + ], + ( + (States.last_changed_ts == States.last_updated_ts) + | States.last_changed_ts.is_(None) + ), + ) + ) + join_states_meta = True + + if metadata_ids: + stmt += lambda q: q.filter( + # https://github.com/python/mypy/issues/2608 + States.metadata_id.in_(metadata_ids) # type:ignore[arg-type] + ) + else: + stmt += _ignore_domains_filter + if filters and filters.has_config: + entity_filter = filters.states_metadata_entity_filter() + stmt = stmt.add_criteria( + lambda q: q.filter(entity_filter), track_on=[filters] + ) + join_states_meta = True + + start_time_ts = start_time.timestamp() + stmt += lambda q: q.filter(States.last_updated_ts > start_time_ts) + if end_time: + end_time_ts = end_time.timestamp() + stmt += lambda q: q.filter(States.last_updated_ts < end_time_ts) + if join_states_meta: + stmt += lambda q: q.outerjoin( + StatesMeta, States.metadata_id == StatesMeta.metadata_id + ) + if join_attributes: + stmt += lambda q: q.outerjoin( + StateAttributes, States.attributes_id == StateAttributes.attributes_id + ) + stmt += lambda q: q.order_by(States.metadata_id, States.last_updated_ts) + return stmt + + +def get_significant_states_with_session( + hass: HomeAssistant, + session: Session, + start_time: datetime, + end_time: datetime | None = None, + entity_ids: list[str] | None = None, + filters: Filters | None = None, + include_start_time_state: bool = True, + significant_changes_only: bool = True, + minimal_response: bool = False, + no_attributes: bool = False, + compressed_state_format: bool = False, +) -> MutableMapping[str, list[State | dict[str, Any]]]: + """Return states changes during UTC period start_time - end_time. + + entity_ids is an optional iterable of entities to include in the results. + + filters is an optional SQLAlchemy filter which will be applied to the database + queries unless entity_ids is given, in which case its ignored. + + Significant states are all states where there is a state change, + as well as all states from certain domains (for instance + thermostat so that we get current temperature in our graphs). + """ + metadata_ids: list[int] | None = None + entity_id_to_metadata_id: dict[str, int | None] | None = None + if entity_ids: + instance = recorder.get_instance(hass) + entity_id_to_metadata_id = instance.states_meta_manager.get_many( + entity_ids, session + ) + metadata_ids = [ + metadata_id + for metadata_id in entity_id_to_metadata_id.values() + if metadata_id is not None + ] + stmt = _significant_states_stmt( + start_time, + end_time, + entity_ids, + metadata_ids, + filters, + significant_changes_only, + no_attributes, + ) + states = execute_stmt_lambda_element( + session, stmt, None if entity_ids else start_time, end_time + ) + return _sorted_states_to_dict( + hass, + session, + states, + start_time, + entity_ids, + entity_id_to_metadata_id, + filters, + include_start_time_state, + minimal_response, + no_attributes, + compressed_state_format, + ) + + +def get_full_significant_states_with_session( + hass: HomeAssistant, + session: Session, + start_time: datetime, + end_time: datetime | None = None, + entity_ids: list[str] | None = None, + filters: Filters | None = None, + include_start_time_state: bool = True, + significant_changes_only: bool = True, + no_attributes: bool = False, +) -> MutableMapping[str, list[State]]: + """Variant of get_significant_states_with_session. + + Difference with get_significant_states_with_session is that it does not + return minimal responses. + """ + return cast( + MutableMapping[str, list[State]], + get_significant_states_with_session( + hass=hass, + session=session, + start_time=start_time, + end_time=end_time, + entity_ids=entity_ids, + filters=filters, + include_start_time_state=include_start_time_state, + significant_changes_only=significant_changes_only, + minimal_response=False, + no_attributes=no_attributes, + ), + ) + + +def _state_changed_during_period_stmt( + start_time: datetime, + end_time: datetime | None, + metadata_id: int | None, + no_attributes: bool, + descending: bool, + limit: int | None, +) -> StatementLambdaElement: + stmt, join_attributes = _lambda_stmt_and_join_attributes( + no_attributes, include_last_changed=False + ) + start_time_ts = start_time.timestamp() + stmt += lambda q: q.filter( + ( + (States.last_changed_ts == States.last_updated_ts) + | States.last_changed_ts.is_(None) + ) + & (States.last_updated_ts > start_time_ts) + ) + if end_time: + end_time_ts = end_time.timestamp() + stmt += lambda q: q.filter(States.last_updated_ts < end_time_ts) + if metadata_id: + stmt += lambda q: q.filter(States.metadata_id == metadata_id) + if join_attributes: + stmt += lambda q: q.outerjoin( + StateAttributes, States.attributes_id == StateAttributes.attributes_id + ) + if descending: + stmt += lambda q: q.order_by(States.metadata_id, States.last_updated_ts.desc()) + else: + stmt += lambda q: q.order_by(States.metadata_id, States.last_updated_ts) + if limit: + stmt += lambda q: q.limit(limit) + return stmt + + +def state_changes_during_period( + hass: HomeAssistant, + start_time: datetime, + end_time: datetime | None = None, + entity_id: str | None = None, + no_attributes: bool = False, + descending: bool = False, + limit: int | None = None, + include_start_time_state: bool = True, +) -> MutableMapping[str, list[State]]: + """Return states changes during UTC period start_time - end_time.""" + entity_id = entity_id.lower() if entity_id is not None else None + entity_ids = [entity_id] if entity_id is not None else None + + with session_scope(hass=hass) as session: + metadata_id: int | None = None + entity_id_to_metadata_id = None + if entity_id: + instance = recorder.get_instance(hass) + metadata_id = instance.states_meta_manager.get(entity_id, session) + entity_id_to_metadata_id = {entity_id: metadata_id} + stmt = _state_changed_during_period_stmt( + start_time, + end_time, + metadata_id, + no_attributes, + descending, + limit, + ) + states = execute_stmt_lambda_element( + session, stmt, None if entity_id else start_time, end_time + ) + return cast( + MutableMapping[str, list[State]], + _sorted_states_to_dict( + hass, + session, + states, + start_time, + entity_ids, + entity_id_to_metadata_id, + include_start_time_state=include_start_time_state, + ), + ) + + +def _get_last_state_changes_stmt( + number_of_states: int, metadata_id: int +) -> StatementLambdaElement: + stmt, join_attributes = _lambda_stmt_and_join_attributes( + False, include_last_changed=False + ) + stmt += lambda q: q.where( + States.state_id + == ( + select(States.state_id) + .filter(States.metadata_id == metadata_id) + .order_by(States.last_updated_ts.desc()) + .limit(number_of_states) + .subquery() + ).c.state_id + ) + if join_attributes: + stmt += lambda q: q.outerjoin( + StateAttributes, States.attributes_id == StateAttributes.attributes_id + ) + + stmt += lambda q: q.order_by(States.state_id.desc()) + return stmt + + +def get_last_state_changes( + hass: HomeAssistant, number_of_states: int, entity_id: str +) -> MutableMapping[str, list[State]]: + """Return the last number_of_states.""" + entity_id_lower = entity_id.lower() + entity_ids = [entity_id_lower] + + with session_scope(hass=hass) as session: + instance = recorder.get_instance(hass) + if not (metadata_id := instance.states_meta_manager.get(entity_id, session)): + return {} + entity_id_to_metadata_id: dict[str, int | None] = {entity_id_lower: metadata_id} + stmt = _get_last_state_changes_stmt(number_of_states, metadata_id) + states = list(execute_stmt_lambda_element(session, stmt)) + return cast( + MutableMapping[str, list[State]], + _sorted_states_to_dict( + hass, + session, + reversed(states), + dt_util.utcnow(), + entity_ids, + entity_id_to_metadata_id, + include_start_time_state=False, + ), + ) + + +def _get_states_for_entities_stmt( + run_start: datetime, + utc_point_in_time: datetime, + metadata_ids: list[int], + no_attributes: bool, +) -> StatementLambdaElement: + """Baked query to get states for specific entities.""" + stmt, join_attributes = _lambda_stmt_and_join_attributes( + no_attributes, include_last_changed=True + ) + # We got an include-list of entities, accelerate the query by filtering already + # in the inner query. + run_start_ts = process_timestamp(run_start).timestamp() + utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time) + stmt += lambda q: q.join( + ( + most_recent_states_for_entities_by_date := ( + select( + States.metadata_id.label("max_metadata_id"), + # https://github.com/sqlalchemy/sqlalchemy/issues/9189 + # pylint: disable-next=not-callable + func.max(States.last_updated_ts).label("max_last_updated"), + ) + .filter( + (States.last_updated_ts >= run_start_ts) + & (States.last_updated_ts < utc_point_in_time_ts) + ) + .filter(States.metadata_id.in_(metadata_ids)) + .group_by(States.metadata_id) + .subquery() + ) + ), + and_( + States.metadata_id + == most_recent_states_for_entities_by_date.c.max_metadata_id, + States.last_updated_ts + == most_recent_states_for_entities_by_date.c.max_last_updated, + ), + ) + if join_attributes: + stmt += lambda q: q.outerjoin( + StateAttributes, (States.attributes_id == StateAttributes.attributes_id) + ) + return stmt + + +def _get_states_for_all_stmt( + run_start: datetime, + utc_point_in_time: datetime, + filters: Filters | None, + no_attributes: bool, +) -> StatementLambdaElement: + """Baked query to get states for all entities.""" + stmt, join_attributes = _lambda_stmt_and_join_attributes( + no_attributes, include_last_changed=True + ) + # We did not get an include-list of entities, query all states in the inner + # query, then filter out unwanted domains as well as applying the custom filter. + # This filtering can't be done in the inner query because the domain column is + # not indexed and we can't control what's in the custom filter. + run_start_ts = process_timestamp(run_start).timestamp() + utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time) + stmt += lambda q: q.join( + ( + most_recent_states_by_date := ( + select( + States.metadata_id.label("max_metadata_id"), + # https://github.com/sqlalchemy/sqlalchemy/issues/9189 + # pylint: disable-next=not-callable + func.max(States.last_updated_ts).label("max_last_updated"), + ) + .filter( + (States.last_updated_ts >= run_start_ts) + & (States.last_updated_ts < utc_point_in_time_ts) + ) + .group_by(States.metadata_id) + .subquery() + ) + ), + and_( + States.metadata_id == most_recent_states_by_date.c.max_metadata_id, + States.last_updated_ts == most_recent_states_by_date.c.max_last_updated, + ), + ) + stmt += _ignore_domains_filter + if filters and filters.has_config: + entity_filter = filters.states_metadata_entity_filter() + stmt = stmt.add_criteria(lambda q: q.filter(entity_filter), track_on=[filters]) + if join_attributes: + stmt += lambda q: q.outerjoin( + StateAttributes, (States.attributes_id == StateAttributes.attributes_id) + ) + stmt += lambda q: q.outerjoin( + StatesMeta, States.metadata_id == StatesMeta.metadata_id + ) + return stmt + + +def _get_rows_with_session( + hass: HomeAssistant, + session: Session, + utc_point_in_time: datetime, + entity_ids: list[str] | None = None, + entity_id_to_metadata_id: dict[str, int | None] | None = None, + run: RecorderRuns | None = None, + filters: Filters | None = None, + no_attributes: bool = False, +) -> Iterable[Row]: + """Return the states at a specific point in time.""" + if entity_ids and len(entity_ids) == 1: + if not entity_id_to_metadata_id or not ( + metadata_id := entity_id_to_metadata_id.get(entity_ids[0]) + ): + return [] + return execute_stmt_lambda_element( + session, + _get_single_entity_states_stmt( + utc_point_in_time, metadata_id, no_attributes + ), + ) + + if run is None: + run = recorder.get_instance(hass).run_history.get(utc_point_in_time) + + if run is None or process_timestamp(run.start) > utc_point_in_time: + # History did not run before utc_point_in_time + return [] + + # We have more than one entity to look at so we need to do a query on states + # since the last recorder run started. + if entity_ids: + if not entity_id_to_metadata_id: + return [] + metadata_ids = [ + metadata_id + for metadata_id in entity_id_to_metadata_id.values() + if metadata_id is not None + ] + if not metadata_ids: + return [] + stmt = _get_states_for_entities_stmt( + run.start, utc_point_in_time, metadata_ids, no_attributes + ) + else: + stmt = _get_states_for_all_stmt( + run.start, utc_point_in_time, filters, no_attributes + ) + + return execute_stmt_lambda_element(session, stmt) + + +def _get_single_entity_states_stmt( + utc_point_in_time: datetime, + metadata_id: int, + no_attributes: bool = False, +) -> StatementLambdaElement: + # Use an entirely different (and extremely fast) query if we only + # have a single entity id + stmt, join_attributes = _lambda_stmt_and_join_attributes( + no_attributes, include_last_changed=True + ) + utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time) + stmt += ( + lambda q: q.filter( + States.last_updated_ts < utc_point_in_time_ts, + States.metadata_id == metadata_id, + ) + .order_by(States.last_updated_ts.desc()) + .limit(1) + ) + if join_attributes: + stmt += lambda q: q.outerjoin( + StateAttributes, States.attributes_id == StateAttributes.attributes_id + ) + return stmt + + +def _sorted_states_to_dict( + hass: HomeAssistant, + session: Session, + states: Iterable[Row], + start_time: datetime, + entity_ids: list[str] | None, + entity_id_to_metadata_id: dict[str, int | None] | None, + filters: Filters | None = None, + include_start_time_state: bool = True, + minimal_response: bool = False, + no_attributes: bool = False, + compressed_state_format: bool = False, +) -> MutableMapping[str, list[State | dict[str, Any]]]: + """Convert SQL results into JSON friendly data structure. + + This takes our state list and turns it into a JSON friendly data + structure {'entity_id': [list of states], 'entity_id2': [list of states]} + + States must be sorted by entity_id and last_updated + + We also need to go back and create a synthetic zero data point for + each list of states, otherwise our graphs won't start on the Y + axis correctly. + """ + field_map = _FIELD_MAP + state_class: Callable[ + [Row, dict[str, dict[str, Any]], datetime | None], State | dict[str, Any] + ] + if compressed_state_format: + state_class = row_to_compressed_state + attr_time = COMPRESSED_STATE_LAST_UPDATED + attr_state = COMPRESSED_STATE_STATE + else: + state_class = LazyState + attr_time = LAST_CHANGED_KEY + attr_state = STATE_KEY + + result: dict[str, list[State | dict[str, Any]]] = defaultdict(list) + metadata_id_to_entity_id: dict[int, str] = {} + metadata_id_idx = field_map["metadata_id"] + + # Set all entity IDs to empty lists in result set to maintain the order + if entity_ids is not None: + for ent_id in entity_ids: + result[ent_id] = [] + + if entity_id_to_metadata_id: + metadata_id_to_entity_id = { + v: k for k, v in entity_id_to_metadata_id.items() if v is not None + } + else: + metadata_id_to_entity_id = recorder.get_instance( + hass + ).states_meta_manager.get_metadata_id_to_entity_id(session) + + # Get the states at the start time + initial_states: dict[int, Row] = {} + if include_start_time_state: + initial_states = { + row[metadata_id_idx]: row + for row in _get_rows_with_session( + hass, + session, + start_time, + entity_ids, + entity_id_to_metadata_id, + filters=filters, + no_attributes=no_attributes, + ) + } + + if entity_ids and len(entity_ids) == 1: + if not entity_id_to_metadata_id or not ( + metadata_id := entity_id_to_metadata_id.get(entity_ids[0]) + ): + return {} + states_iter: Iterable[tuple[int, Iterator[Row]]] = ( + (metadata_id, iter(states)), + ) + else: + key_func = itemgetter(metadata_id_idx) + states_iter = groupby(states, key_func) + + # Append all changes to it + for metadata_id, group in states_iter: + attr_cache: dict[str, dict[str, Any]] = {} + prev_state: Column | str + if not (entity_id := metadata_id_to_entity_id.get(metadata_id)): + continue + ent_results = result[entity_id] + if row := initial_states.pop(metadata_id, None): + prev_state = row.state + ent_results.append(state_class(row, attr_cache, start_time, entity_id=entity_id)) # type: ignore[call-arg] + + if ( + not minimal_response + or split_entity_id(entity_id)[0] in NEED_ATTRIBUTE_DOMAINS + ): + ent_results.extend( + state_class(db_state, attr_cache, None, entity_id=entity_id) # type: ignore[call-arg] + for db_state in group + ) + continue + + # With minimal response we only provide a native + # State for the first and last response. All the states + # in-between only provide the "state" and the + # "last_changed". + if not ent_results: + if (first_state := next(group, None)) is None: + continue + prev_state = first_state.state + ent_results.append( + state_class(first_state, attr_cache, None, entity_id=entity_id) # type: ignore[call-arg] + ) + + state_idx = field_map["state"] + + # + # minimal_response only makes sense with last_updated == last_updated + # + # We use last_updated for for last_changed since its the same + # + # With minimal response we do not care about attribute + # changes so we can filter out duplicate states + last_updated_ts_idx = field_map["last_updated_ts"] + if compressed_state_format: + for row in group: + if (state := row[state_idx]) != prev_state: + ent_results.append( + { + attr_state: state, + attr_time: row[last_updated_ts_idx], + } + ) + prev_state = state + + for row in group: + if (state := row[state_idx]) != prev_state: + ent_results.append( + { + attr_state: state, + attr_time: process_timestamp_to_utc_isoformat( + dt_util.utc_from_timestamp(row[last_updated_ts_idx]) + ), + } + ) + prev_state = state + + # If there are no states beyond the initial state, + # the state a was never popped from initial_states + for metadata_id, row in initial_states.items(): + if entity_id := metadata_id_to_entity_id.get(metadata_id): + result[entity_id].append( + state_class(row, {}, start_time, entity_id=entity_id) # type: ignore[call-arg] + ) + + # Filter out the empty lists if some states had 0 results. + return {key: val for key, val in result.items() if val} diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index e7a34f22fc..392a829cb8 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -38,6 +38,7 @@ from .db_schema import ( EventTypes, SchemaChanges, States, + StatesMeta, Statistics, StatisticsMeta, StatisticsRuns, @@ -45,6 +46,8 @@ from .db_schema import ( ) from .models import process_timestamp from .queries import ( + batch_cleanup_entity_ids, + find_entity_ids_to_migrate, find_event_type_to_migrate, find_events_context_ids_to_migrate, find_states_context_ids_to_migrate, @@ -68,6 +71,8 @@ if TYPE_CHECKING: LIVE_MIGRATION_MIN_SCHEMA_VERSION = 0 _EMPTY_CONTEXT_ID = b"\x00" * 16 +_EMPTY_ENTITY_ID = "missing.entity_id" +_EMPTY_EVENT_TYPE = "missing_event_type" _LOGGER = logging.getLogger(__name__) @@ -985,6 +990,10 @@ def _apply_update( # noqa: C901 _create_index(session_maker, "events", "ix_events_event_type_id") _drop_index(session_maker, "events", "ix_events_event_type_time_fired_ts") _create_index(session_maker, "events", "ix_events_event_type_id_time_fired_ts") + elif new_version == 38: + _add_columns(session_maker, "states", [f"metadata_id {big_int}"]) + _create_index(session_maker, "states", "ix_states_metadata_id") + _create_index(session_maker, "states", "ix_states_metadata_id_last_updated_ts") else: raise ValueError(f"No schema migration defined for version {new_version}") @@ -1305,7 +1314,10 @@ def migrate_event_type_ids(instance: Recorder) -> bool: event_types = {event_type for _, event_type in events} event_type_to_id = event_type_manager.get_many(event_types, session) if missing_event_types := { - event_type + # We should never see see None for the event_Type in the events table + # but we need to be defensive so we don't fail the migration + # because of a bad event + _EMPTY_EVENT_TYPE if event_type is None else event_type for event_type, event_id in event_type_to_id.items() if event_id is None }: @@ -1318,7 +1330,9 @@ def migrate_event_type_ids(instance: Recorder) -> bool: for db_event_type in missing_db_event_types: # We cannot add the assigned ids to the event_type_manager # because the commit could get rolled back - assert db_event_type.event_type is not None + assert ( + db_event_type.event_type is not None + ), "event_type should never be None" event_type_to_id[ db_event_type.event_type ] = db_event_type.event_type_id @@ -1346,6 +1360,89 @@ def migrate_event_type_ids(instance: Recorder) -> bool: return is_done +def migrate_entity_ids(instance: Recorder) -> bool: + """Migrate entity_ids to states_meta. + + We do this in two steps because we need the history queries to work + while we are migrating. + + 1. Link the states to the states_meta table + 2. Remove the entity_id column from the states table (in post_migrate_entity_ids) + """ + _LOGGER.debug("Migrating entity_ids") + states_meta_manager = instance.states_meta_manager + with session_scope(session=instance.get_session()) as session: + if states := session.execute(find_entity_ids_to_migrate()).all(): + entity_ids = {entity_id for _, entity_id in states} + entity_id_to_metadata_id = states_meta_manager.get_many(entity_ids, session) + if missing_entity_ids := { + # We should never see _EMPTY_ENTITY_ID in the states table + # but we need to be defensive so we don't fail the migration + # because of a bad state + _EMPTY_ENTITY_ID if entity_id is None else entity_id + for entity_id, metadata_id in entity_id_to_metadata_id.items() + if metadata_id is None + }: + missing_states_metadata = [ + StatesMeta(entity_id=entity_id) for entity_id in missing_entity_ids + ] + session.add_all(missing_states_metadata) + session.flush() # Assign ids + for db_states_metadata in missing_states_metadata: + # We cannot add the assigned ids to the event_type_manager + # because the commit could get rolled back + assert ( + db_states_metadata.entity_id is not None + ), "entity_id should never be None" + entity_id_to_metadata_id[ + db_states_metadata.entity_id + ] = db_states_metadata.metadata_id + + session.execute( + update(States), + [ + { + "state_id": state_id, + # We cannot set "entity_id": None yet since + # the history queries still need to work while the + # migration is in progress and we will do this in + # post_migrate_entity_ids + "metadata_id": entity_id_to_metadata_id[entity_id], + } + for state_id, entity_id in states + ], + ) + + # If there is more work to do return False + # so that we can be called again + is_done = not states + + _LOGGER.debug("Migrating entity_ids done=%s", is_done) + return is_done + + +def post_migrate_entity_ids(instance: Recorder) -> bool: + """Remove old entity_id strings from states. + + We cannot do this in migrate_entity_ids since the history queries + still need to work while the migration is in progress. + """ + session_maker = instance.get_session + _LOGGER.debug("Cleanup legacy entity_ids") + with session_scope(session=session_maker()) as session: + cursor_result = session.connection().execute(batch_cleanup_entity_ids()) + is_done = not cursor_result or cursor_result.rowcount == 0 + # If there is more work to do return False + # so that we can be called again + + if is_done: + # Drop the old indexes since they are no longer needed + _drop_index(session_maker, "states", "ix_states_entity_id_last_updated_ts") + + _LOGGER.debug("Cleanup legacy entity_ids done=%s", is_done) + return is_done + + def _initialize_database(session: Session) -> bool: """Initialize a new database. diff --git a/homeassistant/components/recorder/models/state.py b/homeassistant/components/recorder/models/state.py index 12983a3e68..c70e434263 100644 --- a/homeassistant/components/recorder/models/state.py +++ b/homeassistant/components/recorder/models/state.py @@ -41,10 +41,11 @@ class LazyState(State): row: Row, attr_cache: dict[str, dict[str, Any]], start_time: datetime | None, + entity_id: str | None = None, ) -> None: """Init the lazy state.""" self._row = row - self.entity_id: str = self._row.entity_id + self.entity_id = entity_id or self._row.entity_id self.state = self._row.state or "" self._attributes: dict[str, Any] | None = None self._last_updated_ts: float | None = self._row.last_updated_ts or ( @@ -127,6 +128,7 @@ def row_to_compressed_state( row: Row, attr_cache: dict[str, dict[str, Any]], start_time: datetime | None, + entity_id: str | None = None, ) -> dict[str, Any]: """Convert a database row to a compressed state schema 31 and later.""" comp_state = { diff --git a/homeassistant/components/recorder/models/state_attributes.py b/homeassistant/components/recorder/models/state_attributes.py index 738684c021..3ed109afa0 100644 --- a/homeassistant/components/recorder/models/state_attributes.py +++ b/homeassistant/components/recorder/models/state_attributes.py @@ -17,11 +17,13 @@ def decode_attributes_from_row( row: Row, attr_cache: dict[str, dict[str, Any]] ) -> dict[str, Any]: """Decode attributes from a database row.""" - source: str = row.shared_attrs or row.attributes - if (attributes := attr_cache.get(source)) is not None: - return attributes + source: str | None = getattr(row, "shared_attrs", None) or getattr( + row, "attributes", None + ) if not source or source == EMPTY_JSON_OBJECT: return {} + if (attributes := attr_cache.get(source)) is not None: + return attributes try: attr_cache[source] = attributes = json_loads_object(source) except ValueError: diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 368a6ccdf1..bb97448f14 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -15,7 +15,7 @@ from homeassistant.const import EVENT_STATE_CHANGED import homeassistant.util.dt as dt_util from .const import SQLITE_MAX_BIND_VARS -from .db_schema import Events, StateAttributes, States +from .db_schema import Events, StateAttributes, States, StatesMeta from .models import DatabaseEngine from .queries import ( attributes_ids_exist_in_states, @@ -27,10 +27,12 @@ from .queries import ( delete_event_types_rows, delete_recorder_runs_rows, delete_states_attributes_rows, + delete_states_meta_rows, delete_states_rows, delete_statistics_runs_rows, delete_statistics_short_term_rows, disconnect_states_rows, + find_entity_ids_to_purge, find_event_types_to_purge, find_events_to_purge, find_latest_statistics_runs_run_id, @@ -116,6 +118,9 @@ def purge_old_data( if instance.event_type_manager.active: _purge_old_event_types(instance, session) + if instance.states_meta_manager.active: + _purge_old_entity_ids(instance, session) + _purge_old_recorder_runs(instance, session, purge_before) if repack: repack_database(instance) @@ -590,6 +595,25 @@ def _purge_old_event_types(instance: Recorder, session: Session) -> None: instance.event_type_manager.evict_purged(purge_event_types) +def _purge_old_entity_ids(instance: Recorder, session: Session) -> None: + """Purge all old entity_ids.""" + # entity_ids are small, no need to batch run it + purge_entity_ids = set() + states_metadata_ids = set() + for metadata_id, entity_id in session.execute(find_entity_ids_to_purge()): + purge_entity_ids.add(entity_id) + states_metadata_ids.add(metadata_id) + + if not states_metadata_ids: + return + + deleted_rows = session.execute(delete_states_meta_rows(states_metadata_ids)) + _LOGGER.debug("Deleted %s states meta", deleted_rows) + + # Evict any entries in the event_type cache referring to a purged state + instance.states_meta_manager.evict_purged(purge_entity_ids) + + def _purge_filtered_data(instance: Recorder, session: Session) -> bool: """Remove filtered states and events that shouldn't be in the database.""" _LOGGER.debug("Cleanup filtered data") @@ -597,13 +621,18 @@ def _purge_filtered_data(instance: Recorder, session: Session) -> bool: assert database_engine is not None # Check if excluded entity_ids are in database - excluded_entity_ids: list[str] = [ - entity_id - for (entity_id,) in session.query(distinct(States.entity_id)).all() - if not instance.entity_filter(entity_id) + entity_filter = instance.entity_filter + excluded_metadata_ids: list[str] = [ + metadata_id + for (metadata_id, entity_id) in session.query( + StatesMeta.metadata_id, StatesMeta.entity_id + ).all() + if not entity_filter(entity_id) ] - if len(excluded_entity_ids) > 0: - _purge_filtered_states(instance, session, excluded_entity_ids, database_engine) + if len(excluded_metadata_ids) > 0: + _purge_filtered_states( + instance, session, excluded_metadata_ids, database_engine + ) return False # Check if excluded event_types are in database @@ -622,7 +651,7 @@ def _purge_filtered_data(instance: Recorder, session: Session) -> bool: def _purge_filtered_states( instance: Recorder, session: Session, - excluded_entity_ids: list[str], + excluded_metadata_ids: list[str], database_engine: DatabaseEngine, ) -> None: """Remove filtered states and linked events.""" @@ -632,7 +661,7 @@ def _purge_filtered_states( state_ids, attributes_ids, event_ids = zip( *( session.query(States.state_id, States.attributes_id, States.event_id) - .filter(States.entity_id.in_(excluded_entity_ids)) + .filter(States.metadata_id.in_(excluded_metadata_ids)) .limit(SQLITE_MAX_BIND_VARS) .all() ) @@ -687,17 +716,19 @@ def purge_entity_data(instance: Recorder, entity_filter: Callable[[str], bool]) database_engine = instance.database_engine assert database_engine is not None with session_scope(session=instance.get_session()) as session: - selected_entity_ids: list[str] = [ - entity_id - for (entity_id,) in session.query(distinct(States.entity_id)).all() + selected_metadata_ids: list[str] = [ + metadata_id + for (metadata_id, entity_id) in session.query( + StatesMeta.metadata_id, StatesMeta.entity_id + ).all() if entity_filter(entity_id) ] - _LOGGER.debug("Purging entity data for %s", selected_entity_ids) - if len(selected_entity_ids) > 0: + _LOGGER.debug("Purging entity data for %s", selected_metadata_ids) + if len(selected_metadata_ids) > 0: # Purge a max of SQLITE_MAX_BIND_VARS, based on the oldest states # or events record. _purge_filtered_states( - instance, session, selected_entity_ids, database_engine + instance, session, selected_metadata_ids, database_engine ) _LOGGER.debug("Purging entity data hasn't fully completed yet") return False diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py index d0672e6158..737faf2f7e 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -16,6 +16,7 @@ from .db_schema import ( RecorderRuns, StateAttributes, States, + StatesMeta, StatisticsRuns, StatisticsShortTerm, ) @@ -59,6 +60,20 @@ def find_event_type_ids(event_types: Iterable[str]) -> StatementLambdaElement: ) +def find_all_states_metadata_ids() -> StatementLambdaElement: + """Find all metadata_ids and entity_ids.""" + return lambda_stmt(lambda: select(StatesMeta.metadata_id, StatesMeta.entity_id)) + + +def find_states_metadata_ids(entity_ids: Iterable[str]) -> StatementLambdaElement: + """Find metadata_ids by entity_ids.""" + return lambda_stmt( + lambda: select(StatesMeta.metadata_id, StatesMeta.entity_id).filter( + StatesMeta.entity_id.in_(entity_ids) + ) + ) + + def find_shared_attributes_id( data_hash: int, shared_attrs: str ) -> StatementLambdaElement: @@ -716,6 +731,54 @@ def find_event_type_to_migrate() -> StatementLambdaElement: ) +def find_entity_ids_to_migrate() -> StatementLambdaElement: + """Find entity_id to migrate.""" + return lambda_stmt( + lambda: select( + States.state_id, + States.entity_id, + ) + .filter(States.metadata_id.is_(None)) + .limit(SQLITE_MAX_BIND_VARS) + ) + + +def batch_cleanup_entity_ids() -> StatementLambdaElement: + """Find entity_id to cleanup.""" + # Self join because This version of MariaDB doesn't yet support 'LIMIT & IN/ALL/ANY/SOME subquery' + return lambda_stmt( + lambda: update(States) + .where( + States.state_id.in_( + select(States.state_id).join( + states_with_entity_ids := select( + States.state_id.label("state_id_with_entity_id") + ) + .filter(States.entity_id.is_not(None)) + .limit(5000) + .subquery(), + States.state_id == states_with_entity_ids.c.state_id_with_entity_id, + ) + ) + ) + .values(entity_id=None) + ) + + +def has_events_context_ids_to_migrate() -> StatementLambdaElement: + """Check if there are events context ids to migrate.""" + return lambda_stmt( + lambda: select(Events.event_id).filter(Events.context_id_bin.is_(None)).limit(1) + ) + + +def has_states_context_ids_to_migrate() -> StatementLambdaElement: + """Check if there are states context ids to migrate.""" + return lambda_stmt( + lambda: select(States.state_id).filter(States.context_id_bin.is_(None)).limit(1) + ) + + def has_event_type_to_migrate() -> StatementLambdaElement: """Check if there are event_types to migrate.""" return lambda_stmt( @@ -723,6 +786,13 @@ def has_event_type_to_migrate() -> StatementLambdaElement: ) +def has_entity_ids_to_migrate() -> StatementLambdaElement: + """Check if there are entity_id to migrate.""" + return lambda_stmt( + lambda: select(States.state_id).filter(States.metadata_id.is_(None)).limit(1) + ) + + def find_states_context_ids_to_migrate() -> StatementLambdaElement: """Find events context_ids to migrate.""" return lambda_stmt( @@ -754,6 +824,23 @@ def find_event_types_to_purge() -> StatementLambdaElement: ) +def find_entity_ids_to_purge() -> StatementLambdaElement: + """Find entity_ids to purge.""" + return lambda_stmt( + lambda: select(StatesMeta.metadata_id, StatesMeta.entity_id).where( + StatesMeta.metadata_id.not_in( + select(StatesMeta.metadata_id).join( + used_states_metadata_id := select( + distinct(States.metadata_id).label("used_states_metadata_id") + ).subquery(), + StatesMeta.metadata_id + == used_states_metadata_id.c.used_states_metadata_id, + ) + ) + ) + ) + + def delete_event_types_rows(event_type_ids: Iterable[int]) -> StatementLambdaElement: """Delete EventTypes rows.""" return lambda_stmt( @@ -761,3 +848,12 @@ def delete_event_types_rows(event_type_ids: Iterable[int]) -> StatementLambdaEle .where(EventTypes.event_type_id.in_(event_type_ids)) .execution_options(synchronize_session=False) ) + + +def delete_states_meta_rows(metadata_ids: Iterable[int]) -> StatementLambdaElement: + """Delete StatesMeta rows.""" + return lambda_stmt( + lambda: delete(StatesMeta) + .where(StatesMeta.metadata_id.in_(metadata_ids)) + .execution_options(synchronize_session=False) + ) diff --git a/homeassistant/components/recorder/table_managers/states_meta.py b/homeassistant/components/recorder/table_managers/states_meta.py new file mode 100644 index 0000000000..8650df7c8b --- /dev/null +++ b/homeassistant/components/recorder/table_managers/states_meta.py @@ -0,0 +1,94 @@ +"""Support managing StatesMeta.""" +from __future__ import annotations + +from collections.abc import Iterable +from typing import cast + +from lru import LRU # pylint: disable=no-name-in-module +from sqlalchemy.orm.session import Session + +from homeassistant.core import Event + +from ..db_schema import StatesMeta +from ..queries import find_all_states_metadata_ids, find_states_metadata_ids + +CACHE_SIZE = 8192 + + +class StatesMetaManager: + """Manage the StatesMeta table.""" + + def __init__(self) -> None: + """Initialize the states meta manager.""" + self._id_map: dict[str, int] = LRU(CACHE_SIZE) + self._pending: dict[str, StatesMeta] = {} + self.active = False + + def load(self, events: list[Event], session: Session) -> None: + """Load the entity_id to metadata_id mapping into memory.""" + self.get_many( + ( + event.data["new_state"].entity_id + for event in events + if event.data.get("new_state") is not None + ), + session, + ) + + def get(self, entity_id: str, session: Session) -> int | None: + """Resolve entity_id to the metadata_id.""" + return self.get_many((entity_id,), session)[entity_id] + + def get_metadata_id_to_entity_id(self, session: Session) -> dict[int, str]: + """Resolve all entity_ids to metadata_ids.""" + with session.no_autoflush: + return dict(tuple(session.execute(find_all_states_metadata_ids()))) # type: ignore[arg-type] + + def get_many( + self, entity_ids: Iterable[str], session: Session + ) -> dict[str, int | None]: + """Resolve entity_id to metadata_id.""" + results: dict[str, int | None] = {} + missing: list[str] = [] + for entity_id in entity_ids: + if (metadata_id := self._id_map.get(entity_id)) is None: + missing.append(entity_id) + + results[entity_id] = metadata_id + + if not missing: + return results + + with session.no_autoflush: + for metadata_id, entity_id in session.execute( + find_states_metadata_ids(missing) + ): + results[entity_id] = self._id_map[entity_id] = cast(int, metadata_id) + + return results + + def get_pending(self, entity_id: str) -> StatesMeta | None: + """Get pending StatesMeta that have not be assigned ids yet.""" + return self._pending.get(entity_id) + + def add_pending(self, db_states_meta: StatesMeta) -> None: + """Add a pending StatesMeta that will be committed at the next interval.""" + assert db_states_meta.entity_id is not None + entity_id: str = db_states_meta.entity_id + self._pending[entity_id] = db_states_meta + + def post_commit_pending(self) -> None: + """Call after commit to load the metadata_ids of the new StatesMeta into the LRU.""" + for entity_id, db_states_meta in self._pending.items(): + self._id_map[entity_id] = db_states_meta.metadata_id + self._pending.clear() + + def reset(self) -> None: + """Reset the states meta manager after the database has been reset or changed.""" + self._id_map.clear() + self._pending.clear() + + def evict_purged(self, entity_ids: Iterable[str]) -> None: + """Evict purged event_types from the cache when they are no longer used.""" + for entity_id in entity_ids: + self._id_map.pop(entity_id, None) diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index 81a105742b..0b99ca742b 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -372,3 +372,39 @@ class EventTypeIDMigrationTask(RecorderTask): if not instance._migrate_event_type_ids(): # pylint: disable=[protected-access] # Schedule a new migration task if this one didn't finish instance.queue_task(EventTypeIDMigrationTask()) + + +@dataclass +class EntityIDMigrationTask(RecorderTask): + """An object to insert into the recorder queue to migrate entity_ids to StatesMeta.""" + + commit_before = True + # We have to commit before to make sure there are + # no new pending states_meta about to be added to + # the db since this happens live + + def run(self, instance: Recorder) -> None: + """Run entity_id migration task.""" + if not instance._migrate_entity_ids(): # pylint: disable=[protected-access] + # Schedule a new migration task if this one didn't finish + instance.queue_task(EntityIDMigrationTask()) + else: + # The migration has finished, now we start the post migration + # to remove the old entity_id data from the states table + # at this point we can also start using the StatesMeta table + # so we set active to True + instance.states_meta_manager.active = True + instance.queue_task(EntityIDPostMigrationTask()) + + +@dataclass +class EntityIDPostMigrationTask(RecorderTask): + """An object to insert into the recorder queue to cleanup after entity_ids migration.""" + + def run(self, instance: Recorder) -> None: + """Run entity_id post migration task.""" + if ( + not instance._post_migrate_entity_ids() # pylint: disable=[protected-access] + ): + # Schedule a new migration task if this one didn't finish + instance.queue_task(EntityIDPostMigrationTask()) diff --git a/tests/components/history/test_init_db_schema_30.py b/tests/components/history/test_init_db_schema_30.py index 7c1b7a5e97..a300f58b96 100644 --- a/tests/components/history/test_init_db_schema_30.py +++ b/tests/components/history/test_init_db_schema_30.py @@ -69,7 +69,9 @@ def db_schema_30(): with patch.object(recorder, "db_schema", old_db_schema), patch.object( recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION - ), patch.object(core, "EventTypes", old_db_schema.EventTypes), patch.object( + ), patch.object(core, "StatesMeta", old_db_schema.StatesMeta), patch.object( + core, "EventTypes", old_db_schema.EventTypes + ), patch.object( core, "EventData", old_db_schema.EventData ), patch.object( core, "States", old_db_schema.States @@ -83,26 +85,34 @@ def db_schema_30(): yield -@pytest.mark.usefixtures("hass_history") +@pytest.fixture +def legacy_hass_history(hass_history): + """Home Assistant fixture to use legacy history recording.""" + instance = recorder.get_instance(hass_history) + with patch.object(instance.states_meta_manager, "active", False): + yield hass_history + + +@pytest.mark.usefixtures("legacy_hass_history") def test_setup() -> None: """Test setup method of history.""" # Verification occurs in the fixture -def test_get_significant_states(hass_history) -> None: +def test_get_significant_states(legacy_hass_history) -> None: """Test that only significant states are returned. We should get back every thermostat change that includes an attribute change, but only the state updates for media player (attribute changes are not significant and not returned). """ - hass = hass_history + hass = legacy_hass_history zero, four, states = record_states(hass) hist = get_significant_states(hass, zero, four, filters=history.Filters()) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) -def test_get_significant_states_minimal_response(hass_history) -> None: +def test_get_significant_states_minimal_response(legacy_hass_history) -> None: """Test that only significant states are returned. When minimal responses is set only the first and @@ -112,7 +122,7 @@ def test_get_significant_states_minimal_response(hass_history) -> None: includes an attribute change, but only the state updates for media player (attribute changes are not significant and not returned). """ - hass = hass_history + hass = legacy_hass_history zero, four, states = record_states(hass) hist = get_significant_states( hass, zero, four, filters=history.Filters(), minimal_response=True @@ -168,14 +178,14 @@ def test_get_significant_states_minimal_response(hass_history) -> None: ) -def test_get_significant_states_with_initial(hass_history) -> None: +def test_get_significant_states_with_initial(legacy_hass_history) -> None: """Test that only significant states are returned. We should get back every thermostat change that includes an attribute change, but only the state updates for media player (attribute changes are not significant and not returned). """ - hass = hass_history + hass = legacy_hass_history zero, four, states = record_states(hass) one = zero + timedelta(seconds=1) one_with_microsecond = zero + timedelta(seconds=1, microseconds=1) @@ -198,14 +208,14 @@ def test_get_significant_states_with_initial(hass_history) -> None: assert_dict_of_states_equal_without_context_and_last_changed(states, hist) -def test_get_significant_states_without_initial(hass_history) -> None: +def test_get_significant_states_without_initial(legacy_hass_history) -> None: """Test that only significant states are returned. We should get back every thermostat change that includes an attribute change, but only the state updates for media player (attribute changes are not significant and not returned). """ - hass = hass_history + hass = legacy_hass_history zero, four, states = record_states(hass) one = zero + timedelta(seconds=1) one_with_microsecond = zero + timedelta(seconds=1, microseconds=1) @@ -233,22 +243,25 @@ def test_get_significant_states_without_initial(hass_history) -> None: def test_get_significant_states_entity_id(hass_history) -> None: """Test that only significant states are returned for one entity.""" hass = hass_history - zero, four, states = record_states(hass) - del states["media_player.test2"] - del states["media_player.test3"] - del states["thermostat.test"] - del states["thermostat.test2"] - del states["script.can_cancel_this_one"] - hist = get_significant_states( - hass, zero, four, ["media_player.test"], filters=history.Filters() - ) - assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + zero, four, states = record_states(hass) + del states["media_player.test2"] + del states["media_player.test3"] + del states["thermostat.test"] + del states["thermostat.test2"] + del states["script.can_cancel_this_one"] + + hist = get_significant_states( + hass, zero, four, ["media_player.test"], filters=history.Filters() + ) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) -def test_get_significant_states_multiple_entity_ids(hass_history) -> None: +def test_get_significant_states_multiple_entity_ids(legacy_hass_history) -> None: """Test that only significant states are returned for one entity.""" - hass = hass_history + hass = legacy_hass_history zero, four, states = record_states(hass) del states["media_player.test2"] del states["media_player.test3"] @@ -265,13 +278,13 @@ def test_get_significant_states_multiple_entity_ids(hass_history) -> None: assert_dict_of_states_equal_without_context_and_last_changed(states, hist) -def test_get_significant_states_exclude_domain(hass_history) -> None: +def test_get_significant_states_exclude_domain(legacy_hass_history) -> None: """Test if significant states are returned when excluding domains. We should get back every thermostat change that includes an attribute change, but no media player changes. """ - hass = hass_history + hass = legacy_hass_history zero, four, states = record_states(hass) del states["media_player.test"] del states["media_player.test2"] @@ -286,13 +299,13 @@ def test_get_significant_states_exclude_domain(hass_history) -> None: check_significant_states(hass, zero, four, states, config) -def test_get_significant_states_exclude_entity(hass_history) -> None: +def test_get_significant_states_exclude_entity(legacy_hass_history) -> None: """Test if significant states are returned when excluding entities. We should get back every thermostat and script changes, but no media player changes. """ - hass = hass_history + hass = legacy_hass_history zero, four, states = record_states(hass) del states["media_player.test"] @@ -305,12 +318,12 @@ def test_get_significant_states_exclude_entity(hass_history) -> None: check_significant_states(hass, zero, four, states, config) -def test_get_significant_states_exclude(hass_history) -> None: +def test_get_significant_states_exclude(legacy_hass_history) -> None: """Test significant states when excluding entities and domains. We should not get back every thermostat and media player test changes. """ - hass = hass_history + hass = legacy_hass_history zero, four, states = record_states(hass) del states["media_player.test"] del states["thermostat.test"] @@ -330,12 +343,12 @@ def test_get_significant_states_exclude(hass_history) -> None: check_significant_states(hass, zero, four, states, config) -def test_get_significant_states_exclude_include_entity(hass_history) -> None: +def test_get_significant_states_exclude_include_entity(legacy_hass_history) -> None: """Test significant states when excluding domains and include entities. We should not get back every thermostat change unless its specifically included """ - hass = hass_history + hass = legacy_hass_history zero, four, states = record_states(hass) del states["thermostat.test2"] @@ -351,13 +364,13 @@ def test_get_significant_states_exclude_include_entity(hass_history) -> None: check_significant_states(hass, zero, four, states, config) -def test_get_significant_states_include_domain(hass_history) -> None: +def test_get_significant_states_include_domain(legacy_hass_history) -> None: """Test if significant states are returned when including domains. We should get back every thermostat and script changes, but no media player changes. """ - hass = hass_history + hass = legacy_hass_history zero, four, states = record_states(hass) del states["media_player.test"] del states["media_player.test2"] @@ -372,12 +385,12 @@ def test_get_significant_states_include_domain(hass_history) -> None: check_significant_states(hass, zero, four, states, config) -def test_get_significant_states_include_entity(hass_history) -> None: +def test_get_significant_states_include_entity(legacy_hass_history) -> None: """Test if significant states are returned when including entities. We should only get back changes of the media_player.test entity. """ - hass = hass_history + hass = legacy_hass_history zero, four, states = record_states(hass) del states["media_player.test2"] del states["media_player.test3"] @@ -394,13 +407,13 @@ def test_get_significant_states_include_entity(hass_history) -> None: check_significant_states(hass, zero, four, states, config) -def test_get_significant_states_include(hass_history) -> None: +def test_get_significant_states_include(legacy_hass_history) -> None: """Test significant states when including domains and entities. We should only get back changes of the media_player.test entity and the thermostat domain. """ - hass = hass_history + hass = legacy_hass_history zero, four, states = record_states(hass) del states["media_player.test2"] del states["media_player.test3"] @@ -420,14 +433,14 @@ def test_get_significant_states_include(hass_history) -> None: check_significant_states(hass, zero, four, states, config) -def test_get_significant_states_include_exclude_domain(hass_history) -> None: +def test_get_significant_states_include_exclude_domain(legacy_hass_history) -> None: """Test if significant states when excluding and including domains. We should get back all the media_player domain changes only since the include wins over the exclude but will exclude everything else. """ - hass = hass_history + hass = legacy_hass_history zero, four, states = record_states(hass) del states["thermostat.test"] del states["thermostat.test2"] @@ -445,13 +458,13 @@ def test_get_significant_states_include_exclude_domain(hass_history) -> None: check_significant_states(hass, zero, four, states, config) -def test_get_significant_states_include_exclude_entity(hass_history) -> None: +def test_get_significant_states_include_exclude_entity(legacy_hass_history) -> None: """Test if significant states when excluding and including domains. We should not get back any changes since we include only media_player.test but also exclude it. """ - hass = hass_history + hass = legacy_hass_history zero, four, states = record_states(hass) del states["media_player.test2"] del states["media_player.test3"] @@ -471,13 +484,13 @@ def test_get_significant_states_include_exclude_entity(hass_history) -> None: check_significant_states(hass, zero, four, states, config) -def test_get_significant_states_include_exclude(hass_history) -> None: +def test_get_significant_states_include_exclude(legacy_hass_history) -> None: """Test if significant states when in/excluding domains and entities. We should get back changes of the media_player.test2, media_player.test3, and thermostat.test. """ - hass = hass_history + hass = legacy_hass_history zero, four, states = record_states(hass) del states["media_player.test"] del states["thermostat.test2"] @@ -501,13 +514,13 @@ def test_get_significant_states_include_exclude(hass_history) -> None: check_significant_states(hass, zero, four, states, config) -def test_get_significant_states_are_ordered(hass_history) -> None: +def test_get_significant_states_are_ordered(legacy_hass_history) -> None: """Test order of results from get_significant_states. When entity ids are given, the results should be returned with the data in the same order. """ - hass = hass_history + hass = legacy_hass_history zero, four, _states = record_states(hass) entity_ids = ["media_player.test", "media_player.test2"] hist = get_significant_states( @@ -521,9 +534,9 @@ def test_get_significant_states_are_ordered(hass_history) -> None: assert list(hist.keys()) == entity_ids -def test_get_significant_states_only(hass_history) -> None: +def test_get_significant_states_only(legacy_hass_history) -> None: """Test significant states when significant_states_only is set.""" - hass = hass_history + hass = legacy_hass_history entity_id = "sensor.test" def set_state(state, **kwargs): @@ -691,9 +704,13 @@ async def test_fetch_period_api( ) -> None: """Test the fetch period view for history.""" await async_setup_component(hass, "history", {}) - client = await hass_client() - response = await client.get(f"/api/history/period/{dt_util.utcnow().isoformat()}") - assert response.status == HTTPStatus.OK + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + client = await hass_client() + response = await client.get( + f"/api/history/period/{dt_util.utcnow().isoformat()}" + ) + assert response.status == HTTPStatus.OK async def test_fetch_period_api_with_use_include_order( @@ -703,9 +720,13 @@ async def test_fetch_period_api_with_use_include_order( await async_setup_component( hass, "history", {history.DOMAIN: {history.CONF_ORDER: True}} ) - client = await hass_client() - response = await client.get(f"/api/history/period/{dt_util.utcnow().isoformat()}") - assert response.status == HTTPStatus.OK + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + client = await hass_client() + response = await client.get( + f"/api/history/period/{dt_util.utcnow().isoformat()}" + ) + assert response.status == HTTPStatus.OK async def test_fetch_period_api_with_minimal_response( @@ -714,40 +735,41 @@ async def test_fetch_period_api_with_minimal_response( """Test the fetch period view for history with minimal_response.""" now = dt_util.utcnow() await async_setup_component(hass, "history", {}) + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + hass.states.async_set("sensor.power", 0, {"attr": "any"}) + await async_wait_recording_done(hass) + hass.states.async_set("sensor.power", 50, {"attr": "any"}) + await async_wait_recording_done(hass) + hass.states.async_set("sensor.power", 23, {"attr": "any"}) + last_changed = hass.states.get("sensor.power").last_changed + await async_wait_recording_done(hass) + hass.states.async_set("sensor.power", 23, {"attr": "any"}) + await async_wait_recording_done(hass) + client = await hass_client() + response = await client.get( + f"/api/history/period/{now.isoformat()}?filter_entity_id=sensor.power&minimal_response&no_attributes" + ) + assert response.status == HTTPStatus.OK + response_json = await response.json() + assert len(response_json[0]) == 3 + state_list = response_json[0] - hass.states.async_set("sensor.power", 0, {"attr": "any"}) - await async_wait_recording_done(hass) - hass.states.async_set("sensor.power", 50, {"attr": "any"}) - await async_wait_recording_done(hass) - hass.states.async_set("sensor.power", 23, {"attr": "any"}) - last_changed = hass.states.get("sensor.power").last_changed - await async_wait_recording_done(hass) - hass.states.async_set("sensor.power", 23, {"attr": "any"}) - await async_wait_recording_done(hass) - client = await hass_client() - response = await client.get( - f"/api/history/period/{now.isoformat()}?filter_entity_id=sensor.power&minimal_response&no_attributes" - ) - assert response.status == HTTPStatus.OK - response_json = await response.json() - assert len(response_json[0]) == 3 - state_list = response_json[0] + assert state_list[0]["entity_id"] == "sensor.power" + assert state_list[0]["attributes"] == {} + assert state_list[0]["state"] == "0" - assert state_list[0]["entity_id"] == "sensor.power" - assert state_list[0]["attributes"] == {} - assert state_list[0]["state"] == "0" + assert "attributes" not in state_list[1] + assert "entity_id" not in state_list[1] + assert state_list[1]["state"] == "50" - assert "attributes" not in state_list[1] - assert "entity_id" not in state_list[1] - assert state_list[1]["state"] == "50" - - assert "attributes" not in state_list[2] - assert "entity_id" not in state_list[2] - assert state_list[2]["state"] == "23" - assert state_list[2]["last_changed"] == json.dumps( - process_timestamp(last_changed), - cls=JSONEncoder, - ).replace('"', "") + assert "attributes" not in state_list[2] + assert "entity_id" not in state_list[2] + assert state_list[2]["state"] == "23" + assert state_list[2]["last_changed"] == json.dumps( + process_timestamp(last_changed), + cls=JSONEncoder, + ).replace('"', "") async def test_fetch_period_api_with_no_timestamp( @@ -755,9 +777,11 @@ async def test_fetch_period_api_with_no_timestamp( ) -> None: """Test the fetch period view for history with no timestamp.""" await async_setup_component(hass, "history", {}) - client = await hass_client() - response = await client.get("/api/history/period") - assert response.status == HTTPStatus.OK + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + client = await hass_client() + response = await client.get("/api/history/period") + assert response.status == HTTPStatus.OK async def test_fetch_period_api_with_include_order( @@ -774,12 +798,14 @@ async def test_fetch_period_api_with_include_order( } }, ) - client = await hass_client() - response = await client.get( - f"/api/history/period/{dt_util.utcnow().isoformat()}", - params={"filter_entity_id": "non.existing,something.else"}, - ) - assert response.status == HTTPStatus.OK + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + client = await hass_client() + response = await client.get( + f"/api/history/period/{dt_util.utcnow().isoformat()}", + params={"filter_entity_id": "non.existing,something.else"}, + ) + assert response.status == HTTPStatus.OK async def test_fetch_period_api_with_entity_glob_include( @@ -795,19 +821,21 @@ async def test_fetch_period_api_with_entity_glob_include( } }, ) - hass.states.async_set("light.kitchen", "on") - hass.states.async_set("light.cow", "on") - hass.states.async_set("light.nomatch", "on") + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + hass.states.async_set("light.kitchen", "on") + hass.states.async_set("light.cow", "on") + hass.states.async_set("light.nomatch", "on") - await async_wait_recording_done(hass) + await async_wait_recording_done(hass) - client = await hass_client() - response = await client.get( - f"/api/history/period/{dt_util.utcnow().isoformat()}", - ) - assert response.status == HTTPStatus.OK - response_json = await response.json() - assert response_json[0][0]["entity_id"] == "light.kitchen" + client = await hass_client() + response = await client.get( + f"/api/history/period/{dt_util.utcnow().isoformat()}", + ) + assert response.status == HTTPStatus.OK + response_json = await response.json() + assert response_json[0][0]["entity_id"] == "light.kitchen" async def test_fetch_period_api_with_entity_glob_exclude( @@ -827,26 +855,28 @@ async def test_fetch_period_api_with_entity_glob_exclude( } }, ) - hass.states.async_set("light.kitchen", "on") - hass.states.async_set("light.cow", "on") - hass.states.async_set("light.match", "on") - hass.states.async_set("switch.match", "on") - hass.states.async_set("media_player.test", "on") - hass.states.async_set("binary_sensor.sensor_l", "on") - hass.states.async_set("binary_sensor.sensor_r", "on") - hass.states.async_set("binary_sensor.sensor", "on") + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + hass.states.async_set("light.kitchen", "on") + hass.states.async_set("light.cow", "on") + hass.states.async_set("light.match", "on") + hass.states.async_set("switch.match", "on") + hass.states.async_set("media_player.test", "on") + hass.states.async_set("binary_sensor.sensor_l", "on") + hass.states.async_set("binary_sensor.sensor_r", "on") + hass.states.async_set("binary_sensor.sensor", "on") - await async_wait_recording_done(hass) + await async_wait_recording_done(hass) - client = await hass_client() - response = await client.get( - f"/api/history/period/{dt_util.utcnow().isoformat()}", - ) - assert response.status == HTTPStatus.OK - response_json = await response.json() - assert len(response_json) == 3 - entities = {state[0]["entity_id"] for state in response_json} - assert entities == {"binary_sensor.sensor", "light.cow", "light.match"} + client = await hass_client() + response = await client.get( + f"/api/history/period/{dt_util.utcnow().isoformat()}", + ) + assert response.status == HTTPStatus.OK + response_json = await response.json() + assert len(response_json) == 3 + entities = {state[0]["entity_id"] for state in response_json} + assert entities == {"binary_sensor.sensor", "light.cow", "light.match"} async def test_fetch_period_api_with_entity_glob_include_and_exclude( @@ -869,30 +899,32 @@ async def test_fetch_period_api_with_entity_glob_include_and_exclude( } }, ) - hass.states.async_set("light.kitchen", "on") - hass.states.async_set("light.cow", "on") - hass.states.async_set("light.match", "on") - hass.states.async_set("light.many_state_changes", "on") - hass.states.async_set("switch.match", "on") - hass.states.async_set("media_player.test", "on") - hass.states.async_set("binary_sensor.exclude", "on") + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + hass.states.async_set("light.kitchen", "on") + hass.states.async_set("light.cow", "on") + hass.states.async_set("light.match", "on") + hass.states.async_set("light.many_state_changes", "on") + hass.states.async_set("switch.match", "on") + hass.states.async_set("media_player.test", "on") + hass.states.async_set("binary_sensor.exclude", "on") - await async_wait_recording_done(hass) + await async_wait_recording_done(hass) - client = await hass_client() - response = await client.get( - f"/api/history/period/{dt_util.utcnow().isoformat()}", - ) - assert response.status == HTTPStatus.OK - response_json = await response.json() - assert len(response_json) == 4 - entities = {state[0]["entity_id"] for state in response_json} - assert entities == { - "light.many_state_changes", - "light.match", - "media_player.test", - "switch.match", - } + client = await hass_client() + response = await client.get( + f"/api/history/period/{dt_util.utcnow().isoformat()}", + ) + assert response.status == HTTPStatus.OK + response_json = await response.json() + assert len(response_json) == 4 + entities = {state[0]["entity_id"] for state in response_json} + assert entities == { + "light.many_state_changes", + "light.match", + "media_player.test", + "switch.match", + } async def test_entity_ids_limit_via_api( @@ -904,21 +936,23 @@ async def test_entity_ids_limit_via_api( "history", {"history": {}}, ) - hass.states.async_set("light.kitchen", "on") - hass.states.async_set("light.cow", "on") - hass.states.async_set("light.nomatch", "on") + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + hass.states.async_set("light.kitchen", "on") + hass.states.async_set("light.cow", "on") + hass.states.async_set("light.nomatch", "on") - await async_wait_recording_done(hass) + await async_wait_recording_done(hass) - client = await hass_client() - response = await client.get( - f"/api/history/period/{dt_util.utcnow().isoformat()}?filter_entity_id=light.kitchen,light.cow", - ) - assert response.status == HTTPStatus.OK - response_json = await response.json() - assert len(response_json) == 2 - assert response_json[0][0]["entity_id"] == "light.kitchen" - assert response_json[1][0]["entity_id"] == "light.cow" + client = await hass_client() + response = await client.get( + f"/api/history/period/{dt_util.utcnow().isoformat()}?filter_entity_id=light.kitchen,light.cow", + ) + assert response.status == HTTPStatus.OK + response_json = await response.json() + assert len(response_json) == 2 + assert response_json[0][0]["entity_id"] == "light.kitchen" + assert response_json[1][0]["entity_id"] == "light.cow" async def test_entity_ids_limit_via_api_with_skip_initial_state( @@ -930,29 +964,31 @@ async def test_entity_ids_limit_via_api_with_skip_initial_state( "history", {"history": {}}, ) - hass.states.async_set("light.kitchen", "on") - hass.states.async_set("light.cow", "on") - hass.states.async_set("light.nomatch", "on") + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + hass.states.async_set("light.kitchen", "on") + hass.states.async_set("light.cow", "on") + hass.states.async_set("light.nomatch", "on") - await async_wait_recording_done(hass) + await async_wait_recording_done(hass) - client = await hass_client() - response = await client.get( - f"/api/history/period/{dt_util.utcnow().isoformat()}?filter_entity_id=light.kitchen,light.cow&skip_initial_state", - ) - assert response.status == HTTPStatus.OK - response_json = await response.json() - assert len(response_json) == 0 + client = await hass_client() + response = await client.get( + f"/api/history/period/{dt_util.utcnow().isoformat()}?filter_entity_id=light.kitchen,light.cow&skip_initial_state", + ) + assert response.status == HTTPStatus.OK + response_json = await response.json() + assert len(response_json) == 0 - when = dt_util.utcnow() - timedelta(minutes=1) - response = await client.get( - f"/api/history/period/{when.isoformat()}?filter_entity_id=light.kitchen,light.cow&skip_initial_state", - ) - assert response.status == HTTPStatus.OK - response_json = await response.json() - assert len(response_json) == 2 - assert response_json[0][0]["entity_id"] == "light.kitchen" - assert response_json[1][0]["entity_id"] == "light.cow" + when = dt_util.utcnow() - timedelta(minutes=1) + response = await client.get( + f"/api/history/period/{when.isoformat()}?filter_entity_id=light.kitchen,light.cow&skip_initial_state", + ) + assert response.status == HTTPStatus.OK + response_json = await response.json() + assert len(response_json) == 2 + assert response_json[0][0]["entity_id"] == "light.kitchen" + assert response_json[1][0]["entity_id"] == "light.cow" async def test_history_during_period( @@ -964,129 +1000,143 @@ async def test_history_during_period( await async_setup_component(hass, "history", {}) await async_setup_component(hass, "sensor", {}) await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "on", attributes={"any": "attr"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "off", attributes={"any": "attr"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "off", attributes={"any": "changed"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "off", attributes={"any": "again"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "on", attributes={"any": "attr"}) - await async_wait_recording_done(hass) + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + hass.states.async_set("sensor.test", "on", attributes={"any": "attr"}) + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.test", "off", attributes={"any": "attr"}) + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.test", "off", attributes={"any": "changed"}) + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.test", "off", attributes={"any": "again"}) + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.test", "on", attributes={"any": "attr"}) + await async_wait_recording_done(hass) - await async_wait_recording_done(hass) + await async_wait_recording_done(hass) - client = await hass_ws_client() - await client.send_json( - { - "id": 1, - "type": "history/history_during_period", - "start_time": now.isoformat(), - "end_time": now.isoformat(), - "entity_ids": ["sensor.test"], - "include_start_time_state": True, - "significant_changes_only": False, - "no_attributes": True, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["result"] == {} + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "history/history_during_period", + "start_time": now.isoformat(), + "end_time": now.isoformat(), + "entity_ids": ["sensor.test"], + "include_start_time_state": True, + "significant_changes_only": False, + "no_attributes": True, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == {} - await client.send_json( - { - "id": 2, - "type": "history/history_during_period", - "start_time": now.isoformat(), - "entity_ids": ["sensor.test"], - "include_start_time_state": True, - "significant_changes_only": False, - "no_attributes": True, - "minimal_response": True, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["id"] == 2 + await client.send_json( + { + "id": 2, + "type": "history/history_during_period", + "start_time": now.isoformat(), + "entity_ids": ["sensor.test"], + "include_start_time_state": True, + "significant_changes_only": False, + "no_attributes": True, + "minimal_response": True, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["id"] == 2 - sensor_test_history = response["result"]["sensor.test"] - assert len(sensor_test_history) == 3 + sensor_test_history = response["result"]["sensor.test"] + assert len(sensor_test_history) == 3 - assert sensor_test_history[0]["s"] == "on" - assert sensor_test_history[0]["a"] == {} - assert isinstance(sensor_test_history[0]["lu"], float) - assert "lc" not in sensor_test_history[0] # skipped if the same a last_updated (lu) + assert sensor_test_history[0]["s"] == "on" + assert sensor_test_history[0]["a"] == {} + assert isinstance(sensor_test_history[0]["lu"], float) + assert ( + "lc" not in sensor_test_history[0] + ) # skipped if the same a last_updated (lu) - assert "a" not in sensor_test_history[1] - assert sensor_test_history[1]["s"] == "off" - assert isinstance(sensor_test_history[1]["lu"], float) - assert "lc" not in sensor_test_history[1] # skipped if the same a last_updated (lu) + assert "a" not in sensor_test_history[1] + assert sensor_test_history[1]["s"] == "off" + assert isinstance(sensor_test_history[1]["lu"], float) + assert ( + "lc" not in sensor_test_history[1] + ) # skipped if the same a last_updated (lu) - assert sensor_test_history[2]["s"] == "on" - assert "a" not in sensor_test_history[2] + assert sensor_test_history[2]["s"] == "on" + assert "a" not in sensor_test_history[2] - await client.send_json( - { - "id": 3, - "type": "history/history_during_period", - "start_time": now.isoformat(), - "entity_ids": ["sensor.test"], - "include_start_time_state": True, - "significant_changes_only": False, - "no_attributes": False, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["id"] == 3 - sensor_test_history = response["result"]["sensor.test"] + await client.send_json( + { + "id": 3, + "type": "history/history_during_period", + "start_time": now.isoformat(), + "entity_ids": ["sensor.test"], + "include_start_time_state": True, + "significant_changes_only": False, + "no_attributes": False, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["id"] == 3 + sensor_test_history = response["result"]["sensor.test"] - assert len(sensor_test_history) == 5 + assert len(sensor_test_history) == 5 - assert sensor_test_history[0]["s"] == "on" - assert sensor_test_history[0]["a"] == {"any": "attr"} - assert isinstance(sensor_test_history[0]["lu"], float) - assert "lc" not in sensor_test_history[0] # skipped if the same a last_updated (lu) + assert sensor_test_history[0]["s"] == "on" + assert sensor_test_history[0]["a"] == {"any": "attr"} + assert isinstance(sensor_test_history[0]["lu"], float) + assert ( + "lc" not in sensor_test_history[0] + ) # skipped if the same a last_updated (lu) - assert sensor_test_history[1]["s"] == "off" - assert isinstance(sensor_test_history[1]["lu"], float) - assert "lc" not in sensor_test_history[1] # skipped if the same a last_updated (lu) - assert sensor_test_history[1]["a"] == {"any": "attr"} + assert sensor_test_history[1]["s"] == "off" + assert isinstance(sensor_test_history[1]["lu"], float) + assert ( + "lc" not in sensor_test_history[1] + ) # skipped if the same a last_updated (lu) + assert sensor_test_history[1]["a"] == {"any": "attr"} - assert sensor_test_history[4]["s"] == "on" - assert sensor_test_history[4]["a"] == {"any": "attr"} + assert sensor_test_history[4]["s"] == "on" + assert sensor_test_history[4]["a"] == {"any": "attr"} - await client.send_json( - { - "id": 4, - "type": "history/history_during_period", - "start_time": now.isoformat(), - "entity_ids": ["sensor.test"], - "include_start_time_state": True, - "significant_changes_only": True, - "no_attributes": False, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["id"] == 4 - sensor_test_history = response["result"]["sensor.test"] + await client.send_json( + { + "id": 4, + "type": "history/history_during_period", + "start_time": now.isoformat(), + "entity_ids": ["sensor.test"], + "include_start_time_state": True, + "significant_changes_only": True, + "no_attributes": False, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["id"] == 4 + sensor_test_history = response["result"]["sensor.test"] - assert len(sensor_test_history) == 3 + assert len(sensor_test_history) == 3 - assert sensor_test_history[0]["s"] == "on" - assert sensor_test_history[0]["a"] == {"any": "attr"} - assert isinstance(sensor_test_history[0]["lu"], float) - assert "lc" not in sensor_test_history[0] # skipped if the same a last_updated (lu) + assert sensor_test_history[0]["s"] == "on" + assert sensor_test_history[0]["a"] == {"any": "attr"} + assert isinstance(sensor_test_history[0]["lu"], float) + assert ( + "lc" not in sensor_test_history[0] + ) # skipped if the same a last_updated (lu) - assert sensor_test_history[1]["s"] == "off" - assert isinstance(sensor_test_history[1]["lu"], float) - assert "lc" not in sensor_test_history[1] # skipped if the same a last_updated (lu) - assert sensor_test_history[1]["a"] == {"any": "attr"} + assert sensor_test_history[1]["s"] == "off" + assert isinstance(sensor_test_history[1]["lu"], float) + assert ( + "lc" not in sensor_test_history[1] + ) # skipped if the same a last_updated (lu) + assert sensor_test_history[1]["a"] == {"any": "attr"} - assert sensor_test_history[2]["s"] == "on" - assert sensor_test_history[2]["a"] == {"any": "attr"} + assert sensor_test_history[2]["s"] == "on" + assert sensor_test_history[2]["a"] == {"any": "attr"} async def test_history_during_period_impossible_conditions( @@ -1096,56 +1146,58 @@ async def test_history_during_period_impossible_conditions( await async_setup_component(hass, "history", {}) await async_setup_component(hass, "sensor", {}) await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "on", attributes={"any": "attr"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "off", attributes={"any": "attr"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "off", attributes={"any": "changed"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "off", attributes={"any": "again"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "on", attributes={"any": "attr"}) - await async_wait_recording_done(hass) + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + hass.states.async_set("sensor.test", "on", attributes={"any": "attr"}) + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.test", "off", attributes={"any": "attr"}) + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.test", "off", attributes={"any": "changed"}) + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.test", "off", attributes={"any": "again"}) + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.test", "on", attributes={"any": "attr"}) + await async_wait_recording_done(hass) - await async_wait_recording_done(hass) + await async_wait_recording_done(hass) - after = dt_util.utcnow() + after = dt_util.utcnow() - client = await hass_ws_client() - await client.send_json( - { - "id": 1, - "type": "history/history_during_period", - "start_time": after.isoformat(), - "end_time": after.isoformat(), - "entity_ids": ["sensor.test"], - "include_start_time_state": False, - "significant_changes_only": False, - "no_attributes": True, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["id"] == 1 - assert response["result"] == {} + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "history/history_during_period", + "start_time": after.isoformat(), + "end_time": after.isoformat(), + "entity_ids": ["sensor.test"], + "include_start_time_state": False, + "significant_changes_only": False, + "no_attributes": True, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["id"] == 1 + assert response["result"] == {} - future = dt_util.utcnow() + timedelta(hours=10) + future = dt_util.utcnow() + timedelta(hours=10) - await client.send_json( - { - "id": 2, - "type": "history/history_during_period", - "start_time": future.isoformat(), - "entity_ids": ["sensor.test"], - "include_start_time_state": True, - "significant_changes_only": True, - "no_attributes": True, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["id"] == 2 - assert response["result"] == {} + await client.send_json( + { + "id": 2, + "type": "history/history_during_period", + "start_time": future.isoformat(), + "entity_ids": ["sensor.test"], + "include_start_time_state": True, + "significant_changes_only": True, + "no_attributes": True, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["id"] == 2 + assert response["result"] == {} @pytest.mark.parametrize( @@ -1164,159 +1216,175 @@ async def test_history_during_period_significant_domain( await async_setup_component(hass, "history", {}) await async_setup_component(hass, "sensor", {}) await async_recorder_block_till_done(hass) - hass.states.async_set("climate.test", "on", attributes={"temperature": "1"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("climate.test", "off", attributes={"temperature": "2"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("climate.test", "off", attributes={"temperature": "3"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("climate.test", "off", attributes={"temperature": "4"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("climate.test", "on", attributes={"temperature": "5"}) - await async_wait_recording_done(hass) + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + hass.states.async_set("climate.test", "on", attributes={"temperature": "1"}) + await async_recorder_block_till_done(hass) + hass.states.async_set("climate.test", "off", attributes={"temperature": "2"}) + await async_recorder_block_till_done(hass) + hass.states.async_set("climate.test", "off", attributes={"temperature": "3"}) + await async_recorder_block_till_done(hass) + hass.states.async_set("climate.test", "off", attributes={"temperature": "4"}) + await async_recorder_block_till_done(hass) + hass.states.async_set("climate.test", "on", attributes={"temperature": "5"}) + await async_wait_recording_done(hass) - await async_wait_recording_done(hass) + await async_wait_recording_done(hass) - client = await hass_ws_client() - await client.send_json( - { - "id": 1, - "type": "history/history_during_period", - "start_time": now.isoformat(), - "end_time": now.isoformat(), - "entity_ids": ["climate.test"], - "include_start_time_state": True, - "significant_changes_only": False, - "no_attributes": True, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["result"] == {} + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "history/history_during_period", + "start_time": now.isoformat(), + "end_time": now.isoformat(), + "entity_ids": ["climate.test"], + "include_start_time_state": True, + "significant_changes_only": False, + "no_attributes": True, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == {} - await client.send_json( - { - "id": 2, - "type": "history/history_during_period", - "start_time": now.isoformat(), - "entity_ids": ["climate.test"], - "include_start_time_state": True, - "significant_changes_only": False, - "no_attributes": True, - "minimal_response": True, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["id"] == 2 + await client.send_json( + { + "id": 2, + "type": "history/history_during_period", + "start_time": now.isoformat(), + "entity_ids": ["climate.test"], + "include_start_time_state": True, + "significant_changes_only": False, + "no_attributes": True, + "minimal_response": True, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["id"] == 2 - sensor_test_history = response["result"]["climate.test"] - assert len(sensor_test_history) == 5 + sensor_test_history = response["result"]["climate.test"] + assert len(sensor_test_history) == 5 - assert sensor_test_history[0]["s"] == "on" - assert sensor_test_history[0]["a"] == {} - assert isinstance(sensor_test_history[0]["lu"], float) - assert "lc" not in sensor_test_history[0] # skipped if the same a last_updated (lu) + assert sensor_test_history[0]["s"] == "on" + assert sensor_test_history[0]["a"] == {} + assert isinstance(sensor_test_history[0]["lu"], float) + assert ( + "lc" not in sensor_test_history[0] + ) # skipped if the same a last_updated (lu) - assert "a" in sensor_test_history[1] - assert sensor_test_history[1]["s"] == "off" - assert "lc" not in sensor_test_history[1] # skipped if the same a last_updated (lu) + assert "a" in sensor_test_history[1] + assert sensor_test_history[1]["s"] == "off" + assert ( + "lc" not in sensor_test_history[1] + ) # skipped if the same a last_updated (lu) - assert sensor_test_history[4]["s"] == "on" - assert sensor_test_history[4]["a"] == {} + assert sensor_test_history[4]["s"] == "on" + assert sensor_test_history[4]["a"] == {} - await client.send_json( - { - "id": 3, - "type": "history/history_during_period", - "start_time": now.isoformat(), - "entity_ids": ["climate.test"], - "include_start_time_state": True, - "significant_changes_only": False, - "no_attributes": False, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["id"] == 3 - sensor_test_history = response["result"]["climate.test"] + await client.send_json( + { + "id": 3, + "type": "history/history_during_period", + "start_time": now.isoformat(), + "entity_ids": ["climate.test"], + "include_start_time_state": True, + "significant_changes_only": False, + "no_attributes": False, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["id"] == 3 + sensor_test_history = response["result"]["climate.test"] - assert len(sensor_test_history) == 5 + assert len(sensor_test_history) == 5 - assert sensor_test_history[0]["s"] == "on" - assert sensor_test_history[0]["a"] == {"temperature": "1"} - assert isinstance(sensor_test_history[0]["lu"], float) - assert "lc" not in sensor_test_history[0] # skipped if the same a last_updated (lu) + assert sensor_test_history[0]["s"] == "on" + assert sensor_test_history[0]["a"] == {"temperature": "1"} + assert isinstance(sensor_test_history[0]["lu"], float) + assert ( + "lc" not in sensor_test_history[0] + ) # skipped if the same a last_updated (lu) - assert sensor_test_history[1]["s"] == "off" - assert isinstance(sensor_test_history[1]["lu"], float) - assert "lc" not in sensor_test_history[1] # skipped if the same a last_updated (lu) - assert sensor_test_history[1]["a"] == {"temperature": "2"} + assert sensor_test_history[1]["s"] == "off" + assert isinstance(sensor_test_history[1]["lu"], float) + assert ( + "lc" not in sensor_test_history[1] + ) # skipped if the same a last_updated (lu) + assert sensor_test_history[1]["a"] == {"temperature": "2"} - assert sensor_test_history[4]["s"] == "on" - assert sensor_test_history[4]["a"] == {"temperature": "5"} + assert sensor_test_history[4]["s"] == "on" + assert sensor_test_history[4]["a"] == {"temperature": "5"} - await client.send_json( - { - "id": 4, - "type": "history/history_during_period", - "start_time": now.isoformat(), - "entity_ids": ["climate.test"], - "include_start_time_state": True, - "significant_changes_only": True, - "no_attributes": False, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["id"] == 4 - sensor_test_history = response["result"]["climate.test"] + await client.send_json( + { + "id": 4, + "type": "history/history_during_period", + "start_time": now.isoformat(), + "entity_ids": ["climate.test"], + "include_start_time_state": True, + "significant_changes_only": True, + "no_attributes": False, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["id"] == 4 + sensor_test_history = response["result"]["climate.test"] - assert len(sensor_test_history) == 5 + assert len(sensor_test_history) == 5 - assert sensor_test_history[0]["s"] == "on" - assert sensor_test_history[0]["a"] == {"temperature": "1"} - assert isinstance(sensor_test_history[0]["lu"], float) - assert "lc" not in sensor_test_history[0] # skipped if the same a last_updated (lu) + assert sensor_test_history[0]["s"] == "on" + assert sensor_test_history[0]["a"] == {"temperature": "1"} + assert isinstance(sensor_test_history[0]["lu"], float) + assert ( + "lc" not in sensor_test_history[0] + ) # skipped if the same a last_updated (lu) - assert sensor_test_history[1]["s"] == "off" - assert isinstance(sensor_test_history[1]["lu"], float) - assert "lc" not in sensor_test_history[1] # skipped if the same a last_updated (lu) - assert sensor_test_history[1]["a"] == {"temperature": "2"} + assert sensor_test_history[1]["s"] == "off" + assert isinstance(sensor_test_history[1]["lu"], float) + assert ( + "lc" not in sensor_test_history[1] + ) # skipped if the same a last_updated (lu) + assert sensor_test_history[1]["a"] == {"temperature": "2"} - assert sensor_test_history[2]["s"] == "off" - assert sensor_test_history[2]["a"] == {"temperature": "3"} + assert sensor_test_history[2]["s"] == "off" + assert sensor_test_history[2]["a"] == {"temperature": "3"} - assert sensor_test_history[3]["s"] == "off" - assert sensor_test_history[3]["a"] == {"temperature": "4"} + assert sensor_test_history[3]["s"] == "off" + assert sensor_test_history[3]["a"] == {"temperature": "4"} - assert sensor_test_history[4]["s"] == "on" - assert sensor_test_history[4]["a"] == {"temperature": "5"} + assert sensor_test_history[4]["s"] == "on" + assert sensor_test_history[4]["a"] == {"temperature": "5"} - # Test we impute the state time state - later = dt_util.utcnow() - await client.send_json( - { - "id": 5, - "type": "history/history_during_period", - "start_time": later.isoformat(), - "entity_ids": ["climate.test"], - "include_start_time_state": True, - "significant_changes_only": True, - "no_attributes": False, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["id"] == 5 - sensor_test_history = response["result"]["climate.test"] + # Test we impute the state time state + later = dt_util.utcnow() + await client.send_json( + { + "id": 5, + "type": "history/history_during_period", + "start_time": later.isoformat(), + "entity_ids": ["climate.test"], + "include_start_time_state": True, + "significant_changes_only": True, + "no_attributes": False, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["id"] == 5 + sensor_test_history = response["result"]["climate.test"] - assert len(sensor_test_history) == 1 + assert len(sensor_test_history) == 1 - assert sensor_test_history[0]["s"] == "on" - assert sensor_test_history[0]["a"] == {"temperature": "5"} - assert sensor_test_history[0]["lu"] == later.timestamp() - assert "lc" not in sensor_test_history[0] # skipped if the same a last_updated (lu) + assert sensor_test_history[0]["s"] == "on" + assert sensor_test_history[0]["a"] == {"temperature": "5"} + assert sensor_test_history[0]["lu"] == later.timestamp() + assert ( + "lc" not in sensor_test_history[0] + ) # skipped if the same a last_updated (lu) async def test_history_during_period_bad_start_time( @@ -1328,18 +1396,19 @@ async def test_history_during_period_bad_start_time( "history", {"history": {}}, ) - - client = await hass_ws_client() - await client.send_json( - { - "id": 1, - "type": "history/history_during_period", - "start_time": "cats", - } - ) - response = await client.receive_json() - assert not response["success"] - assert response["error"]["code"] == "invalid_start_time" + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "history/history_during_period", + "start_time": "cats", + } + ) + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "invalid_start_time" async def test_history_during_period_bad_end_time( @@ -1353,16 +1422,17 @@ async def test_history_during_period_bad_end_time( "history", {"history": {}}, ) - - client = await hass_ws_client() - await client.send_json( - { - "id": 1, - "type": "history/history_during_period", - "start_time": now.isoformat(), - "end_time": "dogs", - } - ) - response = await client.receive_json() - assert not response["success"] - assert response["error"]["code"] == "invalid_end_time" + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "history/history_during_period", + "start_time": now.isoformat(), + "end_time": "dogs", + } + ) + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "invalid_end_time" diff --git a/tests/components/recorder/db_schema_23_with_newer_columns.py b/tests/components/recorder/db_schema_23_with_newer_columns.py index c8c87ca82d..9f73e304e9 100644 --- a/tests/components/recorder/db_schema_23_with_newer_columns.py +++ b/tests/components/recorder/db_schema_23_with_newer_columns.py @@ -62,6 +62,7 @@ DB_TIMEZONE = "+00:00" TABLE_EVENTS = "events" TABLE_STATES = "states" +TABLE_STATES_META = "states_meta" TABLE_RECORDER_RUNS = "recorder_runs" TABLE_SCHEMA_CHANGES = "schema_changes" TABLE_STATISTICS = "statistics" @@ -73,6 +74,7 @@ TABLE_EVENT_TYPES = "event_types" ALL_TABLES = [ TABLE_STATES, + TABLE_STATES_META, TABLE_EVENTS, TABLE_EVENT_TYPES, TABLE_RECORDER_RUNS, @@ -266,6 +268,10 @@ class States(Base): # type: ignore context_parent_id_bin = Column( LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) ) # *** Not originally in v23, only added for recorder to startup ok + metadata_id = Column( + Integer, ForeignKey("states_meta.metadata_id"), index=True + ) # *** Not originally in v23, only added for recorder to startup ok + states_meta_rel = relationship("StatesMeta") event = relationship("Events", uselist=False) old_state = relationship("States", remote_side=[state_id]) @@ -326,6 +332,27 @@ class States(Base): # type: ignore return None +# *** Not originally in v23, only added for recorder to startup ok +# This is not being tested by the v23 statistics migration tests +class StatesMeta(Base): # type: ignore[misc,valid-type] + """Metadata for states.""" + + __table_args__ = ( + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + __tablename__ = TABLE_STATES_META + metadata_id = Column(Integer, Identity(), primary_key=True) + entity_id = Column(String(MAX_LENGTH_STATE_ENTITY_ID)) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + "" + ) + + class StatisticResult(TypedDict): """Statistic result data class. diff --git a/tests/components/recorder/db_schema_28.py b/tests/components/recorder/db_schema_28.py index f7152cec50..d7a9ec0af4 100644 --- a/tests/components/recorder/db_schema_28.py +++ b/tests/components/recorder/db_schema_28.py @@ -8,6 +8,7 @@ from __future__ import annotations from datetime import datetime, timedelta import json import logging +import time from typing import Any, TypedDict, cast, overload from fnvhash import fnv1a_32 @@ -57,6 +58,7 @@ TABLE_EVENTS = "events" TABLE_EVENT_DATA = "event_data" TABLE_EVENT_TYPES = "event_types" TABLE_STATES = "states" +TABLE_STATES_META = "states_meta" TABLE_STATE_ATTRIBUTES = "state_attributes" TABLE_RECORDER_RUNS = "recorder_runs" TABLE_SCHEMA_CHANGES = "schema_changes" @@ -132,7 +134,7 @@ class Events(Base): # type: ignore[misc,valid-type] time_fired = Column(DATETIME_TYPE, index=True) time_fired_ts = Column( TIMESTAMP_TYPE, index=True - ) # *** Not originally in v30, only added for recorder to startup ok + ) # *** Not originally in v28, only added for recorder to startup ok context_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True) context_user_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) context_parent_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) @@ -275,7 +277,13 @@ class States(Base): # type: ignore[misc,valid-type] Integer, ForeignKey("events.event_id", ondelete="CASCADE"), index=True ) last_changed = Column(DATETIME_TYPE, default=dt_util.utcnow) + last_changed_ts = Column( + TIMESTAMP_TYPE + ) # *** Not originally in v30, only added for recorder to startup ok last_updated = Column(DATETIME_TYPE, default=dt_util.utcnow, index=True) + last_updated_ts = Column( + TIMESTAMP_TYPE, default=time.time, index=True + ) # *** Not originally in v30, only added for recorder to startup ok old_state_id = Column(Integer, ForeignKey("states.state_id"), index=True) attributes_id = Column( Integer, ForeignKey("state_attributes.attributes_id"), index=True @@ -284,6 +292,10 @@ class States(Base): # type: ignore[misc,valid-type] context_user_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) context_parent_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) origin_idx = Column(SmallInteger) # 0 is local, 1 is remote + metadata_id = Column( + Integer, ForeignKey("states_meta.metadata_id"), index=True + ) # *** Not originally in v28, only added for recorder to startup ok + states_meta_rel = relationship("StatesMeta") old_state = relationship("States", remote_side=[state_id]) state_attributes = relationship("StateAttributes") @@ -412,6 +424,27 @@ class StateAttributes(Base): # type: ignore[misc,valid-type] return {} +# *** Not originally in v23, only added for recorder to startup ok +# This is not being tested by the v23 statistics migration tests +class StatesMeta(Base): # type: ignore[misc,valid-type] + """Metadata for states.""" + + __table_args__ = ( + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + __tablename__ = TABLE_STATES_META + metadata_id = Column(Integer, Identity(), primary_key=True) + entity_id = Column(String(MAX_LENGTH_STATE_ENTITY_ID)) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + "" + ) + + class StatisticResult(TypedDict): """Statistic result data class. diff --git a/tests/components/recorder/db_schema_30.py b/tests/components/recorder/db_schema_30.py index ed9fb89e46..9c5efaea1d 100644 --- a/tests/components/recorder/db_schema_30.py +++ b/tests/components/recorder/db_schema_30.py @@ -8,6 +8,7 @@ from __future__ import annotations from collections.abc import Callable from datetime import datetime, timedelta import logging +import time from typing import Any, TypedDict, cast, overload import ciso8601 @@ -67,6 +68,7 @@ TABLE_EVENT_DATA = "event_data" TABLE_EVENT_TYPES = "event_types" TABLE_STATES = "states" TABLE_STATE_ATTRIBUTES = "state_attributes" +TABLE_STATES_META = "states_meta" TABLE_RECORDER_RUNS = "recorder_runs" TABLE_SCHEMA_CHANGES = "schema_changes" TABLE_STATISTICS = "statistics" @@ -77,6 +79,7 @@ TABLE_STATISTICS_SHORT_TERM = "statistics_short_term" ALL_TABLES = [ TABLE_STATES, TABLE_STATE_ATTRIBUTES, + TABLE_STATES_META, TABLE_EVENTS, TABLE_EVENT_DATA, TABLE_EVENT_TYPES, @@ -370,7 +373,13 @@ class States(Base): # type: ignore[misc,valid-type] Integer, ForeignKey("events.event_id", ondelete="CASCADE"), index=True ) last_changed = Column(DATETIME_TYPE) + last_changed_ts = Column( + TIMESTAMP_TYPE + ) # *** Not originally in v30, only added for recorder to startup ok last_updated = Column(DATETIME_TYPE, default=dt_util.utcnow, index=True) + last_updated_ts = Column( + TIMESTAMP_TYPE, default=time.time, index=True + ) # *** Not originally in v30, only added for recorder to startup ok old_state_id = Column(Integer, ForeignKey("states.state_id"), index=True) attributes_id = Column( Integer, ForeignKey("state_attributes.attributes_id"), index=True @@ -388,6 +397,10 @@ class States(Base): # type: ignore[misc,valid-type] context_parent_id_bin = Column( LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) ) # *** Not originally in v30, only added for recorder to startup ok + metadata_id = Column( + Integer, ForeignKey("states_meta.metadata_id"), index=True + ) # *** Not originally in v30, only added for recorder to startup ok + states_meta_rel = relationship("StatesMeta") old_state = relationship("States", remote_side=[state_id]) state_attributes = relationship("StateAttributes") @@ -525,6 +538,27 @@ class StateAttributes(Base): # type: ignore[misc,valid-type] return {} +# *** Not originally in v30, only added for recorder to startup ok +# This is not being tested by the v30 statistics migration tests +class StatesMeta(Base): # type: ignore[misc,valid-type] + """Metadata for states.""" + + __table_args__ = ( + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + __tablename__ = TABLE_STATES_META + metadata_id = Column(Integer, Identity(), primary_key=True) + entity_id = Column(String(MAX_LENGTH_STATE_ENTITY_ID)) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + "" + ) + + class StatisticsBase: """Statistics base class.""" diff --git a/tests/components/recorder/test_filters_with_entityfilter.py b/tests/components/recorder/test_filters_with_entityfilter.py index 477691fdc2..896987ee58 100644 --- a/tests/components/recorder/test_filters_with_entityfilter.py +++ b/tests/components/recorder/test_filters_with_entityfilter.py @@ -5,7 +5,7 @@ from sqlalchemy import select from sqlalchemy.engine.row import Row from homeassistant.components.recorder import Recorder, get_instance -from homeassistant.components.recorder.db_schema import EventData, Events, States +from homeassistant.components.recorder.db_schema import EventData, Events, StatesMeta from homeassistant.components.recorder.filters import ( Filters, extract_include_exclude_filter_conf, @@ -39,8 +39,8 @@ async def _async_get_states_and_events_with_filter( def _get_states_with_session(): with session_scope(hass=hass) as session: return session.execute( - select(States.entity_id).filter( - sqlalchemy_filter.states_entity_filter() + select(StatesMeta.entity_id).filter( + sqlalchemy_filter.states_metadata_entity_filter() ) ).all() diff --git a/tests/components/recorder/test_filters_with_entityfilter_schema_37.py b/tests/components/recorder/test_filters_with_entityfilter_schema_37.py new file mode 100644 index 0000000000..18879ffc0a --- /dev/null +++ b/tests/components/recorder/test_filters_with_entityfilter_schema_37.py @@ -0,0 +1,670 @@ +"""The tests for the recorder filter matching the EntityFilter component.""" +import json +from unittest.mock import patch + +import pytest +from sqlalchemy import select +from sqlalchemy.engine.row import Row + +from homeassistant.components.recorder import Recorder, get_instance +from homeassistant.components.recorder.db_schema import EventData, Events, States +from homeassistant.components.recorder.filters import ( + Filters, + extract_include_exclude_filter_conf, + sqlalchemy_filter_from_include_exclude_conf, +) +from homeassistant.components.recorder.util import session_scope +from homeassistant.const import ATTR_ENTITY_ID, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entityfilter import ( + CONF_DOMAINS, + CONF_ENTITIES, + CONF_ENTITY_GLOBS, + CONF_EXCLUDE, + CONF_INCLUDE, + convert_include_exclude_filter, +) + +from .common import async_wait_recording_done + + +@pytest.fixture(name="legacy_recorder_mock") +async def legacy_recorder_mock_fixture(recorder_mock): + """Fixture for legacy recorder mock.""" + with patch.object(recorder_mock.states_meta_manager, "active", False): + yield recorder_mock + + +async def _async_get_states_and_events_with_filter( + hass: HomeAssistant, sqlalchemy_filter: Filters, entity_ids: set[str] +) -> tuple[list[Row], list[Row]]: + """Get states from the database based on a filter.""" + for entity_id in entity_ids: + hass.states.async_set(entity_id, STATE_ON) + hass.bus.async_fire("any", {ATTR_ENTITY_ID: entity_id}) + + await async_wait_recording_done(hass) + + def _get_states_with_session(): + with session_scope(hass=hass) as session: + return session.execute( + select(States.entity_id).filter( + sqlalchemy_filter.states_entity_filter() + ) + ).all() + + filtered_states_entity_ids = { + row[0] + for row in await get_instance(hass).async_add_executor_job( + _get_states_with_session + ) + } + + def _get_events_with_session(): + with session_scope(hass=hass) as session: + return session.execute( + select(EventData.shared_data) + .outerjoin(Events, EventData.data_id == Events.data_id) + .filter(sqlalchemy_filter.events_entity_filter()) + ).all() + + filtered_events_entity_ids = set() + for row in await get_instance(hass).async_add_executor_job( + _get_events_with_session + ): + event_data = json.loads(row[0]) + if ATTR_ENTITY_ID not in event_data: + continue + filtered_events_entity_ids.add(json.loads(row[0])[ATTR_ENTITY_ID]) + + return filtered_states_entity_ids, filtered_events_entity_ids + + +async def test_included_and_excluded_simple_case_no_domains( + legacy_recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test filters with included and excluded without domains.""" + filter_accept = {"sensor.kitchen4", "switch.kitchen"} + filter_reject = { + "light.any", + "switch.other", + "cover.any", + "sensor.weather5", + "light.kitchen", + } + conf = { + CONF_INCLUDE: { + CONF_ENTITY_GLOBS: ["sensor.kitchen*"], + CONF_ENTITIES: ["switch.kitchen"], + }, + CONF_EXCLUDE: { + CONF_ENTITY_GLOBS: ["sensor.weather*"], + CONF_ENTITIES: ["light.kitchen"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + assert not entity_filter.explicitly_included("light.any") + assert not entity_filter.explicitly_included("switch.other") + assert entity_filter.explicitly_included("sensor.kitchen4") + assert entity_filter.explicitly_included("switch.kitchen") + + assert not entity_filter.explicitly_excluded("light.any") + assert not entity_filter.explicitly_excluded("switch.other") + assert entity_filter.explicitly_excluded("sensor.weather5") + assert entity_filter.explicitly_excluded("light.kitchen") + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_included_and_excluded_simple_case_no_globs( + legacy_recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test filters with included and excluded without globs.""" + filter_accept = {"switch.bla", "sensor.blu", "sensor.keep"} + filter_reject = {"sensor.bli"} + conf = { + CONF_INCLUDE: { + CONF_DOMAINS: ["sensor", "homeassistant"], + CONF_ENTITIES: ["switch.bla"], + }, + CONF_EXCLUDE: { + CONF_DOMAINS: ["switch"], + CONF_ENTITIES: ["sensor.bli"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_included_and_excluded_simple_case_without_underscores( + legacy_recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test filters with included and excluded without underscores.""" + filter_accept = {"light.any", "sensor.kitchen4", "switch.kitchen"} + filter_reject = {"switch.other", "cover.any", "sensor.weather5", "light.kitchen"} + conf = { + CONF_INCLUDE: { + CONF_DOMAINS: ["light"], + CONF_ENTITY_GLOBS: ["sensor.kitchen*"], + CONF_ENTITIES: ["switch.kitchen"], + }, + CONF_EXCLUDE: { + CONF_DOMAINS: ["cover"], + CONF_ENTITY_GLOBS: ["sensor.weather*"], + CONF_ENTITIES: ["light.kitchen"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + assert not entity_filter.explicitly_included("light.any") + assert not entity_filter.explicitly_included("switch.other") + assert entity_filter.explicitly_included("sensor.kitchen4") + assert entity_filter.explicitly_included("switch.kitchen") + + assert not entity_filter.explicitly_excluded("light.any") + assert not entity_filter.explicitly_excluded("switch.other") + assert entity_filter.explicitly_excluded("sensor.weather5") + assert entity_filter.explicitly_excluded("light.kitchen") + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_included_and_excluded_simple_case_with_underscores( + legacy_recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test filters with included and excluded with underscores.""" + filter_accept = {"light.any", "sensor.kitchen_4", "switch.kitchen"} + filter_reject = {"switch.other", "cover.any", "sensor.weather_5", "light.kitchen"} + conf = { + CONF_INCLUDE: { + CONF_DOMAINS: ["light"], + CONF_ENTITY_GLOBS: ["sensor.kitchen_*"], + CONF_ENTITIES: ["switch.kitchen"], + }, + CONF_EXCLUDE: { + CONF_DOMAINS: ["cover"], + CONF_ENTITY_GLOBS: ["sensor.weather_*"], + CONF_ENTITIES: ["light.kitchen"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + assert not entity_filter.explicitly_included("light.any") + assert not entity_filter.explicitly_included("switch.other") + assert entity_filter.explicitly_included("sensor.kitchen_4") + assert entity_filter.explicitly_included("switch.kitchen") + + assert not entity_filter.explicitly_excluded("light.any") + assert not entity_filter.explicitly_excluded("switch.other") + assert entity_filter.explicitly_excluded("sensor.weather_5") + assert entity_filter.explicitly_excluded("light.kitchen") + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_included_and_excluded_complex_case( + legacy_recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test filters with included and excluded with a complex filter.""" + filter_accept = {"light.any", "sensor.kitchen_4", "switch.kitchen"} + filter_reject = { + "camera.one", + "notify.any", + "automation.update_readme", + "automation.update_utilities_cost", + "binary_sensor.iss", + } + conf = { + CONF_INCLUDE: { + CONF_ENTITIES: ["group.trackers"], + }, + CONF_EXCLUDE: { + CONF_ENTITIES: [ + "automation.update_readme", + "automation.update_utilities_cost", + "binary_sensor.iss", + ], + CONF_DOMAINS: [ + "camera", + "group", + "media_player", + "notify", + "scene", + "sun", + "zone", + ], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_included_entities_and_excluded_domain( + legacy_recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test filters with included entities and excluded domain.""" + filter_accept = { + "media_player.test", + "media_player.test3", + "thermostat.test", + "zone.home", + "script.can_cancel_this_one", + } + filter_reject = { + "thermostat.test2", + } + conf = { + CONF_INCLUDE: { + CONF_ENTITIES: ["media_player.test", "thermostat.test"], + }, + CONF_EXCLUDE: { + CONF_DOMAINS: ["thermostat"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_same_domain_included_excluded( + legacy_recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test filters with the same domain included and excluded.""" + filter_accept = { + "media_player.test", + "media_player.test3", + } + filter_reject = { + "thermostat.test2", + "thermostat.test", + "zone.home", + "script.can_cancel_this_one", + } + conf = { + CONF_INCLUDE: { + CONF_DOMAINS: ["media_player"], + }, + CONF_EXCLUDE: { + CONF_DOMAINS: ["media_player"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_same_entity_included_excluded( + legacy_recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test filters with the same entity included and excluded.""" + filter_accept = { + "media_player.test", + } + filter_reject = { + "media_player.test3", + "thermostat.test2", + "thermostat.test", + "zone.home", + "script.can_cancel_this_one", + } + conf = { + CONF_INCLUDE: { + CONF_ENTITIES: ["media_player.test"], + }, + CONF_EXCLUDE: { + CONF_ENTITIES: ["media_player.test"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_same_entity_included_excluded_include_domain_wins( + legacy_recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test filters with domain and entities and the include domain wins.""" + filter_accept = { + "media_player.test2", + "media_player.test3", + "thermostat.test", + } + filter_reject = { + "thermostat.test2", + "zone.home", + "script.can_cancel_this_one", + } + conf = { + CONF_INCLUDE: { + CONF_DOMAINS: ["media_player"], + CONF_ENTITIES: ["thermostat.test"], + }, + CONF_EXCLUDE: { + CONF_DOMAINS: ["thermostat"], + CONF_ENTITIES: ["media_player.test"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_specificly_included_entity_always_wins( + legacy_recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test specificlly included entity always wins.""" + filter_accept = { + "media_player.test2", + "media_player.test3", + "thermostat.test", + "binary_sensor.specific_include", + } + filter_reject = { + "binary_sensor.test2", + "binary_sensor.home", + "binary_sensor.can_cancel_this_one", + } + conf = { + CONF_INCLUDE: { + CONF_ENTITIES: ["binary_sensor.specific_include"], + }, + CONF_EXCLUDE: { + CONF_DOMAINS: ["binary_sensor"], + CONF_ENTITY_GLOBS: ["binary_sensor.*"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_specificly_included_entity_always_wins_over_glob( + legacy_recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test specificlly included entity always wins over a glob.""" + filter_accept = { + "sensor.apc900va_status", + "sensor.apc900va_battery_charge", + "sensor.apc900va_battery_runtime", + "sensor.apc900va_load", + "sensor.energy_x", + } + filter_reject = { + "sensor.apc900va_not_included", + } + conf = { + CONF_EXCLUDE: { + CONF_DOMAINS: [ + "updater", + "camera", + "group", + "media_player", + "script", + "sun", + "automation", + "zone", + "weblink", + "scene", + "calendar", + "weather", + "remote", + "notify", + "switch", + "shell_command", + "media_player", + ], + CONF_ENTITY_GLOBS: ["sensor.apc900va_*"], + }, + CONF_INCLUDE: { + CONF_DOMAINS: [ + "binary_sensor", + "climate", + "device_tracker", + "input_boolean", + "sensor", + ], + CONF_ENTITY_GLOBS: ["sensor.energy_*"], + CONF_ENTITIES: [ + "sensor.apc900va_status", + "sensor.apc900va_battery_charge", + "sensor.apc900va_battery_runtime", + "sensor.apc900va_load", + ], + }, + } + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index ccde8c5d18..e39cb1945f 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -19,6 +19,7 @@ from homeassistant.components.recorder.db_schema import ( RecorderRuns, StateAttributes, States, + StatesMeta, ) from homeassistant.components.recorder.history import legacy from homeassistant.components.recorder.models import LazyState, process_timestamp @@ -802,34 +803,15 @@ async def test_state_changes_during_period_query_during_migration_to_schema_25( instance = await async_setup_recorder_instance(hass, {}) - start = dt_util.utcnow() - point = start + timedelta(seconds=1) - end = point + timedelta(seconds=1) - entity_id = "light.test" - await recorder.get_instance(hass).async_add_executor_job( - _add_db_entries, hass, point, [entity_id] - ) + with patch.object(instance.states_meta_manager, "active", False): + start = dt_util.utcnow() + point = start + timedelta(seconds=1) + end = point + timedelta(seconds=1) + entity_id = "light.test" + await recorder.get_instance(hass).async_add_executor_job( + _add_db_entries, hass, point, [entity_id] + ) - no_attributes = True - hist = history.state_changes_during_period( - hass, start, end, entity_id, no_attributes, include_start_time_state=False - ) - state = hist[entity_id][0] - assert state.attributes == {} - - no_attributes = False - hist = history.state_changes_during_period( - hass, start, end, entity_id, no_attributes, include_start_time_state=False - ) - state = hist[entity_id][0] - assert state.attributes == {"name": "the shared light"} - - with instance.engine.connect() as conn: - conn.execute(text("update states set attributes_id=NULL;")) - conn.execute(text("drop table state_attributes;")) - conn.commit() - - with patch.object(instance, "schema_version", 24): no_attributes = True hist = history.state_changes_during_period( hass, start, end, entity_id, no_attributes, include_start_time_state=False @@ -842,7 +824,37 @@ async def test_state_changes_during_period_query_during_migration_to_schema_25( hass, start, end, entity_id, no_attributes, include_start_time_state=False ) state = hist[entity_id][0] - assert state.attributes == {"name": "the light"} + assert state.attributes == {"name": "the shared light"} + + with instance.engine.connect() as conn: + conn.execute(text("update states set attributes_id=NULL;")) + conn.execute(text("drop table state_attributes;")) + conn.commit() + + with patch.object(instance, "schema_version", 24): + no_attributes = True + hist = history.state_changes_during_period( + hass, + start, + end, + entity_id, + no_attributes, + include_start_time_state=False, + ) + state = hist[entity_id][0] + assert state.attributes == {} + + no_attributes = False + hist = history.state_changes_during_period( + hass, + start, + end, + entity_id, + no_attributes, + include_start_time_state=False, + ) + state = hist[entity_id][0] + assert state.attributes == {"name": "the light"} async def test_get_states_query_during_migration_to_schema_25( @@ -993,7 +1005,14 @@ async def test_get_full_significant_states_handles_empty_last_changed( state_attributes.attributes_id: state_attributes for state_attributes in session.query(StateAttributes) } + metadata_id_to_entity_id = { + states_meta.metadata_id: states_meta + for states_meta in session.query(StatesMeta) + } for db_state in session.query(States): + db_state.entity_id = metadata_id_to_entity_id[ + db_state.metadata_id + ].entity_id state = db_state.to_native() state.attributes = db_state_attributes[ db_state.attributes_id diff --git a/tests/components/recorder/test_history_db_schema_30.py b/tests/components/recorder/test_history_db_schema_30.py index ae37d50f03..ef5ec233cf 100644 --- a/tests/components/recorder/test_history_db_schema_30.py +++ b/tests/components/recorder/test_history_db_schema_30.py @@ -65,7 +65,9 @@ def db_schema_30(): with patch.object(recorder, "db_schema", old_db_schema), patch.object( recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION - ), patch.object(core, "EventTypes", old_db_schema.EventTypes), patch.object( + ), patch.object(core, "StatesMeta", old_db_schema.StatesMeta), patch.object( + core, "EventTypes", old_db_schema.EventTypes + ), patch.object( core, "EventData", old_db_schema.EventData ), patch.object( core, "States", old_db_schema.States @@ -86,7 +88,10 @@ def test_get_full_significant_states_with_session_entity_no_matches( hass = hass_recorder() now = dt_util.utcnow() time_before_recorder_ran = now - timedelta(days=1000) - with session_scope(hass=hass) as session: + instance = recorder.get_instance(hass) + with session_scope(hass=hass) as session, patch.object( + instance.states_meta_manager, "active", False + ): assert ( history.get_full_significant_states_with_session( hass, session, time_before_recorder_ran, now, entity_ids=["demo.id"] @@ -112,7 +117,10 @@ def test_significant_states_with_session_entity_minimal_response_no_matches( hass = hass_recorder() now = dt_util.utcnow() time_before_recorder_ran = now - timedelta(days=1000) - with session_scope(hass=hass) as session: + instance = recorder.get_instance(hass) + with session_scope(hass=hass) as session, patch.object( + instance.states_meta_manager, "active", False + ): assert ( history.get_significant_states_with_session( hass, @@ -152,44 +160,46 @@ def test_state_changes_during_period( """Test state change during period.""" hass = hass_recorder() entity_id = "media_player.test" + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): - def set_state(state): - """Set the state.""" - hass.states.set(entity_id, state, attributes) - wait_recording_done(hass) - return hass.states.get(entity_id) + def set_state(state): + """Set the state.""" + hass.states.set(entity_id, state, attributes) + wait_recording_done(hass) + return hass.states.get(entity_id) - start = dt_util.utcnow() - point = start + timedelta(seconds=1) - end = point + timedelta(seconds=1) + start = dt_util.utcnow() + point = start + timedelta(seconds=1) + end = point + timedelta(seconds=1) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): - set_state("idle") - set_state("YouTube") + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start + ): + set_state("idle") + set_state("YouTube") - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): - states = [ - set_state("idle"), - set_state("Netflix"), - set_state("Plex"), - set_state("YouTube"), - ] + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point + ): + states = [ + set_state("idle"), + set_state("Netflix"), + set_state("Plex"), + set_state("YouTube"), + ] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=end - ): - set_state("Netflix") - set_state("Plex") + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=end + ): + set_state("Netflix") + set_state("Plex") - hist = history.state_changes_during_period( - hass, start, end, entity_id, no_attributes, limit=limit - ) + hist = history.state_changes_during_period( + hass, start, end, entity_id, no_attributes, limit=limit + ) - assert_multiple_states_equal_without_context(states[:limit], hist[entity_id]) + assert_multiple_states_equal_without_context(states[:limit], hist[entity_id]) def test_state_changes_during_period_descending( @@ -198,96 +208,100 @@ def test_state_changes_during_period_descending( """Test state change during period descending.""" hass = hass_recorder() entity_id = "media_player.test" + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): - def set_state(state): - """Set the state.""" - hass.states.set(entity_id, state, {"any": 1}) - wait_recording_done(hass) - return hass.states.get(entity_id) + def set_state(state): + """Set the state.""" + hass.states.set(entity_id, state, {"any": 1}) + wait_recording_done(hass) + return hass.states.get(entity_id) - start = dt_util.utcnow() - point = start + timedelta(seconds=1) - point2 = start + timedelta(seconds=1, microseconds=2) - point3 = start + timedelta(seconds=1, microseconds=3) - point4 = start + timedelta(seconds=1, microseconds=4) - end = point + timedelta(seconds=1) + start = dt_util.utcnow() + point = start + timedelta(seconds=1) + point2 = start + timedelta(seconds=1, microseconds=2) + point3 = start + timedelta(seconds=1, microseconds=3) + point4 = start + timedelta(seconds=1, microseconds=4) + end = point + timedelta(seconds=1) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): - set_state("idle") - set_state("YouTube") + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start + ): + set_state("idle") + set_state("YouTube") - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): - states = [set_state("idle")] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point2 - ): - states.append(set_state("Netflix")) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point3 - ): - states.append(set_state("Plex")) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point4 - ): - states.append(set_state("YouTube")) + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point + ): + states = [set_state("idle")] + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point2 + ): + states.append(set_state("Netflix")) + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point3 + ): + states.append(set_state("Plex")) + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point4 + ): + states.append(set_state("YouTube")) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=end - ): - set_state("Netflix") - set_state("Plex") + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=end + ): + set_state("Netflix") + set_state("Plex") - hist = history.state_changes_during_period( - hass, start, end, entity_id, no_attributes=False, descending=False - ) - assert_multiple_states_equal_without_context(states, hist[entity_id]) + hist = history.state_changes_during_period( + hass, start, end, entity_id, no_attributes=False, descending=False + ) + assert_multiple_states_equal_without_context(states, hist[entity_id]) - hist = history.state_changes_during_period( - hass, start, end, entity_id, no_attributes=False, descending=True - ) - assert_multiple_states_equal_without_context( - states, list(reversed(list(hist[entity_id]))) - ) + hist = history.state_changes_during_period( + hass, start, end, entity_id, no_attributes=False, descending=True + ) + assert_multiple_states_equal_without_context( + states, list(reversed(list(hist[entity_id]))) + ) def test_get_last_state_changes(hass_recorder: Callable[..., HomeAssistant]) -> None: """Test number of state changes.""" hass = hass_recorder() entity_id = "sensor.test" + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): - def set_state(state): - """Set the state.""" - hass.states.set(entity_id, state) - wait_recording_done(hass) - return hass.states.get(entity_id) + def set_state(state): + """Set the state.""" + hass.states.set(entity_id, state) + wait_recording_done(hass) + return hass.states.get(entity_id) - start = dt_util.utcnow() - timedelta(minutes=2) - point = start + timedelta(minutes=1) - point2 = point + timedelta(minutes=1, seconds=1) + start = dt_util.utcnow() - timedelta(minutes=2) + point = start + timedelta(minutes=1) + point2 = point + timedelta(minutes=1, seconds=1) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): - set_state("1") + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start + ): + set_state("1") - states = [] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): - states.append(set_state("2")) + states = [] + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point + ): + states.append(set_state("2")) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point2 - ): - states.append(set_state("3")) + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point2 + ): + states.append(set_state("3")) - hist = history.get_last_state_changes(hass, 2, entity_id) + hist = history.get_last_state_changes(hass, 2, entity_id) - assert_multiple_states_equal_without_context(states, hist[entity_id]) + assert_multiple_states_equal_without_context(states, hist[entity_id]) def test_ensure_state_can_be_copied( @@ -300,30 +314,36 @@ def test_ensure_state_can_be_copied( """ hass = hass_recorder() entity_id = "sensor.test" + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): - def set_state(state): - """Set the state.""" - hass.states.set(entity_id, state) - wait_recording_done(hass) - return hass.states.get(entity_id) + def set_state(state): + """Set the state.""" + hass.states.set(entity_id, state) + wait_recording_done(hass) + return hass.states.get(entity_id) - start = dt_util.utcnow() - timedelta(minutes=2) - point = start + timedelta(minutes=1) + start = dt_util.utcnow() - timedelta(minutes=2) + point = start + timedelta(minutes=1) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): - set_state("1") + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start + ): + set_state("1") - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): - set_state("2") + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point + ): + set_state("2") - hist = history.get_last_state_changes(hass, 2, entity_id) + hist = history.get_last_state_changes(hass, 2, entity_id) - assert_states_equal_without_context(copy(hist[entity_id][0]), hist[entity_id][0]) - assert_states_equal_without_context(copy(hist[entity_id][1]), hist[entity_id][1]) + assert_states_equal_without_context( + copy(hist[entity_id][0]), hist[entity_id][0] + ) + assert_states_equal_without_context( + copy(hist[entity_id][1]), hist[entity_id][1] + ) def test_get_significant_states(hass_recorder: Callable[..., HomeAssistant]) -> None: @@ -334,9 +354,11 @@ def test_get_significant_states(hass_recorder: Callable[..., HomeAssistant]) -> media player (attribute changes are not significant and not returned). """ hass = hass_recorder() - zero, four, states = record_states(hass) - hist = history.get_significant_states(hass, zero, four) - assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + zero, four, states = record_states(hass) + hist = history.get_significant_states(hass, zero, four) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) def test_get_significant_states_minimal_response( @@ -351,57 +373,59 @@ def test_get_significant_states_minimal_response( media player (attribute changes are not significant and not returned). """ hass = hass_recorder() - zero, four, states = record_states(hass) - hist = history.get_significant_states(hass, zero, four, minimal_response=True) - entites_with_reducable_states = [ - "media_player.test", - "media_player.test3", - ] + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + zero, four, states = record_states(hass) + hist = history.get_significant_states(hass, zero, four, minimal_response=True) + entites_with_reducable_states = [ + "media_player.test", + "media_player.test3", + ] - # All states for media_player.test state are reduced - # down to last_changed and state when minimal_response - # is set except for the first state. - # is set. We use JSONEncoder to make sure that are - # pre-encoded last_changed is always the same as what - # will happen with encoding a native state - for entity_id in entites_with_reducable_states: - entity_states = states[entity_id] - for state_idx in range(1, len(entity_states)): - input_state = entity_states[state_idx] - orig_last_changed = orig_last_changed = json.dumps( - process_timestamp(input_state.last_changed), - cls=JSONEncoder, - ).replace('"', "") - orig_state = input_state.state - entity_states[state_idx] = { - "last_changed": orig_last_changed, - "state": orig_state, - } + # All states for media_player.test state are reduced + # down to last_changed and state when minimal_response + # is set except for the first state. + # is set. We use JSONEncoder to make sure that are + # pre-encoded last_changed is always the same as what + # will happen with encoding a native state + for entity_id in entites_with_reducable_states: + entity_states = states[entity_id] + for state_idx in range(1, len(entity_states)): + input_state = entity_states[state_idx] + orig_last_changed = orig_last_changed = json.dumps( + process_timestamp(input_state.last_changed), + cls=JSONEncoder, + ).replace('"', "") + orig_state = input_state.state + entity_states[state_idx] = { + "last_changed": orig_last_changed, + "state": orig_state, + } - assert len(hist) == len(states) - assert_states_equal_without_context( - states["media_player.test"][0], hist["media_player.test"][0] - ) - assert states["media_player.test"][1] == hist["media_player.test"][1] - assert states["media_player.test"][2] == hist["media_player.test"][2] + assert len(hist) == len(states) + assert_states_equal_without_context( + states["media_player.test"][0], hist["media_player.test"][0] + ) + assert states["media_player.test"][1] == hist["media_player.test"][1] + assert states["media_player.test"][2] == hist["media_player.test"][2] - assert_multiple_states_equal_without_context( - states["media_player.test2"], hist["media_player.test2"] - ) - assert_states_equal_without_context( - states["media_player.test3"][0], hist["media_player.test3"][0] - ) - assert states["media_player.test3"][1] == hist["media_player.test3"][1] + assert_multiple_states_equal_without_context( + states["media_player.test2"], hist["media_player.test2"] + ) + assert_states_equal_without_context( + states["media_player.test3"][0], hist["media_player.test3"][0] + ) + assert states["media_player.test3"][1] == hist["media_player.test3"][1] - assert_multiple_states_equal_without_context( - states["script.can_cancel_this_one"], hist["script.can_cancel_this_one"] - ) - assert_multiple_states_equal_without_context_and_last_changed( - states["thermostat.test"], hist["thermostat.test"] - ) - assert_multiple_states_equal_without_context_and_last_changed( - states["thermostat.test2"], hist["thermostat.test2"] - ) + assert_multiple_states_equal_without_context( + states["script.can_cancel_this_one"], hist["script.can_cancel_this_one"] + ) + assert_multiple_states_equal_without_context_and_last_changed( + states["thermostat.test"], hist["thermostat.test"] + ) + assert_multiple_states_equal_without_context_and_last_changed( + states["thermostat.test2"], hist["thermostat.test2"] + ) def test_get_significant_states_with_initial( @@ -414,25 +438,30 @@ def test_get_significant_states_with_initial( media player (attribute changes are not significant and not returned). """ hass = hass_recorder() - zero, four, states = record_states(hass) - one = zero + timedelta(seconds=1) - one_with_microsecond = zero + timedelta(seconds=1, microseconds=1) - one_and_half = zero + timedelta(seconds=1.5) - for entity_id in states: - if entity_id == "media_player.test": - states[entity_id] = states[entity_id][1:] - for state in states[entity_id]: - if state.last_changed == one or state.last_changed == one_with_microsecond: - state.last_changed = one_and_half - state.last_updated = one_and_half + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + zero, four, states = record_states(hass) + one = zero + timedelta(seconds=1) + one_with_microsecond = zero + timedelta(seconds=1, microseconds=1) + one_and_half = zero + timedelta(seconds=1.5) + for entity_id in states: + if entity_id == "media_player.test": + states[entity_id] = states[entity_id][1:] + for state in states[entity_id]: + if ( + state.last_changed == one + or state.last_changed == one_with_microsecond + ): + state.last_changed = one_and_half + state.last_updated = one_and_half - hist = history.get_significant_states( - hass, - one_and_half, - four, - include_start_time_state=True, - ) - assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + hist = history.get_significant_states( + hass, + one_and_half, + four, + include_start_time_state=True, + ) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) def test_get_significant_states_without_initial( @@ -445,27 +474,29 @@ def test_get_significant_states_without_initial( media player (attribute changes are not significant and not returned). """ hass = hass_recorder() - zero, four, states = record_states(hass) - one = zero + timedelta(seconds=1) - one_with_microsecond = zero + timedelta(seconds=1, microseconds=1) - one_and_half = zero + timedelta(seconds=1.5) - for entity_id in states: - states[entity_id] = list( - filter( - lambda s: s.last_changed != one - and s.last_changed != one_with_microsecond, - states[entity_id], + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + zero, four, states = record_states(hass) + one = zero + timedelta(seconds=1) + one_with_microsecond = zero + timedelta(seconds=1, microseconds=1) + one_and_half = zero + timedelta(seconds=1.5) + for entity_id in states: + states[entity_id] = list( + filter( + lambda s: s.last_changed != one + and s.last_changed != one_with_microsecond, + states[entity_id], + ) ) - ) - del states["media_player.test2"] + del states["media_player.test2"] - hist = history.get_significant_states( - hass, - one_and_half, - four, - include_start_time_state=False, - ) - assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + hist = history.get_significant_states( + hass, + one_and_half, + four, + include_start_time_state=False, + ) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) def test_get_significant_states_entity_id( @@ -473,15 +504,17 @@ def test_get_significant_states_entity_id( ) -> None: """Test that only significant states are returned for one entity.""" hass = hass_recorder() - zero, four, states = record_states(hass) - del states["media_player.test2"] - del states["media_player.test3"] - del states["thermostat.test"] - del states["thermostat.test2"] - del states["script.can_cancel_this_one"] + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + zero, four, states = record_states(hass) + del states["media_player.test2"] + del states["media_player.test3"] + del states["thermostat.test"] + del states["thermostat.test2"] + del states["script.can_cancel_this_one"] - hist = history.get_significant_states(hass, zero, four, ["media_player.test"]) - assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + hist = history.get_significant_states(hass, zero, four, ["media_player.test"]) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) def test_get_significant_states_multiple_entity_ids( @@ -489,24 +522,26 @@ def test_get_significant_states_multiple_entity_ids( ) -> None: """Test that only significant states are returned for one entity.""" hass = hass_recorder() - zero, four, states = record_states(hass) - del states["media_player.test2"] - del states["media_player.test3"] - del states["thermostat.test2"] - del states["script.can_cancel_this_one"] + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + zero, four, states = record_states(hass) + del states["media_player.test2"] + del states["media_player.test3"] + del states["thermostat.test2"] + del states["script.can_cancel_this_one"] - hist = history.get_significant_states( - hass, - zero, - four, - ["media_player.test", "thermostat.test"], - ) - assert_multiple_states_equal_without_context_and_last_changed( - states["media_player.test"], hist["media_player.test"] - ) - assert_multiple_states_equal_without_context_and_last_changed( - states["thermostat.test"], hist["thermostat.test"] - ) + hist = history.get_significant_states( + hass, + zero, + four, + ["media_player.test", "thermostat.test"], + ) + assert_multiple_states_equal_without_context_and_last_changed( + states["media_player.test"], hist["media_player.test"] + ) + assert_multiple_states_equal_without_context_and_last_changed( + states["thermostat.test"], hist["thermostat.test"] + ) def test_get_significant_states_are_ordered( @@ -518,13 +553,16 @@ def test_get_significant_states_are_ordered( in the same order. """ hass = hass_recorder() - zero, four, _states = record_states(hass) - entity_ids = ["media_player.test", "media_player.test2"] - hist = history.get_significant_states(hass, zero, four, entity_ids) - assert list(hist.keys()) == entity_ids - entity_ids = ["media_player.test2", "media_player.test"] - hist = history.get_significant_states(hass, zero, four, entity_ids) - assert list(hist.keys()) == entity_ids + + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + zero, four, _states = record_states(hass) + entity_ids = ["media_player.test", "media_player.test2"] + hist = history.get_significant_states(hass, zero, four, entity_ids) + assert list(hist.keys()) == entity_ids + entity_ids = ["media_player.test2", "media_player.test"] + hist = history.get_significant_states(hass, zero, four, entity_ids) + assert list(hist.keys()) == entity_ids def test_get_significant_states_only( @@ -533,64 +571,70 @@ def test_get_significant_states_only( """Test significant states when significant_states_only is set.""" hass = hass_recorder() entity_id = "sensor.test" + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): - def set_state(state, **kwargs): - """Set the state.""" - hass.states.set(entity_id, state, **kwargs) - wait_recording_done(hass) - return hass.states.get(entity_id) + def set_state(state, **kwargs): + """Set the state.""" + hass.states.set(entity_id, state, **kwargs) + wait_recording_done(hass) + return hass.states.get(entity_id) - start = dt_util.utcnow() - timedelta(minutes=4) - points = [] - for i in range(1, 4): - points.append(start + timedelta(minutes=i)) + start = dt_util.utcnow() - timedelta(minutes=4) + points = [] + for i in range(1, 4): + points.append(start + timedelta(minutes=i)) - states = [] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): - set_state("123", attributes={"attribute": 10.64}) + states = [] + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start + ): + set_state("123", attributes={"attribute": 10.64}) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[0], - ): - # Attributes are different, state not - states.append(set_state("123", attributes={"attribute": 21.42})) + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", + return_value=points[0], + ): + # Attributes are different, state not + states.append(set_state("123", attributes={"attribute": 21.42})) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[1], - ): - # state is different, attributes not - states.append(set_state("32", attributes={"attribute": 21.42})) + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", + return_value=points[1], + ): + # state is different, attributes not + states.append(set_state("32", attributes={"attribute": 21.42})) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[2], - ): - # everything is different - states.append(set_state("412", attributes={"attribute": 54.23})) + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", + return_value=points[2], + ): + # everything is different + states.append(set_state("412", attributes={"attribute": 54.23})) - hist = history.get_significant_states(hass, start, significant_changes_only=True) + hist = history.get_significant_states( + hass, start, significant_changes_only=True + ) - assert len(hist[entity_id]) == 2 - assert not any( - state.last_updated == states[0].last_updated for state in hist[entity_id] - ) - assert any( - state.last_updated == states[1].last_updated for state in hist[entity_id] - ) - assert any( - state.last_updated == states[2].last_updated for state in hist[entity_id] - ) + assert len(hist[entity_id]) == 2 + assert not any( + state.last_updated == states[0].last_updated for state in hist[entity_id] + ) + assert any( + state.last_updated == states[1].last_updated for state in hist[entity_id] + ) + assert any( + state.last_updated == states[2].last_updated for state in hist[entity_id] + ) - hist = history.get_significant_states(hass, start, significant_changes_only=False) + hist = history.get_significant_states( + hass, start, significant_changes_only=False + ) - assert len(hist[entity_id]) == 3 - assert_multiple_states_equal_without_context_and_last_changed( - states, hist[entity_id] - ) + assert len(hist[entity_id]) == 3 + assert_multiple_states_equal_without_context_and_last_changed( + states, hist[entity_id] + ) def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: @@ -687,23 +731,25 @@ def test_state_changes_during_period_multiple_entities_single_test( generate incorrect results. """ hass = hass_recorder() - start = dt_util.utcnow() - test_entites = {f"sensor.{i}": str(i) for i in range(30)} - for entity_id, value in test_entites.items(): - hass.states.set(entity_id, value) + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + start = dt_util.utcnow() + test_entites = {f"sensor.{i}": str(i) for i in range(30)} + for entity_id, value in test_entites.items(): + hass.states.set(entity_id, value) - wait_recording_done(hass) - end = dt_util.utcnow() + wait_recording_done(hass) + end = dt_util.utcnow() - hist = history.state_changes_during_period(hass, start, end, None) - for entity_id, value in test_entites.items(): - hist[entity_id][0].state == value + hist = history.state_changes_during_period(hass, start, end, None) + for entity_id, value in test_entites.items(): + hist[entity_id][0].state == value - for entity_id, value in test_entites.items(): - hist = history.state_changes_during_period(hass, start, end, entity_id) - assert len(hist) == 1 - hist[entity_id][0].state == value + for entity_id, value in test_entites.items(): + hist = history.state_changes_during_period(hass, start, end, entity_id) + assert len(hist) == 1 + hist[entity_id][0].state == value - hist = history.state_changes_during_period(hass, start, end, None) - for entity_id, value in test_entites.items(): - hist[entity_id][0].state == value + hist = history.state_changes_during_period(hass, start, end, None) + for entity_id, value in test_entites.items(): + hist[entity_id][0].state == value diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index c46d77677a..d6162dd20e 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -43,6 +43,7 @@ from homeassistant.components.recorder.db_schema import ( RecorderRuns, StateAttributes, States, + StatesMeta, StatisticsRuns, ) from homeassistant.components.recorder.models import process_timestamp @@ -235,11 +236,14 @@ async def test_saving_state(recorder_mock: Recorder, hass: HomeAssistant) -> Non with session_scope(hass=hass) as session: db_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id + for db_state, db_state_attributes, states_meta in ( + session.query(States, StateAttributes, StatesMeta) + .outerjoin( + StateAttributes, States.attributes_id == StateAttributes.attributes_id + ) + .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) ): + db_state.entity_id = states_meta.entity_id db_states.append(db_state) state = db_state.to_native() state.attributes = db_state_attributes.to_native() @@ -273,11 +277,14 @@ async def test_saving_state_with_nul( with session_scope(hass=hass) as session: db_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id + for db_state, db_state_attributes, states_meta in ( + session.query(States, StateAttributes, StatesMeta) + .outerjoin( + StateAttributes, States.attributes_id == StateAttributes.attributes_id + ) + .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) ): + db_state.entity_id = states_meta.entity_id db_states.append(db_state) state = db_state.to_native() state.attributes = db_state_attributes.to_native() @@ -542,11 +549,16 @@ def _add_entities(hass, entity_ids): with session_scope(hass=hass) as session: states = [] - for state, state_attributes in session.query(States, StateAttributes).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id + for db_state, db_state_attributes, states_meta in ( + session.query(States, StateAttributes, StatesMeta) + .outerjoin( + StateAttributes, States.attributes_id == StateAttributes.attributes_id + ) + .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) ): - native_state = state.to_native() - native_state.attributes = state_attributes.to_native() + db_state.entity_id = states_meta.entity_id + native_state = db_state.to_native() + native_state.attributes = db_state_attributes.to_native() states.append(native_state) return states @@ -761,7 +773,11 @@ def test_saving_state_and_removing_entity( wait_recording_done(hass) with session_scope(hass=hass) as session: - states = list(session.query(States)) + states = list( + session.query(StatesMeta.entity_id, States.state) + .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) + .order_by(States.last_updated_ts) + ) assert len(states) == 3 assert states[0].entity_id == entity_id assert states[0].state == STATE_LOCKED @@ -784,11 +800,16 @@ def test_saving_state_with_oversized_attributes( states = [] with session_scope(hass=hass) as session: - for state, state_attributes in session.query(States, StateAttributes).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id + for db_state, db_state_attributes, states_meta in ( + session.query(States, StateAttributes, StatesMeta) + .outerjoin( + StateAttributes, States.attributes_id == StateAttributes.attributes_id + ) + .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) ): - native_state = state.to_native() - native_state.attributes = state_attributes.to_native() + db_state.entity_id = states_meta.entity_id + native_state = db_state.to_native() + native_state.attributes = db_state_attributes.to_native() states.append(native_state) assert "switch.too_big" in caplog.text @@ -1267,26 +1288,31 @@ def test_saving_sets_old_state(hass_recorder: Callable[..., HomeAssistant]) -> N """Test saving sets old state.""" hass = hass_recorder() - hass.states.set("test.one", "on", {}) - hass.states.set("test.two", "on", {}) + hass.states.set("test.one", "s1", {}) + hass.states.set("test.two", "s2", {}) wait_recording_done(hass) - hass.states.set("test.one", "off", {}) - hass.states.set("test.two", "off", {}) + hass.states.set("test.one", "s3", {}) + hass.states.set("test.two", "s4", {}) wait_recording_done(hass) with session_scope(hass=hass) as session: - states = list(session.query(States)) + states = list( + session.query( + StatesMeta.entity_id, States.state_id, States.old_state_id, States.state + ).outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) + ) assert len(states) == 4 + states_by_state = {state.state: state for state in states} - assert states[0].entity_id == "test.one" - assert states[1].entity_id == "test.two" - assert states[2].entity_id == "test.one" - assert states[3].entity_id == "test.two" + assert states_by_state["s1"].entity_id == "test.one" + assert states_by_state["s2"].entity_id == "test.two" + assert states_by_state["s3"].entity_id == "test.one" + assert states_by_state["s4"].entity_id == "test.two" - assert states[0].old_state_id is None - assert states[1].old_state_id is None - assert states[2].old_state_id == states[0].state_id - assert states[3].old_state_id == states[1].state_id + assert states_by_state["s1"].old_state_id is None + assert states_by_state["s2"].old_state_id is None + assert states_by_state["s3"].old_state_id == states_by_state["s1"].state_id + assert states_by_state["s4"].old_state_id == states_by_state["s2"].state_id def test_saving_state_with_serializable_data( @@ -1296,21 +1322,25 @@ def test_saving_state_with_serializable_data( hass = hass_recorder() hass.bus.fire("bad_event", {"fail": CannotSerializeMe()}) - hass.states.set("test.one", "on", {"fail": CannotSerializeMe()}) + hass.states.set("test.one", "s1", {"fail": CannotSerializeMe()}) wait_recording_done(hass) - hass.states.set("test.two", "on", {}) + hass.states.set("test.two", "s2", {}) wait_recording_done(hass) - hass.states.set("test.two", "off", {}) + hass.states.set("test.two", "s3", {}) wait_recording_done(hass) with session_scope(hass=hass) as session: - states = list(session.query(States)) + states = list( + session.query( + StatesMeta.entity_id, States.state_id, States.old_state_id, States.state + ).outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) + ) assert len(states) == 2 - - assert states[0].entity_id == "test.two" - assert states[1].entity_id == "test.two" - assert states[0].old_state_id is None - assert states[1].old_state_id == states[0].state_id + states_by_state = {state.state: state for state in states} + assert states_by_state["s2"].entity_id == "test.two" + assert states_by_state["s3"].entity_id == "test.two" + assert states_by_state["s2"].old_state_id is None + assert states_by_state["s3"].old_state_id == states_by_state["s2"].state_id assert "State is not JSON serializable" in caplog.text @@ -1442,6 +1472,7 @@ def test_service_disable_states_not_recording( db_states = list(session.query(States)) assert len(db_states) == 1 assert db_states[0].event_id is None + db_states[0].entity_id = "test.two" assert ( db_states[0].to_native().as_dict() == _state_with_context(hass, "test.two").as_dict() @@ -1554,6 +1585,7 @@ async def test_database_corruption_while_running( with session_scope(hass=hass) as session: db_states = list(session.query(States)) assert len(db_states) == 1 + db_states[0].entity_id = "test.two" assert db_states[0].event_id is None return db_states[0].to_native() @@ -1868,9 +1900,7 @@ def test_deduplication_state_attributes_inside_commit_interval( with session_scope(hass=hass) as session: states = list( - session.query(States) - .filter(States.entity_id == entity_id) - .outerjoin( + session.query(States).outerjoin( StateAttributes, (States.attributes_id == StateAttributes.attributes_id) ) ) @@ -1895,7 +1925,7 @@ async def test_async_block_till_done( def _fetch_states(): with session_scope(hass=hass) as session: - return list(session.query(States).filter(States.entity_id == entity_id)) + return list(session.query(States)) await async_block_recorder(hass, 0.1) await instance.async_block_till_done() @@ -2098,11 +2128,14 @@ async def test_excluding_attributes_by_integration( with session_scope(hass=hass) as session: db_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id + for db_state, db_state_attributes, states_meta in ( + session.query(States, StateAttributes, StatesMeta) + .outerjoin( + StateAttributes, States.attributes_id == StateAttributes.attributes_id + ) + .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) ): + db_state.entity_id = states_meta.entity_id db_states.append(db_state) state = db_state.to_native() state.attributes = db_state_attributes.to_native() diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index 062013e728..060d1bcb74 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -28,10 +28,13 @@ from homeassistant.components.recorder.db_schema import ( EventTypes, RecorderRuns, States, + StatesMeta, ) from homeassistant.components.recorder.queries import select_event_type_ids from homeassistant.components.recorder.tasks import ( ContextIDMigrationTask, + EntityIDMigrationTask, + EntityIDPostMigrationTask, EventTypeIDMigrationTask, ) from homeassistant.components.recorder.util import session_scope @@ -54,10 +57,13 @@ ORIG_TZ = dt_util.DEFAULT_TIME_ZONE def _get_native_states(hass, entity_id): with session_scope(hass=hass) as session: - return [ - state.to_native() - for state in session.query(States).filter(States.entity_id == entity_id) - ] + instance = recorder.get_instance(hass) + metadata_id = instance.states_meta_manager.get(entity_id, session) + states = [] + for dbstate in session.query(States).filter(States.metadata_id == metadata_id): + dbstate.entity_id = entity_id + states.append(dbstate.to_native()) + return states async def test_schema_update_calls(recorder_db_url: str, hass: HomeAssistant) -> None: @@ -764,3 +770,121 @@ async def test_migrate_event_type_ids( events_by_type = await instance.async_add_executor_job(_fetch_migrated_events) assert len(events_by_type["event_type_one"]) == 2 assert len(events_by_type["event_type_two"]) == 1 + + +@pytest.mark.parametrize("enable_migrate_entity_ids", [True]) +async def test_migrate_entity_ids( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test we can migrate entity_ids to the StatesMeta table.""" + instance = await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + def _insert_events(): + with session_scope(hass=hass) as session: + session.add_all( + ( + States( + entity_id="sensor.one", + state="one_1", + last_updated_ts=1.452529, + ), + States( + entity_id="sensor.two", + state="two_2", + last_updated_ts=2.252529, + ), + States( + entity_id="sensor.two", + state="two_1", + last_updated_ts=3.152529, + ), + ) + ) + + await instance.async_add_executor_job(_insert_events) + + await async_wait_recording_done(hass) + # This is a threadsafe way to add a task to the recorder + instance.queue_task(EntityIDMigrationTask()) + await async_recorder_block_till_done(hass) + + def _fetch_migrated_states(): + with session_scope(hass=hass) as session: + states = ( + session.query( + States.state, + States.metadata_id, + States.last_updated_ts, + StatesMeta.entity_id, + ) + .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) + .all() + ) + assert len(states) == 3 + result = {} + for state in states: + result.setdefault(state.entity_id, []).append( + { + "state_id": state.entity_id, + "last_updated_ts": state.last_updated_ts, + "state": state.state, + } + ) + return result + + states_by_entity_id = await instance.async_add_executor_job(_fetch_migrated_states) + assert len(states_by_entity_id["sensor.two"]) == 2 + assert len(states_by_entity_id["sensor.one"]) == 1 + + +@pytest.mark.parametrize("enable_migrate_entity_ids", [True]) +async def test_post_migrate_entity_ids( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test we can migrate entity_ids to the StatesMeta table.""" + instance = await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + def _insert_events(): + with session_scope(hass=hass) as session: + session.add_all( + ( + States( + entity_id="sensor.one", + state="one_1", + last_updated_ts=1.452529, + ), + States( + entity_id="sensor.two", + state="two_2", + last_updated_ts=2.252529, + ), + States( + entity_id="sensor.two", + state="two_1", + last_updated_ts=3.152529, + ), + ) + ) + + await instance.async_add_executor_job(_insert_events) + + await async_wait_recording_done(hass) + # This is a threadsafe way to add a task to the recorder + instance.queue_task(EntityIDPostMigrationTask()) + await async_recorder_block_till_done(hass) + + def _fetch_migrated_states(): + with session_scope(hass=hass) as session: + states = session.query( + States.state, + States.entity_id, + ).all() + assert len(states) == 3 + return {state.state: state.entity_id for state in states} + + states_by_state = await instance.async_add_executor_job(_fetch_migrated_states) + assert states_by_state["one_1"] is None + assert states_by_state["two_2"] is None + assert states_by_state["two_1"] is None diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index fcabb2e83a..b865af68df 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -9,6 +9,7 @@ from sqlalchemy.exc import DatabaseError, OperationalError from sqlalchemy.orm.session import Session from homeassistant.components import recorder +from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.const import ( SQLITE_MAX_BIND_VARS, SupportedDialect, @@ -20,6 +21,7 @@ from homeassistant.components.recorder.db_schema import ( RecorderRuns, StateAttributes, States, + StatesMeta, StatisticsRuns, StatisticsShortTerm, ) @@ -670,6 +672,31 @@ async def test_purge_cutoff_date( assert state_attributes.count() == 0 +def _convert_pending_states_to_meta(instance: Recorder, session: Session) -> None: + """Convert pending states to use states_metadata.""" + entity_ids: set[str] = set() + states: set[States] = set() + for object in session: + states_meta_objects: dict[str, StatesMeta] = {} + if isinstance(object, States): + entity_ids.add(object.entity_id) + states.add(object) + + entity_id_to_metadata_ids = instance.states_meta_manager.get_many( + entity_ids, session + ) + + for state in states: + entity_id = state.entity_id + state.entity_id = None + if metadata_id := entity_id_to_metadata_ids.get(entity_id): + state.metadata_id = metadata_id + continue + if entity_id not in states_meta_objects: + states_meta_objects[entity_id] = StatesMeta(entity_id=entity_id) + state.states_meta_rel = states_meta_objects[entity_id] + + @pytest.mark.parametrize("use_sqlite", (True, False), indirect=True) async def test_purge_filtered_states( async_setup_recorder_instance: RecorderInstanceGenerator, @@ -762,6 +789,7 @@ async def test_purge_filtered_states( time_fired_ts=dt_util.utc_to_timestamp(timestamp), ) ) + _convert_pending_states_to_meta(instance, session) service_data = {"keep_days": 10} _add_db_entries(hass) @@ -815,8 +843,10 @@ async def test_purge_filtered_states( events_keep = session.query(Events).filter(Events.event_type == "EVENT_KEEP") assert events_keep.count() == 1 - states_sensor_excluded = session.query(States).filter( - States.entity_id == "sensor.excluded" + states_sensor_excluded = ( + session.query(States) + .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) + .filter(StatesMeta.entity_id == "sensor.excluded") ) assert states_sensor_excluded.count() == 0 @@ -880,6 +910,7 @@ async def test_purge_filtered_states_to_empty( timestamp, event_id * days, ) + _convert_pending_states_to_meta(instance, session) service_data = {"keep_days": 10} _add_db_entries(hass) @@ -955,6 +986,7 @@ async def test_purge_without_state_attributes_filtered_states_to_empty( time_fired_ts=dt_util.utc_to_timestamp(timestamp), ) ) + _convert_pending_states_to_meta(instance, session) service_data = {"keep_days": 10} _add_db_entries(hass) @@ -1179,7 +1211,7 @@ async def test_purge_entities( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant ) -> None: """Test purging of specific entities.""" - await async_setup_recorder_instance(hass) + instance = await async_setup_recorder_instance(hass) async def _purge_entities(hass, entity_ids, domains, entity_globs): service_data = { @@ -1227,6 +1259,7 @@ async def test_purge_entities( timestamp, event_id * days, ) + _convert_pending_states_to_meta(instance, session) def _add_keep_records(hass: HomeAssistant) -> None: with session_scope(hass=hass) as session: @@ -1240,6 +1273,7 @@ async def test_purge_entities( timestamp, event_id, ) + _convert_pending_states_to_meta(instance, session) _add_purge_records(hass) _add_keep_records(hass) @@ -1255,8 +1289,10 @@ async def test_purge_entities( states = session.query(States) assert states.count() == 10 - states_sensor_kept = session.query(States).filter( - States.entity_id == "sensor.keep" + states_sensor_kept = ( + session.query(States) + .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) + .filter(StatesMeta.entity_id == "sensor.keep") ) assert states_sensor_kept.count() == 10 @@ -1285,8 +1321,10 @@ async def test_purge_entities( states = session.query(States) assert states.count() == 10 - states_sensor_kept = session.query(States).filter( - States.entity_id == "sensor.keep" + states_sensor_kept = ( + session.query(States) + .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) + .filter(StatesMeta.entity_id == "sensor.keep") ) assert states_sensor_kept.count() == 10 @@ -1796,3 +1834,103 @@ async def test_purge_old_events_purges_the_event_type_ids( assert finished assert events.count() == 0 assert event_types.count() == 0 + + +async def test_purge_old_states_purges_the_state_metadata_ids( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test deleting old states purges state metadata_ids.""" + instance = await async_setup_recorder_instance(hass) + assert instance.states_meta_manager.active is True + + utcnow = dt_util.utcnow() + five_days_ago = utcnow - timedelta(days=5) + eleven_days_ago = utcnow - timedelta(days=11) + far_past = utcnow - timedelta(days=1000) + + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + def _insert_states(): + with session_scope(hass=hass) as session: + states_meta_sensor_one = StatesMeta(entity_id="sensor.one") + states_meta_sensor_two = StatesMeta(entity_id="sensor.two") + states_meta_sensor_three = StatesMeta(entity_id="sensor.three") + states_meta_sensor_unused = StatesMeta(entity_id="sensor.unused") + session.add_all( + ( + states_meta_sensor_one, + states_meta_sensor_two, + states_meta_sensor_three, + states_meta_sensor_unused, + ) + ) + session.flush() + for _ in range(5): + for event_id in range(6): + if event_id < 2: + timestamp = eleven_days_ago + metadata_id = states_meta_sensor_one.metadata_id + elif event_id < 4: + timestamp = five_days_ago + metadata_id = states_meta_sensor_two.metadata_id + else: + timestamp = utcnow + metadata_id = states_meta_sensor_three.metadata_id + + session.add( + States( + metadata_id=metadata_id, + state="any", + last_updated_ts=dt_util.utc_to_timestamp(timestamp), + ) + ) + return instance.states_meta_manager.get_many( + ["sensor.one", "sensor.two", "sensor.three", "sensor.unused"], + session, + ) + + entity_id_to_metadata_id = await instance.async_add_executor_job(_insert_states) + test_metadata_ids = entity_id_to_metadata_id.values() + with session_scope(hass=hass) as session: + states = session.query(States).where(States.metadata_id.in_(test_metadata_ids)) + states_meta = session.query(StatesMeta).where( + StatesMeta.metadata_id.in_(test_metadata_ids) + ) + + assert states.count() == 30 + assert states_meta.count() == 4 + + # run purge_old_data() + finished = purge_old_data( + instance, + far_past, + repack=False, + ) + assert finished + assert states.count() == 30 + # We should remove the unused entity_id + assert states_meta.count() == 3 + + assert "sensor.unused" not in instance.event_type_manager._id_map + + # we should only have 10 states left since + # only one event type was recorded now + finished = purge_old_data( + instance, + utcnow, + repack=False, + ) + assert finished + assert states.count() == 10 + assert states_meta.count() == 1 + + # Purge everything + finished = purge_old_data( + instance, + utcnow + timedelta(seconds=1), + repack=False, + ) + assert finished + assert states.count() == 0 + assert states_meta.count() == 0 diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 78302f7427..38622bd45a 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -18,7 +18,7 @@ from homeassistant.components import recorder from homeassistant.components.recorder import util from homeassistant.components.recorder.const import DOMAIN, SQLITE_URL_PREFIX from homeassistant.components.recorder.db_schema import RecorderRuns -from homeassistant.components.recorder.history.legacy import ( +from homeassistant.components.recorder.history.modern import ( _get_single_entity_states_stmt, ) from homeassistant.components.recorder.models import ( @@ -908,26 +908,25 @@ def test_execute_stmt_lambda_element( with session_scope(hass=hass) as session: # No time window, we always get a list - stmt = _get_single_entity_states_stmt( - instance.schema_version, dt_util.utcnow(), "sensor.on", False - ) + metadata_id = instance.states_meta_manager.get("sensor.on", session) + stmt = _get_single_entity_states_stmt(dt_util.utcnow(), metadata_id, False) rows = util.execute_stmt_lambda_element(session, stmt) assert isinstance(rows, list) assert rows[0].state == new_state.state - assert rows[0].entity_id == new_state.entity_id + assert rows[0].metadata_id == metadata_id # Time window >= 2 days, we get a ChunkedIteratorResult rows = util.execute_stmt_lambda_element(session, stmt, now, one_week_from_now) assert isinstance(rows, ChunkedIteratorResult) row = next(rows) assert row.state == new_state.state - assert row.entity_id == new_state.entity_id + assert row.metadata_id == metadata_id # Time window < 2 days, we get a list rows = util.execute_stmt_lambda_element(session, stmt, now, tomorrow) assert isinstance(rows, list) assert rows[0].state == new_state.state - assert rows[0].entity_id == new_state.entity_id + assert rows[0].metadata_id == metadata_id with patch.object(session, "execute", MockExecutor): rows = util.execute_stmt_lambda_element(session, stmt, now, tomorrow) diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index 6fe810758f..4732299fe4 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -1,5 +1,6 @@ """The tests for recorder platform migrating data from v30.""" # pylint: disable=invalid-name +import asyncio from datetime import timedelta import importlib import sys @@ -15,12 +16,12 @@ from homeassistant.components.recorder.queries import select_event_type_ids from homeassistant.components.recorder.util import session_scope from homeassistant.core import EVENT_STATE_CHANGED, Event, EventOrigin, State from homeassistant.helpers import recorder as recorder_helper -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from .common import wait_recording_done +from .common import async_wait_recording_done -from tests.common import get_test_home_assistant +from tests.common import async_test_home_assistant ORIG_TZ = dt_util.DEFAULT_TIME_ZONE @@ -50,7 +51,7 @@ def _create_engine_test(*args, **kwargs): return engine -def test_migrate_times(caplog: pytest.LogCaptureFixture, tmpdir) -> None: +async def test_migrate_times(caplog: pytest.LogCaptureFixture, tmpdir) -> None: """Test we can migrate times.""" test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" @@ -88,7 +89,9 @@ def test_migrate_times(caplog: pytest.LogCaptureFixture, tmpdir) -> None: with patch.object(recorder, "db_schema", old_db_schema), patch.object( recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION - ), patch.object(core, "EventTypes", old_db_schema.EventTypes), patch.object( + ), patch.object(core, "StatesMeta", old_db_schema.StatesMeta), patch.object( + core, "EventTypes", old_db_schema.EventTypes + ), patch.object( core, "EventData", old_db_schema.EventData ), patch.object( core, "States", old_db_schema.States @@ -96,46 +99,77 @@ def test_migrate_times(caplog: pytest.LogCaptureFixture, tmpdir) -> None: core, "Events", old_db_schema.Events ), patch( CREATE_ENGINE_TARGET, new=_create_engine_test + ), patch( + "homeassistant.components.recorder.Recorder._migrate_context_ids", + ), patch( + "homeassistant.components.recorder.Recorder._migrate_event_type_ids", + ), patch( + "homeassistant.components.recorder.Recorder._migrate_entity_ids", ): - hass = get_test_home_assistant() + hass = await async_test_home_assistant(asyncio.get_running_loop()) recorder_helper.async_initialize_recorder(hass) - setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) - wait_recording_done(hass) - wait_recording_done(hass) + assert await async_setup_component( + hass, "recorder", {"recorder": {"db_url": dburl}} + ) + await hass.async_block_till_done() + await async_wait_recording_done(hass) + await async_wait_recording_done(hass) - with session_scope(hass=hass) as session: - session.add(old_db_schema.Events.from_event(custom_event)) - session.add(old_db_schema.States.from_event(state_changed_event)) + def _add_data(): + with session_scope(hass=hass) as session: + session.add(old_db_schema.Events.from_event(custom_event)) + session.add(old_db_schema.States.from_event(state_changed_event)) - hass.stop() + await recorder.get_instance(hass).async_add_executor_job(_add_data) + await hass.async_block_till_done() + + await hass.async_stop() dt_util.DEFAULT_TIME_ZONE = ORIG_TZ # Test that the duplicates are removed during migration from schema 23 - hass = get_test_home_assistant() + hass = await async_test_home_assistant(asyncio.get_running_loop()) recorder_helper.async_initialize_recorder(hass) - setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) - hass.start() - wait_recording_done(hass) - wait_recording_done(hass) - with session_scope(hass=hass) as session: - result = list( - session.query(recorder.db_schema.Events).filter( - recorder.db_schema.Events.event_type_id.in_( - select_event_type_ids(("custom_event",)) + assert await async_setup_component( + hass, "recorder", {"recorder": {"db_url": dburl}} + ) + await hass.async_block_till_done() + + # We need to wait for all the migration tasks to complete + # before we can check the database. + for _ in range(5): + await async_wait_recording_done(hass) + + def _get_test_data_from_db(): + with session_scope(hass=hass) as session: + events_result = list( + session.query(recorder.db_schema.Events).filter( + recorder.db_schema.Events.event_type_id.in_( + select_event_type_ids(("custom_event",)) + ) ) ) - ) - assert len(result) == 1 - assert result[0].time_fired_ts == now_timestamp - result = list( - session.query(recorder.db_schema.States).where( - recorder.db_schema.States.entity_id == "sensor.test" + states_result = list( + session.query(recorder.db_schema.States) + .join( + recorder.db_schema.StatesMeta, + recorder.db_schema.States.metadata_id + == recorder.db_schema.StatesMeta.metadata_id, + ) + .where(recorder.db_schema.StatesMeta.entity_id == "sensor.test") ) - ) - assert len(result) == 1 - assert result[0].last_changed_ts == one_second_past_timestamp - assert result[0].last_updated_ts == now_timestamp + session.expunge_all() + return events_result, states_result - hass.stop() + events_result, states_result = await recorder.get_instance( + hass + ).async_add_executor_job(_get_test_data_from_db) + + assert len(events_result) == 1 + assert events_result[0].time_fired_ts == now_timestamp + assert len(states_result) == 1 + assert states_result[0].last_changed_ts == one_second_past_timestamp + assert states_result[0].last_updated_ts == now_timestamp + + await hass.async_stop() dt_util.DEFAULT_TIME_ZONE = ORIG_TZ diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 55eda6c03b..ae044c535b 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -18,6 +18,7 @@ from homeassistant.components.recorder import ( from homeassistant.components.recorder.db_schema import ( StateAttributes, States, + StatesMeta, StatisticsMeta, ) from homeassistant.components.recorder.models import ( @@ -4735,11 +4736,15 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) def _fetch_states() -> list[State]: with session_scope(hass=hass) as session: native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id + for db_state, db_state_attributes, db_states_meta in ( + session.query(States, StateAttributes, StatesMeta) + .outerjoin( + StateAttributes, + States.attributes_id == StateAttributes.attributes_id, + ) + .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) ): + db_state.entity_id = db_states_meta.entity_id state = db_state.to_native() state.attributes = db_state_attributes.to_native() native_states.append(state) diff --git a/tests/conftest.py b/tests/conftest.py index 25ee814382..4f7b553955 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1158,6 +1158,16 @@ def enable_migrate_event_type_ids() -> bool: return False +@pytest.fixture +def enable_migrate_entity_ids() -> bool: + """Fixture to control enabling of recorder's entity_id migration. + + To enable context id migration, tests can be marked with: + @pytest.mark.parametrize("enable_migrate_entity_ids", [True]) + """ + return False + + @pytest.fixture def recorder_config() -> dict[str, Any] | None: """Fixture to override recorder config. @@ -1221,6 +1231,9 @@ def hass_recorder( enable_nightly_purge: bool, enable_statistics: bool, enable_statistics_table_validation: bool, + enable_migrate_context_ids: bool, + enable_migrate_event_type_ids: bool, + enable_migrate_entity_ids: bool, hass_storage, ) -> Generator[Callable[..., HomeAssistant], None, None]: """Home Assistant fixture with in-memory recorder.""" @@ -1237,6 +1250,17 @@ def hass_recorder( if enable_statistics_table_validation else itertools.repeat(set()) ) + migrate_context_ids = ( + recorder.Recorder._migrate_context_ids if enable_migrate_context_ids else None + ) + migrate_event_type_ids = ( + recorder.Recorder._migrate_event_type_ids + if enable_migrate_event_type_ids + else None + ) + migrate_entity_ids = ( + recorder.Recorder._migrate_entity_ids if enable_migrate_entity_ids else None + ) with patch( "homeassistant.components.recorder.Recorder.async_nightly_tasks", side_effect=nightly, @@ -1249,6 +1273,18 @@ def hass_recorder( "homeassistant.components.recorder.migration.statistics_validate_db_schema", side_effect=stats_validate, autospec=True, + ), patch( + "homeassistant.components.recorder.Recorder._migrate_context_ids", + side_effect=migrate_context_ids, + autospec=True, + ), patch( + "homeassistant.components.recorder.Recorder._migrate_event_type_ids", + side_effect=migrate_event_type_ids, + autospec=True, + ), patch( + "homeassistant.components.recorder.Recorder._migrate_entity_ids", + side_effect=migrate_entity_ids, + autospec=True, ): def setup_recorder(config: dict[str, Any] | None = None) -> HomeAssistant: @@ -1302,6 +1338,7 @@ async def async_setup_recorder_instance( enable_statistics_table_validation: bool, enable_migrate_context_ids: bool, enable_migrate_event_type_ids: bool, + enable_migrate_entity_ids: bool, ) -> AsyncGenerator[RecorderInstanceGenerator, None]: """Yield callable to setup recorder instance.""" # pylint: disable-next=import-outside-toplevel @@ -1325,6 +1362,9 @@ async def async_setup_recorder_instance( if enable_migrate_event_type_ids else None ) + migrate_entity_ids = ( + recorder.Recorder._migrate_entity_ids if enable_migrate_entity_ids else None + ) with patch( "homeassistant.components.recorder.Recorder.async_nightly_tasks", side_effect=nightly, @@ -1345,6 +1385,10 @@ async def async_setup_recorder_instance( "homeassistant.components.recorder.Recorder._migrate_event_type_ids", side_effect=migrate_event_type_ids, autospec=True, + ), patch( + "homeassistant.components.recorder.Recorder._migrate_entity_ids", + side_effect=migrate_entity_ids, + autospec=True, ): async def async_setup_recorder( From e809b636e6e18cc88eb2193a6e592f46a3139c43 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Mar 2023 12:44:47 -1000 Subject: [PATCH 0855/2810] Bump rflink to 0.0.65 for python 3.11 (#89601) --- homeassistant/components/rflink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rflink/manifest.json b/homeassistant/components/rflink/manifest.json index 8b9c9165b2..0d0cf218cd 100644 --- a/homeassistant/components/rflink/manifest.json +++ b/homeassistant/components/rflink/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/rflink", "iot_class": "assumed_state", "loggers": ["rflink"], - "requirements": ["rflink==0.0.63"] + "requirements": ["rflink==0.0.65"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7cf150b811..742344b316 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2246,7 +2246,7 @@ restrictedpython==6.0 rfk101py==0.0.1 # homeassistant.components.rflink -rflink==0.0.63 +rflink==0.0.65 # homeassistant.components.ring ring_doorbell==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ac6045d58b..d631b0ecda 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1600,7 +1600,7 @@ reolink-aio==0.5.3 restrictedpython==6.0 # homeassistant.components.rflink -rflink==0.0.63 +rflink==0.0.65 # homeassistant.components.ring ring_doorbell==0.7.2 From 459ea048ba12c42f4abe1edcc46cb3f5bd8a748a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Mar 2023 14:07:05 -1000 Subject: [PATCH 0856/2810] Fix old indices never being removed with PostgreSQL (#89599) --- .../components/recorder/db_schema.py | 2 +- .../components/recorder/migration.py | 159 ++++++++++++------ .../components/recorder/test_v32_migration.py | 14 +- 3 files changed, 117 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 7aecf2a57c..5e0a78dad3 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -68,7 +68,7 @@ class Base(DeclarativeBase): """Base class for tables.""" -SCHEMA_VERSION = 38 +SCHEMA_VERSION = 39 _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 392a829cb8..c13a031457 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -180,7 +180,9 @@ def migrate_schema( with session_scope(session=session_maker()) as session: session.add(SchemaChanges(schema_version=new_version)) - _LOGGER.info("Upgrade to version %s done", new_version) + # Log at the same level as the long schema changes + # so its clear that the upgrade is done + _LOGGER.warning("Upgrade to version %s done", new_version) if schema_errors := schema_status.statistics_schema_errors: _LOGGER.warning( @@ -215,11 +217,12 @@ def _create_index( _LOGGER.debug("Creating %s index", index_name) _LOGGER.warning( ( - "Adding index `%s` to database. Note: this can take several " + "Adding index `%s` to table `%s`. Note: this can take several " "minutes on large databases and slow computers. Please " "be patient!" ), index_name, + table_name, ) with session_scope(session=session_maker()) as session: try: @@ -250,51 +253,74 @@ def _drop_index( string here is generated from the method parameters without sanitizing. DO NOT USE THIS FUNCTION IN ANY OPERATION THAT TAKES USER INPUT. """ - _LOGGER.debug("Dropping index %s from table %s", index_name, table_name) + _LOGGER.warning( + ( + "Dropping index `%s` from table `%s`. Note: this can take several " + "minutes on large databases and slow computers. Please " + "be patient!" + ), + index_name, + table_name, + ) success = False # Engines like DB2/Oracle - with session_scope(session=session_maker()) as session: - try: - connection = session.connection() - connection.execute(text(f"DROP INDEX {index_name}")) - except SQLAlchemyError: - pass - else: - success = True + with session_scope(session=session_maker()) as session, contextlib.suppress( + SQLAlchemyError + ): + connection = session.connection() + connection.execute(text(f"DROP INDEX {index_name}")) + success = True # Engines like SQLite, SQL Server if not success: - with session_scope(session=session_maker()) as session: - try: - connection = session.connection() - connection.execute( - text( - "DROP INDEX {table}.{index}".format( - index=index_name, table=table_name - ) + with session_scope(session=session_maker()) as session, contextlib.suppress( + SQLAlchemyError + ): + connection = session.connection() + connection.execute( + text( + "DROP INDEX {table}.{index}".format( + index=index_name, table=table_name ) ) - except SQLAlchemyError: - pass - else: - success = True + ) + success = True if not success: # Engines like MySQL, MS Access - with session_scope(session=session_maker()) as session: - try: - connection = session.connection() - connection.execute( - text( - "DROP INDEX {index} ON {table}".format( - index=index_name, table=table_name - ) + with session_scope(session=session_maker()) as session, contextlib.suppress( + SQLAlchemyError + ): + connection = session.connection() + connection.execute( + text( + "DROP INDEX {index} ON {table}".format( + index=index_name, table=table_name ) ) - except SQLAlchemyError: - pass - else: + ) + success = True + + if not success: + # Engines like postgresql may have a prefix + # ex idx_16532_ix_events_event_type_time_fired + with session_scope(session=session_maker()) as session, contextlib.suppress( + SQLAlchemyError + ): + connection = session.connection() + inspector = sqlalchemy.inspect(connection) + indexes = inspector.get_indexes(table_name) + if index_to_drop := next( + ( + possible_index["name"] + for possible_index in indexes + if possible_index["name"] + and possible_index["name"].endswith(f"_{index_name}") + ), + None, + ): + connection.execute(text(f"DROP INDEX {index_to_drop}")) success = True if success: @@ -306,26 +332,9 @@ def _drop_index( if quiet: return - if index_name in ( - "ix_states_entity_id", - "ix_states_context_parent_id", - "ix_statistics_short_term_statistic_id_start", - "ix_statistics_statistic_id_start", - ): - # ix_states_context_parent_id was only there on nightly so we do not want - # to generate log noise or issues about it. - # - # ix_states_entity_id was only there for users who upgraded from schema - # version 8 or earlier. Newer installs will not have it so we do not - # want to generate log noise or issues about it. - # - # ix_statistics_short_term_statistic_id_start and ix_statistics_statistic_id_start - # were only there for users who upgraded from schema version 23 or earlier. - return - _LOGGER.warning( ( - "Failed to drop index %s from table %s. Schema " + "Failed to drop index `%s` from table `%s`. Schema " "Migration will continue; this is not a " "critical operation" ), @@ -902,7 +911,8 @@ def _apply_update( # noqa: C901 # This index is no longer used and can cause MySQL to use the wrong index # when querying the states table. # https://github.com/home-assistant/core/issues/83787 - _drop_index(session_maker, "states", "ix_states_entity_id") + # There was an index cleanup here but its now done in schema 39 + pass elif new_version == 34: # Once we require SQLite >= 3.35.5, we should drop the columns: # ALTER TABLE statistics DROP COLUMN created @@ -964,11 +974,14 @@ def _apply_update( # noqa: C901 elif new_version == 35: # Migration is done in two steps to ensure we can start using # the new columns before we wipe the old ones. - _drop_index(session_maker, "statistics", "ix_statistics_statistic_id_start") + _drop_index( + session_maker, "statistics", "ix_statistics_statistic_id_start", quiet=True + ) _drop_index( session_maker, "statistics_short_term", "ix_statistics_short_term_statistic_id_start", + quiet=True, ) # ix_statistics_start and ix_statistics_statistic_id_start are still used # for the post migration cleanup and can be removed in a future version. @@ -994,6 +1007,40 @@ def _apply_update( # noqa: C901 _add_columns(session_maker, "states", [f"metadata_id {big_int}"]) _create_index(session_maker, "states", "ix_states_metadata_id") _create_index(session_maker, "states", "ix_states_metadata_id_last_updated_ts") + elif new_version == 39: + # Dropping indexes with PostgreSQL never worked correctly if there was a prefix + # so we need to cleanup leftover indexes. + _drop_index( + session_maker, "events", "ix_events_event_type_time_fired_ts", quiet=True + ) + _drop_index(session_maker, "events", "ix_events_event_type", quiet=True) + _drop_index( + session_maker, "events", "ix_events_event_type_time_fired", quiet=True + ) + _drop_index(session_maker, "events", "ix_events_time_fired", quiet=True) + _drop_index(session_maker, "events", "ix_events_context_user_id", quiet=True) + _drop_index(session_maker, "events", "ix_events_context_parent_id", quiet=True) + _drop_index( + session_maker, "states", "ix_states_entity_id_last_updated", quiet=True + ) + _drop_index(session_maker, "states", "ix_states_last_updated", quiet=True) + _drop_index(session_maker, "states", "ix_states_entity_id", quiet=True) + _drop_index(session_maker, "states", "ix_states_context_user_id", quiet=True) + _drop_index(session_maker, "states", "ix_states_context_parent_id", quiet=True) + _drop_index(session_maker, "states", "ix_states_created_domain", quiet=True) + _drop_index(session_maker, "states", "ix_states_entity_id_created", quiet=True) + _drop_index(session_maker, "states", "states__state_changes", quiet=True) + _drop_index(session_maker, "states", "states__significant_changes", quiet=True) + _drop_index(session_maker, "states", "ix_states_entity_id_created", quiet=True) + _drop_index( + session_maker, "statistics", "ix_statistics_statistic_id_start", quiet=True + ) + _drop_index( + session_maker, + "statistics_short_term", + "ix_statistics_short_term_statistic_id_start", + quiet=True, + ) else: raise ValueError(f"No schema migration defined for version {new_version}") @@ -1297,8 +1344,8 @@ def migrate_context_ids(instance: Recorder) -> bool: is_done = not (events or states) if is_done: - _drop_index(session_maker, "events", "ix_events_context_id", quiet=True) - _drop_index(session_maker, "states", "ix_states_context_id", quiet=True) + _drop_index(session_maker, "events", "ix_events_context_id") + _drop_index(session_maker, "states", "ix_states_context_id") _LOGGER.debug("Migrating context_ids to binary format: done=%s", is_done) return is_done diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index 4732299fe4..50029e56b2 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -7,7 +7,7 @@ import sys from unittest.mock import patch import pytest -from sqlalchemy import create_engine +from sqlalchemy import create_engine, inspect from sqlalchemy.orm import Session from homeassistant.components import recorder @@ -171,5 +171,17 @@ async def test_migrate_times(caplog: pytest.LogCaptureFixture, tmpdir) -> None: assert states_result[0].last_changed_ts == one_second_past_timestamp assert states_result[0].last_updated_ts == now_timestamp + def _get_events_index_names(): + with session_scope(hass=hass) as session: + return inspect(session.connection()).get_indexes("events") + + indexes = await recorder.get_instance(hass).async_add_executor_job( + _get_events_index_names + ) + index_names = {index["name"] for index in indexes} + + assert "ix_events_context_id_bin" in index_names + assert "ix_events_context_id" not in index_names + await hass.async_stop() dt_util.DEFAULT_TIME_ZONE = ORIG_TZ From 41b4c5532de0a59de32ff87c8d8179f4732dbb4f Mon Sep 17 00:00:00 2001 From: MarkGodwin Date: Mon, 13 Mar 2023 01:26:34 +0000 Subject: [PATCH 0857/2810] Add Update entities to TP-Link Omada integration (#89562) * Bump tplink-omada * Add omada firmware updates * Excluded from code coverage * Fixed entity name --- .coveragerc | 2 + .../components/tplink_omada/__init__.py | 8 +- .../components/tplink_omada/controller.py | 52 ++++++ .../components/tplink_omada/coordinator.py | 12 +- .../components/tplink_omada/entity.py | 18 +-- .../components/tplink_omada/manifest.json | 2 +- .../components/tplink_omada/switch.py | 26 ++- .../components/tplink_omada/update.py | 149 ++++++++++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 10 files changed, 235 insertions(+), 38 deletions(-) create mode 100644 homeassistant/components/tplink_omada/controller.py create mode 100644 homeassistant/components/tplink_omada/update.py diff --git a/.coveragerc b/.coveragerc index a533343bf0..20ee077ffa 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1290,9 +1290,11 @@ omit = homeassistant/components/touchline/climate.py homeassistant/components/tplink_lte/* homeassistant/components/tplink_omada/__init__.py + homeassistant/components/tplink_omada/controller.py homeassistant/components/tplink_omada/coordinator.py homeassistant/components/tplink_omada/entity.py homeassistant/components/tplink_omada/switch.py + homeassistant/components/tplink_omada/update.py homeassistant/components/traccar/device_tracker.py homeassistant/components/tractive/__init__.py homeassistant/components/tractive/binary_sensor.py diff --git a/homeassistant/components/tplink_omada/__init__.py b/homeassistant/components/tplink_omada/__init__.py index 1e7db69cc9..709ad52012 100644 --- a/homeassistant/components/tplink_omada/__init__.py +++ b/homeassistant/components/tplink_omada/__init__.py @@ -16,8 +16,9 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .config_flow import CONF_SITE, create_omada_client from .const import DOMAIN +from .controller import OmadaSiteController -PLATFORMS: list[Platform] = [Platform.SWITCH] +PLATFORMS: list[Platform] = [Platform.SWITCH, Platform.UPDATE] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -44,11 +45,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) from ex site_client = await client.get_site_client(OmadaSite(None, entry.data[CONF_SITE])) - - hass.data[DOMAIN][entry.entry_id] = site_client + controller = OmadaSiteController(hass, site_client) + hass.data[DOMAIN][entry.entry_id] = controller await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - return True diff --git a/homeassistant/components/tplink_omada/controller.py b/homeassistant/components/tplink_omada/controller.py new file mode 100644 index 0000000000..b42cb37ff7 --- /dev/null +++ b/homeassistant/components/tplink_omada/controller.py @@ -0,0 +1,52 @@ +"""Controller for sharing Omada API coordinators between platforms.""" + +from functools import partial + +from tplink_omada_client.devices import OmadaSwitch, OmadaSwitchPortDetails +from tplink_omada_client.omadasiteclient import OmadaSiteClient + +from homeassistant.core import HomeAssistant + +from .coordinator import OmadaCoordinator + + +async def _poll_switch_state( + client: OmadaSiteClient, network_switch: OmadaSwitch +) -> dict[str, OmadaSwitchPortDetails]: + """Poll a switch's current state.""" + ports = await client.get_switch_ports(network_switch) + return {p.port_id: p for p in ports} + + +class OmadaSiteController: + """Controller for the Omada SDN site.""" + + def __init__(self, hass: HomeAssistant, omada_client: OmadaSiteClient) -> None: + """Create the controller.""" + self._hass = hass + self._omada_client = omada_client + + self._switch_port_coordinators: dict[ + str, OmadaCoordinator[OmadaSwitchPortDetails] + ] = {} + + @property + def omada_client(self) -> OmadaSiteClient: + """Get the connected client API for the site to manage.""" + return self._omada_client + + def get_switch_port_coordinator( + self, switch: OmadaSwitch + ) -> OmadaCoordinator[OmadaSwitchPortDetails]: + """Get coordinator for network port information of a given switch.""" + if switch.mac not in self._switch_port_coordinators: + self._switch_port_coordinators[switch.mac] = OmadaCoordinator[ + OmadaSwitchPortDetails + ]( + self._hass, + self._omada_client, + f"{switch.name} Ports", + partial(_poll_switch_state, network_switch=switch), + ) + + return self._switch_port_coordinators[switch.mac] diff --git a/homeassistant/components/tplink_omada/coordinator.py b/homeassistant/components/tplink_omada/coordinator.py index 6950e3b6d7..d73461dc78 100644 --- a/homeassistant/components/tplink_omada/coordinator.py +++ b/homeassistant/components/tplink_omada/coordinator.py @@ -6,7 +6,7 @@ from typing import Generic, TypeVar import async_timeout from tplink_omada_client.exceptions import OmadaClientException -from tplink_omada_client.omadaclient import OmadaClient +from tplink_omada_client.omadaclient import OmadaSiteClient from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -22,15 +22,17 @@ class OmadaCoordinator(DataUpdateCoordinator[dict[str, T]], Generic[T]): def __init__( self, hass: HomeAssistant, - omada_client: OmadaClient, - update_func: Callable[[OmadaClient], Awaitable[dict[str, T]]], + omada_client: OmadaSiteClient, + name: str, + update_func: Callable[[OmadaSiteClient], Awaitable[dict[str, T]]], + poll_delay: int = 300, ) -> None: """Initialize my coordinator.""" super().__init__( hass, _LOGGER, - name="Omada API Data", - update_interval=timedelta(seconds=300), + name=f"Omada API Data - {name}", + update_interval=timedelta(seconds=poll_delay), ) self.omada_client = omada_client self._update_func = update_func diff --git a/homeassistant/components/tplink_omada/entity.py b/homeassistant/components/tplink_omada/entity.py index c3cc1433b9..41cb1c6918 100644 --- a/homeassistant/components/tplink_omada/entity.py +++ b/homeassistant/components/tplink_omada/entity.py @@ -1,5 +1,7 @@ """Base entity definitions.""" -from tplink_omada_client.devices import OmadaSwitch, OmadaSwitchPortDetails +from typing import Generic, TypeVar + +from tplink_omada_client.devices import OmadaDevice from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import DeviceInfo @@ -8,16 +10,14 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import OmadaCoordinator +T = TypeVar("T") -class OmadaSwitchDeviceEntity( - CoordinatorEntity[OmadaCoordinator[OmadaSwitchPortDetails]] -): - """Common base class for all entities attached to Omada network switches.""" - def __init__( - self, coordinator: OmadaCoordinator[OmadaSwitchPortDetails], device: OmadaSwitch - ) -> None: - """Initialize the switch.""" +class OmadaDeviceEntity(CoordinatorEntity[OmadaCoordinator[T]], Generic[T]): + """Common base class for all entities associated with Omada SDN Devices.""" + + def __init__(self, coordinator: OmadaCoordinator[T], device: OmadaDevice) -> None: + """Initialize the device.""" super().__init__(coordinator) self.device = device diff --git a/homeassistant/components/tplink_omada/manifest.json b/homeassistant/components/tplink_omada/manifest.json index 005589a2f9..a0fb58b3f6 100644 --- a/homeassistant/components/tplink_omada/manifest.json +++ b/homeassistant/components/tplink_omada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tplink_omada", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["tplink-omada-client==1.1.0"] + "requirements": ["tplink-omada-client==1.1.3"] } diff --git a/homeassistant/components/tplink_omada/switch.py b/homeassistant/components/tplink_omada/switch.py index dd5ee3168d..e85b1c181f 100644 --- a/homeassistant/components/tplink_omada/switch.py +++ b/homeassistant/components/tplink_omada/switch.py @@ -1,12 +1,11 @@ """Support for TPLink Omada device toggle options.""" from __future__ import annotations -from functools import partial from typing import Any from tplink_omada_client.definitions import PoEMode from tplink_omada_client.devices import OmadaSwitch, OmadaSwitchPortDetails -from tplink_omada_client.omadasiteclient import OmadaSiteClient, SwitchPortOverrides +from tplink_omada_client.omadasiteclient import SwitchPortOverrides from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry @@ -15,27 +14,21 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN +from .controller import OmadaSiteController from .coordinator import OmadaCoordinator -from .entity import OmadaSwitchDeviceEntity +from .entity import OmadaDeviceEntity POE_SWITCH_ICON = "mdi:ethernet" -async def poll_switch_state( - client: OmadaSiteClient, network_switch: OmadaSwitch -) -> dict[str, OmadaSwitchPortDetails]: - """Poll a switch's current state.""" - ports = await client.get_switch_ports(network_switch) - return {p.port_id: p for p in ports} - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up switches.""" - omada_client: OmadaSiteClient = hass.data[DOMAIN][config_entry.entry_id] + controller: OmadaSiteController = hass.data[DOMAIN][config_entry.entry_id] + omada_client = controller.omada_client # Naming fun. Omada switches, as in the network hardware network_switches = await omada_client.get_switches() @@ -44,10 +37,7 @@ async def async_setup_entry( for switch in [ ns for ns in network_switches if ns.device_capabilities.supports_poe ]: - coordinator = OmadaCoordinator[OmadaSwitchPortDetails]( - hass, omada_client, partial(poll_switch_state, network_switch=switch) - ) - + coordinator = controller.get_switch_port_coordinator(switch) await coordinator.async_request_refresh() for idx, port_id in enumerate(coordinator.data): @@ -67,7 +57,9 @@ def get_port_base_name(port: OmadaSwitchPortDetails) -> str: return f"Port {port.port} ({port.name})" -class OmadaNetworkSwitchPortPoEControl(OmadaSwitchDeviceEntity, SwitchEntity): +class OmadaNetworkSwitchPortPoEControl( + OmadaDeviceEntity[OmadaSwitchPortDetails], SwitchEntity +): """Representation of a PoE control toggle on a single network port on a switch.""" _attr_has_entity_name = True diff --git a/homeassistant/components/tplink_omada/update.py b/homeassistant/components/tplink_omada/update.py new file mode 100644 index 0000000000..5581f61d82 --- /dev/null +++ b/homeassistant/components/tplink_omada/update.py @@ -0,0 +1,149 @@ +"""Support for TPLink Omada device toggle options.""" +from __future__ import annotations + +import logging +from typing import Any, NamedTuple + +from tplink_omada_client.devices import OmadaFirmwareUpdate, OmadaListDevice +from tplink_omada_client.omadasiteclient import OmadaSiteClient + +from homeassistant.components.update import UpdateEntity, UpdateEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_call_later + +from .const import DOMAIN +from .controller import OmadaSiteController +from .coordinator import OmadaCoordinator +from .entity import OmadaDeviceEntity + +_LOGGER = logging.getLogger(__name__) + + +class FirmwareUpdateStatus(NamedTuple): + """Firmware update information for Omada SDN devices.""" + + device: OmadaListDevice + firmware: OmadaFirmwareUpdate | None + + +async def _get_firmware_updates(client: OmadaSiteClient) -> list[FirmwareUpdateStatus]: + devices = await client.get_devices() + return [ + FirmwareUpdateStatus( + device=d, + firmware=None + if not d.need_upgrade + else await client.get_firmware_details(d), + ) + for d in devices + ] + + +async def _poll_firmware_updates( + client: OmadaSiteClient, +) -> dict[str, FirmwareUpdateStatus]: + """Poll the state of Omada Devices firmware update availability.""" + return {d.device.mac: d for d in await _get_firmware_updates(client)} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up switches.""" + controller: OmadaSiteController = hass.data[DOMAIN][config_entry.entry_id] + omada_client = controller.omada_client + + devices = await omada_client.get_devices() + + coordinator = OmadaCoordinator[FirmwareUpdateStatus]( + hass, + omada_client, + "Firmware Updates", + _poll_firmware_updates, + poll_delay=6 * 60 * 60, + ) + + entities: list = [] + for device in devices: + entities.append(OmadaDeviceUpdate(coordinator, device)) + + async_add_entities(entities) + await coordinator.async_request_refresh() + + +class OmadaDeviceUpdate( + OmadaDeviceEntity[FirmwareUpdateStatus], + UpdateEntity, +): + """Firmware update status for Omada SDN devices.""" + + _attr_supported_features = ( + UpdateEntityFeature.INSTALL + | UpdateEntityFeature.PROGRESS + | UpdateEntityFeature.RELEASE_NOTES + ) + _firmware_update: OmadaFirmwareUpdate = None + + def __init__( + self, + coordinator: OmadaCoordinator[FirmwareUpdateStatus], + device: OmadaListDevice, + ) -> None: + """Initialize the update entity.""" + super().__init__(coordinator, device) + + self._mac = device.mac + self._device = device + self._omada_client = coordinator.omada_client + + self._attr_unique_id = f"{device.mac}_firmware" + self._attr_has_entity_name = True + self._attr_name = "Firmware Update" + self._refresh_state() + + def _refresh_state(self) -> None: + if self._firmware_update and self._device.need_upgrade: + self._attr_installed_version = self._firmware_update.current_version + self._attr_latest_version = self._firmware_update.latest_version + else: + self._attr_installed_version = self._device.firmware_version + self._attr_latest_version = self._device.firmware_version + self._attr_in_progress = self._device.fw_download + + if self._attr_in_progress: + # While firmware update is in progress, poll more frequently + async_call_later(self.hass, 60, self._request_refresh) + + async def _request_refresh(self, _now: Any) -> None: + await self.coordinator.async_request_refresh() + + def release_notes(self) -> str | None: + """Get the release notes for the latest update.""" + if self._firmware_update: + return str(self._firmware_update.release_notes) + return "" + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install a firmware update.""" + if self._firmware_update and ( + version is None or self._firmware_update.latest_version == version + ): + await self._omada_client.start_firmware_upgrade(self._device) + await self.coordinator.async_request_refresh() + else: + _LOGGER.error("Firmware upgrade is not available for %s", self._device.name) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + status = self.coordinator.data[self._mac] + self._device = status.device + self._firmware_update = status.firmware + self._refresh_state() + self.async_write_ha_state() diff --git a/requirements_all.txt b/requirements_all.txt index 742344b316..a3f91219b3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2524,7 +2524,7 @@ total_connect_client==2023.2 tp-connected==0.0.4 # homeassistant.components.tplink_omada -tplink-omada-client==1.1.0 +tplink-omada-client==1.1.3 # homeassistant.components.transmission transmission-rpc==3.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d631b0ecda..ae5a35f318 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1785,7 +1785,7 @@ toonapi==0.2.1 total_connect_client==2023.2 # homeassistant.components.tplink_omada -tplink-omada-client==1.1.0 +tplink-omada-client==1.1.3 # homeassistant.components.transmission transmission-rpc==3.4.0 From 977a07de138bd3d10a22cac43eef28a80d259109 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Mar 2023 15:32:26 -1000 Subject: [PATCH 0858/2810] Generate large history responses in the executor (#89606) --- .../components/history/websocket_api.py | 128 ++++++++++++------ 1 file changed, 83 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py index 5d0eb59942..a761021de5 100644 --- a/homeassistant/components/history/websocket_api.py +++ b/homeassistant/components/history/websocket_api.py @@ -189,21 +189,78 @@ def _async_send_empty_response( """Send an empty response when we know all results are filtered away.""" connection.send_result(msg_id) stream_end_time = end_time or dt_util.utcnow() - _async_send_response(connection, msg_id, start_time, stream_end_time, {}) + connection.send_message( + _generate_websocket_response(msg_id, start_time, stream_end_time, {}) + ) -@callback -def _async_send_response( - connection: ActiveConnection, +def _generate_websocket_response( msg_id: int, start_time: dt, end_time: dt, states: MutableMapping[str, list[dict[str, Any]]], -) -> None: - """Send a response.""" - empty_stream_message = _generate_stream_message(states, start_time, end_time) - empty_response = messages.event_message(msg_id, empty_stream_message) - connection.send_message(JSON_DUMP(empty_response)) +) -> str: + """Generate a websocket response.""" + return JSON_DUMP( + messages.event_message( + msg_id, _generate_stream_message(states, start_time, end_time) + ) + ) + + +def _generate_historical_response( + hass: HomeAssistant, + msg_id: int, + start_time: dt, + end_time: dt, + entity_ids: list[str] | None, + filters: Filters | None, + include_start_time_state: bool, + significant_changes_only: bool, + minimal_response: bool, + no_attributes: bool, + send_empty: bool, +) -> tuple[float, dt | None, str | None]: + """Generate a historical response.""" + states = cast( + MutableMapping[str, list[dict[str, Any]]], + history.get_significant_states( + hass, + start_time, + end_time, + entity_ids, + filters, + include_start_time_state, + significant_changes_only, + minimal_response, + no_attributes, + True, + ), + ) + last_time_ts = 0.0 + for state_list in states.values(): + if ( + state_list + and (state_last_time := state_list[-1][COMPRESSED_STATE_LAST_UPDATED]) + > last_time_ts + ): + last_time_ts = cast(float, state_last_time) + + if last_time_ts == 0: + # If we did not send any states ever, we need to send an empty response + # so the websocket client knows it should render/process/consume the + # data. + if not send_empty: + return last_time_ts, None, None + last_time_dt = end_time + else: + last_time_dt = dt_util.utc_from_timestamp(last_time_ts) + + return ( + last_time_ts, + last_time_dt, + _generate_websocket_response(msg_id, start_time, last_time_dt, states), + ) async def _async_send_historical_states( @@ -221,43 +278,24 @@ async def _async_send_historical_states( send_empty: bool, ) -> dt | None: """Fetch history significant_states and send them to the client.""" - states = cast( - MutableMapping[str, list[dict[str, Any]]], - await get_instance(hass).async_add_executor_job( - history.get_significant_states, - hass, - start_time, - end_time, - entity_ids, - filters, - include_start_time_state, - significant_changes_only, - minimal_response, - no_attributes, - True, - ), + instance = get_instance(hass) + last_time_ts, last_time_dt, payload = await instance.async_add_executor_job( + _generate_historical_response, + hass, + msg_id, + start_time, + end_time, + entity_ids, + filters, + include_start_time_state, + significant_changes_only, + minimal_response, + no_attributes, + send_empty, ) - last_time = 0 - - for state_list in states.values(): - if ( - state_list - and (state_last_time := state_list[-1][COMPRESSED_STATE_LAST_UPDATED]) - > last_time - ): - last_time = state_last_time - - if last_time == 0: - # If we did not send any states ever, we need to send an empty response - # so the websocket client knows it should render/process/consume the - # data. - if not send_empty: - return None - last_time_dt = end_time - else: - last_time_dt = dt_util.utc_from_timestamp(last_time) - _async_send_response(connection, msg_id, start_time, last_time_dt, states) - return last_time_dt if last_time != 0 else None + if payload: + connection.send_message(payload) + return last_time_dt if last_time_ts != 0 else None def _history_compressed_state(state: State, no_attributes: bool) -> dict[str, Any]: From 85ca94e9d44e1b4bba995464e214135f1e646877 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Mar 2023 15:33:28 -1000 Subject: [PATCH 0859/2810] Mark database sessions that do not write data as read_only (#89600) * Mark sessions that do not write data as read_only * Mark sessions that do not write data as read_only --- homeassistant/components/history/__init__.py | 2 +- homeassistant/components/logbook/processor.py | 2 +- homeassistant/components/recorder/history/legacy.py | 6 +++--- homeassistant/components/recorder/history/modern.py | 6 +++--- homeassistant/components/recorder/statistics.py | 12 ++++++------ homeassistant/components/recorder/util.py | 10 ++++++++-- 6 files changed, 22 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index 05d6205835..36f2f8945c 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -168,7 +168,7 @@ class HistoryPeriodView(HomeAssistantView): """Fetch significant stats from the database as json.""" timer_start = time.perf_counter() - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: states = history.get_significant_states_with_session( hass, session, diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index aa0bc74958..cd88dcb73a 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -150,7 +150,7 @@ class EventProcessor: # return result.yield_per(1024) - with session_scope(hass=self.hass) as session: + with session_scope(hass=self.hass, read_only=True) as session: metadata_ids: list[int] | None = None if self.entity_ids: instance = get_instance(self.hass) diff --git a/homeassistant/components/recorder/history/legacy.py b/homeassistant/components/recorder/history/legacy.py index 7d7e3d9b47..b8f27211c7 100644 --- a/homeassistant/components/recorder/history/legacy.py +++ b/homeassistant/components/recorder/history/legacy.py @@ -213,7 +213,7 @@ def get_significant_states( compressed_state_format: bool = False, ) -> MutableMapping[str, list[State | dict[str, Any]]]: """Wrap get_significant_states_with_session with an sql session.""" - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: return get_significant_states_with_session( hass, session, @@ -488,7 +488,7 @@ def state_changes_during_period( entity_id = entity_id.lower() if entity_id is not None else None entity_ids = [entity_id] if entity_id is not None else None - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: stmt = _state_changed_during_period_stmt( _schema_version(hass), start_time, @@ -558,7 +558,7 @@ def get_last_state_changes( entity_id_lower = entity_id.lower() entity_ids = [entity_id_lower] - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: stmt = _get_last_state_changes_stmt( _schema_version(hass), number_of_states, entity_id_lower ) diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index dce3b51edf..416a83e873 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -115,7 +115,7 @@ def get_significant_states( compressed_state_format: bool = False, ) -> MutableMapping[str, list[State | dict[str, Any]]]: """Wrap get_significant_states_with_session with an sql session.""" - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: return get_significant_states_with_session( hass, session, @@ -360,7 +360,7 @@ def state_changes_during_period( entity_id = entity_id.lower() if entity_id is not None else None entity_ids = [entity_id] if entity_id is not None else None - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: metadata_id: int | None = None entity_id_to_metadata_id = None if entity_id: @@ -424,7 +424,7 @@ def get_last_state_changes( entity_id_lower = entity_id.lower() entity_ids = [entity_id_lower] - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: instance = recorder.get_instance(hass) if not (metadata_id := instance.states_meta_manager.get(entity_id, session)): return {} diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 48bab4b11f..473d416d75 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -925,7 +925,7 @@ def get_metadata( statistic_source: str | None = None, ) -> dict[str, tuple[int, StatisticMetaData]]: """Return metadata for statistic_ids.""" - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: return get_metadata_with_session( session, statistic_ids=statistic_ids, @@ -985,7 +985,7 @@ def list_statistic_ids( statistic_ids_set = set(statistic_ids) if statistic_ids else None # Query the database - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: metadata = get_metadata_with_session( session, statistic_type=statistic_type, statistic_ids=statistic_ids ) @@ -1589,7 +1589,7 @@ def statistic_during_period( result: dict[str, Any] = {} - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: # Fetch metadata for the given statistic_id if not ( metadata := get_metadata_with_session(session, statistic_ids=[statistic_id]) @@ -1814,7 +1814,7 @@ def statistics_during_period( If end_time is omitted, returns statistics newer than or equal to start_time. If statistic_ids is omitted, returns statistics for all statistics ids. """ - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: return _statistics_during_period_with_session( hass, session, @@ -1866,7 +1866,7 @@ def _get_last_statistics( ) -> dict[str, list[dict]]: """Return the last number_of_stats statistics for a given statistic_id.""" statistic_ids = [statistic_id] - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: # Fetch metadata for the given statistic_id metadata = get_metadata_with_session(session, statistic_ids=statistic_ids) if not metadata: @@ -1953,7 +1953,7 @@ def get_latest_short_term_statistics( metadata: dict[str, tuple[int, StatisticMetaData]] | None = None, ) -> dict[str, list[dict]]: """Return the latest short term statistics for a list of statistic_ids.""" - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: # Fetch metadata for the given statistic_ids if not metadata: metadata = get_metadata_with_session(session, statistic_ids=statistic_ids) diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index bfdd8ff5b1..ae09f9fd6a 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -110,8 +110,14 @@ def session_scope( hass: HomeAssistant | None = None, session: Session | None = None, exception_filter: Callable[[Exception], bool] | None = None, + read_only: bool = False, ) -> Generator[Session, None, None]: - """Provide a transactional scope around a series of operations.""" + """Provide a transactional scope around a series of operations. + + read_only is used to indicate that the session is only used for reading + data and that no commit is required. It does not prevent the session + from writing and is not a security measure. + """ if session is None and hass is not None: session = get_instance(hass).get_session() @@ -121,7 +127,7 @@ def session_scope( need_rollback = False try: yield session - if session.get_transaction(): + if session.get_transaction() and not read_only: need_rollback = True session.commit() except Exception as err: # pylint: disable=broad-except From b9ac6b4a7c4c5ec000c499490031af66f54aac21 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Mar 2023 15:41:48 -1000 Subject: [PATCH 0860/2810] Improve reliability of context id migration (#89609) * Split context id migration into states and events tasks Since events can finish much earlier than states we would keep looking at the table because states as not done. Make them seperate tasks * add retry dec * fix migration happening twice * another case --- homeassistant/components/recorder/core.py | 27 +++- .../components/recorder/migration.py | 65 ++++++--- homeassistant/components/recorder/tasks.py | 25 +++- tests/components/recorder/test_migrate.py | 138 +++++++++++++++++- .../components/recorder/test_v32_migration.py | 12 +- tests/conftest.py | 38 ++++- 6 files changed, 258 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 630efe1956..4ebd4703b6 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -97,9 +97,9 @@ from .tasks import ( ChangeStatisticsUnitTask, ClearStatisticsTask, CommitTask, - ContextIDMigrationTask, DatabaseLockTask, EntityIDMigrationTask, + EventsContextIDMigrationTask, EventTask, EventTypeIDMigrationTask, ImportStatisticsTask, @@ -107,6 +107,7 @@ from .tasks import ( PerodicCleanupTask, PurgeTask, RecorderTask, + StatesContextIDMigrationTask, StatisticsTask, StopTask, SynchronizeTask, @@ -654,8 +655,9 @@ class Recorder(threading.Thread): self.migration_is_live = migration.live_migration(schema_status) self.hass.add_job(self.async_connection_success) + database_was_ready = self.migration_is_live or schema_status.valid - if self.migration_is_live or schema_status.valid: + if database_was_ready: # If the migrate is live or the schema is valid, we need to # wait for startup to complete. If its not live, we need to continue # on. @@ -670,7 +672,6 @@ class Recorder(threading.Thread): # Make sure we cleanly close the run if # we restart before startup finishes self._shutdown() - self._activate_and_set_db_ready() return if not schema_status.valid: @@ -692,7 +693,8 @@ class Recorder(threading.Thread): self._shutdown() return - self._activate_and_set_db_ready() + if not database_was_ready: + self._activate_and_set_db_ready() # Catch up with missed statistics with session_scope(session=self.get_session()) as session: @@ -710,9 +712,14 @@ class Recorder(threading.Thread): if ( self.schema_version < 36 or session.execute(has_events_context_ids_to_migrate()).scalar() + ): + self.queue_task(StatesContextIDMigrationTask()) + + if ( + self.schema_version < 36 or session.execute(has_states_context_ids_to_migrate()).scalar() ): - self.queue_task(ContextIDMigrationTask()) + self.queue_task(EventsContextIDMigrationTask()) if ( self.schema_version < 37 @@ -1236,9 +1243,13 @@ class Recorder(threading.Thread): """Run post schema migration tasks.""" migration.post_schema_migration(self, old_version, new_version) - def _migrate_context_ids(self) -> bool: - """Migrate context ids if needed.""" - return migration.migrate_context_ids(self) + def _migrate_states_context_ids(self) -> bool: + """Migrate states context ids if needed.""" + return migration.migrate_states_context_ids(self) + + def _migrate_events_context_ids(self) -> bool: + """Migrate events context ids if needed.""" + return migration.migrate_events_context_ids(self) def _migrate_event_type_ids(self) -> bool: """Migrate event type ids if needed.""" diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index c13a031457..b1b33dd29e 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -64,7 +64,7 @@ from .tasks import ( PostSchemaMigrationTask, StatisticsTimestampMigrationCleanupTask, ) -from .util import database_job_retry_wrapper, session_scope +from .util import database_job_retry_wrapper, retryable_database_job, session_scope if TYPE_CHECKING: from . import Recorder @@ -1301,8 +1301,43 @@ def _context_id_to_bytes(context_id: str | None) -> bytes | None: return None -def migrate_context_ids(instance: Recorder) -> bool: - """Migrate context_ids to use binary format.""" +@retryable_database_job("migrate states context_ids to binary format") +def migrate_states_context_ids(instance: Recorder) -> bool: + """Migrate states context_ids to use binary format.""" + _to_bytes = _context_id_to_bytes + session_maker = instance.get_session + _LOGGER.debug("Migrating states context_ids to binary format") + with session_scope(session=session_maker()) as session: + if states := session.execute(find_states_context_ids_to_migrate()).all(): + session.execute( + update(States), + [ + { + "state_id": state_id, + "context_id": None, + "context_id_bin": _to_bytes(context_id) or _EMPTY_CONTEXT_ID, + "context_user_id": None, + "context_user_id_bin": _to_bytes(context_user_id), + "context_parent_id": None, + "context_parent_id_bin": _to_bytes(context_parent_id), + } + for state_id, context_id, context_user_id, context_parent_id in states + ], + ) + # If there is more work to do return False + # so that we can be called again + is_done = not states + + if is_done: + _drop_index(session_maker, "states", "ix_states_context_id") + + _LOGGER.debug("Migrating states context_ids to binary format: done=%s", is_done) + return is_done + + +@retryable_database_job("migrate events context_ids to binary format") +def migrate_events_context_ids(instance: Recorder) -> bool: + """Migrate events context_ids to use binary format.""" _to_bytes = _context_id_to_bytes session_maker = instance.get_session _LOGGER.debug("Migrating context_ids to binary format") @@ -1323,34 +1358,18 @@ def migrate_context_ids(instance: Recorder) -> bool: for event_id, context_id, context_user_id, context_parent_id in events ], ) - if states := session.execute(find_states_context_ids_to_migrate()).all(): - session.execute( - update(States), - [ - { - "state_id": state_id, - "context_id": None, - "context_id_bin": _to_bytes(context_id) or _EMPTY_CONTEXT_ID, - "context_user_id": None, - "context_user_id_bin": _to_bytes(context_user_id), - "context_parent_id": None, - "context_parent_id_bin": _to_bytes(context_parent_id), - } - for state_id, context_id, context_user_id, context_parent_id in states - ], - ) # If there is more work to do return False # so that we can be called again - is_done = not (events or states) + is_done = not events if is_done: _drop_index(session_maker, "events", "ix_events_context_id") - _drop_index(session_maker, "states", "ix_states_context_id") - _LOGGER.debug("Migrating context_ids to binary format: done=%s", is_done) + _LOGGER.debug("Migrating events context_ids to binary format: done=%s", is_done) return is_done +@retryable_database_job("migrate events event_types to event_type_ids") def migrate_event_type_ids(instance: Recorder) -> bool: """Migrate event_type to event_type_ids.""" session_maker = instance.get_session @@ -1407,6 +1426,7 @@ def migrate_event_type_ids(instance: Recorder) -> bool: return is_done +@retryable_database_job("migrate states entity_ids to states_meta") def migrate_entity_ids(instance: Recorder) -> bool: """Migrate entity_ids to states_meta. @@ -1468,6 +1488,7 @@ def migrate_entity_ids(instance: Recorder) -> bool: return is_done +@retryable_database_job("post migrate states entity_ids to states_meta") def post_migrate_entity_ids(instance: Recorder) -> bool: """Remove old entity_id strings from states. diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index 0b99ca742b..17b63aad22 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -346,16 +346,33 @@ class AdjustLRUSizeTask(RecorderTask): @dataclass -class ContextIDMigrationTask(RecorderTask): - """An object to insert into the recorder queue to migrate context ids.""" +class StatesContextIDMigrationTask(RecorderTask): + """An object to insert into the recorder queue to migrate states context ids.""" commit_before = False def run(self, instance: Recorder) -> None: """Run context id migration task.""" - if not instance._migrate_context_ids(): # pylint: disable=[protected-access] + if ( + not instance._migrate_states_context_ids() # pylint: disable=[protected-access] + ): # Schedule a new migration task if this one didn't finish - instance.queue_task(ContextIDMigrationTask()) + instance.queue_task(StatesContextIDMigrationTask()) + + +@dataclass +class EventsContextIDMigrationTask(RecorderTask): + """An object to insert into the recorder queue to migrate events context ids.""" + + commit_before = False + + def run(self, instance: Recorder) -> None: + """Run context id migration task.""" + if ( + not instance._migrate_events_context_ids() # pylint: disable=[protected-access] + ): + # Schedule a new migration task if this one didn't finish + instance.queue_task(EventsContextIDMigrationTask()) @dataclass diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index 060d1bcb74..c9d0be5973 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -32,10 +32,11 @@ from homeassistant.components.recorder.db_schema import ( ) from homeassistant.components.recorder.queries import select_event_type_ids from homeassistant.components.recorder.tasks import ( - ContextIDMigrationTask, EntityIDMigrationTask, EntityIDPostMigrationTask, + EventsContextIDMigrationTask, EventTypeIDMigrationTask, + StatesContextIDMigrationTask, ) from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant @@ -558,7 +559,7 @@ def test_raise_if_exception_missing_empty_cause_str() -> None: @pytest.mark.parametrize("enable_migrate_context_ids", [True]) -async def test_migrate_context_ids( +async def test_migrate_events_context_ids( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant ) -> None: """Test we can migrate old uuid context ids and ulid context ids to binary format.""" @@ -632,7 +633,7 @@ async def test_migrate_context_ids( await async_wait_recording_done(hass) # This is a threadsafe way to add a task to the recorder - instance.queue_task(ContextIDMigrationTask()) + instance.queue_task(EventsContextIDMigrationTask()) await async_recorder_block_till_done(hass) def _object_as_dict(obj): @@ -701,6 +702,137 @@ async def test_migrate_context_ids( assert invalid_context_id_event["context_parent_id_bin"] is None +@pytest.mark.parametrize("enable_migrate_context_ids", [True]) +async def test_migrate_states_context_ids( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test we can migrate old uuid context ids and ulid context ids to binary format.""" + instance = await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + test_uuid = uuid.uuid4() + uuid_hex = test_uuid.hex + uuid_bin = test_uuid.bytes + + def _insert_events(): + with session_scope(hass=hass) as session: + session.add_all( + ( + States( + entity_id="state.old_uuid_context_id", + last_updated_ts=1677721632.452529, + context_id=uuid_hex, + context_id_bin=None, + context_user_id=None, + context_user_id_bin=None, + context_parent_id=None, + context_parent_id_bin=None, + ), + States( + entity_id="state.empty_context_id", + last_updated_ts=1677721632.552529, + context_id=None, + context_id_bin=None, + context_user_id=None, + context_user_id_bin=None, + context_parent_id=None, + context_parent_id_bin=None, + ), + States( + entity_id="state.ulid_context_id", + last_updated_ts=1677721632.552529, + context_id="01ARZ3NDEKTSV4RRFFQ69G5FAV", + context_id_bin=None, + context_user_id="9400facee45711eaa9308bfd3d19e474", + context_user_id_bin=None, + context_parent_id="01ARZ3NDEKTSV4RRFFQ69G5FA2", + context_parent_id_bin=None, + ), + States( + entity_id="state.invalid_context_id", + last_updated_ts=1677721632.552529, + context_id="invalid", + context_id_bin=None, + context_user_id=None, + context_user_id_bin=None, + context_parent_id=None, + context_parent_id_bin=None, + ), + ) + ) + + await instance.async_add_executor_job(_insert_events) + + await async_wait_recording_done(hass) + # This is a threadsafe way to add a task to the recorder + instance.queue_task(StatesContextIDMigrationTask()) + await async_recorder_block_till_done(hass) + + def _object_as_dict(obj): + return {c.key: getattr(obj, c.key) for c in inspect(obj).mapper.column_attrs} + + def _fetch_migrated_states(): + with session_scope(hass=hass) as session: + events = ( + session.query(States) + .filter( + States.entity_id.in_( + [ + "state.old_uuid_context_id", + "state.empty_context_id", + "state.ulid_context_id", + "state.invalid_context_id", + ] + ) + ) + .all() + ) + assert len(events) == 4 + return {state.entity_id: _object_as_dict(state) for state in events} + + states_by_entity_id = await instance.async_add_executor_job(_fetch_migrated_states) + + old_uuid_context_id = states_by_entity_id["state.old_uuid_context_id"] + assert old_uuid_context_id["context_id"] is None + assert old_uuid_context_id["context_user_id"] is None + assert old_uuid_context_id["context_parent_id"] is None + assert old_uuid_context_id["context_id_bin"] == uuid_bin + assert old_uuid_context_id["context_user_id_bin"] is None + assert old_uuid_context_id["context_parent_id_bin"] is None + + empty_context_id = states_by_entity_id["state.empty_context_id"] + assert empty_context_id["context_id"] is None + assert empty_context_id["context_user_id"] is None + assert empty_context_id["context_parent_id"] is None + assert empty_context_id["context_id_bin"] == b"\x00" * 16 + assert empty_context_id["context_user_id_bin"] is None + assert empty_context_id["context_parent_id_bin"] is None + + ulid_context_id = states_by_entity_id["state.ulid_context_id"] + assert ulid_context_id["context_id"] is None + assert ulid_context_id["context_user_id"] is None + assert ulid_context_id["context_parent_id"] is None + assert ( + bytes_to_ulid(ulid_context_id["context_id_bin"]) == "01ARZ3NDEKTSV4RRFFQ69G5FAV" + ) + assert ( + ulid_context_id["context_user_id_bin"] + == b"\x94\x00\xfa\xce\xe4W\x11\xea\xa90\x8b\xfd=\x19\xe4t" + ) + assert ( + bytes_to_ulid(ulid_context_id["context_parent_id_bin"]) + == "01ARZ3NDEKTSV4RRFFQ69G5FA2" + ) + + invalid_context_id = states_by_entity_id["state.invalid_context_id"] + assert invalid_context_id["context_id"] is None + assert invalid_context_id["context_user_id"] is None + assert invalid_context_id["context_parent_id"] is None + assert invalid_context_id["context_id_bin"] == b"\x00" * 16 + assert invalid_context_id["context_user_id_bin"] is None + assert invalid_context_id["context_parent_id_bin"] is None + + @pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) async def test_migrate_event_type_ids( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index 50029e56b2..467dc2961c 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -86,6 +86,7 @@ async def test_migrate_times(caplog: pytest.LogCaptureFixture, tmpdir) -> None: EventOrigin.local, time_fired=now, ) + number_of_migrations = 5 with patch.object(recorder, "db_schema", old_db_schema), patch.object( recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION @@ -100,11 +101,15 @@ async def test_migrate_times(caplog: pytest.LogCaptureFixture, tmpdir) -> None: ), patch( CREATE_ENGINE_TARGET, new=_create_engine_test ), patch( - "homeassistant.components.recorder.Recorder._migrate_context_ids", + "homeassistant.components.recorder.Recorder._migrate_events_context_ids", + ), patch( + "homeassistant.components.recorder.Recorder._migrate_states_context_ids", ), patch( "homeassistant.components.recorder.Recorder._migrate_event_type_ids", ), patch( "homeassistant.components.recorder.Recorder._migrate_entity_ids", + ), patch( + "homeassistant.components.recorder.Recorder._post_migrate_entity_ids" ): hass = await async_test_home_assistant(asyncio.get_running_loop()) recorder_helper.async_initialize_recorder(hass) @@ -122,8 +127,10 @@ async def test_migrate_times(caplog: pytest.LogCaptureFixture, tmpdir) -> None: await recorder.get_instance(hass).async_add_executor_job(_add_data) await hass.async_block_till_done() + await recorder.get_instance(hass).async_block_till_done() await hass.async_stop() + await hass.async_block_till_done() dt_util.DEFAULT_TIME_ZONE = ORIG_TZ @@ -137,7 +144,8 @@ async def test_migrate_times(caplog: pytest.LogCaptureFixture, tmpdir) -> None: # We need to wait for all the migration tasks to complete # before we can check the database. - for _ in range(5): + for _ in range(number_of_migrations): + await recorder.get_instance(hass).async_block_till_done() await async_wait_recording_done(hass) def _get_test_data_from_db(): diff --git a/tests/conftest.py b/tests/conftest.py index 4f7b553955..0307e65d27 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1250,8 +1250,15 @@ def hass_recorder( if enable_statistics_table_validation else itertools.repeat(set()) ) - migrate_context_ids = ( - recorder.Recorder._migrate_context_ids if enable_migrate_context_ids else None + migrate_states_context_ids = ( + recorder.Recorder._migrate_states_context_ids + if enable_migrate_context_ids + else None + ) + migrate_events_context_ids = ( + recorder.Recorder._migrate_events_context_ids + if enable_migrate_context_ids + else None ) migrate_event_type_ids = ( recorder.Recorder._migrate_event_type_ids @@ -1274,8 +1281,12 @@ def hass_recorder( side_effect=stats_validate, autospec=True, ), patch( - "homeassistant.components.recorder.Recorder._migrate_context_ids", - side_effect=migrate_context_ids, + "homeassistant.components.recorder.Recorder._migrate_events_context_ids", + side_effect=migrate_events_context_ids, + autospec=True, + ), patch( + "homeassistant.components.recorder.Recorder._migrate_states_context_ids", + side_effect=migrate_states_context_ids, autospec=True, ), patch( "homeassistant.components.recorder.Recorder._migrate_event_type_ids", @@ -1354,8 +1365,15 @@ async def async_setup_recorder_instance( if enable_statistics_table_validation else itertools.repeat(set()) ) - migrate_context_ids = ( - recorder.Recorder._migrate_context_ids if enable_migrate_context_ids else None + migrate_states_context_ids = ( + recorder.Recorder._migrate_states_context_ids + if enable_migrate_context_ids + else None + ) + migrate_events_context_ids = ( + recorder.Recorder._migrate_events_context_ids + if enable_migrate_context_ids + else None ) migrate_event_type_ids = ( recorder.Recorder._migrate_event_type_ids @@ -1378,8 +1396,12 @@ async def async_setup_recorder_instance( side_effect=stats_validate, autospec=True, ), patch( - "homeassistant.components.recorder.Recorder._migrate_context_ids", - side_effect=migrate_context_ids, + "homeassistant.components.recorder.Recorder._migrate_events_context_ids", + side_effect=migrate_events_context_ids, + autospec=True, + ), patch( + "homeassistant.components.recorder.Recorder._migrate_states_context_ids", + side_effect=migrate_states_context_ids, autospec=True, ), patch( "homeassistant.components.recorder.Recorder._migrate_event_type_ids", From 877efc993b5621aca5e089adb52b51e742a9c649 Mon Sep 17 00:00:00 2001 From: Barry Loong Date: Mon, 13 Mar 2023 10:45:25 +0800 Subject: [PATCH 0861/2810] Add support for window device class to google assistant (#89564) --- homeassistant/components/google_assistant/const.py | 1 + tests/components/google_assistant/test_smart_home.py | 1 + 2 files changed, 2 insertions(+) diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 20c4ab60e8..bf511f8eae 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -161,6 +161,7 @@ DEVICE_CLASS_TO_GOOGLE_TYPES = { (cover.DOMAIN, cover.CoverDeviceClass.GARAGE): TYPE_GARAGE, (cover.DOMAIN, cover.CoverDeviceClass.GATE): TYPE_GARAGE, (cover.DOMAIN, cover.CoverDeviceClass.SHUTTER): TYPE_SHUTTER, + (cover.DOMAIN, cover.CoverDeviceClass.WINDOW): TYPE_WINDOW, ( humidifier.DOMAIN, humidifier.HumidifierDeviceClass.DEHUMIDIFIER, diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index cf83e47b3b..d0a7df5a86 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -1113,6 +1113,7 @@ async def test_device_class_binary_sensor( ("awning", "action.devices.types.AWNING"), ("shutter", "action.devices.types.SHUTTER"), ("curtain", "action.devices.types.CURTAIN"), + ("window", "action.devices.types.WINDOW"), ], ) async def test_device_class_cover( From 0575b9bc88585dc94b654607ca50c5a3bf2eeb67 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Mar 2023 16:57:22 -1000 Subject: [PATCH 0862/2810] Increase maximum aiohttp connections to 4096 (#89611) fixes #89408 --- homeassistant/helpers/aiohttp_client.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index f75b8e3aa4..af8fa4d6f4 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -39,6 +39,20 @@ SERVER_SOFTWARE = "{0}/{1} aiohttp/{2} Python/{3[0]}.{3[1]}".format( WARN_CLOSE_MSG = "closes the Home Assistant aiohttp session" +# +# The default connection limit of 100 meant that you could only have +# 100 concurrent connections. +# +# This was effectively a limit of 100 devices and than +# the supervisor API would fail as soon as it was hit. +# +# We now apply the 100 limit per host, so that we can have 100 connections +# to a single host, but can have more than 4096 connections in total to +# prevent a single host from using all available connections. +# +MAXIMUM_CONNECTIONS = 4096 +MAXIMUM_CONNECTIONS_PER_HOST = 100 + class HassClientResponse(aiohttp.ClientResponse): """aiohttp.ClientResponse with a json method that uses json_loads by default.""" @@ -261,7 +275,12 @@ def _async_get_connector( else: ssl_context = False - connector = aiohttp.TCPConnector(enable_cleanup_closed=True, ssl=ssl_context) + connector = aiohttp.TCPConnector( + enable_cleanup_closed=True, + ssl=ssl_context, + limit=MAXIMUM_CONNECTIONS, + limit_per_host=MAXIMUM_CONNECTIONS_PER_HOST, + ) hass.data[key] = connector async def _async_close_connector(event: Event) -> None: From 4dcf7c626705c845ca958df56e759ac2b40fd7c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Mar 2023 17:03:48 -1000 Subject: [PATCH 0863/2810] Fix history_stats test failing during DST (#89589) Note that there is one test that needs `now()` as it is timezone aware --- tests/components/history_stats/test_sensor.py | 112 +++++++++--------- 1 file changed, 56 insertions(+), 56 deletions(-) diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index 74194bed60..4d705fefcc 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -30,7 +30,7 @@ async def test_setup(recorder_mock: Recorder, hass: HomeAssistant) -> None: "platform": "history_stats", "entity_id": "binary_sensor.test_id", "state": "on", - "start": "{{ now().replace(hour=0)" + "start": "{{ utcnow().replace(hour=0)" ".replace(minute=0).replace(second=0) }}", "duration": "02:00", "name": "Test", @@ -54,7 +54,7 @@ async def test_setup_multiple_states( "platform": "history_stats", "entity_id": "binary_sensor.test_id", "state": ["on", "true"], - "start": "{{ now().replace(hour=0)" + "start": "{{ utcnow().replace(hour=0)" ".replace(minute=0).replace(second=0) }}", "duration": "02:00", "name": "Test", @@ -76,7 +76,7 @@ async def test_setup_multiple_states( "entity_id": "binary_sensor.test_id", "name": "Test", "state": "on", - "start": "{{ now() }}", + "start": "{{ utcnow() }}", "duration": "TEST", }, { @@ -84,15 +84,15 @@ async def test_setup_multiple_states( "entity_id": "binary_sensor.test_id", "name": "Test", "state": "on", - "start": "{{ now() }}", + "start": "{{ utcnow() }}", }, { "platform": "history_stats", "entity_id": "binary_sensor.test_id", "name": "Test", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "duration": "01:00", }, ], @@ -226,7 +226,7 @@ async def test_reload(recorder_mock: Recorder, hass: HomeAssistant) -> None: "entity_id": "binary_sensor.test_id", "name": "test", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", "duration": "01:00", }, }, @@ -292,8 +292,8 @@ async def test_measure_multiple(recorder_mock: Recorder, hass: HomeAssistant) -> "entity_id": "input_select.test_id", "name": "sensor1", "state": ["orange", "blue"], - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "time", }, { @@ -301,8 +301,8 @@ async def test_measure_multiple(recorder_mock: Recorder, hass: HomeAssistant) -> "entity_id": "unknown.test_id", "name": "sensor2", "state": ["orange", "blue"], - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "time", }, { @@ -310,8 +310,8 @@ async def test_measure_multiple(recorder_mock: Recorder, hass: HomeAssistant) -> "entity_id": "input_select.test_id", "name": "sensor3", "state": ["orange", "blue"], - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "count", }, { @@ -319,8 +319,8 @@ async def test_measure_multiple(recorder_mock: Recorder, hass: HomeAssistant) -> "entity_id": "input_select.test_id", "name": "sensor4", "state": ["orange", "blue"], - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "ratio", }, ] @@ -371,8 +371,8 @@ async def test_measure(recorder_mock: Recorder, hass: HomeAssistant) -> None: "entity_id": "binary_sensor.test_id", "name": "sensor1", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "time", }, { @@ -380,8 +380,8 @@ async def test_measure(recorder_mock: Recorder, hass: HomeAssistant) -> None: "entity_id": "binary_sensor.test_id", "name": "sensor2", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "time", }, { @@ -390,7 +390,7 @@ async def test_measure(recorder_mock: Recorder, hass: HomeAssistant) -> None: "name": "sensor3", "state": "on", "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "end": "{{ utcnow() }}", "type": "count", }, { @@ -398,8 +398,8 @@ async def test_measure(recorder_mock: Recorder, hass: HomeAssistant) -> None: "entity_id": "binary_sensor.test_id", "name": "sensor4", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "ratio", }, ] @@ -453,8 +453,8 @@ async def test_async_on_entire_period( "entity_id": "binary_sensor.test_on_id", "name": "on_sensor1", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "time", }, { @@ -462,8 +462,8 @@ async def test_async_on_entire_period( "entity_id": "binary_sensor.test_on_id", "name": "on_sensor2", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "time", }, { @@ -471,8 +471,8 @@ async def test_async_on_entire_period( "entity_id": "binary_sensor.test_on_id", "name": "on_sensor3", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "count", }, { @@ -480,8 +480,8 @@ async def test_async_on_entire_period( "entity_id": "binary_sensor.test_on_id", "name": "on_sensor4", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "ratio", }, ] @@ -531,8 +531,8 @@ async def test_async_off_entire_period( "entity_id": "binary_sensor.test_on_id", "name": "on_sensor1", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "time", }, { @@ -540,8 +540,8 @@ async def test_async_off_entire_period( "entity_id": "binary_sensor.test_on_id", "name": "on_sensor2", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "time", }, { @@ -549,8 +549,8 @@ async def test_async_off_entire_period( "entity_id": "binary_sensor.test_on_id", "name": "on_sensor3", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "count", }, { @@ -558,8 +558,8 @@ async def test_async_off_entire_period( "entity_id": "binary_sensor.test_on_id", "name": "on_sensor4", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "ratio", }, ] @@ -1228,7 +1228,7 @@ async def test_measure_from_end_going_backwards( "name": "sensor1", "state": "on", "duration": {"hours": 1}, - "end": "{{ now() }}", + "end": "{{ utcnow() }}", "type": "time", }, { @@ -1237,7 +1237,7 @@ async def test_measure_from_end_going_backwards( "name": "sensor2", "state": "on", "duration": {"hours": 1}, - "end": "{{ now() }}", + "end": "{{ utcnow() }}", "type": "time", }, { @@ -1246,7 +1246,7 @@ async def test_measure_from_end_going_backwards( "name": "sensor3", "state": "on", "duration": {"hours": 1}, - "end": "{{ now() }}", + "end": "{{ utcnow() }}", "type": "count", }, { @@ -1255,7 +1255,7 @@ async def test_measure_from_end_going_backwards( "name": "sensor4", "state": "on", "duration": {"hours": 1}, - "end": "{{ now() }}", + "end": "{{ utcnow() }}", "type": "ratio", }, ] @@ -1320,8 +1320,8 @@ async def test_measure_cet(recorder_mock: Recorder, hass: HomeAssistant) -> None "entity_id": "binary_sensor.test_id", "name": "sensor1", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "time", }, { @@ -1329,8 +1329,8 @@ async def test_measure_cet(recorder_mock: Recorder, hass: HomeAssistant) -> None "entity_id": "binary_sensor.test_id", "name": "sensor2", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "time", }, { @@ -1338,8 +1338,8 @@ async def test_measure_cet(recorder_mock: Recorder, hass: HomeAssistant) -> None "entity_id": "binary_sensor.test_id", "name": "sensor3", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "count", }, { @@ -1347,8 +1347,8 @@ async def test_measure_cet(recorder_mock: Recorder, hass: HomeAssistant) -> None "entity_id": "binary_sensor.test_id", "name": "sensor4", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "ratio", }, ] @@ -1495,8 +1495,8 @@ async def test_device_classes(recorder_mock: Recorder, hass: HomeAssistant) -> N "entity_id": "binary_sensor.test_id", "name": "time", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ as_timestamp(now()) + 3600 }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ as_timestamp(utcnow()) + 3600 }}", "type": "time", }, { @@ -1504,8 +1504,8 @@ async def test_device_classes(recorder_mock: Recorder, hass: HomeAssistant) -> N "entity_id": "binary_sensor.test_id", "name": "count", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ as_timestamp(now()) + 3600 }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ as_timestamp(utcnow()) + 3600 }}", "type": "count", }, { @@ -1513,8 +1513,8 @@ async def test_device_classes(recorder_mock: Recorder, hass: HomeAssistant) -> N "entity_id": "binary_sensor.test_id", "name": "ratio", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ as_timestamp(now()) + 3600 }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ as_timestamp(utcnow()) + 3600 }}", "type": "ratio", }, ] From e34853a82ad247f99b1059640fc99220cd056e74 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Mar 2023 17:05:48 -1000 Subject: [PATCH 0864/2810] Switch underlying history stats calculation to use seconds (#77857) * Switch history stats to report in seconds Because hours were previously used, the data would always be off because of the loss of resolution when the time being tracked was in a window of more than 12s * Apply suggestions from code review * Update homeassistant/components/history_stats/sensor.py * tweak --- homeassistant/components/history_stats/data.py | 16 ++++++++-------- .../components/history_stats/helpers.py | 2 +- homeassistant/components/history_stats/sensor.py | 6 +++--- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py index 33f32e7229..d9b331d82b 100644 --- a/homeassistant/components/history_stats/data.py +++ b/homeassistant/components/history_stats/data.py @@ -18,7 +18,7 @@ MIN_TIME_UTC = datetime.datetime.min.replace(tzinfo=dt_util.UTC) class HistoryStatsState: """The current stats of the history stats.""" - hours_matched: float | None + seconds_matched: float | None match_count: int | None period: tuple[datetime.datetime, datetime.datetime] @@ -125,12 +125,12 @@ class HistoryStats: await self._async_history_from_db(current_period_start, current_period_end) self._previous_run_before_start = False - hours_matched, match_count = self._async_compute_hours_and_changes( + seconds_matched, match_count = self._async_compute_seconds_and_changes( now_timestamp, current_period_start_timestamp, current_period_end_timestamp, ) - self._state = HistoryStatsState(hours_matched, match_count, self._period) + self._state = HistoryStatsState(seconds_matched, match_count, self._period) return self._state async def _async_history_from_db( @@ -162,10 +162,10 @@ class HistoryStats: no_attributes=True, ).get(self.entity_id, []) - def _async_compute_hours_and_changes( + def _async_compute_seconds_and_changes( self, now_timestamp: float, start_timestamp: float, end_timestamp: float ) -> tuple[float, int]: - """Compute the hours matched and changes from the history list and first state.""" + """Compute the seconds matched and changes from the history list and first state.""" # state_changes_during_period is called with include_start_time_state=True # which is the default and always provides the state at the start # of the period @@ -195,6 +195,6 @@ class HistoryStats: measure_end = min(end_timestamp, now_timestamp) elapsed += measure_end - last_state_change_timestamp - # Save value in hours - hours_matched = elapsed / 3600 - return hours_matched, match_count + # Save value in seconds + seconds_matched = elapsed + return seconds_matched, match_count diff --git a/homeassistant/components/history_stats/helpers.py b/homeassistant/components/history_stats/helpers.py index 23143984f4..0c914e1fd4 100644 --- a/homeassistant/components/history_stats/helpers.py +++ b/homeassistant/components/history_stats/helpers.py @@ -79,7 +79,7 @@ def pretty_ratio( if len(period) != 2 or period[0] == period[1]: return 0.0 - ratio = 100 * 3600 * value / (period[1] - period[0]).total_seconds() + ratio = 100 * value / (period[1] - period[0]).total_seconds() return round(ratio, 1) diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py index fc3aedfde2..2b02be17e9 100644 --- a/homeassistant/components/history_stats/sensor.py +++ b/homeassistant/components/history_stats/sensor.py @@ -163,13 +163,13 @@ class HistoryStatsSensor(HistoryStatsSensorBase): def _process_update(self) -> None: """Process an update from the coordinator.""" state = self.coordinator.data - if state is None or state.hours_matched is None: + if state is None or state.seconds_matched is None: self._attr_native_value = None return if self._type == CONF_TYPE_TIME: - self._attr_native_value = round(state.hours_matched, 2) + self._attr_native_value = round(state.seconds_matched / 3600, 2) elif self._type == CONF_TYPE_RATIO: - self._attr_native_value = pretty_ratio(state.hours_matched, state.period) + self._attr_native_value = pretty_ratio(state.seconds_matched, state.period) elif self._type == CONF_TYPE_COUNT: self._attr_native_value = state.match_count From 17c0e187761643cfeb4311dcac73c1cfb859a8b3 Mon Sep 17 00:00:00 2001 From: Stephan Uhle Date: Mon, 13 Mar 2023 08:04:24 +0100 Subject: [PATCH 0865/2810] Code quality update for EDL21 (#89561) Enhance code quality. --- homeassistant/components/edl21/sensor.py | 36 +++--------------------- 1 file changed, 4 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index 35992b9610..68b874149a 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -27,7 +27,7 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -397,30 +397,7 @@ class EDL21: self._OBIS_BLACKLIST.add(obis) if new_entities: - self._hass.loop.create_task(self.add_entities(new_entities)) - - async def add_entities(self, new_entities: list[EDL21Entity]) -> None: - """Migrate old unique IDs, then add entities to hass.""" - registry = er.async_get(self._hass) - - for entity in new_entities: - old_entity_id = registry.async_get_entity_id( - "sensor", DOMAIN, entity.old_unique_id - ) - if old_entity_id is not None and entity.unique_id is not None: - LOGGER.debug( - "Migrating unique_id from [%s] to [%s]", - entity.old_unique_id, - entity.unique_id, - ) - if registry.async_get_entity_id("sensor", DOMAIN, entity.unique_id): - registry.async_remove(old_entity_id) - else: - registry.async_update_entity( - old_entity_id, new_unique_id=entity.unique_id - ) - - self._async_add_entities(new_entities, update_before_add=True) + self._async_add_entities(new_entities, update_before_add=True) class EDL21Entity(SensorEntity): @@ -480,18 +457,13 @@ class EDL21Entity(SensorEntity): if self._async_remove_dispatcher: self._async_remove_dispatcher() - @property - def old_unique_id(self) -> str: - """Return a less unique ID as used in the first version of edl21.""" - return self._obis - @property def native_value(self) -> str: """Return the value of the last received telegram.""" return self._telegram.get("value") @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any]: """Enumerate supported attributes.""" return { self._state_attrs[k]: v @@ -500,7 +472,7 @@ class EDL21Entity(SensorEntity): } @property - def native_unit_of_measurement(self): + def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement.""" if (unit := self._telegram.get("unit")) is None or unit == 0: return None From c45fb85f1771cf484a4fb932bbd70a3daf5c5ba2 Mon Sep 17 00:00:00 2001 From: Stephan Uhle Date: Mon, 13 Mar 2023 08:25:59 +0100 Subject: [PATCH 0866/2810] Bump pysml to 0.0.9 (#89603) --- homeassistant/components/edl21/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/edl21/manifest.json b/homeassistant/components/edl21/manifest.json index 48bab7d84f..f6363473de 100644 --- a/homeassistant/components/edl21/manifest.json +++ b/homeassistant/components/edl21/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["sml"], - "requirements": ["pysml==0.0.8"] + "requirements": ["pysml==0.0.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index a3f91219b3..6740bd8005 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1979,7 +1979,7 @@ pysmartthings==0.7.6 pysmarty==0.8 # homeassistant.components.edl21 -pysml==0.0.8 +pysml==0.0.9 # homeassistant.components.snmp pysnmplib==5.0.21 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ae5a35f318..687fc6c1a1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1435,7 +1435,7 @@ pysmartapp==0.3.3 pysmartthings==0.7.6 # homeassistant.components.edl21 -pysml==0.0.8 +pysml==0.0.9 # homeassistant.components.snmp pysnmplib==5.0.21 From 1fb11aec266ef140978eceb0f8c599461d498c56 Mon Sep 17 00:00:00 2001 From: Eugenio Panadero Date: Mon, 13 Mar 2023 09:07:10 +0100 Subject: [PATCH 0867/2810] Bump aiopvpc to 4.1.0 (#89593) --- homeassistant/components/pvpc_hourly_pricing/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/pvpc_hourly_pricing/manifest.json b/homeassistant/components/pvpc_hourly_pricing/manifest.json index 89520f079a..64e6e19086 100644 --- a/homeassistant/components/pvpc_hourly_pricing/manifest.json +++ b/homeassistant/components/pvpc_hourly_pricing/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["aiopvpc"], "quality_scale": "platinum", - "requirements": ["aiopvpc==4.0.1"] + "requirements": ["aiopvpc==4.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6740bd8005..4e25506c9f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -241,7 +241,7 @@ aiopurpleair==2022.12.1 aiopvapi==2.0.4 # homeassistant.components.pvpc_hourly_pricing -aiopvpc==4.0.1 +aiopvpc==4.1.0 # homeassistant.components.lidarr # homeassistant.components.radarr diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 687fc6c1a1..02b7f7959c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -222,7 +222,7 @@ aiopurpleair==2022.12.1 aiopvapi==2.0.4 # homeassistant.components.pvpc_hourly_pricing -aiopvpc==4.0.1 +aiopvpc==4.1.0 # homeassistant.components.lidarr # homeassistant.components.radarr From e33cb2ee6028c31dbd495f0650309d1486953957 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Mar 2023 09:10:47 +0100 Subject: [PATCH 0868/2810] Bump actions/cache from 3.3.0 to 3.3.1 (#89617) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 46 +++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8697255888..12884206a4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -212,7 +212,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.3.0 + uses: actions/cache@v3.3.1 with: path: venv key: >- @@ -227,7 +227,7 @@ jobs: pip install "$(cat requirements_test.txt | grep pre-commit)" - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v3.3.0 + uses: actions/cache@v3.3.1 with: path: ${{ env.PRE_COMMIT_CACHE }} lookup-only: true @@ -257,7 +257,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.0 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -266,7 +266,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.3.0 + uses: actions/cache/restore@v3.3.1 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -303,7 +303,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.0 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -312,7 +312,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.3.0 + uses: actions/cache/restore@v3.3.1 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -352,7 +352,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.0 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -361,7 +361,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.3.0 + uses: actions/cache/restore@v3.3.1 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -401,7 +401,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.0 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -410,7 +410,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.3.0 + uses: actions/cache/restore@v3.3.1 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -439,7 +439,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.0 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -448,7 +448,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.3.0 + uses: actions/cache/restore@v3.3.1 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -563,7 +563,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.3.0 + uses: actions/cache@v3.3.1 with: path: venv lookup-only: true @@ -572,7 +572,7 @@ jobs: needs.info.outputs.python_cache_key }} - name: Restore pip wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@v3.3.0 + uses: actions/cache@v3.3.1 with: path: ${{ env.PIP_CACHE }} key: >- @@ -626,7 +626,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.0 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -658,7 +658,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.0 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -691,7 +691,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.0 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -742,7 +742,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.0 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -750,7 +750,7 @@ jobs: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore mypy cache - uses: actions/cache@v3.3.0 + uses: actions/cache@v3.3.1 with: path: .mypy_cache key: >- @@ -801,7 +801,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.0 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -854,7 +854,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.0 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -980,7 +980,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.0 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -1088,7 +1088,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.0 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true From 470b0b5471822210b0cd9c0abd2bab55a2784f35 Mon Sep 17 00:00:00 2001 From: dougiteixeira <31328123+dougiteixeira@users.noreply.github.com> Date: Mon, 13 Mar 2023 05:23:04 -0300 Subject: [PATCH 0869/2810] Adjust Tuya entity naming (#89616) Fix entity name --- homeassistant/components/tuya/light.py | 2 +- homeassistant/components/tuya/sensor.py | 2 +- homeassistant/components/tuya/switch.py | 58 ++++++++++++------------- 3 files changed, 31 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index ffc00e6f92..3546e45451 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -221,7 +221,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { ), TuyaLightEntityDescription( key=DPCode.BASIC_INDICATOR, - name="Indicator Light", + name="Indicator light", entity_category=EntityCategory.CONFIG, ), ), diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 020099ba5d..a2cd2d5fc4 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -834,7 +834,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), TuyaSensorEntityDescription( key=DPCode.TOTAL_CLEAN_AREA, - name="Total Cleaning Area", + name="Total cleaning area", icon="mdi:texture-box", state_class=SensorStateClass.TOTAL_INCREASING, ), diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index 1b2fdca326..a7245913e7 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -34,7 +34,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), SwitchEntityDescription( key=DPCode.WARM, - name="Heat Preservation", + name="Heat preservation", entity_category=EntityCategory.CONFIG, ), ), @@ -57,7 +57,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "cwwsq": ( SwitchEntityDescription( key=DPCode.SLOW_FEED, - name="Slow Feed", + name="Slow feed", icon="mdi:speedometer-slow", entity_category=EntityCategory.CONFIG, ), @@ -89,7 +89,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), SwitchEntityDescription( key=DPCode.UV, - name="UV Sterilization", + name="UV sterilization", icon="mdi:lightbulb", entity_category=EntityCategory.CONFIG, ), @@ -109,7 +109,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "dlq": ( SwitchEntityDescription( key=DPCode.CHILD_LOCK, - name="Child Lock", + name="Child lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), @@ -152,7 +152,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), SwitchEntityDescription( key=DPCode.SWITCH_6, - name="Sleep Aid", + name="Sleep aid", icon="mdi:power-sleep", ), ), @@ -176,7 +176,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "kg": ( SwitchEntityDescription( key=DPCode.CHILD_LOCK, - name="Child Lock", + name="Child lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), @@ -283,7 +283,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), SwitchEntityDescription( key=DPCode.UV, - name="UV Sterilization", + name="UV sterilization", icon="mdi:minus-circle-outline", entity_category=EntityCategory.CONFIG, ), @@ -299,7 +299,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), SwitchEntityDescription( key=DPCode.LOCK, - name="Child Lock", + name="Child lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), @@ -325,7 +325,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "pc": ( SwitchEntityDescription( key=DPCode.CHILD_LOCK, - name="Child Lock", + name="Child lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), @@ -409,7 +409,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), SwitchEntityDescription( key=DPCode.LOCK, - name="Child Lock", + name="Child lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), @@ -419,13 +419,13 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "sd": ( SwitchEntityDescription( key=DPCode.SWITCH_DISTURB, - name="Do Not Disturb", + name="Do not disturb", icon="mdi:minus-circle", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.VOICE_SWITCH, - name="Mute Voice", + name="Mute voice", icon="mdi:account-voice", entity_category=EntityCategory.CONFIG, ), @@ -444,38 +444,38 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "sp": ( SwitchEntityDescription( key=DPCode.WIRELESS_BATTERYLOCK, - name="Battery Lock", + name="Battery lock", icon="mdi:battery-lock", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.CRY_DETECTION_SWITCH, icon="mdi:emoticon-cry", - name="Cry Detection", + name="Cry detection", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.DECIBEL_SWITCH, icon="mdi:microphone-outline", - name="Sound Detection", + name="Sound detection", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.RECORD_SWITCH, icon="mdi:record-rec", - name="Video Recording", + name="Video recording", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.MOTION_RECORD, icon="mdi:record-rec", - name="Motion Recording", + name="Motion recording", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.BASIC_PRIVATE, icon="mdi:eye-off", - name="Privacy Mode", + name="Privacy mode", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( @@ -487,25 +487,25 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { SwitchEntityDescription( key=DPCode.BASIC_OSD, icon="mdi:watermark", - name="Time Watermark", + name="Time watermark", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.BASIC_WDR, icon="mdi:watermark", - name="Wide Dynamic Range", + name="Wide dynamic range", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.MOTION_TRACKING, icon="mdi:motion-sensor", - name="Motion Tracking", + name="Motion tracking", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.MOTION_SWITCH, icon="mdi:motion-sensor", - name="Motion Alarm", + name="Motion alarm", entity_category=EntityCategory.CONFIG, ), ), @@ -542,7 +542,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), SwitchEntityDescription( key=DPCode.CHILD_LOCK, - name="Child Lock", + name="Child lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), @@ -552,7 +552,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "tyndj": ( SwitchEntityDescription( key=DPCode.SWITCH_SAVE_ENERGY, - name="Energy Saving", + name="Energy saving", icon="mdi:leaf", entity_category=EntityCategory.CONFIG, ), @@ -562,13 +562,13 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "wkf": ( SwitchEntityDescription( key=DPCode.CHILD_LOCK, - name="Child Lock", + name="Child lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.WINDOW_CHECK, - name="Open Window Detection", + name="Open window detection", icon="mdi:window-open", entity_category=EntityCategory.CONFIG, ), @@ -636,13 +636,13 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), SwitchEntityDescription( key=DPCode.OXYGEN, - name="Oxygen Bar", + name="Oxygen bar", icon="mdi:molecule", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.FAN_COOL, - name="Natural Wind", + name="Natural wind", icon="mdi:weather-windy", entity_category=EntityCategory.CONFIG, ), @@ -654,7 +654,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), SwitchEntityDescription( key=DPCode.CHILD_LOCK, - name="Child Lock", + name="Child lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), From d1ee303e8547396ef220f5229aa56b6963af07ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Mar 2023 22:24:57 -1000 Subject: [PATCH 0870/2810] Drop duplicated indices from recorder database schema (#89613) Drop duplicated indices from schema https://docs.percona.com/percona-toolkit/pt-duplicate-key-checker.html ``` % pt-duplicate-key-checker --databases fresh ALTER TABLE `fresh`.`events` DROP INDEX `ix_events_event_type_id`; ALTER TABLE `fresh`.`states` DROP INDEX `ix_states_metadata_id`; ALTER TABLE `fresh`.`statistics` DROP INDEX `ix_statistics_metadata_id`; ALTER TABLE `fresh`.`statistics_short_term` DROP INDEX `ix_statistics_short_term_metadata_id`; ``` --- homeassistant/components/recorder/db_schema.py | 7 +++---- homeassistant/components/recorder/migration.py | 13 +++++++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 5e0a78dad3..b715ef9bc5 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -68,7 +68,7 @@ class Base(DeclarativeBase): """Base class for tables.""" -SCHEMA_VERSION = 39 +SCHEMA_VERSION = 40 _LOGGER = logging.getLogger(__name__) @@ -229,7 +229,7 @@ class Events(Base): LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) ) event_type_id: Mapped[int | None] = mapped_column( - Integer, ForeignKey("event_types.event_type_id"), index=True + Integer, ForeignKey("event_types.event_type_id") ) event_data_rel: Mapped[EventData | None] = relationship("EventData") event_type_rel: Mapped[EventTypes | None] = relationship("EventTypes") @@ -426,7 +426,7 @@ class States(Base): LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) ) metadata_id: Mapped[int | None] = mapped_column( - Integer, ForeignKey("states_meta.metadata_id"), index=True + Integer, ForeignKey("states_meta.metadata_id") ) states_meta_rel: Mapped[StatesMeta | None] = relationship("StatesMeta") @@ -617,7 +617,6 @@ class StatisticsBase: metadata_id: Mapped[int | None] = mapped_column( Integer, ForeignKey(f"{TABLE_STATISTICS_META}.id", ondelete="CASCADE"), - index=True, ) start: Mapped[datetime | None] = mapped_column( DATETIME_TYPE, index=True diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index b1b33dd29e..5b2180773a 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1041,6 +1041,19 @@ def _apply_update( # noqa: C901 "ix_statistics_short_term_statistic_id_start", quiet=True, ) + elif new_version == 40: + # ix_events_event_type_id is a left-prefix of ix_events_event_type_id_time_fired_ts + _drop_index(session_maker, "events", "ix_events_event_type_id") + # ix_states_metadata_id is a left-prefix of ix_states_metadata_id_last_updated_ts + _drop_index(session_maker, "states", "ix_states_metadata_id") + # ix_statistics_metadata_id is a left-prefix of ix_statistics_statistic_id_start_ts + _drop_index(session_maker, "statistics", "ix_statistics_metadata_id") + # ix_statistics_short_term_metadata_id is a left-prefix of ix_statistics_short_term_statistic_id_start_ts + _drop_index( + session_maker, + "statistics_short_term", + "ix_statistics_short_term_metadata_id", + ) else: raise ValueError(f"No schema migration defined for version {new_version}") From fd5c56fc7dfa7e2e6c1fda31828305a957b330a9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 13 Mar 2023 09:44:20 +0100 Subject: [PATCH 0871/2810] Rename modules named repairs.py which are not repairs platforms (#89618) --- .../components/bayesian/binary_sensor.py | 2 +- .../bayesian/{repairs.py => issues.py} | 2 +- homeassistant/components/hassio/__init__.py | 10 ++++----- .../hassio/{repairs.py => issues.py} | 18 +++++++-------- tests/components/hassio/conftest.py | 2 +- .../{test_repairs.py => test_issues.py} | 22 +++++++++---------- 6 files changed, 28 insertions(+), 28 deletions(-) rename homeassistant/components/bayesian/{repairs.py => issues.py} (97%) rename homeassistant/components/hassio/{repairs.py => issues.py} (92%) rename tests/components/hassio/{test_repairs.py => test_issues.py} (96%) diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py index 77571e1a80..06baef1bd0 100644 --- a/homeassistant/components/bayesian/binary_sensor.py +++ b/homeassistant/components/bayesian/binary_sensor.py @@ -60,7 +60,7 @@ from .const import ( DEFAULT_PROBABILITY_THRESHOLD, ) from .helpers import Observation -from .repairs import raise_mirrored_entries, raise_no_prob_given_false +from .issues import raise_mirrored_entries, raise_no_prob_given_false _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/bayesian/repairs.py b/homeassistant/components/bayesian/issues.py similarity index 97% rename from homeassistant/components/bayesian/repairs.py rename to homeassistant/components/bayesian/issues.py index 47d7dff6e1..fbc3a86258 100644 --- a/homeassistant/components/bayesian/repairs.py +++ b/homeassistant/components/bayesian/issues.py @@ -1,4 +1,4 @@ -"""Helpers for generating repairs.""" +"""Helpers for generating issues.""" from __future__ import annotations from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 25f3477bff..0f17c0b527 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -96,7 +96,7 @@ from .handler import ( # noqa: F401 ) from .http import HassIOView from .ingress import async_setup_ingress_view -from .repairs import SupervisorRepairs +from .issues import SupervisorIssues from .websocket_api import async_load_websocket_api _LOGGER = logging.getLogger(__name__) @@ -125,7 +125,7 @@ DATA_SUPERVISOR_STATS = "hassio_supervisor_stats" DATA_ADDONS_CHANGELOGS = "hassio_addons_changelogs" DATA_ADDONS_INFO = "hassio_addons_info" DATA_ADDONS_STATS = "hassio_addons_stats" -DATA_SUPERVISOR_REPAIRS = "supervisor_repairs" +DATA_SUPERVISOR_ISSUES = "supervisor_issues" HASSIO_UPDATE_INTERVAL = timedelta(minutes=5) ADDONS_COORDINATOR = "hassio_addons_coordinator" @@ -604,9 +604,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: hass.config_entries.flow.async_init(DOMAIN, context={"source": "system"}) ) - # Start listening for problems with supervisor and making repairs - hass.data[DATA_SUPERVISOR_REPAIRS] = repairs = SupervisorRepairs(hass, hassio) - await repairs.setup() + # Start listening for problems with supervisor and making issues + hass.data[DATA_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass, hassio) + await issues.setup() return True diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/issues.py similarity index 92% rename from homeassistant/components/hassio/repairs.py rename to homeassistant/components/hassio/issues.py index 21120d8d52..a0d51c4806 100644 --- a/homeassistant/components/hassio/repairs.py +++ b/homeassistant/components/hassio/issues.py @@ -70,11 +70,11 @@ UNHEALTHY_REASONS = { } -class SupervisorRepairs: - """Create repairs from supervisor events.""" +class SupervisorIssues: + """Create issues from supervisor events.""" def __init__(self, hass: HomeAssistant, client: HassIO) -> None: - """Initialize supervisor repairs.""" + """Initialize supervisor issues.""" self._hass = hass self._client = client self._unsupported_reasons: set[str] = set() @@ -87,7 +87,7 @@ class SupervisorRepairs: @unhealthy_reasons.setter def unhealthy_reasons(self, reasons: set[str]) -> None: - """Set unhealthy reasons. Create or delete repairs as necessary.""" + """Set unhealthy reasons. Create or delete issues as necessary.""" for unhealthy in reasons - self.unhealthy_reasons: if unhealthy in UNHEALTHY_REASONS: translation_key = f"unhealthy_{unhealthy}" @@ -119,7 +119,7 @@ class SupervisorRepairs: @unsupported_reasons.setter def unsupported_reasons(self, reasons: set[str]) -> None: - """Set unsupported reasons. Create or delete repairs as necessary.""" + """Set unsupported reasons. Create or delete issues as necessary.""" for unsupported in reasons - UNSUPPORTED_SKIP_REPAIR - self.unsupported_reasons: if unsupported in UNSUPPORTED_REASONS: translation_key = f"unsupported_{unsupported}" @@ -149,18 +149,18 @@ class SupervisorRepairs: await self.update() async_dispatcher_connect( - self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_repairs + self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_issues ) async def update(self) -> None: - """Update repairs from Supervisor resolution center.""" + """Update issuess from Supervisor resolution center.""" data = await self._client.get_resolution_info() self.unhealthy_reasons = set(data[ATTR_UNHEALTHY]) self.unsupported_reasons = set(data[ATTR_UNSUPPORTED]) @callback - def _supervisor_events_to_repairs(self, event: dict[str, Any]) -> None: - """Create repairs from supervisor events.""" + def _supervisor_events_to_issues(self, event: dict[str, Any]) -> None: + """Create issues from supervisor events.""" if ATTR_WS_EVENT not in event: return diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index 78ae9643d6..afe641405e 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -52,7 +52,7 @@ def hassio_stubs(hassio_env, hass, hass_client, aioclient_mock): "homeassistant.components.hassio.HassIO.get_ingress_panels", return_value={"panels": []}, ), patch( - "homeassistant.components.hassio.repairs.SupervisorRepairs.setup" + "homeassistant.components.hassio.issues.SupervisorIssues.setup" ), patch( "homeassistant.components.hassio.HassIO.refresh_updates" ): diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_issues.py similarity index 96% rename from tests/components/hassio/test_repairs.py rename to tests/components/hassio/test_issues.py index 8806e641a5..5b280d0c82 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_issues.py @@ -1,4 +1,4 @@ -"""Test repairs from supervisor issues.""" +"""Test issues from supervisor issues.""" from __future__ import annotations import os @@ -145,12 +145,12 @@ def assert_repair_in_list(issues: list[dict[str, Any]], unhealthy: bool, reason: } in issues -async def test_unhealthy_repairs( +async def test_unhealthy_issues( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: - """Test repairs added for unhealthy systems.""" + """Test issues added for unhealthy systems.""" mock_resolution_info(aioclient_mock, unhealthy=["docker", "setup"]) result = await async_setup_component(hass, "hassio", {}) @@ -166,12 +166,12 @@ async def test_unhealthy_repairs( assert_repair_in_list(msg["result"]["issues"], unhealthy=True, reason="setup") -async def test_unsupported_repairs( +async def test_unsupported_issues( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: - """Test repairs added for unsupported systems.""" + """Test issues added for unsupported systems.""" mock_resolution_info(aioclient_mock, unsupported=["content_trust", "os"]) result = await async_setup_component(hass, "hassio", {}) @@ -189,12 +189,12 @@ async def test_unsupported_repairs( assert_repair_in_list(msg["result"]["issues"], unhealthy=False, reason="os") -async def test_unhealthy_repairs_add_remove( +async def test_unhealthy_issues_add_remove( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: - """Test unhealthy repairs added and removed from dispatches.""" + """Test unhealthy issues added and removed from dispatches.""" mock_resolution_info(aioclient_mock) result = await async_setup_component(hass, "hassio", {}) @@ -245,12 +245,12 @@ async def test_unhealthy_repairs_add_remove( assert msg["result"] == {"issues": []} -async def test_unsupported_repairs_add_remove( +async def test_unsupported_issues_add_remove( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: - """Test unsupported repairs added and removed from dispatches.""" + """Test unsupported issues added and removed from dispatches.""" mock_resolution_info(aioclient_mock) result = await async_setup_component(hass, "hassio", {}) @@ -301,12 +301,12 @@ async def test_unsupported_repairs_add_remove( assert msg["result"] == {"issues": []} -async def test_reset_repairs_supervisor_restart( +async def test_reset_issues_supervisor_restart( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: - """Unsupported/unhealthy repairs reset on supervisor restart.""" + """Unsupported/unhealthy issues reset on supervisor restart.""" mock_resolution_info(aioclient_mock, unsupported=["os"], unhealthy=["docker"]) result = await async_setup_component(hass, "hassio", {}) From 5e73ad9cb0eed32860e8946e3afc1b2465d1a02c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Mar 2023 10:45:59 +0100 Subject: [PATCH 0872/2810] Use SnapshotAssertion in SFR sensor tests (#89619) * Use SnapshotAssertion in SFR sensor tests * Name snapshots * Cleanup const.py * Remove name from snapshot --- tests/components/sfr_box/const.py | 141 +--- .../sfr_box/snapshots/test_sensor.ambr | 684 ++++++++++++++++++ tests/components/sfr_box/test_sensor.py | 39 +- 3 files changed, 700 insertions(+), 164 deletions(-) create mode 100644 tests/components/sfr_box/snapshots/test_sensor.ambr diff --git a/tests/components/sfr_box/const.py b/tests/components/sfr_box/const.py index fb1694ebef..c3ed56e32a 100644 --- a/tests/components/sfr_box/const.py +++ b/tests/components/sfr_box/const.py @@ -1,12 +1,7 @@ """Constants for SFR Box tests.""" from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.button import ButtonDeviceClass -from homeassistant.components.sensor import ( - ATTR_OPTIONS, - ATTR_STATE_CLASS, - SensorDeviceClass, - SensorStateClass, -) +from homeassistant.components.sensor import ATTR_OPTIONS, ATTR_STATE_CLASS from homeassistant.components.sfr_box.const import DOMAIN from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -17,13 +12,9 @@ from homeassistant.const import ( ATTR_STATE, ATTR_SW_VERSION, ATTR_UNIT_OF_MEASUREMENT, - SIGNAL_STRENGTH_DECIBELS, STATE_ON, STATE_UNKNOWN, Platform, - UnitOfDataRate, - UnitOfElectricPotential, - UnitOfTemperature, ) ATTR_DEFAULT_DISABLED = "default_disabled" @@ -58,134 +49,4 @@ EXPECTED_ENTITIES = { ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_system_reboot", }, ], - Platform.SENSOR: [ - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM, - ATTR_ENTITY_ID: "sensor.sfr_box_network_infrastructure", - ATTR_OPTIONS: ["adsl", "ftth", "gprs", "unknown"], - ATTR_STATE: "adsl", - ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_system_net_infra", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.sfr_box_temperature", - ATTR_STATE: "27.56", - ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_system_temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.VOLTAGE, - ATTR_ENTITY_ID: "sensor.sfr_box_voltage", - ATTR_STATE: "12251", - ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_system_alimvoltage", - ATTR_UNIT_OF_MEASUREMENT: UnitOfElectricPotential.MILLIVOLT, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "sensor.sfr_box_dsl_line_mode", - ATTR_STATE: "ADSL2+", - ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_linemode", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "sensor.sfr_box_dsl_counter", - ATTR_STATE: "16", - ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_counter", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "sensor.sfr_box_dsl_crc", - ATTR_STATE: "0", - ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_crc", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.SIGNAL_STRENGTH, - ATTR_ENTITY_ID: "sensor.sfr_box_dsl_noise_down", - ATTR_STATE: "5.8", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_noise_down", - ATTR_UNIT_OF_MEASUREMENT: SIGNAL_STRENGTH_DECIBELS, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.SIGNAL_STRENGTH, - ATTR_ENTITY_ID: "sensor.sfr_box_dsl_noise_up", - ATTR_STATE: "6.0", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_noise_up", - ATTR_UNIT_OF_MEASUREMENT: SIGNAL_STRENGTH_DECIBELS, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.SIGNAL_STRENGTH, - ATTR_ENTITY_ID: "sensor.sfr_box_dsl_attenuation_down", - ATTR_STATE: "28.5", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_attenuation_down", - ATTR_UNIT_OF_MEASUREMENT: SIGNAL_STRENGTH_DECIBELS, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.SIGNAL_STRENGTH, - ATTR_ENTITY_ID: "sensor.sfr_box_dsl_attenuation_up", - ATTR_STATE: "20.8", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_attenuation_up", - ATTR_UNIT_OF_MEASUREMENT: SIGNAL_STRENGTH_DECIBELS, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.DATA_RATE, - ATTR_ENTITY_ID: "sensor.sfr_box_dsl_rate_down", - ATTR_STATE: "5549", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_rate_down", - ATTR_UNIT_OF_MEASUREMENT: UnitOfDataRate.KILOBITS_PER_SECOND, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.DATA_RATE, - ATTR_ENTITY_ID: "sensor.sfr_box_dsl_rate_up", - ATTR_STATE: "187", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_rate_up", - ATTR_UNIT_OF_MEASUREMENT: UnitOfDataRate.KILOBITS_PER_SECOND, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM, - ATTR_ENTITY_ID: "sensor.sfr_box_dsl_line_status", - ATTR_OPTIONS: [ - "no_defect", - "of_frame", - "loss_of_signal", - "loss_of_power", - "loss_of_signal_quality", - "unknown", - ], - ATTR_STATE: "no_defect", - ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_line_status", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM, - ATTR_ENTITY_ID: "sensor.sfr_box_dsl_training", - ATTR_OPTIONS: [ - "idle", - "g_994_training", - "g_992_started", - "g_922_channel_analysis", - "g_992_message_exchange", - "g_993_started", - "g_993_channel_analysis", - "g_993_message_exchange", - "showtime", - "unknown", - ], - ATTR_STATE: "showtime", - ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_training", - }, - ], } diff --git a/tests/components/sfr_box/snapshots/test_sensor.ambr b/tests/components/sfr_box/snapshots/test_sensor.ambr new file mode 100644 index 0000000000..a5788a1d6c --- /dev/null +++ b/tests/components/sfr_box/snapshots/test_sensor.ambr @@ -0,0 +1,684 @@ +# serializer version: 1 +# name: test_sensors + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://192.168.0.1', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'sfr_box', + 'e4:5d:51:00:11:22', + ), + }), + 'is_new': False, + 'manufacturer': None, + 'model': 'NB6VAC-FXC-r0', + 'name': 'SFR Box', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'NB6VAC-MAIN-R4.0.44k', + 'via_device_id': None, + }) +# --- +# name: test_sensors.1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'adsl', + 'ftth', + 'gprs', + 'unknown', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sfr_box_network_infrastructure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Network infrastructure', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': 'net_infra', + 'unique_id': 'e4:5d:51:00:11:22_system_net_infra', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sfr_box_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e4:5d:51:00:11:22_system_alimvoltage', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sfr_box_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e4:5d:51:00:11:22_system_temperature', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sfr_box_dsl_line_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'DSL line mode', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e4:5d:51:00:11:22_dsl_linemode', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sfr_box_dsl_counter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'DSL counter', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e4:5d:51:00:11:22_dsl_counter', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sfr_box_dsl_crc', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'DSL CRC', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e4:5d:51:00:11:22_dsl_crc', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sfr_box_dsl_noise_down', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DSL noise down', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e4:5d:51:00:11:22_dsl_noise_down', + 'unit_of_measurement': 'dB', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sfr_box_dsl_noise_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DSL noise up', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e4:5d:51:00:11:22_dsl_noise_up', + 'unit_of_measurement': 'dB', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sfr_box_dsl_attenuation_down', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DSL attenuation down', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e4:5d:51:00:11:22_dsl_attenuation_down', + 'unit_of_measurement': 'dB', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sfr_box_dsl_attenuation_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DSL attenuation up', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e4:5d:51:00:11:22_dsl_attenuation_up', + 'unit_of_measurement': 'dB', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sfr_box_dsl_rate_down', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DSL rate down', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e4:5d:51:00:11:22_dsl_rate_down', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sfr_box_dsl_rate_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DSL rate up', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e4:5d:51:00:11:22_dsl_rate_up', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_defect', + 'of_frame', + 'loss_of_signal', + 'loss_of_power', + 'loss_of_signal_quality', + 'unknown', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sfr_box_dsl_line_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DSL line status', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': 'line_status', + 'unique_id': 'e4:5d:51:00:11:22_dsl_line_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'idle', + 'g_994_training', + 'g_992_started', + 'g_922_channel_analysis', + 'g_992_message_exchange', + 'g_993_started', + 'g_993_channel_analysis', + 'g_993_message_exchange', + 'showtime', + 'unknown', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sfr_box_dsl_training', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DSL training', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': 'training', + 'unique_id': 'e4:5d:51:00:11:22_dsl_training', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_sensors[sensor.sfr_box_dsl_attenuation_down] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'SFR Box DSL attenuation down', + 'state_class': , + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.sfr_box_dsl_attenuation_down', + 'last_changed': , + 'last_updated': , + 'state': '28.5', + }) +# --- +# name: test_sensors[sensor.sfr_box_dsl_attenuation_up] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'SFR Box DSL attenuation up', + 'state_class': , + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.sfr_box_dsl_attenuation_up', + 'last_changed': , + 'last_updated': , + 'state': '20.8', + }) +# --- +# name: test_sensors[sensor.sfr_box_dsl_counter] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SFR Box DSL counter', + }), + 'context': , + 'entity_id': 'sensor.sfr_box_dsl_counter', + 'last_changed': , + 'last_updated': , + 'state': '16', + }) +# --- +# name: test_sensors[sensor.sfr_box_dsl_crc] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SFR Box DSL CRC', + }), + 'context': , + 'entity_id': 'sensor.sfr_box_dsl_crc', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.sfr_box_dsl_line_mode] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SFR Box DSL line mode', + }), + 'context': , + 'entity_id': 'sensor.sfr_box_dsl_line_mode', + 'last_changed': , + 'last_updated': , + 'state': 'ADSL2+', + }) +# --- +# name: test_sensors[sensor.sfr_box_dsl_line_status] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'SFR Box DSL line status', + 'options': list([ + 'no_defect', + 'of_frame', + 'loss_of_signal', + 'loss_of_power', + 'loss_of_signal_quality', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.sfr_box_dsl_line_status', + 'last_changed': , + 'last_updated': , + 'state': 'no_defect', + }) +# --- +# name: test_sensors[sensor.sfr_box_dsl_noise_down] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'SFR Box DSL noise down', + 'state_class': , + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.sfr_box_dsl_noise_down', + 'last_changed': , + 'last_updated': , + 'state': '5.8', + }) +# --- +# name: test_sensors[sensor.sfr_box_dsl_noise_up] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'SFR Box DSL noise up', + 'state_class': , + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.sfr_box_dsl_noise_up', + 'last_changed': , + 'last_updated': , + 'state': '6.0', + }) +# --- +# name: test_sensors[sensor.sfr_box_dsl_rate_down] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'SFR Box DSL rate down', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sfr_box_dsl_rate_down', + 'last_changed': , + 'last_updated': , + 'state': '5549', + }) +# --- +# name: test_sensors[sensor.sfr_box_dsl_rate_up] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'SFR Box DSL rate up', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sfr_box_dsl_rate_up', + 'last_changed': , + 'last_updated': , + 'state': '187', + }) +# --- +# name: test_sensors[sensor.sfr_box_dsl_training] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'SFR Box DSL training', + 'options': list([ + 'idle', + 'g_994_training', + 'g_992_started', + 'g_922_channel_analysis', + 'g_992_message_exchange', + 'g_993_started', + 'g_993_channel_analysis', + 'g_993_message_exchange', + 'showtime', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.sfr_box_dsl_training', + 'last_changed': , + 'last_updated': , + 'state': 'showtime', + }) +# --- +# name: test_sensors[sensor.sfr_box_network_infrastructure] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'SFR Box Network infrastructure', + 'options': list([ + 'adsl', + 'ftth', + 'gprs', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.sfr_box_network_infrastructure', + 'last_changed': , + 'last_updated': , + 'state': 'adsl', + }) +# --- +# name: test_sensors[sensor.sfr_box_temperature] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'SFR Box Temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sfr_box_temperature', + 'last_changed': , + 'last_updated': , + 'state': '27.56', + }) +# --- +# name: test_sensors[sensor.sfr_box_voltage] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SFR Box Voltage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sfr_box_voltage', + 'last_changed': , + 'last_updated': , + 'state': '12251', + }) +# --- diff --git a/tests/components/sfr_box/test_sensor.py b/tests/components/sfr_box/test_sensor.py index cd8b868bac..4e2c9e33a7 100644 --- a/tests/components/sfr_box/test_sensor.py +++ b/tests/components/sfr_box/test_sensor.py @@ -1,18 +1,16 @@ """Test the SFR Box sensors.""" from collections.abc import Generator -from types import MappingProxyType from unittest.mock import patch import pytest +from syrupy.assertion import SnapshotAssertion +from homeassistant.components.sfr_box import DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import check_device_registry, check_entities -from .const import ATTR_DEFAULT_DISABLED, EXPECTED_ENTITIES - pytestmark = pytest.mark.usefixtures("system_get_info", "dsl_get_info") @@ -23,37 +21,30 @@ def override_platforms() -> Generator[None, None, None]: yield -def _check_and_enable_disabled_entities( - entity_registry: er.EntityRegistry, expected_entities: MappingProxyType -) -> None: - """Ensure that the expected_entities are correctly disabled.""" - for expected_entity in expected_entities: - if expected_entity.get(ATTR_DEFAULT_DISABLED): - entity_id = expected_entity[ATTR_ENTITY_ID] - registry_entry = entity_registry.entities.get(entity_id) - assert registry_entry, f"Registry entry not found for {entity_id}" - assert registry_entry.disabled - assert registry_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - entity_registry.async_update_entity(entity_id, **{"disabled_by": None}) - - async def test_sensors( hass: HomeAssistant, config_entry: ConfigEntry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for SFR Box sensors.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - check_device_registry(device_registry, EXPECTED_ENTITIES["expected_device"]) + device_entry = device_registry.async_get_device({(DOMAIN, "e4:5d:51:00:11:22")}) + assert device_entry == snapshot - expected_entities = EXPECTED_ENTITIES[Platform.SENSOR] - assert len(entity_registry.entities) == len(expected_entities) + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert entity_entries == snapshot + + for entity in entity_entries: + entity_registry.async_update_entity(entity.entity_id, **{"disabled_by": None}) - _check_and_enable_disabled_entities(entity_registry, expected_entities) await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() - check_entities(hass, entity_registry, expected_entities) + for entity in entity_entries: + assert hass.states.get(entity.entity_id) == snapshot(name=entity.entity_id) From 5c42261210230b3346e148f544e5af169c198ace Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 13 Mar 2023 10:56:18 +0100 Subject: [PATCH 0873/2810] Refactor Command line binary sensor to inherit TemplateEntity (#81212) * Refactor binary sensor * Align --- .../components/command_line/binary_sensor.py | 43 +++++++++++++------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index f4a3a29f29..2e1ddb7a96 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -23,8 +23,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.reload import setup_reload_service +from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.template import Template +from homeassistant.helpers.template_entity import ( + TEMPLATE_ENTITY_BASE_SCHEMA, + TemplateEntity, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS @@ -51,17 +55,21 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Command line Binary Sensor.""" - setup_reload_service(hass, DOMAIN, PLATFORMS) + await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - name: str = config[CONF_NAME] + binary_sensor_config = vol.Schema( + TEMPLATE_ENTITY_BASE_SCHEMA.schema, extra=vol.REMOVE_EXTRA + )(config) + + name: str = config.get(CONF_NAME, DEFAULT_NAME) command: str = config[CONF_COMMAND] payload_off: str = config[CONF_PAYLOAD_OFF] payload_on: str = config[CONF_PAYLOAD_ON] @@ -73,9 +81,11 @@ def setup_platform( value_template.hass = hass data = CommandSensorData(hass, command, command_timeout) - add_entities( + async_add_entities( [ CommandBinarySensor( + hass, + binary_sensor_config, data, name, device_class, @@ -89,11 +99,13 @@ def setup_platform( ) -class CommandBinarySensor(BinarySensorEntity): +class CommandBinarySensor(TemplateEntity, BinarySensorEntity): """Representation of a command line binary sensor.""" def __init__( self, + hass: HomeAssistant, + config: ConfigType, data: CommandSensorData, name: str, device_class: BinarySensorDeviceClass | None, @@ -103,22 +115,29 @@ class CommandBinarySensor(BinarySensorEntity): unique_id: str | None, ) -> None: """Initialize the Command line binary sensor.""" + TemplateEntity.__init__( + self, + hass, + config=config, + fallback_name=name, + unique_id=unique_id, + ) self.data = data - self._attr_name = name self._attr_device_class = device_class self._attr_is_on = None self._payload_on = payload_on self._payload_off = payload_off self._value_template = value_template - self._attr_unique_id = unique_id - def update(self) -> None: + async def async_update(self) -> None: """Get the latest data and updates the state.""" - self.data.update() + await self.hass.async_add_executor_job(self.data.update) value = self.data.value if self._value_template is not None: - value = self._value_template.render_with_possible_json_value(value, False) + value = await self.hass.async_add_executor_job( + self._value_template.render_with_possible_json_value, value, False + ) if value == self._payload_on: self._attr_is_on = True elif value == self._payload_off: From 5c4f93fa3601f8a96506ec171eef61e1c09794b0 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 13 Mar 2023 10:57:30 +0100 Subject: [PATCH 0874/2810] Refactor Command line cover to inherit TemplateEntity (#81214) * Refactor cover * Remove not needed --- .../components/command_line/cover.py | 41 ++++++++++++++----- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index 8298201228..53773ae4e9 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -20,8 +20,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.reload import setup_reload_service +from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.template import Template +from homeassistant.helpers.template_entity import ( + TEMPLATE_ENTITY_BASE_SCHEMA, + TemplateEntity, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import call_shell_with_timeout, check_output_or_log @@ -47,15 +51,15 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up cover controlled by shell commands.""" - setup_reload_service(hass, DOMAIN, PLATFORMS) + await async_setup_reload_service(hass, DOMAIN, PLATFORMS) devices: dict[str, Any] = config.get(CONF_COVERS, {}) covers = [] @@ -65,8 +69,14 @@ def setup_platform( if value_template is not None: value_template.hass = hass + cover_config = vol.Schema( + TEMPLATE_ENTITY_BASE_SCHEMA.schema, extra=vol.REMOVE_EXTRA + )(device_config) + covers.append( CommandCover( + hass, + cover_config, device_config.get(CONF_FRIENDLY_NAME, device_name), device_config[CONF_COMMAND_OPEN], device_config[CONF_COMMAND_CLOSE], @@ -82,14 +92,16 @@ def setup_platform( _LOGGER.error("No covers added") return - add_entities(covers) + async_add_entities(covers) -class CommandCover(CoverEntity): +class CommandCover(TemplateEntity, CoverEntity): """Representation a command line cover.""" def __init__( self, + hass: HomeAssistant, + config: ConfigType, name: str, command_open: str, command_close: str, @@ -100,7 +112,13 @@ class CommandCover(CoverEntity): unique_id: str | None, ) -> None: """Initialize the cover.""" - self._attr_name = name + TemplateEntity.__init__( + self, + hass, + config=config, + fallback_name=name, + unique_id=unique_id, + ) self._state: int | None = None self._command_open = command_open self._command_close = command_close @@ -108,7 +126,6 @@ class CommandCover(CoverEntity): self._command_state = command_state self._value_template = value_template self._timeout = timeout - self._attr_unique_id = unique_id self._attr_should_poll = bool(command_state) def _move_cover(self, command: str) -> bool: @@ -148,12 +165,14 @@ class CommandCover(CoverEntity): if TYPE_CHECKING: return None - def update(self) -> None: + async def async_update(self) -> None: """Update device state.""" if self._command_state: - payload = str(self._query_state()) + payload = str(await self.hass.async_add_executor_job(self._query_state)) if self._value_template: - payload = self._value_template.render_with_possible_json_value(payload) + payload = await self.hass.async_add_executor_job( + self._value_template.render_with_possible_json_value, payload + ) self._state = int(payload) def open_cover(self, **kwargs: Any) -> None: From 7284af6a3ecdf1f92d92238b2ab4747c4fa6c312 Mon Sep 17 00:00:00 2001 From: David Poll Date: Mon, 13 Mar 2023 03:00:05 -0700 Subject: [PATCH 0875/2810] Add an in-memory-preloading loader for Jinja imports (#88850) * Adds a loader to enable jinja imports. * Switch to in-memory * Move loading custom_jinja off of the event loop * Raise TemplateNotFound if template doesn't exist * Fix docstring * Adds a service to reload custom jinja * Remove IO from test setup * Improve coverage and small refactor * Incorporate feedback and use .jinja extension * Check the loaded sources in test. * Incorporate PR feedback. * Update homeassistant/helpers/template.py Co-authored-by: Erik Montnemery --------- Co-authored-by: Erik Montnemery --- homeassistant/bootstrap.py | 2 + .../components/homeassistant/__init__.py | 25 ++++++-- .../components/homeassistant/services.yaml | 6 ++ homeassistant/helpers/template.py | 62 +++++++++++++++++++ tests/components/homeassistant/test_init.py | 18 ++++++ tests/helpers/test_template.py | 61 ++++++++++++++++++ .../custom_jinja/inner/inner_test.jinja | 5 ++ tests/testing_config/custom_jinja/test.jinja | 5 ++ 8 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 tests/testing_config/custom_jinja/inner/inner_test.jinja create mode 100644 tests/testing_config/custom_jinja/test.jinja diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 29772e865a..9ba4e99a08 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -31,6 +31,7 @@ from .helpers import ( entity_registry, issue_registry, recorder, + template, ) from .helpers.dispatcher import async_dispatcher_send from .helpers.typing import ConfigType @@ -244,6 +245,7 @@ async def load_registries(hass: core.HomeAssistant) -> None: entity_registry.async_load(hass), issue_registry.async_load(hass), hass.async_add_executor_job(_cache_uname_processor), + template.async_load_custom_jinja(hass), ) diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 5602fd6b59..4b033fd711 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -30,6 +30,7 @@ from homeassistant.helpers.service import ( async_extract_referenced_entity_ids, async_register_admin_service, ) +from homeassistant.helpers.template import async_load_custom_jinja from homeassistant.helpers.typing import ConfigType ATTR_ENTRY_ID = "entry_id" @@ -38,6 +39,7 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = ha.DOMAIN SERVICE_RELOAD_CORE_CONFIG = "reload_core_config" SERVICE_RELOAD_CONFIG_ENTRY = "reload_config_entry" +SERVICE_RELOAD_CUSTOM_JINJA = "reload_custom_jinja" SERVICE_CHECK_CONFIG = "check_config" SERVICE_UPDATE_ENTITY = "update_entity" SERVICE_SET_LOCATION = "set_location" @@ -258,6 +260,14 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no vol.Schema({ATTR_LATITUDE: cv.latitude, ATTR_LONGITUDE: cv.longitude}), ) + async def async_handle_reload_jinja(call: ha.ServiceCall) -> None: + """Service handler to reload custom Jinja.""" + await async_load_custom_jinja(hass) + + async_register_admin_service( + hass, ha.DOMAIN, SERVICE_RELOAD_CUSTOM_JINJA, async_handle_reload_jinja + ) + async def async_handle_reload_config_entry(call: ha.ServiceCall) -> None: """Service handler for reloading a config entry.""" reload_entries = set() @@ -288,8 +298,10 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no reload of YAML configurations for the domain that support it. Additionally, it also calls the `homeasssitant.reload_core_config` - service, as that reloads the core YAML configuration, and the - `frontend.reload_themes` service, as that reloads the themes. + service, as that reloads the core YAML configuration, the + `frontend.reload_themes` service that reloads the themes, and the + `homeassistant.reload_custom_jinja` service that reloads any custom + jinja into memory. We only do so, if there are no configuration errors. """ @@ -315,10 +327,11 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no hass.services.async_call( domain, service, context=call.context, blocking=True ) - for domain, service in { - ha.DOMAIN: SERVICE_RELOAD_CORE_CONFIG, - "frontend": "reload_themes", - }.items() + for domain, service in ( + (ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG), + ("frontend", "reload_themes"), + (ha.DOMAIN, SERVICE_RELOAD_CUSTOM_JINJA), + ) ] await asyncio.gather(*tasks) diff --git a/homeassistant/components/homeassistant/services.yaml b/homeassistant/components/homeassistant/services.yaml index da52ff50d2..20f23402a7 100644 --- a/homeassistant/components/homeassistant/services.yaml +++ b/homeassistant/components/homeassistant/services.yaml @@ -59,6 +59,12 @@ update_entity: target: entity: {} +reload_custom_jinja: + name: Reload custom Jinja2 templates + description: >- + Reload Jinja2 templates found in the custom_jinja folder in your config. + New values will be applied on the next render of the template. + reload_config_entry: name: Reload config entry description: Reload a config entry that matches a target. diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 5205d51273..1c5d15801f 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -14,6 +14,7 @@ import json import logging import math from operator import attrgetter, contains +import pathlib import random import re import statistics @@ -73,6 +74,7 @@ from homeassistant.util.read_only_dict import ReadOnlyDict from homeassistant.util.thread import ThreadWithException from . import area_registry, device_registry, entity_registry, location as loc_helper +from .singleton import singleton from .typing import TemplateVarsType # mypy: allow-untyped-defs, no-check-untyped-defs @@ -85,6 +87,7 @@ _RENDER_INFO = "template.render_info" _ENVIRONMENT = "template.environment" _ENVIRONMENT_LIMITED = "template.environment_limited" _ENVIRONMENT_STRICT = "template.environment_strict" +_HASS_LOADER = "template.hass_loader" _RE_JINJA_DELIMITERS = re.compile(r"\{%|\{\{|\{#") # Match "simple" ints and floats. -1.0, 1, +5, 5.0 @@ -120,6 +123,8 @@ template_cv: ContextVar[tuple[str, str] | None] = ContextVar( CACHED_TEMPLATE_STATES = 512 EVAL_CACHE_SIZE = 512 +MAX_CUSTOM_JINJA_SIZE = 5 * 1024 * 1024 + @bind_hass def attach(hass: HomeAssistant, obj: Any) -> None: @@ -2056,6 +2061,60 @@ class LoggingUndefined(jinja2.Undefined): return super().__bool__() +async def async_load_custom_jinja(hass: HomeAssistant) -> None: + """Load all custom jinja files under 5MiB into memory.""" + return await hass.async_add_executor_job(_load_custom_jinja, hass) + + +def _load_custom_jinja(hass: HomeAssistant) -> None: + result = {} + jinja_path = hass.config.path("custom_jinja") + all_files = [ + item + for item in pathlib.Path(jinja_path).rglob("*.jinja") + if item.is_file() and item.stat().st_size <= MAX_CUSTOM_JINJA_SIZE + ] + for file in all_files: + content = file.read_text() + path = str(file.relative_to(jinja_path)) + result[path] = content + + _get_hass_loader(hass).sources = result + + +@singleton(_HASS_LOADER) +def _get_hass_loader(hass: HomeAssistant) -> HassLoader: + return HassLoader({}) + + +class HassLoader(jinja2.BaseLoader): + """An in-memory jinja loader that keeps track of templates that need to be reloaded.""" + + def __init__(self, sources: dict[str, str]) -> None: + """Initialize an empty HassLoader.""" + self._sources = sources + self._reload = 0 + + @property + def sources(self) -> dict[str, str]: + """Map filename to jinja source.""" + return self._sources + + @sources.setter + def sources(self, value: dict[str, str]) -> None: + self._sources = value + self._reload += 1 + + def get_source( + self, environment: jinja2.Environment, template: str + ) -> tuple[str, str | None, Callable[[], bool] | None]: + """Get in-memory sources.""" + if template not in self._sources: + raise jinja2.TemplateNotFound(template) + cur_reload = self._reload + return self._sources[template], template, lambda: cur_reload == self._reload + + class TemplateEnvironment(ImmutableSandboxedEnvironment): """The Home Assistant template environment.""" @@ -2159,6 +2218,9 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): if hass is None: return + # This environment has access to hass, attach its loader to enable imports. + self.loader = _get_hass_loader(hass) + # We mark these as a context functions to ensure they get # evaluated fresh with every execution, rather than executed # at compile time and the value stored. The context itself diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index 8b982dc1c3..4a04241696 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -14,6 +14,7 @@ from homeassistant.components.homeassistant import ( SERVICE_CHECK_CONFIG, SERVICE_RELOAD_ALL, SERVICE_RELOAD_CORE_CONFIG, + SERVICE_RELOAD_CUSTOM_JINJA, SERVICE_SET_LOCATION, ) from homeassistant.const import ( @@ -575,6 +576,21 @@ async def test_save_persistent_states(hass: HomeAssistant) -> None: assert mock_save.called +async def test_reload_custom_jinja(hass: HomeAssistant) -> None: + """Test we can call reload_custom_jinja.""" + await async_setup_component(hass, "homeassistant", {}) + with patch( + "homeassistant.components.homeassistant.async_load_custom_jinja", + return_value=None, + ) as mock_load_custom_jinja: + await hass.services.async_call( + "homeassistant", + SERVICE_RELOAD_CUSTOM_JINJA, + blocking=True, + ) + assert mock_load_custom_jinja.called + + async def test_reload_all( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -586,6 +602,7 @@ async def test_reload_all( notify = async_mock_service(hass, "notify", "reload") core_config = async_mock_service(hass, "homeassistant", "reload_core_config") themes = async_mock_service(hass, "frontend", "reload_themes") + jinja = async_mock_service(hass, "homeassistant", "reload_custom_jinja") with patch( "homeassistant.config.async_check_ha_config_file", @@ -632,3 +649,4 @@ async def test_reload_all( assert len(test2) == 1 assert len(core_config) == 1 assert len(themes) == 1 + assert len(jinja) == 1 diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 5122a4238e..740040835c 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -243,6 +243,67 @@ def test_iterating_domain_states(hass: HomeAssistant) -> None: ) +async def test_import(hass: HomeAssistant) -> None: + """Test that imports work from the config/custom_jinja folder.""" + await template.async_load_custom_jinja(hass) + assert "test.jinja" in template._get_hass_loader(hass).sources + assert "inner/inner_test.jinja" in template._get_hass_loader(hass).sources + assert ( + template.Template( + """ + {% import 'test.jinja' as t %} + {{ t.test_macro() }} {{ t.test_variable }} + """, + hass, + ).async_render() + == "macro variable" + ) + + assert ( + template.Template( + """ + {% import 'inner/inner_test.jinja' as t %} + {{ t.test_macro() }} {{ t.test_variable }} + """, + hass, + ).async_render() + == "inner macro inner variable" + ) + + with pytest.raises(TemplateError): + template.Template( + """ + {% import 'notfound.jinja' as t %} + {{ t.test_macro() }} {{ t.test_variable }} + """, + hass, + ).async_render() + + +async def test_import_change(hass: HomeAssistant) -> None: + """Test that a change in HassLoader results in updated imports.""" + await template.async_load_custom_jinja(hass) + to_test = template.Template( + """ + {% import 'test.jinja' as t %} + {{ t.test_macro() }} {{ t.test_variable }} + """, + hass, + ) + assert to_test.async_render() == "macro variable" + + template._get_hass_loader(hass).sources = { + "test.jinja": """ + {% macro test_macro() -%} + macro2 + {%- endmacro %} + + {% set test_variable = "variable2" %} + """ + } + assert to_test.async_render() == "macro2 variable2" + + def test_loop_controls(hass: HomeAssistant) -> None: """Test that loop controls are enabled.""" assert ( diff --git a/tests/testing_config/custom_jinja/inner/inner_test.jinja b/tests/testing_config/custom_jinja/inner/inner_test.jinja new file mode 100644 index 0000000000..e0cf20be76 --- /dev/null +++ b/tests/testing_config/custom_jinja/inner/inner_test.jinja @@ -0,0 +1,5 @@ +{% macro test_macro() -%} +inner macro +{%- endmacro %} + +{% set test_variable = "inner variable" %} \ No newline at end of file diff --git a/tests/testing_config/custom_jinja/test.jinja b/tests/testing_config/custom_jinja/test.jinja new file mode 100644 index 0000000000..44be0f3a57 --- /dev/null +++ b/tests/testing_config/custom_jinja/test.jinja @@ -0,0 +1,5 @@ +{% macro test_macro() -%} +macro +{%- endmacro %} + +{% set test_variable = "variable" %} \ No newline at end of file From e73e88b9222c9e55722c6a209632f89a7d0890b6 Mon Sep 17 00:00:00 2001 From: cnico Date: Mon, 13 Mar 2023 11:16:45 +0100 Subject: [PATCH 0876/2810] Bump flipr-api to 1.5.0 (#89598) flipr api 1.5.0 to use the new cloud api --- homeassistant/components/flipr/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/flipr/manifest.json b/homeassistant/components/flipr/manifest.json index e7b9c8bf81..73a0b3edb2 100644 --- a/homeassistant/components/flipr/manifest.json +++ b/homeassistant/components/flipr/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/flipr", "iot_class": "cloud_polling", "loggers": ["flipr_api"], - "requirements": ["flipr-api==1.4.4"] + "requirements": ["flipr-api==1.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4e25506c9f..1a19b09072 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -722,7 +722,7 @@ fixerio==1.0.0a0 fjaraskupan==2.2.0 # homeassistant.components.flipr -flipr-api==1.4.4 +flipr-api==1.5.0 # homeassistant.components.flux_led flux_led==0.28.35 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 02b7f7959c..685653627e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -550,7 +550,7 @@ fivem-api==0.1.2 fjaraskupan==2.2.0 # homeassistant.components.flipr -flipr-api==1.4.4 +flipr-api==1.5.0 # homeassistant.components.flux_led flux_led==0.28.35 From 78e8de9bd7cf631802c45680b435191d2cd0f265 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Mon, 13 Mar 2023 11:33:35 +0100 Subject: [PATCH 0877/2810] Add ESERA 1-Wire virtual integration (#89487) --- homeassistant/components/esera_onewire/__init__.py | 1 + homeassistant/components/esera_onewire/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/esera_onewire/__init__.py create mode 100644 homeassistant/components/esera_onewire/manifest.json diff --git a/homeassistant/components/esera_onewire/__init__.py b/homeassistant/components/esera_onewire/__init__.py new file mode 100644 index 0000000000..1adcf6cf63 --- /dev/null +++ b/homeassistant/components/esera_onewire/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: ESERA 1-Wire.""" diff --git a/homeassistant/components/esera_onewire/manifest.json b/homeassistant/components/esera_onewire/manifest.json new file mode 100644 index 0000000000..8d5e944c5c --- /dev/null +++ b/homeassistant/components/esera_onewire/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "esera_onewire", + "name": "ESERA 1-Wire", + "integration_type": "virtual", + "supported_by": "onewire" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9742af1edf..4adde5b244 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1473,6 +1473,11 @@ "config_flow": true, "iot_class": "local_push" }, + "esera_onewire": { + "name": "ESERA 1-Wire", + "integration_type": "virtual", + "supported_by": "onewire" + }, "esphome": { "name": "ESPHome", "integration_type": "device", From 6e10cd81ddc7ebf9f26f6ec044f64c7cb4766987 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 13 Mar 2023 11:43:41 +0100 Subject: [PATCH 0878/2810] Use repair issue when port enable fails in Reolink (#89591) * Reolink use repair issue for disabled ports * fix styling * Add port repair issue tests * Update homeassistant/components/reolink/strings.json Co-authored-by: Erik Montnemery --------- Co-authored-by: Erik Montnemery --- homeassistant/components/reolink/host.py | 33 +++++++++++-------- homeassistant/components/reolink/strings.json | 4 +++ tests/components/reolink/test_init.py | 23 ++++++++++++- 3 files changed, 46 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 9994afe79a..9d54191aad 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -109,23 +109,30 @@ class ReolinkHost: enable_rtsp=enable_rtsp, ) except ReolinkError: + ports = "" if enable_onvif: - _LOGGER.error( - "Failed to enable ONVIF on %s. " - "Set it to ON to receive notifications", - self._api.nvr_name, - ) + ports += "ONVIF " if enable_rtmp: - _LOGGER.error( - "Failed to enable RTMP on %s. Set it to ON", - self._api.nvr_name, - ) + ports += "RTMP " elif enable_rtsp: - _LOGGER.error( - "Failed to enable RTSP on %s. Set it to ON", - self._api.nvr_name, - ) + ports += "RTSP " + + ir.async_create_issue( + self._hass, + DOMAIN, + "enable_port", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="enable_port", + translation_placeholders={ + "name": self._api.nvr_name, + "ports": ports, + "info_link": "https://support.reolink.com/hc/en-us/articles/900004435763-How-to-Set-up-Reolink-Ports-Settings-via-Reolink-Client-New-Client-", + }, + ) + else: + ir.async_delete_issue(self._hass, DOMAIN, "enable_port") self._unique_id = format_mac(self._api.mac_address) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 3ab77d2b8f..c86917b4de 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -42,6 +42,10 @@ "https_webhook": { "title": "Reolink webhook URL uses HTTPS (SSL)", "description": "Reolink products can not push motion events to an HTTPS address (SSL), please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}). The current (local) address is: `{base_url}`" + }, + "enable_port": { + "title": "Reolink port not enabled", + "description": "Failed to automatically enable {ports}port(s) on {name}. Use the [Reolink client]({info_link}) to manually set it to ON" } }, "entity": { diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 035bfa6e53..52bd2d8c5f 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -86,7 +86,7 @@ async def test_entry_reloading( assert config_entry.title == "New Name" -async def test_http_no_repair_issue( +async def test_no_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test no repairs issue is raised when http local url is used.""" @@ -99,6 +99,7 @@ async def test_http_no_repair_issue( issue_registry = ir.async_get(hass) assert (const.DOMAIN, "https_webhook") not in issue_registry.issues + assert (const.DOMAIN, "enable_port") not in issue_registry.issues async def test_https_repair_issue( @@ -114,3 +115,23 @@ async def test_https_repair_issue( issue_registry = ir.async_get(hass) assert (const.DOMAIN, "https_webhook") in issue_registry.issues + + +@pytest.mark.parametrize("protocol", ["rtsp", "rtmp"]) +async def test_port_repair_issue( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + protocol: str, +) -> None: + """Test repairs issue is raised when auto enable of ports fails.""" + reolink_connect.set_net_port = AsyncMock(side_effect=ReolinkError("Test error")) + reolink_connect.onvif_enabled = False + reolink_connect.rtsp_enabled = False + reolink_connect.rtmp_enabled = False + reolink_connect.protocol = protocol + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + issue_registry = ir.async_get(hass) + assert (const.DOMAIN, "enable_port") in issue_registry.issues From 40ed3be4a82505c71349f26080598d214af2659f Mon Sep 17 00:00:00 2001 From: Arjan <44190435+vingerha@users.noreply.github.com> Date: Mon, 13 Mar 2023 11:57:49 +0100 Subject: [PATCH 0879/2810] Fix gtfs with 2023.3 (sqlachemy update) (#89175) --- homeassistant/components/gtfs/sensor.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index 3a79d8d88a..6cf1a6d460 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -342,12 +342,14 @@ def get_next_departure( origin_stop_time.departure_time LIMIT :limit """ - result = schedule.engine.execute( + result = schedule.engine.connect().execute( text(sql_query), - origin_station_id=start_station_id, - end_station_id=end_station_id, - today=now_date, - limit=limit, + { + "origin_station_id": start_station_id, + "end_station_id": end_station_id, + "today": now_date, + "limit": limit, + }, ) # Create lookup timetable for today and possibly tomorrow, taking into @@ -357,7 +359,8 @@ def get_next_departure( yesterday_start = today_start = tomorrow_start = None yesterday_last = today_last = "" - for row in result: + for row_cursor in result: + row = row_cursor._asdict() if row["yesterday"] == 1 and yesterday_date >= row["start_date"]: extras = {"day": "yesterday", "first": None, "last": False} if yesterday_start is None: @@ -800,7 +803,10 @@ class GTFSDepartureSensor(SensorEntity): @staticmethod def dict_for_table(resource: Any) -> dict: """Return a dictionary for the SQLAlchemy resource given.""" - return {col: getattr(resource, col) for col in resource.__table__.columns} + _dict = {} + for column in resource.__table__.columns: + _dict[column.name] = str(getattr(resource, column.name)) + return _dict def append_keys(self, resource: dict, prefix: str | None = None) -> None: """Properly format key val pairs to append to attributes.""" From 431a8d0047dd22f1c40a97db72cbf23efb844344 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 13 Mar 2023 12:16:52 +0100 Subject: [PATCH 0880/2810] Add support for dual lens cameras in Reolink (#89554) --- homeassistant/components/reolink/camera.py | 9 +++++++-- homeassistant/components/reolink/entity.py | 12 +++++++++--- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 19 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index 13471df339..a34f8c85d3 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -3,6 +3,8 @@ from __future__ import annotations import logging +from reolink_aio.api import DUAL_LENS_MODELS + from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -25,7 +27,7 @@ async def async_setup_entry( host = reolink_data.host cameras = [] - for channel in host.api.channels: + for channel in host.api.stream_channels: streams = ["sub", "main", "snapshots"] if host.api.protocol in ["rtmp", "flv"]: streams.append("ext") @@ -56,7 +58,10 @@ class ReolinkCamera(ReolinkChannelCoordinatorEntity, Camera): self._stream = stream - self._attr_name = self._stream + if self._host.api.model in DUAL_LENS_MODELS: + self._attr_name = f"{self._stream} lens {self._channel}" + else: + self._attr_name = self._stream self._attr_unique_id = f"{self._host.unique_id}_{self._channel}_{self._stream}" self._attr_entity_registry_enabled_default = stream == "sub" diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 3a962d099d..48652eac21 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -3,6 +3,8 @@ from __future__ import annotations from typing import TypeVar +from reolink_aio.api import DUAL_LENS_MODELS + from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( @@ -75,12 +77,16 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): self._channel = channel + dev_ch = channel + if self._host.api.model in DUAL_LENS_MODELS: + dev_ch = 0 + if self._host.api.is_nvr: self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, f"{self._host.unique_id}_ch{self._channel}")}, + identifiers={(DOMAIN, f"{self._host.unique_id}_ch{dev_ch}")}, via_device=(DOMAIN, self._host.unique_id), - name=self._host.api.camera_name(self._channel), - model=self._host.api.camera_model(self._channel), + name=self._host.api.camera_name(dev_ch), + model=self._host.api.camera_model(dev_ch), manufacturer=self._host.api.manufacturer, configuration_url=self._conf_url, ) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 5cb7530ec8..35ce21ab78 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.5.3"] + "requirements": ["reolink-aio==0.5.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1a19b09072..478db40569 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2237,7 +2237,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.3 +reolink-aio==0.5.4 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 685653627e..9122d05933 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1594,7 +1594,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.3 +reolink-aio==0.5.4 # homeassistant.components.python_script restrictedpython==6.0 From 429e52cf3db5e7f806c594e4d4b4f9917da20fc1 Mon Sep 17 00:00:00 2001 From: Penny Wood Date: Mon, 13 Mar 2023 19:40:09 +0800 Subject: [PATCH 0881/2810] Improve typing in climate.py (#89577) --- homeassistant/components/izone/climate.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py index 78fd87b8e2..3e19afcca2 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -1,6 +1,7 @@ """Support for the iZone HVAC.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -246,7 +247,7 @@ class ControllerDevice(ClimateEntity): zone.async_schedule_update_ha_state() @property - def unique_id(self): + def unique_id(self) -> str: """Return the ID of the controller device.""" return self._controller.device_uid @@ -256,7 +257,7 @@ class ControllerDevice(ClimateEntity): return f"iZone Controller {self._controller.device_uid}" @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any]: """Return the optional state attributes.""" return { "supply_temperature": show_temp( @@ -306,13 +307,13 @@ class ControllerDevice(ClimateEntity): @property @_return_on_connection_error(PRESET_NONE) - def preset_mode(self): + def preset_mode(self) -> str: """Eco mode is external air.""" return PRESET_ECO if self._controller.free_air else PRESET_NONE @property @_return_on_connection_error([PRESET_NONE]) - def preset_modes(self): + def preset_modes(self) -> list[str]: """Available preset modes, normal or eco.""" if self._controller.free_air_enabled: return [PRESET_NONE, PRESET_ECO] @@ -507,7 +508,7 @@ class ZoneDevice(ClimateEntity): return self._controller.available @property - def unique_id(self): + def unique_id(self) -> str: """Return the ID of the controller device.""" return f"{self._controller.unique_id}_z{self._zone.index + 1}" @@ -539,29 +540,29 @@ class ZoneDevice(ClimateEntity): return list(self._state_to_pizone) @property - def current_temperature(self): + def current_temperature(self) -> float: """Return the current temperature.""" return self._zone.temp_current @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" if self._zone.type != Zone.Type.AUTO: return None return self._zone.temp_setpoint @property - def target_temperature_step(self): + def target_temperature_step(self) -> float: """Return the supported step of target temperature.""" return 0.5 @property - def min_temp(self): + def min_temp(self) -> float: """Return the minimum temperature.""" return self._controller.min_temp @property - def max_temp(self): + def max_temp(self) -> float: """Return the maximum temperature.""" return self._controller.max_temp @@ -626,7 +627,7 @@ class ZoneDevice(ClimateEntity): return self._zone.index @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any]: """Return the optional state attributes.""" return { "airflow_max": self._zone.airflow_max, From f3da95fb1f43329028e918dbea0a1e6dab34556c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Mar 2023 13:37:51 +0100 Subject: [PATCH 0882/2810] Use SnapshotAssertion in SFR binary sensor tests (#89624) --- tests/components/sfr_box/const.py | 10 --- .../sfr_box/snapshots/test_binary_sensor.ambr | 75 +++++++++++++++++++ .../components/sfr_box/test_binary_sensor.py | 21 ++++-- 3 files changed, 88 insertions(+), 18 deletions(-) create mode 100644 tests/components/sfr_box/snapshots/test_binary_sensor.ambr diff --git a/tests/components/sfr_box/const.py b/tests/components/sfr_box/const.py index c3ed56e32a..44a2ce4a57 100644 --- a/tests/components/sfr_box/const.py +++ b/tests/components/sfr_box/const.py @@ -1,5 +1,4 @@ """Constants for SFR Box tests.""" -from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.button import ButtonDeviceClass from homeassistant.components.sensor import ATTR_OPTIONS, ATTR_STATE_CLASS from homeassistant.components.sfr_box.const import DOMAIN @@ -12,7 +11,6 @@ from homeassistant.const import ( ATTR_STATE, ATTR_SW_VERSION, ATTR_UNIT_OF_MEASUREMENT, - STATE_ON, STATE_UNKNOWN, Platform, ) @@ -33,14 +31,6 @@ EXPECTED_ENTITIES = { ATTR_NAME: "SFR Box", ATTR_SW_VERSION: "NB6VAC-MAIN-R4.0.44k", }, - Platform.BINARY_SENSOR: [ - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.CONNECTIVITY, - ATTR_ENTITY_ID: "binary_sensor.sfr_box_dsl_status", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_status", - }, - ], Platform.BUTTON: [ { ATTR_DEVICE_CLASS: ButtonDeviceClass.RESTART, diff --git a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr new file mode 100644 index 0000000000..c3442c08e2 --- /dev/null +++ b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr @@ -0,0 +1,75 @@ +# serializer version: 1 +# name: test_binary_sensors + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://192.168.0.1', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'sfr_box', + 'e4:5d:51:00:11:22', + ), + }), + 'is_new': False, + 'manufacturer': None, + 'model': 'NB6VAC-FXC-r0', + 'name': 'SFR Box', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'NB6VAC-MAIN-R4.0.44k', + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensors.1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.sfr_box_dsl_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DSL status', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e4:5d:51:00:11:22_dsl_status', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_binary_sensors[binary_sensor.sfr_box_dsl_status] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'SFR Box DSL status', + }), + 'context': , + 'entity_id': 'binary_sensor.sfr_box_dsl_status', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/sfr_box/test_binary_sensor.py b/tests/components/sfr_box/test_binary_sensor.py index 0aed381cff..03e0677713 100644 --- a/tests/components/sfr_box/test_binary_sensor.py +++ b/tests/components/sfr_box/test_binary_sensor.py @@ -1,17 +1,15 @@ -"""Test the SFR Box sensors.""" +"""Test the SFR Box binary sensors.""" from collections.abc import Generator from unittest.mock import patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import check_device_registry, check_entities -from .const import EXPECTED_ENTITIES - pytestmark = pytest.mark.usefixtures("system_get_info", "dsl_get_info") @@ -27,14 +25,21 @@ async def test_binary_sensors( config_entry: ConfigEntry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for SFR Box binary sensors.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - check_device_registry(device_registry, EXPECTED_ENTITIES["expected_device"]) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries == snapshot - expected_entities = EXPECTED_ENTITIES[Platform.BINARY_SENSOR] - assert len(entity_registry.entities) == len(expected_entities) + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert entity_entries == snapshot - check_entities(hass, entity_registry, expected_entities) + for entity in entity_entries: + assert hass.states.get(entity.entity_id) == snapshot(name=entity.entity_id) From a230732087f44ebfa1c7e950120b5385abfa1243 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 13 Mar 2023 13:56:08 +0100 Subject: [PATCH 0883/2810] Correct naming of some otbr tests (#89631) --- tests/components/otbr/test_websocket_api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index 1c44091ae5..32c5ae19e0 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -154,7 +154,7 @@ async def test_create_network_no_entry( assert msg["error"]["code"] == "not_loaded" -async def test_get_info_fetch_fails_1( +async def test_create_network_fails_1( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_config_entry, @@ -180,7 +180,7 @@ async def test_get_info_fetch_fails_1( assert msg["error"]["code"] == "set_enabled_failed" -async def test_get_info_fetch_fails_2( +async def test_create_network_fails_2( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_config_entry, @@ -208,7 +208,7 @@ async def test_get_info_fetch_fails_2( assert msg["error"]["code"] == "create_active_dataset_failed" -async def test_get_info_fetch_fails_3( +async def test_create_network_fails_3( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_config_entry, From 3637d787cf5fe85146aa693a00813928e539211e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 13 Mar 2023 14:01:28 +0100 Subject: [PATCH 0884/2810] Fix `intellifire` name property (#89632) Fix intellifire name property --- homeassistant/components/intellifire/entity.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/intellifire/entity.py b/homeassistant/components/intellifire/entity.py index 1e406aeb11..6ef63f5347 100644 --- a/homeassistant/components/intellifire/entity.py +++ b/homeassistant/components/intellifire/entity.py @@ -11,6 +11,7 @@ class IntellifireEntity(CoordinatorEntity[IntellifireDataUpdateCoordinator]): """Define a generic class for Intellifire entities.""" _attr_attribution = "Data provided by unpublished Intellifire API" + _attr_has_entity_name = True def __init__( self, @@ -20,9 +21,6 @@ class IntellifireEntity(CoordinatorEntity[IntellifireDataUpdateCoordinator]): """Class initializer.""" super().__init__(coordinator=coordinator) self.entity_description = description - # Set the Display name the User will see - self._attr_name = description.name self._attr_unique_id = f"{description.key}_{coordinator.read_api.data.serial}" - self._attr_has_entity_name = True # Configure the Device Info self._attr_device_info = self.coordinator.device_info From 179cc4d7f78b685e7f8cdac7ed13ee85ae08ce43 Mon Sep 17 00:00:00 2001 From: anotherthomas Date: Mon, 13 Mar 2023 14:46:16 +0100 Subject: [PATCH 0885/2810] Improve warnings in mqtt light messages (#89552) * improved warnings in mqtt light messages. * fixed tests. --- .../components/mqtt/light/schema_json.py | 25 +++++++++++++------ tests/components/mqtt/test_light_json.py | 12 ++++++--- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 55b2f99d53..e0b20436fe 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -260,7 +260,9 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): pass except ValueError: _LOGGER.warning( - "Invalid RGB color value received for entity %s", self.entity_id + "Invalid RGB color value '%s' received for entity %s", + values, + self.entity_id, ) return @@ -272,7 +274,9 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): pass except ValueError: _LOGGER.warning( - "Invalid XY color value received for entity %s", self.entity_id + "Invalid XY color value '%s' received for entity %s", + values, + self.entity_id, ) return @@ -284,14 +288,18 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): pass except ValueError: _LOGGER.warning( - "Invalid HS color value received for entity %s", self.entity_id + "Invalid HS color value '%s' received for entity %s", + values, + self.entity_id, ) return else: color_mode: str = values["color_mode"] if not self._supports_color_mode(color_mode): _LOGGER.warning( - "Invalid color mode received for entity %s", self.entity_id + "Invalid color mode '%s' received for entity %s", + color_mode, + self.entity_id, ) return try: @@ -333,7 +341,8 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self._attr_xy_color = (x, y) except (KeyError, ValueError): _LOGGER.warning( - "Invalid or incomplete color value received for entity %s", + "Invalid or incomplete color value '%s' received for entity %s", + values, self.entity_id, ) @@ -378,7 +387,8 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): pass except (TypeError, ValueError): _LOGGER.warning( - "Invalid brightness value received for entity %s", + "Invalid brightness value '%s' received for entity %s", + values["brightness"], self.entity_id, ) @@ -397,7 +407,8 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): pass except ValueError: _LOGGER.warning( - "Invalid color temp value received for entity %s", + "Invalid color temp value '%s' received for entity %s", + values["color_temp"], self.entity_id, ) diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index be664d1c1f..dc73d7e6d1 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -616,14 +616,17 @@ async def test_controlling_state_via_topic2( async_fire_mqtt_message( hass, "test_light_rgb", '{"state":"ON", "color_mode":"col_temp"}' ) - assert "Invalid color mode received" in caplog.text + assert "Invalid color mode 'col_temp' received" in caplog.text caplog.clear() # Incomplete color async_fire_mqtt_message( hass, "test_light_rgb", '{"state":"ON", "color_mode":"rgb"}' ) - assert "Invalid or incomplete color value received" in caplog.text + assert ( + "Invalid or incomplete color value '{'state': 'ON', 'color_mode': 'rgb'}' received" + in caplog.text + ) caplog.clear() # Invalid color @@ -632,7 +635,10 @@ async def test_controlling_state_via_topic2( "test_light_rgb", '{"state":"ON", "color_mode":"rgb", "color":{"r":64,"g":128,"b":"cow"}}', ) - assert "Invalid or incomplete color value received" in caplog.text + assert ( + "Invalid or incomplete color value '{'state': 'ON', 'color_mode': 'rgb', 'color': {'r': 64, 'g': 128, 'b': 'cow'}}' received" + in caplog.text + ) async def test_sending_mqtt_commands_and_optimistic( From 3aeda1792a326de0a37898895044133e51a25756 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Mon, 13 Mar 2023 15:00:50 +0100 Subject: [PATCH 0886/2810] Bump easyEnergy to v0.2.1 (#89630) --- homeassistant/components/easyenergy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json index 6b88dd84c8..fc0a4fd773 100644 --- a/homeassistant/components/easyenergy/manifest.json +++ b/homeassistant/components/easyenergy/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/easyenergy", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["easyenergy==0.1.2"] + "requirements": ["easyenergy==0.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 478db40569..e95acc4de4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -625,7 +625,7 @@ dynalite_devices==0.1.47 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==0.1.2 +easyenergy==0.2.1 # homeassistant.components.ebusd ebusdpy==0.0.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9122d05933..d242e7a8df 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -493,7 +493,7 @@ dynalite_devices==0.1.47 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==0.1.2 +easyenergy==0.2.1 # homeassistant.components.elgato elgato==4.0.1 From cdfb43d4035724ab58c7d62a83abc76029876182 Mon Sep 17 00:00:00 2001 From: Thijs Walcarius Date: Mon, 13 Mar 2023 15:06:45 +0100 Subject: [PATCH 0887/2810] Address late review comments for frontier_silicon config flow (#89507) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> Co-authored-by: wlcrs --- .../frontier_silicon/config_flow.py | 62 +++++++++---------- .../components/frontier_silicon/strings.json | 2 - .../frontier_silicon/test_config_flow.py | 40 +++++++++++- 3 files changed, 67 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py index 5e9472de62..a3fbdb52c1 100644 --- a/homeassistant/components/frontier_silicon/config_flow.py +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -37,19 +37,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self) -> None: - """Initialize flow.""" - - self._webfsapi_url: str | None = None - self._name: str | None = None - self._unique_id: str | None = None + _webfsapi_url: str async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult: """Handle the import of legacy configuration.yaml entries.""" device_url = f"http://{import_info[CONF_HOST]}:{import_info[CONF_PORT]}/device" try: - self._webfsapi_url = await AFSAPI.get_webfsapi_endpoint(device_url) + webfsapi_url = await AFSAPI.get_webfsapi_endpoint(device_url) except FSConnectionError: return self.async_abort(reason="cannot_connect") except Exception as exception: # pylint: disable=broad-except @@ -57,9 +52,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="unknown") try: - afsapi = AFSAPI(self._webfsapi_url, import_info[CONF_PIN]) + afsapi = AFSAPI(webfsapi_url, import_info[CONF_PIN]) - self._unique_id = await afsapi.get_radio_id() + unique_id = await afsapi.get_radio_id() except FSConnectionError: return self.async_abort(reason="cannot_connect") except InvalidPinException: @@ -68,12 +63,16 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception(exception) return self.async_abort(reason="unknown") - await self.async_set_unique_id(self._unique_id, raise_on_progress=False) + await self.async_set_unique_id(unique_id, raise_on_progress=False) self._abort_if_unique_id_configured() - self._name = import_info[CONF_NAME] or "Radio" - - return await self._create_entry(pin=import_info[CONF_PIN]) + return self.async_create_entry( + title=import_info[CONF_NAME] or "Radio", + data={ + CONF_WEBFSAPI_URL: webfsapi_url, + CONF_PIN: import_info[CONF_PIN], + }, + ) async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -112,18 +111,21 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # try to login with default pin afsapi = AFSAPI(self._webfsapi_url, DEFAULT_PIN) - self._name = await afsapi.get_friendly_name() + name = await afsapi.get_friendly_name() except InvalidPinException: # Ask for a PIN return await self.async_step_device_config() - self.context["title_placeholders"] = {"name": self._name} + self.context["title_placeholders"] = {"name": name} - self._unique_id = await afsapi.get_radio_id() - await self.async_set_unique_id(self._unique_id) + unique_id = await afsapi.get_radio_id() + await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() - return await self._create_entry() + return self.async_create_entry( + title=name, + data={CONF_WEBFSAPI_URL: self._webfsapi_url, CONF_PIN: DEFAULT_PIN}, + ) async def async_step_device_config( self, user_input: dict[str, Any] | None = None @@ -132,7 +134,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): We ask for the PIN in this step. """ - assert self._webfsapi_url is not None if user_input is None: return self.async_show_form( @@ -144,7 +145,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: afsapi = AFSAPI(self._webfsapi_url, user_input[CONF_PIN]) - self._name = await afsapi.get_friendly_name() + name = await afsapi.get_friendly_name() except FSConnectionError: errors["base"] = "cannot_connect" @@ -154,10 +155,16 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception(exception) errors["base"] = "unknown" else: - self._unique_id = await afsapi.get_radio_id() - await self.async_set_unique_id(self._unique_id) + unique_id = await afsapi.get_radio_id() + await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() - return await self._create_entry(pin=user_input[CONF_PIN]) + return self.async_create_entry( + title=name, + data={ + CONF_WEBFSAPI_URL: self._webfsapi_url, + CONF_PIN: user_input[CONF_PIN], + }, + ) data_schema = self.add_suggested_values_to_schema( STEP_DEVICE_CONFIG_DATA_SCHEMA, user_input @@ -167,12 +174,3 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data_schema=data_schema, errors=errors, ) - - async def _create_entry(self, pin: str | None = None) -> FlowResult: - """Create the entry.""" - assert self._name is not None - assert self._webfsapi_url is not None - - data = {CONF_WEBFSAPI_URL: self._webfsapi_url, CONF_PIN: pin or DEFAULT_PIN} - - return self.async_create_entry(title=self._name, data=data) diff --git a/homeassistant/components/frontier_silicon/strings.json b/homeassistant/components/frontier_silicon/strings.json index 85b0b6958a..3a0a504761 100644 --- a/homeassistant/components/frontier_silicon/strings.json +++ b/homeassistant/components/frontier_silicon/strings.json @@ -1,9 +1,7 @@ { "config": { - "flow_title": "{name}", "step": { "user": { - "title": "Frontier Silicon Setup", "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" diff --git a/tests/components/frontier_silicon/test_config_flow.py b/tests/components/frontier_silicon/test_config_flow.py index a643b121c7..6a61f0b618 100644 --- a/tests/components/frontier_silicon/test_config_flow.py +++ b/tests/components/frontier_silicon/test_config_flow.py @@ -194,7 +194,10 @@ async def test_form_nondefault_pin( ], ) async def test_form_nondefault_pin_invalid( - hass: HomeAssistant, friendly_name_error: Exception, result_error: str + hass: HomeAssistant, + friendly_name_error: Exception, + result_error: str, + mock_setup_entry: AsyncMock, ) -> None: """Test we get the proper errors when trying to validate an user-provided PIN.""" result = await hass.config_entries.flow.async_init( @@ -232,6 +235,20 @@ async def test_form_nondefault_pin_invalid( assert result2["step_id"] == "device_config" assert result3["errors"] == {"base": result_error} + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + {CONF_PIN: "4321"}, + ) + await hass.async_block_till_done() + + assert result4["type"] == FlowResultType.CREATE_ENTRY + assert result4["title"] == "Name of the device" + assert result4["data"] == { + "webfsapi_url": "http://1.1.1.1:80/webfsapi", + "pin": "4321", + } + mock_setup_entry.assert_called_once() + @pytest.mark.parametrize( ("webfsapi_endpoint_error", "result_error"), @@ -241,9 +258,12 @@ async def test_form_nondefault_pin_invalid( ], ) async def test_invalid_device_url( - hass: HomeAssistant, webfsapi_endpoint_error: Exception, result_error: str + hass: HomeAssistant, + webfsapi_endpoint_error: Exception, + result_error: str, + mock_setup_entry: AsyncMock, ) -> None: - """Test we get the form.""" + """Test flow when the user provides an invalid device IP/hostname.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -264,3 +284,17 @@ async def test_invalid_device_url( assert result2["type"] == FlowResultType.FORM assert result2["step_id"] == "user" assert result2["errors"] == {"base": result_error} + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_PORT: 80}, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "Name of the device" + assert result3["data"] == { + "webfsapi_url": "http://1.1.1.1:80/webfsapi", + "pin": "1234", + } + mock_setup_entry.assert_called_once() From 15506da332122bbdf627ffcbd69ce6e2e35f8b1a Mon Sep 17 00:00:00 2001 From: tomrennen Date: Mon, 13 Mar 2023 15:15:13 +0100 Subject: [PATCH 0888/2810] Improved "ON" state check for `Use room sensor for cooling` (#89634) --- homeassistant/components/nibe_heatpump/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nibe_heatpump/climate.py b/homeassistant/components/nibe_heatpump/climate.py index 9c7d8641b6..a68aabacf4 100644 --- a/homeassistant/components/nibe_heatpump/climate.py +++ b/homeassistant/components/nibe_heatpump/climate.py @@ -139,7 +139,7 @@ class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): mode = HVACMode.OFF if _get_value(self._coil_use_room_sensor) == "ON": - if _get_value(self._coil_cooling_with_room_sensor) == "ON": + if _get_value(self._coil_cooling_with_room_sensor) != "OFF": mode = HVACMode.HEAT_COOL else: mode = HVACMode.HEAT From 07b25939a287e7ed20d3648b8a1f4674cc9f25d6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Mar 2023 15:23:00 +0100 Subject: [PATCH 0889/2810] Use SnapshotAssertion in SFR button tests (#89633) --- tests/components/sfr_box/__init__.py | 54 ------------- tests/components/sfr_box/const.py | 42 ----------- .../sfr_box/snapshots/test_button.ambr | 75 +++++++++++++++++++ tests/components/sfr_box/test_button.py | 25 +++++-- 4 files changed, 93 insertions(+), 103 deletions(-) delete mode 100644 tests/components/sfr_box/const.py create mode 100644 tests/components/sfr_box/snapshots/test_button.ambr diff --git a/tests/components/sfr_box/__init__.py b/tests/components/sfr_box/__init__.py index 651c419f4e..52d911ef83 100644 --- a/tests/components/sfr_box/__init__.py +++ b/tests/components/sfr_box/__init__.py @@ -1,55 +1 @@ """Tests for the SFR Box integration.""" -from __future__ import annotations - -from types import MappingProxyType - -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_IDENTIFIERS, - ATTR_MODEL, - ATTR_NAME, - ATTR_STATE, - ATTR_SW_VERSION, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceRegistry -from homeassistant.helpers.entity_registry import EntityRegistry - -from .const import ATTR_UNIQUE_ID, FIXED_ATTRIBUTES - - -def check_device_registry( - device_registry: DeviceRegistry, expected_device: MappingProxyType -) -> None: - """Ensure that the expected_device is correctly registered.""" - assert len(device_registry.devices) == 1 - registry_entry = device_registry.async_get_device(expected_device[ATTR_IDENTIFIERS]) - assert registry_entry is not None - assert registry_entry.identifiers == expected_device[ATTR_IDENTIFIERS] - assert registry_entry.name == expected_device[ATTR_NAME] - assert registry_entry.model == expected_device[ATTR_MODEL] - assert registry_entry.sw_version == expected_device[ATTR_SW_VERSION] - - -def check_entities( - hass: HomeAssistant, - entity_registry: EntityRegistry, - expected_entities: MappingProxyType, -) -> None: - """Ensure that the expected_entities are correct.""" - for expected_entity in expected_entities: - entity_id = expected_entity[ATTR_ENTITY_ID] - registry_entry = entity_registry.entities.get(entity_id) - assert registry_entry is not None - assert registry_entry.unique_id == expected_entity[ATTR_UNIQUE_ID] - state = hass.states.get(entity_id) - assert state, f"Expected valid state for {entity_id}, got {state}" - assert state.state == expected_entity[ATTR_STATE], ( - f"Expected state {expected_entity[ATTR_STATE]}, got {state.state} for" - f" {entity_id}" - ) - for attr in FIXED_ATTRIBUTES: - assert state.attributes.get(attr) == expected_entity.get(attr), ( - f"Expected attribute {attr} == {expected_entity.get(attr)}, got" - f" {state.attributes.get(attr)} for {entity_id}" - ) diff --git a/tests/components/sfr_box/const.py b/tests/components/sfr_box/const.py deleted file mode 100644 index 44a2ce4a57..0000000000 --- a/tests/components/sfr_box/const.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Constants for SFR Box tests.""" -from homeassistant.components.button import ButtonDeviceClass -from homeassistant.components.sensor import ATTR_OPTIONS, ATTR_STATE_CLASS -from homeassistant.components.sfr_box.const import DOMAIN -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, - ATTR_IDENTIFIERS, - ATTR_MODEL, - ATTR_NAME, - ATTR_STATE, - ATTR_SW_VERSION, - ATTR_UNIT_OF_MEASUREMENT, - STATE_UNKNOWN, - Platform, -) - -ATTR_DEFAULT_DISABLED = "default_disabled" -ATTR_UNIQUE_ID = "unique_id" -FIXED_ATTRIBUTES = ( - ATTR_DEVICE_CLASS, - ATTR_OPTIONS, - ATTR_STATE_CLASS, - ATTR_UNIT_OF_MEASUREMENT, -) - -EXPECTED_ENTITIES = { - "expected_device": { - ATTR_IDENTIFIERS: {(DOMAIN, "e4:5d:51:00:11:22")}, - ATTR_MODEL: "NB6VAC-FXC-r0", - ATTR_NAME: "SFR Box", - ATTR_SW_VERSION: "NB6VAC-MAIN-R4.0.44k", - }, - Platform.BUTTON: [ - { - ATTR_DEVICE_CLASS: ButtonDeviceClass.RESTART, - ATTR_ENTITY_ID: "button.sfr_box_reboot", - ATTR_STATE: STATE_UNKNOWN, - ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_system_reboot", - }, - ], -} diff --git a/tests/components/sfr_box/snapshots/test_button.ambr b/tests/components/sfr_box/snapshots/test_button.ambr new file mode 100644 index 0000000000..a7ce9334e1 --- /dev/null +++ b/tests/components/sfr_box/snapshots/test_button.ambr @@ -0,0 +1,75 @@ +# serializer version: 1 +# name: test_buttons + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://192.168.0.1', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'sfr_box', + 'e4:5d:51:00:11:22', + ), + }), + 'is_new': False, + 'manufacturer': None, + 'model': 'NB6VAC-FXC-r0', + 'name': 'SFR Box', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'NB6VAC-MAIN-R4.0.44k', + 'via_device_id': None, + }), + ]) +# --- +# name: test_buttons.1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.sfr_box_reboot', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reboot', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e4:5d:51:00:11:22_system_reboot', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_buttons[button.sfr_box_reboot] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'SFR Box Reboot', + }), + 'context': , + 'entity_id': 'button.sfr_box_reboot', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/sfr_box/test_button.py b/tests/components/sfr_box/test_button.py index 22666505cf..d1bb06fc79 100644 --- a/tests/components/sfr_box/test_button.py +++ b/tests/components/sfr_box/test_button.py @@ -4,6 +4,7 @@ from unittest.mock import patch import pytest from sfrbox_api.exceptions import SFRBoxError +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.config_entries import ConfigEntry @@ -12,9 +13,6 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import check_device_registry, check_entities -from .const import EXPECTED_ENTITIES - pytestmark = pytest.mark.usefixtures("system_get_info", "dsl_get_info") @@ -32,17 +30,30 @@ async def test_buttons( config_entry_with_auth: ConfigEntry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for SFR Box buttons.""" await hass.config_entries.async_setup(config_entry_with_auth.entry_id) await hass.async_block_till_done() - check_device_registry(device_registry, EXPECTED_ENTITIES["expected_device"]) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry_with_auth.entry_id + ) + assert device_entries == snapshot - expected_entities = EXPECTED_ENTITIES[Platform.BUTTON] - assert len(entity_registry.entities) == len(expected_entities) + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry_with_auth.entry_id + ) + assert entity_entries == snapshot - check_entities(hass, entity_registry, expected_entities) + for entity in entity_entries: + assert hass.states.get(entity.entity_id) == snapshot(name=entity.entity_id) + + +async def test_reboot(hass: HomeAssistant, config_entry_with_auth: ConfigEntry) -> None: + """Test for SFR Box reboot button.""" + await hass.config_entries.async_setup(config_entry_with_auth.entry_id) + await hass.async_block_till_done() # Reboot success service_data = {ATTR_ENTITY_ID: "button.sfr_box_reboot"} From 11e21378b17c82c33251afabb86a6b7c74d1e0f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 13 Mar 2023 15:39:49 +0100 Subject: [PATCH 0890/2810] Add sensors for supervisor host (#89461) Co-authored-by: Franck Nijhof --- homeassistant/components/hassio/__init__.py | 19 +++++++ homeassistant/components/hassio/const.py | 2 + homeassistant/components/hassio/entity.py | 27 +++++++++ homeassistant/components/hassio/sensor.py | 63 ++++++++++++++++++++- tests/components/hassio/test_diagnostics.py | 3 +- tests/components/hassio/test_init.py | 8 +-- tests/components/hassio/test_sensor.py | 13 +++-- 7 files changed, 122 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 0f17c0b527..25482ddde9 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -69,6 +69,7 @@ from .const import ( ATTR_VERSION, DATA_KEY_ADDONS, DATA_KEY_CORE, + DATA_KEY_HOST, DATA_KEY_OS, DATA_KEY_SUPERVISOR, DOMAIN, @@ -668,6 +669,22 @@ def async_register_os_in_dev_reg( dev_reg.async_get_or_create(config_entry_id=entry_id, **params) +@callback +def async_register_host_in_dev_reg( + entry_id: str, + dev_reg: dr.DeviceRegistry, +) -> None: + """Register host in the device registry.""" + params = DeviceInfo( + identifiers={(DOMAIN, "host")}, + manufacturer="Home Assistant", + model=SupervisorEntityModel.HOST, + name="Home Assistant Host", + entry_type=dr.DeviceEntryType.SERVICE, + ) + dev_reg.async_get_or_create(config_entry_id=entry_id, **params) + + @callback def async_register_core_in_dev_reg( entry_id: str, @@ -777,6 +794,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): **supervisor_info, **get_supervisor_stats(self.hass), } + new_data[DATA_KEY_HOST] = get_host_info(self.hass) or {} # If this is the initial refresh, register all addons and return the dict if not self.data: @@ -789,6 +807,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): async_register_supervisor_in_dev_reg( self.entry_id, self.dev_reg, new_data[DATA_KEY_SUPERVISOR] ) + async_register_host_in_dev_reg(self.entry_id, self.dev_reg) if self.is_hass_os: async_register_os_in_dev_reg( self.entry_id, self.dev_reg, new_data[DATA_KEY_OS] diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 2710e14654..cc9c58a3d2 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -68,6 +68,7 @@ DATA_KEY_ADDONS = "addons" DATA_KEY_OS = "os" DATA_KEY_SUPERVISOR = "supervisor" DATA_KEY_CORE = "core" +DATA_KEY_HOST = "host" class SupervisorEntityModel(str, Enum): @@ -77,3 +78,4 @@ class SupervisorEntityModel(str, Enum): OS = "Home Assistant Operating System" CORE = "Home Assistant Core" SUPERVIOSR = "Home Assistant Supervisor" + HOST = "Home Assistant Host" diff --git a/homeassistant/components/hassio/entity.py b/homeassistant/components/hassio/entity.py index dfa89ae911..3a6a5a9f7c 100644 --- a/homeassistant/components/hassio/entity.py +++ b/homeassistant/components/hassio/entity.py @@ -11,6 +11,7 @@ from .const import ( ATTR_SLUG, DATA_KEY_ADDONS, DATA_KEY_CORE, + DATA_KEY_HOST, DATA_KEY_OS, DATA_KEY_SUPERVISOR, ) @@ -71,6 +72,32 @@ class HassioOSEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): ) +class HassioHostEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): + """Base Entity for Hass.io host.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: HassioDataUpdateCoordinator, + entity_description: EntityDescription, + ) -> None: + """Initialize base entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = f"home_assistant_host_{entity_description.key}" + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, "host")}) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return ( + super().available + and DATA_KEY_HOST in self.coordinator.data + and self.entity_description.key in self.coordinator.data[DATA_KEY_HOST] + ) + + class HassioSupervisorEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): """Base Entity for Supervisor.""" diff --git a/homeassistant/components/hassio/sensor.py b/homeassistant/components/hassio/sensor.py index a5b0b3a725..b9a97adcbc 100644 --- a/homeassistant/components/hassio/sensor.py +++ b/homeassistant/components/hassio/sensor.py @@ -2,12 +2,13 @@ from __future__ import annotations from homeassistant.components.sensor import ( + SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -19,12 +20,14 @@ from .const import ( ATTR_VERSION_LATEST, DATA_KEY_ADDONS, DATA_KEY_CORE, + DATA_KEY_HOST, DATA_KEY_OS, DATA_KEY_SUPERVISOR, ) from .entity import ( HassioAddonEntity, HassioCoreEntity, + HassioHostEntity, HassioOSEntity, HassioSupervisorEntity, ) @@ -66,6 +69,45 @@ CORE_ENTITY_DESCRIPTIONS = STATS_ENTITY_DESCRIPTIONS OS_ENTITY_DESCRIPTIONS = COMMON_ENTITY_DESCRIPTIONS SUPERVISOR_ENTITY_DESCRIPTIONS = STATS_ENTITY_DESCRIPTIONS +HOST_ENTITY_DESCRIPTIONS = ( + SensorEntityDescription( + entity_registry_enabled_default=False, + key="agent_version", + name="OS Agent version", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + entity_registry_enabled_default=False, + key="apparmor_version", + name="Apparmor version", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + entity_registry_enabled_default=False, + key="disk_total", + name="Disk total", + native_unit_of_measurement=UnitOfInformation.GIGABYTES, + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + entity_registry_enabled_default=False, + key="disk_used", + name="Disk used", + native_unit_of_measurement=UnitOfInformation.GIGABYTES, + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + entity_registry_enabled_default=False, + key="disk_free", + name="Disk free", + native_unit_of_measurement=UnitOfInformation.GIGABYTES, + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -76,7 +118,7 @@ async def async_setup_entry( coordinator = hass.data[ADDONS_COORDINATOR] entities: list[ - HassioOSSensor | HassioAddonSensor | CoreSensor | SupervisorSensor + HassioOSSensor | HassioAddonSensor | CoreSensor | SupervisorSensor | HostSensor ] = [] for addon in coordinator.data[DATA_KEY_ADDONS].values(): @@ -105,6 +147,14 @@ async def async_setup_entry( ) ) + for entity_description in HOST_ENTITY_DESCRIPTIONS: + entities.append( + HostSensor( + coordinator=coordinator, + entity_description=entity_description, + ) + ) + if coordinator.is_hass_os: for entity_description in OS_ENTITY_DESCRIPTIONS: entities.append( @@ -153,3 +203,12 @@ class SupervisorSensor(HassioSupervisorEntity, SensorEntity): def native_value(self) -> str: """Return native value of entity.""" return self.coordinator.data[DATA_KEY_SUPERVISOR][self.entity_description.key] + + +class HostSensor(HassioHostEntity, SensorEntity): + """Sensor to track a host attribute.""" + + @property + def native_value(self) -> str: + """Return native value of entity.""" + return self.coordinator.data[DATA_KEY_HOST][self.entity_description.key] diff --git a/tests/components/hassio/test_diagnostics.py b/tests/components/hassio/test_diagnostics.py index b3d47e93af..6b0dae170c 100644 --- a/tests/components/hassio/test_diagnostics.py +++ b/tests/components/hassio/test_diagnostics.py @@ -211,5 +211,6 @@ async def test_diagnostics( assert "core" in diagnostics["coordinator_data"] assert "supervisor" in diagnostics["coordinator_data"] assert "os" in diagnostics["coordinator_data"] + assert "host" in diagnostics["coordinator_data"] - assert len(diagnostics["devices"]) == 5 + assert len(diagnostics["devices"]) == 6 diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index a752fe1b67..ead65d8129 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -678,7 +678,7 @@ async def test_device_registry_calls(hass: HomeAssistant) -> None: config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(dev_reg.devices) == 5 + assert len(dev_reg.devices) == 6 supervisor_mock_data = { "version": "1.0.0", @@ -709,11 +709,11 @@ async def test_device_registry_calls(hass: HomeAssistant) -> None: ): async_fire_time_changed(hass, dt_util.now() + timedelta(hours=1)) await hass.async_block_till_done() - assert len(dev_reg.devices) == 4 + assert len(dev_reg.devices) == 5 async_fire_time_changed(hass, dt_util.now() + timedelta(hours=2)) await hass.async_block_till_done() - assert len(dev_reg.devices) == 4 + assert len(dev_reg.devices) == 5 supervisor_mock_data = { "version": "1.0.0", @@ -763,7 +763,7 @@ async def test_device_registry_calls(hass: HomeAssistant) -> None: ): async_fire_time_changed(hass, dt_util.now() + timedelta(hours=3)) await hass.async_block_till_done() - assert len(dev_reg.devices) == 4 + assert len(dev_reg.devices) == 5 async def test_coordinator_updates( diff --git a/tests/components/hassio/test_sensor.py b/tests/components/hassio/test_sensor.py index 99b1db2a99..d33c669732 100644 --- a/tests/components/hassio/test_sensor.py +++ b/tests/components/hassio/test_sensor.py @@ -44,12 +44,10 @@ def mock_all(aioclient_mock, request): json={ "result": "ok", "data": { - "result": "ok", - "data": { - "chassis": "vm", - "operating_system": "Debian GNU/Linux 10 (buster)", - "kernel": "4.19.0-6-amd64", - }, + "agent_version": "1.0.0", + "chassis": "vm", + "operating_system": "Debian GNU/Linux 10 (buster)", + "kernel": "4.19.0-6-amd64", }, }, ) @@ -179,6 +177,9 @@ def mock_all(aioclient_mock, request): [ ("sensor.home_assistant_operating_system_version", "1.0.0"), ("sensor.home_assistant_operating_system_newest_version", "1.0.0"), + ("sensor.home_assistant_host_os_agent_version", "1.0.0"), + ("sensor.home_assistant_core_cpu_percent", "0.99"), + ("sensor.home_assistant_supervisor_cpu_percent", "0.99"), ("sensor.test_version", "2.0.0"), ("sensor.test_newest_version", "2.0.1"), ("sensor.test2_version", "3.1.0"), From 0f2abe7f252cb5c74ec9c43c7f86e247f4baff54 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 13 Mar 2023 15:52:36 +0100 Subject: [PATCH 0891/2810] Bump python-otbr-api to 1.0.9 (#89637) --- homeassistant/components/otbr/manifest.json | 2 +- homeassistant/components/thread/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index 7efe5fefc3..2590e92210 100644 --- a/homeassistant/components/otbr/manifest.json +++ b/homeassistant/components/otbr/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/otbr", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==1.0.8"] + "requirements": ["python-otbr-api==1.0.9"] } diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index 5fcb287796..3d61315f3d 100644 --- a/homeassistant/components/thread/manifest.json +++ b/homeassistant/components/thread/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/thread", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==1.0.8", "pyroute2==0.7.5"], + "requirements": ["python-otbr-api==1.0.9", "pyroute2==0.7.5"], "zeroconf": ["_meshcop._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index e95acc4de4..7c1f493071 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2097,7 +2097,7 @@ python-nest==4.2.0 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==1.0.8 +python-otbr-api==1.0.9 # homeassistant.components.picnic python-picnic-api==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d242e7a8df..915a14476d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1499,7 +1499,7 @@ python-nest==4.2.0 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==1.0.8 +python-otbr-api==1.0.9 # homeassistant.components.picnic python-picnic-api==1.1.0 From 8a0522ca2a0802d6eed114a9529a3baaed699944 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 13 Mar 2023 15:59:22 +0100 Subject: [PATCH 0892/2810] Include extended address in thread discovery data (#89640) --- homeassistant/components/thread/discovery.py | 3 +++ tests/components/thread/test_discovery.py | 4 ++++ tests/components/thread/test_websocket_api.py | 2 ++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/thread/discovery.py b/homeassistant/components/thread/discovery.py index b2373ff982..7dce5a429d 100644 --- a/homeassistant/components/thread/discovery.py +++ b/homeassistant/components/thread/discovery.py @@ -32,6 +32,7 @@ class ThreadRouterDiscoveryData: addresses: list[str] | None brand: str | None + extended_address: str | None extended_pan_id: str | None model_name: str | None network_name: str | None @@ -55,6 +56,7 @@ def async_discovery_data_from_service( except UnicodeDecodeError: return None + ext_addr = service.properties.get(b"xa") ext_pan_id = service.properties.get(b"xp") network_name = try_decode(service.properties.get(b"nn")) model_name = try_decode(service.properties.get(b"mn")) @@ -78,6 +80,7 @@ def async_discovery_data_from_service( return ThreadRouterDiscoveryData( addresses=service.parsed_addresses(), brand=brand, + extended_address=ext_addr.hex() if ext_addr is not None else None, extended_pan_id=ext_pan_id.hex() if ext_pan_id is not None else None, model_name=model_name, network_name=network_name, diff --git a/tests/components/thread/test_discovery.py b/tests/components/thread/test_discovery.py index e832f18c4e..84fe4c3097 100644 --- a/tests/components/thread/test_discovery.py +++ b/tests/components/thread/test_discovery.py @@ -73,6 +73,7 @@ async def test_discover_routers(hass: HomeAssistant, mock_async_zeroconf: None) discovery.ThreadRouterDiscoveryData( addresses=["192.168.0.115"], brand="homeassistant", + extended_address="aeeb2f594b570bbf", extended_pan_id="e60fc7c186212ce5", model_name="OpenThreadBorderRouter", network_name="OpenThread HC", @@ -98,6 +99,7 @@ async def test_discover_routers(hass: HomeAssistant, mock_async_zeroconf: None) discovery.ThreadRouterDiscoveryData( addresses=["192.168.0.124"], brand="google", + extended_address="f6a99b425a67abed", extended_pan_id="9e75e256f61409a3", model_name="Google Nest Hub", network_name="NEST-PAN-E1AF", @@ -175,6 +177,7 @@ async def test_discover_routers_unconfigured( discovery.ThreadRouterDiscoveryData( addresses=["192.168.0.115"], brand="homeassistant", + extended_address="aeeb2f594b570bbf", extended_pan_id="e60fc7c186212ce5", model_name="OpenThreadBorderRouter", network_name="OpenThread HC", @@ -219,6 +222,7 @@ async def test_discover_routers_bad_data( discovery.ThreadRouterDiscoveryData( addresses=["192.168.0.115"], brand=None, + extended_address="aeeb2f594b570bbf", extended_pan_id="e60fc7c186212ce5", model_name="OpenThreadBorderRouter", network_name="OpenThread HC", diff --git a/tests/components/thread/test_websocket_api.py b/tests/components/thread/test_websocket_api.py index 0f3a2ff765..f8f09b0b8c 100644 --- a/tests/components/thread/test_websocket_api.py +++ b/tests/components/thread/test_websocket_api.py @@ -236,6 +236,7 @@ async def test_discover_routers( "data": { "addresses": ["192.168.0.115"], "brand": "homeassistant", + "extended_address": "aeeb2f594b570bbf", "extended_pan_id": "e60fc7c186212ce5", "model_name": "OpenThreadBorderRouter", "network_name": "OpenThread HC", @@ -264,6 +265,7 @@ async def test_discover_routers( "data": { "addresses": ["192.168.0.124"], "brand": "google", + "extended_address": "f6a99b425a67abed", "extended_pan_id": "9e75e256f61409a3", "model_name": "Google Nest Hub", "network_name": "NEST-PAN-E1AF", From a7396af4bb8b5684727878eadd43676f481b583b Mon Sep 17 00:00:00 2001 From: Nick Borgers Date: Mon, 13 Mar 2023 11:06:29 -0400 Subject: [PATCH 0893/2810] Bump pybravia to 0.3.2 (#89635) --- homeassistant/components/braviatv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/braviatv/manifest.json b/homeassistant/components/braviatv/manifest.json index 295a56b324..c5b42e73be 100644 --- a/homeassistant/components/braviatv/manifest.json +++ b/homeassistant/components/braviatv/manifest.json @@ -7,7 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["pybravia"], - "requirements": ["pybravia==0.3.1"], + "requirements": ["pybravia==0.3.2"], "ssdp": [ { "st": "urn:schemas-sony-com:service:ScalarWebAPI:1", diff --git a/requirements_all.txt b/requirements_all.txt index 7c1f493071..26ccf0efab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1531,7 +1531,7 @@ pyblackbird==0.5 pybotvac==0.0.23 # homeassistant.components.braviatv -pybravia==0.3.1 +pybravia==0.3.2 # homeassistant.components.nissan_leaf pycarwings2==2.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 915a14476d..6ea71ed0b2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1119,7 +1119,7 @@ pyblackbird==0.5 pybotvac==0.0.23 # homeassistant.components.braviatv -pybravia==0.3.1 +pybravia==0.3.2 # homeassistant.components.cloudflare pycfdns==2.0.1 From 02389960ce62342fdfe7a213895c5e28dd92466b Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 13 Mar 2023 17:23:25 +0100 Subject: [PATCH 0894/2810] Refactor Command line sensor to inherit TemplateSensor (#81222) * Refactor sensor * Remove not needed * block until done * reset test * test sensor * Add time --- .../components/command_line/sensor.py | 68 ++++++++++++++----- tests/components/command_line/test_sensor.py | 22 +++++- 2 files changed, 72 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index 5dbbbf88e5..24224c12ca 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -8,9 +8,16 @@ import logging import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + DEVICE_CLASSES_SCHEMA, + PLATFORM_SCHEMA, + STATE_CLASSES_SCHEMA, + SensorEntity, +) from homeassistant.const import ( CONF_COMMAND, + CONF_DEVICE_CLASS, CONF_NAME, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, @@ -21,8 +28,12 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.reload import setup_reload_service +from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.template import Template +from homeassistant.helpers.template_entity import ( + TEMPLATE_SENSOR_BASE_SCHEMA, + TemplateSensor, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import check_output_or_log @@ -45,19 +56,25 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, } ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Command Sensor.""" - setup_reload_service(hass, DOMAIN, PLATFORMS) + await async_setup_reload_service(hass, DOMAIN, PLATFORMS) + + sensor_config = vol.Schema( + TEMPLATE_SENSOR_BASE_SCHEMA.schema, extra=vol.REMOVE_EXTRA + )(config) name: str = config[CONF_NAME] command: str = config[CONF_COMMAND] @@ -70,17 +87,30 @@ def setup_platform( json_attributes: list[str] | None = config.get(CONF_JSON_ATTRIBUTES) data = CommandSensorData(hass, command, command_timeout) - add_entities( - [CommandSensor(data, name, unit, value_template, json_attributes, unique_id)], + async_add_entities( + [ + CommandSensor( + hass, + sensor_config, + data, + name, + unit, + value_template, + json_attributes, + unique_id, + ) + ], True, ) -class CommandSensor(SensorEntity): +class CommandSensor(TemplateSensor, SensorEntity): """Representation of a sensor that is using shell commands.""" def __init__( self, + hass: HomeAssistant, + config: ConfigType, data: CommandSensorData, name: str, unit_of_measurement: str | None, @@ -89,18 +119,22 @@ class CommandSensor(SensorEntity): unique_id: str | None, ) -> None: """Initialize the sensor.""" + TemplateSensor.__init__( + self, + hass, + config=config, + fallback_name=name, + unique_id=unique_id, + ) self.data = data self._attr_extra_state_attributes = {} self._json_attributes = json_attributes - self._attr_name = name self._attr_native_value = None - self._attr_native_unit_of_measurement = unit_of_measurement self._value_template = value_template - self._attr_unique_id = unique_id - def update(self) -> None: + async def async_update(self) -> None: """Get the latest data and updates the state.""" - self.data.update() + await self.hass.async_add_executor_job(self.data.update) value = self.data.value if self._json_attributes: @@ -124,10 +158,10 @@ class CommandSensor(SensorEntity): if value is None: value = STATE_UNKNOWN elif self._value_template is not None: - self._attr_native_value = ( - self._value_template.render_with_possible_json_value( - value, STATE_UNKNOWN - ) + self._attr_native_value = await self.hass.async_add_executor_job( + self._value_template.render_with_possible_json_value, + value, + STATE_UNKNOWN, ) else: self._attr_native_value = value diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index f7de3b3394..5aab14225f 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -1,6 +1,7 @@ """The tests for the Command line sensor platform.""" from __future__ import annotations +from datetime import timedelta from typing import Any from unittest.mock import patch @@ -10,6 +11,9 @@ from homeassistant import setup from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt + +from tests.common import async_fire_time_changed async def setup_test_entities(hass: HomeAssistant, config_dict: dict[str, Any]) -> None: @@ -67,6 +71,14 @@ async def test_template_render(hass: HomeAssistant) -> None: "command": "echo {{ states.sensor.input_sensor.state }}", }, ) + + # Give time for template to load + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=1), + ) + await hass.async_block_till_done() + entity_state = hass.states.get("sensor.test") assert entity_state assert entity_state.state == "sensor_value" @@ -86,7 +98,15 @@ async def test_template_render_with_quote(hass: HomeAssistant) -> None: }, ) - check_output.assert_called_once_with( + # Give time for template to load + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=1), + ) + await hass.async_block_till_done() + + assert len(check_output.mock_calls) == 2 + check_output.assert_called_with( 'echo "sensor_value" "3 4"', shell=True, # nosec # shell by design timeout=15, From 0457bb2717c6ef61be1c975cb98a96f596fb474a Mon Sep 17 00:00:00 2001 From: David Poll Date: Mon, 13 Mar 2023 10:20:33 -0700 Subject: [PATCH 0895/2810] Add is_hidden_entity test for Jinja templates (#89011) --- homeassistant/helpers/template.py | 10 ++++++++++ tests/helpers/test_template.py | 20 ++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 1c5d15801f..8f68c7af37 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -1442,6 +1442,13 @@ def distance(hass, *args): ) +def is_hidden_entity(hass: HomeAssistant, entity_id: str) -> bool: + """Test if an entity is hidden.""" + entity_reg = entity_registry.async_get(hass) + entry = entity_reg.async_get(entity_id) + return entry is not None and entry.hidden + + def is_state(hass: HomeAssistant, entity_id: str, state: str | list[str]) -> bool: """Test if a state is a specific value.""" state_obj = _get_state(hass, entity_id) @@ -2266,6 +2273,9 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["area_devices"] = hassfunction(area_devices) self.filters["area_devices"] = pass_context(self.globals["area_devices"]) + self.globals["is_hidden_entity"] = hassfunction(is_hidden_entity) + self.tests["is_hidden_entity"] = pass_context(self.globals["is_hidden_entity"]) + self.globals["integration_entities"] = hassfunction(integration_entities) self.filters["integration_entities"] = pass_context( self.globals["integration_entities"] diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 740040835c..750602c9d6 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1443,6 +1443,26 @@ def test_if_state_exists(hass: HomeAssistant) -> None: assert tpl.async_render() == "exists" +def test_is_hidden_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test is_hidden_entity method.""" + hidden_entity = entity_registry.async_get_or_create( + "sensor", "mock", "hidden", hidden_by=er.RegistryEntryHider.USER + ) + visible_entity = entity_registry.async_get_or_create("sensor", "mock", "visible") + assert template.Template( + f"{{{{ is_hidden_entity('{hidden_entity.entity_id}') }}}}", + hass, + ).async_render() + + assert not template.Template( + f"{{{{ is_hidden_entity('{visible_entity.entity_id}') }}}}", + hass, + ).async_render() + + def test_is_state(hass: HomeAssistant) -> None: """Test is_state method.""" hass.states.async_set("test.object", "available") From 990ecbba72243323ab9b197677c18911bb3213eb Mon Sep 17 00:00:00 2001 From: Jan Stienstra <65826735+j-stienstra@users.noreply.github.com> Date: Sun, 12 Mar 2023 19:31:10 +0100 Subject: [PATCH 0896/2810] Recode Home Assistant instance name to ascii for Jellyfin (#87368) Recode instance name to ascii --- homeassistant/components/jellyfin/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/jellyfin/__init__.py b/homeassistant/components/jellyfin/__init__.py index 39085317a5..95038d54f5 100644 --- a/homeassistant/components/jellyfin/__init__.py +++ b/homeassistant/components/jellyfin/__init__.py @@ -20,10 +20,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry_data[CONF_CLIENT_DEVICE_ID] = entry.entry_id hass.config_entries.async_update_entry(entry, data=entry_data) - client = create_client( - device_id=entry.data[CONF_CLIENT_DEVICE_ID], - device_name=hass.config.location_name, - ) + device_id = entry.data[CONF_CLIENT_DEVICE_ID] + device_name = ascii(hass.config.location_name) + + client = create_client(device_id=device_id, device_name=device_name) try: user_id, connect_result = await validate_input(hass, dict(entry.data), client) From 6ebd493c4d98b8a1462d277f2edf8f58b90cb9ce Mon Sep 17 00:00:00 2001 From: Arjan <44190435+vingerha@users.noreply.github.com> Date: Mon, 13 Mar 2023 11:57:49 +0100 Subject: [PATCH 0897/2810] Fix gtfs with 2023.3 (sqlachemy update) (#89175) --- homeassistant/components/gtfs/sensor.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index 3a79d8d88a..6cf1a6d460 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -342,12 +342,14 @@ def get_next_departure( origin_stop_time.departure_time LIMIT :limit """ - result = schedule.engine.execute( + result = schedule.engine.connect().execute( text(sql_query), - origin_station_id=start_station_id, - end_station_id=end_station_id, - today=now_date, - limit=limit, + { + "origin_station_id": start_station_id, + "end_station_id": end_station_id, + "today": now_date, + "limit": limit, + }, ) # Create lookup timetable for today and possibly tomorrow, taking into @@ -357,7 +359,8 @@ def get_next_departure( yesterday_start = today_start = tomorrow_start = None yesterday_last = today_last = "" - for row in result: + for row_cursor in result: + row = row_cursor._asdict() if row["yesterday"] == 1 and yesterday_date >= row["start_date"]: extras = {"day": "yesterday", "first": None, "last": False} if yesterday_start is None: @@ -800,7 +803,10 @@ class GTFSDepartureSensor(SensorEntity): @staticmethod def dict_for_table(resource: Any) -> dict: """Return a dictionary for the SQLAlchemy resource given.""" - return {col: getattr(resource, col) for col in resource.__table__.columns} + _dict = {} + for column in resource.__table__.columns: + _dict[column.name] = str(getattr(resource, column.name)) + return _dict def append_keys(self, resource: dict, prefix: str | None = None) -> None: """Properly format key val pairs to append to attributes.""" From 8c2569d2cebee3fc794a87ac7bc30fa361274c5b Mon Sep 17 00:00:00 2001 From: Kevin Worrel <37058192+dieselrabbit@users.noreply.github.com> Date: Sat, 11 Mar 2023 14:27:33 -0500 Subject: [PATCH 0898/2810] Reconnect on any ScreenLogic exception (#89269) Co-authored-by: J. Nick Koston --- homeassistant/components/screenlogic/__init__.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/screenlogic/__init__.py b/homeassistant/components/screenlogic/__init__.py index fad4dc6509..ad2f9c64f3 100644 --- a/homeassistant/components/screenlogic/__init__.py +++ b/homeassistant/components/screenlogic/__init__.py @@ -159,11 +159,9 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator): """Fetch data from the Screenlogic gateway.""" try: await self._async_update_configured_data() - except ScreenLogicError as error: - _LOGGER.warning("Update error - attempting reconnect: %s", error) + except (ScreenLogicError, ScreenLogicWarning) as ex: + _LOGGER.warning("Update error - attempting reconnect: %s", ex) await self._async_reconnect_update_data() - except ScreenLogicWarning as warn: - raise UpdateFailed(f"Incomplete update: {warn}") from warn return None From 7cb462067106ddd914abaf57018c72b1a1930098 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Mar 2023 16:03:41 -1000 Subject: [PATCH 0899/2810] Fix data migration never finishing when database has invalid datetimes (#89474) * Fix data migration never finishing when database has invalid datetimes If there were impossible datetime values in the database (likely from a manual sqlite to MySQL conversion) the conversion would never complete * Update homeassistant/components/recorder/migration.py --- homeassistant/components/recorder/migration.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 0b8fe9243b..838cb181d0 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1072,7 +1072,7 @@ def _migrate_columns_to_timestamp( result = session.connection().execute( text( "UPDATE events set time_fired_ts=" - "IF(time_fired is NULL,0," + "IF(time_fired is NULL or UNIX_TIMESTAMP(time_fired) is NULL,0," "UNIX_TIMESTAMP(time_fired)" ") " "where time_fired_ts is NULL " @@ -1085,7 +1085,7 @@ def _migrate_columns_to_timestamp( result = session.connection().execute( text( "UPDATE states set last_updated_ts=" - "IF(last_updated is NULL,0," + "IF(last_updated is NULL or UNIX_TIMESTAMP(last_updated) is NULL,0," "UNIX_TIMESTAMP(last_updated) " "), " "last_changed_ts=" @@ -1161,7 +1161,7 @@ def _migrate_statistics_columns_to_timestamp( result = session.connection().execute( text( f"UPDATE {table} set start_ts=" - "IF(start is NULL,0," + "IF(start is NULL or UNIX_TIMESTAMP(start) is NULL,0," "UNIX_TIMESTAMP(start) " "), " "created_ts=" From 1e7f58d85947b58c3f0b98b3efdf25ce33c9ea57 Mon Sep 17 00:00:00 2001 From: rappenze Date: Sat, 11 Mar 2023 09:15:05 +0100 Subject: [PATCH 0900/2810] Fix bug in fibaro cover (#89502) --- homeassistant/components/fibaro/cover.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fibaro/cover.py b/homeassistant/components/fibaro/cover.py index e19c5c32e8..c73c45d254 100644 --- a/homeassistant/components/fibaro/cover.py +++ b/homeassistant/components/fibaro/cover.py @@ -94,9 +94,9 @@ class FibaroCover(FibaroDevice, CoverEntity): """Return if the cover is closed.""" if self._is_open_close_only(): state = self.fibaro_device.state - if not state.has_value or state.str_value.lower() == "unknown": + if not state.has_value or state.str_value().lower() == "unknown": return None - return state.str_value.lower() == "closed" + return state.str_value().lower() == "closed" if self.current_cover_position is None: return None From 950a1f6e9e8348b28131ccc756658b61ae51e285 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 11 Mar 2023 20:13:27 +0100 Subject: [PATCH 0901/2810] Bump pydeconz to v110 (#89527) * Bump pydeconz to v109 * Bump pydeconz to v110 for additional color modes --- homeassistant/components/deconz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index b0ad90857b..5569f9d5e8 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["pydeconz"], "quality_scale": "platinum", - "requirements": ["pydeconz==108"], + "requirements": ["pydeconz==110"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/requirements_all.txt b/requirements_all.txt index 33e6d70f7e..e67ca68c3d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1573,7 +1573,7 @@ pydaikin==2.9.0 pydanfossair==0.1.0 # homeassistant.components.deconz -pydeconz==108 +pydeconz==110 # homeassistant.components.delijn pydelijn==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e2f1b0f751..bbc2053d1f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1134,7 +1134,7 @@ pycoolmasternet-async==0.1.5 pydaikin==2.9.0 # homeassistant.components.deconz -pydeconz==108 +pydeconz==110 # homeassistant.components.dexcom pydexcom==0.2.3 From ca0304ffc4e227b3048c053614c691ae37319c4e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Mar 2023 10:37:00 -1000 Subject: [PATCH 0902/2810] Fix get_significant_states_with_session query looking at legacy columns (#89558) --- homeassistant/components/recorder/history.py | 8 +++++--- tests/components/recorder/test_history.py | 21 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/recorder/history.py b/homeassistant/components/recorder/history.py index b67790f9a4..a745716757 100644 --- a/homeassistant/components/recorder/history.py +++ b/homeassistant/components/recorder/history.py @@ -282,9 +282,11 @@ def _significant_states_stmt( (States.last_changed_ts == States.last_updated_ts) | States.last_changed_ts.is_(None) ) - stmt += lambda q: q.filter( - (States.last_changed == States.last_updated) | States.last_changed.is_(None) - ) + else: + stmt += lambda q: q.filter( + (States.last_changed == States.last_updated) + | States.last_changed.is_(None) + ) elif significant_changes_only: if schema_version >= 31: stmt += lambda q: q.filter( diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index d082806f3d..2b4bed072a 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -209,6 +209,27 @@ def test_significant_states_with_session_entity_minimal_response_no_matches( ) +def test_significant_states_with_session_single_entity( + hass_recorder: Callable[..., HomeAssistant], +) -> None: + """Test get_significant_states_with_session with a single entity.""" + hass = hass_recorder() + hass.states.set("demo.id", "any", {"attr": True}) + hass.states.set("demo.id", "any2", {"attr": True}) + wait_recording_done(hass) + now = dt_util.utcnow() + with session_scope(hass=hass) as session: + states = history.get_significant_states_with_session( + hass, + session, + now - timedelta(days=1), + now, + entity_ids=["demo.id"], + minimal_response=False, + ) + assert len(states["demo.id"]) == 2 + + @pytest.mark.parametrize( ("attributes", "no_attributes", "limit"), [ From 22922da60737c05122d641d56df2e11424abebba Mon Sep 17 00:00:00 2001 From: Eugenio Panadero Date: Mon, 13 Mar 2023 09:07:10 +0100 Subject: [PATCH 0903/2810] Bump aiopvpc to 4.1.0 (#89593) --- homeassistant/components/pvpc_hourly_pricing/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/pvpc_hourly_pricing/manifest.json b/homeassistant/components/pvpc_hourly_pricing/manifest.json index 89520f079a..64e6e19086 100644 --- a/homeassistant/components/pvpc_hourly_pricing/manifest.json +++ b/homeassistant/components/pvpc_hourly_pricing/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["aiopvpc"], "quality_scale": "platinum", - "requirements": ["aiopvpc==4.0.1"] + "requirements": ["aiopvpc==4.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e67ca68c3d..b387aac197 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -241,7 +241,7 @@ aiopurpleair==2022.12.1 aiopvapi==2.0.4 # homeassistant.components.pvpc_hourly_pricing -aiopvpc==4.0.1 +aiopvpc==4.1.0 # homeassistant.components.lidarr # homeassistant.components.radarr diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bbc2053d1f..ef9c6d1916 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -219,7 +219,7 @@ aiopurpleair==2022.12.1 aiopvapi==2.0.4 # homeassistant.components.pvpc_hourly_pricing -aiopvpc==4.0.1 +aiopvpc==4.1.0 # homeassistant.components.lidarr # homeassistant.components.radarr From 65c614421adaea5e20a28ed40dbe19dc414213c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Mar 2023 16:57:22 -1000 Subject: [PATCH 0904/2810] Increase maximum aiohttp connections to 4096 (#89611) fixes #89408 --- homeassistant/helpers/aiohttp_client.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index d623de5e81..3ab583096c 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -39,6 +39,20 @@ SERVER_SOFTWARE = "{0}/{1} aiohttp/{2} Python/{3[0]}.{3[1]}".format( WARN_CLOSE_MSG = "closes the Home Assistant aiohttp session" +# +# The default connection limit of 100 meant that you could only have +# 100 concurrent connections. +# +# This was effectively a limit of 100 devices and than +# the supervisor API would fail as soon as it was hit. +# +# We now apply the 100 limit per host, so that we can have 100 connections +# to a single host, but can have more than 4096 connections in total to +# prevent a single host from using all available connections. +# +MAXIMUM_CONNECTIONS = 4096 +MAXIMUM_CONNECTIONS_PER_HOST = 100 + class HassClientResponse(aiohttp.ClientResponse): """aiohttp.ClientResponse with a json method that uses json_loads by default.""" @@ -261,7 +275,12 @@ def _async_get_connector( else: ssl_context = False - connector = aiohttp.TCPConnector(enable_cleanup_closed=True, ssl=ssl_context) + connector = aiohttp.TCPConnector( + enable_cleanup_closed=True, + ssl=ssl_context, + limit=MAXIMUM_CONNECTIONS, + limit_per_host=MAXIMUM_CONNECTIONS_PER_HOST, + ) hass.data[key] = connector async def _async_close_connector(event: Event) -> None: From d2f90236d11ff32c7c7f7eced4e03697967f3444 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 13 Mar 2023 09:44:20 +0100 Subject: [PATCH 0905/2810] Rename modules named repairs.py which are not repairs platforms (#89618) --- .../components/bayesian/binary_sensor.py | 2 +- .../bayesian/{repairs.py => issues.py} | 2 +- homeassistant/components/hassio/__init__.py | 10 ++++----- .../hassio/{repairs.py => issues.py} | 18 +++++++-------- tests/components/hassio/conftest.py | 2 +- .../{test_repairs.py => test_issues.py} | 22 +++++++++---------- 6 files changed, 28 insertions(+), 28 deletions(-) rename homeassistant/components/bayesian/{repairs.py => issues.py} (97%) rename homeassistant/components/hassio/{repairs.py => issues.py} (92%) rename tests/components/hassio/{test_repairs.py => test_issues.py} (96%) diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py index 77571e1a80..06baef1bd0 100644 --- a/homeassistant/components/bayesian/binary_sensor.py +++ b/homeassistant/components/bayesian/binary_sensor.py @@ -60,7 +60,7 @@ from .const import ( DEFAULT_PROBABILITY_THRESHOLD, ) from .helpers import Observation -from .repairs import raise_mirrored_entries, raise_no_prob_given_false +from .issues import raise_mirrored_entries, raise_no_prob_given_false _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/bayesian/repairs.py b/homeassistant/components/bayesian/issues.py similarity index 97% rename from homeassistant/components/bayesian/repairs.py rename to homeassistant/components/bayesian/issues.py index 9a52763694..0f97b86b0e 100644 --- a/homeassistant/components/bayesian/repairs.py +++ b/homeassistant/components/bayesian/issues.py @@ -1,4 +1,4 @@ -"""Helpers for generating repairs.""" +"""Helpers for generating issues.""" from __future__ import annotations from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 4f5d8e9d31..3ea42e331d 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -96,7 +96,7 @@ from .handler import ( # noqa: F401 ) from .http import HassIOView from .ingress import async_setup_ingress_view -from .repairs import SupervisorRepairs +from .issues import SupervisorIssues from .websocket_api import async_load_websocket_api _LOGGER = logging.getLogger(__name__) @@ -123,7 +123,7 @@ DATA_SUPERVISOR_INFO = "hassio_supervisor_info" DATA_ADDONS_CHANGELOGS = "hassio_addons_changelogs" DATA_ADDONS_INFO = "hassio_addons_info" DATA_ADDONS_STATS = "hassio_addons_stats" -DATA_SUPERVISOR_REPAIRS = "supervisor_repairs" +DATA_SUPERVISOR_ISSUES = "supervisor_issues" HASSIO_UPDATE_INTERVAL = timedelta(minutes=5) ADDONS_COORDINATOR = "hassio_addons_coordinator" @@ -581,9 +581,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: hass.config_entries.flow.async_init(DOMAIN, context={"source": "system"}) ) - # Start listening for problems with supervisor and making repairs - hass.data[DATA_SUPERVISOR_REPAIRS] = repairs = SupervisorRepairs(hass, hassio) - await repairs.setup() + # Start listening for problems with supervisor and making issues + hass.data[DATA_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass, hassio) + await issues.setup() return True diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/issues.py similarity index 92% rename from homeassistant/components/hassio/repairs.py rename to homeassistant/components/hassio/issues.py index 21120d8d52..a0d51c4806 100644 --- a/homeassistant/components/hassio/repairs.py +++ b/homeassistant/components/hassio/issues.py @@ -70,11 +70,11 @@ UNHEALTHY_REASONS = { } -class SupervisorRepairs: - """Create repairs from supervisor events.""" +class SupervisorIssues: + """Create issues from supervisor events.""" def __init__(self, hass: HomeAssistant, client: HassIO) -> None: - """Initialize supervisor repairs.""" + """Initialize supervisor issues.""" self._hass = hass self._client = client self._unsupported_reasons: set[str] = set() @@ -87,7 +87,7 @@ class SupervisorRepairs: @unhealthy_reasons.setter def unhealthy_reasons(self, reasons: set[str]) -> None: - """Set unhealthy reasons. Create or delete repairs as necessary.""" + """Set unhealthy reasons. Create or delete issues as necessary.""" for unhealthy in reasons - self.unhealthy_reasons: if unhealthy in UNHEALTHY_REASONS: translation_key = f"unhealthy_{unhealthy}" @@ -119,7 +119,7 @@ class SupervisorRepairs: @unsupported_reasons.setter def unsupported_reasons(self, reasons: set[str]) -> None: - """Set unsupported reasons. Create or delete repairs as necessary.""" + """Set unsupported reasons. Create or delete issues as necessary.""" for unsupported in reasons - UNSUPPORTED_SKIP_REPAIR - self.unsupported_reasons: if unsupported in UNSUPPORTED_REASONS: translation_key = f"unsupported_{unsupported}" @@ -149,18 +149,18 @@ class SupervisorRepairs: await self.update() async_dispatcher_connect( - self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_repairs + self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_issues ) async def update(self) -> None: - """Update repairs from Supervisor resolution center.""" + """Update issuess from Supervisor resolution center.""" data = await self._client.get_resolution_info() self.unhealthy_reasons = set(data[ATTR_UNHEALTHY]) self.unsupported_reasons = set(data[ATTR_UNSUPPORTED]) @callback - def _supervisor_events_to_repairs(self, event: dict[str, Any]) -> None: - """Create repairs from supervisor events.""" + def _supervisor_events_to_issues(self, event: dict[str, Any]) -> None: + """Create issues from supervisor events.""" if ATTR_WS_EVENT not in event: return diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index 78ae9643d6..afe641405e 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -52,7 +52,7 @@ def hassio_stubs(hassio_env, hass, hass_client, aioclient_mock): "homeassistant.components.hassio.HassIO.get_ingress_panels", return_value={"panels": []}, ), patch( - "homeassistant.components.hassio.repairs.SupervisorRepairs.setup" + "homeassistant.components.hassio.issues.SupervisorIssues.setup" ), patch( "homeassistant.components.hassio.HassIO.refresh_updates" ): diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_issues.py similarity index 96% rename from tests/components/hassio/test_repairs.py rename to tests/components/hassio/test_issues.py index 8806e641a5..5b280d0c82 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_issues.py @@ -1,4 +1,4 @@ -"""Test repairs from supervisor issues.""" +"""Test issues from supervisor issues.""" from __future__ import annotations import os @@ -145,12 +145,12 @@ def assert_repair_in_list(issues: list[dict[str, Any]], unhealthy: bool, reason: } in issues -async def test_unhealthy_repairs( +async def test_unhealthy_issues( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: - """Test repairs added for unhealthy systems.""" + """Test issues added for unhealthy systems.""" mock_resolution_info(aioclient_mock, unhealthy=["docker", "setup"]) result = await async_setup_component(hass, "hassio", {}) @@ -166,12 +166,12 @@ async def test_unhealthy_repairs( assert_repair_in_list(msg["result"]["issues"], unhealthy=True, reason="setup") -async def test_unsupported_repairs( +async def test_unsupported_issues( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: - """Test repairs added for unsupported systems.""" + """Test issues added for unsupported systems.""" mock_resolution_info(aioclient_mock, unsupported=["content_trust", "os"]) result = await async_setup_component(hass, "hassio", {}) @@ -189,12 +189,12 @@ async def test_unsupported_repairs( assert_repair_in_list(msg["result"]["issues"], unhealthy=False, reason="os") -async def test_unhealthy_repairs_add_remove( +async def test_unhealthy_issues_add_remove( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: - """Test unhealthy repairs added and removed from dispatches.""" + """Test unhealthy issues added and removed from dispatches.""" mock_resolution_info(aioclient_mock) result = await async_setup_component(hass, "hassio", {}) @@ -245,12 +245,12 @@ async def test_unhealthy_repairs_add_remove( assert msg["result"] == {"issues": []} -async def test_unsupported_repairs_add_remove( +async def test_unsupported_issues_add_remove( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: - """Test unsupported repairs added and removed from dispatches.""" + """Test unsupported issues added and removed from dispatches.""" mock_resolution_info(aioclient_mock) result = await async_setup_component(hass, "hassio", {}) @@ -301,12 +301,12 @@ async def test_unsupported_repairs_add_remove( assert msg["result"] == {"issues": []} -async def test_reset_repairs_supervisor_restart( +async def test_reset_issues_supervisor_restart( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: - """Unsupported/unhealthy repairs reset on supervisor restart.""" + """Unsupported/unhealthy issues reset on supervisor restart.""" mock_resolution_info(aioclient_mock, unsupported=["os"], unhealthy=["docker"]) result = await async_setup_component(hass, "hassio", {}) From f5562e93ac41e6a264fb5fdb8ac7c4299565a649 Mon Sep 17 00:00:00 2001 From: tomrennen Date: Mon, 13 Mar 2023 15:15:13 +0100 Subject: [PATCH 0906/2810] Improved "ON" state check for `Use room sensor for cooling` (#89634) --- homeassistant/components/nibe_heatpump/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nibe_heatpump/climate.py b/homeassistant/components/nibe_heatpump/climate.py index 9c7d8641b6..a68aabacf4 100644 --- a/homeassistant/components/nibe_heatpump/climate.py +++ b/homeassistant/components/nibe_heatpump/climate.py @@ -139,7 +139,7 @@ class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): mode = HVACMode.OFF if _get_value(self._coil_use_room_sensor) == "ON": - if _get_value(self._coil_cooling_with_room_sensor) == "ON": + if _get_value(self._coil_cooling_with_room_sensor) != "OFF": mode = HVACMode.HEAT_COOL else: mode = HVACMode.HEAT From daa5718a80e3a3d84c59a50836bea73c594da29a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 13 Mar 2023 13:26:50 -0400 Subject: [PATCH 0907/2810] Bumped version to 2023.3.4 --- 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 82b9fd1a31..040794ed21 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 = "3" +PATCH_VERSION: Final = "4" __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 0dcc14344a..eed3a9b967 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.3.3" +version = "2023.3.4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From d54259f9ac3f72fda66de8e2268ae9697241fa4f Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 13 Mar 2023 18:47:00 +0100 Subject: [PATCH 0908/2810] Bump reolink-aio to 0.5.5 (#89646) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 35ce21ab78..1f776d1372 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.5.4"] + "requirements": ["reolink-aio==0.5.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 26ccf0efab..3414807809 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2237,7 +2237,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.4 +reolink-aio==0.5.5 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6ea71ed0b2..1ad038042d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1594,7 +1594,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.4 +reolink-aio==0.5.5 # homeassistant.components.python_script restrictedpython==6.0 From d422b0dcc22a01cb4c8cf05eaad573741a50aee5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 13 Mar 2023 19:09:09 +0100 Subject: [PATCH 0909/2810] Make OTBR add newly created dataset to thread credential store (#89645) --- homeassistant/components/otbr/__init__.py | 5 +- .../components/otbr/websocket_api.py | 12 +++ tests/components/otbr/test_websocket_api.py | 73 ++++++++++++++++--- 3 files changed, 78 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index ca977e774f..b1a9999f46 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -60,6 +60,7 @@ class OTBRData: url: str api: python_otbr_api.OTBR + dataset_source: str @_handle_otbr_error async def set_enabled(self, enabled: bool) -> None: @@ -137,7 +138,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up an Open Thread Border Router config entry.""" api = python_otbr_api.OTBR(entry.data["url"], async_get_clientsession(hass), 10) - otbrdata = OTBRData(entry.data["url"], api) + otbrdata = OTBRData(entry.data["url"], api, entry.title) try: dataset_tlvs = await otbrdata.get_active_dataset_tlvs() except ( @@ -148,7 +149,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady("Unable to connect") from err if dataset_tlvs: _warn_on_default_network_settings(hass, entry, dataset_tlvs) - await async_add_dataset(hass, entry.title, dataset_tlvs.hex()) + await async_add_dataset(hass, otbrdata.dataset_source, dataset_tlvs.hex()) hass.data[DOMAIN] = otbrdata diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py index 506a8cad1b..8ea9933623 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING import python_otbr_api from homeassistant.components import websocket_api +from homeassistant.components.thread import async_add_dataset from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -96,6 +97,17 @@ async def websocket_create_network( connection.send_error(msg["id"], "set_enabled_failed", str(exc)) return + try: + dataset_tlvs = await data.get_active_dataset_tlvs() + except HomeAssistantError as exc: + connection.send_error(msg["id"], "get_active_dataset_tlvs_failed", str(exc)) + return + if not dataset_tlvs: + connection.send_error(msg["id"], "get_active_dataset_tlvs_empty", "") + return + + await async_add_dataset(hass, data.dataset_source, dataset_tlvs.hex()) + connection.send_result(msg["id"]) diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index 32c5ae19e0..087b5bb486 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -7,7 +7,7 @@ import python_otbr_api from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from . import BASE_URL +from . import BASE_URL, DATASET_CH16 from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import WebSocketGenerator @@ -27,13 +27,7 @@ async def test_get_info( ) -> None: """Test async_get_info.""" - mock_response = ( - "0E080000000000010000000300001035060004001FFFE00208F642646DA209B1C00708FDF57B5A" - "0FE2AAF60510DE98B5BA1A528FEE049D4B4B01835375030D4F70656E5468726561642048410102" - "25A40410F5DD18371BFD29E1A601EF6FFAD94C030C0402A0F7F8" - ) - - aioclient_mock.get(f"{BASE_URL}/node/dataset/active", text=mock_response) + aioclient_mock.get(f"{BASE_URL}/node/dataset/active", text=DATASET_CH16.hex()) await websocket_client.send_json( { @@ -47,7 +41,7 @@ async def test_get_info( assert msg["success"] assert msg["result"] == { "url": BASE_URL, - "active_dataset_tlvs": mock_response.lower(), + "active_dataset_tlvs": DATASET_CH16.hex().lower(), } @@ -110,7 +104,11 @@ async def test_create_network( "python_otbr_api.OTBR.create_active_dataset" ) as create_dataset_mock, patch( "python_otbr_api.OTBR.set_enabled" - ) as set_enabled_mock: + ) as set_enabled_mock, patch( + "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET_CH16 + ) as get_active_dataset_tlvs_mock, patch( + "homeassistant.components.thread.dataset_store.DatasetStore.async_add" + ) as mock_add: await websocket_client.send_json( { "id": 5, @@ -131,6 +129,8 @@ async def test_create_network( assert len(set_enabled_mock.mock_calls) == 2 assert set_enabled_mock.mock_calls[0][1][0] is False assert set_enabled_mock.mock_calls[1][1][0] is True + get_active_dataset_tlvs_mock.assert_called_once() + mock_add.assert_called_once_with("Open Thread Border Router", DATASET_CH16.hex()) async def test_create_network_no_entry( @@ -236,6 +236,59 @@ async def test_create_network_fails_3( assert msg["error"]["code"] == "set_enabled_failed" +async def test_create_network_fails_4( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry, + websocket_client, +) -> None: + """Test create network.""" + await async_setup_component(hass, "otbr", {}) + + with patch("python_otbr_api.OTBR.set_enabled"), patch( + "python_otbr_api.OTBR.create_active_dataset" + ), patch( + "python_otbr_api.OTBR.get_active_dataset_tlvs", + side_effect=python_otbr_api.OTBRError, + ): + await websocket_client.send_json( + { + "id": 5, + "type": "otbr/create_network", + } + ) + msg = await websocket_client.receive_json() + + assert msg["id"] == 5 + assert not msg["success"] + assert msg["error"]["code"] == "get_active_dataset_tlvs_failed" + + +async def test_create_network_fails_5( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry, + websocket_client, +) -> None: + """Test create network.""" + await async_setup_component(hass, "otbr", {}) + + with patch("python_otbr_api.OTBR.set_enabled"), patch( + "python_otbr_api.OTBR.create_active_dataset" + ), patch("python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=None): + await websocket_client.send_json( + { + "id": 5, + "type": "otbr/create_network", + } + ) + msg = await websocket_client.receive_json() + + assert msg["id"] == 5 + assert not msg["success"] + assert msg["error"]["code"] == "get_active_dataset_tlvs_empty" + + async def test_get_extended_address( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, From 8a4233ac8e5b667aac432e12755d19107509f5ba Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 13 Mar 2023 14:51:01 -0400 Subject: [PATCH 0910/2810] Bump SQLAlchemy to 2.0.6 (#89650) --- homeassistant/components/recorder/manifest.json | 2 +- homeassistant/components/sql/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index ed885127b1..4f87c19ca7 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -6,5 +6,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["sqlalchemy==2.0.5.post1", "fnvhash==0.1.0"] + "requirements": ["sqlalchemy==2.0.6", "fnvhash==0.1.0"] } diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index bdedbb9b20..7513bbd8c7 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["sqlalchemy==2.0.5.post1"] + "requirements": ["sqlalchemy==2.0.6"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b79a0a5ef7..10df98fdb8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -42,7 +42,7 @@ pyudev==0.23.2 pyyaml==6.0 requests==2.28.2 scapy==2.5.0 -sqlalchemy==2.0.5.post1 +sqlalchemy==2.0.6 typing-extensions>=4.5.0,<5.0 ulid-transform==0.4.0 voluptuous-serialize==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 3414807809..9e741884d6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2398,7 +2398,7 @@ spotipy==2.22.1 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==2.0.5.post1 +sqlalchemy==2.0.6 # homeassistant.components.srp_energy srpenergy==1.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1ad038042d..4b7639560d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1707,7 +1707,7 @@ spotipy==2.22.1 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==2.0.5.post1 +sqlalchemy==2.0.6 # homeassistant.components.srp_energy srpenergy==1.3.6 From 160518350fdf9601374d34d97b9d6bdf71d834bf Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 13 Mar 2023 14:51:01 -0400 Subject: [PATCH 0911/2810] Bump SQLAlchemy to 2.0.6 (#89650) --- homeassistant/components/recorder/manifest.json | 2 +- homeassistant/components/sql/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index ed885127b1..4f87c19ca7 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -6,5 +6,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["sqlalchemy==2.0.5.post1", "fnvhash==0.1.0"] + "requirements": ["sqlalchemy==2.0.6", "fnvhash==0.1.0"] } diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index bdedbb9b20..7513bbd8c7 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["sqlalchemy==2.0.5.post1"] + "requirements": ["sqlalchemy==2.0.6"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0d3be634e7..e290d4730d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -42,7 +42,7 @@ pyudev==0.23.2 pyyaml==6.0 requests==2.28.2 scapy==2.5.0 -sqlalchemy==2.0.5.post1 +sqlalchemy==2.0.6 typing-extensions>=4.5.0,<5.0 voluptuous-serialize==2.6.0 voluptuous==0.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index b387aac197..d8280f48dd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2398,7 +2398,7 @@ spotipy==2.22.1 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==2.0.5.post1 +sqlalchemy==2.0.6 # homeassistant.components.srp_energy srpenergy==1.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ef9c6d1916..b788f40b39 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.5.post1 +sqlalchemy==2.0.6 # homeassistant.components.srp_energy srpenergy==1.3.6 From 5104e7f51a1c1f10f95d6258137b3fc69fe02c0f Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 13 Mar 2023 22:22:28 +0100 Subject: [PATCH 0912/2810] Use C-Extension for sqlalchemy (#89661) --- .github/workflows/wheels.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 144d6cbae1..e16e7f69fa 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -54,6 +54,9 @@ jobs: # OpenCV headless installation echo "CI_BUILD=1" echo "ENABLE_HEADLESS=1" + + # Use C-Extension for sqlalchemy + echo "REQUIRE_SQLALCHEMY_CEXT=1" ) > .env_file - name: Upload env_file From 0442a189e9ba95dac4894f6fda26b63be635df6f Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Tue, 14 Mar 2023 00:42:12 +0100 Subject: [PATCH 0913/2810] Add silent option for DynamicShutter (ogp:Shutter) in Overkiz (#89164) Add new switch --- homeassistant/components/overkiz/switch.py | 27 ++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/overkiz/switch.py b/homeassistant/components/overkiz/switch.py index b7416711e7..a40bd731a0 100644 --- a/homeassistant/components/overkiz/switch.py +++ b/homeassistant/components/overkiz/switch.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Any +from typing import Any, cast from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState from pyoverkiz.enums.ui import UIClass, UIWidget @@ -15,12 +15,12 @@ from homeassistant.components.switch import ( SwitchEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, Platform +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HomeAssistantOverkizData -from .const import DOMAIN +from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES from .entity import OverkizDescriptiveEntity @@ -107,6 +107,19 @@ SWITCH_DESCRIPTIONS: list[OverkizSwitchDescription] = [ ), entity_category=EntityCategory.CONFIG, ), + OverkizSwitchDescription( + key=UIWidget.DYNAMIC_SHUTTER, + name="Silent mode", + turn_on=OverkizCommand.ACTIVATE_OPTION, + turn_on_args=OverkizCommandParam.SILENCE, + turn_off=OverkizCommand.DEACTIVATE_OPTION, + turn_off_args=OverkizCommandParam.SILENCE, + is_on=lambda select_state: ( + OverkizCommandParam.SILENCE + in cast(list, select_state(OverkizState.CORE_ACTIVATED_OPTIONS)) + ), + icon="mdi:feather", + ), ] SUPPORTED_DEVICES = { @@ -123,7 +136,13 @@ async def async_setup_entry( data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] entities: list[OverkizSwitch] = [] - for device in data.platforms[Platform.SWITCH]: + for device in data.coordinator.data.values(): + if ( + device.widget in IGNORED_OVERKIZ_DEVICES + or device.ui_class in IGNORED_OVERKIZ_DEVICES + ): + continue + if description := SUPPORTED_DEVICES.get(device.widget) or SUPPORTED_DEVICES.get( device.ui_class ): From cbffaf30ba5af91eaeb1df4a1a98c82112fb2d51 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 13 Mar 2023 20:52:01 -0400 Subject: [PATCH 0914/2810] Bump ZHA dependencies (#89667) * Bump `zha-quirks` library and account for `setup_quirks` signature * Bump other ZHA dependencies * Revert zigpy bump --- homeassistant/components/zha/__init__.py | 2 +- homeassistant/components/zha/manifest.json | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index d0496fe7b6..dd07d4da42 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -107,7 +107,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b zha_data.setdefault(platform, []) if config.get(CONF_ENABLE_QUIRKS, True): - setup_quirks(config) + setup_quirks(custom_quirks_path=config.get(CONF_CUSTOM_QUIRKS_PATH)) # temporary code to remove the ZHA storage file from disk. # this will be removed in 2022.10.0 diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 44f88aa733..3061d867b6 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -20,10 +20,10 @@ "zigpy_znp" ], "requirements": [ - "bellows==0.34.9", + "bellows==0.34.10", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.93", + "zha-quirks==0.0.94", "zigpy-deconz==0.19.2", "zigpy==0.53.2", "zigpy-xbee==0.16.2", diff --git a/requirements_all.txt b/requirements_all.txt index 9e741884d6..9aa47dde7f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ beautifulsoup4==4.11.1 # beewi_smartclim==0.0.10 # homeassistant.components.zha -bellows==0.34.9 +bellows==0.34.10 # homeassistant.components.bmw_connected_drive bimmer_connected==0.12.1 @@ -2706,7 +2706,7 @@ zeroconf==0.47.3 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.93 +zha-quirks==0.0.94 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4b7639560d..2844a2abe7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -355,7 +355,7 @@ base36==0.1.1 beautifulsoup4==4.11.1 # homeassistant.components.zha -bellows==0.34.9 +bellows==0.34.10 # homeassistant.components.bmw_connected_drive bimmer_connected==0.12.1 @@ -1931,7 +1931,7 @@ zeroconf==0.47.3 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.93 +zha-quirks==0.0.94 # homeassistant.components.zha zigpy-deconz==0.19.2 From 671325355340cf25bd1b15431e165e72eb59e90e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Mar 2023 14:52:27 -1000 Subject: [PATCH 0915/2810] Bump ulid-transform to 0.4.2 (#89666) 32 bit fixes changelog: https://github.com/bdraco/ulid-transform/compare/v0.4.0...v0.4.2 --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 10df98fdb8..4bcddab1f8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -44,7 +44,7 @@ requests==2.28.2 scapy==2.5.0 sqlalchemy==2.0.6 typing-extensions>=4.5.0,<5.0 -ulid-transform==0.4.0 +ulid-transform==0.4.2 voluptuous-serialize==2.6.0 voluptuous==0.13.1 yarl==1.8.1 diff --git a/pyproject.toml b/pyproject.toml index bc7a603e5e..082ae3ef2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ dependencies = [ "pyyaml==6.0", "requests==2.28.2", "typing-extensions>=4.5.0,<5.0", - "ulid-transform==0.4.0", + "ulid-transform==0.4.2", "voluptuous==0.13.1", "voluptuous-serialize==2.6.0", "yarl==1.8.1", diff --git a/requirements.txt b/requirements.txt index 478b8a64d5..168488a54d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,7 @@ python-slugify==4.0.1 pyyaml==6.0 requests==2.28.2 typing-extensions>=4.5.0,<5.0 -ulid-transform==0.4.0 +ulid-transform==0.4.2 voluptuous==0.13.1 voluptuous-serialize==2.6.0 yarl==1.8.1 From 8e242c1fe6e8a1f3e0ea02bda98faa1e3fc8c352 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Mar 2023 14:52:53 -1000 Subject: [PATCH 0916/2810] Force binary build of sqlalchemy wheels (#89658) Force binary build of sqlalchemy --- .github/workflows/wheels.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index e16e7f69fa..ae8ee1938e 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -179,7 +179,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "libexecinfo-dev;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" - skip-binary: aiohttp;grpcio + skip-binary: aiohttp;grpcio;sqlalchemy legacy: true constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -194,7 +194,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "libexecinfo-dev;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" - skip-binary: aiohttp;grpcio + skip-binary: aiohttp;grpcio;sqlalchemy legacy: true constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" From 6809bd3029d14b1915595e1d11dd097e6531d08f Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Tue, 14 Mar 2023 00:54:49 +0000 Subject: [PATCH 0917/2810] Remove incorrect state class for System Bridge sensors (#89655) --- homeassistant/components/system_bridge/sensor.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index eb835b2c95..e73dec69c0 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -159,7 +159,6 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( SystemBridgeSensorEntityDescription( key="kernel", name="Kernel", - state_class=SensorStateClass.MEASUREMENT, icon="mdi:devices", value=lambda data: data.system.platform, ), @@ -193,7 +192,6 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( SystemBridgeSensorEntityDescription( key="os", name="Operating System", - state_class=SensorStateClass.MEASUREMENT, icon="mdi:devices", value=lambda data: f"{data.system.platform} {data.system.platform_version}", ), @@ -232,7 +230,6 @@ BATTERY_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( key="battery_time_remaining", name="Battery Time Remaining", device_class=SensorDeviceClass.TIMESTAMP, - state_class=SensorStateClass.MEASUREMENT, value=battery_time_remaining, ), ) From a99f6f71249180ea5a6766b0d1c38666906546cb Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Tue, 14 Mar 2023 00:55:37 +0000 Subject: [PATCH 0918/2810] Handle ConnectionClosedException from System Bridge (#89654) Handle unretrieved ConnectionClosedException from System Bridge --- .../components/system_bridge/coordinator.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/system_bridge/coordinator.py b/homeassistant/components/system_bridge/coordinator.py index 320c09a6f0..2810bcfac7 100644 --- a/homeassistant/components/system_bridge/coordinator.py +++ b/homeassistant/components/system_bridge/coordinator.py @@ -186,6 +186,12 @@ class SystemBridgeDataUpdateCoordinator( await self.websocket_client.connect( session=async_get_clientsession(self.hass), ) + + self.hass.async_create_task(self._listen_for_data()) + + await self.websocket_client.register_data_listener( + RegisterDataListener(modules=MODULES) + ) except AuthenticationException as exception: self.last_update_success = False self.logger.error("Authentication failed for %s: %s", self.title, exception) @@ -211,12 +217,6 @@ class SystemBridgeDataUpdateCoordinator( self.last_update_success = False self.async_update_listeners() - self.hass.async_create_task(self._listen_for_data()) - - await self.websocket_client.register_data_listener( - RegisterDataListener(modules=MODULES) - ) - self.last_update_success = True self.async_update_listeners() From 2f4e9c8ef38a7d9f2e9e0fc638decef402313c83 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 14 Mar 2023 01:56:09 +0100 Subject: [PATCH 0919/2810] Use otbr domain as dataset source (#89653) --- homeassistant/components/otbr/__init__.py | 5 ++--- homeassistant/components/otbr/websocket_api.py | 2 +- tests/components/otbr/test_init.py | 4 ++-- tests/components/otbr/test_websocket_api.py | 3 ++- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index b1a9999f46..d313c61c0e 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -60,7 +60,6 @@ class OTBRData: url: str api: python_otbr_api.OTBR - dataset_source: str @_handle_otbr_error async def set_enabled(self, enabled: bool) -> None: @@ -138,7 +137,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up an Open Thread Border Router config entry.""" api = python_otbr_api.OTBR(entry.data["url"], async_get_clientsession(hass), 10) - otbrdata = OTBRData(entry.data["url"], api, entry.title) + otbrdata = OTBRData(entry.data["url"], api) try: dataset_tlvs = await otbrdata.get_active_dataset_tlvs() except ( @@ -149,7 +148,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady("Unable to connect") from err if dataset_tlvs: _warn_on_default_network_settings(hass, entry, dataset_tlvs) - await async_add_dataset(hass, otbrdata.dataset_source, dataset_tlvs.hex()) + await async_add_dataset(hass, DOMAIN, dataset_tlvs.hex()) hass.data[DOMAIN] = otbrdata diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py index 8ea9933623..3d885cd500 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -106,7 +106,7 @@ async def websocket_create_network( connection.send_error(msg["id"], "get_active_dataset_tlvs_empty", "") return - await async_add_dataset(hass, data.dataset_source, dataset_tlvs.hex()) + await async_add_dataset(hass, DOMAIN, dataset_tlvs.hex()) connection.send_result(msg["id"]) diff --git a/tests/components/otbr/test_init.py b/tests/components/otbr/test_init.py index 86443ce5c0..2b329ae8d9 100644 --- a/tests/components/otbr/test_init.py +++ b/tests/components/otbr/test_init.py @@ -42,7 +42,7 @@ async def test_import_dataset(hass: HomeAssistant) -> None: ) as mock_add: assert await hass.config_entries.async_setup(config_entry.entry_id) - mock_add.assert_called_once_with(config_entry.title, DATASET_CH16.hex()) + mock_add.assert_called_once_with(otbr.DOMAIN, DATASET_CH16.hex()) assert not issue_registry.async_get_issue( domain=otbr.DOMAIN, issue_id=f"insecure_thread_network_{config_entry.entry_id}" ) @@ -72,7 +72,7 @@ async def test_import_insecure_dataset(hass: HomeAssistant, dataset: bytes) -> N ) as mock_add: assert await hass.config_entries.async_setup(config_entry.entry_id) - mock_add.assert_called_once_with(config_entry.title, dataset.hex()) + mock_add.assert_called_once_with(otbr.DOMAIN, dataset.hex()) assert issue_registry.async_get_issue( domain=otbr.DOMAIN, issue_id=f"insecure_thread_network_{config_entry.entry_id}" ) diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index 087b5bb486..056563e7b8 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -4,6 +4,7 @@ from unittest.mock import patch import pytest import python_otbr_api +from homeassistant.components import otbr from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -130,7 +131,7 @@ async def test_create_network( assert set_enabled_mock.mock_calls[0][1][0] is False assert set_enabled_mock.mock_calls[1][1][0] is True get_active_dataset_tlvs_mock.assert_called_once() - mock_add.assert_called_once_with("Open Thread Border Router", DATASET_CH16.hex()) + mock_add.assert_called_once_with(otbr.DOMAIN, DATASET_CH16.hex()) async def test_create_network_no_entry( From 69582b7ecbced2cb0a07cb72b50c29c99d99382e Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 13 Mar 2023 20:52:01 -0400 Subject: [PATCH 0920/2810] Bump ZHA dependencies (#89667) * Bump `zha-quirks` library and account for `setup_quirks` signature * Bump other ZHA dependencies * Revert zigpy bump --- homeassistant/components/zha/__init__.py | 2 +- homeassistant/components/zha/manifest.json | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index d0496fe7b6..dd07d4da42 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -107,7 +107,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b zha_data.setdefault(platform, []) if config.get(CONF_ENABLE_QUIRKS, True): - setup_quirks(config) + setup_quirks(custom_quirks_path=config.get(CONF_CUSTOM_QUIRKS_PATH)) # temporary code to remove the ZHA storage file from disk. # this will be removed in 2022.10.0 diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 44f88aa733..3061d867b6 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -20,10 +20,10 @@ "zigpy_znp" ], "requirements": [ - "bellows==0.34.9", + "bellows==0.34.10", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.93", + "zha-quirks==0.0.94", "zigpy-deconz==0.19.2", "zigpy==0.53.2", "zigpy-xbee==0.16.2", diff --git a/requirements_all.txt b/requirements_all.txt index d8280f48dd..38c2675815 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ beautifulsoup4==4.11.1 # beewi_smartclim==0.0.10 # homeassistant.components.zha -bellows==0.34.9 +bellows==0.34.10 # homeassistant.components.bmw_connected_drive bimmer_connected==0.12.1 @@ -2706,7 +2706,7 @@ zeroconf==0.47.3 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.93 +zha-quirks==0.0.94 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b788f40b39..72fb11f485 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.9 +bellows==0.34.10 # homeassistant.components.bmw_connected_drive bimmer_connected==0.12.1 @@ -1922,7 +1922,7 @@ zeroconf==0.47.3 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.93 +zha-quirks==0.0.94 # homeassistant.components.zha zigpy-deconz==0.19.2 From afa58b80bd5d2f6d8277e19d00c2636471c15174 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 14 Mar 2023 04:41:32 +0100 Subject: [PATCH 0921/2810] Default to recorder db for SQL integration (#85436) Co-authored-by: J. Nick Koston --- homeassistant/components/sql/__init__.py | 17 ++++++++- homeassistant/components/sql/config_flow.py | 34 ++++++++++-------- homeassistant/components/sql/sensor.py | 15 ++++---- homeassistant/components/sql/util.py | 12 +++++++ tests/components/sql/__init__.py | 5 --- tests/components/sql/test_config_flow.py | 10 +----- tests/components/sql/test_init.py | 39 +++++++++++++++++++++ tests/components/sql/test_sensor.py | 17 ++------- tests/components/sql/test_util.py | 25 +++++++++++++ 9 files changed, 120 insertions(+), 54 deletions(-) create mode 100644 homeassistant/components/sql/util.py create mode 100644 tests/components/sql/test_util.py diff --git a/homeassistant/components/sql/__init__.py b/homeassistant/components/sql/__init__.py index c0ec2dfab7..92b640580e 100644 --- a/homeassistant/components/sql/__init__.py +++ b/homeassistant/components/sql/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations import voluptuous as vol -from homeassistant.components.recorder import CONF_DB_URL +from homeassistant.components.recorder import CONF_DB_URL, get_instance from homeassistant.components.sensor import ( CONF_STATE_CLASS, DEVICE_CLASSES_SCHEMA, @@ -53,6 +53,18 @@ CONFIG_SCHEMA = vol.Schema( ) +def remove_configured_db_url_if_not_needed( + hass: HomeAssistant, entry: ConfigEntry +) -> None: + """Remove db url from config if it matches recorder database.""" + hass.config_entries.async_update_entry( + entry, + options={ + key: value for key, value in entry.options.items() if key != CONF_DB_URL + }, + ) + + async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update listener for options.""" await hass.config_entries.async_reload(entry.entry_id) @@ -73,6 +85,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up SQL from a config entry.""" + if entry.options.get(CONF_DB_URL) == get_instance(hass).db_url: + remove_configured_db_url_if_not_needed(hass, entry) + entry.async_on_unload(entry.add_update_listener(async_update_listener)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/sql/config_flow.py b/homeassistant/components/sql/config_flow.py index a6b1afe404..d52f2d10d0 100644 --- a/homeassistant/components/sql/config_flow.py +++ b/homeassistant/components/sql/config_flow.py @@ -11,13 +11,14 @@ from sqlalchemy.orm import Session, scoped_session, sessionmaker import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.recorder import CONF_DB_URL, DEFAULT_DB_FILE, DEFAULT_URL +from homeassistant.components.recorder import CONF_DB_URL from homeassistant.const import CONF_NAME, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import selector from .const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN +from .util import resolve_db_url _LOGGER = logging.getLogger(__name__) @@ -85,34 +86,37 @@ class SQLConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle the user step.""" errors = {} - db_url_default = DEFAULT_URL.format( - hass_config_path=self.hass.config.path(DEFAULT_DB_FILE) - ) if user_input is not None: - db_url = user_input.get(CONF_DB_URL, db_url_default) + db_url = user_input.get(CONF_DB_URL) query = user_input[CONF_QUERY] column = user_input[CONF_COLUMN_NAME] uom = user_input.get(CONF_UNIT_OF_MEASUREMENT) value_template = user_input.get(CONF_VALUE_TEMPLATE) name = user_input[CONF_NAME] + db_url_for_validation = None try: validate_sql_select(query) + db_url_for_validation = resolve_db_url(self.hass, db_url) await self.hass.async_add_executor_job( - validate_query, db_url, query, column + validate_query, db_url_for_validation, query, column ) except SQLAlchemyError: errors["db_url"] = "db_url_invalid" except ValueError: errors["query"] = "query_invalid" + add_db_url = ( + {CONF_DB_URL: db_url} if db_url == db_url_for_validation else {} + ) + if not errors: return self.async_create_entry( title=name, data={}, options={ - CONF_DB_URL: db_url, + **add_db_url, CONF_QUERY: query, CONF_COLUMN_NAME: column, CONF_UNIT_OF_MEASUREMENT: uom, @@ -140,32 +144,32 @@ class SQLOptionsFlowHandler(config_entries.OptionsFlow): ) -> FlowResult: """Manage SQL options.""" errors = {} - db_url_default = DEFAULT_URL.format( - hass_config_path=self.hass.config.path(DEFAULT_DB_FILE) - ) if user_input is not None: - db_url = user_input.get(CONF_DB_URL, db_url_default) + db_url = user_input.get(CONF_DB_URL) query = user_input[CONF_QUERY] column = user_input[CONF_COLUMN_NAME] name = self.entry.options.get(CONF_NAME, self.entry.title) try: validate_sql_select(query) + db_url_for_validation = resolve_db_url(self.hass, db_url) await self.hass.async_add_executor_job( - validate_query, db_url, query, column + validate_query, db_url_for_validation, query, column ) except SQLAlchemyError: errors["db_url"] = "db_url_invalid" except ValueError: errors["query"] = "query_invalid" else: + new_user_input = user_input + if new_user_input.get(CONF_DB_URL) and db_url == db_url_for_validation: + new_user_input.pop(CONF_DB_URL) return self.async_create_entry( title="", data={ CONF_NAME: name, - CONF_DB_URL: db_url, - **user_input, + **new_user_input, }, ) @@ -176,7 +180,7 @@ class SQLOptionsFlowHandler(config_entries.OptionsFlow): vol.Optional( CONF_DB_URL, description={ - "suggested_value": self.entry.options[CONF_DB_URL] + "suggested_value": self.entry.options.get(CONF_DB_URL) }, ): selector.TextSelector(), vol.Required( diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 27cf798db3..26c899d4d3 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -10,7 +10,7 @@ from sqlalchemy.engine import Result from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session, scoped_session, sessionmaker -from homeassistant.components.recorder import CONF_DB_URL, DEFAULT_DB_FILE, DEFAULT_URL +from homeassistant.components.recorder import CONF_DB_URL from homeassistant.components.sensor import ( CONF_STATE_CLASS, SensorDeviceClass, @@ -34,6 +34,7 @@ from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_COLUMN_NAME, CONF_QUERY, DB_URL_RE, DOMAIN +from .util import resolve_db_url _LOGGER = logging.getLogger(__name__) @@ -59,7 +60,7 @@ async def async_setup_platform( value_template: Template | None = conf.get(CONF_VALUE_TEMPLATE) column_name: str = conf[CONF_COLUMN_NAME] unique_id: str | None = conf.get(CONF_UNIQUE_ID) - db_url: str | None = conf.get(CONF_DB_URL) + db_url: str = resolve_db_url(hass, conf.get(CONF_DB_URL)) device_class: SensorDeviceClass | None = conf.get(CONF_DEVICE_CLASS) state_class: SensorStateClass | None = conf.get(CONF_STATE_CLASS) @@ -87,7 +88,7 @@ async def async_setup_entry( ) -> None: """Set up the SQL sensor from config entry.""" - db_url: str = entry.options[CONF_DB_URL] + db_url: str = resolve_db_url(hass, entry.options.get(CONF_DB_URL)) name: str = entry.options[CONF_NAME] query_str: str = entry.options[CONF_QUERY] unit: str | None = entry.options.get(CONF_UNIT_OF_MEASUREMENT) @@ -128,7 +129,7 @@ async def async_setup_sensor( unit: str | None, value_template: Template | None, unique_id: str | None, - db_url: str | None, + db_url: str, yaml: bool, device_class: SensorDeviceClass | None, state_class: SensorStateClass | None, @@ -136,16 +137,12 @@ async def async_setup_sensor( ) -> None: """Set up the SQL sensor.""" - if not db_url: - db_url = DEFAULT_URL.format(hass_config_path=hass.config.path(DEFAULT_DB_FILE)) - - sess: Session | None = None try: engine = sqlalchemy.create_engine(db_url, future=True) sessmaker = scoped_session(sessionmaker(bind=engine, future=True)) # Run a dummy query just to test the db_url - sess = sessmaker() + sess: Session = sessmaker() sess.execute(sqlalchemy.text("SELECT 1;")) except SQLAlchemyError as err: diff --git a/homeassistant/components/sql/util.py b/homeassistant/components/sql/util.py new file mode 100644 index 0000000000..81d8cd9900 --- /dev/null +++ b/homeassistant/components/sql/util.py @@ -0,0 +1,12 @@ +"""Utils for sql.""" +from __future__ import annotations + +from homeassistant.components.recorder import get_instance +from homeassistant.core import HomeAssistant + + +def resolve_db_url(hass: HomeAssistant, db_url: str | None) -> str: + """Return the db_url provided if not empty, otherwise return the recorder db_url.""" + if db_url and not db_url.isspace(): + return db_url + return get_instance(hass).db_url diff --git a/tests/components/sql/__init__.py b/tests/components/sql/__init__.py index f6cfba01e3..ea58d06632 100644 --- a/tests/components/sql/__init__.py +++ b/tests/components/sql/__init__.py @@ -23,7 +23,6 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry ENTRY_CONFIG = { - CONF_DB_URL: "sqlite://", CONF_NAME: "Get Value", CONF_QUERY: "SELECT 5 as value", CONF_COLUMN_NAME: "value", @@ -31,7 +30,6 @@ ENTRY_CONFIG = { } ENTRY_CONFIG_INVALID_QUERY = { - CONF_DB_URL: "sqlite://", CONF_NAME: "Get Value", CONF_QUERY: "UPDATE 5 as value", CONF_COLUMN_NAME: "size", @@ -39,14 +37,12 @@ ENTRY_CONFIG_INVALID_QUERY = { } ENTRY_CONFIG_INVALID_QUERY_OPT = { - CONF_DB_URL: "sqlite://", CONF_QUERY: "UPDATE 5 as value", CONF_COLUMN_NAME: "size", CONF_UNIT_OF_MEASUREMENT: "MiB", } ENTRY_CONFIG_NO_RESULTS = { - CONF_DB_URL: "sqlite://", CONF_NAME: "Get Value", CONF_QUERY: "SELECT kalle as value from no_table;", CONF_COLUMN_NAME: "value", @@ -69,7 +65,6 @@ YAML_CONFIG = { YAML_CONFIG_INVALID = { "sql": { - CONF_DB_URL: "sqlite://", CONF_QUERY: "SELECT 5 as value", CONF_COLUMN_NAME: "value", CONF_UNIT_OF_MEASUREMENT: "MiB", diff --git a/tests/components/sql/test_config_flow.py b/tests/components/sql/test_config_flow.py index 789fc98389..3213296a47 100644 --- a/tests/components/sql/test_config_flow.py +++ b/tests/components/sql/test_config_flow.py @@ -6,7 +6,7 @@ from unittest.mock import patch from sqlalchemy.exc import SQLAlchemyError from homeassistant import config_entries -from homeassistant.components.recorder import DEFAULT_DB_FILE, DEFAULT_URL, Recorder +from homeassistant.components.recorder import Recorder from homeassistant.components.sql.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -43,7 +43,6 @@ async def test_form(recorder_mock: Recorder, hass: HomeAssistant) -> None: assert result2["type"] == FlowResultType.CREATE_ENTRY assert result2["title"] == "Get Value" assert result2["options"] == { - "db_url": "sqlite://", "name": "Get Value", "query": "SELECT 5 as value", "column": "value", @@ -113,7 +112,6 @@ async def test_flow_fails_invalid_query( assert result5["type"] == FlowResultType.CREATE_ENTRY assert result5["title"] == "Get Value" assert result5["options"] == { - "db_url": "sqlite://", "name": "Get Value", "query": "SELECT 5 as value", "column": "value", @@ -163,7 +161,6 @@ async def test_options_flow(recorder_mock: Recorder, hass: HomeAssistant) -> Non assert result["type"] == FlowResultType.CREATE_ENTRY assert result["data"] == { "name": "Get Value", - "db_url": "sqlite://", "query": "SELECT 5 as size", "column": "size", "unit_of_measurement": "MiB", @@ -215,7 +212,6 @@ async def test_options_flow_name_previously_removed( assert result["type"] == FlowResultType.CREATE_ENTRY assert result["data"] == { "name": "Get Value Title", - "db_url": "sqlite://", "query": "SELECT 5 as size", "column": "size", "unit_of_measurement": "MiB", @@ -316,7 +312,6 @@ async def test_options_flow_fails_invalid_query( assert result4["type"] == FlowResultType.CREATE_ENTRY assert result4["data"] == { "name": "Get Value", - "db_url": "sqlite://", "query": "SELECT 5 as size", "column": "size", "unit_of_measurement": "MiB", @@ -369,12 +364,9 @@ async def test_options_flow_db_url_empty( ) await hass.async_block_till_done() - db_url = DEFAULT_URL.format(hass_config_path=hass.config.path(DEFAULT_DB_FILE)) - assert result["type"] == FlowResultType.CREATE_ENTRY assert result["data"] == { "name": "Get Value", - "db_url": db_url, "query": "SELECT 5 as size", "column": "size", "unit_of_measurement": "MiB", diff --git a/tests/components/sql/test_init.py b/tests/components/sql/test_init.py index a110f789a9..50de8aba7b 100644 --- a/tests/components/sql/test_init.py +++ b/tests/components/sql/test_init.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components.recorder import Recorder +from homeassistant.components.recorder.util import get_instance from homeassistant.components.sql import validate_sql_select from homeassistant.components.sql.const import DOMAIN from homeassistant.core import HomeAssistant @@ -56,3 +57,41 @@ async def test_invalid_query(hass: HomeAssistant) -> None: """Test invalid query.""" with pytest.raises(vol.Invalid): validate_sql_select("DROP TABLE *") + + +async def test_remove_configured_db_url_if_not_needed_when_not_needed( + recorder_mock: Recorder, + hass: HomeAssistant, +) -> None: + """Test configured db_url is replaced with None if matching the recorder db.""" + recorder_db_url = get_instance(hass).db_url + + config = { + "db_url": recorder_db_url, + "query": "SELECT 5 as value", + "column": "value", + "name": "count_tables", + } + + config_entry = await init_integration(hass, config) + + assert config_entry.options.get("db_url") is None + + +async def test_remove_configured_db_url_if_not_needed_when_needed( + recorder_mock: Recorder, + hass: HomeAssistant, +) -> None: + """Test configured db_url is not replaced if it differs from the recorder db.""" + db_url = "mssql://" + + config = { + "db_url": db_url, + "query": "SELECT 5 as value", + "column": "value", + "name": "count_tables", + } + + config_entry = await init_integration(hass, config) + + assert config_entry.options.get("db_url") == db_url diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index bc3143347b..32e5a778a8 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -182,6 +182,7 @@ async def test_invalid_url_setup( async def test_invalid_url_on_update( + recorder_mock: Recorder, hass: HomeAssistant, caplog: pytest.LogCaptureFixture, ) -> None: @@ -192,22 +193,9 @@ async def test_invalid_url_on_update( "column": "value", "name": "count_tables", } - entry = MockConfigEntry( - domain=DOMAIN, - source=SOURCE_USER, - data={}, - options=config, - entry_id="1", - ) - - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + await init_integration(hass, config) with patch( - "homeassistant.components.recorder", - ), patch( "homeassistant.components.sql.sensor.sqlalchemy.engine.cursor.CursorResult", side_effect=SQLAlchemyError( "sqlite://homeassistant:hunter2@homeassistant.local" @@ -219,7 +207,6 @@ async def test_invalid_url_on_update( ) await hass.async_block_till_done() - assert "sqlite://homeassistant:hunter2@homeassistant.local" not in caplog.text assert "sqlite://****:****@homeassistant.local" in caplog.text diff --git a/tests/components/sql/test_util.py b/tests/components/sql/test_util.py new file mode 100644 index 0000000000..31adbe076e --- /dev/null +++ b/tests/components/sql/test_util.py @@ -0,0 +1,25 @@ +"""Test the sql utils.""" +from unittest.mock import AsyncMock + +from homeassistant.components.recorder import get_instance +from homeassistant.components.sql.util import resolve_db_url +from homeassistant.core import HomeAssistant + + +async def test_resolve_db_url_when_none_configured( + recorder_mock: AsyncMock, + hass: HomeAssistant, +): + """Test return recorder db_url if provided db_url is None.""" + db_url = None + resolved_url = resolve_db_url(hass, db_url) + + assert resolved_url == get_instance(hass).db_url + + +async def test_resolve_db_url_when_configured(hass: HomeAssistant): + """Test return provided db_url if it's set.""" + db_url = "mssql://" + resolved_url = resolve_db_url(hass, db_url) + + assert resolved_url == db_url From 2cb673db04d4ad4fbd7ff3513cba98aa20126ba8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Mar 2023 18:07:05 -1000 Subject: [PATCH 0922/2810] Handle bytes data in sql sensors (#89169) --- homeassistant/components/sql/sensor.py | 7 ++++++- tests/components/sql/__init__.py | 10 ++++++++++ tests/components/sql/test_sensor.py | 14 +++++++++++++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 26c899d4d3..39a11049aa 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -242,10 +242,15 @@ class SQLSensor(SensorEntity): for key, value in res.items(): if isinstance(value, decimal.Decimal): value = float(value) - if isinstance(value, date): + elif isinstance(value, date): value = value.isoformat() + elif isinstance(value, (bytes, bytearray)): + value = f"0x{value.hex()}" self._attr_extra_state_attributes[key] = value + if data is not None and isinstance(data, (bytes, bytearray)): + data = f"0x{data.hex()}" + if data is not None and self._template is not None: self._attr_native_value = ( self._template.async_render_with_possible_json_value(data, None) diff --git a/tests/components/sql/__init__.py b/tests/components/sql/__init__.py index ea58d06632..c794f7a6b9 100644 --- a/tests/components/sql/__init__.py +++ b/tests/components/sql/__init__.py @@ -63,6 +63,16 @@ YAML_CONFIG = { } } +YAML_CONFIG_BINARY = { + "sql": { + CONF_DB_URL: "sqlite://", + CONF_NAME: "Get Binary Value", + CONF_QUERY: "SELECT cast(x'd34324324230392032' as blob) as value, cast(x'd343aa' as blob) as test_attr", + CONF_COLUMN_NAME: "value", + CONF_UNIQUE_ID: "unique_id_12345", + } +} + YAML_CONFIG_INVALID = { "sql": { CONF_QUERY: "SELECT 5 as value", diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index 32e5a778a8..400e3056d5 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt -from . import YAML_CONFIG, init_integration +from . import YAML_CONFIG, YAML_CONFIG_BINARY, init_integration from tests.common import MockConfigEntry, async_fire_time_changed @@ -304,3 +304,15 @@ async def test_attributes_from_yaml_setup( assert state.attributes["device_class"] == SensorDeviceClass.DATA_RATE assert state.attributes["state_class"] == SensorStateClass.MEASUREMENT assert state.attributes["unit_of_measurement"] == "MiB" + + +async def test_binary_data_from_yaml_setup( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test binary data from yaml config.""" + + assert await async_setup_component(hass, DOMAIN, YAML_CONFIG_BINARY) + await hass.async_block_till_done() + state = hass.states.get("sensor.get_binary_value") + assert state.state == "0xd34324324230392032" + assert state.attributes["test_attr"] == "0xd343aa" From cbee1ba496477675c21a75bb90fe6cce9414bb7c Mon Sep 17 00:00:00 2001 From: amitfin Date: Tue, 14 Mar 2023 10:47:26 +0200 Subject: [PATCH 0923/2810] Increase timeout for coolmaster with swing (#87573) Co-authored-by: G Johansson --- .../components/coolmaster/__init__.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/coolmaster/__init__.py b/homeassistant/components/coolmaster/__init__.py index 129797c356..289e70e806 100644 --- a/homeassistant/components/coolmaster/__init__.py +++ b/homeassistant/components/coolmaster/__init__.py @@ -21,9 +21,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Coolmaster from a config entry.""" host = entry.data[CONF_HOST] port = entry.data[CONF_PORT] - coolmaster = CoolMasterNet( - host, port, swing_support=entry.data.get(CONF_SWING_SUPPORT, False) - ) + if not entry.data.get(CONF_SWING_SUPPORT): + coolmaster = CoolMasterNet( + host, + port, + ) + else: + # Swing support adds an additional request per unit. The requests are + # done in parallel, which can cause delays on the server. Therefore, + # we increase the request timeout to 5 seconds instead of 1. + coolmaster = CoolMasterNet( + host, + port, + read_timeout=5, + swing_support=True, + ) try: info = await coolmaster.info() if not info: From b620e5d8a6b527bbd37d71338d29ac05940e1e39 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 14 Mar 2023 09:51:03 +0100 Subject: [PATCH 0924/2810] Move nextcloud constants (#89679) --- .../components/nextcloud/__init__.py | 61 ++----------------- .../components/nextcloud/binary_sensor.py | 9 ++- homeassistant/components/nextcloud/const.py | 5 ++ homeassistant/components/nextcloud/sensor.py | 48 ++++++++++++++- 4 files changed, 65 insertions(+), 58 deletions(-) create mode 100644 homeassistant/components/nextcloud/const.py diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py index 269bd96aa3..b4080dd2a1 100644 --- a/homeassistant/components/nextcloud/__init__.py +++ b/homeassistant/components/nextcloud/__init__.py @@ -1,5 +1,4 @@ """The Nextcloud integration.""" -from datetime import timedelta import logging from nextcloudmonitor import NextcloudMonitor, NextcloudMonitorError @@ -17,11 +16,11 @@ from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.typing import ConfigType +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + _LOGGER = logging.getLogger(__name__) -DOMAIN = "nextcloud" PLATFORMS = (Platform.SENSOR, Platform.BINARY_SENSOR) -SCAN_INTERVAL = timedelta(seconds=60) # Validate user configuration CONFIG_SCHEMA = vol.Schema( @@ -31,64 +30,14 @@ CONFIG_SCHEMA = vol.Schema( vol.Required(CONF_URL): cv.url, vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): cv.time_period, + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.time_period, } ) }, extra=vol.ALLOW_EXTRA, ) -BINARY_SENSORS = ( - "nextcloud_system_enable_avatars", - "nextcloud_system_enable_previews", - "nextcloud_system_filelocking.enabled", - "nextcloud_system_debug", -) - -SENSORS = ( - "nextcloud_system_version", - "nextcloud_system_theme", - "nextcloud_system_memcache.local", - "nextcloud_system_memcache.distributed", - "nextcloud_system_memcache.locking", - "nextcloud_system_freespace", - "nextcloud_system_cpuload", - "nextcloud_system_mem_total", - "nextcloud_system_mem_free", - "nextcloud_system_swap_total", - "nextcloud_system_swap_free", - "nextcloud_system_apps_num_installed", - "nextcloud_system_apps_num_updates_available", - "nextcloud_system_apps_app_updates_calendar", - "nextcloud_system_apps_app_updates_contacts", - "nextcloud_system_apps_app_updates_tasks", - "nextcloud_system_apps_app_updates_twofactor_totp", - "nextcloud_storage_num_users", - "nextcloud_storage_num_files", - "nextcloud_storage_num_storages", - "nextcloud_storage_num_storages_local", - "nextcloud_storage_num_storages_home", - "nextcloud_storage_num_storages_other", - "nextcloud_shares_num_shares", - "nextcloud_shares_num_shares_user", - "nextcloud_shares_num_shares_groups", - "nextcloud_shares_num_shares_link", - "nextcloud_shares_num_shares_mail", - "nextcloud_shares_num_shares_room", - "nextcloud_shares_num_shares_link_no_password", - "nextcloud_shares_num_fed_shares_sent", - "nextcloud_shares_num_fed_shares_received", - "nextcloud_shares_permissions_3_1", - "nextcloud_server_webserver", - "nextcloud_server_php_version", - "nextcloud_server_php_memory_limit", - "nextcloud_server_php_max_execution_time", - "nextcloud_server_php_upload_max_filesize", - "nextcloud_database_type", - "nextcloud_database_version", - "nextcloud_activeUsers_last5minutes", - "nextcloud_activeUsers_last1hour", - "nextcloud_activeUsers_last24hours", -) def setup(hass: HomeAssistant, config: ConfigType) -> bool: diff --git a/homeassistant/components/nextcloud/binary_sensor.py b/homeassistant/components/nextcloud/binary_sensor.py index e9d5b4a8d7..d811c6c924 100644 --- a/homeassistant/components/nextcloud/binary_sensor.py +++ b/homeassistant/components/nextcloud/binary_sensor.py @@ -6,7 +6,14 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import BINARY_SENSORS, DOMAIN +from . import DOMAIN + +BINARY_SENSORS = ( + "nextcloud_system_enable_avatars", + "nextcloud_system_enable_previews", + "nextcloud_system_filelocking.enabled", + "nextcloud_system_debug", +) def setup_platform( diff --git a/homeassistant/components/nextcloud/const.py b/homeassistant/components/nextcloud/const.py new file mode 100644 index 0000000000..223d21771b --- /dev/null +++ b/homeassistant/components/nextcloud/const.py @@ -0,0 +1,5 @@ +"""Constants for Nextcloud integration.""" +from datetime import timedelta + +DOMAIN = "nextcloud" +DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py index 31caa46028..6f1ce00eee 100644 --- a/homeassistant/components/nextcloud/sensor.py +++ b/homeassistant/components/nextcloud/sensor.py @@ -6,7 +6,53 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN, SENSORS +from . import DOMAIN + +SENSORS = ( + "nextcloud_system_version", + "nextcloud_system_theme", + "nextcloud_system_memcache.local", + "nextcloud_system_memcache.distributed", + "nextcloud_system_memcache.locking", + "nextcloud_system_freespace", + "nextcloud_system_cpuload", + "nextcloud_system_mem_total", + "nextcloud_system_mem_free", + "nextcloud_system_swap_total", + "nextcloud_system_swap_free", + "nextcloud_system_apps_num_installed", + "nextcloud_system_apps_num_updates_available", + "nextcloud_system_apps_app_updates_calendar", + "nextcloud_system_apps_app_updates_contacts", + "nextcloud_system_apps_app_updates_tasks", + "nextcloud_system_apps_app_updates_twofactor_totp", + "nextcloud_storage_num_users", + "nextcloud_storage_num_files", + "nextcloud_storage_num_storages", + "nextcloud_storage_num_storages_local", + "nextcloud_storage_num_storages_home", + "nextcloud_storage_num_storages_other", + "nextcloud_shares_num_shares", + "nextcloud_shares_num_shares_user", + "nextcloud_shares_num_shares_groups", + "nextcloud_shares_num_shares_link", + "nextcloud_shares_num_shares_mail", + "nextcloud_shares_num_shares_room", + "nextcloud_shares_num_shares_link_no_password", + "nextcloud_shares_num_fed_shares_sent", + "nextcloud_shares_num_fed_shares_received", + "nextcloud_shares_permissions_3_1", + "nextcloud_server_webserver", + "nextcloud_server_php_version", + "nextcloud_server_php_memory_limit", + "nextcloud_server_php_max_execution_time", + "nextcloud_server_php_upload_max_filesize", + "nextcloud_database_type", + "nextcloud_database_version", + "nextcloud_activeUsers_last5minutes", + "nextcloud_activeUsers_last1hour", + "nextcloud_activeUsers_last24hours", +) def setup_platform( From dbc0890ce8844143d1984bc46b9a14840515ea7f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Mar 2023 23:09:21 -1000 Subject: [PATCH 0925/2810] Add index to event_type and entity_id (#89676) --- homeassistant/components/recorder/db_schema.py | 10 +++++++--- homeassistant/components/recorder/migration.py | 3 +++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index b715ef9bc5..a310161457 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -68,7 +68,7 @@ class Base(DeclarativeBase): """Base class for tables.""" -SCHEMA_VERSION = 40 +SCHEMA_VERSION = 41 _LOGGER = logging.getLogger(__name__) @@ -348,7 +348,9 @@ class EventTypes(Base): __table_args__ = (_DEFAULT_TABLE_ARGS,) __tablename__ = TABLE_EVENT_TYPES event_type_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) - event_type: Mapped[str | None] = mapped_column(String(MAX_LENGTH_EVENT_EVENT_TYPE)) + event_type: Mapped[str | None] = mapped_column( + String(MAX_LENGTH_EVENT_EVENT_TYPE), index=True + ) def __repr__(self) -> str: """Return string representation of instance for debugging.""" @@ -597,7 +599,9 @@ class StatesMeta(Base): __table_args__ = (_DEFAULT_TABLE_ARGS,) __tablename__ = TABLE_STATES_META metadata_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) - entity_id: Mapped[str | None] = mapped_column(String(MAX_LENGTH_STATE_ENTITY_ID)) + entity_id: Mapped[str | None] = mapped_column( + String(MAX_LENGTH_STATE_ENTITY_ID), index=True + ) def __repr__(self) -> str: """Return string representation of instance for debugging.""" diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 5b2180773a..74fbcf307f 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1054,6 +1054,9 @@ def _apply_update( # noqa: C901 "statistics_short_term", "ix_statistics_short_term_metadata_id", ) + elif new_version == 41: + _create_index(session_maker, "event_types", "ix_event_types_event_type") + _create_index(session_maker, "states_meta", "ix_states_meta_entity_id") else: raise ValueError(f"No schema migration defined for version {new_version}") From 03b204f445aa6d9fc2a2a732778833c8a6808788 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Mar 2023 23:56:02 -1000 Subject: [PATCH 0926/2810] Execute sql queries in the database executor when using the recorder database (#89673) --- homeassistant/components/sql/sensor.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 39a11049aa..95227bac65 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -10,7 +10,7 @@ from sqlalchemy.engine import Result from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session, scoped_session, sessionmaker -from homeassistant.components.recorder import CONF_DB_URL +from homeassistant.components.recorder import CONF_DB_URL, get_instance from homeassistant.components.sensor import ( CONF_STATE_CLASS, SensorDeviceClass, @@ -136,7 +136,6 @@ async def async_setup_sensor( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the SQL sensor.""" - try: engine = sqlalchemy.create_engine(db_url, future=True) sessmaker = scoped_session(sessionmaker(bind=engine, future=True)) @@ -163,6 +162,8 @@ async def async_setup_sensor( else: query_str = query_str.replace(";", "") + " LIMIT 1;" + use_database_executor = db_url == get_instance(hass).db_url + async_add_entities( [ SQLSensor( @@ -176,6 +177,7 @@ async def async_setup_sensor( yaml, device_class, state_class, + use_database_executor, ) ], True, @@ -200,6 +202,7 @@ class SQLSensor(SensorEntity): yaml: bool, device_class: SensorDeviceClass | None, state_class: SensorStateClass | None, + use_database_executor: bool, ) -> None: """Initialize the SQL sensor.""" self._query = query @@ -212,6 +215,7 @@ class SQLSensor(SensorEntity): self.sessionmaker = sessmaker self._attr_extra_state_attributes = {} self._attr_unique_id = unique_id + self._use_database_executor = use_database_executor if not yaml and unique_id: self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, @@ -220,9 +224,15 @@ class SQLSensor(SensorEntity): name=name, ) - def update(self) -> None: - """Retrieve sensor data from the query.""" + async def async_update(self) -> None: + """Retrieve sensor data from the query using the right executor.""" + if self._use_database_executor: + await get_instance(self.hass).async_add_executor_job(self._update) + else: + await self.hass.async_add_executor_job(self._update) + def _update(self) -> None: + """Retrieve sensor data from the query.""" data = None self._attr_extra_state_attributes = {} sess: scoped_session = self.sessionmaker() From ec1b8b616f73d5b7f7021cc0fc405be34a5ed115 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 14 Mar 2023 11:13:55 +0100 Subject: [PATCH 0927/2810] Debounce and group MQTT subscriptions (#88862) * Debounce and group mqtt subscriptions * Cleanup * Do not cooldown on resubscribe * Remove lock from task Co-authored-by: Erik Montnemery * ruff * Longer initial cool down. Manages unsubscribes * Own lock for access to self._pending_subscriptions * adjust * Subscribe to highest QoS when sharing subscription * do not block _pending_subscriptions_lock with io * Test the highest qos is subscribed at * Cleanup max qos * Follow up comments part 1 * Make docstr more generic * Make max qos update thread safe * Add lock on clearing _max_qos when resubscribing * Wait for linger task * User copy * Check for key before cleaning up * Fix lingering task * Do not use a lock * do not await _async_queue_subscriptions * Replace copy with assignment * Update max qos before returning * Do not iterate if max_qos == 0 * Do not ieterate subs if max qos == 0 * Set initial cooldown correctly * Ensure discovery cooldown ends after subscribing * plan last subscribe with debouncer timeout * cooldown if self._pending_subscriptions is set * Revert format changes * Remove stale assingnment self._last_subscribe * Remove not used property * Also check while for pending subscriptions * revert first added sleep() * Optimize --------- Co-authored-by: Erik Montnemery Co-authored-by: J. Nick Koston --- homeassistant/components/mqtt/client.py | 151 +++++++++++++-- tests/components/mqtt/test_discovery.py | 6 + tests/components/mqtt/test_init.py | 242 ++++++++++++++++++++++-- 3 files changed, 361 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index e717da5144..5585a6cee5 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -83,6 +83,8 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) DISCOVERY_COOLDOWN = 2 +INITIAL_SUBSCRIBE_COOLDOWN = 1.0 +SUBSCRIBE_COOLDOWN = 0.1 TIMEOUT_ACK = 10 SubscribePayloadType = str | bytes # Only bytes if encoding is None @@ -295,10 +297,86 @@ def _is_simple_match(topic: str) -> bool: return not ("+" in topic or "#" in topic) +class EnsureJobAfterCooldown: + """Ensure a cool down period before executing a job. + + When a new execute request arrives we cancel the current request + and start a new one. + """ + + def __init__( + self, timeout: float, callback_job: Callable[[], Coroutine[Any, None, None]] + ) -> None: + """Initialize the timer.""" + self._loop = asyncio.get_running_loop() + self._timeout = timeout + self._callback = callback_job + self._task: asyncio.Future | None = None + self._timer: asyncio.TimerHandle | None = None + + def set_timeout(self, timeout: float) -> None: + """Set a new timeout period.""" + self._timeout = timeout + + async def _async_job(self) -> None: + """Execute after a cooldown period.""" + try: + await self._callback() + except HomeAssistantError as ha_error: + _LOGGER.error("%s", ha_error) + + @callback + def _async_task_done(self, task: asyncio.Future) -> None: + """Handle task done.""" + self._task = None + + @callback + def _async_execute(self) -> None: + """Execute the job.""" + if self._task: + # Task already running, + # so we schedule another run + self.async_schedule() + return + + self._async_cancel_timer() + self._task = asyncio.create_task(self._async_job()) + self._task.add_done_callback(self._async_task_done) + + @callback + def _async_cancel_timer(self) -> None: + """Cancel any pending task.""" + if self._timer: + self._timer.cancel() + self._timer = None + + @callback + def async_schedule(self) -> None: + """Ensure we execute after a cooldown period.""" + # We want to reschedule the timer in the future + # every time this is called. + self._async_cancel_timer() + self._timer = self._loop.call_later(self._timeout, self._async_execute) + + async def async_cleanup(self) -> None: + """Cleanup any pending task.""" + self._async_cancel_timer() + if not self._task: + return + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error cleaning up task", exc_info=True) + + class MQTT: """Home Assistant MQTT client.""" _mqttc: mqtt.Client + _last_subscribe: float def __init__( self, @@ -316,12 +394,16 @@ class MQTT: self._wildcard_subscriptions: list[Subscription] = [] self.connected = False self._ha_started = asyncio.Event() - self._last_subscribe = time.time() self._cleanup_on_unload: list[Callable[[], None]] = [] self._paho_lock = asyncio.Lock() # Prevents parallel calls to the MQTT client self._pending_operations: dict[int, asyncio.Event] = {} self._pending_operations_condition = asyncio.Condition() + self._subscribe_debouncer = EnsureJobAfterCooldown( + INITIAL_SUBSCRIBE_COOLDOWN, self._async_perform_subscriptions + ) + self._max_qos: dict[str, int] = {} # topic, max qos + self._pending_subscriptions: dict[str, int] = {} # topic, qos if self.hass.state == CoreState.running: self._ha_started.set() @@ -442,6 +524,11 @@ class MQTT: """Return False if there are unprocessed ACKs.""" return not any(not op.is_set() for op in self._pending_operations.values()) + # stop waiting for any pending subscriptions + await self._subscribe_debouncer.async_cleanup() + # reset timeout to initial subscribe cooldown + self._subscribe_debouncer.set_timeout(INITIAL_SUBSCRIBE_COOLDOWN) + # wait for ACKs to be processed async with self._pending_operations_condition: await self._pending_operations_condition.wait_for(no_more_acks) @@ -494,6 +581,20 @@ class MQTT: except (KeyError, ValueError) as ex: raise HomeAssistantError("Can't remove subscription twice") from ex + @callback + def _async_queue_subscriptions( + self, subscriptions: Iterable[tuple[str, int]], queue_only: bool = False + ) -> None: + """Queue requested subscriptions.""" + for subscription in subscriptions: + topic, qos = subscription + max_qos = max(qos, self._max_qos.setdefault(topic, qos)) + self._max_qos[topic] = max_qos + self._pending_subscriptions[topic] = max_qos + if queue_only: + return + self._subscribe_debouncer.async_schedule() + async def async_subscribe( self, topic: str, @@ -516,15 +617,13 @@ class MQTT: # Only subscribe if currently connected. if self.connected: - self._last_subscribe = time.time() - await self._async_perform_subscriptions(((topic, qos),)) + self._async_queue_subscriptions(((topic, qos),)) @callback def async_remove() -> None: """Remove subscription.""" self._async_untrack_subscription(subscription) self._matching_subscriptions.cache_clear() - # Only unsubscribe if currently connected if self.connected: self.hass.async_create_task(self._async_unsubscribe(topic)) @@ -543,21 +642,27 @@ class MQTT: _raise_on_error(result) return mid - async with self._paho_lock: - if self._is_active_subscription(topic): - # Other subscriptions on topic remaining - don't unsubscribe. + if self._is_active_subscription(topic): + if self._max_qos[topic] == 0: return - + subs = self._matching_subscriptions(topic) + self._max_qos[topic] = max(sub.qos for sub in subs) + # Other subscriptions on topic remaining - don't unsubscribe. + return + if topic in self._max_qos: + del self._max_qos[topic] + if topic in self._pending_subscriptions: + # avoid any pending subscription to be executed + del self._pending_subscriptions[topic] + async with self._paho_lock: mid = await self.hass.async_add_executor_job(_client_unsubscribe, topic) await self._register_mid(mid) self.hass.async_create_task(self._wait_for_mid(mid)) - async def _async_perform_subscriptions( - self, subscriptions: Iterable[tuple[str, int]] - ) -> None: + async def _async_perform_subscriptions(self) -> None: """Perform MQTT client subscriptions.""" - + subscriptions: dict[str, int] # Section 3.3.1.3 in the specification: # http://docs.oasis-open.org/mqtt/mqtt/v3.1.1/os/mqtt-v3.1.1-os.html # When sending a PUBLISH Packet to a Client the Server MUST @@ -573,16 +678,20 @@ class MQTT: def _process_client_subscriptions() -> list[tuple[int, int]]: """Initiate all subscriptions on the MQTT client and return the results.""" subscribe_result_list = [] - for topic, qos in subscriptions: + for topic, qos in subscriptions.items(): result, mid = self._mqttc.subscribe(topic, qos) subscribe_result_list.append((result, mid)) _LOGGER.debug("Subscribing to %s, mid: %s, qos: %s", topic, mid, qos) return subscribe_result_list + subscriptions = self._pending_subscriptions + self._pending_subscriptions = {} + async with self._paho_lock: results = await self.hass.async_add_executor_job( _process_client_subscriptions ) + self._last_subscribe = time.time() tasks: list[Coroutine[Any, Any, None]] = [] errors: list[int] = [] @@ -639,6 +748,8 @@ class MQTT: async def publish_birth_message(birth_message: PublishMessage) -> None: await self._ha_started.wait() # Wait for Home Assistant to start await self._discovery_cooldown() # Wait for MQTT discovery to cool down + # Update subscribe cooldown period to a shorter time + self._subscribe_debouncer.set_timeout(SUBSCRIBE_COOLDOWN) await self.async_publish( topic=birth_message.topic, payload=birth_message.payload, @@ -654,16 +765,19 @@ class MQTT: async def _async_resubscribe(self) -> None: """Resubscribe on reconnect.""" # Group subscriptions to only re-subscribe once for each topic. + self._max_qos.clear() keyfunc = attrgetter("topic") - await self._async_perform_subscriptions( + self._async_queue_subscriptions( [ # Re-subscribe with the highest requested qos (topic, max(subscription.qos for subscription in subs)) for topic, subs in groupby( sorted(self.subscriptions, key=keyfunc), keyfunc ) - ] + ], + queue_only=True, ) + await self._async_perform_subscriptions() def _mqtt_on_message( self, _mqttc: mqtt.Client, _userdata: None, msg: mqtt.MQTTMessage @@ -785,13 +899,14 @@ class MQTT: self._pending_operations_condition.notify_all() async def _discovery_cooldown(self) -> None: + """Wait until all discovery and subscriptions are processed.""" now = time.time() # Reset discovery and subscribe cooldowns self._mqtt_data.last_discovery = now self._last_subscribe = now last_discovery = self._mqtt_data.last_discovery - last_subscribe = self._last_subscribe + last_subscribe = now if self._pending_subscriptions else self._last_subscribe wait_until = max( last_discovery + DISCOVERY_COOLDOWN, last_subscribe + DISCOVERY_COOLDOWN ) @@ -799,7 +914,9 @@ class MQTT: await asyncio.sleep(wait_until - now) now = time.time() last_discovery = self._mqtt_data.last_discovery - last_subscribe = self._last_subscribe + last_subscribe = ( + now if self._pending_subscriptions else self._last_subscribe + ) wait_until = max( last_discovery + DISCOVERY_COOLDOWN, last_subscribe + DISCOVERY_COOLDOWN ) diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 5cd615e0eb..a21f69544e 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -1374,6 +1374,8 @@ async def test_complex_discovery_topic_prefix( @patch("homeassistant.components.mqtt.PLATFORMS", []) +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) async def test_mqtt_integration_discovery_subscribe_unsubscribe( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, @@ -1392,6 +1394,7 @@ async def test_mqtt_integration_discovery_subscribe_unsubscribe( ): await async_start(hass, "homeassistant", entry) await hass.async_block_till_done() + await hass.async_block_till_done() mqtt_client_mock.subscribe.assert_any_call("comp/discovery/#", 0) assert not mqtt_client_mock.unsubscribe.called @@ -1418,6 +1421,8 @@ async def test_mqtt_integration_discovery_subscribe_unsubscribe( @patch("homeassistant.components.mqtt.PLATFORMS", []) +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) async def test_mqtt_discovery_unsubscribe_once( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, @@ -1436,6 +1441,7 @@ async def test_mqtt_discovery_unsubscribe_once( ): await async_start(hass, "homeassistant", entry) await hass.async_block_till_done() + await hass.async_block_till_done() mqtt_client_mock.subscribe.assert_any_call("comp/discovery/#", 0) assert not mqtt_client_mock.unsubscribe.called diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 8c6ee21c93..ec373aab0d 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -17,6 +17,7 @@ import yaml from homeassistant import config as hass_config from homeassistant.components import mqtt from homeassistant.components.mqtt import CONFIG_SCHEMA, debug_info +from homeassistant.components.mqtt.client import EnsureJobAfterCooldown from homeassistant.components.mqtt.mixins import MQTT_ENTITY_DEVICE_INFO_SCHEMA from homeassistant.components.mqtt.models import MessageCallbackType, ReceiveMessage from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState @@ -1262,6 +1263,9 @@ async def test_subscribe_special_characters( assert calls[0].payload == payload +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) async def test_subscribe_same_topic( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, @@ -1286,26 +1290,35 @@ async def test_subscribe_same_topic( def _callback_b(msg: ReceiveMessage) -> None: calls_b.append(msg) - await mqtt.async_subscribe(hass, "test/state", _callback_a) + await mqtt.async_subscribe(hass, "test/state", _callback_a, qos=0) async_fire_mqtt_message( hass, "test/state", "online" - ) # Simulate a (retained) message + ) # Simulate a (retained) message replaying + async_fire_time_changed(hass, utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() assert len(calls_a) == 1 mqtt_client_mock.subscribe.assert_called() calls_a = [] mqtt_client_mock.reset_mock() - await mqtt.async_subscribe(hass, "test/state", _callback_b) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) + await hass.async_block_till_done() + await mqtt.async_subscribe(hass, "test/state", _callback_b, qos=1) async_fire_mqtt_message( hass, "test/state", "online" - ) # Simulate a (retained) message + ) # Simulate a (retained) message replaying + async_fire_time_changed(hass, utcnow() + timedelta(seconds=1)) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() assert len(calls_a) == 1 assert len(calls_b) == 1 mqtt_client_mock.subscribe.assert_called() +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) async def test_not_calling_unsubscribe_with_active_subscribers( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, @@ -1317,8 +1330,10 @@ async def test_not_calling_unsubscribe_with_active_subscribers( # Fake that the client is connected mqtt_mock().connected = True - unsub = await mqtt.async_subscribe(hass, "test/state", record_calls) - await mqtt.async_subscribe(hass, "test/state", record_calls) + unsub = await mqtt.async_subscribe(hass, "test/state", record_calls, 2) + await mqtt.async_subscribe(hass, "test/state", record_calls, 1) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown await hass.async_block_till_done() assert mqtt_client_mock.subscribe.called @@ -1327,6 +1342,30 @@ async def test_not_calling_unsubscribe_with_active_subscribers( assert not mqtt_client_mock.unsubscribe.called +async def test_not_calling_subscribe_when_unsubscribed_within_cooldown( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + record_calls: MessageCallbackType, +) -> None: + """Test not calling subscribe() when it is unsubscribed. + + Make sure subscriptions are cleared if unsubscribed before + the subscribe cool down period has ended. + """ + mqtt_mock = await mqtt_mock_entry_no_yaml_config() + # Fake that the client is connected + mqtt_mock().connected = True + + unsub = await mqtt.async_subscribe(hass, "test/state", record_calls) + unsub() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown + await hass.async_block_till_done() + assert not mqtt_client_mock.subscribe.called + + +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) async def test_unsubscribe_race( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, @@ -1351,13 +1390,15 @@ async def test_unsubscribe_race( unsub() await mqtt.async_subscribe(hass, "test/state", _callback_b) await hass.async_block_till_done() + await hass.async_block_till_done() async_fire_mqtt_message(hass, "test/state", "online") await hass.async_block_till_done() assert not calls_a assert calls_b - # We allow either calls [subscribe, unsubscribe, subscribe] or [subscribe, subscribe] + # We allow either calls [subscribe, unsubscribe, subscribe], [subscribe, subscribe] or + # when both subscriptions were combined [subscribe] expected_calls_1 = [ call.subscribe("test/state", 0), call.unsubscribe("test/state"), @@ -1367,13 +1408,23 @@ async def test_unsubscribe_race( call.subscribe("test/state", 0), call.subscribe("test/state", 0), ] - assert mqtt_client_mock.mock_calls in (expected_calls_1, expected_calls_2) + expected_calls_3 = [ + call.subscribe("test/state", 0), + ] + assert mqtt_client_mock.mock_calls in ( + expected_calls_1, + expected_calls_2, + expected_calls_3, + ) @pytest.mark.parametrize( "mqtt_config_entry_data", [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_DISCOVERY: False}], ) +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) async def test_restore_subscriptions_on_reconnect( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, @@ -1386,13 +1437,15 @@ async def test_restore_subscriptions_on_reconnect( mqtt_mock().connected = True await mqtt.async_subscribe(hass, "test/state", record_calls) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown await hass.async_block_till_done() assert mqtt_client_mock.subscribe.call_count == 1 mqtt_client_mock.on_disconnect(None, None, 0) - with patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0): - mqtt_client_mock.on_connect(None, None, None, 0) - await hass.async_block_till_done() + mqtt_client_mock.on_connect(None, None, None, 0) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown + await hass.async_block_till_done() + await hass.async_block_till_done() assert mqtt_client_mock.subscribe.call_count == 2 @@ -1400,6 +1453,9 @@ async def test_restore_subscriptions_on_reconnect( "mqtt_config_entry_data", [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_DISCOVERY: False}], ) +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 1.0) +@patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 1.0) async def test_restore_all_active_subscriptions_on_reconnect( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, @@ -1412,14 +1468,15 @@ async def test_restore_all_active_subscriptions_on_reconnect( mqtt_mock().connected = True unsub = await mqtt.async_subscribe(hass, "test/state", record_calls, qos=2) - await mqtt.async_subscribe(hass, "test/state", record_calls) await mqtt.async_subscribe(hass, "test/state", record_calls, qos=1) + await mqtt.async_subscribe(hass, "test/state", record_calls, qos=0) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown await hass.async_block_till_done() + # the subscribtion with the highest QoS should survive expected = [ call("test/state", 2), - call("test/state", 0), - call("test/state", 1), ] assert mqtt_client_mock.subscribe.mock_calls == expected @@ -1428,13 +1485,60 @@ async def test_restore_all_active_subscriptions_on_reconnect( assert mqtt_client_mock.unsubscribe.call_count == 0 mqtt_client_mock.on_disconnect(None, None, 0) - with patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0): - mqtt_client_mock.on_connect(None, None, None, 0) - await hass.async_block_till_done() + await hass.async_block_till_done() + mqtt_client_mock.on_connect(None, None, None, 0) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown + await hass.async_block_till_done() expected.append(call("test/state", 1)) assert mqtt_client_mock.subscribe.mock_calls == expected + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown + await hass.async_block_till_done() + + +@pytest.mark.parametrize( + "mqtt_config_entry_data", + [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_DISCOVERY: False}], +) +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 1.0) +@patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 1.0) +async def test_subscribed_at_highest_qos( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + record_calls: MessageCallbackType, +) -> None: + """Test the highest qos as assigned when subscribing to the same topic.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() + # Fake that the client is connected + mqtt_mock().connected = True + + await mqtt.async_subscribe(hass, "test/state", record_calls, qos=0) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) # cooldown + await hass.async_block_till_done() + assert mqtt_client_mock.subscribe.mock_calls == [ + call("test/state", 0), + ] + mqtt_client_mock.reset_mock() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) # cooldown + await hass.async_block_till_done() + await hass.async_block_till_done() + + await mqtt.async_subscribe(hass, "test/state", record_calls, qos=1) + await mqtt.async_subscribe(hass, "test/state", record_calls, qos=2) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) # cooldown + await hass.async_block_till_done() + # the subscribtion with the highest QoS should survive + assert mqtt_client_mock.subscribe.mock_calls == [ + call("test/state", 2), + ] + async def test_reload_entry_with_restored_subscriptions( hass: HomeAssistant, @@ -1499,6 +1603,93 @@ async def test_reload_entry_with_restored_subscriptions( assert calls[1].payload == "wild-card-payload3" +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 2) +@patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 2) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 2) +async def test_canceling_debouncer_on_shutdown( + hass: HomeAssistant, + record_calls: MessageCallbackType, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test canceling the debouncer when HA shuts down.""" + + mqtt_mock = await mqtt_mock_entry_no_yaml_config() + + # Fake that the client is connected + mqtt_mock().connected = True + + await mqtt.async_subscribe(hass, "test/state1", record_calls) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=0.2)) + await hass.async_block_till_done() + + await mqtt.async_subscribe(hass, "test/state2", record_calls) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=0.2)) + await hass.async_block_till_done() + + await mqtt.async_subscribe(hass, "test/state3", record_calls) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=0.2)) + await hass.async_block_till_done() + + await mqtt.async_subscribe(hass, "test/state4", record_calls) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=0.2)) + await hass.async_block_till_done() + + await mqtt.async_subscribe(hass, "test/state5", record_calls) + + mqtt_client_mock.subscribe.assert_not_called() + + # Stop HA so the scheduled task will be canceled + hass.bus.fire(EVENT_HOMEASSISTANT_STOP) + # mock disconnect status + mqtt_client_mock.on_disconnect(None, None, 0) + await hass.async_block_till_done() + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + mqtt_client_mock.subscribe.assert_not_called() + + +async def test_canceling_debouncer_normal( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test canceling the debouncer before completion.""" + + async def _async_myjob() -> None: + await asyncio.sleep(1.0) + + debouncer = EnsureJobAfterCooldown(0.0, _async_myjob) + debouncer.async_schedule() + await asyncio.sleep(0.01) + assert debouncer._task is not None + await debouncer.async_cleanup() + assert debouncer._task is None + + +async def test_canceling_debouncer_throws( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test canceling the debouncer when HA shuts down.""" + + async def _async_myjob() -> None: + await asyncio.sleep(1.0) + + debouncer = EnsureJobAfterCooldown(0.0, _async_myjob) + debouncer.async_schedule() + await asyncio.sleep(0.01) + assert debouncer._task is not None + # let debouncer._task fail by mocking it + with patch.object(debouncer, "_task") as task: + task.cancel = MagicMock(return_value=True) + await debouncer.async_cleanup() + assert "Error cleaning up task" in caplog.text + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + + async def test_initial_setup_logs_error( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -1575,21 +1766,30 @@ async def test_publish_error( assert "Failed to connect to MQTT server: Out of memory." in caplog.text +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) async def test_subscribe_error( hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, mqtt_client_mock: MqttMockPahoClient, record_calls: MessageCallbackType, + caplog: pytest.LogCaptureFixture, ) -> None: """Test publish error.""" await mqtt_mock_entry_no_yaml_config() mqtt_client_mock.on_connect(mqtt_client_mock, None, None, 0) await hass.async_block_till_done() - with pytest.raises(HomeAssistantError): - # simulate client is not connected error before subscribing - mqtt_client_mock.subscribe.side_effect = lambda *args: (4, None) - await mqtt.async_subscribe(hass, "some-topic", record_calls) + await hass.async_block_till_done() + mqtt_client_mock.reset_mock() + # simulate client is not connected error before subscribing + mqtt_client_mock.subscribe.side_effect = lambda *args: (4, None) + await mqtt.async_subscribe(hass, "some-topic", record_calls) + while mqtt_client_mock.subscribe.call_count == 0: await hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() + assert ( + "Error talking to MQTT: The client is not currently connected." in caplog.text + ) async def test_handle_message_callback( From 2809a686be7fd5ed074ccf65c7cb1bf7f8a1aad2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 14 Mar 2023 12:14:29 +0100 Subject: [PATCH 0928/2810] Remove duplicate code in nextcloud (#89681) --- .../components/nextcloud/binary_sensor.py | 33 +++---------------- homeassistant/components/nextcloud/entity.py | 26 +++++++++++++++ homeassistant/components/nextcloud/sensor.py | 33 +++---------------- 3 files changed, 36 insertions(+), 56 deletions(-) create mode 100644 homeassistant/components/nextcloud/entity.py diff --git a/homeassistant/components/nextcloud/binary_sensor.py b/homeassistant/components/nextcloud/binary_sensor.py index d811c6c924..6e0df919f9 100644 --- a/homeassistant/components/nextcloud/binary_sensor.py +++ b/homeassistant/components/nextcloud/binary_sensor.py @@ -6,7 +6,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN +from .const import DOMAIN +from .entity import NextcloudEntity BINARY_SENSORS = ( "nextcloud_system_enable_avatars", @@ -32,34 +33,10 @@ def setup_platform( add_entities(binary_sensors, True) -class NextcloudBinarySensor(BinarySensorEntity): +class NextcloudBinarySensor(NextcloudEntity, BinarySensorEntity): """Represents a Nextcloud binary sensor.""" - def __init__(self, item): - """Initialize the Nextcloud binary sensor.""" - self._name = item - self._is_on = None - @property - def icon(self): - """Return the icon for this binary sensor.""" - return "mdi:cloud" - - @property - def name(self): - """Return the name for this binary sensor.""" - return self._name - - @property - def is_on(self): + def is_on(self) -> bool: """Return true if the binary sensor is on.""" - return self._is_on == "yes" - - @property - def unique_id(self): - """Return the unique ID for this binary sensor.""" - return f"{self.hass.data[DOMAIN]['instance']}#{self._name}" - - def update(self) -> None: - """Update the binary sensor.""" - self._is_on = self.hass.data[DOMAIN][self._name] + return self._state == "yes" diff --git a/homeassistant/components/nextcloud/entity.py b/homeassistant/components/nextcloud/entity.py new file mode 100644 index 0000000000..cb066e0fcf --- /dev/null +++ b/homeassistant/components/nextcloud/entity.py @@ -0,0 +1,26 @@ +"""Base entity for the Nextcloud integration.""" +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import StateType + +from .const import DOMAIN + + +class NextcloudEntity(Entity): + """Base Nextcloud entity.""" + + _attr_icon = "mdi:cloud" + + def __init__(self, item: str) -> None: + """Initialize the Nextcloud entity.""" + self._attr_name = item + self.item = item + self._state: StateType = None + + @property + def unique_id(self): + """Return the unique ID for this sensor.""" + return f"{self.hass.data[DOMAIN]['instance']}#{self.item}" + + def update(self) -> None: + """Update the sensor.""" + self._state = self.hass.data[DOMAIN][self.item] diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py index 6f1ce00eee..91d4411b0c 100644 --- a/homeassistant/components/nextcloud/sensor.py +++ b/homeassistant/components/nextcloud/sensor.py @@ -4,9 +4,10 @@ from __future__ import annotations from homeassistant.components.sensor import SensorEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType -from . import DOMAIN +from .const import DOMAIN +from .entity import NextcloudEntity SENSORS = ( "nextcloud_system_version", @@ -71,34 +72,10 @@ def setup_platform( add_entities(sensors, True) -class NextcloudSensor(SensorEntity): +class NextcloudSensor(NextcloudEntity, SensorEntity): """Represents a Nextcloud sensor.""" - def __init__(self, item): - """Initialize the Nextcloud sensor.""" - self._name = item - self._state = None - @property - def icon(self): - """Return the icon for this sensor.""" - return "mdi:cloud" - - @property - def name(self): - """Return the name for this sensor.""" - return self._name - - @property - def native_value(self): + def native_value(self) -> StateType: """Return the state for this sensor.""" return self._state - - @property - def unique_id(self): - """Return the unique ID for this sensor.""" - return f"{self.hass.data[DOMAIN]['instance']}#{self._name}" - - def update(self) -> None: - """Update the sensor.""" - self._state = self.hass.data[DOMAIN][self._name] From 73e1942eebe79ccf2bae3a4b342e15b532bfcc78 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Tue, 14 Mar 2023 11:28:43 +0000 Subject: [PATCH 0929/2810] Update entity names to capitalize first word only for System Bridge (#89688) --- .../components/system_bridge/binary_sensor.py | 4 +- .../components/system_bridge/sensor.py | 48 +++++++++---------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/system_bridge/binary_sensor.py b/homeassistant/components/system_bridge/binary_sensor.py index 8feb111428..bb83d90235 100644 --- a/homeassistant/components/system_bridge/binary_sensor.py +++ b/homeassistant/components/system_bridge/binary_sensor.py @@ -29,7 +29,7 @@ class SystemBridgeBinarySensorEntityDescription(BinarySensorEntityDescription): BASE_BINARY_SENSOR_TYPES: tuple[SystemBridgeBinarySensorEntityDescription, ...] = ( SystemBridgeBinarySensorEntityDescription( key="version_available", - name="New Version Available", + name="New version available", device_class=BinarySensorDeviceClass.UPDATE, value=lambda data: data.system.version_newer_available, ), @@ -38,7 +38,7 @@ BASE_BINARY_SENSOR_TYPES: tuple[SystemBridgeBinarySensorEntityDescription, ...] BATTERY_BINARY_SENSOR_TYPES: tuple[SystemBridgeBinarySensorEntityDescription, ...] = ( SystemBridgeBinarySensorEntityDescription( key="battery_is_charging", - name="Battery Is Charging", + name="Battery is charging", device_class=BinarySensorDeviceClass.BATTERY_CHARGING, value=lambda data: data.battery.is_charging, ), diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index e73dec69c0..a6bf29ac54 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -122,7 +122,7 @@ def memory_used(data: SystemBridgeCoordinatorData) -> float | None: BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( SystemBridgeSensorEntityDescription( key="boot_time", - name="Boot Time", + name="Boot time", device_class=SensorDeviceClass.TIMESTAMP, icon="mdi:av-timer", value=lambda data: datetime.fromtimestamp( @@ -131,7 +131,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="cpu_speed", - name="CPU Speed", + name="CPU speed", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.GIGAHERTZ, device_class=SensorDeviceClass.FREQUENCY, @@ -140,7 +140,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="cpu_temperature", - name="CPU Temperature", + name="CPU temperature", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -149,7 +149,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="cpu_voltage", - name="CPU Voltage", + name="CPU voltage", entity_registry_enabled_default=False, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -164,7 +164,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="memory_free", - name="Memory Free", + name="Memory free", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -173,7 +173,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="memory_used_percentage", - name="Memory Used %", + name="Memory used %", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", @@ -181,7 +181,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="memory_used", - name="Memory Used", + name="Memory used", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.GIGABYTES, @@ -191,7 +191,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="os", - name="Operating System", + name="Operating system", icon="mdi:devices", value=lambda data: f"{data.system.platform} {data.system.platform_version}", ), @@ -211,7 +211,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="version_latest", - name="Latest Version", + name="Latest version", icon="mdi:counter", value=lambda data: data.system.version_latest, ), @@ -228,7 +228,7 @@ BATTERY_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="battery_time_remaining", - name="Battery Time Remaining", + name="Battery time remaining", device_class=SensorDeviceClass.TIMESTAMP, value=battery_time_remaining, ), @@ -255,7 +255,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"filesystem_{partition.replace(':', '')}", - name=f"{partition} Space Used", + name=f"{partition} space used", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:harddisk", @@ -296,7 +296,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key="displays_connected", - name="Displays Connected", + name="Displays connected", state_class=SensorStateClass.MEASUREMENT, icon="mdi:monitor", value=lambda _, count=display_count: count, @@ -312,7 +312,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"display_{display['name']}_resolution_x", - name=f"Display {display['name']} Resolution X", + name=f"Display {display['name']} resolution x", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PIXELS, icon="mdi:monitor", @@ -326,7 +326,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"display_{display['name']}_resolution_y", - name=f"Display {display['name']} Resolution Y", + name=f"Display {display['name']} resolution y", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PIXELS, icon="mdi:monitor", @@ -340,7 +340,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"display_{display['name']}_refresh_rate", - name=f"Display {display['name']} Refresh Rate", + name=f"Display {display['name']} refresh rate", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, @@ -371,7 +371,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{index}_core_clock_speed", - name=f"{gpu['name']} Clock Speed", + name=f"{gpu['name']} clock speed", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.MEGAHERTZ, @@ -385,7 +385,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{index}_memory_clock_speed", - name=f"{gpu['name']} Memory Clock Speed", + name=f"{gpu['name']} memory clock speed", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.MEGAHERTZ, @@ -399,7 +399,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{index}_memory_free", - name=f"{gpu['name']} Memory Free", + name=f"{gpu['name']} memory free", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -412,7 +412,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{index}_memory_used_percentage", - name=f"{gpu['name']} Memory Used %", + name=f"{gpu['name']} memory used %", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", @@ -426,7 +426,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{index}_memory_used", - name=f"{gpu['name']} Memory Used", + name=f"{gpu['name']} memory used", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.GIGABYTES, @@ -440,7 +440,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{index}_fan_speed", - name=f"{gpu['name']} Fan Speed", + name=f"{gpu['name']} fan speed", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, @@ -455,7 +455,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{index}_power_usage", - name=f"{gpu['name']} Power Usage", + name=f"{gpu['name']} power usage", entity_registry_enabled_default=False, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -470,7 +470,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{index}_temperature", - name=f"{gpu['name']} Temperature", + name=f"{gpu['name']} temperature", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -485,7 +485,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{index}_usage_percentage", - name=f"{gpu['name']} Usage %", + name=f"{gpu['name']} usage %", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:percent", From a213ef24757e95413e935da169e926dec29c20eb Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 14 Mar 2023 15:27:31 +0100 Subject: [PATCH 0930/2810] Add websocket command to set preferred thread dataset (#89700) --- .../components/thread/websocket_api.py | 26 ++++++++++ tests/components/thread/test_websocket_api.py | 50 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/homeassistant/components/thread/websocket_api.py b/homeassistant/components/thread/websocket_api.py index 053ec69a0f..9f9bc3455a 100644 --- a/homeassistant/components/thread/websocket_api.py +++ b/homeassistant/components/thread/websocket_api.py @@ -20,6 +20,7 @@ def async_setup(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_discover_routers) websocket_api.async_register_command(hass, ws_get_dataset) websocket_api.async_register_command(hass, ws_list_datasets) + websocket_api.async_register_command(hass, ws_set_preferred_dataset) @websocket_api.require_admin @@ -49,6 +50,31 @@ async def ws_add_dataset( connection.send_result(msg["id"]) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "thread/set_preferred_dataset", + vol.Required("dataset_id"): str, + } +) +@websocket_api.async_response +async def ws_set_preferred_dataset( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Add a thread dataset.""" + dataset_id = msg["dataset_id"] + + store = await dataset_store.async_get_store(hass) + if not (store.async_get(dataset_id)): + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_FOUND, "unknown dataset" + ) + return + + store.preferred_dataset = dataset_id + connection.send_result(msg["id"]) + + @websocket_api.require_admin @websocket_api.websocket_command( { diff --git a/tests/components/thread/test_websocket_api.py b/tests/components/thread/test_websocket_api.py index f8f09b0b8c..c2e9e5f593 100644 --- a/tests/components/thread/test_websocket_api.py +++ b/tests/components/thread/test_websocket_api.py @@ -197,6 +197,56 @@ async def test_list_get_dataset( assert msg["error"] == {"code": "not_found", "message": "unknown dataset"} +async def test_set_preferred_dataset( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test we set a dataset as default.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + datasets = [ + {"source": "Google", "tlv": DATASET_1}, + {"source": "Multipan", "tlv": DATASET_2}, + {"source": "🎅", "tlv": DATASET_3}, + ] + for dataset in datasets: + await dataset_store.async_add_dataset(hass, dataset["source"], dataset["tlv"]) + + store = await dataset_store.async_get_store(hass) + + for dataset in store.datasets.values(): + if dataset.source == "🎅": + dataset_3 = dataset + + client = await hass_ws_client(hass) + + await client.send_json( + {"id": 1, "type": "thread/set_preferred_dataset", "dataset_id": dataset_3.id} + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + store = await dataset_store.async_get_store(hass) + assert store.preferred_dataset == dataset_3.id + + +async def test_set_preferred_dataset_wrong_id( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test we set a dataset as default.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + await client.send_json( + {"id": 1, "type": "thread/set_preferred_dataset", "dataset_id": "don_t_exist"} + ) + msg = await client.receive_json() + assert msg["error"]["code"] == "not_found" + + async def test_discover_routers( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_async_zeroconf: None ) -> None: From 85e01771955ceb105ae62a20c6ab1f492ae1738f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 14 Mar 2023 15:28:06 +0100 Subject: [PATCH 0931/2810] Add WS command for connecting OTBR to a known Thread network (#89692) * Add WS command for connecting OTBR to a known Thread network * Add test --- homeassistant/components/otbr/__init__.py | 5 + .../components/otbr/websocket_api.py | 66 +++++- homeassistant/components/thread/__init__.py | 8 +- .../components/thread/dataset_store.py | 8 + tests/components/otbr/test_websocket_api.py | 188 +++++++++++++++++- tests/components/thread/test_dataset_store.py | 11 + 6 files changed, 282 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index d313c61c0e..602c76f77e 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -78,6 +78,11 @@ class OTBRData: """Create an active operational dataset.""" return await self.api.create_active_dataset(dataset) + @_handle_otbr_error + async def set_active_dataset_tlvs(self, dataset: bytes) -> None: + """Set current active operational dataset in TLVS format.""" + await self.api.set_active_dataset_tlvs(dataset) + @_handle_otbr_error async def get_extended_address(self) -> bytes: """Get extended address (EUI-64).""" diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py index 3d885cd500..aa8c1dd2dd 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -2,9 +2,11 @@ from typing import TYPE_CHECKING import python_otbr_api +from python_otbr_api import tlv_parser +import voluptuous as vol from homeassistant.components import websocket_api -from homeassistant.components.thread import async_add_dataset +from homeassistant.components.thread import async_add_dataset, async_get_dataset from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -20,6 +22,7 @@ def async_setup(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_info) websocket_api.async_register_command(hass, websocket_create_network) websocket_api.async_register_command(hass, websocket_get_extended_address) + websocket_api.async_register_command(hass, websocket_set_network) @websocket_api.websocket_command( @@ -111,6 +114,67 @@ async def websocket_create_network( connection.send_result(msg["id"]) +@websocket_api.websocket_command( + { + "type": "otbr/set_network", + vol.Required("dataset_id"): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_set_network( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Set the Thread network to be used by the OTBR.""" + if DOMAIN not in hass.data: + connection.send_error(msg["id"], "not_loaded", "No OTBR API loaded") + return + + dataset_tlv = await async_get_dataset(hass, msg["dataset_id"]) + + if not dataset_tlv: + connection.send_error(msg["id"], "unknown_dataset", "Unknown dataset") + return + dataset = tlv_parser.parse_tlv(dataset_tlv) + if channel_str := dataset.get(tlv_parser.MeshcopTLVType.CHANNEL): + thread_dataset_channel = int(channel_str, base=16) + + # We currently have no way to know which channel zha is using, assume it's + # the default + zha_channel = DEFAULT_CHANNEL + + if thread_dataset_channel != zha_channel: + connection.send_error( + msg["id"], + "channel_conflict", + f"Can't connect to network on channel {thread_dataset_channel}, ZHA is " + f"using channel {zha_channel}", + ) + return + + data: OTBRData = hass.data[DOMAIN] + + try: + await data.set_enabled(False) + except HomeAssistantError as exc: + connection.send_error(msg["id"], "set_enabled_failed", str(exc)) + return + + try: + await data.set_active_dataset_tlvs(bytes.fromhex(dataset_tlv)) + except HomeAssistantError as exc: + connection.send_error(msg["id"], "set_active_dataset_tlvs_failed", str(exc)) + return + + try: + await data.set_enabled(True) + except HomeAssistantError as exc: + connection.send_error(msg["id"], "set_enabled_failed", str(exc)) + return + + connection.send_result(msg["id"]) + + @websocket_api.websocket_command( { "type": "otbr/get_extended_address", diff --git a/homeassistant/components/thread/__init__.py b/homeassistant/components/thread/__init__.py index 345fca854d..4fc8847981 100644 --- a/homeassistant/components/thread/__init__.py +++ b/homeassistant/components/thread/__init__.py @@ -6,13 +6,19 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType from .const import DOMAIN -from .dataset_store import DatasetEntry, async_add_dataset, async_get_preferred_dataset +from .dataset_store import ( + DatasetEntry, + async_add_dataset, + async_get_dataset, + async_get_preferred_dataset, +) from .websocket_api import async_setup as async_setup_ws_api __all__ = [ "DOMAIN", "DatasetEntry", "async_add_dataset", + "async_get_dataset", "async_get_preferred_dataset", ] diff --git a/homeassistant/components/thread/dataset_store.py b/homeassistant/components/thread/dataset_store.py index b9a27b617e..ea5a16f90c 100644 --- a/homeassistant/components/thread/dataset_store.py +++ b/homeassistant/components/thread/dataset_store.py @@ -159,6 +159,14 @@ async def async_add_dataset(hass: HomeAssistant, source: str, tlv: str) -> None: store.async_add(source, tlv) +async def async_get_dataset(hass: HomeAssistant, dataset_id: str) -> str | None: + """Get a dataset.""" + store = await async_get_store(hass) + if (entry := store.async_get(dataset_id)) is None: + return None + return entry.tlv + + async def async_get_preferred_dataset(hass: HomeAssistant) -> str | None: """Get the preferred dataset.""" store = await async_get_store(hass) diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index 056563e7b8..04210a3433 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -4,11 +4,11 @@ from unittest.mock import patch import pytest import python_otbr_api -from homeassistant.components import otbr +from homeassistant.components import otbr, thread from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from . import BASE_URL, DATASET_CH16 +from . import BASE_URL, DATASET_CH15, DATASET_CH16 from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import WebSocketGenerator @@ -290,6 +290,190 @@ async def test_create_network_fails_5( assert msg["error"]["code"] == "get_active_dataset_tlvs_empty" +async def test_set_network( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry, + websocket_client, +) -> None: + """Test set network.""" + + await thread.async_add_dataset(hass, "test", DATASET_CH15.hex()) + dataset_store = await thread.dataset_store.async_get_store(hass) + dataset_id = list(dataset_store.datasets)[1] + + with patch( + "python_otbr_api.OTBR.set_active_dataset_tlvs" + ) as set_active_dataset_tlvs_mock, patch( + "python_otbr_api.OTBR.set_enabled" + ) as set_enabled_mock: + await websocket_client.send_json_auto_id( + { + "type": "otbr/set_network", + "dataset_id": dataset_id, + } + ) + + msg = await websocket_client.receive_json() + assert msg["success"] + assert msg["result"] is None + + set_active_dataset_tlvs_mock.assert_called_once_with(DATASET_CH15) + assert len(set_enabled_mock.mock_calls) == 2 + assert set_enabled_mock.mock_calls[0][1][0] is False + assert set_enabled_mock.mock_calls[1][1][0] is True + + +async def test_set_network_no_entry( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test set network.""" + await async_setup_component(hass, "otbr", {}) + websocket_client = await hass_ws_client(hass) + await websocket_client.send_json_auto_id( + { + "type": "otbr/set_network", + "dataset_id": "abc", + } + ) + + msg = await websocket_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "not_loaded" + + +async def test_set_network_channel_conflict( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry, + websocket_client, +) -> None: + """Test set network.""" + + dataset_store = await thread.dataset_store.async_get_store(hass) + dataset_id = list(dataset_store.datasets)[0] + + await websocket_client.send_json_auto_id( + { + "type": "otbr/set_network", + "dataset_id": dataset_id, + } + ) + + msg = await websocket_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "channel_conflict" + + +async def test_set_network_unknown_dataset( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry, + websocket_client, +) -> None: + """Test set network.""" + + await websocket_client.send_json_auto_id( + { + "type": "otbr/set_network", + "dataset_id": "abc", + } + ) + + msg = await websocket_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "unknown_dataset" + + +async def test_set_network_fails_1( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry, + websocket_client, +) -> None: + """Test set network.""" + await thread.async_add_dataset(hass, "test", DATASET_CH15.hex()) + dataset_store = await thread.dataset_store.async_get_store(hass) + dataset_id = list(dataset_store.datasets)[1] + + with patch( + "python_otbr_api.OTBR.set_enabled", + side_effect=python_otbr_api.OTBRError, + ): + await websocket_client.send_json_auto_id( + { + "type": "otbr/set_network", + "dataset_id": dataset_id, + } + ) + msg = await websocket_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "set_enabled_failed" + + +async def test_set_network_fails_2( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry, + websocket_client, +) -> None: + """Test set network.""" + await thread.async_add_dataset(hass, "test", DATASET_CH15.hex()) + dataset_store = await thread.dataset_store.async_get_store(hass) + dataset_id = list(dataset_store.datasets)[1] + + with patch( + "python_otbr_api.OTBR.set_enabled", + ), patch( + "python_otbr_api.OTBR.set_active_dataset_tlvs", + side_effect=python_otbr_api.OTBRError, + ): + await websocket_client.send_json_auto_id( + { + "type": "otbr/set_network", + "dataset_id": dataset_id, + } + ) + msg = await websocket_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "set_active_dataset_tlvs_failed" + + +async def test_set_network_fails_3( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry, + websocket_client, +) -> None: + """Test set network.""" + await thread.async_add_dataset(hass, "test", DATASET_CH15.hex()) + dataset_store = await thread.dataset_store.async_get_store(hass) + dataset_id = list(dataset_store.datasets)[1] + + with patch( + "python_otbr_api.OTBR.set_enabled", + side_effect=[None, python_otbr_api.OTBRError], + ), patch( + "python_otbr_api.OTBR.set_active_dataset_tlvs", + ): + await websocket_client.send_json_auto_id( + { + "type": "otbr/set_network", + "dataset_id": dataset_id, + } + ) + msg = await websocket_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "set_enabled_failed" + + async def test_get_extended_address( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, diff --git a/tests/components/thread/test_dataset_store.py b/tests/components/thread/test_dataset_store.py index 553068ab8b..581329e860 100644 --- a/tests/components/thread/test_dataset_store.py +++ b/tests/components/thread/test_dataset_store.py @@ -83,6 +83,17 @@ async def test_delete_preferred_dataset(hass: HomeAssistant) -> None: assert len(store.datasets) == 1 +async def test_get_dataset(hass: HomeAssistant) -> None: + """Test get the preferred dataset.""" + assert await dataset_store.async_get_dataset(hass, "blah") is None + + await dataset_store.async_add_dataset(hass, "source", DATASET_1) + store = await dataset_store.async_get_store(hass) + dataset_id = list(store.datasets.values())[0].id + + assert (await dataset_store.async_get_dataset(hass, dataset_id)) == DATASET_1 + + async def test_get_preferred_dataset(hass: HomeAssistant) -> None: """Test get the preferred dataset.""" assert await dataset_store.async_get_preferred_dataset(hass) is None From 1bc4802c04860d68637c622b2843f582653655bf Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 14 Mar 2023 08:12:44 -0700 Subject: [PATCH 0932/2810] Move local calendar text fixtures to conftest.py (#89674) * Move local calendar text fixtures to conftest.py * Apply suggestions from code review Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Add imports for suggested typing fixes * Apply suggestions from code review Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- tests/components/local_calendar/conftest.py | 161 ++++++++++++++++++ .../local_calendar/test_calendar.py | 161 +----------------- 2 files changed, 169 insertions(+), 153 deletions(-) create mode 100644 tests/components/local_calendar/conftest.py diff --git a/tests/components/local_calendar/conftest.py b/tests/components/local_calendar/conftest.py new file mode 100644 index 0000000000..02c984c284 --- /dev/null +++ b/tests/components/local_calendar/conftest.py @@ -0,0 +1,161 @@ +"""Fixtures for local calendar.""" + +from collections.abc import Awaitable, Callable, Generator +from http import HTTPStatus +from pathlib import Path +from typing import Any +from unittest.mock import patch +import urllib + +from aiohttp import ClientWebSocketResponse +import pytest + +from homeassistant.components.local_calendar import LocalCalendarStore +from homeassistant.components.local_calendar.const import CONF_CALENDAR_NAME, DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator, WebSocketGenerator + +CALENDAR_NAME = "Light Schedule" +FRIENDLY_NAME = "Light schedule" +TEST_ENTITY = "calendar.light_schedule" + + +class FakeStore(LocalCalendarStore): + """Mock storage implementation.""" + + def __init__(self, hass: HomeAssistant, path: Path) -> None: + """Initialize FakeStore.""" + super().__init__(hass, path) + self._content = "" + + def _load(self) -> str: + """Read from calendar storage.""" + return self._content + + def _store(self, ics_content: str) -> None: + """Persist the calendar storage.""" + self._content = ics_content + + +@pytest.fixture(name="store", autouse=True) +def mock_store() -> Generator[None, None, None]: + """Test cleanup, remove any media storage persisted during the test.""" + + stores: dict[Path, FakeStore] = {} + + def new_store(hass: HomeAssistant, path: Path) -> FakeStore: + if path not in stores: + stores[path] = FakeStore(hass, path) + return stores[path] + + with patch( + "homeassistant.components.local_calendar.LocalCalendarStore", new=new_store + ): + yield + + +@pytest.fixture(name="time_zone") +def mock_time_zone() -> str: + """Fixture for time zone to use in tests.""" + # Set our timezone to CST/Regina so we can check calculations + # This keeps UTC-6 all year round + return "America/Regina" + + +@pytest.fixture(autouse=True) +def set_time_zone(hass: HomeAssistant, time_zone: str): + """Set the time zone for the tests.""" + # Set our timezone to CST/Regina so we can check calculations + # This keeps UTC-6 all year round + hass.config.set_time_zone(time_zone) + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Fixture for mock configuration entry.""" + return MockConfigEntry(domain=DOMAIN, data={CONF_CALENDAR_NAME: CALENDAR_NAME}) + + +@pytest.fixture(name="setup_integration") +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the integration.""" + config_entry.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + +GetEventsFn = Callable[[str, str], Awaitable[dict[str, Any]]] + + +@pytest.fixture(name="get_events") +def get_events_fixture(hass_client: ClientSessionGenerator) -> GetEventsFn: + """Fetch calendar events from the HTTP API.""" + + async def _fetch(start: str, end: str) -> None: + client = await hass_client() + response = await client.get( + f"/api/calendars/{TEST_ENTITY}?start={urllib.parse.quote(start)}&end={urllib.parse.quote(end)}" + ) + assert response.status == HTTPStatus.OK + return await response.json() + + return _fetch + + +def event_fields(data: dict[str, str]) -> dict[str, str]: + """Filter event API response to minimum fields.""" + return { + k: data.get(k) + for k in ["summary", "start", "end", "recurrence_id"] + if data.get(k) + } + + +class Client: + """Test client with helper methods for calendar websocket.""" + + def __init__(self, client: ClientWebSocketResponse) -> None: + """Initialize Client.""" + self.client = client + self.id = 0 + + async def cmd(self, cmd: str, payload: dict[str, Any] = None) -> dict[str, Any]: + """Send a command and receive the json result.""" + self.id += 1 + await self.client.send_json( + { + "id": self.id, + "type": f"calendar/event/{cmd}", + **(payload if payload is not None else {}), + } + ) + resp = await self.client.receive_json() + assert resp.get("id") == self.id + return resp + + async def cmd_result(self, cmd: str, payload: dict[str, Any] = None) -> Any: + """Send a command and parse the result.""" + resp = await self.cmd(cmd, payload) + assert resp.get("success") + assert resp.get("type") == "result" + return resp.get("result") + + +ClientFixture = Callable[[], Awaitable[Client]] + + +@pytest.fixture +async def ws_client( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> ClientFixture: + """Fixture for creating the test websocket client.""" + + async def create_client() -> Client: + ws_client = await hass_ws_client(hass) + return Client(ws_client) + + return create_client diff --git a/tests/components/local_calendar/test_calendar.py b/tests/components/local_calendar/test_calendar.py index f432fe3f97..8364f6df62 100644 --- a/tests/components/local_calendar/test_calendar.py +++ b/tests/components/local_calendar/test_calendar.py @@ -1,168 +1,23 @@ """Tests for calendar platform of local calendar.""" -from collections.abc import Awaitable, Callable import datetime -from http import HTTPStatus -from pathlib import Path -from typing import Any -from unittest.mock import patch -import urllib -from aiohttp import ClientWebSocketResponse import pytest -from homeassistant.components.local_calendar import LocalCalendarStore -from homeassistant.components.local_calendar.const import CONF_CALENDAR_NAME, DOMAIN from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.template import DATE_STR_FORMAT -from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util +from .conftest import ( + FRIENDLY_NAME, + TEST_ENTITY, + ClientFixture, + GetEventsFn, + event_fields, +) + from tests.common import MockConfigEntry -from tests.typing import ClientSessionGenerator - -CALENDAR_NAME = "Light Schedule" -FRIENDLY_NAME = "Light schedule" -TEST_ENTITY = "calendar.light_schedule" - - -class FakeStore(LocalCalendarStore): - """Mock storage implementation.""" - - def __init__(self, hass: HomeAssistant, path: Path) -> None: - """Initialize FakeStore.""" - super().__init__(hass, path) - self._content = "" - - def _load(self) -> str: - """Read from calendar storage.""" - return self._content - - def _store(self, ics_content: str) -> None: - """Persist the calendar storage.""" - self._content = ics_content - - -@pytest.fixture(name="store", autouse=True) -def mock_store() -> None: - """Test cleanup, remove any media storage persisted during the test.""" - - stores: dict[Path, FakeStore] = {} - - def new_store(hass: HomeAssistant, path: Path) -> FakeStore: - if path not in stores: - stores[path] = FakeStore(hass, path) - return stores[path] - - with patch( - "homeassistant.components.local_calendar.LocalCalendarStore", new=new_store - ): - yield - - -@pytest.fixture(name="time_zone") -def mock_time_zone() -> str: - """Fixture for time zone to use in tests.""" - # Set our timezone to CST/Regina so we can check calculations - # This keeps UTC-6 all year round - return "America/Regina" - - -@pytest.fixture(autouse=True) -def set_time_zone(hass: HomeAssistant, time_zone: str): - """Set the time zone for the tests.""" - # Set our timezone to CST/Regina so we can check calculations - # This keeps UTC-6 all year round - hass.config.set_time_zone(time_zone) - - -@pytest.fixture(name="config_entry") -def mock_config_entry() -> MockConfigEntry: - """Fixture for mock configuration entry.""" - return MockConfigEntry(domain=DOMAIN, data={CONF_CALENDAR_NAME: CALENDAR_NAME}) - - -@pytest.fixture(name="setup_integration") -async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: - """Set up the integration.""" - config_entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - -GetEventsFn = Callable[[str, str], Awaitable[dict[str, Any]]] - - -@pytest.fixture(name="get_events") -def get_events_fixture(hass_client: ClientSessionGenerator) -> GetEventsFn: - """Fetch calendar events from the HTTP API.""" - - async def _fetch(start: str, end: str) -> None: - client = await hass_client() - response = await client.get( - f"/api/calendars/{TEST_ENTITY}?start={urllib.parse.quote(start)}&end={urllib.parse.quote(end)}" - ) - assert response.status == HTTPStatus.OK - return await response.json() - - return _fetch - - -def event_fields(data: dict[str, str]) -> dict[str, str]: - """Filter event API response to minimum fields.""" - return { - k: data.get(k) - for k in ["summary", "start", "end", "recurrence_id"] - if data.get(k) - } - - -class Client: - """Test client with helper methods for calendar websocket.""" - - def __init__(self, client): - """Initialize Client.""" - self.client = client - self.id = 0 - - async def cmd(self, cmd: str, payload: dict[str, Any] = None) -> dict[str, Any]: - """Send a command and receive the json result.""" - self.id += 1 - await self.client.send_json( - { - "id": self.id, - "type": f"calendar/event/{cmd}", - **(payload if payload is not None else {}), - } - ) - resp = await self.client.receive_json() - assert resp.get("id") == self.id - return resp - - async def cmd_result(self, cmd: str, payload: dict[str, Any] = None) -> Any: - """Send a command and parse the result.""" - resp = await self.cmd(cmd, payload) - assert resp.get("success") - assert resp.get("type") == "result" - return resp.get("result") - - -ClientFixture = Callable[[], Awaitable[Client]] - - -@pytest.fixture -async def ws_client( - hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], -) -> ClientFixture: - """Fixture for creating the test websocket client.""" - - async def create_client() -> Client: - ws_client = await hass_ws_client(hass) - return Client(ws_client) - - return create_client async def test_empty_calendar( From 71dc98a39c68dbd30b0cff6f6a26ffeef7858fc2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 14 Mar 2023 16:31:40 +0100 Subject: [PATCH 0933/2810] Improve hass_ws_client type hint in tests (#89703) --- tests/components/automation/test_init.py | 4 +--- tests/components/backup/test_websocket.py | 10 +++++----- tests/components/devolo_home_control/test_init.py | 6 +++--- tests/components/dlink/test_switch.py | 4 ++-- tests/components/google/test_calendar.py | 5 ++--- tests/components/matter/test_api.py | 11 +++++------ tests/components/matter/test_init.py | 8 ++++---- tests/components/mysensors/test_init.py | 6 ++---- tests/components/onewire/test_init.py | 7 +++---- tests/components/pushover/test_init.py | 7 ++----- tests/components/repairs/__init__.py | 6 +++--- tests/components/repairs/test_init.py | 4 +--- tests/components/repairs/test_websocket_api.py | 4 +--- tests/components/rtsp_to_webrtc/test_init.py | 10 +++++----- tests/components/schedule/test_init.py | 12 ++++++------ tests/components/unifiprotect/test_init.py | 10 +++------- tests/components/update/test_init.py | 9 ++++----- 17 files changed, 52 insertions(+), 71 deletions(-) diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index b47e663765..ff4ede357a 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -1,11 +1,9 @@ """The tests for the automation component.""" import asyncio -from collections.abc import Awaitable, Callable from datetime import timedelta import logging from unittest.mock import Mock, patch -from aiohttp import ClientWebSocketResponse import pytest import homeassistant.components.automation as automation @@ -1437,7 +1435,7 @@ async def test_automation_bad_config_validation( async def test_automation_with_error_in_script( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, ) -> None: """Test automation with an error in script.""" assert await async_setup_component( diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 2e8ac8f2f5..5a50f1afa8 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -1,18 +1,18 @@ """Tests for the Backup integration.""" -from collections.abc import Awaitable, Callable from unittest.mock import patch -from aiohttp import ClientWebSocketResponse import pytest from homeassistant.core import HomeAssistant from .common import TEST_BACKUP, setup_backup_integration +from tests.typing import WebSocketGenerator + async def test_info( hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, ) -> None: """Test getting backup info.""" await setup_backup_integration(hass) @@ -34,7 +34,7 @@ async def test_info( async def test_remove( hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test removing a backup file.""" @@ -55,7 +55,7 @@ async def test_remove( async def test_generate( hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, ) -> None: """Test removing a backup file.""" await setup_backup_integration(hass) diff --git a/tests/components/devolo_home_control/test_init.py b/tests/components/devolo_home_control/test_init.py index 9ded392a48..0eb011d5ef 100644 --- a/tests/components/devolo_home_control/test_init.py +++ b/tests/components/devolo_home_control/test_init.py @@ -1,8 +1,6 @@ """Tests for the devolo Home Control integration.""" -from collections.abc import Awaitable, Callable from unittest.mock import patch -from aiohttp import ClientWebSocketResponse from devolo_home_control_api.exceptions.gateway import GatewayOfflineError import pytest @@ -16,6 +14,8 @@ from homeassistant.setup import async_setup_component from . import configure_integration from .mocks import HomeControlMock, HomeControlMockBinarySensor +from tests.typing import WebSocketGenerator + async def test_setup_entry(hass: HomeAssistant, mock_zeroconf: None) -> None: """Test setup entry.""" @@ -64,7 +64,7 @@ async def test_unload_entry(hass: HomeAssistant) -> None: async def test_remove_device( hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, ): """Test removing a device.""" assert await async_setup_component(hass, "config", {}) diff --git a/tests/components/dlink/test_switch.py b/tests/components/dlink/test_switch.py index 66c40892a6..683d30be7f 100644 --- a/tests/components/dlink/test_switch.py +++ b/tests/components/dlink/test_switch.py @@ -1,5 +1,4 @@ """Switch tests for the D-Link Smart Plug integration.""" -from collections.abc import Awaitable, Callable from homeassistant.components.dlink import DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -16,11 +15,12 @@ from homeassistant.setup import async_setup_component from .conftest import ComponentSetup from tests.components.repairs import get_repairs +from tests.typing import WebSocketGenerator async def test_switch_state( hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[None]], + hass_ws_client: WebSocketGenerator, setup_integration: ComponentSetup, ) -> None: """Test we get the switch status.""" diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 3997795c39..8b544a828e 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -8,7 +8,6 @@ from typing import Any from unittest.mock import patch import urllib -from aiohttp import ClientWebSocketResponse from aiohttp.client_exceptions import ClientError from gcal_sync.auth import API_BASE_URL import pytest @@ -32,7 +31,7 @@ from .conftest import ( from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker -from tests.typing import ClientSessionGenerator +from tests.typing import ClientSessionGenerator, WebSocketGenerator TEST_ENTITY = TEST_API_ENTITY TEST_ENTITY_NAME = TEST_API_ENTITY_NAME @@ -134,7 +133,7 @@ ClientFixture = Callable[[], Awaitable[Client]] @pytest.fixture async def ws_client( hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, ) -> ClientFixture: """Fixture for creating the test websocket client.""" diff --git a/tests/components/matter/test_api.py b/tests/components/matter/test_api.py index 6575cb8fdb..041920f653 100644 --- a/tests/components/matter/test_api.py +++ b/tests/components/matter/test_api.py @@ -1,8 +1,6 @@ """Test the api module.""" -from collections.abc import Awaitable, Callable from unittest.mock import MagicMock, call -from aiohttp import ClientWebSocketResponse from matter_server.common.errors import InvalidCommand, NodeCommissionFailed import pytest @@ -10,13 +8,14 @@ from homeassistant.components.matter.api import ID, TYPE from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_commission( hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, matter_client: MagicMock, integration: MockConfigEntry, ) -> None: @@ -58,7 +57,7 @@ async def test_commission( @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_commission_on_network( hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, matter_client: MagicMock, integration: MockConfigEntry, ) -> None: @@ -100,7 +99,7 @@ async def test_commission_on_network( @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_set_thread_dataset( hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, matter_client: MagicMock, integration: MockConfigEntry, ) -> None: @@ -142,7 +141,7 @@ async def test_set_thread_dataset( @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_set_wifi_credentials( hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, matter_client: MagicMock, integration: MockConfigEntry, ) -> None: diff --git a/tests/components/matter/test_init.py b/tests/components/matter/test_init.py index aea52cc307..58d0d0d445 100644 --- a/tests/components/matter/test_init.py +++ b/tests/components/matter/test_init.py @@ -2,10 +2,9 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, call, patch -from aiohttp import ClientWebSocketResponse from matter_server.client.exceptions import CannotConnect, InvalidServerVersion from matter_server.client.models.node import MatterNode from matter_server.common.errors import MatterError @@ -28,6 +27,7 @@ from homeassistant.setup import async_setup_component from .common import load_and_parse_node_fixture, setup_integration_with_node_fixture from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator @pytest.fixture(name="connect_timeout") @@ -613,7 +613,7 @@ async def test_remove_entry( async def test_remove_config_entry_device( hass: HomeAssistant, matter_client: MagicMock, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, ) -> None: """Test that a device can be removed ok.""" assert await async_setup_component(hass, "config", {}) @@ -656,7 +656,7 @@ async def test_remove_config_entry_device_no_node( hass: HomeAssistant, matter_client: MagicMock, integration: MockConfigEntry, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, ) -> None: """Test that a device can be removed ok without an existing node.""" assert await async_setup_component(hass, "config", {}) diff --git a/tests/components/mysensors/test_init.py b/tests/components/mysensors/test_init.py index 5d44cdbdb3..9d1867b315 100644 --- a/tests/components/mysensors/test_init.py +++ b/tests/components/mysensors/test_init.py @@ -1,9 +1,6 @@ """Test function in __init__.py.""" from __future__ import annotations -from collections.abc import Awaitable, Callable - -from aiohttp import ClientWebSocketResponse from mysensors import BaseSyncGateway from mysensors.sensor import Sensor @@ -13,6 +10,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator async def test_remove_config_entry_device( @@ -20,7 +18,7 @@ async def test_remove_config_entry_device( gps_sensor: Sensor, integration: MockConfigEntry, gateway: BaseSyncGateway, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, ) -> None: """Test that a device can be removed ok.""" entity_id = "sensor.gps_sensor_1_1" diff --git a/tests/components/onewire/test_init.py b/tests/components/onewire/test_init.py index 874f9fbf49..9382b95521 100644 --- a/tests/components/onewire/test_init.py +++ b/tests/components/onewire/test_init.py @@ -1,5 +1,4 @@ """Tests for 1-Wire config flow.""" -from collections.abc import Awaitable, Callable from unittest.mock import MagicMock, patch import aiohttp @@ -15,6 +14,8 @@ from homeassistant.setup import async_setup_component from . import setup_owproxy_mock_devices +from tests.typing import WebSocketGenerator + async def remove_device( ws_client: aiohttp.ClientWebSocketResponse, device_id: str, config_entry_id: str @@ -78,9 +79,7 @@ async def test_registry_cleanup( hass: HomeAssistant, config_entry: ConfigEntry, owproxy: MagicMock, - hass_ws_client: Callable[ - [HomeAssistant], Awaitable[aiohttp.ClientWebSocketResponse] - ], + hass_ws_client: WebSocketGenerator, ): """Test being able to remove a disconnected device.""" assert await async_setup_component(hass, "config", {}) diff --git a/tests/components/pushover/test_init.py b/tests/components/pushover/test_init.py index 3d8837923e..ef1413e408 100644 --- a/tests/components/pushover/test_init.py +++ b/tests/components/pushover/test_init.py @@ -1,8 +1,6 @@ """Test pushbullet integration.""" -from collections.abc import Awaitable, Callable from unittest.mock import MagicMock, patch -import aiohttp from pushover_complete import BadAPIRequestError import pytest import requests_mock @@ -17,6 +15,7 @@ from . import MOCK_CONFIG from tests.common import MockConfigEntry from tests.components.repairs import get_repairs +from tests.typing import WebSocketGenerator @pytest.fixture(autouse=False) @@ -30,9 +29,7 @@ def mock_pushover(): async def test_setup( hass: HomeAssistant, - hass_ws_client: Callable[ - [HomeAssistant], Awaitable[aiohttp.ClientWebSocketResponse] - ], + hass_ws_client: WebSocketGenerator, mock_pushover: MagicMock, ) -> None: """Test integration failed due to an error.""" diff --git a/tests/components/repairs/__init__.py b/tests/components/repairs/__init__.py index 77971d0284..4d584da170 100644 --- a/tests/components/repairs/__init__.py +++ b/tests/components/repairs/__init__.py @@ -1,15 +1,15 @@ """Tests for the repairs integration.""" -from collections.abc import Awaitable, Callable -from aiohttp import ClientWebSocketResponse from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.typing import WebSocketGenerator + async def get_repairs( hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, ): """Return the repairs list of issues.""" assert await async_setup_component(hass, "repairs", {}) diff --git a/tests/components/repairs/test_init.py b/tests/components/repairs/test_init.py index 0a085e3820..87acc96db2 100644 --- a/tests/components/repairs/test_init.py +++ b/tests/components/repairs/test_init.py @@ -1,8 +1,6 @@ """Test the repairs websocket API.""" -from collections.abc import Awaitable, Callable from unittest.mock import AsyncMock, Mock -from aiohttp import ClientWebSocketResponse from freezegun import freeze_time import pytest @@ -489,7 +487,7 @@ async def test_non_compliant_platform( @freeze_time("2022-07-21 08:22:00") async def test_sync_methods( hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, ) -> None: """Test sync method for creating and deleting an issue.""" diff --git a/tests/components/repairs/test_websocket_api.py b/tests/components/repairs/test_websocket_api.py index be50dba14b..4db5b6a9d1 100644 --- a/tests/components/repairs/test_websocket_api.py +++ b/tests/components/repairs/test_websocket_api.py @@ -1,12 +1,10 @@ """Test the repairs websocket API.""" from __future__ import annotations -from collections.abc import Awaitable, Callable from http import HTTPStatus from typing import Any from unittest.mock import ANY, AsyncMock, Mock -from aiohttp import ClientWebSocketResponse from freezegun import freeze_time import pytest import voluptuous as vol @@ -524,7 +522,7 @@ async def test_list_issues( async def test_fix_issue_aborted( hass: HomeAssistant, hass_client: ClientSessionGenerator, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, ) -> None: """Test we can fix an issue.""" assert await async_setup_component(hass, "http", {}) diff --git a/tests/components/rtsp_to_webrtc/test_init.py b/tests/components/rtsp_to_webrtc/test_init.py index abbe3728a1..a6d2d34b17 100644 --- a/tests/components/rtsp_to_webrtc/test_init.py +++ b/tests/components/rtsp_to_webrtc/test_init.py @@ -3,7 +3,6 @@ from __future__ import annotations import base64 -from collections.abc import Awaitable, Callable from typing import Any from unittest.mock import patch @@ -20,6 +19,7 @@ from .conftest import SERVER_URL, STREAM_SOURCE, ComponentSetup from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import WebSocketGenerator # The webrtc component does not inspect the details of the offer and answer, # and is only a pass through. @@ -83,7 +83,7 @@ async def test_setup_communication_failure( async def test_offer_for_stream_source( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - hass_ws_client: Callable[[...], Awaitable[aiohttp.ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, mock_camera: Any, rtsp_to_webrtc_client: Any, setup_integration: ComponentSetup, @@ -124,7 +124,7 @@ async def test_offer_for_stream_source( async def test_offer_failure( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - hass_ws_client: Callable[[...], Awaitable[aiohttp.ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, mock_camera: Any, rtsp_to_webrtc_client: Any, setup_integration: ComponentSetup, @@ -161,7 +161,7 @@ async def test_no_stun_server( hass: HomeAssistant, rtsp_to_webrtc_client: Any, setup_integration: ComponentSetup, - hass_ws_client: Callable[[...], Awaitable[aiohttp.ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, ) -> None: """Test successful setup and unload.""" await setup_integration() @@ -188,7 +188,7 @@ async def test_stun_server( rtsp_to_webrtc_client: Any, setup_integration: ComponentSetup, config_entry: MockConfigEntry, - hass_ws_client: Callable[[...], Awaitable[aiohttp.ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, ) -> None: """Test successful setup and unload.""" await setup_integration() diff --git a/tests/components/schedule/test_init.py b/tests/components/schedule/test_init.py index a2089240bf..70ba6dfde3 100644 --- a/tests/components/schedule/test_init.py +++ b/tests/components/schedule/test_init.py @@ -1,11 +1,10 @@ """Test for the Schedule integration.""" from __future__ import annotations -from collections.abc import Awaitable, Callable, Coroutine +from collections.abc import Callable, Coroutine from typing import Any from unittest.mock import patch -from aiohttp import ClientWebSocketResponse import pytest from homeassistant.components.schedule import STORAGE_VERSION, STORAGE_VERSION_MINOR @@ -39,6 +38,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from tests.common import MockUser, async_capture_events, async_fire_time_changed +from tests.typing import WebSocketGenerator @pytest.fixture @@ -537,7 +537,7 @@ async def test_schedule_updates( async def test_ws_list( hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, schedule_setup: Callable[..., Coroutine[Any, Any, bool]], ) -> None: """Test listing via WS.""" @@ -567,7 +567,7 @@ async def test_ws_list( async def test_ws_delete( hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, schedule_setup: Callable[..., Coroutine[Any, Any, bool]], ) -> None: """Test WS delete cleans up entity registry.""" @@ -602,7 +602,7 @@ async def test_ws_delete( ) async def test_update( hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, schedule_setup: Callable[..., Coroutine[Any, Any, bool]], to: str, next_event: str, @@ -672,7 +672,7 @@ async def test_update( ) async def test_ws_create( hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, schedule_setup: Callable[..., Coroutine[Any, Any, bool]], freezer, to: str, diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index 2ff0ebe850..caa77e8408 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable from unittest.mock import AsyncMock, patch import aiohttp @@ -23,6 +22,7 @@ from . import _patch_discovery from .utils import MockUFPFixture, init_entry, time_changed from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator async def remove_device( @@ -217,9 +217,7 @@ async def test_device_remove_devices( hass: HomeAssistant, ufp: MockUFPFixture, light: Light, - hass_ws_client: Callable[ - [HomeAssistant], Awaitable[aiohttp.ClientWebSocketResponse] - ], + hass_ws_client: WebSocketGenerator, ) -> None: """Test we can only remove a device that no longer exists.""" @@ -252,9 +250,7 @@ async def test_device_remove_devices( async def test_device_remove_devices_nvr( hass: HomeAssistant, ufp: MockUFPFixture, - hass_ws_client: Callable[ - [HomeAssistant], Awaitable[aiohttp.ClientWebSocketResponse] - ], + hass_ws_client: WebSocketGenerator, ) -> None: """Test we can only remove a NVR device that no longer exists.""" assert await async_setup_component(hass, "config", {}) diff --git a/tests/components/update/test_init.py b/tests/components/update/test_init.py index 29838145db..d0546b6a2e 100644 --- a/tests/components/update/test_init.py +++ b/tests/components/update/test_init.py @@ -1,8 +1,6 @@ """The tests for the Update component.""" -from collections.abc import Awaitable, Callable from unittest.mock import MagicMock, patch -from aiohttp import ClientWebSocketResponse import pytest from homeassistant.components.update import ( @@ -40,6 +38,7 @@ from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component from tests.common import MockEntityPlatform, mock_restore_cache +from tests.typing import WebSocketGenerator class MockUpdateEntity(UpdateEntity): @@ -681,7 +680,7 @@ async def test_restore_state( async def test_release_notes( hass: HomeAssistant, enable_custom_integrations: None, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, ) -> None: """Test getting the release notes over the websocket connection.""" platform = getattr(hass.components, f"test.{DOMAIN}") @@ -707,7 +706,7 @@ async def test_release_notes( async def test_release_notes_entity_not_found( hass: HomeAssistant, enable_custom_integrations: None, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, ) -> None: """Test getting the release notes for not found entity.""" platform = getattr(hass.components, f"test.{DOMAIN}") @@ -734,7 +733,7 @@ async def test_release_notes_entity_not_found( async def test_release_notes_entity_does_not_support_release_notes( hass: HomeAssistant, enable_custom_integrations: None, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, ) -> None: """Test getting the release notes for entity that does not support release notes.""" platform = getattr(hass.components, f"test.{DOMAIN}") From d1969fd0c222550537ec8def29c68d1e010ca87f Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Tue, 14 Mar 2023 19:26:05 +0100 Subject: [PATCH 0934/2810] Add water sensor to bthome (#89595) * Add water sensor to bthome * Use TOTAL state class for gas water and energy --- homeassistant/components/bthome/manifest.json | 2 +- homeassistant/components/bthome/sensor.py | 14 +++++++++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bthome/test_sensor.py | 23 ++++++++++++++++--- 5 files changed, 35 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index da8f719bf7..87a84e5fab 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", "iot_class": "local_push", - "requirements": ["bthome-ble==2.8.0"] + "requirements": ["bthome-ble==2.9.0"] } diff --git a/homeassistant/components/bthome/sensor.py b/homeassistant/components/bthome/sensor.py index 9816395730..9b5def3005 100644 --- a/homeassistant/components/bthome/sensor.py +++ b/homeassistant/components/bthome/sensor.py @@ -117,7 +117,7 @@ SENSOR_DESCRIPTIONS = { key=f"{BTHomeSensorDeviceClass.ENERGY}_{Units.ENERGY_KILO_WATT_HOUR}", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, ), # Gas (m3) ( @@ -127,7 +127,7 @@ SENSOR_DESCRIPTIONS = { key=f"{BTHomeSensorDeviceClass.GAS}_{Units.VOLUME_CUBIC_METERS}", device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, ), # Humidity in (percent) (BTHomeSensorDeviceClass.HUMIDITY, Units.PERCENTAGE): SensorEntityDescription( @@ -297,6 +297,16 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, ), + # Water (L) + ( + BTHomeSensorDeviceClass.WATER, + Units.VOLUME_LITERS, + ): SensorEntityDescription( + key=f"{BTHomeSensorDeviceClass.WATER}_{Units.VOLUME_LITERS}", + device_class=SensorDeviceClass.WATER, + native_unit_of_measurement=UnitOfVolume.LITERS, + state_class=SensorStateClass.TOTAL, + ), } diff --git a/requirements_all.txt b/requirements_all.txt index 9aa47dde7f..fb6a0c4e60 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -492,7 +492,7 @@ brunt==1.2.0 bt_proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==2.8.0 +bthome-ble==2.9.0 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2844a2abe7..df875eccea 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -402,7 +402,7 @@ brother==2.3.0 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==2.8.0 +bthome-ble==2.9.0 # homeassistant.components.buienradar buienradar==1.0.5 diff --git a/tests/components/bthome/test_sensor.py b/tests/components/bthome/test_sensor.py index af01db0a6e..7893ad3cb4 100644 --- a/tests/components/bthome/test_sensor.py +++ b/tests/components/bthome/test_sensor.py @@ -188,7 +188,7 @@ _LOGGER = logging.getLogger(__name__) "sensor_entity": "sensor.test_device_18b2_energy", "friendly_name": "Test Device 18B2 Energy", "unit_of_measurement": "kWh", - "state_class": "total_increasing", + "state_class": "total", "expected_state": "1346.067", }, ], @@ -542,7 +542,7 @@ async def test_v1_sensors( "sensor_entity": "sensor.test_device_18b2_energy", "friendly_name": "Test Device 18B2 Energy", "unit_of_measurement": "kWh", - "state_class": "total_increasing", + "state_class": "total", "expected_state": "1346.067", }, ], @@ -856,11 +856,28 @@ async def test_v1_sensors( "sensor_entity": "sensor.test_device_18b2_gas", "friendly_name": "Test Device 18B2 Gas", "unit_of_measurement": "m³", - "state_class": "total_increasing", + "state_class": "total", "expected_state": "1346.067", }, ], ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x4f\x87\x56\x2a\x01", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_water", + "friendly_name": "Test Device 18B2 Water", + "unit_of_measurement": "L", + "state_class": "total", + "expected_state": "19551.879", + }, + ], + ), ( "A4:C1:38:8D:18:B2", make_bthome_v2_adv( From 9d2c62095f89ff93c4f62cab204e65b09dbcd1e7 Mon Sep 17 00:00:00 2001 From: Marcio Granzotto Rodrigues Date: Tue, 14 Mar 2023 15:44:55 -0300 Subject: [PATCH 0935/2810] Bump bond-async to 0.1.23 (#89697) --- homeassistant/components/bond/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json index bf343673fd..fc91f8eb72 100644 --- a/homeassistant/components/bond/manifest.json +++ b/homeassistant/components/bond/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_push", "loggers": ["bond_async"], "quality_scale": "platinum", - "requirements": ["bond-async==0.1.22"], + "requirements": ["bond-async==0.1.23"], "zeroconf": ["_bond._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index fb6a0c4e60..0831d51553 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -467,7 +467,7 @@ bluetooth-auto-recovery==1.0.3 bluetooth-data-tools==0.3.1 # homeassistant.components.bond -bond-async==0.1.22 +bond-async==0.1.23 # homeassistant.components.bosch_shc boschshcpy==0.2.35 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index df875eccea..a192592ca2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -387,7 +387,7 @@ bluetooth-auto-recovery==1.0.3 bluetooth-data-tools==0.3.1 # homeassistant.components.bond -bond-async==0.1.22 +bond-async==0.1.23 # homeassistant.components.bosch_shc boschshcpy==0.2.35 From a6d6807dd0db5631e711cb24de1e52cb7a95d1cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Mar 2023 09:06:56 -1000 Subject: [PATCH 0936/2810] Add typing to statistics results (#89118) --- .../components/energy/websocket_api.py | 19 +++--- .../components/recorder/statistics.py | 65 ++++++++++++------- homeassistant/components/sensor/recorder.py | 12 ++-- homeassistant/components/tibber/sensor.py | 9 +-- 4 files changed, 63 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/energy/websocket_api.py b/homeassistant/components/energy/websocket_api.py index 2075d00003..15ffc6a280 100644 --- a/homeassistant/components/energy/websocket_api.py +++ b/homeassistant/components/energy/websocket_api.py @@ -13,6 +13,7 @@ from typing import Any, cast import voluptuous as vol from homeassistant.components import recorder, websocket_api +from homeassistant.components.recorder.statistics import StatisticsRow from homeassistant.const import UnitOfEnergy from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.integration_platform import ( @@ -277,7 +278,7 @@ async def ws_get_fossil_energy_consumption( ) def _combine_sum_statistics( - stats: dict[str, list[dict[str, Any]]], statistic_ids: list[str] + stats: dict[str, list[StatisticsRow]], statistic_ids: list[str] ) -> dict[float, float]: """Combine multiple statistics, returns a dict indexed by start time.""" result: defaultdict[float, float] = defaultdict(float) @@ -313,11 +314,10 @@ async def ws_get_fossil_energy_consumption( if not stat_list: return result prev_stat: dict[str, Any] = stat_list[0] + fake_stat = {"start": stat_list[-1]["start"] + period.total_seconds()} # Loop over the hourly deltas + a fake entry to end the period - for statistic in chain( - stat_list, ({"start": stat_list[-1]["start"] + period.total_seconds()},) - ): + for statistic in chain(stat_list, (fake_stat,)): if not same_period(prev_stat["start"], statistic["start"]): start, _ = period_start_end(prev_stat["start"]) # The previous statistic was the last entry of the period @@ -338,10 +338,13 @@ async def ws_get_fossil_energy_consumption( statistics, msg["energy_statistic_ids"] ) energy_deltas = _calculate_deltas(merged_energy_statistics) - indexed_co2_statistics = { - period["start"]: period["mean"] - for period in statistics.get(msg["co2_statistic_id"], {}) - } + indexed_co2_statistics = cast( + dict[float, float], + { + period["start"]: period["mean"] + for period in statistics.get(msg["co2_statistic_id"], {}) + }, + ) # Calculate amount of fossil based energy, assume 100% fossil if missing fossil_energy = [ diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 473d416d75..645b7d4f04 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -14,7 +14,7 @@ from operator import itemgetter import os import re from statistics import mean -from typing import TYPE_CHECKING, Any, Literal, cast +from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast from sqlalchemy import Select, and_, bindparam, func, lambda_stmt, select, text from sqlalchemy.engine import Engine @@ -166,6 +166,24 @@ STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { _LOGGER = logging.getLogger(__name__) +class BaseStatisticsRow(TypedDict, total=False): + """A processed row of statistic data.""" + + start: float + + +class StatisticsRow(BaseStatisticsRow, total=False): + """A processed row of statistic data.""" + + end: float + last_reset: float | None + state: float | None + sum: float | None + min: float | None + max: float | None + mean: float | None + + def _get_unit_class(unit: str | None) -> str | None: """Get corresponding unit class from from the statistics unit.""" if converter := STATISTIC_UNIT_TO_UNIT_CONVERTER.get(unit): @@ -1048,14 +1066,14 @@ def list_statistic_ids( def _reduce_statistics( - stats: dict[str, list[dict[str, Any]]], + stats: dict[str, list[StatisticsRow]], same_period: Callable[[float, float], bool], period_start_end: Callable[[float], tuple[float, float]], period: timedelta, types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], -) -> dict[str, list[dict[str, Any]]]: +) -> dict[str, list[StatisticsRow]]: """Reduce hourly statistics to daily or monthly statistics.""" - result: dict[str, list[dict[str, Any]]] = defaultdict(list) + result: dict[str, list[StatisticsRow]] = defaultdict(list) period_seconds = period.total_seconds() _want_mean = "mean" in types _want_min = "min" in types @@ -1067,16 +1085,15 @@ def _reduce_statistics( max_values: list[float] = [] mean_values: list[float] = [] min_values: list[float] = [] - prev_stat: dict[str, Any] = stat_list[0] + prev_stat: StatisticsRow = stat_list[0] + fake_entry: StatisticsRow = {"start": stat_list[-1]["start"] + period_seconds} # Loop over the hourly statistics + a fake entry to end the period - for statistic in chain( - stat_list, ({"start": stat_list[-1]["start"] + period_seconds},) - ): + for statistic in chain(stat_list, (fake_entry,)): if not same_period(prev_stat["start"], statistic["start"]): start, end = period_start_end(prev_stat["start"]) # The previous statistic was the last entry of the period - row: dict[str, Any] = { + row: StatisticsRow = { "start": start, "end": end, } @@ -1146,9 +1163,9 @@ def reduce_day_ts_factory() -> ( def _reduce_statistics_per_day( - stats: dict[str, list[dict[str, Any]]], + stats: dict[str, list[StatisticsRow]], types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], -) -> dict[str, list[dict[str, Any]]]: +) -> dict[str, list[StatisticsRow]]: """Reduce hourly statistics to daily statistics.""" _same_day_ts, _day_start_end_ts = reduce_day_ts_factory() return _reduce_statistics( @@ -1196,9 +1213,9 @@ def reduce_week_ts_factory() -> ( def _reduce_statistics_per_week( - stats: dict[str, list[dict[str, Any]]], + stats: dict[str, list[StatisticsRow]], types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], -) -> dict[str, list[dict[str, Any]]]: +) -> dict[str, list[StatisticsRow]]: """Reduce hourly statistics to weekly statistics.""" _same_week_ts, _week_start_end_ts = reduce_week_ts_factory() return _reduce_statistics( @@ -1248,9 +1265,9 @@ def reduce_month_ts_factory() -> ( def _reduce_statistics_per_month( - stats: dict[str, list[dict[str, Any]]], + stats: dict[str, list[StatisticsRow]], types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], -) -> dict[str, list[dict[str, Any]]]: +) -> dict[str, list[StatisticsRow]]: """Reduce hourly statistics to monthly statistics.""" _same_month_ts, _month_start_end_ts = reduce_month_ts_factory() return _reduce_statistics( @@ -1724,7 +1741,7 @@ def _statistics_during_period_with_session( period: Literal["5minute", "day", "hour", "week", "month"], units: dict[str, str] | None, types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], -) -> dict[str, list[dict[str, Any]]]: +) -> dict[str, list[StatisticsRow]]: """Return statistic data points during UTC period start_time - end_time. If end_time is omitted, returns statistics newer than or equal to start_time. @@ -1808,7 +1825,7 @@ def statistics_during_period( period: Literal["5minute", "day", "hour", "week", "month"], units: dict[str, str] | None, types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], -) -> dict[str, list[dict[str, Any]]]: +) -> dict[str, list[StatisticsRow]]: """Return statistic data points during UTC period start_time - end_time. If end_time is omitted, returns statistics newer than or equal to start_time. @@ -1863,7 +1880,7 @@ def _get_last_statistics( convert_units: bool, table: type[StatisticsBase], types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], -) -> dict[str, list[dict]]: +) -> dict[str, list[StatisticsRow]]: """Return the last number_of_stats statistics for a given statistic_id.""" statistic_ids = [statistic_id] with session_scope(hass=hass, read_only=True) as session: @@ -1902,7 +1919,7 @@ def get_last_statistics( statistic_id: str, convert_units: bool, types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], -) -> dict[str, list[dict]]: +) -> dict[str, list[StatisticsRow]]: """Return the last number_of_stats statistics for a statistic_id.""" return _get_last_statistics( hass, number_of_stats, statistic_id, convert_units, Statistics, types @@ -1915,7 +1932,7 @@ def get_last_short_term_statistics( statistic_id: str, convert_units: bool, types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], -) -> dict[str, list[dict]]: +) -> dict[str, list[StatisticsRow]]: """Return the last number_of_stats short term statistics for a statistic_id.""" return _get_last_statistics( hass, number_of_stats, statistic_id, convert_units, StatisticsShortTerm, types @@ -1951,7 +1968,7 @@ def get_latest_short_term_statistics( statistic_ids: list[str], types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], metadata: dict[str, tuple[int, StatisticMetaData]] | None = None, -) -> dict[str, list[dict]]: +) -> dict[str, list[StatisticsRow]]: """Return the latest short term statistics for a list of statistic_ids.""" with session_scope(hass=hass, read_only=True) as session: # Fetch metadata for the given statistic_ids @@ -2054,10 +2071,10 @@ def _sorted_statistics_to_dict( start_time: datetime | None, units: dict[str, str] | None, types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], -) -> dict[str, list[dict]]: +) -> dict[str, list[StatisticsRow]]: """Convert SQL results into JSON friendly data structure.""" assert stats, "stats must not be empty" # Guard against implementation error - result: dict = defaultdict(list) + result: dict[str, list[StatisticsRow]] = defaultdict(list) metadata = dict(_metadata.values()) need_stat_at_start_time: set[int] = set() start_time_ts = start_time.timestamp() if start_time else None @@ -2123,7 +2140,7 @@ def _sorted_statistics_to_dict( # attribute lookups, and dict lookups as much as possible. # for db_state in stats_list: - row: dict[str, Any] = { + row: StatisticsRow = { "start": (start_ts := db_state[start_ts_idx]), "end": start_ts + table_duration_seconds, } diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 0d2dc06b83..bd4facbea1 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -529,11 +529,11 @@ def _compile_statistics( # noqa: C901 if entity_id in last_stats: # We have compiled history for this sensor before, # use that as a starting point. - last_reset = old_last_reset = _timestamp_to_isoformat_or_none( - last_stats[entity_id][0]["last_reset"] - ) - new_state = old_state = last_stats[entity_id][0]["state"] - _sum = last_stats[entity_id][0]["sum"] or 0.0 + last_stat = last_stats[entity_id][0] + last_reset = _timestamp_to_isoformat_or_none(last_stat["last_reset"]) + old_last_reset = last_reset + new_state = old_state = last_stat["state"] + _sum = last_stat["sum"] or 0.0 for fstate, state in fstates: reset = False @@ -596,7 +596,7 @@ def _compile_statistics( # noqa: C901 if reset: # The sensor has been reset, update the sum - if old_state is not None: + if old_state is not None and new_state is not None: _sum += new_state - old_state # ..and update the starting point new_state = fstate diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 7c56320872..4d847c1920 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -6,7 +6,7 @@ import datetime from datetime import timedelta import logging from random import randrange -from typing import Any +from typing import Any, cast import aiohttp import tibber @@ -614,7 +614,7 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]): 5 * 365 * 24, production=is_production ) - _sum = 0 + _sum = 0.0 last_stats_time = None else: # hourly_consumption/production_data contains the last 30 days @@ -641,8 +641,9 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]): None, {"sum"}, ) - _sum = stat[statistic_id][0]["sum"] - last_stats_time = stat[statistic_id][0]["start"] + first_stat = stat[statistic_id][0] + _sum = cast(float, first_stat["sum"]) + last_stats_time = first_stat["start"] statistics = [] From c2c809682ad01f5859aac8a8d655fe1f3efe0efa Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 14 Mar 2023 21:26:16 +0100 Subject: [PATCH 0937/2810] Tweak OTBR tests (#89694) --- tests/components/otbr/test_websocket_api.py | 118 +++----------------- 1 file changed, 13 insertions(+), 105 deletions(-) diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index 04210a3433..8442162257 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -30,15 +30,9 @@ async def test_get_info( aioclient_mock.get(f"{BASE_URL}/node/dataset/active", text=DATASET_CH16.hex()) - await websocket_client.send_json( - { - "id": 5, - "type": "otbr/info", - } - ) + await websocket_client.send_json_auto_id({"type": "otbr/info"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["success"] assert msg["result"] == { "url": BASE_URL, @@ -54,15 +48,9 @@ async def test_get_info_no_entry( """Test async_get_info.""" await async_setup_component(hass, "otbr", {}) websocket_client = await hass_ws_client(hass) - await websocket_client.send_json( - { - "id": 5, - "type": "otbr/info", - } - ) + await websocket_client.send_json_auto_id({"type": "otbr/info"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert not msg["success"] assert msg["error"]["code"] == "not_loaded" @@ -74,21 +62,13 @@ async def test_get_info_fetch_fails( websocket_client, ) -> None: """Test async_get_info.""" - await async_setup_component(hass, "otbr", {}) - with patch( "python_otbr_api.OTBR.get_active_dataset_tlvs", side_effect=python_otbr_api.OTBRError, ): - await websocket_client.send_json( - { - "id": 5, - "type": "otbr/info", - } - ) + await websocket_client.send_json_auto_id({"type": "otbr/info"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert not msg["success"] assert msg["error"]["code"] == "get_dataset_failed" @@ -110,15 +90,9 @@ async def test_create_network( ) as get_active_dataset_tlvs_mock, patch( "homeassistant.components.thread.dataset_store.DatasetStore.async_add" ) as mock_add: - await websocket_client.send_json( - { - "id": 5, - "type": "otbr/create_network", - } - ) + await websocket_client.send_json_auto_id({"type": "otbr/create_network"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["success"] assert msg["result"] is None @@ -142,15 +116,9 @@ async def test_create_network_no_entry( """Test create network.""" await async_setup_component(hass, "otbr", {}) websocket_client = await hass_ws_client(hass) - await websocket_client.send_json( - { - "id": 5, - "type": "otbr/create_network", - } - ) + await websocket_client.send_json_auto_id({"type": "otbr/create_network"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert not msg["success"] assert msg["error"]["code"] == "not_loaded" @@ -162,21 +130,13 @@ async def test_create_network_fails_1( websocket_client, ) -> None: """Test create network.""" - await async_setup_component(hass, "otbr", {}) - with patch( "python_otbr_api.OTBR.set_enabled", side_effect=python_otbr_api.OTBRError, ): - await websocket_client.send_json( - { - "id": 5, - "type": "otbr/create_network", - } - ) + await websocket_client.send_json_auto_id({"type": "otbr/create_network"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert not msg["success"] assert msg["error"]["code"] == "set_enabled_failed" @@ -188,23 +148,15 @@ async def test_create_network_fails_2( websocket_client, ) -> None: """Test create network.""" - await async_setup_component(hass, "otbr", {}) - with patch( "python_otbr_api.OTBR.set_enabled", ), patch( "python_otbr_api.OTBR.create_active_dataset", side_effect=python_otbr_api.OTBRError, ): - await websocket_client.send_json( - { - "id": 5, - "type": "otbr/create_network", - } - ) + await websocket_client.send_json_auto_id({"type": "otbr/create_network"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert not msg["success"] assert msg["error"]["code"] == "create_active_dataset_failed" @@ -216,23 +168,15 @@ async def test_create_network_fails_3( websocket_client, ) -> None: """Test create network.""" - await async_setup_component(hass, "otbr", {}) - with patch( "python_otbr_api.OTBR.set_enabled", side_effect=[None, python_otbr_api.OTBRError], ), patch( "python_otbr_api.OTBR.create_active_dataset", ): - await websocket_client.send_json( - { - "id": 5, - "type": "otbr/create_network", - } - ) + await websocket_client.send_json_auto_id({"type": "otbr/create_network"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert not msg["success"] assert msg["error"]["code"] == "set_enabled_failed" @@ -244,23 +188,15 @@ async def test_create_network_fails_4( websocket_client, ) -> None: """Test create network.""" - await async_setup_component(hass, "otbr", {}) - with patch("python_otbr_api.OTBR.set_enabled"), patch( "python_otbr_api.OTBR.create_active_dataset" ), patch( "python_otbr_api.OTBR.get_active_dataset_tlvs", side_effect=python_otbr_api.OTBRError, ): - await websocket_client.send_json( - { - "id": 5, - "type": "otbr/create_network", - } - ) + await websocket_client.send_json_auto_id({"type": "otbr/create_network"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert not msg["success"] assert msg["error"]["code"] == "get_active_dataset_tlvs_failed" @@ -272,20 +208,12 @@ async def test_create_network_fails_5( websocket_client, ) -> None: """Test create network.""" - await async_setup_component(hass, "otbr", {}) - with patch("python_otbr_api.OTBR.set_enabled"), patch( "python_otbr_api.OTBR.create_active_dataset" ), patch("python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=None): - await websocket_client.send_json( - { - "id": 5, - "type": "otbr/create_network", - } - ) + await websocket_client.send_json_auto_id({"type": "otbr/create_network"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert not msg["success"] assert msg["error"]["code"] == "get_active_dataset_tlvs_empty" @@ -486,15 +414,9 @@ async def test_get_extended_address( "python_otbr_api.OTBR.get_extended_address", return_value=bytes.fromhex("4EF6C4F3FF750626"), ): - await websocket_client.send_json( - { - "id": 5, - "type": "otbr/get_extended_address", - } - ) + await websocket_client.send_json_auto_id({"type": "otbr/get_extended_address"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["success"] assert msg["result"] == {"extended_address": "4EF6C4F3FF750626".lower()} @@ -507,15 +429,9 @@ async def test_get_extended_address_no_entry( """Test get extended address.""" await async_setup_component(hass, "otbr", {}) websocket_client = await hass_ws_client(hass) - await websocket_client.send_json( - { - "id": 5, - "type": "otbr/get_extended_address", - } - ) + await websocket_client.send_json_auto_id({"type": "otbr/get_extended_address"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert not msg["success"] assert msg["error"]["code"] == "not_loaded" @@ -527,20 +443,12 @@ async def test_get_extended_address_fetch_fails( websocket_client, ) -> None: """Test get extended address.""" - await async_setup_component(hass, "otbr", {}) - with patch( "python_otbr_api.OTBR.get_extended_address", side_effect=python_otbr_api.OTBRError, ): - await websocket_client.send_json( - { - "id": 5, - "type": "otbr/get_extended_address", - } - ) + await websocket_client.send_json_auto_id({"type": "otbr/get_extended_address"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert not msg["success"] assert msg["error"]["code"] == "get_extended_address_failed" From 0630b7b9623e4ee543e5e0e61853384536458a22 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Mar 2023 10:31:31 -1000 Subject: [PATCH 0938/2810] Reduce size of load query to prime event_types and states_meta at startup (#89677) --- homeassistant/components/recorder/core.py | 8 ++++---- .../recorder/table_managers/event_types.py | 17 ++++++++++------- .../recorder/table_managers/states_meta.py | 17 +++++++++++------ 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 4ebd4703b6..9eb1c6c166 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -793,13 +793,13 @@ class Recorder(threading.Thread): until its primed. """ assert self.event_session is not None - if hashes := [ + if hashes := { StateAttributes.hash_shared_attrs_bytes(shared_attrs_bytes) for event in events if ( shared_attrs_bytes := self._serialize_state_attributes_from_event(event) ) - ]: + }: with self.event_session.no_autoflush: for hash_chunk in chunked(hashes, SQLITE_MAX_BIND_VARS): for id_, shared_attrs in self.event_session.execute( @@ -815,11 +815,11 @@ class Recorder(threading.Thread): the data in the database for every event until its primed. """ assert self.event_session is not None - if hashes := [ + if hashes := { EventData.hash_shared_data_bytes(shared_event_bytes) for event in events if (shared_event_bytes := self._serialize_event_data_from_event(event)) - ]: + }: with self.event_session.no_autoflush: for hash_chunk in chunked(hashes, SQLITE_MAX_BIND_VARS): for id_, shared_data in self.event_session.execute( diff --git a/homeassistant/components/recorder/table_managers/event_types.py b/homeassistant/components/recorder/table_managers/event_types.py index 15dfff28b8..21bcf78bf1 100644 --- a/homeassistant/components/recorder/table_managers/event_types.py +++ b/homeassistant/components/recorder/table_managers/event_types.py @@ -9,8 +9,10 @@ from sqlalchemy.orm.session import Session from homeassistant.core import Event +from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import EventTypes from ..queries import find_event_type_ids +from ..util import chunked CACHE_SIZE = 2048 @@ -27,7 +29,7 @@ class EventTypeManager: def load(self, events: list[Event], session: Session) -> None: """Load the event_type to event_type_ids mapping into memory.""" self.get_many( - (event.event_type for event in events if event.event_type is not None), + {event.event_type for event in events if event.event_type is not None}, session, ) @@ -51,12 +53,13 @@ class EventTypeManager: return results with session.no_autoflush: - for event_type_id, event_type in session.execute( - find_event_type_ids(missing) - ): - results[event_type] = self._id_map[event_type] = cast( - int, event_type_id - ) + for missing_chunk in chunked(missing, SQLITE_MAX_BIND_VARS): + for event_type_id, event_type in session.execute( + find_event_type_ids(missing_chunk) + ): + results[event_type] = self._id_map[event_type] = cast( + int, event_type_id + ) return results diff --git a/homeassistant/components/recorder/table_managers/states_meta.py b/homeassistant/components/recorder/table_managers/states_meta.py index 8650df7c8b..8af872ff96 100644 --- a/homeassistant/components/recorder/table_managers/states_meta.py +++ b/homeassistant/components/recorder/table_managers/states_meta.py @@ -9,8 +9,10 @@ from sqlalchemy.orm.session import Session from homeassistant.core import Event +from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import StatesMeta from ..queries import find_all_states_metadata_ids, find_states_metadata_ids +from ..util import chunked CACHE_SIZE = 8192 @@ -27,11 +29,11 @@ class StatesMetaManager: def load(self, events: list[Event], session: Session) -> None: """Load the entity_id to metadata_id mapping into memory.""" self.get_many( - ( + { event.data["new_state"].entity_id for event in events if event.data.get("new_state") is not None - ), + }, session, ) @@ -60,10 +62,13 @@ class StatesMetaManager: return results with session.no_autoflush: - for metadata_id, entity_id in session.execute( - find_states_metadata_ids(missing) - ): - results[entity_id] = self._id_map[entity_id] = cast(int, metadata_id) + for missing_chunk in chunked(missing, SQLITE_MAX_BIND_VARS): + for metadata_id, entity_id in session.execute( + find_states_metadata_ids(missing_chunk) + ): + results[entity_id] = self._id_map[entity_id] = cast( + int, metadata_id + ) return results From c33ca4f664a4e3eea801170be07a35b134f45e1d Mon Sep 17 00:00:00 2001 From: Jack Boswell Date: Wed, 15 Mar 2023 10:24:47 +1300 Subject: [PATCH 0939/2810] Add diagnostics to Starlink (#86328) --- homeassistant/components/starlink/__init__.py | 2 +- .../components/starlink/diagnostics.py | 21 ++++++ .../fixtures/status_data_success.json | 70 ++++++++++++++++++ tests/components/starlink/patchers.py | 9 +-- .../starlink/snapshots/test_diagnostics.ambr | 73 +++++++++++++++++++ tests/components/starlink/test_diagnostics.py | 34 +++++++++ 6 files changed, 202 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/starlink/diagnostics.py create mode 100644 tests/components/starlink/fixtures/status_data_success.json create mode 100644 tests/components/starlink/snapshots/test_diagnostics.ambr create mode 100644 tests/components/starlink/test_diagnostics.py diff --git a/homeassistant/components/starlink/__init__.py b/homeassistant/components/starlink/__init__.py index ceb962c88c..c59269d2e0 100644 --- a/homeassistant/components/starlink/__init__.py +++ b/homeassistant/components/starlink/__init__.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN from .coordinator import StarlinkUpdateCoordinator -PLATFORMS: list[Platform] = [ +PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR, diff --git a/homeassistant/components/starlink/diagnostics.py b/homeassistant/components/starlink/diagnostics.py new file mode 100644 index 0000000000..10711e7155 --- /dev/null +++ b/homeassistant/components/starlink/diagnostics.py @@ -0,0 +1,21 @@ +"""Fetches diagnostic data for Starlink systems.""" + +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import StarlinkUpdateCoordinator + +TO_REDACT = {"id"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for Starlink config entries.""" + coordinator: StarlinkUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + return async_redact_data(asdict(coordinator.data), TO_REDACT) diff --git a/tests/components/starlink/fixtures/status_data_success.json b/tests/components/starlink/fixtures/status_data_success.json new file mode 100644 index 0000000000..e8cdc27e62 --- /dev/null +++ b/tests/components/starlink/fixtures/status_data_success.json @@ -0,0 +1,70 @@ +[ + { + "id": "ut00000000-00000000-000000aa", + "hardware_version": "rev3_proto2", + "software_version": "191e4dfa-d63a-46b1-a73b-9fa907733864.uterm.release", + "state": "CONNECTED", + "uptime": 804138, + "snr": null, + "seconds_to_first_nonempty_slot": 0.0, + "pop_ping_drop_rate": 0.0, + "downlink_throughput_bps": 10108.2724609375, + "uplink_throughput_bps": 11802.771484375, + "pop_ping_latency_ms": 30.285715103149414, + "alerts": 0, + "fraction_obstructed": 0.0, + "currently_obstructed": false, + "seconds_obstructed": null, + "obstruction_duration": null, + "obstruction_interval": null, + "direction_azimuth": -179.00344848632812, + "direction_elevation": 68.67173767089844, + "is_snr_above_noise_floor": true + }, + { + "wedges_fraction_obstructed[]": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "raw_wedges_fraction_obstructed[]": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "valid_s": 803872.0 + }, + { + "alert_motors_stuck": false, + "alert_thermal_throttle": false, + "alert_thermal_shutdown": false, + "alert_mast_not_near_vertical": false, + "alert_unexpected_location": false, + "alert_slow_ethernet_speeds": false, + "alert_roaming": false, + "alert_install_pending": false, + "alert_is_heating": false, + "alert_power_supply_thermal_throttle": false, + "alert_is_power_save_idle": false, + "alert_moving_while_not_mobile": false, + "alert_moving_fast_while_not_aviation": false + } +] diff --git a/tests/components/starlink/patchers.py b/tests/components/starlink/patchers.py index 0013b4e56b..dfc0d2415d 100644 --- a/tests/components/starlink/patchers.py +++ b/tests/components/starlink/patchers.py @@ -1,7 +1,8 @@ """General Starlink patchers.""" +import json from unittest.mock import patch -from starlink_grpc import StatusDict +from tests.common import load_fixture SETUP_ENTRY_PATCHER = patch( "homeassistant.components.starlink.async_setup_entry", return_value=True @@ -9,11 +10,7 @@ SETUP_ENTRY_PATCHER = patch( COORDINATOR_SUCCESS_PATCHER = patch( "homeassistant.components.starlink.coordinator.status_data", - return_value=[ - StatusDict(id="1", software_version="1", hardware_version="1"), - {}, - {}, - ], + return_value=json.loads(load_fixture("status_data_success.json", "starlink")), ) DEVICE_FOUND_PATCHER = patch( diff --git a/tests/components/starlink/snapshots/test_diagnostics.ambr b/tests/components/starlink/snapshots/test_diagnostics.ambr new file mode 100644 index 0000000000..6f859aaf50 --- /dev/null +++ b/tests/components/starlink/snapshots/test_diagnostics.ambr @@ -0,0 +1,73 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'alert': dict({ + 'alert_install_pending': False, + 'alert_is_heating': False, + 'alert_is_power_save_idle': False, + 'alert_mast_not_near_vertical': False, + 'alert_motors_stuck': False, + 'alert_moving_fast_while_not_aviation': False, + 'alert_moving_while_not_mobile': False, + 'alert_power_supply_thermal_throttle': False, + 'alert_roaming': False, + 'alert_slow_ethernet_speeds': False, + 'alert_thermal_shutdown': False, + 'alert_thermal_throttle': False, + 'alert_unexpected_location': False, + }), + 'obstruction': dict({ + 'raw_wedges_fraction_obstructed[]': list([ + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + ]), + 'valid_s': 803872.0, + 'wedges_fraction_obstructed[]': list([ + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + ]), + }), + 'status': dict({ + 'alerts': 0, + 'currently_obstructed': False, + 'direction_azimuth': -179.00344848632812, + 'direction_elevation': 68.67173767089844, + 'downlink_throughput_bps': 10108.2724609375, + 'fraction_obstructed': 0.0, + 'hardware_version': 'rev3_proto2', + 'id': '**REDACTED**', + 'is_snr_above_noise_floor': True, + 'obstruction_duration': None, + 'obstruction_interval': None, + 'pop_ping_drop_rate': 0.0, + 'pop_ping_latency_ms': 30.285715103149414, + 'seconds_obstructed': None, + 'seconds_to_first_nonempty_slot': 0.0, + 'snr': None, + 'software_version': '191e4dfa-d63a-46b1-a73b-9fa907733864.uterm.release', + 'state': 'CONNECTED', + 'uplink_throughput_bps': 11802.771484375, + 'uptime': 804138, + }), + }) +# --- diff --git a/tests/components/starlink/test_diagnostics.py b/tests/components/starlink/test_diagnostics.py new file mode 100644 index 0000000000..4bf8a619c8 --- /dev/null +++ b/tests/components/starlink/test_diagnostics.py @@ -0,0 +1,34 @@ +"""Tests for Starlink diagnostics.""" +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.starlink.const import DOMAIN +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.core import HomeAssistant + +from .patchers import COORDINATOR_SUCCESS_PATCHER + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics for a config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_IP_ADDRESS: "1.2.3.4:0000"}, + ) + + with COORDINATOR_SUCCESS_PATCHER: + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + assert diag == snapshot From 4ddcb140532f5771a2420a3ef52d066daede2fe5 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 14 Mar 2023 17:27:38 -0700 Subject: [PATCH 0940/2810] Add additional CalendarEvent validation (#89533) Add additional event validation --- homeassistant/components/caldav/calendar.py | 6 + homeassistant/components/calendar/__init__.py | 194 +++++++++++------- tests/components/calendar/test_init.py | 13 +- tests/components/google/test_init.py | 2 +- 4 files changed, 133 insertions(+), 82 deletions(-) diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index ab3c47b969..9a01cd2186 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -356,4 +356,10 @@ class WebDavCalendarData: else: enddate = obj.dtstart.value + timedelta(days=1) + # End date for an all day event is exclusive. This fixes the case where + # an all day event has a start and end values are the same, or the event + # has a zero duration. + if not isinstance(enddate, datetime) and obj.dtstart.value == enddate: + enddate += timedelta(days=1) + return enddate diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index c77d6c9c67..d09a389ce8 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -67,6 +67,23 @@ SCAN_INTERVAL = datetime.timedelta(seconds=60) VALID_FREQS = {"DAILY", "WEEKLY", "MONTHLY", "YEARLY"} +def _has_timezone(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]: + """Assert that all datetime values have a timezone.""" + + def validate(obj: dict[str, Any]) -> dict[str, Any]: + """Validate that all datetime values have a timezone.""" + for k in keys: + if ( + (value := obj.get(k)) + and isinstance(value, datetime.datetime) + and value.tzinfo is None + ): + raise vol.Invalid("Expected all values to have a timezone") + return obj + + return validate + + def _has_consistent_timezone(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]: """Verify that all datetime values have a consistent timezone.""" @@ -89,7 +106,7 @@ def _as_local_timezone(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]] """Convert all datetime values to the local timezone.""" def validate(obj: dict[str, Any]) -> dict[str, Any]: - """Test that all keys that are datetime values have the same timezone.""" + """Convert all keys that are datetime values to local timezone.""" for k in keys: if (value := obj.get(k)) and isinstance(value, datetime.datetime): obj[k] = dt.as_local(value) @@ -98,23 +115,59 @@ def _as_local_timezone(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]] return validate -def _is_sorted(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]: - """Verify that the specified values are sequential.""" +def _has_duration( + start_key: str, end_key: str +) -> Callable[[dict[str, Any]], dict[str, Any]]: + """Verify that the time span between start and end is positive.""" def validate(obj: dict[str, Any]) -> dict[str, Any]: """Test that all keys in the dict are in order.""" - values = [] - for k in keys: - if not (value := obj.get(k)): - return obj - values.append(value) - if all(values) and values != sorted(values): - raise vol.Invalid(f"Values were not in order: {values}") + if (start := obj.get(start_key)) and (end := obj.get(end_key)): + duration = end - start + if duration.total_seconds() <= 0: + raise vol.Invalid(f"Expected positive event duration ({start}, {end})") return obj return validate +def _has_same_type(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]: + """Verify that all values are of the same type.""" + + def validate(obj: dict[str, Any]) -> dict[str, Any]: + """Test that all keys in the dict have values of the same type.""" + uniq_values = groupby(type(obj[k]) for k in keys) + if len(list(uniq_values)) > 1: + raise vol.Invalid(f"Expected all values to be the same type: {keys}") + return obj + + return validate + + +def _validate_rrule(value: Any) -> str: + """Validate a recurrence rule string.""" + if value is None: + raise vol.Invalid("rrule value is None") + + if not isinstance(value, str): + raise vol.Invalid("rrule value expected a string") + + try: + rrulestr(value) + except ValueError as err: + raise vol.Invalid(f"Invalid rrule: {str(err)}") from err + + # Example format: FREQ=DAILY;UNTIL=... + rule_parts = dict(s.split("=", 1) for s in value.split(";")) + if not (freq := rule_parts.get("FREQ")): + raise vol.Invalid("rrule did not contain FREQ") + + if freq not in VALID_FREQS: + raise vol.Invalid(f"Invalid frequency for rule: {value}") + + return str(value) + + CREATE_EVENT_SERVICE = "create_event" CREATE_EVENT_SCHEMA = vol.All( cv.has_at_least_one_key(EVENT_START_DATE, EVENT_START_DATETIME, EVENT_IN), @@ -149,8 +202,42 @@ CREATE_EVENT_SCHEMA = vol.All( ), _has_consistent_timezone(EVENT_START_DATETIME, EVENT_END_DATETIME), _as_local_timezone(EVENT_START_DATETIME, EVENT_END_DATETIME), - _is_sorted(EVENT_START_DATE, EVENT_END_DATE), - _is_sorted(EVENT_START_DATETIME, EVENT_END_DATETIME), + _has_duration(EVENT_START_DATE, EVENT_END_DATE), + _has_duration(EVENT_START_DATETIME, EVENT_END_DATETIME), +) + +WEBSOCKET_EVENT_SCHEMA = vol.Schema( + vol.All( + { + vol.Required(EVENT_START): vol.Any(cv.date, cv.datetime), + vol.Required(EVENT_END): vol.Any(cv.date, cv.datetime), + vol.Required(EVENT_SUMMARY): cv.string, + vol.Optional(EVENT_DESCRIPTION): cv.string, + vol.Optional(EVENT_RRULE): _validate_rrule, + }, + _has_same_type(EVENT_START, EVENT_END), + _has_consistent_timezone(EVENT_START, EVENT_END), + _as_local_timezone(EVENT_START, EVENT_END), + _has_duration(EVENT_START, EVENT_END), + ) +) + +# Validation for the CalendarEvent dataclass +CALENDAR_EVENT_SCHEMA = vol.Schema( + vol.All( + { + vol.Required("start"): vol.Any(cv.date, cv.datetime), + vol.Required("end"): vol.Any(cv.date, cv.datetime), + vol.Required(EVENT_SUMMARY): cv.string, + vol.Optional(EVENT_RRULE): _validate_rrule, + }, + _has_same_type("start", "end"), + _has_timezone("start", "end"), + _has_consistent_timezone("start", "end"), + _as_local_timezone("start", "end"), + _has_duration("start", "end"), + ), + extra=vol.ALLOW_EXTRA, ) @@ -243,6 +330,19 @@ class CalendarEvent: "all_day": self.all_day, } + def __post_init__(self) -> None: + """Perform validation on the CalendarEvent.""" + + def skip_none(obj: Iterable[tuple[str, Any]]) -> dict[str, str]: + return {k: v for k, v in obj if v is not None} + + try: + CALENDAR_EVENT_SCHEMA(dataclasses.asdict(self, dict_factory=skip_none)) + except vol.Invalid as err: + raise HomeAssistantError( + f"Failed to validate CalendarEvent: {err}" + ) from err + def _event_dict_factory(obj: Iterable[tuple[str, Any]]) -> dict[str, str]: """Convert CalendarEvent dataclass items to dictionary of attributes.""" @@ -316,30 +416,6 @@ def is_offset_reached( return start + offset_time <= dt.now(start.tzinfo) -def _validate_rrule(value: Any) -> str: - """Validate a recurrence rule string.""" - if value is None: - raise vol.Invalid("rrule value is None") - - if not isinstance(value, str): - raise vol.Invalid("rrule value expected a string") - - try: - rrulestr(value) - except ValueError as err: - raise vol.Invalid(f"Invalid rrule: {str(err)}") from err - - # Example format: FREQ=DAILY;UNTIL=... - rule_parts = dict(s.split("=", 1) for s in value.split(";")) - if not (freq := rule_parts.get("FREQ")): - raise vol.Invalid("rrule did not contain FREQ") - - if freq not in VALID_FREQS: - raise vol.Invalid(f"Invalid frequency for rule: {value}") - - return str(value) - - class CalendarEntity(Entity): """Base class for calendar event entities.""" @@ -447,6 +523,7 @@ class CalendarEventView(http.HomeAssistantView): request.app["hass"], start_date, end_date ) except HomeAssistantError as err: + _LOGGER.debug("Error reading events: %s", err) return self.json_message( f"Error reading events: {err}", HTTPStatus.INTERNAL_SERVER_ERROR ) @@ -481,38 +558,11 @@ class CalendarListView(http.HomeAssistantView): return self.json(sorted(calendar_list, key=lambda x: cast(str, x["name"]))) -def _has_same_type(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]: - """Verify that all values are of the same type.""" - - def validate(obj: dict[str, Any]) -> dict[str, Any]: - """Test that all keys in the dict have values of the same type.""" - uniq_values = groupby(type(obj[k]) for k in keys) - if len(list(uniq_values)) > 1: - raise vol.Invalid(f"Expected all values to be the same type: {keys}") - return obj - - return validate - - @websocket_api.websocket_command( { vol.Required("type"): "calendar/event/create", vol.Required("entity_id"): cv.entity_id, - CONF_EVENT: vol.Schema( - vol.All( - { - vol.Required(EVENT_START): vol.Any(cv.date, cv.datetime), - vol.Required(EVENT_END): vol.Any(cv.date, cv.datetime), - vol.Required(EVENT_SUMMARY): cv.string, - vol.Optional(EVENT_DESCRIPTION): cv.string, - vol.Optional(EVENT_RRULE): _validate_rrule, - }, - _has_same_type(EVENT_START, EVENT_END), - _has_consistent_timezone(EVENT_START, EVENT_END), - _as_local_timezone(EVENT_START, EVENT_END), - _is_sorted(EVENT_START, EVENT_END), - ) - ), + CONF_EVENT: WEBSOCKET_EVENT_SCHEMA, } ) @websocket_api.async_response @@ -595,21 +645,7 @@ async def handle_calendar_event_delete( vol.Required(EVENT_UID): cv.string, vol.Optional(EVENT_RECURRENCE_ID): cv.string, vol.Optional(EVENT_RECURRENCE_RANGE): cv.string, - vol.Required(CONF_EVENT): vol.Schema( - vol.All( - { - vol.Required(EVENT_START): vol.Any(cv.date, cv.datetime), - vol.Required(EVENT_END): vol.Any(cv.date, cv.datetime), - vol.Required(EVENT_SUMMARY): cv.string, - vol.Optional(EVENT_DESCRIPTION): cv.string, - vol.Optional(EVENT_RRULE): _validate_rrule, - }, - _has_same_type(EVENT_START, EVENT_END), - _has_consistent_timezone(EVENT_START, EVENT_END), - _as_local_timezone(EVENT_START, EVENT_END), - _is_sorted(EVENT_START, EVENT_END), - ) - ), + vol.Required(CONF_EVENT): WEBSOCKET_EVENT_SCHEMA, } ) @websocket_api.async_response diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index 5c90a1cfc2..875d5bf8c1 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -324,7 +324,7 @@ async def test_unsupported_create_event_service(hass: HomeAssistant) -> None: "end_date_time": "2022-04-01T06:00:00", }, vol.error.MultipleInvalid, - "Values were not in order", + "Expected positive event duration", ), ( { @@ -332,7 +332,15 @@ async def test_unsupported_create_event_service(hass: HomeAssistant) -> None: "end_date": "2022-04-01", }, vol.error.MultipleInvalid, - "Values were not in order", + "Expected positive event duration", + ), + ( + { + "start_date": "2022-04-01", + "end_date": "2022-04-01", + }, + vol.error.MultipleInvalid, + "Expected positive event duration", ), ], ids=[ @@ -351,6 +359,7 @@ async def test_unsupported_create_event_service(hass: HomeAssistant) -> None: "inconsistent_timezone", "incorrect_date_order", "incorrect_datetime_order", + "dates_not_exclusive", ], ) async def test_create_event_service_invalid_params( diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index 28525acd46..eac3bff585 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -597,7 +597,7 @@ async def test_add_event_failure( with pytest.raises(HomeAssistantError): await add_event_call_service( - {"start_date": "2022-05-01", "end_date": "2022-05-01"} + {"start_date": "2022-05-01", "end_date": "2022-05-02"} ) From 858fc30fcd0db62ed40f609ec344acfd5eb482b3 Mon Sep 17 00:00:00 2001 From: Penny Wood Date: Wed, 15 Mar 2023 10:27:29 +0800 Subject: [PATCH 0941/2810] Fix infinite loop in sun.sun (#89723) --- homeassistant/helpers/sun.py | 11 ++++++++--- tests/helpers/test_sun.py | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/sun.py b/homeassistant/helpers/sun.py index 25bef38ed0..cf944dfc47 100644 --- a/homeassistant/helpers/sun.py +++ b/homeassistant/helpers/sun.py @@ -82,7 +82,8 @@ def get_location_astral_event_next( kwargs["observer_elevation"] = elevation mod = -1 - while True: + first_err = None + while mod < 367: try: next_dt = ( cast(_AstralSunEventCallable, getattr(location, event))( @@ -94,9 +95,13 @@ def get_location_astral_event_next( ) if next_dt > utc_point_in_time: return next_dt - except ValueError: - pass + except ValueError as err: + if not first_err: + first_err = err mod += 1 + raise ValueError( + f"Unable to find event after one year, initial ValueError: {first_err}" + ) from first_err @callback diff --git a/tests/helpers/test_sun.py b/tests/helpers/test_sun.py index 8607622148..e030958ab8 100644 --- a/tests/helpers/test_sun.py +++ b/tests/helpers/test_sun.py @@ -3,6 +3,8 @@ from datetime import datetime, timedelta from unittest.mock import patch +import pytest + from homeassistant.const import SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET from homeassistant.core import HomeAssistant import homeassistant.helpers.sun as sun @@ -192,3 +194,15 @@ def test_norway_in_june(hass: HomeAssistant) -> None: ) assert sun.get_astral_event_date(hass, SUN_EVENT_SUNRISE, june) is None assert sun.get_astral_event_date(hass, SUN_EVENT_SUNSET, june) is None + + +def test_impossible_elevation(hass: HomeAssistant) -> None: + """Test altitude where the sun can't set.""" + hass.config.latitude = 69.6 + hass.config.longitude = 18.8 + hass.config.elevation = 10000000 + + june = datetime(2016, 6, 1, tzinfo=dt_util.UTC) + + with pytest.raises(ValueError): + sun.get_astral_event_next(hass, SUN_EVENT_SUNRISE, june) From b906d67c1e88975e313ece4a0a81dfd0b007ac13 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Mar 2023 16:33:19 -1000 Subject: [PATCH 0942/2810] Fix filtered purge not removing newer events (#89721) --- homeassistant/components/recorder/__init__.py | 12 +- homeassistant/components/recorder/core.py | 6 +- .../components/recorder/db_schema.py | 3 +- homeassistant/components/recorder/purge.py | 158 +++++++++++------- homeassistant/components/recorder/services.py | 3 +- homeassistant/components/recorder/tasks.py | 5 +- tests/components/recorder/test_init.py | 2 +- tests/components/recorder/test_purge.py | 148 ++++++++++++---- 8 files changed, 232 insertions(+), 105 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 71795bfa66..385c12f37a 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -142,12 +142,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass_config_path=hass.config.path(DEFAULT_DB_FILE) ) exclude = conf[CONF_EXCLUDE] - exclude_t = exclude.get(CONF_EVENT_TYPES, []) - if EVENT_STATE_CHANGED in exclude_t: - _LOGGER.warning( - "State change events are excluded, recorder will not record state changes." - "This will become an error in Home Assistant Core 2022.2" - ) + exclude_event_types: set[str] = set(exclude.get(CONF_EVENT_TYPES, [])) + if EVENT_STATE_CHANGED in exclude_event_types: + _LOGGER.error("State change events cannot be excluded, use a filter instead") + exclude_event_types.remove(EVENT_STATE_CHANGED) instance = hass.data[DATA_INSTANCE] = Recorder( hass=hass, auto_purge=auto_purge, @@ -158,7 +156,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: db_max_retries=db_max_retries, db_retry_wait=db_retry_wait, entity_filter=entity_filter, - exclude_t=exclude_t, + exclude_event_types=exclude_event_types, exclude_attributes_by_domain=exclude_attributes_by_domain, ) instance.async_initialize() diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 9eb1c6c166..b57498e298 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -181,7 +181,7 @@ class Recorder(threading.Thread): db_max_retries: int, db_retry_wait: int, entity_filter: Callable[[str], bool], - exclude_t: list[str], + exclude_event_types: set[str], exclude_attributes_by_domain: dict[str, set[str]], ) -> None: """Initialize the recorder.""" @@ -214,7 +214,7 @@ class Recorder(threading.Thread): # it can be used to see if an entity is being recorded and is called # by is_entity_recorder and the sensor recorder. self.entity_filter = entity_filter - self.exclude_t = set(exclude_t) + self.exclude_event_types = exclude_event_types self.schema_version = 0 self._commits_without_expire = 0 @@ -388,7 +388,7 @@ class Recorder(threading.Thread): @callback def _async_event_filter(self, event: Event) -> bool: """Filter events.""" - if event.event_type in self.exclude_t: + if event.event_type in self.exclude_event_types: return False if (entity_id := event.data.get(ATTR_ENTITY_ID)) is None: diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index a310161457..2fa3746a2c 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -435,7 +435,8 @@ class States(Base): def __repr__(self) -> str: """Return string representation of instance for debugging.""" return ( - f"" diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index bb97448f14..c644a17be0 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -1,21 +1,20 @@ """Purge old data helper.""" from __future__ import annotations -from collections.abc import Callable, Iterable +from collections.abc import Callable from datetime import datetime from itertools import zip_longest import logging +import time from typing import TYPE_CHECKING from sqlalchemy.engine.row import Row from sqlalchemy.orm.session import Session -from sqlalchemy.sql.expression import distinct -from homeassistant.const import EVENT_STATE_CHANGED import homeassistant.util.dt as dt_util from .const import SQLITE_MAX_BIND_VARS -from .db_schema import Events, StateAttributes, States, StatesMeta +from .db_schema import Events, States, StatesMeta from .models import DatabaseEngine from .queries import ( attributes_ids_exist_in_states, @@ -144,11 +143,9 @@ def _purge_legacy_format( ) = _select_legacy_event_state_and_attributes_and_data_ids_to_purge( session, purge_before ) - if state_ids: - _purge_state_ids(instance, session, state_ids) + _purge_state_ids(instance, session, state_ids) _purge_unused_attributes_ids(instance, session, attributes_ids) - if event_ids: - _purge_event_ids(session, event_ids) + _purge_event_ids(session, event_ids) _purge_unused_data_ids(instance, session, data_ids) return bool(event_ids or state_ids or attributes_ids or data_ids) @@ -448,6 +445,8 @@ def _select_legacy_event_state_and_attributes_and_data_ids_to_purge( def _purge_state_ids(instance: Recorder, session: Session, state_ids: set[int]) -> None: """Disconnect states and delete by state id.""" + if not state_ids: + return # Update old_state_id to NULL before deleting to ensure # the delete does not fail due to a foreign key constraint @@ -559,8 +558,10 @@ def _purge_short_term_statistics( _LOGGER.debug("Deleted %s short term statistics", deleted_rows) -def _purge_event_ids(session: Session, event_ids: Iterable[int]) -> None: +def _purge_event_ids(session: Session, event_ids: set[int]) -> None: """Delete by event id.""" + if not event_ids: + return deleted_rows = session.execute(delete_event_rows(event_ids)) _LOGGER.debug("Deleted %s events", deleted_rows) @@ -619,9 +620,11 @@ def _purge_filtered_data(instance: Recorder, session: Session) -> bool: _LOGGER.debug("Cleanup filtered data") database_engine = instance.database_engine assert database_engine is not None + now_timestamp = time.time() # Check if excluded entity_ids are in database entity_filter = instance.entity_filter + has_more_states_to_purge = False excluded_metadata_ids: list[str] = [ metadata_id for (metadata_id, entity_id) in session.query( @@ -629,92 +632,123 @@ def _purge_filtered_data(instance: Recorder, session: Session) -> bool: ).all() if not entity_filter(entity_id) ] - if len(excluded_metadata_ids) > 0: - _purge_filtered_states( - instance, session, excluded_metadata_ids, database_engine + if excluded_metadata_ids: + has_more_states_to_purge = _purge_filtered_states( + instance, session, excluded_metadata_ids, database_engine, now_timestamp ) - return False # Check if excluded event_types are in database - excluded_event_types: list[str] = [ - event_type - for (event_type,) in session.query(distinct(Events.event_type)).all() - if event_type in instance.exclude_t - ] - if len(excluded_event_types) > 0: - _purge_filtered_events(instance, session, excluded_event_types) - return False + has_more_events_to_purge = False + if ( + event_type_to_event_type_ids := instance.event_type_manager.get_many( + instance.exclude_event_types, session + ) + ) and ( + excluded_event_type_ids := [ + event_type_id + for event_type_id in event_type_to_event_type_ids.values() + if event_type_id is not None + ] + ): + has_more_events_to_purge = _purge_filtered_events( + instance, session, excluded_event_type_ids, now_timestamp + ) - return True + # Purge has completed if there are not more state or events to purge + return not (has_more_states_to_purge or has_more_events_to_purge) def _purge_filtered_states( instance: Recorder, session: Session, - excluded_metadata_ids: list[str], + metadata_ids_to_purge: list[str], database_engine: DatabaseEngine, -) -> None: - """Remove filtered states and linked events.""" + purge_before_timestamp: float, +) -> bool: + """Remove filtered states and linked events. + + Return true if all states are purged + """ state_ids: tuple[int, ...] attributes_ids: tuple[int, ...] event_ids: tuple[int, ...] - state_ids, attributes_ids, event_ids = zip( - *( - session.query(States.state_id, States.attributes_id, States.event_id) - .filter(States.metadata_id.in_(excluded_metadata_ids)) - .limit(SQLITE_MAX_BIND_VARS) - .all() - ) + to_purge = list( + session.query(States.state_id, States.attributes_id, States.event_id) + .filter(States.metadata_id.in_(metadata_ids_to_purge)) + .filter(States.last_updated_ts < purge_before_timestamp) + .limit(SQLITE_MAX_BIND_VARS) + .all() ) - filtered_event_ids = [id_ for id_ in event_ids if id_ is not None] + if not to_purge: + return True + state_ids, attributes_ids, event_ids = zip(*to_purge) + filtered_event_ids = {id_ for id_ in event_ids if id_ is not None} _LOGGER.debug( "Selected %s state_ids to remove that should be filtered", len(state_ids) ) _purge_state_ids(instance, session, set(state_ids)) + # These are legacy events that are linked to a state that are no longer + # created but since we did not remove them when we stopped adding new ones + # we will need to purge them here. _purge_event_ids(session, filtered_event_ids) unused_attribute_ids_set = _select_unused_attributes_ids( session, {id_ for id_ in attributes_ids if id_ is not None}, database_engine ) _purge_batch_attributes_ids(instance, session, unused_attribute_ids_set) + return False def _purge_filtered_events( - instance: Recorder, session: Session, excluded_event_types: list[str] -) -> None: - """Remove filtered events and linked states.""" + instance: Recorder, + session: Session, + excluded_event_type_ids: list[int], + purge_before_timestamp: float, +) -> bool: + """Remove filtered events and linked states. + + Return true if all events are purged. + """ database_engine = instance.database_engine assert database_engine is not None - event_ids, data_ids = zip( - *( - session.query(Events.event_id, Events.data_id) - .filter(Events.event_type.in_(excluded_event_types)) - .limit(SQLITE_MAX_BIND_VARS) - .all() - ) + to_purge = list( + session.query(Events.event_id, Events.data_id) + .filter(Events.event_type_id.in_(excluded_event_type_ids)) + .filter(Events.time_fired_ts < purge_before_timestamp) + .limit(SQLITE_MAX_BIND_VARS) + .all() ) + if not to_purge: + return True + event_ids, data_ids = zip(*to_purge) + event_ids_set = set(event_ids) _LOGGER.debug( - "Selected %s event_ids to remove that should be filtered", len(event_ids) + "Selected %s event_ids to remove that should be filtered", len(event_ids_set) ) states: list[Row[tuple[int]]] = ( - session.query(States.state_id).filter(States.event_id.in_(event_ids)).all() + session.query(States.state_id).filter(States.event_id.in_(event_ids_set)).all() ) - state_ids: set[int] = {state.state_id for state in states} - _purge_state_ids(instance, session, state_ids) - _purge_event_ids(session, event_ids) + if states: + # These are legacy states that are linked to an event that are no longer + # created but since we did not remove them when we stopped adding new ones + # we will need to purge them here. + state_ids: set[int] = {state.state_id for state in states} + _purge_state_ids(instance, session, state_ids) + _purge_event_ids(session, event_ids_set) if unused_data_ids_set := _select_unused_event_data_ids( session, set(data_ids), database_engine ): _purge_batch_data_ids(instance, session, unused_data_ids_set) - if EVENT_STATE_CHANGED in excluded_event_types: - session.query(StateAttributes).delete(synchronize_session=False) - instance._state_attributes_ids = {} # pylint: disable=protected-access + return False -@retryable_database_job("purge") -def purge_entity_data(instance: Recorder, entity_filter: Callable[[str], bool]) -> bool: +@retryable_database_job("purge_entity_data") +def purge_entity_data( + instance: Recorder, entity_filter: Callable[[str], bool], purge_before: datetime +) -> bool: """Purge states and events of specified entities.""" database_engine = instance.database_engine assert database_engine is not None + purge_before_timestamp = purge_before.timestamp() with session_scope(session=instance.get_session()) as session: selected_metadata_ids: list[str] = [ metadata_id @@ -724,12 +758,18 @@ def purge_entity_data(instance: Recorder, entity_filter: Callable[[str], bool]) if entity_filter(entity_id) ] _LOGGER.debug("Purging entity data for %s", selected_metadata_ids) - if len(selected_metadata_ids) > 0: - # Purge a max of SQLITE_MAX_BIND_VARS, based on the oldest states - # or events record. - _purge_filtered_states( - instance, session, selected_metadata_ids, database_engine - ) + if not selected_metadata_ids: + return True + + # Purge a max of SQLITE_MAX_BIND_VARS, based on the oldest states + # or events record. + if not _purge_filtered_states( + instance, + session, + selected_metadata_ids, + database_engine, + purge_before_timestamp, + ): _LOGGER.debug("Purging entity data hasn't fully completed yet") return False diff --git a/homeassistant/components/recorder/services.py b/homeassistant/components/recorder/services.py index 14337290c9..e1b2e388d6 100644 --- a/homeassistant/components/recorder/services.py +++ b/homeassistant/components/recorder/services.py @@ -71,7 +71,8 @@ def _async_register_purge_entities_service( domains = service.data.get(ATTR_DOMAINS, []) entity_globs = service.data.get(ATTR_ENTITY_GLOBS, []) entity_filter = generate_filter(domains, list(entity_ids), [], [], entity_globs) - instance.queue_task(PurgeEntitiesTask(entity_filter)) + purge_before = dt_util.utcnow() + instance.queue_task(PurgeEntitiesTask(entity_filter, purge_before)) hass.services.async_register( DOMAIN, diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index 17b63aad22..f2ba42bdea 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -114,13 +114,14 @@ class PurgeEntitiesTask(RecorderTask): """Object to store entity information about purge task.""" entity_filter: Callable[[str], bool] + purge_before: datetime def run(self, instance: Recorder) -> None: """Purge entities from the database.""" - if purge.purge_entity_data(instance, self.entity_filter): + if purge.purge_entity_data(instance, self.entity_filter, self.purge_before): return # Schedule a new purge task if this one didn't finish - instance.queue_task(PurgeEntitiesTask(self.entity_filter)) + instance.queue_task(PurgeEntitiesTask(self.entity_filter, self.purge_before)) @dataclass diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index d6162dd20e..5355931a76 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -100,7 +100,7 @@ def _default_recorder(hass): db_max_retries=10, db_retry_wait=3, entity_filter=CONFIG_SCHEMA({DOMAIN: {}}), - exclude_t=[], + exclude_event_types=set(), exclude_attributes_by_domain={}, ) diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index b865af68df..6594f0352a 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -26,6 +26,7 @@ from homeassistant.components.recorder.db_schema import ( StatisticsShortTerm, ) from homeassistant.components.recorder.purge import purge_old_data +from homeassistant.components.recorder.queries import select_event_type_ids from homeassistant.components.recorder.services import ( SERVICE_PURGE, SERVICE_PURGE_ENTITIES, @@ -676,8 +677,8 @@ def _convert_pending_states_to_meta(instance: Recorder, session: Session) -> Non """Convert pending states to use states_metadata.""" entity_ids: set[str] = set() states: set[States] = set() + states_meta_objects: dict[str, StatesMeta] = {} for object in session: - states_meta_objects: dict[str, StatesMeta] = {} if isinstance(object, States): entity_ids.add(object.entity_id) states.add(object) @@ -697,6 +698,33 @@ def _convert_pending_states_to_meta(instance: Recorder, session: Session) -> Non state.states_meta_rel = states_meta_objects[entity_id] +def _convert_pending_events_to_event_types( + instance: Recorder, session: Session +) -> None: + """Convert pending events to use event_type_ids.""" + event_types: set[str] = set() + events: set[Events] = set() + event_types_objects: dict[str, EventTypes] = {} + for object in session: + if isinstance(object, Events): + event_types.add(object.event_type) + events.add(object) + + event_type_to_event_type_ids = instance.event_type_manager.get_many( + event_types, session + ) + + for event in events: + event_type = event.event_type + event.event_type = None + if event_type_id := event_type_to_event_type_ids.get(event_type): + event.event_type_id = event_type_id + continue + if event_type not in event_types_objects: + event_types_objects[event_type] = EventTypes(event_type=event_type) + event.event_type_rel = event_types_objects[event_type] + + @pytest.mark.parametrize("use_sqlite", (True, False), indirect=True) async def test_purge_filtered_states( async_setup_recorder_instance: RecorderInstanceGenerator, @@ -850,12 +878,24 @@ async def test_purge_filtered_states( ) assert states_sensor_excluded.count() == 0 - assert session.query(States).get(72).old_state_id is None - assert session.query(States).get(72).attributes_id == 71 - assert session.query(States).get(73).old_state_id is None - assert session.query(States).get(73).attributes_id == 71 + assert ( + session.query(States).filter(States.state_id == 72).first().old_state_id + is None + ) + assert ( + session.query(States).filter(States.state_id == 72).first().attributes_id + == 71 + ) + assert ( + session.query(States).filter(States.state_id == 73).first().old_state_id + is None + ) + assert ( + session.query(States).filter(States.state_id == 73).first().attributes_id + == 71 + ) - final_keep_state = session.query(States).get(74) + final_keep_state = session.query(States).filter(States.state_id == 74).first() assert final_keep_state.old_state_id == 62 # should have been kept assert final_keep_state.attributes_id == 71 @@ -867,7 +907,7 @@ async def test_purge_filtered_states( await async_wait_purge_done(hass) with session_scope(hass=hass) as session: - final_keep_state = session.query(States).get(74) + final_keep_state = session.query(States).filter(States.state_id == 74).first() assert final_keep_state.old_state_id == 62 # should have been kept assert final_keep_state.attributes_id == 71 @@ -1022,7 +1062,7 @@ async def test_purge_filtered_events( ) -> None: """Test filtered events are purged.""" config: ConfigType = {"exclude": {"event_types": ["EVENT_PURGE"]}} - await async_setup_recorder_instance(hass, config) + instance = await async_setup_recorder_instance(hass, config) def _add_db_entries(hass: HomeAssistant) -> None: with session_scope(hass=hass) as session: @@ -1050,14 +1090,17 @@ async def test_purge_filtered_events( timestamp, event_id, ) + _convert_pending_events_to_event_types(instance, session) service_data = {"keep_days": 10} _add_db_entries(hass) with session_scope(hass=hass) as session: - events_purge = session.query(Events).filter(Events.event_type == "EVENT_PURGE") + events_purge = session.query(Events).filter( + Events.event_type_id.in_(select_event_type_ids(("EVENT_PURGE",))) + ) events_keep = session.query(Events).filter( - Events.event_type == EVENT_STATE_CHANGED + Events.event_type_id.in_(select_event_type_ids((EVENT_STATE_CHANGED,))) ) states = session.query(States) @@ -1073,9 +1116,11 @@ async def test_purge_filtered_events( await async_wait_purge_done(hass) with session_scope(hass=hass) as session: - events_purge = session.query(Events).filter(Events.event_type == "EVENT_PURGE") + events_purge = session.query(Events).filter( + Events.event_type_id.in_(select_event_type_ids(("EVENT_PURGE",))) + ) events_keep = session.query(Events).filter( - Events.event_type == EVENT_STATE_CHANGED + Events.event_type_id.in_(select_event_type_ids((EVENT_STATE_CHANGED,))) ) states = session.query(States) assert events_purge.count() == 60 @@ -1094,9 +1139,11 @@ async def test_purge_filtered_events( await async_wait_purge_done(hass) with session_scope(hass=hass) as session: - events_purge = session.query(Events).filter(Events.event_type == "EVENT_PURGE") + events_purge = session.query(Events).filter( + Events.event_type_id.in_(select_event_type_ids(("EVENT_PURGE",))) + ) events_keep = session.query(Events).filter( - Events.event_type == EVENT_STATE_CHANGED + Events.event_type_id.in_(select_event_type_ids((EVENT_STATE_CHANGED,))) ) states = session.query(States) assert events_purge.count() == 0 @@ -1109,10 +1156,18 @@ async def test_purge_filtered_events_state_changed( hass: HomeAssistant, ) -> None: """Test filtered state_changed events are purged. This should also remove all states.""" - config: ConfigType = {"exclude": {"event_types": [EVENT_STATE_CHANGED]}} + config: ConfigType = { + "exclude": { + "event_types": ["excluded_event"], + "entities": ["sensor.excluded", "sensor.old_format"], + } + } instance = await async_setup_recorder_instance(hass, config) # Assert entity_id is NOT excluded - assert instance.entity_filter("sensor.excluded") is True + assert instance.entity_filter("sensor.excluded") is False + assert instance.entity_filter("sensor.old_format") is False + assert instance.entity_filter("sensor.keep") is True + assert "excluded_event" in instance.exclude_event_types def _add_db_entries(hass: HomeAssistant) -> None: with session_scope(hass=hass) as session: @@ -1167,34 +1222,56 @@ async def test_purge_filtered_events_state_changed( old_state_id=62, # keep ) session.add_all((state_1, state_2, state_3)) + session.add( + Events( + event_id=231, + event_type="excluded_event", + event_data="{}", + origin="LOCAL", + time_fired_ts=dt_util.utc_to_timestamp(timestamp), + ) + ) + session.add( + States( + entity_id="sensor.old_format", + state="remove", + attributes="{}", + last_changed_ts=dt_util.utc_to_timestamp(timestamp), + last_updated_ts=dt_util.utc_to_timestamp(timestamp), + ) + ) + _convert_pending_events_to_event_types(instance, session) + _convert_pending_states_to_meta(instance, session) service_data = {"keep_days": 10, "apply_filter": True} _add_db_entries(hass) with session_scope(hass=hass) as session: - events_keep = session.query(Events).filter(Events.event_type == "EVENT_KEEP") + events_keep = session.query(Events).filter( + Events.event_type_id.in_(select_event_type_ids(("EVENT_KEEP",))) + ) events_purge = session.query(Events).filter( - Events.event_type == EVENT_STATE_CHANGED + Events.event_type_id.in_(select_event_type_ids(("excluded_event",))) ) states = session.query(States) assert events_keep.count() == 10 - assert events_purge.count() == 60 - assert states.count() == 63 + assert events_purge.count() == 1 + assert states.count() == 64 await hass.services.async_call(recorder.DOMAIN, SERVICE_PURGE, service_data) await hass.async_block_till_done() - await async_recorder_block_till_done(hass) - await async_wait_purge_done(hass) - - await async_recorder_block_till_done(hass) - await async_wait_purge_done(hass) + for _ in range(4): + await async_recorder_block_till_done(hass) + await async_wait_purge_done(hass) with session_scope(hass=hass) as session: - events_keep = session.query(Events).filter(Events.event_type == "EVENT_KEEP") + events_keep = session.query(Events).filter( + Events.event_type_id.in_(select_event_type_ids(("EVENT_KEEP",))) + ) events_purge = session.query(Events).filter( - Events.event_type == EVENT_STATE_CHANGED + Events.event_type_id.in_(select_event_type_ids(("excluded_event",))) ) states = session.query(States) @@ -1202,9 +1279,18 @@ async def test_purge_filtered_events_state_changed( assert events_purge.count() == 0 assert states.count() == 3 - assert session.query(States).get(61).old_state_id is None - assert session.query(States).get(62).old_state_id is None - assert session.query(States).get(63).old_state_id == 62 # should have been kept + assert ( + session.query(States).filter(States.state_id == 61).first().old_state_id + is None + ) + assert ( + session.query(States).filter(States.state_id == 62).first().old_state_id + is None + ) + assert ( + session.query(States).filter(States.state_id == 63).first().old_state_id + == 62 + ) # should have been kept async def test_purge_entities( @@ -1330,7 +1416,7 @@ async def test_purge_entities( _add_purge_records(hass) - # Confirm calling service without arguments matches all records (default filter behaviour) + # Confirm calling service without arguments matches all records (default filter behavior) with session_scope(hass=hass) as session: states = session.query(States) assert states.count() == 190 From a91055cc2af0fe7800b0f6663b8a433b3b01c556 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 14 Mar 2023 19:33:46 -0700 Subject: [PATCH 0943/2810] Fix additional typing in local calendar tests (#89704) * Fix additional typing in local calendar tests * Update tests/components/local_calendar/test_calendar.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- tests/components/local_calendar/conftest.py | 16 +++++++------- .../local_calendar/test_calendar.py | 21 +++++++++++++------ 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/tests/components/local_calendar/conftest.py b/tests/components/local_calendar/conftest.py index 02c984c284..bde9c226ba 100644 --- a/tests/components/local_calendar/conftest.py +++ b/tests/components/local_calendar/conftest.py @@ -87,14 +87,14 @@ async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) await hass.async_block_till_done() -GetEventsFn = Callable[[str, str], Awaitable[dict[str, Any]]] +GetEventsFn = Callable[[str, str], Awaitable[list[dict[str, Any]]]] @pytest.fixture(name="get_events") def get_events_fixture(hass_client: ClientSessionGenerator) -> GetEventsFn: """Fetch calendar events from the HTTP API.""" - async def _fetch(start: str, end: str) -> None: + async def _fetch(start: str, end: str) -> list[dict[str, Any]]: client = await hass_client() response = await client.get( f"/api/calendars/{TEST_ENTITY}?start={urllib.parse.quote(start)}&end={urllib.parse.quote(end)}" @@ -108,9 +108,7 @@ def get_events_fixture(hass_client: ClientSessionGenerator) -> GetEventsFn: def event_fields(data: dict[str, str]) -> dict[str, str]: """Filter event API response to minimum fields.""" return { - k: data.get(k) - for k in ["summary", "start", "end", "recurrence_id"] - if data.get(k) + k: data[k] for k in ["summary", "start", "end", "recurrence_id"] if data.get(k) } @@ -122,7 +120,9 @@ class Client: self.client = client self.id = 0 - async def cmd(self, cmd: str, payload: dict[str, Any] = None) -> dict[str, Any]: + async def cmd( + self, cmd: str, payload: dict[str, Any] | None = None + ) -> dict[str, Any]: """Send a command and receive the json result.""" self.id += 1 await self.client.send_json( @@ -136,7 +136,9 @@ class Client: assert resp.get("id") == self.id return resp - async def cmd_result(self, cmd: str, payload: dict[str, Any] = None) -> Any: + async def cmd_result( + self, cmd: str, payload: dict[str, Any] | None = None + ) -> dict[str, Any] | None: """Send a command and parse the result.""" resp = await self.cmd(cmd, payload) assert resp.get("success") diff --git a/tests/components/local_calendar/test_calendar.py b/tests/components/local_calendar/test_calendar.py index 8364f6df62..a859ed1d90 100644 --- a/tests/components/local_calendar/test_calendar.py +++ b/tests/components/local_calendar/test_calendar.py @@ -28,6 +28,7 @@ async def test_empty_calendar( assert len(events) == 0 state = hass.states.get(TEST_ENTITY) + assert state assert state.name == FRIENDLY_NAME assert state.state == STATE_OFF assert dict(state.attributes) == { @@ -140,6 +141,7 @@ async def test_active_event( ) state = hass.states.get(TEST_ENTITY) + assert state assert state.name == FRIENDLY_NAME assert state.state == STATE_ON assert dict(state.attributes) == { @@ -176,6 +178,7 @@ async def test_upcoming_event( ) state = hass.states.get(TEST_ENTITY) + assert state assert state.name == FRIENDLY_NAME assert state.state == STATE_OFF assert dict(state.attributes) == { @@ -642,9 +645,10 @@ async def test_invalid_rrule( }, }, ) + assert resp assert not resp.get("success") assert "error" in resp - assert resp.get("error").get("code") == "invalid_format" + assert resp["error"].get("code") == "invalid_format" @pytest.mark.parametrize( @@ -720,9 +724,10 @@ async def test_start_end_types( }, }, ) + assert result assert not result.get("success") assert "error" in result - assert "code" in result.get("error") + assert "code" in result["error"] assert result["error"]["code"] == "invalid_format" @@ -743,9 +748,10 @@ async def test_end_before_start( }, }, ) + assert result assert not result.get("success") assert "error" in result - assert "code" in result.get("error") + assert "code" in result["error"] assert result["error"]["code"] == "invalid_format" @@ -767,9 +773,10 @@ async def test_invalid_recurrence_rule( }, }, ) + assert result assert not result.get("success") assert "error" in result - assert "code" in result.get("error") + assert "code" in result["error"] assert result["error"]["code"] == "invalid_format" @@ -790,9 +797,10 @@ async def test_invalid_date_formats( }, }, ) + assert result assert not result.get("success") assert "error" in result - assert "code" in result.get("error") + assert "code" in result["error"] assert result["error"]["code"] == "invalid_format" @@ -815,9 +823,10 @@ async def test_update_invalid_event_id( }, }, ) + assert resp assert not resp.get("success") assert "error" in resp - assert resp.get("error").get("code") == "failed" + assert resp["error"].get("code") == "failed" @pytest.mark.parametrize( From bf8c4cae27fec62791316beb5ed977fb9823c209 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Wed, 15 Mar 2023 06:01:34 +0100 Subject: [PATCH 0944/2810] Update to nibe 2.1.4 (#89686) --- homeassistant/components/nibe_heatpump/config_flow.py | 1 + homeassistant/components/nibe_heatpump/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nibe_heatpump/config_flow.py b/homeassistant/components/nibe_heatpump/config_flow.py index 434a9a50ea..6680ca6e32 100644 --- a/homeassistant/components/nibe_heatpump/config_flow.py +++ b/homeassistant/components/nibe_heatpump/config_flow.py @@ -89,6 +89,7 @@ async def validate_nibegw_input( """Validate the user input allows us to connect.""" heatpump = HeatPump(Model[data[CONF_MODEL]]) + heatpump.word_swap = True await heatpump.initialize() connection = NibeGW( diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json index 5114cc222e..81c23437bb 100644 --- a/homeassistant/components/nibe_heatpump/manifest.json +++ b/homeassistant/components/nibe_heatpump/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nibe_heatpump", "iot_class": "local_polling", - "requirements": ["nibe==2.0.0"] + "requirements": ["nibe==2.1.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0831d51553..526713c3a5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1201,7 +1201,7 @@ nextcord==2.0.0a8 nextdns==1.3.0 # homeassistant.components.nibe_heatpump -nibe==2.0.0 +nibe==2.1.4 # homeassistant.components.niko_home_control niko-home-control==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a192592ca2..409b6d9257 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -894,7 +894,7 @@ nextcord==2.0.0a8 nextdns==1.3.0 # homeassistant.components.nibe_heatpump -nibe==2.0.0 +nibe==2.1.4 # homeassistant.components.nfandroidtv notifications-android-tv==0.1.5 From 59de7f3057ec19dbef627ba06a376f1bb6307c24 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Mar 2023 21:40:59 -1000 Subject: [PATCH 0945/2810] Migrate EventData management to a table manager (#89716) --- homeassistant/components/recorder/core.py | 102 ++++---------- homeassistant/components/recorder/purge.py | 17 +-- homeassistant/components/recorder/queries.py | 9 -- .../recorder/table_managers/__init__.py | 14 ++ .../recorder/table_managers/event_data.py | 128 ++++++++++++++++++ .../recorder/table_managers/event_types.py | 13 +- .../recorder/table_managers/states_meta.py | 12 +- 7 files changed, 184 insertions(+), 111 deletions(-) create mode 100644 homeassistant/components/recorder/table_managers/event_data.py diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index b57498e298..3e4f11dc95 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -80,15 +80,14 @@ from .models import ( from .pool import POOL_SIZE, MutexPool, RecorderPool from .queries import ( find_shared_attributes_id, - find_shared_data_id, get_shared_attributes, - get_shared_event_datas, has_entity_ids_to_migrate, has_event_type_to_migrate, has_events_context_ids_to_migrate, has_states_context_ids_to_migrate, ) from .run_history import RunHistory +from .table_managers.event_data import EventDataManager from .table_managers.event_types import EventTypeManager from .table_managers.states_meta import StatesMetaManager from .tasks import ( @@ -144,7 +143,6 @@ EXPIRE_AFTER_COMMITS = 120 # - How frequently states with overlapping attributes will change # - How much memory our low end hardware has STATE_ATTRIBUTES_ID_CACHE_SIZE = 2048 -EVENT_DATA_ID_CACHE_SIZE = 2048 SHUTDOWN_TASK = object() @@ -220,11 +218,10 @@ class Recorder(threading.Thread): self._commits_without_expire = 0 self._old_states: dict[str | None, States] = {} self._state_attributes_ids: LRU = LRU(STATE_ATTRIBUTES_ID_CACHE_SIZE) - self._event_data_ids: LRU = LRU(EVENT_DATA_ID_CACHE_SIZE) - self.event_type_manager = EventTypeManager() - self.states_meta_manager = StatesMetaManager() + self.event_data_manager = EventDataManager(self) + self.event_type_manager = EventTypeManager(self) + self.states_meta_manager = StatesMetaManager(self) self._pending_state_attributes: dict[str, StateAttributes] = {} - self._pending_event_data: dict[str, EventData] = {} self._pending_expunge: list[States] = [] self.event_session: Session | None = None self._get_session: Callable[[], Session] | None = None @@ -780,7 +777,7 @@ class Recorder(threading.Thread): assert self.event_session is not None self._pre_process_state_change_events(state_change_events) - self._pre_process_non_state_change_events(non_state_change_events) + self.event_data_manager.load(non_state_change_events, self.event_session) self.event_type_manager.load(non_state_change_events, self.event_session) self.states_meta_manager.load(state_change_events, self.event_session) @@ -807,26 +804,6 @@ class Recorder(threading.Thread): ).fetchall(): self._state_attributes_ids[shared_attrs] = id_ - def _pre_process_non_state_change_events(self, events: list[Event]) -> None: - """Load startup event attributes from the database. - - Since the _event_data_ids cache is empty at startup - we restore it from the database to avoid having to look up - the data in the database for every event until its primed. - """ - assert self.event_session is not None - if hashes := { - EventData.hash_shared_data_bytes(shared_event_bytes) - for event in events - if (shared_event_bytes := self._serialize_event_data_from_event(event)) - }: - with self.event_session.no_autoflush: - for hash_chunk in chunked(hashes, SQLITE_MAX_BIND_VARS): - for id_, shared_data in self.event_session.execute( - get_shared_event_datas(hash_chunk) - ).fetchall(): - self._event_data_ids[shared_data] = id_ - def _guarded_process_one_task_or_recover(self, task: RecorderTask) -> None: """Process a task, guarding against exceptions to ensure the loop does not collapse.""" _LOGGER.debug("Processing task: %s", task) @@ -973,79 +950,51 @@ class Recorder(threading.Thread): return cast(int, attributes_id[0]) return None - def _find_shared_data_in_db(self, data_hash: int, shared_data: str) -> int | None: - """Find shared event data in the db from the hash and shared_attrs.""" - # - # Avoid the event session being flushed since it will - # commit all the pending events and states to the database. - # - # The lookup has already have checked to see if the data is cached - # or going to be written in the next commit so there is no - # need to flush before checking the database. - # - assert self.event_session is not None - with self.event_session.no_autoflush: - if data_id := self.event_session.execute( - find_shared_data_id(data_hash, shared_data) - ).first(): - return cast(int, data_id[0]) - return None - - def _serialize_event_data_from_event(self, event: Event) -> bytes | None: - """Serialize event data.""" - try: - return EventData.shared_data_bytes_from_event(event, self.dialect_name) - except JSON_ENCODE_EXCEPTIONS as ex: - _LOGGER.warning("Event is not JSON serializable: %s: %s", event, ex) - return None - def _process_non_state_changed_event_into_session(self, event: Event) -> None: """Process any event into the session except state changed.""" - event_session = self.event_session - assert event_session is not None + session = self.event_session + assert session is not None dbevent = Events.from_event(event) # Map the event_type to the EventTypes table event_type_manager = self.event_type_manager if pending_event_types := event_type_manager.get_pending(event.event_type): dbevent.event_type_rel = pending_event_types - elif event_type_id := event_type_manager.get(event.event_type, event_session): + elif event_type_id := event_type_manager.get(event.event_type, session): dbevent.event_type_id = event_type_id else: event_types = EventTypes(event_type=event.event_type) event_type_manager.add_pending(event_types) - event_session.add(event_types) + session.add(event_types) dbevent.event_type_rel = event_types if not event.data: - event_session.add(dbevent) + session.add(dbevent) return - if not (shared_data_bytes := self._serialize_event_data_from_event(event)): + event_data_manager = self.event_data_manager + if not (shared_data_bytes := event_data_manager.serialize_from_event(event)): return # Map the event data to the EventData table shared_data = shared_data_bytes.decode("utf-8") # Matching attributes found in the pending commit - if pending_event_data := self._pending_event_data.get(shared_data): + if pending_event_data := event_data_manager.get_pending(shared_data): dbevent.event_data_rel = pending_event_data # Matching attributes id found in the cache - elif data_id := self._event_data_ids.get(shared_data): + elif (data_id := event_data_manager.get_from_cache(shared_data)) or ( + (hash_ := EventData.hash_shared_data_bytes(shared_data_bytes)) + and (data_id := event_data_manager.get(shared_data, hash_, session)) + ): dbevent.data_id = data_id else: - data_hash = EventData.hash_shared_data_bytes(shared_data_bytes) - # Matching attributes found in the database - if data_id := self._find_shared_data_in_db(data_hash, shared_data): - self._event_data_ids[shared_data] = dbevent.data_id = data_id # No matching attributes found, save them in the DB - else: - dbevent_data = EventData(shared_data=shared_data, hash=data_hash) - dbevent.event_data_rel = self._pending_event_data[ - shared_data - ] = dbevent_data - event_session.add(dbevent_data) + dbevent_data = EventData(shared_data=shared_data, hash=hash_) + event_data_manager.add_pending(dbevent_data) + session.add(dbevent_data) + dbevent.event_data_rel = dbevent_data - event_session.add(dbevent) + session.add(dbevent) def _serialize_state_attributes_from_event(self, event: Event) -> bytes | None: """Serialize state changed event data.""" @@ -1184,9 +1133,7 @@ class Recorder(threading.Thread): state_attr.shared_attrs ] = state_attr.attributes_id self._pending_state_attributes = {} - for event_data in self._pending_event_data.values(): - self._event_data_ids[event_data.shared_data] = event_data.data_id - self._pending_event_data = {} + self.event_data_manager.post_commit_pending() self.event_type_manager.post_commit_pending() self.states_meta_manager.post_commit_pending() @@ -1212,9 +1159,8 @@ class Recorder(threading.Thread): """Close the event session.""" self._old_states.clear() self._state_attributes_ids.clear() - self._event_data_ids.clear() self._pending_state_attributes.clear() - self._pending_event_data.clear() + self.event_data_manager.reset() self.event_type_manager.reset() self.states_meta_manager.reset() diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index c644a17be0..5dffead597 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -479,21 +479,6 @@ def _evict_purged_states_from_old_states_cache( old_states.pop(old_state_reversed[purged_state_id], None) -def _evict_purged_data_from_data_cache( - instance: Recorder, purged_data_ids: set[int] -) -> None: - """Evict purged data ids from the data ids cache.""" - # Make a map from data_id to the data json - event_data_ids = instance._event_data_ids # pylint: disable=protected-access - event_data_ids_reversed = { - data_id: data for data, data_id in event_data_ids.items() - } - - # Evict any purged data from the event_data_ids cache - for purged_attribute_id in purged_data_ids.intersection(event_data_ids_reversed): - event_data_ids.pop(event_data_ids_reversed[purged_attribute_id], None) - - def _evict_purged_attributes_from_attributes_cache( instance: Recorder, purged_attributes_ids: set[int] ) -> None: @@ -539,7 +524,7 @@ def _purge_batch_data_ids( _LOGGER.debug("Deleted %s data events", deleted_rows) # Evict any entries in the event_data_ids cache referring to a purged state - _evict_purged_data_from_data_cache(instance, data_ids) + instance.event_data_manager.evict_purged(data_ids) def _purge_statistics_runs(session: Session, statistics_runs: list[int]) -> None: diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py index 737faf2f7e..0882da9d48 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -85,15 +85,6 @@ def find_shared_attributes_id( ) -def find_shared_data_id(attr_hash: int, shared_data: str) -> StatementLambdaElement: - """Find a data_id by hash and shared_data.""" - return lambda_stmt( - lambda: select(EventData.data_id) - .filter(EventData.hash == attr_hash) - .filter(EventData.shared_data == shared_data) - ) - - def _state_attrs_exist(attr: int | None) -> Select: """Check if a state attributes id exists in the states table.""" # https://github.com/sqlalchemy/sqlalchemy/issues/9189 diff --git a/homeassistant/components/recorder/table_managers/__init__.py b/homeassistant/components/recorder/table_managers/__init__.py index c011520204..50ea8f0e11 100644 --- a/homeassistant/components/recorder/table_managers/__init__.py +++ b/homeassistant/components/recorder/table_managers/__init__.py @@ -1 +1,15 @@ """Managers for each table.""" + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ..core import Recorder + + +class BaseTableManager: + """Base class for table managers.""" + + def __init__(self, recorder: "Recorder") -> None: + """Initialize the table manager.""" + self.active = False + self.recorder = recorder diff --git a/homeassistant/components/recorder/table_managers/event_data.py b/homeassistant/components/recorder/table_managers/event_data.py new file mode 100644 index 0000000000..975681b466 --- /dev/null +++ b/homeassistant/components/recorder/table_managers/event_data.py @@ -0,0 +1,128 @@ +"""Support managing EventData.""" +from __future__ import annotations + +from collections.abc import Iterable +import logging +from typing import TYPE_CHECKING, cast + +from lru import LRU # pylint: disable=no-name-in-module +from sqlalchemy.orm.session import Session + +from homeassistant.core import Event +from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS + +from . import BaseTableManager +from ..const import SQLITE_MAX_BIND_VARS +from ..db_schema import EventData +from ..queries import get_shared_event_datas +from ..util import chunked + +if TYPE_CHECKING: + from ..core import Recorder + + +CACHE_SIZE = 2048 + +_LOGGER = logging.getLogger(__name__) + + +class EventDataManager(BaseTableManager): + """Manage the EventData table.""" + + def __init__(self, recorder: Recorder) -> None: + """Initialize the event type manager.""" + self._id_map: dict[str, int] = LRU(CACHE_SIZE) + self._pending: dict[str, EventData] = {} + super().__init__(recorder) + self.active = True # always active + + def serialize_from_event(self, event: Event) -> bytes | None: + """Serialize event data.""" + try: + return EventData.shared_data_bytes_from_event( + event, self.recorder.dialect_name + ) + except JSON_ENCODE_EXCEPTIONS as ex: + _LOGGER.warning("Event is not JSON serializable: %s: %s", event, ex) + return None + + def load(self, events: list[Event], session: Session) -> None: + """Load the shared_datas to data_ids mapping into memory from events.""" + if hashes := { + EventData.hash_shared_data_bytes(shared_event_bytes) + for event in events + if (shared_event_bytes := self.serialize_from_event(event)) + }: + self._load_from_hashes(hashes, session) + + def get(self, shared_data: str, data_hash: int, session: Session) -> int | None: + """Resolve shared_datas to the data_id.""" + return self.get_many(((shared_data, data_hash),), session)[shared_data] + + def get_from_cache(self, shared_data: str) -> int | None: + """Resolve shared_data to the data_id without accessing the underlying database.""" + return self._id_map.get(shared_data) + + def get_many( + self, shared_data_data_hashs: Iterable[tuple[str, int]], session: Session + ) -> dict[str, int | None]: + """Resolve shared_datas to data_ids.""" + results: dict[str, int | None] = {} + missing_hashes: set[int] = set() + for shared_data, data_hash in shared_data_data_hashs: + if (data_id := self._id_map.get(shared_data)) is None: + missing_hashes.add(data_hash) + + results[shared_data] = data_id + + if not missing_hashes: + return results + + return results | self._load_from_hashes(missing_hashes, session) + + def _load_from_hashes( + self, hashes: Iterable[int], session: Session + ) -> dict[str, int | None]: + """Load the shared_datas to data_ids mapping into memory from a list of hashes.""" + results: dict[str, int | None] = {} + with session.no_autoflush: + for hashs_chunk in chunked(hashes, SQLITE_MAX_BIND_VARS): + for data_id, shared_data in session.execute( + get_shared_event_datas(hashs_chunk) + ): + results[shared_data] = self._id_map[shared_data] = cast( + int, data_id + ) + + return results + + def get_pending(self, shared_data: str) -> EventData | None: + """Get pending EventData that have not be assigned ids yet.""" + return self._pending.get(shared_data) + + def add_pending(self, db_event_data: EventData) -> None: + """Add a pending EventData that will be committed at the next interval.""" + assert db_event_data.shared_data is not None + shared_data: str = db_event_data.shared_data + self._pending[shared_data] = db_event_data + + def post_commit_pending(self) -> None: + """Call after commit to load the data_ids of the new EventData into the LRU.""" + for shared_data, db_event_data in self._pending.items(): + self._id_map[shared_data] = db_event_data.data_id + self._pending.clear() + + def reset(self) -> None: + """Reset the event manager after the database has been reset or changed.""" + self._id_map.clear() + self._pending.clear() + + def evict_purged(self, data_ids: set[int]) -> None: + """Evict purged data_ids from the cache when they are no longer used.""" + id_map = self._id_map + event_data_ids_reversed = { + data_id: shared_data for shared_data, data_id in id_map.items() + } + # Evict any purged data from the cache + for purged_data_id in data_ids.intersection(event_data_ids_reversed): + id_map.pop(event_data_ids_reversed[purged_data_id], None) diff --git a/homeassistant/components/recorder/table_managers/event_types.py b/homeassistant/components/recorder/table_managers/event_types.py index 21bcf78bf1..cc7490f8d7 100644 --- a/homeassistant/components/recorder/table_managers/event_types.py +++ b/homeassistant/components/recorder/table_managers/event_types.py @@ -2,29 +2,34 @@ from __future__ import annotations from collections.abc import Iterable -from typing import cast +from typing import TYPE_CHECKING, cast from lru import LRU # pylint: disable=no-name-in-module from sqlalchemy.orm.session import Session from homeassistant.core import Event +from . import BaseTableManager from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import EventTypes from ..queries import find_event_type_ids from ..util import chunked +if TYPE_CHECKING: + from ..core import Recorder + + CACHE_SIZE = 2048 -class EventTypeManager: +class EventTypeManager(BaseTableManager): """Manage the EventTypes table.""" - def __init__(self) -> None: + def __init__(self, recorder: Recorder) -> None: """Initialize the event type manager.""" self._id_map: dict[str, int] = LRU(CACHE_SIZE) self._pending: dict[str, EventTypes] = {} - self.active = False + super().__init__(recorder) def load(self, events: list[Event], session: Session) -> None: """Load the event_type to event_type_ids mapping into memory.""" diff --git a/homeassistant/components/recorder/table_managers/states_meta.py b/homeassistant/components/recorder/table_managers/states_meta.py index 8af872ff96..0352587aa6 100644 --- a/homeassistant/components/recorder/table_managers/states_meta.py +++ b/homeassistant/components/recorder/table_managers/states_meta.py @@ -2,29 +2,33 @@ from __future__ import annotations from collections.abc import Iterable -from typing import cast +from typing import TYPE_CHECKING, cast from lru import LRU # pylint: disable=no-name-in-module from sqlalchemy.orm.session import Session from homeassistant.core import Event +from . import BaseTableManager from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import StatesMeta from ..queries import find_all_states_metadata_ids, find_states_metadata_ids from ..util import chunked +if TYPE_CHECKING: + from ..core import Recorder + CACHE_SIZE = 8192 -class StatesMetaManager: +class StatesMetaManager(BaseTableManager): """Manage the StatesMeta table.""" - def __init__(self) -> None: + def __init__(self, recorder: Recorder) -> None: """Initialize the states meta manager.""" self._id_map: dict[str, int] = LRU(CACHE_SIZE) self._pending: dict[str, StatesMeta] = {} - self.active = False + super().__init__(recorder) def load(self, events: list[Event], session: Session) -> None: """Load the entity_id to metadata_id mapping into memory.""" From 9719f817c09197c6f47e1ec1a716d83eb7ff017e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 15 Mar 2023 10:07:12 +0100 Subject: [PATCH 0946/2810] Bump ruff to 0.0.256 (#89734) --- .pre-commit-config.yaml | 2 +- requirements_test_pre_commit.txt | 2 +- tests/components/trace/test_websocket_api.py | 7 ++++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 269d786ab2..af0d3b318e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.254 + rev: v0.0.256 hooks: - id: ruff args: diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 65913a3159..0b7c52f85d 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -14,5 +14,5 @@ pycodestyle==2.10.0 pydocstyle==6.2.3 pyflakes==3.0.1 pyupgrade==3.3.1 -ruff==0.0.254 +ruff==0.0.256 yamllint==1.28.0 diff --git a/tests/components/trace/test_websocket_api.py b/tests/components/trace/test_websocket_api.py index fe9ee39a36..5dbd78268e 100644 --- a/tests/components/trace/test_websocket_api.py +++ b/tests/components/trace/test_websocket_api.py @@ -1,7 +1,8 @@ """Test Trace websocket API.""" import asyncio +from collections import defaultdict import json -from typing import Any, DefaultDict +from typing import Any from unittest.mock import patch import pytest @@ -391,7 +392,7 @@ async def test_get_trace( trace_list = response["result"] # Get all traces and generate expected stored traces - traces = DefaultDict(list) + traces = defaultdict(list) for trace in trace_list: item_id = trace["item_id"] run_id = trace["run_id"] @@ -448,7 +449,7 @@ async def test_restore_traces( trace_list = response["result"] # Get all traces and generate expected stored traces - traces = DefaultDict(list) + traces = defaultdict(list) contexts = {} for trace in trace_list: item_id = trace["item_id"] From cd23caff58ccddd164a1e6e8d9e155b0e2f0194f Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 15 Mar 2023 12:27:45 +0100 Subject: [PATCH 0947/2810] Correct modbus serial method parameter (#89738) --- homeassistant/components/modbus/modbus.py | 9 +++++++-- tests/components/modbus/test_init.py | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 627950fe00..abc1508d7d 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -16,7 +16,7 @@ from pymodbus.client import ( from pymodbus.constants import Defaults from pymodbus.exceptions import ModbusException from pymodbus.pdu import ModbusResponse -from pymodbus.transaction import ModbusRtuFramer +from pymodbus.transaction import ModbusAsciiFramer, ModbusRtuFramer, ModbusSocketFramer import voluptuous as vol from homeassistant.const import ( @@ -279,9 +279,12 @@ class ModbusHub: } if self._config_type == SERIAL: # serial configuration + if client_config[CONF_METHOD] == "ascii": + self._pb_params["framer"] = ModbusAsciiFramer + else: + self._pb_params["framer"] = ModbusRtuFramer self._pb_params.update( { - "method": client_config[CONF_METHOD], "baudrate": client_config[CONF_BAUDRATE], "stopbits": client_config[CONF_STOPBITS], "bytesize": client_config[CONF_BYTESIZE], @@ -293,6 +296,8 @@ class ModbusHub: self._pb_params["host"] = client_config[CONF_HOST] if self._config_type == RTUOVERTCP: self._pb_params["framer"] = ModbusRtuFramer + else: + self._pb_params["framer"] = ModbusSocketFramer Defaults.Timeout = client_config[CONF_TIMEOUT] if CONF_MSG_WAIT in client_config: diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 75f2f9d3e6..7a06923404 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -378,7 +378,7 @@ async def test_duplicate_entity_validator(do_config) -> None: CONF_TYPE: SERIAL, CONF_BAUDRATE: 9600, CONF_BYTESIZE: 8, - CONF_METHOD: "rtu", + CONF_METHOD: "ascii", CONF_PORT: TEST_PORT_SERIAL, CONF_PARITY: "E", CONF_STOPBITS: 1, From 6270776fbb573a62b1bcc83bc080768692e40abe Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 15 Mar 2023 12:43:53 +0100 Subject: [PATCH 0948/2810] Add turn_on trigger to Samsung TV (#89018) * Add turn_on trigger to Samsung TV * Add tests * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Remove assert * Cleanup mock_send_magic_packet --------- Co-authored-by: Martin Hjelmare --- .../components/samsungtv/device_trigger.py | 80 +++++++ homeassistant/components/samsungtv/helpers.py | 61 ++++++ .../components/samsungtv/media_player.py | 22 +- .../components/samsungtv/strings.json | 5 + homeassistant/components/samsungtv/trigger.py | 46 ++++ .../components/samsungtv/triggers/__init__.py | 1 + .../components/samsungtv/triggers/turn_on.py | 108 ++++++++++ tests/components/samsungtv/conftest.py | 9 + .../samsungtv/test_device_trigger.py | 149 +++++++++++++ tests/components/samsungtv/test_trigger.py | 196 ++++++++++++++++++ 10 files changed, 675 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/samsungtv/device_trigger.py create mode 100644 homeassistant/components/samsungtv/helpers.py create mode 100644 homeassistant/components/samsungtv/trigger.py create mode 100644 homeassistant/components/samsungtv/triggers/__init__.py create mode 100644 homeassistant/components/samsungtv/triggers/turn_on.py create mode 100644 tests/components/samsungtv/test_device_trigger.py create mode 100644 tests/components/samsungtv/test_trigger.py diff --git a/homeassistant/components/samsungtv/device_trigger.py b/homeassistant/components/samsungtv/device_trigger.py new file mode 100644 index 0000000000..f3a69e637e --- /dev/null +++ b/homeassistant/components/samsungtv/device_trigger.py @@ -0,0 +1,80 @@ +"""Provides device automations for control of Samsung TV.""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) +from homeassistant.const import CONF_DEVICE_ID, CONF_PLATFORM, CONF_TYPE +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.typing import ConfigType + +from . import trigger +from .const import DOMAIN +from .helpers import ( + async_get_client_by_device_entry, + async_get_device_entry_by_device_id, +) +from .triggers.turn_on import ( + PLATFORM_TYPE as TURN_ON_PLATFORM_TYPE, + async_get_turn_on_trigger, +) + +TRIGGER_TYPES = {TURN_ON_PLATFORM_TYPE} +TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), + } +) + + +async def async_validate_trigger_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + config = TRIGGER_SCHEMA(config) + + if config[CONF_TYPE] == TURN_ON_PLATFORM_TYPE: + device_id = config[CONF_DEVICE_ID] + try: + device = async_get_device_entry_by_device_id(hass, device_id) + if DOMAIN in hass.data: + async_get_client_by_device_entry(hass, device) + except ValueError as err: + raise InvalidDeviceAutomationConfig(err) from err + + return config + + +async def async_get_triggers( + _hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: + """List device triggers for device.""" + triggers = [async_get_turn_on_trigger(device_id)] + return triggers + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, +) -> CALLBACK_TYPE: + """Attach a trigger.""" + if (trigger_type := config[CONF_TYPE]) == TURN_ON_PLATFORM_TYPE: + trigger_config = { + CONF_PLATFORM: trigger_type, + CONF_DEVICE_ID: config[CONF_DEVICE_ID], + } + trigger_config = await trigger.async_validate_trigger_config( + hass, trigger_config + ) + return await trigger.async_attach_trigger( + hass, trigger_config, action, trigger_info + ) + + raise HomeAssistantError(f"Unhandled trigger type {trigger_type}") diff --git a/homeassistant/components/samsungtv/helpers.py b/homeassistant/components/samsungtv/helpers.py new file mode 100644 index 0000000000..06a3c3e70e --- /dev/null +++ b/homeassistant/components/samsungtv/helpers.py @@ -0,0 +1,61 @@ +"""Helper functions for Samsung TV.""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import DeviceEntry + +from .bridge import SamsungTVBridge +from .const import DOMAIN + + +@callback +def async_get_device_entry_by_device_id( + hass: HomeAssistant, device_id: str +) -> DeviceEntry: + """Get Device Entry from Device Registry by device ID. + + Raises ValueError if device ID is invalid. + """ + device_reg = dr.async_get(hass) + if (device := device_reg.async_get(device_id)) is None: + raise ValueError(f"Device {device_id} is not a valid {DOMAIN} device.") + + return device + + +@callback +def async_get_device_id_from_entity_id(hass: HomeAssistant, entity_id: str) -> str: + """Get device ID from an entity ID. + + Raises ValueError if entity or device ID is invalid. + """ + ent_reg = er.async_get(hass) + entity_entry = ent_reg.async_get(entity_id) + + if ( + entity_entry is None + or entity_entry.device_id is None + or entity_entry.platform != DOMAIN + ): + raise ValueError(f"Entity {entity_id} is not a valid {DOMAIN} entity.") + + return entity_entry.device_id + + +@callback +def async_get_client_by_device_entry( + hass: HomeAssistant, device: DeviceEntry +) -> SamsungTVBridge: + """Get SamsungTVBridge from Device Registry by device entry. + + Raises ValueError if client is not found. + """ + domain_data: dict[str, SamsungTVBridge] = hass.data[DOMAIN] + for config_entry_id in device.config_entries: + if bridge := domain_data.get(config_entry_id): + return bridge + + raise ValueError( + f"Device {device.id} is not from an existing {DOMAIN} config entry" + ) diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 59b4131af1..ea31cca3e9 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -40,6 +40,7 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script +from homeassistant.helpers.trigger import PluggableAction from homeassistant.util import dt as dt_util from .bridge import SamsungTVBridge, SamsungTVWSBridge @@ -51,6 +52,7 @@ from .const import ( DOMAIN, LOGGER, ) +from .triggers.turn_on import async_get_turn_on_trigger SOURCES = {"TV": "KEY_TV", "HDMI": "KEY_HDMI"} @@ -112,6 +114,7 @@ class SamsungTVDevice(MediaPlayerEntity): self._ssdp_rendering_control_location: str | None = config_entry.data.get( CONF_SSDP_RENDERING_CONTROL_LOCATION ) + self._turn_on = PluggableAction(self.async_write_ha_state) self._on_script = on_script # Assume that the TV is in Play mode self._playing: bool = True @@ -125,8 +128,8 @@ class SamsungTVDevice(MediaPlayerEntity): self._app_list_event: asyncio.Event = asyncio.Event() self._attr_supported_features = SUPPORT_SAMSUNGTV - if self._on_script or self._mac: - # Add turn-on if on_script or mac is available + if self._turn_on or self._on_script or self._mac: + # Add turn-on if turn_on trigger or on_script YAML or mac is available self._attr_supported_features |= MediaPlayerEntityFeature.TURN_ON if self._ssdp_rendering_control_location: self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_SET @@ -359,11 +362,23 @@ class SamsungTVDevice(MediaPlayerEntity): return False return ( self.state == MediaPlayerState.ON + or bool(self._turn_on) or self._on_script is not None or self._mac is not None or self._power_off_in_progress() ) + async def async_added_to_hass(self) -> None: + """Connect and subscribe to dispatcher signals and state updates.""" + await super().async_added_to_hass() + + if (entry := self.registry_entry) and entry.device_id: + self.async_on_remove( + self._turn_on.async_register( + self.hass, async_get_turn_on_trigger(entry.device_id) + ) + ) + async def async_turn_off(self) -> None: """Turn off media player.""" self._end_of_power_off = dt_util.utcnow() + SCAN_INTERVAL_PLUS_OFF_TIME @@ -448,6 +463,9 @@ class SamsungTVDevice(MediaPlayerEntity): async def async_turn_on(self) -> None: """Turn the media player on.""" + if self._turn_on: + await self._turn_on.async_run(self.hass, self._context) + # on_script is deprecated - replaced by turn_on trigger if self._on_script: await self._on_script.async_run(context=self._context) elif self._mac: diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json index e67b50fae7..f1f237fa4f 100644 --- a/homeassistant/components/samsungtv/strings.json +++ b/homeassistant/components/samsungtv/strings.json @@ -39,5 +39,10 @@ "unknown": "[%key:common::config_flow::error::unknown%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "device_automation": { + "trigger_type": { + "samsungtv.turn_on": "Device is requested to turn on" + } } } diff --git a/homeassistant/components/samsungtv/trigger.py b/homeassistant/components/samsungtv/trigger.py new file mode 100644 index 0000000000..cd78ff18be --- /dev/null +++ b/homeassistant/components/samsungtv/trigger.py @@ -0,0 +1,46 @@ +"""Samsung TV trigger dispatcher.""" +from __future__ import annotations + +from typing import cast + +from homeassistant.const import CONF_PLATFORM +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers.trigger import ( + TriggerActionType, + TriggerInfo, + TriggerProtocol, +) +from homeassistant.helpers.typing import ConfigType + +from .triggers import turn_on + +TRIGGERS = { + "turn_on": turn_on, +} + + +def _get_trigger_platform(config: ConfigType) -> TriggerProtocol: + """Return trigger platform.""" + platform_split = config[CONF_PLATFORM].split(".", maxsplit=1) + if len(platform_split) < 2 or platform_split[1] not in TRIGGERS: + raise ValueError(f"Unknown Samsung TV trigger platform {config[CONF_PLATFORM]}") + return cast(TriggerProtocol, TRIGGERS[platform_split[1]]) + + +async def async_validate_trigger_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + platform = _get_trigger_platform(config) + return cast(ConfigType, platform.TRIGGER_SCHEMA(config)) + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, +) -> CALLBACK_TYPE: + """Attach trigger of specified platform.""" + platform = _get_trigger_platform(config) + return await platform.async_attach_trigger(hass, config, action, trigger_info) diff --git a/homeassistant/components/samsungtv/triggers/__init__.py b/homeassistant/components/samsungtv/triggers/__init__.py new file mode 100644 index 0000000000..9e2e2af6d4 --- /dev/null +++ b/homeassistant/components/samsungtv/triggers/__init__.py @@ -0,0 +1 @@ +"""Samsung TV triggers.""" diff --git a/homeassistant/components/samsungtv/triggers/turn_on.py b/homeassistant/components/samsungtv/triggers/turn_on.py new file mode 100644 index 0000000000..de0036234a --- /dev/null +++ b/homeassistant/components/samsungtv/triggers/turn_on.py @@ -0,0 +1,108 @@ +"""Samsung TV device turn on trigger.""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_DEVICE_ID, + ATTR_ENTITY_ID, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_PLATFORM, + CONF_TYPE, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.trigger import ( + PluggableAction, + TriggerActionType, + TriggerInfo, +) +from homeassistant.helpers.typing import ConfigType + +from ..const import DOMAIN +from ..helpers import ( + async_get_device_entry_by_device_id, + async_get_device_id_from_entity_id, +) + +# Platform type should be . +PLATFORM_TYPE = f"{DOMAIN}.{__name__.rsplit('.', maxsplit=1)[-1]}" + +TRIGGER_TYPE_TURN_ON = "turn_on" + +TRIGGER_SCHEMA = vol.All( + cv.TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_PLATFORM): PLATFORM_TYPE, + vol.Optional(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + }, + ), + cv.has_at_least_one_key(ATTR_ENTITY_ID, ATTR_DEVICE_ID), +) + + +def async_get_turn_on_trigger(device_id: str) -> dict[str, str]: + """Return data for a turn on trigger.""" + + return { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_TYPE: PLATFORM_TYPE, + } + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, + *, + platform_type: str = PLATFORM_TYPE, +) -> CALLBACK_TYPE | None: + """Attach a trigger.""" + device_ids = set() + if ATTR_DEVICE_ID in config: + device_ids.update(config.get(ATTR_DEVICE_ID, [])) + + if ATTR_ENTITY_ID in config: + device_ids.update( + { + async_get_device_id_from_entity_id(hass, entity_id) + for entity_id in config.get(ATTR_ENTITY_ID, []) + } + ) + + trigger_data = trigger_info["trigger_data"] + + unsubs = [] + + for device_id in device_ids: + device = async_get_device_entry_by_device_id(hass, device_id) + device_name = device.name_by_user or device.name + + variables = { + **trigger_data, + CONF_PLATFORM: platform_type, + ATTR_DEVICE_ID: device_id, + "description": f"Samsung turn on trigger for {device_name}", + } + + turn_on_trigger = async_get_turn_on_trigger(device_id) + + unsubs.append( + PluggableAction.async_attach_trigger( + hass, turn_on_trigger, action, {"trigger": variables} + ) + ) + + @callback + def async_remove() -> None: + """Remove state listeners async.""" + for unsub in unsubs: + unsub() + unsubs.clear() + + return async_remove diff --git a/tests/components/samsungtv/conftest.py b/tests/components/samsungtv/conftest.py index 0e95dfa28a..1638057464 100644 --- a/tests/components/samsungtv/conftest.py +++ b/tests/components/samsungtv/conftest.py @@ -20,10 +20,13 @@ from samsungtvws.exceptions import ResponseError from samsungtvws.remote import ChannelEmitCommand from homeassistant.components.samsungtv.const import WEBSOCKET_SSL_PORT +from homeassistant.core import HomeAssistant, ServiceCall import homeassistant.util.dt as dt_util from .const import SAMPLE_DEVICE_INFO_UE48JU6400, SAMPLE_DEVICE_INFO_WIFI +from tests.common import async_mock_service + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: @@ -307,3 +310,9 @@ def mac_address_fixture() -> Mock: """Patch getmac.get_mac_address.""" with patch("getmac.get_mac_address", return_value=None) as mac: yield mac + + +@pytest.fixture +def calls(hass: HomeAssistant) -> list[ServiceCall]: + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") diff --git a/tests/components/samsungtv/test_device_trigger.py b/tests/components/samsungtv/test_device_trigger.py new file mode 100644 index 0000000000..1420440ad4 --- /dev/null +++ b/tests/components/samsungtv/test_device_trigger.py @@ -0,0 +1,149 @@ +"""The tests for Samsung TV device triggers.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components import automation +from homeassistant.components.device_automation import DeviceAutomationType +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) +from homeassistant.components.samsungtv import DOMAIN, device_trigger +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import async_get as get_dev_reg +from homeassistant.setup import async_setup_component + +from . import setup_samsungtv_entry +from .test_media_player import ENTITY_ID, MOCK_ENTRYDATA_ENCRYPTED_WS + +from tests.common import MockConfigEntry, async_get_device_automations + + +@pytest.mark.usefixtures("remoteencws", "rest_api") +async def test_get_triggers(hass: HomeAssistant) -> None: + """Test we get the expected triggers.""" + await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + + device_reg = get_dev_reg(hass) + device = device_reg.async_get_device(identifiers={(DOMAIN, "any")}) + + turn_on_trigger = { + "platform": "device", + "domain": DOMAIN, + "type": "samsungtv.turn_on", + "device_id": device.id, + "metadata": {}, + } + + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device.id + ) + assert turn_on_trigger in triggers + + +@pytest.mark.usefixtures("remoteencws", "rest_api") +async def test_if_fires_on_turn_on_request( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: + """Test for turn_on and turn_off triggers firing.""" + await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + + device_reg = get_dev_reg(hass) + device = device_reg.async_get_device(identifiers={(DOMAIN, "any")}) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "samsungtv.turn_on", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "{{ trigger.device_id }}", + "id": "{{ trigger.id }}", + }, + }, + }, + { + "trigger": { + "platform": "samsungtv.turn_on", + "entity_id": ENTITY_ID, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ENTITY_ID, + "id": "{{ trigger.id }}", + }, + }, + }, + ], + }, + ) + + with patch("homeassistant.components.samsungtv.media_player.send_magic_packet"): + await hass.services.async_call( + "media_player", + "turn_on", + {"entity_id": ENTITY_ID}, + blocking=True, + ) + + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[0].data["some"] == device.id + assert calls[0].data["id"] == 0 + assert calls[1].data["some"] == ENTITY_ID + assert calls[1].data["id"] == 0 + + +@pytest.mark.usefixtures("remoteencws", "rest_api") +async def test_failure_scenarios(hass: HomeAssistant) -> None: + """Test failure scenarios.""" + await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + + # Test wrong trigger platform type + with pytest.raises(HomeAssistantError): + await device_trigger.async_attach_trigger( + hass, {"type": "wrong.type", "device_id": "invalid_device_id"}, None, {} + ) + + # Test invalid device id + with pytest.raises(InvalidDeviceAutomationConfig): + await device_trigger.async_validate_trigger_config( + hass, + { + "platform": "device", + "domain": DOMAIN, + "type": "samsungtv.turn_on", + "device_id": "invalid_device_id", + }, + ) + + entry = MockConfigEntry(domain="fake", state=ConfigEntryState.LOADED, data={}) + entry.add_to_hass(hass) + device_reg = get_dev_reg(hass) + + device = device_reg.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("fake", "fake")} + ) + + config = { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "samsungtv.turn_on", + } + + # Test that device id from non samsungtv domain raises exception + with pytest.raises(InvalidDeviceAutomationConfig): + await device_trigger.async_validate_trigger_config(hass, config) diff --git a/tests/components/samsungtv/test_trigger.py b/tests/components/samsungtv/test_trigger.py new file mode 100644 index 0000000000..407d98186b --- /dev/null +++ b/tests/components/samsungtv/test_trigger.py @@ -0,0 +1,196 @@ +"""The tests for WebOS TV automation triggers.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components import automation +from homeassistant.components.samsungtv import DOMAIN +from homeassistant.const import SERVICE_RELOAD +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from . import setup_samsungtv_entry +from .test_media_player import ENTITY_ID, MOCK_ENTRYDATA_ENCRYPTED_WS + +from tests.common import MockEntity, MockEntityPlatform + + +@pytest.mark.usefixtures("remoteencws", "rest_api") +async def test_turn_on_trigger_device_id( + hass: HomeAssistant, calls: list[ServiceCall], device_registry: dr.DeviceRegistry +) -> None: + """Test for turn_on triggers by device_id firing.""" + await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + + device = device_registry.async_get_device(identifiers={(DOMAIN, "any")}) + assert device, repr(device_registry.devices) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "samsungtv.turn_on", + "device_id": device.id, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": device.id, + "id": "{{ trigger.id }}", + }, + }, + }, + ], + }, + ) + + with patch("homeassistant.components.samsungtv.media_player.send_magic_packet"): + await hass.services.async_call( + "media_player", + "turn_on", + {"entity_id": ENTITY_ID}, + blocking=True, + ) + + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == device.id + assert calls[0].data["id"] == 0 + + with patch("homeassistant.config.load_yaml", return_value={}): + await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) + + calls.clear() + + with patch("homeassistant.components.samsungtv.media_player.send_magic_packet"): + await hass.services.async_call( + "media_player", + "turn_on", + {"entity_id": ENTITY_ID}, + blocking=True, + ) + + await hass.async_block_till_done() + assert len(calls) == 0 + + +@pytest.mark.usefixtures("remoteencws", "rest_api") +async def test_turn_on_trigger_entity_id( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: + """Test for turn_on triggers by entity_id firing.""" + await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "samsungtv.turn_on", + "entity_id": ENTITY_ID, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ENTITY_ID, + "id": "{{ trigger.id }}", + }, + }, + }, + ], + }, + ) + + with patch("homeassistant.components.samsungtv.media_player.send_magic_packet"): + await hass.services.async_call( + "media_player", + "turn_on", + {"entity_id": ENTITY_ID}, + blocking=True, + ) + + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == ENTITY_ID + assert calls[0].data["id"] == 0 + + +@pytest.mark.usefixtures("remoteencws", "rest_api") +async def test_wrong_trigger_platform_type( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test wrong trigger platform type.""" + await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "samsungtv.wrong_type", + "entity_id": ENTITY_ID, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ENTITY_ID, + "id": "{{ trigger.id }}", + }, + }, + }, + ], + }, + ) + + assert ( + "ValueError: Unknown Samsung TV trigger platform samsungtv.wrong_type" + in caplog.text + ) + + +@pytest.mark.usefixtures("remoteencws", "rest_api") +async def test_trigger_invalid_entity_id( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test turn on trigger using invalid entity_id.""" + await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + + platform = MockEntityPlatform(hass) + + invalid_entity = f"{DOMAIN}.invalid" + await platform.async_add_entities([MockEntity(name=invalid_entity)]) + + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "samsungtv.turn_on", + "entity_id": invalid_entity, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ENTITY_ID, + "id": "{{ trigger.id }}", + }, + }, + }, + ], + }, + ) + + assert ( + f"ValueError: Entity {invalid_entity} is not a valid samsungtv entity" + in caplog.text + ) From 6a01c3369d375d3e865c33a695608fb7a4dbd410 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 15 Mar 2023 12:56:01 +0100 Subject: [PATCH 0949/2810] Reolink auto quick reply (#89656) --- homeassistant/components/reolink/number.py | 13 +++++++++++++ homeassistant/components/reolink/select.py | 12 ++++++++++++ homeassistant/components/reolink/strings.json | 5 +++++ 3 files changed, 30 insertions(+) diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 616fc8b74c..cab925d41f 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -175,6 +175,19 @@ NUMBER_ENTITIES = ( value=lambda api, ch: api.ai_sensitivity(ch, "dog_cat"), method=lambda api, ch, value: api.set_ai_sensitivity(ch, int(value), "dog_cat"), ), + ReolinkNumberEntityDescription( + key="auto_quick_reply_time", + name="Auto quick reply time", + icon="mdi:message-reply-text-outline", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=1, + native_max_value=60, + supported=lambda api, ch: api.supported(ch, "quick_reply"), + value=lambda api, ch: api.quick_reply_time(ch), + method=lambda api, ch, value: api.set_quick_reply(ch, time=int(value)), + ), ) diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index c7bd621a4b..e18961c97d 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -67,6 +67,18 @@ SELECT_ENTITIES = ( supported=lambda api, ch: api.supported(ch, "ptz_presets"), method=lambda api, ch, name: api.set_ptz_command(ch, preset=name), ), + ReolinkSelectEntityDescription( + key="auto_quick_reply_message", + name="Auto quick reply message", + icon="mdi:message-reply-text-outline", + translation_key="auto_quick_reply_message", + get_options=lambda api, ch: list(api.quick_reply_dict(ch).values()), + supported=lambda api, ch: api.supported(ch, "quick_reply"), + value=lambda api, ch: api.quick_reply_dict(ch)[api.quick_reply_file(ch)], + method=lambda api, ch, mess: api.set_quick_reply( + ch, file_id=[k for k, v in api.quick_reply_dict(ch).items() if v == mess][0] + ), + ), ) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index c86917b4de..5047c4f271 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -63,6 +63,11 @@ "color": "Color", "blackwhite": "Black&White" } + }, + "auto_quick_reply_message": { + "state": { + "off": "Off" + } } } } From a244749712705c798e3f56278d8be5917506c364 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Mar 2023 02:54:02 -1000 Subject: [PATCH 0950/2810] Make StatesMetaManager thread-safe when an entity_id is fully deleted from the database and than re-added (#89732) * refactor to make StatesMetaManager threadsafe * refactor to make StatesMetaManager threadsafe * refactor to make StatesMetaManager threadsafe * refactor to make StatesMetaManager threadsafe * reduce * comments --- homeassistant/components/logbook/processor.py | 2 +- homeassistant/components/recorder/core.py | 2 +- .../components/recorder/history/modern.py | 8 +- .../components/recorder/migration.py | 4 +- .../recorder/table_managers/event_data.py | 60 ++++++++++--- .../recorder/table_managers/event_types.py | 48 +++++++++-- .../recorder/table_managers/states_meta.py | 86 +++++++++++++++---- tests/components/recorder/test_migrate.py | 2 +- tests/components/recorder/test_purge.py | 3 +- tests/components/recorder/test_util.py | 2 +- 10 files changed, 175 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index cd88dcb73a..e053dcb081 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -155,7 +155,7 @@ class EventProcessor: if self.entity_ids: instance = get_instance(self.hass) entity_id_to_metadata_id = instance.states_meta_manager.get_many( - self.entity_ids, session + self.entity_ids, session, False ) metadata_ids = [ metadata_id diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 3e4f11dc95..26cd5c3b88 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -1027,7 +1027,7 @@ class Recorder(threading.Thread): states_meta_manager = self.states_meta_manager if pending_states_meta := states_meta_manager.get_pending(entity_id): dbstate.states_meta_rel = pending_states_meta - elif metadata_id := states_meta_manager.get(entity_id, event_session): + elif metadata_id := states_meta_manager.get(entity_id, event_session, True): dbstate.metadata_id = metadata_id else: states_meta = StatesMeta(entity_id=entity_id) diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index 416a83e873..6ac139cc78 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -242,7 +242,7 @@ def get_significant_states_with_session( if entity_ids: instance = recorder.get_instance(hass) entity_id_to_metadata_id = instance.states_meta_manager.get_many( - entity_ids, session + entity_ids, session, False ) metadata_ids = [ metadata_id @@ -365,7 +365,7 @@ def state_changes_during_period( entity_id_to_metadata_id = None if entity_id: instance = recorder.get_instance(hass) - metadata_id = instance.states_meta_manager.get(entity_id, session) + metadata_id = instance.states_meta_manager.get(entity_id, session, False) entity_id_to_metadata_id = {entity_id: metadata_id} stmt = _state_changed_during_period_stmt( start_time, @@ -426,7 +426,9 @@ def get_last_state_changes( with session_scope(hass=hass, read_only=True) as session: instance = recorder.get_instance(hass) - if not (metadata_id := instance.states_meta_manager.get(entity_id, session)): + if not ( + metadata_id := instance.states_meta_manager.get(entity_id, session, False) + ): return {} entity_id_to_metadata_id: dict[str, int | None] = {entity_id_lower: metadata_id} stmt = _get_last_state_changes_stmt(number_of_states, metadata_id) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 74fbcf307f..d594118ea5 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1457,7 +1457,9 @@ def migrate_entity_ids(instance: Recorder) -> bool: with session_scope(session=instance.get_session()) as session: if states := session.execute(find_entity_ids_to_migrate()).all(): entity_ids = {entity_id for _, entity_id in states} - entity_id_to_metadata_id = states_meta_manager.get_many(entity_ids, session) + entity_id_to_metadata_id = states_meta_manager.get_many( + entity_ids, session, True + ) if missing_entity_ids := { # We should never see _EMPTY_ENTITY_ID in the states table # but we need to be defensive so we don't fail the migration diff --git a/homeassistant/components/recorder/table_managers/event_data.py b/homeassistant/components/recorder/table_managers/event_data.py index 975681b466..c877f08f87 100644 --- a/homeassistant/components/recorder/table_managers/event_data.py +++ b/homeassistant/components/recorder/table_managers/event_data.py @@ -47,7 +47,11 @@ class EventDataManager(BaseTableManager): return None def load(self, events: list[Event], session: Session) -> None: - """Load the shared_datas to data_ids mapping into memory from events.""" + """Load the shared_datas to data_ids mapping into memory from events. + + This call is not thread-safe and must be called from the + recorder thread. + """ if hashes := { EventData.hash_shared_data_bytes(shared_event_bytes) for event in events @@ -56,17 +60,29 @@ class EventDataManager(BaseTableManager): self._load_from_hashes(hashes, session) def get(self, shared_data: str, data_hash: int, session: Session) -> int | None: - """Resolve shared_datas to the data_id.""" + """Resolve shared_datas to the data_id. + + This call is not thread-safe and must be called from the + recorder thread. + """ return self.get_many(((shared_data, data_hash),), session)[shared_data] def get_from_cache(self, shared_data: str) -> int | None: - """Resolve shared_data to the data_id without accessing the underlying database.""" + """Resolve shared_data to the data_id without accessing the underlying database. + + This call is not thread-safe and must be called from the + recorder thread. + """ return self._id_map.get(shared_data) def get_many( self, shared_data_data_hashs: Iterable[tuple[str, int]], session: Session ) -> dict[str, int | None]: - """Resolve shared_datas to data_ids.""" + """Resolve shared_datas to data_ids. + + This call is not thread-safe and must be called from the + recorder thread. + """ results: dict[str, int | None] = {} missing_hashes: set[int] = set() for shared_data, data_hash in shared_data_data_hashs: @@ -83,7 +99,11 @@ class EventDataManager(BaseTableManager): def _load_from_hashes( self, hashes: Iterable[int], session: Session ) -> dict[str, int | None]: - """Load the shared_datas to data_ids mapping into memory from a list of hashes.""" + """Load the shared_datas to data_ids mapping into memory from a list of hashes. + + This call is not thread-safe and must be called from the + recorder thread. + """ results: dict[str, int | None] = {} with session.no_autoflush: for hashs_chunk in chunked(hashes, SQLITE_MAX_BIND_VARS): @@ -97,28 +117,48 @@ class EventDataManager(BaseTableManager): return results def get_pending(self, shared_data: str) -> EventData | None: - """Get pending EventData that have not be assigned ids yet.""" + """Get pending EventData that have not be assigned ids yet. + + This call is not thread-safe and must be called from the + recorder thread. + """ return self._pending.get(shared_data) def add_pending(self, db_event_data: EventData) -> None: - """Add a pending EventData that will be committed at the next interval.""" + """Add a pending EventData that will be committed at the next interval. + + This call is not thread-safe and must be called from the + recorder thread. + """ assert db_event_data.shared_data is not None shared_data: str = db_event_data.shared_data self._pending[shared_data] = db_event_data def post_commit_pending(self) -> None: - """Call after commit to load the data_ids of the new EventData into the LRU.""" + """Call after commit to load the data_ids of the new EventData into the LRU. + + This call is not thread-safe and must be called from the + recorder thread. + """ for shared_data, db_event_data in self._pending.items(): self._id_map[shared_data] = db_event_data.data_id self._pending.clear() def reset(self) -> None: - """Reset the event manager after the database has been reset or changed.""" + """Reset the event manager after the database has been reset or changed. + + This call is not thread-safe and must be called from the + recorder thread. + """ self._id_map.clear() self._pending.clear() def evict_purged(self, data_ids: set[int]) -> None: - """Evict purged data_ids from the cache when they are no longer used.""" + """Evict purged data_ids from the cache when they are no longer used. + + This call is not thread-safe and must be called from the + recorder thread. + """ id_map = self._id_map event_data_ids_reversed = { data_id: shared_data for shared_data, data_id in id_map.items() diff --git a/homeassistant/components/recorder/table_managers/event_types.py b/homeassistant/components/recorder/table_managers/event_types.py index cc7490f8d7..b31382336c 100644 --- a/homeassistant/components/recorder/table_managers/event_types.py +++ b/homeassistant/components/recorder/table_managers/event_types.py @@ -32,20 +32,32 @@ class EventTypeManager(BaseTableManager): super().__init__(recorder) def load(self, events: list[Event], session: Session) -> None: - """Load the event_type to event_type_ids mapping into memory.""" + """Load the event_type to event_type_ids mapping into memory. + + This call is not thread-safe and must be called from the + recorder thread. + """ self.get_many( {event.event_type for event in events if event.event_type is not None}, session, ) def get(self, event_type: str, session: Session) -> int | None: - """Resolve event_type to the event_type_id.""" + """Resolve event_type to the event_type_id. + + This call is not thread-safe and must be called from the + recorder thread. + """ return self.get_many((event_type,), session)[event_type] def get_many( self, event_types: Iterable[str], session: Session ) -> dict[str, int | None]: - """Resolve event_types to event_type_ids.""" + """Resolve event_types to event_type_ids. + + This call is not thread-safe and must be called from the + recorder thread. + """ results: dict[str, int | None] = {} missing: list[str] = [] for event_type in event_types: @@ -69,27 +81,47 @@ class EventTypeManager(BaseTableManager): return results def get_pending(self, event_type: str) -> EventTypes | None: - """Get pending EventTypes that have not be assigned ids yet.""" + """Get pending EventTypes that have not be assigned ids yet. + + This call is not thread-safe and must be called from the + recorder thread. + """ return self._pending.get(event_type) def add_pending(self, db_event_type: EventTypes) -> None: - """Add a pending EventTypes that will be committed at the next interval.""" + """Add a pending EventTypes that will be committed at the next interval. + + This call is not thread-safe and must be called from the + recorder thread. + """ assert db_event_type.event_type is not None event_type: str = db_event_type.event_type self._pending[event_type] = db_event_type def post_commit_pending(self) -> None: - """Call after commit to load the event_type_ids of the new EventTypes into the LRU.""" + """Call after commit to load the event_type_ids of the new EventTypes into the LRU. + + This call is not thread-safe and must be called from the + recorder thread. + """ for event_type, db_event_types in self._pending.items(): self._id_map[event_type] = db_event_types.event_type_id self._pending.clear() def reset(self) -> None: - """Reset the event manager after the database has been reset or changed.""" + """Reset the event manager after the database has been reset or changed. + + This call is not thread-safe and must be called from the + recorder thread. + """ self._id_map.clear() self._pending.clear() def evict_purged(self, event_types: Iterable[str]) -> None: - """Evict purged event_types from the cache when they are no longer used.""" + """Evict purged event_types from the cache when they are no longer used. + + This call is not thread-safe and must be called from the + recorder thread. + """ for event_type in event_types: self._id_map.pop(event_type, None) diff --git a/homeassistant/components/recorder/table_managers/states_meta.py b/homeassistant/components/recorder/table_managers/states_meta.py index 0352587aa6..ded1690df1 100644 --- a/homeassistant/components/recorder/table_managers/states_meta.py +++ b/homeassistant/components/recorder/table_managers/states_meta.py @@ -28,10 +28,16 @@ class StatesMetaManager(BaseTableManager): """Initialize the states meta manager.""" self._id_map: dict[str, int] = LRU(CACHE_SIZE) self._pending: dict[str, StatesMeta] = {} + self._did_first_load = False super().__init__(recorder) def load(self, events: list[Event], session: Session) -> None: - """Load the entity_id to metadata_id mapping into memory.""" + """Load the entity_id to metadata_id mapping into memory. + + This call is not thread-safe and must be called from the + recorder thread. + """ + self._did_first_load = True self.get_many( { event.data["new_state"].entity_id @@ -39,21 +45,41 @@ class StatesMetaManager(BaseTableManager): if event.data.get("new_state") is not None }, session, + True, ) - def get(self, entity_id: str, session: Session) -> int | None: - """Resolve entity_id to the metadata_id.""" - return self.get_many((entity_id,), session)[entity_id] + def get(self, entity_id: str, session: Session, from_recorder: bool) -> int | None: + """Resolve entity_id to the metadata_id. + + This call is not thread-safe after startup since + purge can remove all references to an entity_id. + + When calling this method from the recorder thread, set + from_recorder to True to ensure any missing entity_ids + are added to the cache. + """ + return self.get_many((entity_id,), session, from_recorder)[entity_id] def get_metadata_id_to_entity_id(self, session: Session) -> dict[int, str]: - """Resolve all entity_ids to metadata_ids.""" + """Resolve all entity_ids to metadata_ids. + + This call is always thread-safe. + """ with session.no_autoflush: return dict(tuple(session.execute(find_all_states_metadata_ids()))) # type: ignore[arg-type] def get_many( - self, entity_ids: Iterable[str], session: Session + self, entity_ids: Iterable[str], session: Session, from_recorder: bool ) -> dict[str, int | None]: - """Resolve entity_id to metadata_id.""" + """Resolve entity_id to metadata_id. + + This call is not thread-safe after startup since + purge can remove all references to an entity_id. + + When calling this method from the recorder thread, set + from_recorder to True to ensure any missing entity_ids + are added to the cache. + """ results: dict[str, int | None] = {} missing: list[str] = [] for entity_id in entity_ids: @@ -65,39 +91,69 @@ class StatesMetaManager(BaseTableManager): if not missing: return results + # Only update the cache if we are in the recorder thread + # or the recorder event loop has not started yet since + # there is a chance that we could have just deleted all + # instances of an entity_id from the database via purge + # and we do not want to add it back to the cache from another + # thread (history query). + update_cache = from_recorder or not self._did_first_load + with session.no_autoflush: for missing_chunk in chunked(missing, SQLITE_MAX_BIND_VARS): for metadata_id, entity_id in session.execute( find_states_metadata_ids(missing_chunk) ): - results[entity_id] = self._id_map[entity_id] = cast( - int, metadata_id - ) + metadata_id = cast(int, metadata_id) + results[entity_id] = metadata_id + + if update_cache: + self._id_map[entity_id] = metadata_id return results def get_pending(self, entity_id: str) -> StatesMeta | None: - """Get pending StatesMeta that have not be assigned ids yet.""" + """Get pending StatesMeta that have not be assigned ids yet. + + This call is not thread-safe and must be called from the + recorder thread. + """ return self._pending.get(entity_id) def add_pending(self, db_states_meta: StatesMeta) -> None: - """Add a pending StatesMeta that will be committed at the next interval.""" + """Add a pending StatesMeta that will be committed at the next interval. + + This call is not thread-safe and must be called from the + recorder thread. + """ assert db_states_meta.entity_id is not None entity_id: str = db_states_meta.entity_id self._pending[entity_id] = db_states_meta def post_commit_pending(self) -> None: - """Call after commit to load the metadata_ids of the new StatesMeta into the LRU.""" + """Call after commit to load the metadata_ids of the new StatesMeta into the LRU. + + This call is not thread-safe and must be called from the + recorder thread. + """ for entity_id, db_states_meta in self._pending.items(): self._id_map[entity_id] = db_states_meta.metadata_id self._pending.clear() def reset(self) -> None: - """Reset the states meta manager after the database has been reset or changed.""" + """Reset the states meta manager after the database has been reset or changed. + + This call is not thread-safe and must be called from the + recorder thread. + """ self._id_map.clear() self._pending.clear() def evict_purged(self, entity_ids: Iterable[str]) -> None: - """Evict purged event_types from the cache when they are no longer used.""" + """Evict purged event_types from the cache when they are no longer used. + + This call is not thread-safe and must be called from the + recorder thread. + """ for entity_id in entity_ids: self._id_map.pop(entity_id, None) diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index c9d0be5973..e030ef1629 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -59,7 +59,7 @@ ORIG_TZ = dt_util.DEFAULT_TIME_ZONE def _get_native_states(hass, entity_id): with session_scope(hass=hass) as session: instance = recorder.get_instance(hass) - metadata_id = instance.states_meta_manager.get(entity_id, session) + metadata_id = instance.states_meta_manager.get(entity_id, session, True) states = [] for dbstate in session.query(States).filter(States.metadata_id == metadata_id): dbstate.entity_id = entity_id diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index 6594f0352a..3411c1eb30 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -684,7 +684,7 @@ def _convert_pending_states_to_meta(instance: Recorder, session: Session) -> Non states.add(object) entity_id_to_metadata_ids = instance.states_meta_manager.get_many( - entity_ids, session + entity_ids, session, True ) for state in states: @@ -1974,6 +1974,7 @@ async def test_purge_old_states_purges_the_state_metadata_ids( return instance.states_meta_manager.get_many( ["sensor.one", "sensor.two", "sensor.three", "sensor.unused"], session, + True, ) entity_id_to_metadata_id = await instance.async_add_executor_job(_insert_states) diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 38622bd45a..6aad3e440d 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -908,7 +908,7 @@ def test_execute_stmt_lambda_element( with session_scope(hass=hass) as session: # No time window, we always get a list - metadata_id = instance.states_meta_manager.get("sensor.on", session) + metadata_id = instance.states_meta_manager.get("sensor.on", session, True) stmt = _get_single_entity_states_stmt(dt_util.utcnow(), metadata_id, False) rows = util.execute_stmt_lambda_element(session, stmt) assert isinstance(rows, list) From 6e5b4f9f827ec063e398abf00b8fb56fac9ec7d3 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 15 Mar 2023 14:09:14 +0100 Subject: [PATCH 0951/2810] Add modbus hvac_* write registers (#89695) --- homeassistant/components/modbus/__init__.py | 3 ++ homeassistant/components/modbus/climate.py | 43 +++++++++++++++------ homeassistant/components/modbus/const.py | 1 + 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 043d7375ae..e8c5346976 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -105,6 +105,7 @@ from .const import ( # noqa: F401 CONF_SWAP_WORD_BYTE, CONF_TARGET_TEMP, CONF_VERIFY, + CONF_WRITE_REGISTERS, CONF_WRITE_TYPE, CONF_ZERO_SUPPRESS, DEFAULT_HUB, @@ -232,6 +233,7 @@ CLIMATE_SCHEMA = vol.All( vol.Optional(CONF_STEP, default=0.5): vol.Coerce(float), vol.Optional(CONF_TEMPERATURE_UNIT, default=DEFAULT_TEMP_UNIT): cv.string, vol.Optional(CONF_HVAC_ONOFF_REGISTER): cv.positive_int, + vol.Optional(CONF_WRITE_REGISTERS, default=False): cv.boolean, vol.Optional(CONF_HVAC_MODE_REGISTER): vol.Maybe( { CONF_ADDRESS: cv.positive_int, @@ -244,6 +246,7 @@ CLIMATE_SCHEMA = vol.All( vol.Optional(CONF_HVAC_MODE_DRY): cv.positive_int, vol.Optional(CONF_HVAC_MODE_FAN_ONLY): cv.positive_int, }, + vol.Optional(CONF_WRITE_REGISTERS, default=False): cv.boolean, } ), } diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 5573ef0b7e..0a8b8dabee 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -45,6 +45,7 @@ from .const import ( CONF_MIN_TEMP, CONF_STEP, CONF_TARGET_TEMP, + CONF_WRITE_REGISTERS, DataType, ) from .modbus import ModbusHub @@ -106,6 +107,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._attr_hvac_modes = cast(list[HVACMode], []) self._attr_hvac_mode = None self._hvac_mode_mapping: list[tuple[int, HVACMode]] = [] + self._hvac_mode_write_type = mode_config[CONF_WRITE_REGISTERS] mode_value_config = mode_config[CONF_HVAC_MODE_VALUES] for hvac_mode_kw, hvac_mode in ( @@ -131,6 +133,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): if CONF_HVAC_ONOFF_REGISTER in config: self._hvac_onoff_register = config[CONF_HVAC_ONOFF_REGISTER] + self._hvac_onoff_write_type = config[CONF_WRITE_REGISTERS] if HVACMode.OFF not in self._attr_hvac_modes: self._attr_hvac_modes.append(HVACMode.OFF) else: @@ -147,23 +150,39 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): """Set new target hvac mode.""" if self._hvac_onoff_register is not None: # Turn HVAC Off by writing 0 to the On/Off register, or 1 otherwise. - await self._hub.async_pymodbus_call( - self._slave, - self._hvac_onoff_register, - 0 if hvac_mode == HVACMode.OFF else 1, - CALL_TYPE_WRITE_REGISTER, - ) + if self._hvac_onoff_write_type: + await self._hub.async_pymodbus_call( + self._slave, + self._hvac_onoff_register, + [0 if hvac_mode == HVACMode.OFF else 1], + CALL_TYPE_WRITE_REGISTERS, + ) + else: + await self._hub.async_pymodbus_call( + self._slave, + self._hvac_onoff_register, + 0 if hvac_mode == HVACMode.OFF else 1, + CALL_TYPE_WRITE_REGISTER, + ) if self._hvac_mode_register is not None: # Write a value to the mode register for the desired mode. for value, mode in self._hvac_mode_mapping: if mode == hvac_mode: - await self._hub.async_pymodbus_call( - self._slave, - self._hvac_mode_register, - value, - CALL_TYPE_WRITE_REGISTER, - ) + if self._hvac_mode_write_type: + await self._hub.async_pymodbus_call( + self._slave, + self._hvac_mode_register, + [value], + CALL_TYPE_WRITE_REGISTERS, + ) + else: + await self._hub.async_pymodbus_call( + self._slave, + self._hvac_mode_register, + value, + CALL_TYPE_WRITE_REGISTER, + ) break await self.async_update() diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index b7fcfee905..4191e1df56 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -65,6 +65,7 @@ CONF_HVAC_MODE_HEAT_COOL = "state_heat_cool" CONF_HVAC_MODE_AUTO = "state_auto" CONF_HVAC_MODE_DRY = "state_dry" CONF_HVAC_MODE_FAN_ONLY = "state_fan_only" +CONF_WRITE_REGISTERS = "write_registers" CONF_VERIFY = "verify" CONF_VERIFY_REGISTER = "verify_register" CONF_VERIFY_STATE = "verify_state" From 4d3799a9de1116de8ac202ef970dcbcdba330a97 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 15 Mar 2023 14:22:16 +0100 Subject: [PATCH 0952/2810] Make CalendarEntityFeature an IntFlag (#89733) --- homeassistant/components/calendar/const.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/calendar/const.py b/homeassistant/components/calendar/const.py index aa47cb3592..3fbab6742a 100644 --- a/homeassistant/components/calendar/const.py +++ b/homeassistant/components/calendar/const.py @@ -1,11 +1,11 @@ """Constants for calendar components.""" -from enum import IntEnum +from enum import IntFlag CONF_EVENT = "event" -class CalendarEntityFeature(IntEnum): +class CalendarEntityFeature(IntFlag): """Supported features of the calendar entity.""" CREATE_EVENT = 1 From 8cbb1e542f5ebbfe7d41e772dbacc9ac8d0d4b5e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 15 Mar 2023 17:14:27 +0100 Subject: [PATCH 0953/2810] Address late feedback for SamsungTV (#89751) --- homeassistant/components/samsungtv/media_player.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index ea31cca3e9..3fac14a82c 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -128,8 +128,9 @@ class SamsungTVDevice(MediaPlayerEntity): self._app_list_event: asyncio.Event = asyncio.Event() self._attr_supported_features = SUPPORT_SAMSUNGTV - if self._turn_on or self._on_script or self._mac: - # Add turn-on if turn_on trigger or on_script YAML or mac is available + if self._on_script or self._mac: + # (deprecated) add turn-on if on_script YAML or mac is available + # Triggers have not yet been registered so this is adjusted in the property self._attr_supported_features |= MediaPlayerEntityFeature.TURN_ON if self._ssdp_rendering_control_location: self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_SET @@ -157,6 +158,15 @@ class SamsungTVDevice(MediaPlayerEntity): self._dmr_device: DmrDevice | None = None self._upnp_server: AiohttpNotifyServer | None = None + @property + def supported_features(self) -> MediaPlayerEntityFeature: + """Flag media player features that are supported.""" + # `turn_on` triggers are not yet registered during initialisation, + # so this property needs to be dynamic + if self._turn_on: + return self._attr_supported_features | MediaPlayerEntityFeature.TURN_ON + return self._attr_supported_features + def _update_sources(self) -> None: self._attr_source_list = list(SOURCES) if app_list := self._app_list: From 6b768b90b4730d4d25d3b5433de190f63df48cfc Mon Sep 17 00:00:00 2001 From: StefanIacobLivisi <109964424+StefanIacobLivisi@users.noreply.github.com> Date: Wed, 15 Mar 2023 19:05:45 +0200 Subject: [PATCH 0954/2810] Bump aiolivisi to 0.0.19 (#89752) --- homeassistant/components/livisi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/livisi/manifest.json b/homeassistant/components/livisi/manifest.json index 5b5facd445..e6f46324ed 100644 --- a/homeassistant/components/livisi/manifest.json +++ b/homeassistant/components/livisi/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/livisi", "iot_class": "local_polling", - "requirements": ["aiolivisi==0.0.16"] + "requirements": ["aiolivisi==0.0.19"] } diff --git a/requirements_all.txt b/requirements_all.txt index 526713c3a5..63117f0be9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -202,7 +202,7 @@ aiolifx_effects==0.3.1 aiolifx_themes==0.4.0 # homeassistant.components.livisi -aiolivisi==0.0.16 +aiolivisi==0.0.19 # homeassistant.components.lookin aiolookin==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 409b6d9257..65909e2ca8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -186,7 +186,7 @@ aiolifx_effects==0.3.1 aiolifx_themes==0.4.0 # homeassistant.components.livisi -aiolivisi==0.0.16 +aiolivisi==0.0.19 # homeassistant.components.lookin aiolookin==1.0.0 From b588b8b215b820268fc0db135bd21589203809de Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 15 Mar 2023 10:06:46 -0700 Subject: [PATCH 0955/2810] Bump ical to 4.5.0 (#89744) --- .../components/local_calendar/calendar.py | 31 ++++++++++++------- .../components/local_calendar/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../local_calendar/test_calendar.py | 20 ++++++++++++ 5 files changed, 42 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index 88737150c0..9cb6878ca5 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -9,7 +9,7 @@ from typing import Any from ical.calendar import Calendar from ical.calendar_stream import IcsCalendarStream from ical.event import Event -from ical.store import EventStore +from ical.store import EventStore, EventStoreError from ical.types import Range, Recur from pydantic import ValidationError import voluptuous as vol @@ -24,6 +24,7 @@ from homeassistant.components.calendar import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util @@ -119,11 +120,14 @@ class LocalCalendarEntity(CalendarEntity): range_value: Range = Range.NONE if recurrence_range == Range.THIS_AND_FUTURE: range_value = Range.THIS_AND_FUTURE - EventStore(self._calendar).delete( - uid, - recurrence_id=recurrence_id, - recurrence_range=range_value, - ) + try: + EventStore(self._calendar).delete( + uid, + recurrence_id=recurrence_id, + recurrence_range=range_value, + ) + except EventStoreError as err: + raise HomeAssistantError("Error while deleting event: {err}") from err await self._async_store() await self.async_update_ha_state(force_refresh=True) @@ -139,12 +143,15 @@ class LocalCalendarEntity(CalendarEntity): range_value: Range = Range.NONE if recurrence_range == Range.THIS_AND_FUTURE: range_value = Range.THIS_AND_FUTURE - EventStore(self._calendar).edit( - uid, - new_event, - recurrence_id=recurrence_id, - recurrence_range=range_value, - ) + try: + EventStore(self._calendar).edit( + uid, + new_event, + recurrence_id=recurrence_id, + recurrence_range=range_value, + ) + except EventStoreError as err: + raise HomeAssistantError("Error while updating event: {err}") from err await self._async_store() await self.async_update_ha_state(force_refresh=True) diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index a1659a9ba3..42cd7fcf5a 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==4.2.9"] + "requirements": ["ical==4.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 63117f0be9..a946d316c1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -952,7 +952,7 @@ ibm-watson==5.2.2 ibmiotf==0.3.4 # homeassistant.components.local_calendar -ical==4.2.9 +ical==4.5.0 # homeassistant.components.ping icmplib==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 65909e2ca8..55b9c03c50 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -723,7 +723,7 @@ iaqualink==0.5.0 ibeacon_ble==1.0.1 # homeassistant.components.local_calendar -ical==4.2.9 +ical==4.5.0 # homeassistant.components.ping icmplib==3.0 diff --git a/tests/components/local_calendar/test_calendar.py b/tests/components/local_calendar/test_calendar.py index a859ed1d90..319a352f62 100644 --- a/tests/components/local_calendar/test_calendar.py +++ b/tests/components/local_calendar/test_calendar.py @@ -829,6 +829,26 @@ async def test_update_invalid_event_id( assert resp["error"].get("code") == "failed" +async def test_delete_invalid_event_id( + ws_client: ClientFixture, + setup_integration: None, + hass: HomeAssistant, +) -> None: + """Test deleting an event with an invalid event uid.""" + client = await ws_client() + resp = await client.cmd( + "delete", + { + "entity_id": TEST_ENTITY, + "uid": "uid-does-not-exist", + }, + ) + assert resp + assert not resp.get("success") + assert "error" in resp + assert resp["error"].get("code") == "failed" + + @pytest.mark.parametrize( ("start_date_time", "end_date_time"), [ From 35c02ddc8107944f889c4cfca7f147f9e06929cb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 15 Mar 2023 18:07:43 +0100 Subject: [PATCH 0956/2810] Add type hints to update coordinator tests (#89748) --- tests/helpers/test_update_coordinator.py | 83 +++++++++++++++++------- 1 file changed, 58 insertions(+), 25 deletions(-) diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 9f904f0802..94a9dfd6b9 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -20,10 +20,10 @@ from tests.common import MockConfigEntry, async_fire_time_changed _LOGGER = logging.getLogger(__name__) -KNOWN_ERRORS = [ - (asyncio.TimeoutError, asyncio.TimeoutError, "Timeout fetching test data"), +KNOWN_ERRORS: list[tuple[Exception, type[Exception], str]] = [ + (asyncio.TimeoutError(), asyncio.TimeoutError, "Timeout fetching test data"), ( - requests.exceptions.Timeout, + requests.exceptions.Timeout(), requests.exceptions.Timeout, "Timeout fetching test data", ), @@ -32,9 +32,9 @@ KNOWN_ERRORS = [ urllib.error.URLError, "Timeout fetching test data", ), - (aiohttp.ClientError, aiohttp.ClientError, "Error requesting test data"), + (aiohttp.ClientError(), aiohttp.ClientError, "Error requesting test data"), ( - requests.exceptions.RequestException, + requests.exceptions.RequestException(), requests.exceptions.RequestException, "Error requesting test data", ), @@ -44,14 +44,16 @@ KNOWN_ERRORS = [ "Error requesting test data", ), ( - update_coordinator.UpdateFailed, + update_coordinator.UpdateFailed(), update_coordinator.UpdateFailed, "Error fetching test data", ), ] -def get_crd(hass, update_interval): +def get_crd( + hass: HomeAssistant, update_interval: timedelta | None +) -> update_coordinator.DataUpdateCoordinator[int]: """Make coordinator mocks.""" calls = 0 @@ -74,18 +76,22 @@ DEFAULT_UPDATE_INTERVAL = timedelta(seconds=10) @pytest.fixture -def crd(hass): +def crd(hass: HomeAssistant) -> update_coordinator.DataUpdateCoordinator[int]: """Coordinator mock with default update interval.""" return get_crd(hass, DEFAULT_UPDATE_INTERVAL) @pytest.fixture -def crd_without_update_interval(hass): +def crd_without_update_interval( + hass: HomeAssistant, +) -> update_coordinator.DataUpdateCoordinator[int]: """Coordinator mock that never automatically updates.""" return get_crd(hass, None) -async def test_async_refresh(crd) -> None: +async def test_async_refresh( + crd: update_coordinator.DataUpdateCoordinator[int], +) -> None: """Test async_refresh for update coordinator.""" assert crd.data is None await crd.async_refresh() @@ -110,7 +116,9 @@ async def test_async_refresh(crd) -> None: assert updates == [2] -async def test_update_context(crd: update_coordinator.DataUpdateCoordinator[int]): +async def test_update_context( + crd: update_coordinator.DataUpdateCoordinator[int], +) -> None: """Test update contexts for the update coordinator.""" await crd.async_refresh() assert not set(crd.async_contexts()) @@ -134,7 +142,9 @@ async def test_update_context(crd: update_coordinator.DataUpdateCoordinator[int] assert not set(crd.async_contexts()) -async def test_request_refresh(crd) -> None: +async def test_request_refresh( + crd: update_coordinator.DataUpdateCoordinator[int], +) -> None: """Test request refresh for update coordinator.""" assert crd.data is None await crd.async_request_refresh() @@ -150,7 +160,9 @@ async def test_request_refresh(crd) -> None: crd._unschedule_refresh() -async def test_request_refresh_no_auto_update(crd_without_update_interval) -> None: +async def test_request_refresh_no_auto_update( + crd_without_update_interval: update_coordinator.DataUpdateCoordinator[int], +) -> None: """Test request refresh for update coordinator without automatic update.""" crd = crd_without_update_interval assert crd.data is None @@ -172,7 +184,9 @@ async def test_request_refresh_no_auto_update(crd_without_update_interval) -> No KNOWN_ERRORS, ) async def test_refresh_known_errors( - err_msg, crd, caplog: pytest.LogCaptureFixture + err_msg: tuple[Exception, type[Exception], str], + crd: update_coordinator.DataUpdateCoordinator[int], + caplog: pytest.LogCaptureFixture, ) -> None: """Test raising known errors.""" crd.update_method = AsyncMock(side_effect=err_msg[0]) @@ -185,7 +199,9 @@ async def test_refresh_known_errors( assert err_msg[2] in caplog.text -async def test_refresh_fail_unknown(crd, caplog: pytest.LogCaptureFixture) -> None: +async def test_refresh_fail_unknown( + crd: update_coordinator.DataUpdateCoordinator[int], caplog: pytest.LogCaptureFixture +) -> None: """Test raising unknown error.""" await crd.async_refresh() @@ -198,7 +214,9 @@ async def test_refresh_fail_unknown(crd, caplog: pytest.LogCaptureFixture) -> No assert "Unexpected error fetching test data" in caplog.text -async def test_refresh_no_update_method(crd) -> None: +async def test_refresh_no_update_method( + crd: update_coordinator.DataUpdateCoordinator[int], +) -> None: """Test raising error is no update method is provided.""" await crd.async_refresh() @@ -208,7 +226,9 @@ async def test_refresh_no_update_method(crd) -> None: await crd.async_refresh() -async def test_update_interval(hass: HomeAssistant, crd) -> None: +async def test_update_interval( + hass: HomeAssistant, crd: update_coordinator.DataUpdateCoordinator[int] +) -> None: """Test update interval works.""" # Test we don't update without subscriber async_fire_time_changed(hass, utcnow() + crd.update_interval) @@ -239,7 +259,8 @@ async def test_update_interval(hass: HomeAssistant, crd) -> None: async def test_update_interval_not_present( - hass: HomeAssistant, crd_without_update_interval + hass: HomeAssistant, + crd_without_update_interval: update_coordinator.DataUpdateCoordinator[int], ) -> None: """Test update never happens with no update interval.""" crd = crd_without_update_interval @@ -271,7 +292,9 @@ async def test_update_interval_not_present( assert crd.data is None -async def test_refresh_recover(crd, caplog: pytest.LogCaptureFixture) -> None: +async def test_refresh_recover( + crd: update_coordinator.DataUpdateCoordinator[int], caplog: pytest.LogCaptureFixture +) -> None: """Test recovery of freshing data.""" crd.last_update_success = False @@ -281,7 +304,9 @@ async def test_refresh_recover(crd, caplog: pytest.LogCaptureFixture) -> None: assert "Fetching test data recovered" in caplog.text -async def test_coordinator_entity(crd: update_coordinator.DataUpdateCoordinator[int]): +async def test_coordinator_entity( + crd: update_coordinator.DataUpdateCoordinator[int], +) -> None: """Test the CoordinatorEntity class.""" context = object() entity = update_coordinator.CoordinatorEntity(crd, context) @@ -316,7 +341,9 @@ async def test_coordinator_entity(crd: update_coordinator.DataUpdateCoordinator[ assert len(crd._listeners) == 0 -async def test_async_set_updated_data(crd) -> None: +async def test_async_set_updated_data( + crd: update_coordinator.DataUpdateCoordinator[int], +) -> None: """Test async_set_updated_data for update coordinator.""" assert crd.data is None @@ -350,7 +377,9 @@ async def test_async_set_updated_data(crd) -> None: assert crd._unsub_refresh is not old_refresh -async def test_stop_refresh_on_ha_stop(hass: HomeAssistant, crd) -> None: +async def test_stop_refresh_on_ha_stop( + hass: HomeAssistant, crd: update_coordinator.DataUpdateCoordinator[int] +) -> None: """Test no update interval refresh when Home Assistant is stopping.""" # Add subscriber update_callback = Mock() @@ -388,7 +417,9 @@ async def test_stop_refresh_on_ha_stop(hass: HomeAssistant, crd) -> None: KNOWN_ERRORS, ) async def test_async_config_entry_first_refresh_failure( - err_msg, crd, caplog: pytest.LogCaptureFixture + err_msg: tuple[Exception, type[Exception], str], + crd: update_coordinator.DataUpdateCoordinator[int], + caplog: pytest.LogCaptureFixture, ) -> None: """Test async_config_entry_first_refresh raises ConfigEntryNotReady on failure. @@ -407,7 +438,7 @@ async def test_async_config_entry_first_refresh_failure( async def test_async_config_entry_first_refresh_success( - crd, caplog: pytest.LogCaptureFixture + crd: update_coordinator.DataUpdateCoordinator[int], caplog: pytest.LogCaptureFixture ) -> None: """Test first refresh successfully.""" await crd.async_config_entry_first_refresh() @@ -426,7 +457,9 @@ async def test_not_schedule_refresh_if_system_option_disable_polling( assert crd._unsub_refresh is None -async def test_async_set_update_error(crd, caplog: pytest.LogCaptureFixture) -> None: +async def test_async_set_update_error( + crd: update_coordinator.DataUpdateCoordinator[int], caplog: pytest.LogCaptureFixture +) -> None: """Test manually setting an update failure.""" update_callback = Mock() crd.async_add_listener(update_callback) From cb74b934dcde99a149eb85148e9f850990ca6aa4 Mon Sep 17 00:00:00 2001 From: PatrickGlesner <34370149+PatrickGlesner@users.noreply.github.com> Date: Wed, 15 Mar 2023 18:09:39 +0100 Subject: [PATCH 0957/2810] Fix NMBS IndexError (#89698) --- homeassistant/components/nmbs/sensor.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index b9a216875f..c3bcdb3553 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -162,7 +162,11 @@ class NMBSLiveBoard(SensorEntity): """Set the state equal to the next departure.""" liveboard = self._api_client.get_liveboard(self._station) - if liveboard is None or not liveboard.get("departures"): + if ( + liveboard is None + or not liveboard.get("departures") + or liveboard.get("number") == "0" + ): return next_departure = liveboard["departures"]["departure"][0] From c416d185061bd5eaabfe1af7348f6845c1c5313b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 15 Mar 2023 18:49:57 +0100 Subject: [PATCH 0958/2810] Add WAN information to SFR Box (#89678) --- homeassistant/components/sfr_box/__init__.py | 14 +++-- .../components/sfr_box/binary_sensor.py | 22 ++++++-- .../components/sfr_box/diagnostics.py | 6 +- homeassistant/components/sfr_box/models.py | 3 +- homeassistant/components/sfr_box/sensor.py | 24 +++++++- homeassistant/components/sfr_box/strings.json | 9 +++ .../sfr_box/snapshots/test_binary_sensor.ambr | 41 ++++++++++++++ .../sfr_box/snapshots/test_diagnostics.ambr | 2 +- .../sfr_box/snapshots/test_sensor.ambr | 56 +++++++++++++++++++ .../components/sfr_box/test_binary_sensor.py | 2 +- tests/components/sfr_box/test_button.py | 2 +- tests/components/sfr_box/test_init.py | 2 +- tests/components/sfr_box/test_sensor.py | 2 +- 13 files changed, 165 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/sfr_box/__init__.py b/homeassistant/components/sfr_box/__init__.py index 4873acf753..b4014c159a 100644 --- a/homeassistant/components/sfr_box/__init__.py +++ b/homeassistant/components/sfr_box/__init__.py @@ -1,11 +1,13 @@ """SFR Box.""" from __future__ import annotations +import asyncio + from sfrbox_api.bridge import SFRBox from sfrbox_api.exceptions import SFRBoxAuthenticationError, SFRBoxError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr @@ -37,15 +39,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: system=SFRDataUpdateCoordinator( hass, box, "system", lambda b: b.system_get_info() ), + wan=SFRDataUpdateCoordinator(hass, box, "wan", lambda b: b.wan_get_info()), ) + # Preload system information await data.system.async_config_entry_first_refresh() system_info = data.system.data + # Preload other coordinators (based on net infrastructure) + tasks = [data.wan.async_config_entry_first_refresh()] if system_info.net_infra == "adsl": - await data.dsl.async_config_entry_first_refresh() - else: - platforms = list(platforms) - platforms.remove(Platform.BINARY_SENSOR) + tasks.append(data.dsl.async_config_entry_first_refresh()) + await asyncio.gather(*tasks) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = data diff --git a/homeassistant/components/sfr_box/binary_sensor.py b/homeassistant/components/sfr_box/binary_sensor.py index 8b3883c6ee..83c7cd8d10 100644 --- a/homeassistant/components/sfr_box/binary_sensor.py +++ b/homeassistant/components/sfr_box/binary_sensor.py @@ -5,7 +5,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Generic, TypeVar -from sfrbox_api.models import DslInfo, SystemInfo +from sfrbox_api.models import DslInfo, SystemInfo, WanInfo from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -48,6 +48,15 @@ DSL_SENSOR_TYPES: tuple[SFRBoxBinarySensorEntityDescription[DslInfo], ...] = ( value_fn=lambda x: x.status == "up", ), ) +WAN_SENSOR_TYPES: tuple[SFRBoxBinarySensorEntityDescription[WanInfo], ...] = ( + SFRBoxBinarySensorEntityDescription[WanInfo]( + key="status", + name="WAN status", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda x: x.status == "up", + ), +) async def async_setup_entry( @@ -56,10 +65,15 @@ async def async_setup_entry( """Set up the sensors.""" data: DomainData = hass.data[DOMAIN][entry.entry_id] - entities = [ - SFRBoxBinarySensor(data.dsl, description, data.system.data) - for description in DSL_SENSOR_TYPES + entities: list[SFRBoxBinarySensor] = [ + SFRBoxBinarySensor(data.wan, description, data.system.data) + for description in WAN_SENSOR_TYPES ] + if data.system.data.net_infra == "adsl": + entities.extend( + SFRBoxBinarySensor(data.dsl, description, data.system.data) + for description in DSL_SENSOR_TYPES + ) async_add_entities(entities) diff --git a/homeassistant/components/sfr_box/diagnostics.py b/homeassistant/components/sfr_box/diagnostics.py index 60df217396..e85bd602b7 100644 --- a/homeassistant/components/sfr_box/diagnostics.py +++ b/homeassistant/components/sfr_box/diagnostics.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN from .models import DomainData -TO_REDACT = {"mac_addr", "serial_number"} +TO_REDACT = {"mac_addr", "serial_number", "ip_addr", "ipv6_addr"} async def async_get_config_entry_diagnostics( @@ -33,8 +33,6 @@ async def async_get_config_entry_diagnostics( "system": async_redact_data( dataclasses.asdict(data.system.data), TO_REDACT ), - "wan": async_redact_data( - dataclasses.asdict(await data.system.box.wan_get_info()), TO_REDACT - ), + "wan": async_redact_data(dataclasses.asdict(data.wan.data), TO_REDACT), }, } diff --git a/homeassistant/components/sfr_box/models.py b/homeassistant/components/sfr_box/models.py index e2f86aeb92..9302de83e7 100644 --- a/homeassistant/components/sfr_box/models.py +++ b/homeassistant/components/sfr_box/models.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from sfrbox_api.bridge import SFRBox -from sfrbox_api.models import DslInfo, SystemInfo +from sfrbox_api.models import DslInfo, SystemInfo, WanInfo from .coordinator import SFRDataUpdateCoordinator @@ -14,3 +14,4 @@ class DomainData: box: SFRBox dsl: SFRDataUpdateCoordinator[DslInfo] system: SFRDataUpdateCoordinator[SystemInfo] + wan: SFRDataUpdateCoordinator[WanInfo] diff --git a/homeassistant/components/sfr_box/sensor.py b/homeassistant/components/sfr_box/sensor.py index 48d1c16d0f..d276d30821 100644 --- a/homeassistant/components/sfr_box/sensor.py +++ b/homeassistant/components/sfr_box/sensor.py @@ -3,7 +3,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Generic, TypeVar -from sfrbox_api.models import DslInfo, SystemInfo +from sfrbox_api.models import DslInfo, SystemInfo, WanInfo from homeassistant.components.sensor import ( SensorDeviceClass, @@ -195,6 +195,24 @@ SYSTEM_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[SystemInfo], ...] = ( value_fn=lambda x: x.temperature / 1000, ), ) +WAN_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[WanInfo], ...] = ( + SFRBoxSensorEntityDescription[WanInfo]( + key="mode", + name="WAN mode", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + options=[ + "adsl_ppp", + "adsl_routed", + "ftth_routed", + "grps_ppp", + "unknown", + ], + translation_key="wan_mode", + value_fn=lambda x: x.mode.replace("/", "_"), + ), +) async def async_setup_entry( @@ -207,6 +225,10 @@ async def async_setup_entry( SFRBoxSensor(data.system, description, data.system.data) for description in SYSTEM_SENSOR_TYPES ] + entities.extend( + SFRBoxSensor(data.wan, description, data.system.data) + for description in WAN_SENSOR_TYPES + ) if data.system.data.net_infra == "adsl": entities.extend( SFRBoxSensor(data.dsl, description, data.system.data) diff --git a/homeassistant/components/sfr_box/strings.json b/homeassistant/components/sfr_box/strings.json index ddff342a10..2141abad87 100644 --- a/homeassistant/components/sfr_box/strings.json +++ b/homeassistant/components/sfr_box/strings.json @@ -63,6 +63,15 @@ "showtime": "Showtime", "unknown": "Unknown" } + }, + "wan_mode": { + "state": { + "adsl_ppp": "ADSL (PPP)", + "adsl_routed": "ADSL (Routed)", + "ftth_routed": "FTTH (Routed)", + "grps_ppp": "GPRS (PPP)", + "unknown": "Unknown" + } } } } diff --git a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr index c3442c08e2..a932a3beae 100644 --- a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr @@ -30,6 +30,34 @@ # --- # name: test_binary_sensors.1 list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.sfr_box_wan_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'WAN status', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e4:5d:51:00:11:22_wan_status', + 'unit_of_measurement': None, + }), EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -73,3 +101,16 @@ 'state': 'on', }) # --- +# name: test_binary_sensors[binary_sensor.sfr_box_wan_status] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'SFR Box WAN status', + }), + 'context': , + 'entity_id': 'binary_sensor.sfr_box_wan_status', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/sfr_box/snapshots/test_diagnostics.ambr b/tests/components/sfr_box/snapshots/test_diagnostics.ambr index 2e25259268..fe9268358f 100644 --- a/tests/components/sfr_box/snapshots/test_diagnostics.ambr +++ b/tests/components/sfr_box/snapshots/test_diagnostics.ambr @@ -41,7 +41,7 @@ 'wan': dict({ 'infra': 'adsl', 'infra6': '', - 'ip_addr': '1.2.3.4', + 'ip_addr': '**REDACTED**', 'ipv6_addr': '', 'mode': 'adsl/routed', 'status': 'up', diff --git a/tests/components/sfr_box/snapshots/test_sensor.ambr b/tests/components/sfr_box/snapshots/test_sensor.ambr index a5788a1d6c..82e5f3aa15 100644 --- a/tests/components/sfr_box/snapshots/test_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_sensor.ambr @@ -119,6 +119,42 @@ 'unique_id': 'e4:5d:51:00:11:22_system_temperature', 'unit_of_measurement': , }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'adsl_ppp', + 'adsl_routed', + 'ftth_routed', + 'grps_ppp', + 'unknown', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sfr_box_wan_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'WAN mode', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': 'wan_mode', + 'unique_id': 'e4:5d:51:00:11:22_wan_mode', + 'unit_of_measurement': None, + }), EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -682,3 +718,23 @@ 'state': '12251', }) # --- +# name: test_sensors[sensor.sfr_box_wan_mode] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'SFR Box WAN mode', + 'options': list([ + 'adsl_ppp', + 'adsl_routed', + 'ftth_routed', + 'grps_ppp', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.sfr_box_wan_mode', + 'last_changed': , + 'last_updated': , + 'state': 'adsl_routed', + }) +# --- diff --git a/tests/components/sfr_box/test_binary_sensor.py b/tests/components/sfr_box/test_binary_sensor.py index 03e0677713..c7a643a916 100644 --- a/tests/components/sfr_box/test_binary_sensor.py +++ b/tests/components/sfr_box/test_binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -pytestmark = pytest.mark.usefixtures("system_get_info", "dsl_get_info") +pytestmark = pytest.mark.usefixtures("system_get_info", "dsl_get_info", "wan_get_info") @pytest.fixture(autouse=True) diff --git a/tests/components/sfr_box/test_button.py b/tests/components/sfr_box/test_button.py index d1bb06fc79..202593e482 100644 --- a/tests/components/sfr_box/test_button.py +++ b/tests/components/sfr_box/test_button.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -pytestmark = pytest.mark.usefixtures("system_get_info", "dsl_get_info") +pytestmark = pytest.mark.usefixtures("system_get_info", "dsl_get_info", "wan_get_info") @pytest.fixture(autouse=True) diff --git a/tests/components/sfr_box/test_init.py b/tests/components/sfr_box/test_init.py index 3a740753b2..df4d1242e0 100644 --- a/tests/components/sfr_box/test_init.py +++ b/tests/components/sfr_box/test_init.py @@ -17,7 +17,7 @@ def override_platforms() -> Generator[None, None, None]: yield -@pytest.mark.usefixtures("system_get_info", "dsl_get_info") +@pytest.mark.usefixtures("system_get_info", "dsl_get_info", "wan_get_info") async def test_setup_unload_entry( hass: HomeAssistant, config_entry: ConfigEntry ) -> None: diff --git a/tests/components/sfr_box/test_sensor.py b/tests/components/sfr_box/test_sensor.py index 4e2c9e33a7..ff84defb83 100644 --- a/tests/components/sfr_box/test_sensor.py +++ b/tests/components/sfr_box/test_sensor.py @@ -11,7 +11,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -pytestmark = pytest.mark.usefixtures("system_get_info", "dsl_get_info") +pytestmark = pytest.mark.usefixtures("system_get_info", "dsl_get_info", "wan_get_info") @pytest.fixture(autouse=True) From 54ad8b8ee91b6c33d8ff0d99153dd95751287a6f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 15 Mar 2023 18:50:32 +0100 Subject: [PATCH 0959/2810] Avoid lingering timers in update coordinator tests (#89749) --- tests/helpers/test_update_coordinator.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 94a9dfd6b9..fc6b7bcf75 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -365,7 +365,7 @@ async def test_async_set_updated_data( def update_callback(): updates.append(crd.data) - crd.async_add_listener(update_callback) + remove_callbacks = crd.async_add_listener(update_callback) crd.async_set_updated_data(200) assert updates == [200] assert crd._unsub_refresh is not None @@ -376,6 +376,9 @@ async def test_async_set_updated_data( # We have created a new refresh listener assert crd._unsub_refresh is not old_refresh + # Remove callbacks to avoid lingering timers + remove_callbacks() + async def test_stop_refresh_on_ha_stop( hass: HomeAssistant, crd: update_coordinator.DataUpdateCoordinator[int] @@ -462,7 +465,7 @@ async def test_async_set_update_error( ) -> None: """Test manually setting an update failure.""" update_callback = Mock() - crd.async_add_listener(update_callback) + remove_callbacks = crd.async_add_listener(update_callback) crd.async_set_update_error(aiohttp.ClientError("Client Failure #1")) assert crd.last_update_success is False @@ -486,3 +489,6 @@ async def test_async_set_update_error( assert crd.last_update_success is False assert "Client Failure #2" not in caplog.text update_callback.assert_called_once() + + # Remove callbacks to avoid lingering timers + remove_callbacks() From dea29f539f317a97e6a5f2f16131fb64884dc801 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 15 Mar 2023 18:52:42 +0100 Subject: [PATCH 0960/2810] Use `SensorDeviceClass.ENUM` and add state attributes translations in Shelly integration (#89660) --- .../components/shelly/binary_sensor.py | 1 + homeassistant/components/shelly/sensor.py | 3 ++ homeassistant/components/shelly/strings.json | 37 +++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index 820afcf0f0..449fc14221 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -91,6 +91,7 @@ SENSORS: Final = { key="sensor|gas", name="Gas", device_class=BinarySensorDeviceClass.GAS, + translation_key="gas", value=lambda value: value in ["mild", "heavy"], extra_state_attributes=lambda block: {"detected": block.gas}, ), diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 5e05134fdc..b83131a100 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -295,6 +295,9 @@ SENSORS: Final = { ("sensor", "sensorOp"): BlockSensorDescription( key="sensor|sensorOp", name="Operation", + device_class=SensorDeviceClass.ENUM, + options=["unknown", "warmup", "normal", "fault"], + translation_key="operation", icon="mdi:cog-transfer", value=lambda value: value, extra_state_attributes=lambda block: {"self_test": block.selfTest}, diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 62a43c5bbc..f0d4e1c9bd 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -81,5 +81,42 @@ "passive": "Passive" } } + }, + "entity": { + "binary_sensor": { + "gas": { + "state_attributes": { + "detected": { + "state": { + "unknown": "Unknown", + "none": "None", + "mild": "Mild", + "heavy": "Heavy", + "test": "Test" + } + } + } + } + }, + "sensor": { + "operation": { + "state": { + "unknown": "Unknown", + "warmup": "Warm-up", + "normal": "normal", + "fault": "Fault" + }, + "state_attributes": { + "self_test": { + "state": { + "not_completed": "Not completed", + "completed": "Completed", + "running": "Running", + "pending": "Pending" + } + } + } + } + } } } From 4eee6267703c9287c893a58c2a2354cb56fc17a4 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 15 Mar 2023 18:54:28 +0100 Subject: [PATCH 0961/2810] Reolink check firmware (#88903) --- homeassistant/components/reolink/host.py | 20 +++++++++++++++++++ homeassistant/components/reolink/strings.json | 4 ++++ tests/components/reolink/test_init.py | 13 ++++++++++++ 3 files changed, 37 insertions(+) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 9d54191aad..e8e96ffe9b 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -138,6 +138,26 @@ class ReolinkHost: await self.subscribe() + if self._api.sw_version_update_required: + ir.async_create_issue( + self._hass, + DOMAIN, + "firmware_update", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="firmware_update", + translation_placeholders={ + "required_firmware": self._api.sw_version_required.version_string, + "current_firmware": self._api.sw_version, + "model": self._api.model, + "hw_version": self._api.hardware_version, + "name": self._api.nvr_name, + "download_link": "https://reolink.com/download-center/", + }, + ) + else: + ir.async_delete_issue(self._hass, DOMAIN, "firmware_update") + async def update_states(self) -> None: """Call the API of the camera device to update the internal states.""" await self._api.get_states() diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 5047c4f271..06b588a119 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -46,6 +46,10 @@ "enable_port": { "title": "Reolink port not enabled", "description": "Failed to automatically enable {ports}port(s) on {name}. Use the [Reolink client]({info_link}) to manually set it to ON" + }, + "firmware_update": { + "title": "Reolink firmware update required", + "description": "\"{name}\" with model \"{model}\" and hardware version \"{hw_version}\" is running a old firmware version \"{current_firmware}\", while at least firmware version \"{required_firmware}\" is required for proper operation of the Reolink integration. The latest firmware can be downloaded from the [Reolink download center]({download_link})." } }, "entity": { diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 52bd2d8c5f..8849c7d52d 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -100,6 +100,7 @@ async def test_no_repair_issue( issue_registry = ir.async_get(hass) assert (const.DOMAIN, "https_webhook") not in issue_registry.issues assert (const.DOMAIN, "enable_port") not in issue_registry.issues + assert (const.DOMAIN, "firmware_update") not in issue_registry.issues async def test_https_repair_issue( @@ -135,3 +136,15 @@ async def test_port_repair_issue( issue_registry = ir.async_get(hass) assert (const.DOMAIN, "enable_port") in issue_registry.issues + + +async def test_firmware_repair_issue( + hass: HomeAssistant, config_entry: MockConfigEntry, reolink_connect: MagicMock +) -> None: + """Test firmware issue is raised when too old firmware is used.""" + reolink_connect.sw_version_update_required = True + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + issue_registry = ir.async_get(hass) + assert (const.DOMAIN, "firmware_update") in issue_registry.issues From fceb20838163412e2842388b1ca3b84e8044f99d Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 15 Mar 2023 18:55:34 +0100 Subject: [PATCH 0962/2810] Abort Hue config flow if bridge can not be reached (#88893) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> Co-authored-by: Franck Nijhof --- homeassistant/components/hue/config_flow.py | 27 ++++++-- tests/components/hue/test_config_flow.py | 75 ++++++++++++++++++++- 2 files changed, 96 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index cd2553353b..2b0ebdebca 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -78,7 +78,13 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): bridge = await discover_bridge( host, websession=aiohttp_client.async_get_clientsession(self.hass) ) - except aiohttp.ClientError: + except aiohttp.ClientError as err: + LOGGER.warning( + "Error while attempting to retrieve discovery information, " + "is there a bridge alive on IP %s ?", + host, + exc_info=err, + ) return None if bridge_id is not None: bridge_id = normalize_bridge_id(bridge_id) @@ -147,7 +153,9 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) self._async_abort_entries_match({"host": user_input["host"]}) - self.bridge = await self._get_bridge(user_input[CONF_HOST]) + if (bridge := await self._get_bridge(user_input[CONF_HOST])) is None: + return self.async_abort(reason="cannot_connect") + self.bridge = bridge return await self.async_step_link() async def async_step_link( @@ -224,9 +232,12 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) # we need to query the other capabilities too - self.bridge = await self._get_bridge( + bridge = await self._get_bridge( discovery_info.host, discovery_info.properties["bridgeid"] ) + if bridge is None: + return self.async_abort(reason="cannot_connect") + self.bridge = bridge return await self.async_step_link() async def async_step_homekit( @@ -238,7 +249,10 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): as the unique identifier. Therefore, this method uses discovery without a unique ID. """ - self.bridge = await self._get_bridge(discovery_info.host) + bridge = await self._get_bridge(discovery_info.host) + if bridge is None: + return self.async_abort(reason="cannot_connect") + self.bridge = bridge await self._async_handle_discovery_without_unique_id() return await self.async_step_link() @@ -254,7 +268,10 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # Check if host exists, abort if so. self._async_abort_entries_match({"host": import_info["host"]}) - self.bridge = await self._get_bridge(import_info["host"]) + bridge = await self._get_bridge(import_info["host"]) + if bridge is None: + return self.async_abort(reason="cannot_connect") + self.bridge = bridge return await self.async_step_link() diff --git a/tests/components/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py index 7f6e4ae1d0..6fa03e1de1 100644 --- a/tests/components/hue/test_config_flow.py +++ b/tests/components/hue/test_config_flow.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.test_util.aiohttp import AiohttpClientMocker, ClientError @pytest.fixture(name="hue_setup", autouse=True) @@ -645,3 +645,76 @@ async def test_bridge_zeroconf_ipv6(hass: HomeAssistant) -> None: assert result["type"] == "abort" assert result["reason"] == "invalid_host" + + +async def test_bridge_connection_failed( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that connection errors to the bridge are handled.""" + create_mock_api_discovery(aioclient_mock, []) + + with patch( + "homeassistant.components.hue.config_flow.discover_bridge", + side_effect=ClientError, + ): + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"host": "blah"} + ) + + # a warning message should have been logged that the bridge could not be reached + assert "Error while attempting to retrieve discovery information" in caplog.text + + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + + # test again with zeroconf discovered wrong bridge IP + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host="blah", + addresses=["1.2.3.4"], + port=443, + hostname="Philips-hue.local", + type="_hue._tcp.local.", + name="Philips Hue - ABCABC._hue._tcp.local.", + properties={ + "_raw": {"bridgeid": b"ecb5fafffeabcabc", "modelid": b"BSB002"}, + "bridgeid": "ecb5fafffeabcabc", + "modelid": "BSB002", + }, + ), + ) + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + + # test again with homekit discovered wrong bridge IP + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=zeroconf.ZeroconfServiceInfo( + host="0.0.0.0", + addresses=["0.0.0.0"], + hostname="mock_hostname", + name="mock_name", + port=None, + properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + type="mock_type", + ), + ) + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + + # repeat test with import flow + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={"host": "blah"}, + ) + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" From b7ac0058af7c99fb4da9cc153d386d7fd6c61051 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 15 Mar 2023 18:56:58 +0100 Subject: [PATCH 0963/2810] Fix hassio cleanup when addon in uninstalled (#89756) --- homeassistant/components/hassio/discovery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/hassio/discovery.py b/homeassistant/components/hassio/discovery.py index 6d936c6ce2..29cb53de70 100644 --- a/homeassistant/components/hassio/discovery.py +++ b/homeassistant/components/hassio/discovery.py @@ -130,4 +130,4 @@ class HassIODiscovery(HomeAssistantView): for entry in self.hass.config_entries.async_entries(service): if entry.source != config_entries.SOURCE_HASSIO: continue - await self.hass.config_entries.async_remove(entry) + await self.hass.config_entries.async_remove(entry.entry_id) From d4edec28633afa6efb5b43debef87b12776ec5c2 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 15 Mar 2023 18:59:03 +0100 Subject: [PATCH 0964/2810] Move calculation of current value into lib in Fritz!SmartHome (#89150) --- homeassistant/components/fritzbox/sensor.py | 13 +------------ tests/components/fritzbox/__init__.py | 1 + tests/components/fritzbox/test_switch.py | 18 ------------------ 3 files changed, 2 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 4d045c2c98..a048a7bba5 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -74,17 +74,6 @@ def suitable_temperature(device: FritzhomeDevice) -> bool: return device.has_temperature_sensor and not device.has_thermostat -def value_electric_current(device: FritzhomeDevice) -> float: - """Return native value for electric current sensor.""" - if ( - isinstance(device.power, int) - and isinstance(device.voltage, int) - and device.voltage > 0 - ): - return round(device.power / device.voltage, 3) - return 0.0 - - def value_nextchange_preset(device: FritzhomeDevice) -> str: """Return native value for next scheduled preset sensor.""" if device.nextchange_temperature == device.eco_temperature: @@ -153,7 +142,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, suitable=lambda device: device.has_powermeter, # type: ignore[no-any-return] - native_value=value_electric_current, + native_value=lambda device: round((device.current or 0.0) / 1000, 3), ), FritzSensorEntityDescription( key="total_energy", diff --git a/tests/components/fritzbox/__init__.py b/tests/components/fritzbox/__init__.py index 6cf60a9065..15ff04f372 100644 --- a/tests/components/fritzbox/__init__.py +++ b/tests/components/fritzbox/__init__.py @@ -129,6 +129,7 @@ class FritzDeviceSwitchMock(FritzEntityBaseMock): device_lock = "fake_locked_device" energy = 1234 voltage = 230000 + current = 25 fw_version = "1.2.3" has_alarm = False has_powermeter = True diff --git a/tests/components/fritzbox/test_switch.py b/tests/components/fritzbox/test_switch.py index 8555cdb989..fdc3cbff2a 100644 --- a/tests/components/fritzbox/test_switch.py +++ b/tests/components/fritzbox/test_switch.py @@ -174,21 +174,3 @@ async def test_assume_device_unavailable(hass: HomeAssistant, fritz: Mock) -> No state = hass.states.get(ENTITY_ID) assert state assert state.state == STATE_UNAVAILABLE - - -async def test_device_current_unavailable(hass: HomeAssistant, fritz: Mock) -> None: - """Test current in case voltage and power are not available.""" - device = FritzDeviceSwitchMock() - device.voltage = None - device.power = None - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) - - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_ON - - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_electric_current") - assert state - assert state.state == "0.0" From bf21b2622c407cbe008b6c52782e88e1cc09febf Mon Sep 17 00:00:00 2001 From: zhangshengdong29 <435878393@qq.com> Date: Thu, 16 Mar 2023 02:13:32 +0800 Subject: [PATCH 0965/2810] ArestData does not have available (#88631) --- homeassistant/components/arest/sensor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/arest/sensor.py b/homeassistant/components/arest/sensor.py index 5c95fd63c3..2e6012e0e6 100644 --- a/homeassistant/components/arest/sensor.py +++ b/homeassistant/components/arest/sensor.py @@ -180,7 +180,7 @@ class ArestData: self._resource = resource self._pin = pin self.data = {} - self._attr_available = True + self.available = True @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): @@ -201,7 +201,7 @@ class ArestData: f"{self._resource}/digital/{self._pin}", timeout=10 ) self.data = {"value": response.json()["return_value"]} - self._attr_available = True + self.available = True except requests.exceptions.ConnectionError: _LOGGER.error("No route to device %s", self._resource) - self._attr_available = False + self.available = False From 3aa5629665ddeea1b9f9948967beb82e5dba971d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 15 Mar 2023 19:42:23 +0100 Subject: [PATCH 0966/2810] Improve type hints in condition helper tests (#89754) --- tests/helpers/test_condition.py | 37 ++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index bb4f862711..e345da1538 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -1,5 +1,6 @@ """Test the condition helper.""" from datetime import datetime, timedelta +from typing import Any from unittest.mock import AsyncMock, patch import pytest @@ -16,7 +17,7 @@ from homeassistant.const import ( SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConditionError, HomeAssistantError from homeassistant.helpers import ( condition, @@ -33,13 +34,13 @@ from tests.typing import WebSocketGenerator @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @pytest.fixture(autouse=True) -def setup_comp(hass): +def setup_comp(hass: HomeAssistant) -> None: """Initialize components.""" hass.config.set_time_zone(hass.config.time_zone) hass.loop.run_until_complete( @@ -69,7 +70,7 @@ def assert_element(trace_element, expected_element, path): @pytest.fixture(autouse=True) -def prepare_condition_trace(): +def prepare_condition_trace() -> None: """Clear previous trace.""" trace.trace_clear() @@ -1177,7 +1178,9 @@ async def test_state_for_template(hass: HomeAssistant) -> None: @pytest.mark.parametrize("for_template", [{"{{invalid}}": 5}, {"hours": "{{ 1/0 }}"}]) -async def test_state_for_invalid_template(hass: HomeAssistant, for_template) -> None: +async def test_state_for_invalid_template( + hass: HomeAssistant, for_template: dict[str, Any] +) -> None: """Test state with invalid templated duration.""" config = { "condition": "and", @@ -2217,7 +2220,7 @@ async def assert_automation_condition_trace(hass_ws_client, automation_id, expec async def test_if_action_before_sunrise_no_offset( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] ) -> None: """Test if action was before sunrise. @@ -2288,7 +2291,7 @@ async def test_if_action_before_sunrise_no_offset( async def test_if_action_after_sunrise_no_offset( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] ) -> None: """Test if action was after sunrise. @@ -2359,7 +2362,7 @@ async def test_if_action_after_sunrise_no_offset( async def test_if_action_before_sunrise_with_offset( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] ) -> None: """Test if action was before sunrise with offset. @@ -2482,7 +2485,7 @@ async def test_if_action_before_sunrise_with_offset( async def test_if_action_before_sunset_with_offset( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] ) -> None: """Test if action was before sunset with offset. @@ -2605,7 +2608,7 @@ async def test_if_action_before_sunset_with_offset( async def test_if_action_after_sunrise_with_offset( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] ) -> None: """Test if action was after sunrise with offset. @@ -2752,7 +2755,7 @@ async def test_if_action_after_sunrise_with_offset( async def test_if_action_after_sunset_with_offset( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] ) -> None: """Test if action was after sunset with offset. @@ -2827,7 +2830,7 @@ async def test_if_action_after_sunset_with_offset( async def test_if_action_after_and_before_during( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] ) -> None: """Test if action was after sunrise and before sunset. @@ -2930,7 +2933,7 @@ async def test_if_action_after_and_before_during( async def test_if_action_before_or_after_during( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] ) -> None: """Test if action was before sunrise or after sunset. @@ -3053,7 +3056,7 @@ async def test_if_action_before_or_after_during( async def test_if_action_before_sunrise_no_offset_kotzebue( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] ) -> None: """Test if action was before sunrise. @@ -3130,7 +3133,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( async def test_if_action_after_sunrise_no_offset_kotzebue( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] ) -> None: """Test if action was after sunrise. @@ -3207,7 +3210,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( async def test_if_action_before_sunset_no_offset_kotzebue( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] ) -> None: """Test if action was before sunrise. @@ -3284,7 +3287,7 @@ async def test_if_action_before_sunset_no_offset_kotzebue( async def test_if_action_after_sunset_no_offset_kotzebue( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] ) -> None: """Test if action was after sunrise. From b43b2eb3cb67876d9015cad5913fa93df7bba53e Mon Sep 17 00:00:00 2001 From: Jack Boswell Date: Thu, 16 Mar 2023 08:40:22 +1300 Subject: [PATCH 0967/2810] Avoid rounding Starlink sensor data & instead allow configurable precision (#89486) --- homeassistant/components/starlink/sensor.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/starlink/sensor.py b/homeassistant/components/starlink/sensor.py index bb84f03224..79cd5ca389 100644 --- a/homeassistant/components/starlink/sensor.py +++ b/homeassistant/components/starlink/sensor.py @@ -66,7 +66,8 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( icon="mdi:speedometer", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTime.MILLISECONDS, - value_fn=lambda data: round(data.status["pop_ping_latency_ms"]), + suggested_display_precision=0, + value_fn=lambda data: data.status["pop_ping_latency_ms"], ), StarlinkSensorEntityDescription( key="azimuth", @@ -76,7 +77,8 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=DEGREE, entity_registry_enabled_default=False, - value_fn=lambda data: round(data.status["direction_azimuth"]), + suggested_display_precision=0, + value_fn=lambda data: data.status["direction_azimuth"], ), StarlinkSensorEntityDescription( key="elevation", @@ -86,7 +88,8 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=DEGREE, entity_registry_enabled_default=False, - value_fn=lambda data: round(data.status["direction_elevation"]), + suggested_display_precision=0, + value_fn=lambda data: data.status["direction_elevation"], ), StarlinkSensorEntityDescription( key="uplink_throughput", @@ -95,7 +98,8 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.BITS_PER_SECOND, - value_fn=lambda data: round(data.status["uplink_throughput_bps"]), + suggested_display_precision=0, + value_fn=lambda data: data.status["uplink_throughput_bps"], ), StarlinkSensorEntityDescription( key="downlink_throughput", @@ -104,7 +108,8 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.BITS_PER_SECOND, - value_fn=lambda data: round(data.status["downlink_throughput_bps"]), + suggested_display_precision=0, + value_fn=lambda data: data.status["downlink_throughput_bps"], ), StarlinkSensorEntityDescription( key="last_boot_time", From 7a267460d3f771effd2fb3689a2ea9b704cd107e Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 15 Mar 2023 15:42:23 -0400 Subject: [PATCH 0968/2810] Cache remote app list for vizio TVs (#89003) --- homeassistant/components/vizio/__init__.py | 63 +++++++++++-------- .../components/vizio/media_player.py | 16 +++-- 2 files changed, 47 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/vizio/__init__.py b/homeassistant/components/vizio/__init__.py index 9fc40c40c2..d694f4b93f 100644 --- a/homeassistant/components/vizio/__init__.py +++ b/homeassistant/components/vizio/__init__.py @@ -15,6 +15,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -66,8 +67,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: CONF_APPS not in hass.data[DOMAIN] and entry.data[CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV ): - coordinator = VizioAppsDataUpdateCoordinator(hass) - await coordinator.async_refresh() + store: Store = Store(hass, 1, DOMAIN) + coordinator = VizioAppsDataUpdateCoordinator(hass, store) + await coordinator.async_config_entry_first_refresh() hass.data[DOMAIN][CONF_APPS] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -98,7 +100,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> class VizioAppsDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): """Define an object to hold Vizio app config data.""" - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, store: Store) -> None: """Initialize.""" super().__init__( hass, @@ -107,31 +109,40 @@ class VizioAppsDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]] update_interval=timedelta(days=1), update_method=self._async_update_data, ) - self.data = APPS self.fail_count = 0 self.fail_threshold = 10 + self.store = store + + async def async_config_entry_first_refresh(self) -> None: + """Refresh data for the first time when a config entry is setup.""" + self.data = await self.store.async_load() or APPS + await super().async_config_entry_first_refresh() async def _async_update_data(self) -> list[dict[str, Any]]: """Update data via library.""" - data = await gen_apps_list_from_url(session=async_get_clientsession(self.hass)) - if not data: - # For every failure, increase the fail count until we reach the threshold. - # We then log a warning, increase the threshold, and reset the fail count. - # This is here to prevent silent failures but to reduce repeat logs. - if self.fail_count == self.fail_threshold: - _LOGGER.warning( - ( - "Unable to retrieve the apps list from the external server " - "for the last %s days" - ), - self.fail_threshold, - ) - self.fail_count = 0 - self.fail_threshold += 10 - else: - self.fail_count += 1 - return self.data - # Reset the fail count and threshold when the data is successfully retrieved - self.fail_count = 0 - self.fail_threshold = 10 - return sorted(data, key=lambda app: app["name"]) + if data := await gen_apps_list_from_url( + session=async_get_clientsession(self.hass) + ): + # Reset the fail count and threshold when the data is successfully retrieved + self.fail_count = 0 + self.fail_threshold = 10 + # Store the new data if it has changed so we have it for the next restart + if data != self.data: + await self.store.async_save(data) + return data + # For every failure, increase the fail count until we reach the threshold. + # We then log a warning, increase the threshold, and reset the fail count. + # This is here to prevent silent failures but to reduce repeat logs. + if self.fail_count == self.fail_threshold: + _LOGGER.warning( + ( + "Unable to retrieve the apps list from the external server for the " + "last %s days" + ), + self.fail_threshold, + ) + self.fail_count = 0 + self.fail_threshold += 10 + else: + self.fail_count += 1 + return self.data diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index e1ca306ddf..a989cea488 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -137,7 +137,7 @@ class VizioDevice(MediaPlayerEntity): device: VizioAsync, name: str, device_class: MediaPlayerDeviceClass, - apps_coordinator: VizioAppsDataUpdateCoordinator, + apps_coordinator: VizioAppsDataUpdateCoordinator | None, ) -> None: """Initialize Vizio device.""" self._config_entry = config_entry @@ -330,17 +330,21 @@ class VizioDevice(MediaPlayerEntity): ) ) + if not self._apps_coordinator: + return + # Register callback for app list updates if device is a TV @callback - def apps_list_update(): + def apps_list_update() -> None: """Update list of all apps.""" + if not self._apps_coordinator: + return self._all_apps = self._apps_coordinator.data self.async_write_ha_state() - if self._attr_device_class == MediaPlayerDeviceClass.TV: - self.async_on_remove( - self._apps_coordinator.async_add_listener(apps_list_update) - ) + self.async_on_remove( + self._apps_coordinator.async_add_listener(apps_list_update) + ) @property def source(self) -> str | None: From 6ba5f8e43af2cb40c7ce6208c4809dfb1943ec76 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 15 Mar 2023 21:22:13 +0100 Subject: [PATCH 0969/2810] Fix imap server push holding HA startup (#89750) --- homeassistant/components/imap/coordinator.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 8a716fe478..e170f79e7f 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -77,7 +77,9 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int]): f"Invalid response for search '{self.config_entry.data[CONF_SEARCH]}': {result} / {lines[0]}" ) if self.support_push: - self.hass.async_create_task(self.async_wait_server_push()) + self.hass.async_create_background_task( + self.async_wait_server_push(), "Wait for IMAP data push" + ) return len(lines[0].split()) async def async_wait_server_push(self) -> None: @@ -100,5 +102,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int]): async def shutdown(self, *_) -> None: """Close resources.""" if self.imap_client: + if self.imap_client.has_pending_idle(): + self.imap_client.idle_done() await self.imap_client.stop_wait_server_push() await self.imap_client.logout() From aec2d6330247ff089ccae0eea9ec7730decbb949 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Mar 2023 11:13:47 -1000 Subject: [PATCH 0970/2810] Add keep_days to recorder.purge_entities (#89726) --- homeassistant/components/recorder/services.py | 26 +++++-- .../components/recorder/services.yaml | 10 +++ tests/components/recorder/test_purge.py | 69 +++++++++++++++++++ 3 files changed, 98 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/recorder/services.py b/homeassistant/components/recorder/services.py index e1b2e388d6..fb2cd1f0be 100644 --- a/homeassistant/components/recorder/services.py +++ b/homeassistant/components/recorder/services.py @@ -9,7 +9,10 @@ import voluptuous as vol from homeassistant.core import HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import generate_filter -from homeassistant.helpers.service import async_extract_entity_ids +from homeassistant.helpers.service import ( + async_extract_entity_ids, + async_register_admin_service, +) import homeassistant.util.dt as dt_util from .const import ATTR_APPLY_FILTER, ATTR_KEEP_DAYS, ATTR_REPACK, DOMAIN @@ -38,6 +41,7 @@ SERVICE_PURGE_ENTITIES_SCHEMA = vol.Schema( vol.Optional(ATTR_ENTITY_GLOBS, default=[]): vol.All( cv.ensure_list, [cv.string] ), + vol.Optional(ATTR_KEEP_DAYS, default=0): cv.positive_int, } ).extend(cv.ENTITY_SERVICE_FIELDS) @@ -56,8 +60,12 @@ def _async_register_purge_service(hass: HomeAssistant, instance: Recorder) -> No purge_before = dt_util.utcnow() - timedelta(days=keep_days) instance.queue_task(PurgeTask(purge_before, repack, apply_filter)) - hass.services.async_register( - DOMAIN, SERVICE_PURGE, async_handle_purge_service, schema=SERVICE_PURGE_SCHEMA + async_register_admin_service( + hass, + DOMAIN, + SERVICE_PURGE, + async_handle_purge_service, + schema=SERVICE_PURGE_SCHEMA, ) @@ -69,12 +77,14 @@ def _async_register_purge_entities_service( """Handle calls to the purge entities service.""" entity_ids = await async_extract_entity_ids(hass, service) domains = service.data.get(ATTR_DOMAINS, []) + keep_days = service.data.get(ATTR_KEEP_DAYS, 0) entity_globs = service.data.get(ATTR_ENTITY_GLOBS, []) entity_filter = generate_filter(domains, list(entity_ids), [], [], entity_globs) - purge_before = dt_util.utcnow() + purge_before = dt_util.utcnow() - timedelta(days=keep_days) instance.queue_task(PurgeEntitiesTask(entity_filter, purge_before)) - hass.services.async_register( + async_register_admin_service( + hass, DOMAIN, SERVICE_PURGE_ENTITIES, async_handle_purge_entities_service, @@ -87,7 +97,8 @@ def _async_register_enable_service(hass: HomeAssistant, instance: Recorder) -> N async def async_handle_enable_service(service: ServiceCall) -> None: instance.set_enable(True) - hass.services.async_register( + async_register_admin_service( + hass, DOMAIN, SERVICE_ENABLE, async_handle_enable_service, @@ -100,7 +111,8 @@ def _async_register_disable_service(hass: HomeAssistant, instance: Recorder) -> async def async_handle_disable_service(service: ServiceCall) -> None: instance.set_enable(False) - hass.services.async_register( + async_register_admin_service( + hass, DOMAIN, SERVICE_DISABLE, async_handle_disable_service, diff --git a/homeassistant/components/recorder/services.yaml b/homeassistant/components/recorder/services.yaml index 43ff7548dd..f099cede9f 100644 --- a/homeassistant/components/recorder/services.yaml +++ b/homeassistant/components/recorder/services.yaml @@ -51,6 +51,16 @@ purge_entities: selector: object: + keep_days: + name: Days to keep + description: Number of history days to keep in database of matching rows. The default of 0 days will remove all matching rows. + default: 0 + selector: + number: + min: 0 + max: 365 + unit_of_measurement: days + disable: name: Disable description: Stop the recording of events and state changes diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index 3411c1eb30..2979b04e5c 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -4,6 +4,7 @@ import json import sqlite3 from unittest.mock import patch +from freezegun import freeze_time import pytest from sqlalchemy.exc import DatabaseError, OperationalError from sqlalchemy.orm.session import Session @@ -25,6 +26,7 @@ from homeassistant.components.recorder.db_schema import ( StatisticsRuns, StatisticsShortTerm, ) +from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.recorder.purge import purge_old_data from homeassistant.components.recorder.queries import select_event_type_ids from homeassistant.components.recorder.services import ( @@ -2021,3 +2023,70 @@ async def test_purge_old_states_purges_the_state_metadata_ids( assert finished assert states.count() == 0 assert states_meta.count() == 0 + + +async def test_purge_entities_keep_days( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, +) -> None: + """Test purging states with an entity filter and keep_days.""" + instance = await async_setup_recorder_instance(hass, {}) + await hass.async_block_till_done() + await async_wait_recording_done(hass) + start = dt_util.utcnow() + two_days_ago = start - timedelta(days=2) + one_week_ago = start - timedelta(days=7) + one_month_ago = start - timedelta(days=30) + with freeze_time(one_week_ago): + hass.states.async_set("sensor.keep", "initial") + hass.states.async_set("sensor.purge", "initial") + + await async_wait_recording_done(hass) + + with freeze_time(two_days_ago): + hass.states.async_set("sensor.purge", "two_days_ago") + + await async_wait_recording_done(hass) + + hass.states.async_set("sensor.purge", "now") + hass.states.async_set("sensor.keep", "now") + await async_recorder_block_till_done(hass) + + states = await instance.async_add_executor_job( + get_significant_states, hass, one_month_ago + ) + assert len(states["sensor.keep"]) == 2 + assert len(states["sensor.purge"]) == 3 + + await hass.services.async_call( + recorder.DOMAIN, + SERVICE_PURGE_ENTITIES, + { + "entity_id": "sensor.purge", + "keep_days": 1, + }, + ) + await async_recorder_block_till_done(hass) + await async_wait_purge_done(hass) + + states = await instance.async_add_executor_job( + get_significant_states, hass, one_month_ago + ) + assert len(states["sensor.keep"]) == 2 + assert len(states["sensor.purge"]) == 1 + + await hass.services.async_call( + recorder.DOMAIN, + SERVICE_PURGE_ENTITIES, + { + "entity_id": "sensor.purge", + }, + ) + await async_recorder_block_till_done(hass) + await async_wait_purge_done(hass) + + states = await instance.async_add_executor_job( + get_significant_states, hass, one_month_ago + ) + assert len(states["sensor.keep"]) == 2 + assert "sensor.purge" not in states From 69078b5aed2a8dbcb0e72e5ae53176cd3f498091 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Mar 2023 12:14:49 -1000 Subject: [PATCH 0971/2810] Bump pyblackbird to 0.6 for py3.11 (#89719) --- homeassistant/components/blackbird/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/blackbird/manifest.json b/homeassistant/components/blackbird/manifest.json index fd7cc76aad..d75b69dfaf 100644 --- a/homeassistant/components/blackbird/manifest.json +++ b/homeassistant/components/blackbird/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/blackbird", "iot_class": "local_polling", "loggers": ["pyblackbird"], - "requirements": ["pyblackbird==0.5"] + "requirements": ["pyblackbird==0.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index a946d316c1..a718ea2897 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1522,7 +1522,7 @@ pybalboa==1.0.1 pybbox==0.0.5-alpha # homeassistant.components.blackbird -pyblackbird==0.5 +pyblackbird==0.6 # homeassistant.components.bluetooth_tracker # pybluez==0.22 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 55b9c03c50..a35b960b2b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1113,7 +1113,7 @@ pyaussiebb==0.0.15 pybalboa==1.0.1 # homeassistant.components.blackbird -pyblackbird==0.5 +pyblackbird==0.6 # homeassistant.components.neato pybotvac==0.0.23 From a360da8bc34e3656ed64817e0b75dda8c69dbecd Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Wed, 15 Mar 2023 11:28:43 -1100 Subject: [PATCH 0972/2810] Update xknx to 2.7.0 (#89765) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index ce09032e1a..0ad4404290 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["xknx"], "quality_scale": "platinum", - "requirements": ["xknx==2.6.0"] + "requirements": ["xknx==2.7.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index a718ea2897..6b8b2f9780 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2653,7 +2653,7 @@ xboxapi==2.0.1 xiaomi-ble==0.16.4 # homeassistant.components.knx -xknx==2.6.0 +xknx==2.7.0 # homeassistant.components.bluesound # homeassistant.components.fritz diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a35b960b2b..0591758b9f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1890,7 +1890,7 @@ xbox-webapi==2.0.11 xiaomi-ble==0.16.4 # homeassistant.components.knx -xknx==2.6.0 +xknx==2.7.0 # homeassistant.components.bluesound # homeassistant.components.fritz From ccab45520b9c10bd3033988a5f07e28b242b6c5a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Mar 2023 14:04:31 -1000 Subject: [PATCH 0973/2810] Remove asyncio.coroutine workarounds (#88560) --- tests/asyncio_legacy.py | 128 ---------------------------------------- tests/conftest.py | 6 -- 2 files changed, 134 deletions(-) delete mode 100644 tests/asyncio_legacy.py diff --git a/tests/asyncio_legacy.py b/tests/asyncio_legacy.py deleted file mode 100644 index bc054b7e36..0000000000 --- a/tests/asyncio_legacy.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Minimal legacy asyncio.coroutine.""" - -# flake8: noqa -# stubbing out for integrations that have -# not yet been updated for python 3.11 -# but can still run on python 3.10 -# -# Remove this once rflink, fido, and blackbird -# have had their libraries updated to remove -# asyncio.coroutine -from asyncio import base_futures, constants, format_helpers -from asyncio.coroutines import _is_coroutine -import collections.abc -import functools -import inspect -import logging -import traceback -import types -import warnings - -logger = logging.getLogger(__name__) - - -class CoroWrapper: - # Wrapper for coroutine object in _DEBUG mode. - - def __init__(self, gen, func=None): - assert inspect.isgenerator(gen) or inspect.iscoroutine(gen), gen - self.gen = gen - self.func = func # Used to unwrap @coroutine decorator - self._source_traceback = format_helpers.extract_stack(sys._getframe(1)) - self.__name__ = getattr(gen, "__name__", None) - self.__qualname__ = getattr(gen, "__qualname__", None) - - def __iter__(self): - return self - - def __next__(self): - return self.gen.send(None) - - def send(self, value): - return self.gen.send(value) - - def throw(self, type, value=None, traceback=None): - return self.gen.throw(type, value, traceback) - - def close(self): - return self.gen.close() - - @property - def gi_frame(self): - return self.gen.gi_frame - - @property - def gi_running(self): - return self.gen.gi_running - - @property - def gi_code(self): - return self.gen.gi_code - - def __await__(self): - return self - - @property - def gi_yieldfrom(self): - return self.gen.gi_yieldfrom - - def __del__(self): - # Be careful accessing self.gen.frame -- self.gen might not exist. - gen = getattr(self, "gen", None) - frame = getattr(gen, "gi_frame", None) - if frame is not None and frame.f_lasti == -1: - msg = f"{self!r} was never yielded from" - tb = getattr(self, "_source_traceback", ()) - if tb: - tb = "".join(traceback.format_list(tb)) - msg += ( - f"\nCoroutine object created at " - f"(most recent call last, truncated to " - f"{constants.DEBUG_STACK_DEPTH} last lines):\n" - ) - msg += tb.rstrip() - logger.error(msg) - - -def legacy_coroutine(func): - """Decorator to mark coroutines. - If the coroutine is not yielded from before it is destroyed, - an error message is logged. - """ - warnings.warn( - '"@coroutine" decorator is deprecated since Python 3.8, use "async def" instead', - DeprecationWarning, - stacklevel=2, - ) - if inspect.iscoroutinefunction(func): - # In Python 3.5 that's all we need to do for coroutines - # defined with "async def". - return func - - if inspect.isgeneratorfunction(func): - coro = func - else: - - @functools.wraps(func) - def coro(*args, **kw): - res = func(*args, **kw) - if ( - base_futures.isfuture(res) - or inspect.isgenerator(res) - or isinstance(res, CoroWrapper) - ): - res = yield from res - else: - # If 'res' is an awaitable, run it. - try: - await_meth = res.__await__ - except AttributeError: - pass - else: - if isinstance(res, collections.abc.Awaitable): - res = yield from await_meth() - return res - - wrapper = types.coroutine(coro) - wrapper._is_coroutine = _is_coroutine # For iscoroutinefunction(). - return wrapper diff --git a/tests/conftest.py b/tests/conftest.py index 0307e65d27..32b6f98ca7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,6 @@ import itertools import logging import sqlite3 import ssl -import sys import threading from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, cast from unittest.mock import AsyncMock, MagicMock, Mock, patch @@ -108,11 +107,6 @@ asyncio.set_event_loop_policy(runner.HassEventLoopPolicy(False)) # Disable fixtures overriding our beautiful policy asyncio.set_event_loop_policy = lambda policy: None -if sys.version_info[:2] >= (3, 11): - from .asyncio_legacy import legacy_coroutine - - setattr(asyncio, "coroutine", legacy_coroutine) - def _utcnow() -> datetime.datetime: """Make utcnow patchable by freezegun.""" From e379aa23bdc3ec469fd363e300ec329b85da3158 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Mar 2023 15:26:29 -1000 Subject: [PATCH 0974/2810] Migrate StateAttributes to use a table manager (#89760) Co-authored-by: Paulus Schoutsen --- homeassistant/components/recorder/core.py | 142 ++++------------ homeassistant/components/recorder/purge.py | 24 +-- homeassistant/components/recorder/queries.py | 11 -- .../recorder/table_managers/__init__.py | 66 +++++++- .../recorder/table_managers/event_data.py | 34 +--- .../recorder/table_managers/event_types.py | 26 +-- .../table_managers/state_attributes.py | 160 ++++++++++++++++++ .../recorder/table_managers/states_meta.py | 26 +-- tests/components/recorder/test_init.py | 12 +- 9 files changed, 274 insertions(+), 227 deletions(-) create mode 100644 homeassistant/components/recorder/table_managers/state_attributes.py diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 26cd5c3b88..e7fdf64581 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -11,10 +11,9 @@ import queue import sqlite3 import threading import time -from typing import Any, TypeVar, cast +from typing import Any, TypeVar import async_timeout -from lru import LRU # pylint: disable=no-name-in-module from sqlalchemy import create_engine, event as sqlalchemy_event, exc, func, select from sqlalchemy.engine import Engine from sqlalchemy.exc import SQLAlchemyError @@ -30,7 +29,6 @@ from homeassistant.const import ( MATCH_ALL, ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback -from homeassistant.helpers.entity import entity_sources from homeassistant.helpers.event import ( async_track_time_change, async_track_time_interval, @@ -40,7 +38,6 @@ from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import UNDEFINED, UndefinedType import homeassistant.util.dt as dt_util from homeassistant.util.enum import try_parse_enum -from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS from . import migration, statistics from .const import ( @@ -52,7 +49,6 @@ from .const import ( MAX_QUEUE_BACKLOG, MYSQLDB_PYMYSQL_URL_PREFIX, MYSQLDB_URL_PREFIX, - SQLITE_MAX_BIND_VARS, SQLITE_URL_PREFIX, SupportedDialect, ) @@ -79,8 +75,6 @@ from .models import ( ) from .pool import POOL_SIZE, MutexPool, RecorderPool from .queries import ( - find_shared_attributes_id, - get_shared_attributes, has_entity_ids_to_migrate, has_event_type_to_migrate, has_events_context_ids_to_migrate, @@ -89,6 +83,7 @@ from .queries import ( from .run_history import RunHistory from .table_managers.event_data import EventDataManager from .table_managers.event_types import EventTypeManager +from .table_managers.state_attributes import StateAttributesManager from .table_managers.states_meta import StatesMetaManager from .tasks import ( AdjustLRUSizeTask, @@ -115,7 +110,6 @@ from .tasks import ( ) from .util import ( build_mysqldb_conv, - chunked, dburl_to_path, end_incomplete_runs, is_second_sunday, @@ -136,15 +130,6 @@ DEFAULT_URL = "sqlite:///{hass_config_path}" # States and Events objects EXPIRE_AFTER_COMMITS = 120 -# The number of attribute ids to cache in memory -# -# Based on: -# - The number of overlapping attributes -# - How frequently states with overlapping attributes will change -# - How much memory our low end hardware has -STATE_ATTRIBUTES_ID_CACHE_SIZE = 2048 - - SHUTDOWN_TASK = object() COMMIT_TASK = CommitTask() @@ -206,7 +191,6 @@ class Recorder(threading.Thread): self._queue_watch = threading.Event() self.engine: Engine | None = None self.run_history = RunHistory() - self._entity_sources = entity_sources(hass) # The entity_filter is exposed on the recorder instance so that # it can be used to see if an entity is being recorded and is called @@ -217,11 +201,12 @@ class Recorder(threading.Thread): self.schema_version = 0 self._commits_without_expire = 0 self._old_states: dict[str | None, States] = {} - self._state_attributes_ids: LRU = LRU(STATE_ATTRIBUTES_ID_CACHE_SIZE) self.event_data_manager = EventDataManager(self) self.event_type_manager = EventTypeManager(self) self.states_meta_manager = StatesMetaManager(self) - self._pending_state_attributes: dict[str, StateAttributes] = {} + self.state_attributes_manager = StateAttributesManager( + self, exclude_attributes_by_domain + ) self._pending_expunge: list[States] = [] self.event_session: Session | None = None self._get_session: Callable[[], Session] | None = None @@ -231,7 +216,6 @@ class Recorder(threading.Thread): self.migration_is_live = False self._database_lock_task: DatabaseLockTask | None = None self._db_executor: DBInterruptibleThreadPoolExecutor | None = None - self._exclude_attributes_by_domain = exclude_attributes_by_domain self._event_listener: CALLBACK_TYPE | None = None self._queue_watcher: CALLBACK_TYPE | None = None @@ -507,11 +491,9 @@ class Recorder(threading.Thread): If the number of entities has increased, increase the size of the LRU cache to avoid thrashing. """ - state_attributes_lru = self._state_attributes_ids - current_size = state_attributes_lru.get_size() new_size = self.hass.states.async_entity_ids_count() * 2 - if new_size > current_size: - state_attributes_lru.set_size(new_size) + self.state_attributes_manager.adjust_lru_size(new_size) + self.states_meta_manager.adjust_lru_size(new_size) @callback def async_periodic_statistics(self) -> None: @@ -776,33 +758,10 @@ class Recorder(threading.Thread): non_state_change_events.append(event_) assert self.event_session is not None - self._pre_process_state_change_events(state_change_events) self.event_data_manager.load(non_state_change_events, self.event_session) self.event_type_manager.load(non_state_change_events, self.event_session) self.states_meta_manager.load(state_change_events, self.event_session) - - def _pre_process_state_change_events(self, events: list[Event]) -> None: - """Load startup state attributes from the database. - - Since the _state_attributes_ids cache is empty at startup - we restore it from the database to avoid having to look up - the attributes in the database for every state change - until its primed. - """ - assert self.event_session is not None - if hashes := { - StateAttributes.hash_shared_attrs_bytes(shared_attrs_bytes) - for event in events - if ( - shared_attrs_bytes := self._serialize_state_attributes_from_event(event) - ) - }: - with self.event_session.no_autoflush: - for hash_chunk in chunked(hashes, SQLITE_MAX_BIND_VARS): - for id_, shared_attrs in self.event_session.execute( - get_shared_attributes(hash_chunk) - ).fetchall(): - self._state_attributes_ids[shared_attrs] = id_ + self.state_attributes_manager.load(state_change_events, self.event_session) def _guarded_process_one_task_or_recover(self, task: RecorderTask) -> None: """Process a task, guarding against exceptions to ensure the loop does not collapse.""" @@ -932,24 +891,6 @@ class Recorder(threading.Thread): if not self.commit_interval: self._commit_event_session_or_retry() - def _find_shared_attr_in_db(self, attr_hash: int, shared_attrs: str) -> int | None: - """Find shared attributes in the db from the hash and shared_attrs.""" - # - # Avoid the event session being flushed since it will - # commit all the pending events and states to the database. - # - # The lookup has already have checked to see if the data is cached - # or going to be written in the next commit so there is no - # need to flush before checking the database. - # - assert self.event_session is not None - with self.event_session.no_autoflush: - if attributes_id := self.event_session.execute( - find_shared_attributes_id(attr_hash, shared_attrs) - ).first(): - return cast(int, attributes_id[0]) - return None - def _process_non_state_changed_event_into_session(self, event: Event) -> None: """Process any event into the session except state changed.""" session = self.event_session @@ -996,67 +937,53 @@ class Recorder(threading.Thread): session.add(dbevent) - def _serialize_state_attributes_from_event(self, event: Event) -> bytes | None: - """Serialize state changed event data.""" - try: - return StateAttributes.shared_attrs_bytes_from_event( - event, - self._entity_sources, - self._exclude_attributes_by_domain, - self.dialect_name, - ) - except JSON_ENCODE_EXCEPTIONS as ex: - _LOGGER.warning( - "State is not JSON serializable: %s: %s", - event.data.get("new_state"), - ex, - ) - return None - def _process_state_changed_event_into_session(self, event: Event) -> None: """Process a state_changed event into the session.""" + state_attributes_manager = self.state_attributes_manager dbstate = States.from_event(event) if (entity_id := dbstate.entity_id) is None or not ( - shared_attrs_bytes := self._serialize_state_attributes_from_event(event) + shared_attrs_bytes := state_attributes_manager.serialize_from_event(event) ): return assert self.event_session is not None - event_session = self.event_session + session = self.event_session # Map the entity_id to the StatesMeta table states_meta_manager = self.states_meta_manager if pending_states_meta := states_meta_manager.get_pending(entity_id): dbstate.states_meta_rel = pending_states_meta - elif metadata_id := states_meta_manager.get(entity_id, event_session, True): + elif metadata_id := states_meta_manager.get(entity_id, session, True): dbstate.metadata_id = metadata_id else: states_meta = StatesMeta(entity_id=entity_id) states_meta_manager.add_pending(states_meta) - event_session.add(states_meta) + session.add(states_meta) dbstate.states_meta_rel = states_meta + # Map the event data to the StateAttributes table shared_attrs = shared_attrs_bytes.decode("utf-8") dbstate.attributes = None # Matching attributes found in the pending commit - if pending_attributes := self._pending_state_attributes.get(shared_attrs): - dbstate.state_attributes = pending_attributes + if pending_event_data := state_attributes_manager.get_pending(shared_attrs): + dbstate.state_attributes = pending_event_data # Matching attributes id found in the cache - elif attributes_id := self._state_attributes_ids.get(shared_attrs): + elif ( + attributes_id := state_attributes_manager.get_from_cache(shared_attrs) + ) or ( + (hash_ := StateAttributes.hash_shared_attrs_bytes(shared_attrs_bytes)) + and ( + attributes_id := state_attributes_manager.get( + shared_attrs, hash_, session + ) + ) + ): dbstate.attributes_id = attributes_id else: - attr_hash = StateAttributes.hash_shared_attrs_bytes(shared_attrs_bytes) - # Matching attributes found in the database - if attributes_id := self._find_shared_attr_in_db(attr_hash, shared_attrs): - dbstate.attributes_id = attributes_id - self._state_attributes_ids[shared_attrs] = attributes_id # No matching attributes found, save them in the DB - else: - dbstate_attributes = StateAttributes( - shared_attrs=shared_attrs, hash=attr_hash - ) - dbstate.state_attributes = dbstate_attributes - self._pending_state_attributes[shared_attrs] = dbstate_attributes - self.event_session.add(dbstate_attributes) + dbstate_attributes = StateAttributes(shared_attrs=shared_attrs, hash=hash_) + state_attributes_manager.add_pending(dbstate_attributes) + session.add(dbstate_attributes) + dbstate.state_attributes = dbstate_attributes if old_state := self._old_states.pop(entity_id, None): if old_state.state_id: @@ -1128,11 +1055,7 @@ class Recorder(threading.Thread): # and we now know the attributes_ids. We can save # many selects for matching attributes by loading them # into the LRU cache now. - for state_attr in self._pending_state_attributes.values(): - self._state_attributes_ids[ - state_attr.shared_attrs - ] = state_attr.attributes_id - self._pending_state_attributes = {} + self.state_attributes_manager.post_commit_pending() self.event_data_manager.post_commit_pending() self.event_type_manager.post_commit_pending() self.states_meta_manager.post_commit_pending() @@ -1158,8 +1081,7 @@ class Recorder(threading.Thread): def _close_event_session(self) -> None: """Close the event session.""" self._old_states.clear() - self._state_attributes_ids.clear() - self._pending_state_attributes.clear() + self.state_attributes_manager.reset() self.event_data_manager.reset() self.event_type_manager.reset() self.states_meta_manager.reset() diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 5dffead597..08122b9fba 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -479,28 +479,6 @@ def _evict_purged_states_from_old_states_cache( old_states.pop(old_state_reversed[purged_state_id], None) -def _evict_purged_attributes_from_attributes_cache( - instance: Recorder, purged_attributes_ids: set[int] -) -> None: - """Evict purged attribute ids from the attribute ids cache.""" - # Make a map from attributes_id to the attributes json - state_attributes_ids = ( - instance._state_attributes_ids # pylint: disable=protected-access - ) - state_attributes_ids_reversed = { - attributes_id: attributes - for attributes, attributes_id in state_attributes_ids.items() - } - - # Evict any purged attributes from the state_attributes_ids cache - for purged_attribute_id in purged_attributes_ids.intersection( - state_attributes_ids_reversed - ): - state_attributes_ids.pop( - state_attributes_ids_reversed[purged_attribute_id], None - ) - - def _purge_batch_attributes_ids( instance: Recorder, session: Session, attributes_ids: set[int] ) -> None: @@ -512,7 +490,7 @@ def _purge_batch_attributes_ids( _LOGGER.debug("Deleted %s attribute states", deleted_rows) # Evict any entries in the state_attributes_ids cache referring to a purged state - _evict_purged_attributes_from_attributes_cache(instance, attributes_ids) + instance.state_attributes_manager.evict_purged(attributes_ids) def _purge_batch_data_ids( diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py index 0882da9d48..5a2c7040f4 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -74,17 +74,6 @@ def find_states_metadata_ids(entity_ids: Iterable[str]) -> StatementLambdaElemen ) -def find_shared_attributes_id( - data_hash: int, shared_attrs: str -) -> StatementLambdaElement: - """Find an attributes_id by hash and shared_attrs.""" - return lambda_stmt( - lambda: select(StateAttributes.attributes_id) - .filter(StateAttributes.hash == data_hash) - .filter(StateAttributes.shared_attrs == shared_attrs) - ) - - def _state_attrs_exist(attr: int | None) -> Select: """Check if a state attributes id exists in the states table.""" # https://github.com/sqlalchemy/sqlalchemy/issues/9189 diff --git a/homeassistant/components/recorder/table_managers/__init__.py b/homeassistant/components/recorder/table_managers/__init__.py index 50ea8f0e11..e56ee4f341 100644 --- a/homeassistant/components/recorder/table_managers/__init__.py +++ b/homeassistant/components/recorder/table_managers/__init__.py @@ -1,15 +1,75 @@ """Managers for each table.""" -from typing import TYPE_CHECKING +from collections.abc import MutableMapping +from typing import TYPE_CHECKING, Generic, TypeVar + +from lru import LRU # pylint: disable=no-name-in-module if TYPE_CHECKING: from ..core import Recorder +_DataT = TypeVar("_DataT") -class BaseTableManager: + +class BaseTableManager(Generic[_DataT]): """Base class for table managers.""" def __init__(self, recorder: "Recorder") -> None: - """Initialize the table manager.""" + """Initialize the table manager. + + The table manager is responsible for managing the id mappings + for a table. When data is committed to the database, the + manager will move the data from the pending to the id map. + """ self.active = False self.recorder = recorder + self._pending: dict[str, _DataT] = {} + self._id_map: MutableMapping[str, int] = {} + + def get_from_cache(self, data: str) -> int | None: + """Resolve data to the id without accessing the underlying database. + + This call is not thread-safe and must be called from the + recorder thread. + """ + return self._id_map.get(data) + + def get_pending(self, shared_data: str) -> _DataT | None: + """Get pending data that have not be assigned ids yet. + + This call is not thread-safe and must be called from the + recorder thread. + """ + return self._pending.get(shared_data) + + def reset(self) -> None: + """Reset after the database has been reset or changed. + + This call is not thread-safe and must be called from the + recorder thread. + """ + self._id_map.clear() + self._pending.clear() + + +class BaseLRUTableManager(BaseTableManager[_DataT]): + """Base class for LRU table managers.""" + + def __init__(self, recorder: "Recorder", lru_size: int) -> None: + """Initialize the LRU table manager. + + We keep track of the most recently used items + and evict the least recently used items when the cache is full. + """ + super().__init__(recorder) + self._id_map: MutableMapping[str, int] = LRU(lru_size) + + def adjust_lru_size(self, new_size: int) -> None: + """Adjust the LRU cache size. + + This call is not thread-safe and must be called from the + recorder thread. + """ + lru: LRU = self._id_map + if new_size > lru.get_size(): + lru.set_size(new_size) diff --git a/homeassistant/components/recorder/table_managers/event_data.py b/homeassistant/components/recorder/table_managers/event_data.py index c877f08f87..a99b25fe0b 100644 --- a/homeassistant/components/recorder/table_managers/event_data.py +++ b/homeassistant/components/recorder/table_managers/event_data.py @@ -5,13 +5,12 @@ from collections.abc import Iterable import logging from typing import TYPE_CHECKING, cast -from lru import LRU # pylint: disable=no-name-in-module from sqlalchemy.orm.session import Session from homeassistant.core import Event from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS -from . import BaseTableManager +from . import BaseLRUTableManager from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import EventData from ..queries import get_shared_event_datas @@ -26,14 +25,12 @@ CACHE_SIZE = 2048 _LOGGER = logging.getLogger(__name__) -class EventDataManager(BaseTableManager): +class EventDataManager(BaseLRUTableManager[EventData]): """Manage the EventData table.""" def __init__(self, recorder: Recorder) -> None: """Initialize the event type manager.""" - self._id_map: dict[str, int] = LRU(CACHE_SIZE) - self._pending: dict[str, EventData] = {} - super().__init__(recorder) + super().__init__(recorder, CACHE_SIZE) self.active = True # always active def serialize_from_event(self, event: Event) -> bytes | None: @@ -67,14 +64,6 @@ class EventDataManager(BaseTableManager): """ return self.get_many(((shared_data, data_hash),), session)[shared_data] - def get_from_cache(self, shared_data: str) -> int | None: - """Resolve shared_data to the data_id without accessing the underlying database. - - This call is not thread-safe and must be called from the - recorder thread. - """ - return self._id_map.get(shared_data) - def get_many( self, shared_data_data_hashs: Iterable[tuple[str, int]], session: Session ) -> dict[str, int | None]: @@ -116,14 +105,6 @@ class EventDataManager(BaseTableManager): return results - def get_pending(self, shared_data: str) -> EventData | None: - """Get pending EventData that have not be assigned ids yet. - - This call is not thread-safe and must be called from the - recorder thread. - """ - return self._pending.get(shared_data) - def add_pending(self, db_event_data: EventData) -> None: """Add a pending EventData that will be committed at the next interval. @@ -144,15 +125,6 @@ class EventDataManager(BaseTableManager): self._id_map[shared_data] = db_event_data.data_id self._pending.clear() - def reset(self) -> None: - """Reset the event manager after the database has been reset or changed. - - This call is not thread-safe and must be called from the - recorder thread. - """ - self._id_map.clear() - self._pending.clear() - def evict_purged(self, data_ids: set[int]) -> None: """Evict purged data_ids from the cache when they are no longer used. diff --git a/homeassistant/components/recorder/table_managers/event_types.py b/homeassistant/components/recorder/table_managers/event_types.py index b31382336c..3cb3d9fad9 100644 --- a/homeassistant/components/recorder/table_managers/event_types.py +++ b/homeassistant/components/recorder/table_managers/event_types.py @@ -4,12 +4,11 @@ from __future__ import annotations from collections.abc import Iterable from typing import TYPE_CHECKING, cast -from lru import LRU # pylint: disable=no-name-in-module from sqlalchemy.orm.session import Session from homeassistant.core import Event -from . import BaseTableManager +from . import BaseLRUTableManager from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import EventTypes from ..queries import find_event_type_ids @@ -22,14 +21,12 @@ if TYPE_CHECKING: CACHE_SIZE = 2048 -class EventTypeManager(BaseTableManager): +class EventTypeManager(BaseLRUTableManager[EventTypes]): """Manage the EventTypes table.""" def __init__(self, recorder: Recorder) -> None: """Initialize the event type manager.""" - self._id_map: dict[str, int] = LRU(CACHE_SIZE) - self._pending: dict[str, EventTypes] = {} - super().__init__(recorder) + super().__init__(recorder, CACHE_SIZE) def load(self, events: list[Event], session: Session) -> None: """Load the event_type to event_type_ids mapping into memory. @@ -80,14 +77,6 @@ class EventTypeManager(BaseTableManager): return results - def get_pending(self, event_type: str) -> EventTypes | None: - """Get pending EventTypes that have not be assigned ids yet. - - This call is not thread-safe and must be called from the - recorder thread. - """ - return self._pending.get(event_type) - def add_pending(self, db_event_type: EventTypes) -> None: """Add a pending EventTypes that will be committed at the next interval. @@ -108,15 +97,6 @@ class EventTypeManager(BaseTableManager): self._id_map[event_type] = db_event_types.event_type_id self._pending.clear() - def reset(self) -> None: - """Reset the event manager after the database has been reset or changed. - - This call is not thread-safe and must be called from the - recorder thread. - """ - self._id_map.clear() - self._pending.clear() - def evict_purged(self, event_types: Iterable[str]) -> None: """Evict purged event_types from the cache when they are no longer used. diff --git a/homeassistant/components/recorder/table_managers/state_attributes.py b/homeassistant/components/recorder/table_managers/state_attributes.py new file mode 100644 index 0000000000..7489a6f165 --- /dev/null +++ b/homeassistant/components/recorder/table_managers/state_attributes.py @@ -0,0 +1,160 @@ +"""Support managing StateAttributes.""" +from __future__ import annotations + +from collections.abc import Iterable +import logging +from typing import TYPE_CHECKING, cast + +from sqlalchemy.orm.session import Session + +from homeassistant.core import Event +from homeassistant.helpers.entity import entity_sources +from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS + +from . import BaseLRUTableManager +from ..const import SQLITE_MAX_BIND_VARS +from ..db_schema import StateAttributes +from ..queries import get_shared_attributes +from ..util import chunked + +if TYPE_CHECKING: + from ..core import Recorder + +# The number of attribute ids to cache in memory +# +# Based on: +# - The number of overlapping attributes +# - How frequently states with overlapping attributes will change +# - How much memory our low end hardware has +CACHE_SIZE = 2048 + +_LOGGER = logging.getLogger(__name__) + + +class StateAttributesManager(BaseLRUTableManager[StateAttributes]): + """Manage the StateAttributes table.""" + + def __init__( + self, recorder: Recorder, exclude_attributes_by_domain: dict[str, set[str]] + ) -> None: + """Initialize the event type manager.""" + super().__init__(recorder, CACHE_SIZE) + self.active = True # always active + self._exclude_attributes_by_domain = exclude_attributes_by_domain + self._entity_sources = entity_sources(recorder.hass) + + def serialize_from_event(self, event: Event) -> bytes | None: + """Serialize event data.""" + try: + return StateAttributes.shared_attrs_bytes_from_event( + event, + self._entity_sources, + self._exclude_attributes_by_domain, + self.recorder.dialect_name, + ) + except JSON_ENCODE_EXCEPTIONS as ex: + _LOGGER.warning( + "State is not JSON serializable: %s: %s", + event.data.get("new_state"), + ex, + ) + return None + + def load(self, events: list[Event], session: Session) -> None: + """Load the shared_attrs to attributes_ids mapping into memory from events. + + This call is not thread-safe and must be called from the + recorder thread. + """ + if hashes := { + StateAttributes.hash_shared_attrs_bytes(shared_attrs_bytes) + for event in events + if (shared_attrs_bytes := self.serialize_from_event(event)) + }: + self._load_from_hashes(hashes, session) + + def get(self, shared_attr: str, data_hash: int, session: Session) -> int | None: + """Resolve shared_attrs to the attributes_id. + + This call is not thread-safe and must be called from the + recorder thread. + """ + return self.get_many(((shared_attr, data_hash),), session)[shared_attr] + + def get_many( + self, shared_attrs_data_hashes: Iterable[tuple[str, int]], session: Session + ) -> dict[str, int | None]: + """Resolve shared_attrs to attributes_ids. + + This call is not thread-safe and must be called from the + recorder thread. + """ + results: dict[str, int | None] = {} + missing_hashes: set[int] = set() + for shared_attrs, data_hash in shared_attrs_data_hashes: + if (attributes_id := self._id_map.get(shared_attrs)) is None: + missing_hashes.add(data_hash) + + results[shared_attrs] = attributes_id + + if not missing_hashes: + return results + + return results | self._load_from_hashes(missing_hashes, session) + + def _load_from_hashes( + self, hashes: Iterable[int], session: Session + ) -> dict[str, int | None]: + """Load the shared_attrs to attributes_ids mapping into memory from a list of hashes. + + This call is not thread-safe and must be called from the + recorder thread. + """ + results: dict[str, int | None] = {} + with session.no_autoflush: + for hashs_chunk in chunked(hashes, SQLITE_MAX_BIND_VARS): + for attributes_id, shared_attrs in session.execute( + get_shared_attributes(hashs_chunk) + ): + results[shared_attrs] = self._id_map[shared_attrs] = cast( + int, attributes_id + ) + + return results + + def add_pending(self, db_state_attributes: StateAttributes) -> None: + """Add a pending StateAttributes that will be committed at the next interval. + + This call is not thread-safe and must be called from the + recorder thread. + """ + assert db_state_attributes.shared_attrs is not None + shared_attrs: str = db_state_attributes.shared_attrs + self._pending[shared_attrs] = db_state_attributes + + def post_commit_pending(self) -> None: + """Call after commit to load the attributes_ids of the new StateAttributes into the LRU. + + This call is not thread-safe and must be called from the + recorder thread. + """ + for shared_attrs, db_state_attributes in self._pending.items(): + self._id_map[shared_attrs] = db_state_attributes.attributes_id + self._pending.clear() + + def evict_purged(self, attributes_ids: set[int]) -> None: + """Evict purged attributes_ids from the cache when they are no longer used. + + This call is not thread-safe and must be called from the + recorder thread. + """ + id_map = self._id_map + state_attributes_ids_reversed = { + attributes_id: shared_attrs + for shared_attrs, attributes_id in id_map.items() + } + # Evict any purged data from the cache + for purged_attributes_id in attributes_ids.intersection( + state_attributes_ids_reversed + ): + id_map.pop(state_attributes_ids_reversed[purged_attributes_id], None) diff --git a/homeassistant/components/recorder/table_managers/states_meta.py b/homeassistant/components/recorder/table_managers/states_meta.py index ded1690df1..b8b763aae3 100644 --- a/homeassistant/components/recorder/table_managers/states_meta.py +++ b/homeassistant/components/recorder/table_managers/states_meta.py @@ -4,12 +4,11 @@ from __future__ import annotations from collections.abc import Iterable from typing import TYPE_CHECKING, cast -from lru import LRU # pylint: disable=no-name-in-module from sqlalchemy.orm.session import Session from homeassistant.core import Event -from . import BaseTableManager +from . import BaseLRUTableManager from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import StatesMeta from ..queries import find_all_states_metadata_ids, find_states_metadata_ids @@ -21,15 +20,13 @@ if TYPE_CHECKING: CACHE_SIZE = 8192 -class StatesMetaManager(BaseTableManager): +class StatesMetaManager(BaseLRUTableManager[StatesMeta]): """Manage the StatesMeta table.""" def __init__(self, recorder: Recorder) -> None: """Initialize the states meta manager.""" - self._id_map: dict[str, int] = LRU(CACHE_SIZE) - self._pending: dict[str, StatesMeta] = {} self._did_first_load = False - super().__init__(recorder) + super().__init__(recorder, CACHE_SIZE) def load(self, events: list[Event], session: Session) -> None: """Load the entity_id to metadata_id mapping into memory. @@ -112,14 +109,6 @@ class StatesMetaManager(BaseTableManager): return results - def get_pending(self, entity_id: str) -> StatesMeta | None: - """Get pending StatesMeta that have not be assigned ids yet. - - This call is not thread-safe and must be called from the - recorder thread. - """ - return self._pending.get(entity_id) - def add_pending(self, db_states_meta: StatesMeta) -> None: """Add a pending StatesMeta that will be committed at the next interval. @@ -140,15 +129,6 @@ class StatesMetaManager(BaseTableManager): self._id_map[entity_id] = db_states_meta.metadata_id self._pending.clear() - def reset(self) -> None: - """Reset the states meta manager after the database has been reset or changed. - - This call is not thread-safe and must be called from the - recorder thread. - """ - self._id_map.clear() - self._pending.clear() - def evict_purged(self, entity_ids: Iterable[str]) -> None: """Evict purged event_types from the cache when they are no longer used. diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 5355931a76..ed804087d8 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -1872,9 +1872,11 @@ def test_deduplication_event_data_inside_commit_interval( assert all(event.data_id == first_data_id for event in events) -# Patch STATE_ATTRIBUTES_ID_CACHE_SIZE since otherwise +# Patch CACHE_SIZE since otherwise # the CI can fail because the test takes too long to run -@patch("homeassistant.components.recorder.core.STATE_ATTRIBUTES_ID_CACHE_SIZE", 5) +@patch( + "homeassistant.components.recorder.table_managers.state_attributes.CACHE_SIZE", 5 +) def test_deduplication_state_attributes_inside_commit_interval( hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture ) -> None: @@ -2159,4 +2161,8 @@ async def test_lru_increases_with_many_entities( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) await async_wait_recording_done(hass) - assert recorder_mock._state_attributes_ids.get_size() == mock_entity_count * 2 + assert ( + recorder_mock.state_attributes_manager._id_map.get_size() + == mock_entity_count * 2 + ) + assert recorder_mock.states_meta_manager._id_map.get_size() == mock_entity_count * 2 From 4080d6848943a4ee063f4d9435a707b9a5db1277 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Mar 2023 15:29:41 -1000 Subject: [PATCH 0975/2810] Fix logbook tests failing because time was not url encoded correctly (#89770) --- tests/components/logbook/test_init.py | 120 +++++++++++++++----------- 1 file changed, 71 insertions(+), 49 deletions(-) diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index a3e240f682..c4f2f0a9ee 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -417,7 +417,7 @@ async def test_logbook_view_period_entity( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries without filters response = await client.get(f"/api/logbook/{start_date.isoformat()}") @@ -455,7 +455,7 @@ async def test_logbook_view_period_entity( # Tomorrow time 00:00:00 start = (dt_util.utcnow() + timedelta(days=1)).date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test tomorrow entries without filters response = await client.get(f"/api/logbook/{start_date.isoformat()}") @@ -512,12 +512,13 @@ async def test_logbook_describe_event( client = await hass_client() # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries with filter by end_time - end_time = start + timedelta(hours=24) + end_time = start_date + timedelta(hours=24) response = await client.get( - f"/api/logbook/{start_date.isoformat()}?end_time={end_time}" + f"/api/logbook/{start_date.isoformat()}", + params={"end_time": end_time.isoformat()}, ) results = await response.json() assert len(results) == 1 @@ -586,12 +587,13 @@ async def test_exclude_described_event( client = await hass_client() # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries with filter by end_time - end_time = start + timedelta(hours=24) + end_time = start_date + timedelta(hours=24) response = await client.get( - f"/api/logbook/{start_date.isoformat()}?end_time={end_time}" + f"/api/logbook/{start_date.isoformat()}", + params={"end_time": end_time.isoformat()}, ) results = await response.json() assert len(results) == 1 @@ -619,12 +621,13 @@ async def test_logbook_view_end_time_entity( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries with filter by end_time - end_time = start + timedelta(hours=24) + end_time = start_date + timedelta(hours=24) response = await client.get( - f"/api/logbook/{start_date.isoformat()}?end_time={end_time}" + f"/api/logbook/{start_date.isoformat()}", + params={"end_time": end_time.isoformat()}, ) assert response.status == HTTPStatus.OK response_json = await response.json() @@ -635,7 +638,8 @@ async def test_logbook_view_end_time_entity( # Test entries for 3 days with filter by entity_id end_time = start + timedelta(hours=72) response = await client.get( - f"/api/logbook/{start_date.isoformat()}?end_time={end_time}&entity=switch.test" + f"/api/logbook/{start_date.isoformat()}", + params={"end_time": end_time.isoformat(), "entity": "switch.test"}, ) assert response.status == HTTPStatus.OK response_json = await response.json() @@ -644,15 +648,16 @@ async def test_logbook_view_end_time_entity( # Tomorrow time 00:00:00 start = dt_util.utcnow() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test entries from today to 3 days with filter by entity_id end_time = start_date + timedelta(hours=72) response = await client.get( - f"/api/logbook/{start_date.isoformat()}?end_time={end_time}&entity=switch.test" + f"/api/logbook/{start_date.isoformat()}", + params={"end_time": end_time.isoformat(), "entity": "switch.test"}, ) - assert response.status == HTTPStatus.OK response_json = await response.json() + assert response.status == HTTPStatus.OK assert len(response_json) == 1 assert response_json[0]["entity_id"] == entity_id_test @@ -693,12 +698,13 @@ async def test_logbook_entity_filter_with_automations( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries with filter by end_time - end_time = start + timedelta(hours=24) + end_time = start_date + timedelta(hours=24) response = await client.get( - f"/api/logbook/{start_date.isoformat()}?end_time={end_time}" + f"/api/logbook/{start_date.isoformat()}", + params={"end_time": end_time.isoformat()}, ) assert response.status == HTTPStatus.OK json_dict = await response.json() @@ -712,7 +718,11 @@ async def test_logbook_entity_filter_with_automations( # Test entries for 3 days with filter by entity_id end_time = start + timedelta(hours=72) response = await client.get( - f"/api/logbook/{start_date.isoformat()}?end_time={end_time}&entity=alarm_control_panel.area_001" + f"/api/logbook/{start_date.isoformat()}", + params={ + "end_time": end_time.isoformat(), + "entity": "alarm_control_panel.area_001", + }, ) assert response.status == HTTPStatus.OK json_dict = await response.json() @@ -721,12 +731,16 @@ async def test_logbook_entity_filter_with_automations( # Tomorrow time 00:00:00 start = dt_util.utcnow() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test entries from today to 3 days with filter by entity_id end_time = start_date + timedelta(hours=72) response = await client.get( - f"/api/logbook/{start_date.isoformat()}?end_time={end_time}&entity=alarm_control_panel.area_002" + f"/api/logbook/{start_date.isoformat()}", + params={ + "end_time": end_time.isoformat(), + "entity": "alarm_control_panel.area_002", + }, ) assert response.status == HTTPStatus.OK json_dict = await response.json() @@ -760,12 +774,13 @@ async def test_logbook_entity_no_longer_in_state_machine( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries with filter by end_time - end_time = start + timedelta(hours=24) + end_time = start_date + timedelta(hours=24) response = await client.get( - f"/api/logbook/{start_date.isoformat()}?end_time={end_time}" + f"/api/logbook/{start_date.isoformat()}", + params={"end_time": end_time.isoformat()}, ) assert response.status == HTTPStatus.OK json_dict = await response.json() @@ -804,7 +819,7 @@ async def test_filter_continuous_sensor_values( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries without filters response = await client.get(f"/api/logbook/{start_date.isoformat()}") @@ -845,7 +860,7 @@ async def test_exclude_new_entities( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries without filters response = await client.get(f"/api/logbook/{start_date.isoformat()}") @@ -893,7 +908,7 @@ async def test_exclude_removed_entities( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries without filters response = await client.get(f"/api/logbook/{start_date.isoformat()}") @@ -939,7 +954,7 @@ async def test_exclude_attribute_changes( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries without filters response = await client.get(f"/api/logbook/{start_date.isoformat()}") @@ -1053,12 +1068,13 @@ async def test_logbook_entity_context_id( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries with filter by end_time - end_time = start + timedelta(hours=24) + end_time = start_date + timedelta(hours=24) response = await client.get( - f"/api/logbook/{start_date.isoformat()}?end_time={end_time}" + f"/api/logbook/{start_date.isoformat()}", + params={"end_time": end_time.isoformat()}, ) assert response.status == HTTPStatus.OK json_dict = await response.json() @@ -1159,12 +1175,13 @@ async def test_logbook_context_id_automation_script_started_manually( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries with filter by end_time - end_time = start + timedelta(hours=24) + end_time = start_date + timedelta(hours=24) response = await client.get( - f"/api/logbook/{start_date.isoformat()}?end_time={end_time}" + f"/api/logbook/{start_date.isoformat()}", + params={"end_time": end_time.isoformat()}, ) assert response.status == HTTPStatus.OK json_dict = await response.json() @@ -1317,12 +1334,13 @@ async def test_logbook_entity_context_parent_id( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries with filter by end_time - end_time = start + timedelta(hours=24) + end_time = start_date + timedelta(hours=24) response = await client.get( - f"/api/logbook/{start_date.isoformat()}?end_time={end_time}" + f"/api/logbook/{start_date.isoformat()}", + params={"end_time": end_time.isoformat()}, ) assert response.status == HTTPStatus.OK json_dict = await response.json() @@ -1435,12 +1453,13 @@ async def test_logbook_context_from_template( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries with filter by end_time - end_time = start + timedelta(hours=24) + end_time = start_date + timedelta(hours=24) response = await client.get( - f"/api/logbook/{start_date.isoformat()}?end_time={end_time}" + f"/api/logbook/{start_date.isoformat()}", + params={"end_time": end_time.isoformat()}, ) assert response.status == HTTPStatus.OK json_dict = await response.json() @@ -1518,7 +1537,7 @@ async def test_logbook_( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries with filter by end_time end_time = start + timedelta(hours=24) @@ -1561,7 +1580,7 @@ async def test_logbook_many_entities_multiple_calls( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) end_time = start + timedelta(hours=24) for automation_id in range(5): @@ -1628,7 +1647,7 @@ async def test_custom_log_entry_discoverable_via_( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries with filter by end_time end_time = start + timedelta(hours=24) @@ -1708,7 +1727,7 @@ async def test_logbook_multiple_entities( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries with filter by end_time end_time = start + timedelta(hours=24) @@ -1781,7 +1800,7 @@ async def test_logbook_invalid_entity( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries with filter by end_time end_time = start + timedelta(hours=24) @@ -2333,11 +2352,14 @@ async def _async_fetch_logbook(client, params=None): params = {} # Today time 00:00:00 - start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) - timedelta(hours=24) + now = dt_util.utcnow() + start = datetime(now.year, now.month, now.day, tzinfo=dt_util.UTC) + start_date = datetime( + start.year, start.month, start.day, tzinfo=dt_util.UTC + ) - timedelta(hours=24) if "end_time" not in params: - params["end_time"] = str(start + timedelta(hours=48)) + params["end_time"] = (start + timedelta(hours=48)).isoformat() # Test today entries without filters response = await client.get(f"/api/logbook/{start_date.isoformat()}", params=params) @@ -2825,7 +2847,7 @@ async def test_logbook_select_entities_context_id( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries with filter by end_time end_time = start + timedelta(hours=24) From 99d6b1fa5731195384fa2e055c966e6cb3753899 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Mar 2023 16:19:43 -1000 Subject: [PATCH 0976/2810] Migrate States to use a table manager (#89769) --- homeassistant/components/recorder/core.py | 30 +++--- homeassistant/components/recorder/purge.py | 20 +--- .../recorder/table_managers/states.py | 91 +++++++++++++++++++ tests/components/recorder/test_purge.py | 10 +- 4 files changed, 109 insertions(+), 42 deletions(-) create mode 100644 homeassistant/components/recorder/table_managers/states.py diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index e7fdf64581..3468acaed4 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -84,6 +84,7 @@ from .run_history import RunHistory from .table_managers.event_data import EventDataManager from .table_managers.event_types import EventTypeManager from .table_managers.state_attributes import StateAttributesManager +from .table_managers.states import StatesManager from .table_managers.states_meta import StatesMetaManager from .tasks import ( AdjustLRUSizeTask, @@ -200,14 +201,13 @@ class Recorder(threading.Thread): self.schema_version = 0 self._commits_without_expire = 0 - self._old_states: dict[str | None, States] = {} + self.states_manager = StatesManager() self.event_data_manager = EventDataManager(self) self.event_type_manager = EventTypeManager(self) self.states_meta_manager = StatesMetaManager(self) self.state_attributes_manager = StateAttributesManager( self, exclude_attributes_by_domain ) - self._pending_expunge: list[States] = [] self.event_session: Session | None = None self._get_session: Callable[[], Session] | None = None self._completed_first_database_setup: bool | None = None @@ -985,14 +985,13 @@ class Recorder(threading.Thread): session.add(dbstate_attributes) dbstate.state_attributes = dbstate_attributes - if old_state := self._old_states.pop(entity_id, None): - if old_state.state_id: - dbstate.old_state_id = old_state.state_id - else: - dbstate.old_state = old_state + states_manager = self.states_manager + if old_state := states_manager.pop_pending(entity_id): + dbstate.old_state = old_state + elif old_state_id := states_manager.pop_committed(entity_id): + dbstate.old_state_id = old_state_id if event.data.get("new_state"): - self._old_states[entity_id] = dbstate - self._pending_expunge.append(dbstate) + states_manager.add_pending(entity_id, dbstate) else: dbstate.state = None @@ -1043,18 +1042,11 @@ class Recorder(threading.Thread): self._commits_without_expire += 1 self.event_session.commit() - if self._pending_expunge: - for dbstate in self._pending_expunge: - # Expunge the state so its not expired - # until we use it later for dbstate.old_state - if dbstate in self.event_session: - self.event_session.expunge(dbstate) - self._pending_expunge = [] - # We just committed the state attributes to the database # and we now know the attributes_ids. We can save # many selects for matching attributes by loading them - # into the LRU cache now. + # into the LRU or committed now. + self.states_manager.post_commit_pending() self.state_attributes_manager.post_commit_pending() self.event_data_manager.post_commit_pending() self.event_type_manager.post_commit_pending() @@ -1080,7 +1072,7 @@ class Recorder(threading.Thread): def _close_event_session(self) -> None: """Close the event session.""" - self._old_states.clear() + self.states_manager.reset() self.state_attributes_manager.reset() self.event_data_manager.reset() self.event_type_manager.reset() diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 08122b9fba..528cb1247f 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -459,24 +459,7 @@ def _purge_state_ids(instance: Recorder, session: Session, state_ids: set[int]) _LOGGER.debug("Deleted %s states", deleted_rows) # Evict eny entries in the old_states cache referring to a purged state - _evict_purged_states_from_old_states_cache(instance, state_ids) - - -def _evict_purged_states_from_old_states_cache( - instance: Recorder, purged_state_ids: set[int] -) -> None: - """Evict purged states from the old states cache.""" - # Make a map from old_state_id to entity_id - old_states = instance._old_states # pylint: disable=protected-access - old_state_reversed = { - old_state.state_id: entity_id - for entity_id, old_state in old_states.items() - if old_state.state_id - } - - # Evict any purged state from the old states cache - for purged_state_id in purged_state_ids.intersection(old_state_reversed): - old_states.pop(old_state_reversed[purged_state_id], None) + instance.states_manager.evict_purged_state_ids(state_ids) def _purge_batch_attributes_ids( @@ -576,6 +559,7 @@ def _purge_old_entity_ids(instance: Recorder, session: Session) -> None: # Evict any entries in the event_type cache referring to a purged state instance.states_meta_manager.evict_purged(purge_entity_ids) + instance.states_manager.evict_purged_entity_ids(purge_entity_ids) def _purge_filtered_data(instance: Recorder, session: Session) -> bool: diff --git a/homeassistant/components/recorder/table_managers/states.py b/homeassistant/components/recorder/table_managers/states.py new file mode 100644 index 0000000000..fcfdcef089 --- /dev/null +++ b/homeassistant/components/recorder/table_managers/states.py @@ -0,0 +1,91 @@ +"""Support managing States.""" +from __future__ import annotations + +from ..db_schema import States + + +class StatesManager: + """Manage the states table.""" + + def __init__(self) -> None: + """Initialize the states manager for linking old_state_id.""" + self._pending: dict[str, States] = {} + self._last_committed_id: dict[str, int] = {} + + def pop_pending(self, entity_id: str) -> States | None: + """Pop a pending state. + + Pending states are states that are in the session but not yet committed. + + This call is not thread-safe and must be called from the + recorder thread. + """ + return self._pending.pop(entity_id, None) + + def pop_committed(self, entity_id: str) -> int | None: + """Pop a committed state. + + Committed states are states that have already been committed to the + database. + + This call is not thread-safe and must be called from the + recorder thread. + """ + return self._last_committed_id.pop(entity_id, None) + + def add_pending(self, entity_id: str, state: States) -> None: + """Add a pending state. + + Pending states are states that are in the session but not yet committed. + + This call is not thread-safe and must be called from the + recorder thread. + """ + self._pending[entity_id] = state + + def post_commit_pending(self) -> None: + """Call after commit to load the state_id of the new States into committed. + + This call is not thread-safe and must be called from the + recorder thread. + """ + for entity_id, db_states in self._pending.items(): + self._last_committed_id[entity_id] = db_states.state_id + self._pending.clear() + + def reset(self) -> None: + """Reset after the database has been reset or changed. + + This call is not thread-safe and must be called from the + recorder thread. + """ + self._last_committed_id.clear() + self._pending.clear() + + def evict_purged_state_ids(self, purged_state_ids: set[int]) -> None: + """Evict purged states from the committed states. + + When we purge states we need to make sure the next call to record a state + does not link the old_state_id to the purged state. + """ + # Make a map from the committed state_id to the entity_id + last_committed_ids = self._last_committed_id + last_committed_ids_reversed = { + state_id: entity_id for entity_id, state_id in last_committed_ids.items() + } + + # Evict any purged state from the old states cache + for purged_state_id in purged_state_ids.intersection( + last_committed_ids_reversed + ): + last_committed_ids.pop(last_committed_ids_reversed[purged_state_id], None) + + def evict_purged_entity_ids(self, purged_entity_ids: set[str]) -> None: + """Evict purged entity_ids from the committed states. + + When we purge states we need to make sure the next call to record a state + does not link the old_state_id to the purged state. + """ + last_committed_ids = self._last_committed_id + for entity_id in purged_entity_ids: + last_committed_ids.pop(entity_id, None) diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index 2979b04e5c..f268325b21 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -82,7 +82,7 @@ async def test_purge_old_states( events = session.query(Events).filter(Events.event_type == "state_changed") assert events.count() == 0 - assert "test.recorder2" in instance._old_states + assert "test.recorder2" in instance.states_manager._last_committed_id purge_before = dt_util.utcnow() - timedelta(days=4) @@ -98,7 +98,7 @@ async def test_purge_old_states( assert states.count() == 2 assert state_attributes.count() == 1 - assert "test.recorder2" in instance._old_states + assert "test.recorder2" in instance.states_manager._last_committed_id states_after_purge = list(session.query(States)) # Since these states are deleted in batches, we can't guarantee the order @@ -115,7 +115,7 @@ async def test_purge_old_states( assert states.count() == 2 assert state_attributes.count() == 1 - assert "test.recorder2" in instance._old_states + assert "test.recorder2" in instance.states_manager._last_committed_id # run purge_old_data again purge_before = dt_util.utcnow() @@ -130,7 +130,7 @@ async def test_purge_old_states( assert states.count() == 0 assert state_attributes.count() == 0 - assert "test.recorder2" not in instance._old_states + assert "test.recorder2" not in instance.states_manager._last_committed_id # Add some more states await _add_test_states(hass) @@ -144,7 +144,7 @@ async def test_purge_old_states( events = session.query(Events).filter(Events.event_type == "state_changed") assert events.count() == 0 - assert "test.recorder2" in instance._old_states + assert "test.recorder2" in instance.states_manager._last_committed_id state_attributes = session.query(StateAttributes) assert state_attributes.count() == 3 From ed27dae173066670b3dd4f2baed23a928c2f3905 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Mar 2023 17:44:33 -1000 Subject: [PATCH 0977/2810] Small cleanups to recorder history (#89774) * Small cleanups to recorder history * Small cleanups to recorder history * fixes * flake8 cannot figure it out --- .../components/recorder/history/modern.py | 55 ++++++++----------- 1 file changed, 23 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index 6ac139cc78..a6ca9adf7b 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -5,7 +5,6 @@ from collections import defaultdict from collections.abc import Callable, Iterable, Iterator, MutableMapping from datetime import datetime from itertools import groupby -import logging from operator import itemgetter from typing import Any, cast @@ -24,12 +23,7 @@ import homeassistant.util.dt as dt_util from ... import recorder from ..db_schema import RecorderRuns, StateAttributes, States, StatesMeta from ..filters import Filters -from ..models import ( - LazyState, - process_timestamp, - process_timestamp_to_utc_isoformat, - row_to_compressed_state, -) +from ..models import LazyState, process_timestamp, row_to_compressed_state from ..util import execute_stmt_lambda_element, session_scope from .const import ( IGNORE_DOMAINS_ENTITY_ID_LIKE, @@ -40,9 +34,6 @@ from .const import ( STATE_KEY, ) -_LOGGER = logging.getLogger(__name__) - - _BASE_STATES = ( States.metadata_id, States.state, @@ -710,7 +701,7 @@ def _sorted_states_to_dict( # Append all changes to it for metadata_id, group in states_iter: attr_cache: dict[str, dict[str, Any]] = {} - prev_state: Column | str + prev_state: Column | str | None = None if not (entity_id := metadata_id_to_entity_id.get(metadata_id)): continue ent_results = result[entity_id] @@ -741,6 +732,7 @@ def _sorted_states_to_dict( ) state_idx = field_map["state"] + last_updated_ts_idx = field_map["last_updated_ts"] # # minimal_response only makes sense with last_updated == last_updated @@ -749,29 +741,28 @@ def _sorted_states_to_dict( # # With minimal response we do not care about attribute # changes so we can filter out duplicate states - last_updated_ts_idx = field_map["last_updated_ts"] if compressed_state_format: - for row in group: - if (state := row[state_idx]) != prev_state: - ent_results.append( - { - attr_state: state, - attr_time: row[last_updated_ts_idx], - } - ) - prev_state = state + # Compressed state format uses the timestamp directly + ent_results.extend( + { + attr_state: (prev_state := state), + attr_time: row[last_updated_ts_idx], + } + for row in group + if (state := row[state_idx]) != prev_state + ) + continue - for row in group: - if (state := row[state_idx]) != prev_state: - ent_results.append( - { - attr_state: state, - attr_time: process_timestamp_to_utc_isoformat( - dt_util.utc_from_timestamp(row[last_updated_ts_idx]) - ), - } - ) - prev_state = state + # Non-compressed state format returns an ISO formatted string + _utc_from_timestamp = dt_util.utc_from_timestamp + ent_results.extend( + { + attr_state: (prev_state := state), # noqa: F841 + attr_time: _utc_from_timestamp(row[last_updated_ts_idx]).isoformat(), + } + for row in group + if (state := row[state_idx]) != prev_state + ) # If there are no states beyond the initial state, # the state a was never popped from initial_states From ecf6922ade48559d7a4b8667335a778f0aa4f957 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Mar 2023 17:47:26 -1000 Subject: [PATCH 0978/2810] Bump yalexs_ble to 2.1.0 (#89772) switches to using cryptography to reduce the number of deps changelog: https://github.com/bdraco/yalexs-ble/compare/v2.0.4...v2.1.0 --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index dedfc9127a..eba6e2c1b3 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.2.7", "yalexs_ble==2.0.4"] + "requirements": ["yalexs==1.2.7", "yalexs_ble==2.1.0"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index e34ace05e1..e793fe2728 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.0.4"] + "requirements": ["yalexs-ble==2.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6b8b2f9780..8a4bb3ef53 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2670,13 +2670,13 @@ xs1-api-client==3.0.0 yalesmartalarmclient==0.3.9 # homeassistant.components.yalexs_ble -yalexs-ble==2.0.4 +yalexs-ble==2.1.0 # homeassistant.components.august yalexs==1.2.7 # homeassistant.components.august -yalexs_ble==2.0.4 +yalexs_ble==2.1.0 # homeassistant.components.yeelight yeelight==0.7.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0591758b9f..cc342ecabe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1904,13 +1904,13 @@ xmltodict==0.13.0 yalesmartalarmclient==0.3.9 # homeassistant.components.yalexs_ble -yalexs-ble==2.0.4 +yalexs-ble==2.1.0 # homeassistant.components.august yalexs==1.2.7 # homeassistant.components.august -yalexs_ble==2.0.4 +yalexs_ble==2.1.0 # homeassistant.components.yeelight yeelight==0.7.10 From 2365a884d2f0fb835205e41a274a3fe9d0279e07 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Thu, 16 Mar 2023 04:48:00 +0100 Subject: [PATCH 0979/2810] Secure modbus hub_collect remains valid (#89684) Secure hub_collect remains valid. --- homeassistant/components/modbus/modbus.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index abc1508d7d..b53cfda104 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -137,8 +137,10 @@ async def async_modbus_setup( for name in hubs: if not await hubs[name].async_setup(): return False + hub_collect = hass.data[DOMAIN] + else: + hass.data[DOMAIN] = hub_collect = {} - hass.data[DOMAIN] = hub_collect = {} for conf_hub in config[DOMAIN]: my_hub = ModbusHub(hass, conf_hub) hub_collect[conf_hub[CONF_NAME]] = my_hub From c707ddbf7cb8a2613e3223a41d09280965db5026 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Mar 2023 20:00:47 -1000 Subject: [PATCH 0980/2810] Bump aioesphomeapi to 13.5.1 (#89777) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 54cdc23355..95b6c091d5 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -14,6 +14,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], - "requirements": ["aioesphomeapi==13.5.0", "esphome-dashboard-api==1.2.3"], + "requirements": ["aioesphomeapi==13.5.1", "esphome-dashboard-api==1.2.3"], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 8a4bb3ef53..fb76c21073 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -156,7 +156,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.5.0 +aioesphomeapi==13.5.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cc342ecabe..6e88193d56 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -146,7 +146,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.5.0 +aioesphomeapi==13.5.1 # homeassistant.components.flo aioflo==2021.11.0 From 913156b0e021251b63835fe5c14be819f79de4f9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 16 Mar 2023 08:00:21 +0100 Subject: [PATCH 0981/2810] Avoid lingering timer on script shutdown (#89753) --- homeassistant/helpers/script.py | 19 ++++++++++++----- tests/components/automation/test_init.py | 2 ++ tests/components/script/test_init.py | 1 + tests/conftest.py | 27 ++++++++++++++++++++++++ tests/helpers/test_script.py | 2 ++ 5 files changed, 46 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 02fa9dc780..9ba4e7a9d8 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -66,6 +66,7 @@ from homeassistant.const import ( from homeassistant.core import ( SERVICE_CALL_LIMIT, Context, + Event, HassJob, HomeAssistant, callback, @@ -1074,7 +1075,17 @@ class _QueuedScriptRun(_ScriptRun): super()._finish() -async def _async_stop_scripts_after_shutdown(hass, point_in_time): +@callback +def _schedule_stop_scripts_after_shutdown(hass: HomeAssistant) -> None: + """Stop running Script objects started after shutdown.""" + async_call_later( + hass, _SHUTDOWN_MAX_WAIT, partial(_async_stop_scripts_after_shutdown, hass) + ) + + +async def _async_stop_scripts_after_shutdown( + hass: HomeAssistant, point_in_time: datetime +) -> None: """Stop running Script objects started after shutdown.""" hass.data[DATA_NEW_SCRIPT_RUNS_NOT_ALLOWED] = None running_scripts = [ @@ -1091,11 +1102,9 @@ async def _async_stop_scripts_after_shutdown(hass, point_in_time): ) -async def _async_stop_scripts_at_shutdown(hass, event): +async def _async_stop_scripts_at_shutdown(hass: HomeAssistant, event: Event) -> None: """Stop running Script objects started before shutdown.""" - async_call_later( - hass, _SHUTDOWN_MAX_WAIT, partial(_async_stop_scripts_after_shutdown, hass) - ) + _schedule_stop_scripts_after_shutdown(hass) running_scripts = [ script diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index ff4ede357a..b8a1c52f47 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -2208,6 +2208,7 @@ async def test_trigger_condition_explicit_id(hass: HomeAssistant, calls) -> None (SCRIPT_MODE_SINGLE, "script1: Already running"), ), ) +@pytest.mark.parametrize("wait_for_stop_scripts_after_shutdown", [True]) async def test_recursive_automation_starting_script( hass: HomeAssistant, automation_mode, @@ -2318,6 +2319,7 @@ async def test_recursive_automation_starting_script( @pytest.mark.parametrize("automation_mode", SCRIPT_MODE_CHOICES) +@pytest.mark.parametrize("wait_for_stop_scripts_after_shutdown", [True]) async def test_recursive_automation( hass: HomeAssistant, automation_mode, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py index 8712e40801..dc88ae2f0f 100644 --- a/tests/components/script/test_init.py +++ b/tests/components/script/test_init.py @@ -1127,6 +1127,7 @@ async def test_recursive_script_indirect( @pytest.mark.parametrize( "script_mode", [SCRIPT_MODE_PARALLEL, SCRIPT_MODE_QUEUED, SCRIPT_MODE_RESTART] ) +@pytest.mark.parametrize("wait_for_stop_scripts_after_shutdown", [True]) async def test_recursive_script_turn_on( hass: HomeAssistant, script_mode, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 32b6f98ca7..c4530bd538 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -263,6 +263,33 @@ def expected_lingering_tasks() -> bool: return False +@pytest.fixture +def wait_for_stop_scripts_after_shutdown() -> bool: + """Add ability to bypass _schedule_stop_scripts_after_shutdown. + + _schedule_stop_scripts_after_shutdown leaves a lingering timer. + + Parametrize to True to bypass the pytest failure. + @pytest.mark.parametrize("wait_for_stop_scripts_at_shutdown", [True]) + """ + return False + + +@pytest.fixture(autouse=True) +def skip_stop_scripts( + wait_for_stop_scripts_after_shutdown: bool, +) -> Generator[None, None, None]: + """Add ability to bypass _schedule_stop_scripts_after_shutdown.""" + if wait_for_stop_scripts_after_shutdown: + yield + return + with patch( + "homeassistant.helpers.script._schedule_stop_scripts_after_shutdown", + AsyncMock(), + ): + yield + + @pytest.fixture(autouse=True) def verify_cleanup( event_loop: asyncio.AbstractEventLoop, expected_lingering_tasks: bool diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 0521bc722c..5affce5d50 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -4213,6 +4213,7 @@ async def test_shutdown_at( assert_action_trace(expected_trace) +@pytest.mark.parametrize("wait_for_stop_scripts_after_shutdown", [True]) async def test_shutdown_after( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -4251,6 +4252,7 @@ async def test_shutdown_after( assert_action_trace(expected_trace) +@pytest.mark.parametrize("wait_for_stop_scripts_after_shutdown", [True]) async def test_start_script_after_shutdown( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: From a591f642589a708bd26939dcabb69763bc440735 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Mar 2023 09:46:40 +0100 Subject: [PATCH 0982/2810] Bump actions/checkout from 3.3.0 to 3.4.0 (#89778) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 12 +++++------ .github/workflows/ci.yaml | 34 +++++++++++++++--------------- .github/workflows/translations.yml | 2 +- .github/workflows/wheels.yml | 6 +++--- 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 45774642a5..bc21a2e3c7 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -24,7 +24,7 @@ jobs: publish: ${{ steps.version.outputs.publish }} steps: - name: Checkout the repository - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 with: fetch-depth: 0 @@ -67,7 +67,7 @@ jobs: if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' steps: - name: Checkout the repository - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 @@ -105,7 +105,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' @@ -249,7 +249,7 @@ jobs: - yellow steps: - name: Checkout the repository - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set build additional args run: | @@ -292,7 +292,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Initialize git uses: home-assistant/actions/helpers/git-init@master @@ -331,7 +331,7 @@ jobs: - "homeassistant" steps: - name: Checkout the repository - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Login to DockerHub if: matrix.registry == 'homeassistant' diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 12884206a4..4ac1075fcb 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -79,7 +79,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Generate partial Python venv restore key id: generate_python_cache_key run: >- @@ -203,7 +203,7 @@ jobs: - info steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.5.0 @@ -248,7 +248,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 id: python @@ -294,7 +294,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 id: python @@ -343,7 +343,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 id: python @@ -392,7 +392,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 id: python @@ -430,7 +430,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 id: python @@ -549,7 +549,7 @@ jobs: python-version: ${{ fromJSON(needs.info.outputs.python_versions) }} steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.5.0 @@ -617,7 +617,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.5.0 @@ -649,7 +649,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.5.0 @@ -682,7 +682,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.5.0 @@ -726,7 +726,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.5.0 @@ -792,7 +792,7 @@ jobs: name: Run pip check ${{ matrix.python-version }} steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.5.0 @@ -845,7 +845,7 @@ jobs: bluez \ ffmpeg - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.5.0 @@ -971,7 +971,7 @@ jobs: ffmpeg \ libmariadb-dev-compat - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.5.0 @@ -1079,7 +1079,7 @@ jobs: ffmpeg \ postgresql-server-dev-14 - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.5.0 @@ -1155,7 +1155,7 @@ jobs: - pytest steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Download all coverage artifacts uses: actions/download-artifact@v3 - name: Upload coverage to Codecov (full coverage) diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index 8f8244c490..b8cbd9204b 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index ae8ee1938e..1086738283 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -22,7 +22,7 @@ jobs: architectures: ${{ steps.info.outputs.architectures }} steps: - name: Checkout the repository - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Get information id: info @@ -82,7 +82,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Download env_file uses: actions/download-artifact@v3 @@ -119,7 +119,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Download env_file uses: actions/download-artifact@v3 From 8a58457203877a3bbad88086172d93b57985c212 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 16 Mar 2023 11:07:42 +0100 Subject: [PATCH 0983/2810] Fix lingering timer in config entries test (#89787) --- tests/test_config_entries.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 12b77aded8..36fbaff150 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -3210,6 +3210,9 @@ async def test_setup_retrying_during_shutdown(hass: HomeAssistant) -> None: assert len(mock_call.return_value.mock_calls) == 0 + # Cleanup to avoid lingering timer + entry.async_cancel_retry_setup() + @pytest.mark.parametrize( ("matchers", "reason"), From fec6236dd94a9ede52a40a1875814fa11a56db35 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 16 Mar 2023 11:08:47 +0100 Subject: [PATCH 0984/2810] Add type hints to root tests (#89785) --- tests/test_bootstrap.py | 105 ++++++++-------- tests/test_config_entries.py | 226 +++++++++++++++++++++++++---------- 2 files changed, 214 insertions(+), 117 deletions(-) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 8fe35a20bc..1ee60b71da 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -1,9 +1,10 @@ """Test the bootstrapping.""" - import asyncio +from collections.abc import Generator import glob import os -from unittest.mock import Mock, patch +from typing import Any +from unittest.mock import AsyncMock, Mock, patch import pytest @@ -27,7 +28,7 @@ VERSION_PATH = os.path.join(get_test_config_dir(), config_util.VERSION_FILE) @pytest.fixture(autouse=True) -def apply_mock_storage(hass_storage): +def apply_mock_storage(hass_storage: dict[str, Any]) -> None: """Apply the storage mock.""" @@ -37,7 +38,7 @@ async def apply_stop_hass(stop_hass: None) -> None: @pytest.fixture(autouse=True) -def mock_http_start_stop(): +def mock_http_start_stop() -> Generator[None, None, None]: """Mock HTTP start and stop.""" with patch( "homeassistant.components.http.start_http_server_and_save_config" @@ -416,8 +417,8 @@ async def test_setup_after_deps_not_present(hass: HomeAssistant) -> None: @pytest.fixture -def mock_is_virtual_env(): - """Mock enable logging.""" +def mock_is_virtual_env() -> Generator[Mock, None, None]: + """Mock is_virtual_env.""" with patch( "homeassistant.bootstrap.is_virtual_env", return_value=False ) as is_virtual_env: @@ -425,14 +426,14 @@ def mock_is_virtual_env(): @pytest.fixture -def mock_enable_logging(): +def mock_enable_logging() -> Generator[Mock, None, None]: """Mock enable logging.""" with patch("homeassistant.bootstrap.async_enable_logging") as enable_logging: yield enable_logging @pytest.fixture -def mock_mount_local_lib_path(): +def mock_mount_local_lib_path() -> Generator[AsyncMock, None, None]: """Mock enable logging.""" with patch( "homeassistant.bootstrap.async_mount_local_lib_path" @@ -441,7 +442,7 @@ def mock_mount_local_lib_path(): @pytest.fixture -def mock_process_ha_config_upgrade(): +def mock_process_ha_config_upgrade() -> Generator[Mock, None, None]: """Mock enable logging.""" with patch( "homeassistant.config.process_ha_config_upgrade" @@ -450,7 +451,7 @@ def mock_process_ha_config_upgrade(): @pytest.fixture -def mock_ensure_config_exists(): +def mock_ensure_config_exists() -> Generator[AsyncMock, None, None]: """Mock enable logging.""" with patch( "homeassistant.config.async_ensure_config_exists", return_value=True @@ -461,13 +462,13 @@ def mock_ensure_config_exists(): @pytest.mark.parametrize("hass_config", [{"browser": {}, "frontend": {}}]) async def test_setup_hass( mock_hass_config: None, - mock_enable_logging, - mock_is_virtual_env, - mock_mount_local_lib_path, - mock_ensure_config_exists, - mock_process_ha_config_upgrade, + mock_enable_logging: Mock, + mock_is_virtual_env: Mock, + mock_mount_local_lib_path: AsyncMock, + mock_ensure_config_exists: AsyncMock, + mock_process_ha_config_upgrade: Mock, caplog: pytest.LogCaptureFixture, - event_loop, + event_loop: asyncio.AbstractEventLoop, ) -> None: """Test it works.""" verbose = Mock() @@ -511,13 +512,13 @@ async def test_setup_hass( @pytest.mark.parametrize("hass_config", [{"browser": {}, "frontend": {}}]) async def test_setup_hass_takes_longer_than_log_slow_startup( mock_hass_config: None, - mock_enable_logging, - mock_is_virtual_env, - mock_mount_local_lib_path, - mock_ensure_config_exists, - mock_process_ha_config_upgrade, + mock_enable_logging: Mock, + mock_is_virtual_env: Mock, + mock_mount_local_lib_path: AsyncMock, + mock_ensure_config_exists: AsyncMock, + mock_process_ha_config_upgrade: Mock, caplog: pytest.LogCaptureFixture, - event_loop, + event_loop: asyncio.AbstractEventLoop, ) -> None: """Test it works.""" verbose = Mock() @@ -551,12 +552,12 @@ async def test_setup_hass_takes_longer_than_log_slow_startup( async def test_setup_hass_invalid_yaml( - mock_enable_logging, - mock_is_virtual_env, - mock_mount_local_lib_path, - mock_ensure_config_exists, - mock_process_ha_config_upgrade, - event_loop, + mock_enable_logging: Mock, + mock_is_virtual_env: Mock, + mock_mount_local_lib_path: AsyncMock, + mock_ensure_config_exists: AsyncMock, + mock_process_ha_config_upgrade: Mock, + event_loop: asyncio.AbstractEventLoop, ) -> None: """Test it works.""" with patch( @@ -579,12 +580,12 @@ async def test_setup_hass_invalid_yaml( async def test_setup_hass_config_dir_nonexistent( - mock_enable_logging, - mock_is_virtual_env, - mock_mount_local_lib_path, - mock_ensure_config_exists, - mock_process_ha_config_upgrade, - event_loop, + mock_enable_logging: Mock, + mock_is_virtual_env: Mock, + mock_mount_local_lib_path: AsyncMock, + mock_ensure_config_exists: AsyncMock, + mock_process_ha_config_upgrade: Mock, + event_loop: asyncio.AbstractEventLoop, ) -> None: """Test it works.""" mock_ensure_config_exists.return_value = False @@ -606,12 +607,12 @@ async def test_setup_hass_config_dir_nonexistent( async def test_setup_hass_safe_mode( - mock_enable_logging, - mock_is_virtual_env, - mock_mount_local_lib_path, - mock_ensure_config_exists, - mock_process_ha_config_upgrade, - event_loop, + mock_enable_logging: Mock, + mock_is_virtual_env: Mock, + mock_mount_local_lib_path: AsyncMock, + mock_ensure_config_exists: AsyncMock, + mock_process_ha_config_upgrade: Mock, + event_loop: asyncio.AbstractEventLoop, ) -> None: """Test it works.""" with patch("homeassistant.components.browser.setup") as browser_setup, patch( @@ -641,12 +642,12 @@ async def test_setup_hass_safe_mode( @pytest.mark.parametrize("hass_config", [{"homeassistant": {"non-existing": 1}}]) async def test_setup_hass_invalid_core_config( mock_hass_config: None, - mock_enable_logging, - mock_is_virtual_env, - mock_mount_local_lib_path, - mock_ensure_config_exists, - mock_process_ha_config_upgrade, - event_loop, + mock_enable_logging: Mock, + mock_is_virtual_env: Mock, + mock_mount_local_lib_path: AsyncMock, + mock_ensure_config_exists: AsyncMock, + mock_process_ha_config_upgrade: Mock, + event_loop: asyncio.AbstractEventLoop, ) -> None: """Test it works.""" hass = await bootstrap.async_setup_hass( @@ -679,12 +680,12 @@ async def test_setup_hass_invalid_core_config( ) async def test_setup_safe_mode_if_no_frontend( mock_hass_config: None, - mock_enable_logging, - mock_is_virtual_env, - mock_mount_local_lib_path, - mock_ensure_config_exists, - mock_process_ha_config_upgrade, - event_loop, + mock_enable_logging: Mock, + mock_is_virtual_env: Mock, + mock_mount_local_lib_path: AsyncMock, + mock_ensure_config_exists: AsyncMock, + mock_process_ha_config_upgrade: Mock, + event_loop: asyncio.AbstractEventLoop, ) -> None: """Test we setup safe mode if frontend didn't load.""" verbose = Mock() diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 36fbaff150..29041730da 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from collections.abc import Generator from datetime import timedelta import logging from typing import Any @@ -26,6 +27,8 @@ from homeassistant.exceptions import ( HomeAssistantError, ) from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.setup import async_set_domains_to_be_loaded, async_setup_component from homeassistant.util import dt @@ -44,7 +47,7 @@ from .common import ( @pytest.fixture(autouse=True) -def mock_handlers(): +def mock_handlers() -> Generator[None, None, None]: """Mock config flows.""" class MockFlowHandler(config_entries.ConfigFlow): @@ -63,7 +66,7 @@ def mock_handlers(): @pytest.fixture -def manager(hass): +def manager(hass: HomeAssistant) -> config_entries.ConfigEntries: """Fixture of a loaded config manager.""" manager = config_entries.ConfigEntries(hass, {}) hass.config_entries = manager @@ -263,15 +266,21 @@ async def test_call_async_migrate_entry_failure_not_supported( assert not entry.supports_unload -async def test_remove_entry(hass: HomeAssistant, manager) -> None: +async def test_remove_entry( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that we can remove an entry.""" - async def mock_setup_entry(hass, entry): + async def mock_setup_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> bool: """Mock setting up entry.""" hass.config_entries.async_setup_platforms(entry, ["light"]) return True - async def mock_unload_entry(hass, entry): + async def mock_unload_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> bool: """Mock unloading an entry.""" result = await hass.config_entries.async_unload_platforms(entry, ["light"]) assert result @@ -281,7 +290,11 @@ async def test_remove_entry(hass: HomeAssistant, manager) -> None: entity = MockEntity(unique_id="1234", name="Test Entity") - async def mock_setup_entry_platform(hass, entry, async_add_entities): + async def mock_setup_entry_platform( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: """Mock setting up platform.""" async_add_entities([entity]) @@ -347,7 +360,9 @@ async def test_remove_entry(hass: HomeAssistant, manager) -> None: assert not entity_entry_list -async def test_remove_entry_cancels_reauth(hass: HomeAssistant, manager) -> None: +async def test_remove_entry_cancels_reauth( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Tests that removing a config entry, also aborts existing reauth flows.""" entry = MockConfigEntry(title="test_title", domain="test") @@ -372,7 +387,7 @@ async def test_remove_entry_cancels_reauth(hass: HomeAssistant, manager) -> None async def test_remove_entry_handles_callback_error( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that exceptions in the remove callback are handled.""" mock_setup_entry = AsyncMock(return_value=True) @@ -406,7 +421,9 @@ async def test_remove_entry_handles_callback_error( assert [item.entry_id for item in manager.async_entries()] == [] -async def test_remove_entry_raises(hass: HomeAssistant, manager) -> None: +async def test_remove_entry_raises( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test if a component raises while removing entry.""" async def mock_unload_entry(hass, entry): @@ -433,7 +450,9 @@ async def test_remove_entry_raises(hass: HomeAssistant, manager) -> None: assert [item.entry_id for item in manager.async_entries()] == ["test1", "test3"] -async def test_remove_entry_if_not_loaded(hass: HomeAssistant, manager) -> None: +async def test_remove_entry_if_not_loaded( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that we can remove an entry that is not loaded.""" mock_unload_entry = AsyncMock(return_value=True) @@ -458,7 +477,7 @@ async def test_remove_entry_if_not_loaded(hass: HomeAssistant, manager) -> None: async def test_remove_entry_if_integration_deleted( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that we can remove an entry when the integration is deleted.""" mock_unload_entry = AsyncMock(return_value=True) @@ -481,7 +500,9 @@ async def test_remove_entry_if_integration_deleted( assert len(mock_unload_entry.mock_calls) == 0 -async def test_add_entry_calls_setup_entry(hass: HomeAssistant, manager) -> None: +async def test_add_entry_calls_setup_entry( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test we call setup_config_entry.""" mock_setup_entry = AsyncMock(return_value=True) @@ -510,7 +531,7 @@ async def test_add_entry_calls_setup_entry(hass: HomeAssistant, manager) -> None assert p_entry.data == {"token": "supersecret"} -async def test_entries_gets_entries(manager) -> None: +async def test_entries_gets_entries(manager: config_entries.ConfigEntries) -> None: """Test entries are filtered by domain.""" MockConfigEntry(domain="test").add_to_manager(manager) entry1 = MockConfigEntry(domain="test2") @@ -521,7 +542,9 @@ async def test_entries_gets_entries(manager) -> None: assert manager.async_entries("test2") == [entry1, entry2] -async def test_domains_gets_domains_uniques(manager) -> None: +async def test_domains_gets_domains_uniques( + manager: config_entries.ConfigEntries, +) -> None: """Test we only return each domain once.""" MockConfigEntry(domain="test").add_to_manager(manager) MockConfigEntry(domain="test2").add_to_manager(manager) @@ -532,7 +555,9 @@ async def test_domains_gets_domains_uniques(manager) -> None: assert manager.async_domains() == ["test", "test2", "test3"] -async def test_domains_gets_domains_excludes_ignore_and_disabled(manager) -> None: +async def test_domains_gets_domains_excludes_ignore_and_disabled( + manager: config_entries.ConfigEntries, +) -> None: """Test we only return each domain once.""" MockConfigEntry(domain="test").add_to_manager(manager) MockConfigEntry(domain="test2").add_to_manager(manager) @@ -838,7 +863,7 @@ async def test_loading_default_config(hass: HomeAssistant) -> None: assert len(manager.async_entries()) == 0 -async def test_updating_entry_data(manager) -> None: +async def test_updating_entry_data(manager: config_entries.ConfigEntries) -> None: """Test that we can update an entry data.""" entry = MockConfigEntry( domain="test", @@ -854,7 +879,9 @@ async def test_updating_entry_data(manager) -> None: assert entry.data == {"second": True} -async def test_updating_entry_system_options(manager) -> None: +async def test_updating_entry_system_options( + manager: config_entries.ConfigEntries, +) -> None: """Test that we can update an entry data.""" entry = MockConfigEntry( domain="test", @@ -876,7 +903,7 @@ async def test_updating_entry_system_options(manager) -> None: async def test_update_entry_options_and_trigger_listener( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that we can update entry options and trigger listener.""" entry = MockConfigEntry(domain="test", options={"first": True}) @@ -1070,7 +1097,9 @@ async def test_create_entry_options(hass: HomeAssistant) -> None: assert entries[0].options == {"example": "option"} -async def test_entry_options(hass: HomeAssistant, manager) -> None: +async def test_entry_options( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that we can set options on an entry.""" entry = MockConfigEntry(domain="test", data={"first": True}, options=None) entry.add_to_manager(manager) @@ -1104,7 +1133,9 @@ async def test_entry_options(hass: HomeAssistant, manager) -> None: assert entry.options == {"second": True} -async def test_entry_options_abort(hass: HomeAssistant, manager) -> None: +async def test_entry_options_abort( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that we can abort options flow.""" entry = MockConfigEntry(domain="test", data={"first": True}, options=None) entry.add_to_manager(manager) @@ -1134,7 +1165,9 @@ async def test_entry_options_abort(hass: HomeAssistant, manager) -> None: ) -async def test_entry_setup_succeed(hass: HomeAssistant, manager) -> None: +async def test_entry_setup_succeed( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that we can setup an entry.""" entry = MockConfigEntry( domain="comp", state=config_entries.ConfigEntryState.NOT_LOADED @@ -1166,7 +1199,11 @@ async def test_entry_setup_succeed(hass: HomeAssistant, manager) -> None: config_entries.ConfigEntryState.FAILED_UNLOAD, ), ) -async def test_entry_setup_invalid_state(hass: HomeAssistant, manager, state) -> None: +async def test_entry_setup_invalid_state( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + state: config_entries.ConfigEntryState, +) -> None: """Test that we cannot setup an entry with invalid state.""" entry = MockConfigEntry(domain="comp", state=state) entry.add_to_hass(hass) @@ -1187,7 +1224,9 @@ async def test_entry_setup_invalid_state(hass: HomeAssistant, manager, state) -> assert entry.state is state -async def test_entry_unload_succeed(hass: HomeAssistant, manager) -> None: +async def test_entry_unload_succeed( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that we can unload an entry.""" entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) entry.add_to_hass(hass) @@ -1209,7 +1248,11 @@ async def test_entry_unload_succeed(hass: HomeAssistant, manager) -> None: config_entries.ConfigEntryState.SETUP_RETRY, ), ) -async def test_entry_unload_failed_to_load(hass: HomeAssistant, manager, state) -> None: +async def test_entry_unload_failed_to_load( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + state: config_entries.ConfigEntryState, +) -> None: """Test that we can unload an entry.""" entry = MockConfigEntry(domain="comp", state=state) entry.add_to_hass(hass) @@ -1230,7 +1273,11 @@ async def test_entry_unload_failed_to_load(hass: HomeAssistant, manager, state) config_entries.ConfigEntryState.FAILED_UNLOAD, ), ) -async def test_entry_unload_invalid_state(hass: HomeAssistant, manager, state) -> None: +async def test_entry_unload_invalid_state( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + state: config_entries.ConfigEntryState, +) -> None: """Test that we cannot unload an entry with invalid state.""" entry = MockConfigEntry(domain="comp", state=state) entry.add_to_hass(hass) @@ -1246,7 +1293,9 @@ async def test_entry_unload_invalid_state(hass: HomeAssistant, manager, state) - assert entry.state is state -async def test_entry_reload_succeed(hass: HomeAssistant, manager) -> None: +async def test_entry_reload_succeed( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that we can reload an entry.""" entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) entry.add_to_hass(hass) @@ -1281,7 +1330,11 @@ async def test_entry_reload_succeed(hass: HomeAssistant, manager) -> None: config_entries.ConfigEntryState.SETUP_RETRY, ), ) -async def test_entry_reload_not_loaded(hass: HomeAssistant, manager, state) -> None: +async def test_entry_reload_not_loaded( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + state: config_entries.ConfigEntryState, +) -> None: """Test that we can reload an entry.""" entry = MockConfigEntry(domain="comp", state=state) entry.add_to_hass(hass) @@ -1315,7 +1368,11 @@ async def test_entry_reload_not_loaded(hass: HomeAssistant, manager, state) -> N config_entries.ConfigEntryState.FAILED_UNLOAD, ), ) -async def test_entry_reload_error(hass: HomeAssistant, manager, state) -> None: +async def test_entry_reload_error( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + state: config_entries.ConfigEntryState, +) -> None: """Test that we can reload an entry.""" entry = MockConfigEntry(domain="comp", state=state) entry.add_to_hass(hass) @@ -1344,7 +1401,9 @@ async def test_entry_reload_error(hass: HomeAssistant, manager, state) -> None: assert entry.state == state -async def test_entry_disable_succeed(hass: HomeAssistant, manager) -> None: +async def test_entry_disable_succeed( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that we can disable an entry.""" entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) entry.add_to_hass(hass) @@ -1382,7 +1441,7 @@ async def test_entry_disable_succeed(hass: HomeAssistant, manager) -> None: async def test_entry_disable_without_reload_support( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that we can disable an entry without reload support.""" entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) @@ -1421,7 +1480,7 @@ async def test_entry_disable_without_reload_support( async def test_entry_enable_without_reload_support( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that we can disable an entry without reload support.""" entry = MockConfigEntry( @@ -1550,7 +1609,9 @@ async def test_reload_entry_entity_registry_works( assert len(mock_unload_entry.mock_calls) == 2 -async def test_unique_id_persisted(hass: HomeAssistant, manager) -> None: +async def test_unique_id_persisted( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that a unique ID is stored in the config entry.""" mock_setup_entry = AsyncMock(return_value=True) @@ -1579,7 +1640,9 @@ async def test_unique_id_persisted(hass: HomeAssistant, manager) -> None: assert p_entry.unique_id == "mock-unique-id" -async def test_unique_id_existing_entry(hass: HomeAssistant, manager) -> None: +async def test_unique_id_existing_entry( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that we remove an entry if there already is an entry with unique ID.""" hass.config.components.add("comp") MockConfigEntry( @@ -1632,7 +1695,9 @@ async def test_unique_id_existing_entry(hass: HomeAssistant, manager) -> None: assert len(async_remove_entry.mock_calls) == 1 -async def test_entry_id_existing_entry(hass: HomeAssistant, manager) -> None: +async def test_entry_id_existing_entry( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that we throw when the entry id collides.""" collide_entry_id = "collide" hass.config.components.add("comp") @@ -1670,7 +1735,7 @@ async def test_entry_id_existing_entry(hass: HomeAssistant, manager) -> None: async def test_unique_id_update_existing_entry_without_reload( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that we update an entry if there already is an entry with unique ID.""" hass.config.components.add("comp") @@ -1716,7 +1781,7 @@ async def test_unique_id_update_existing_entry_without_reload( async def test_unique_id_update_existing_entry_with_reload( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that we update an entry if there already is an entry with unique ID and we reload on changes.""" hass.config.components.add("comp") @@ -1780,7 +1845,7 @@ async def test_unique_id_update_existing_entry_with_reload( async def test_unique_id_from_discovery_in_setup_retry( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that we reload when in a setup retry state from discovery.""" hass.config.components.add("comp") @@ -1851,7 +1916,7 @@ async def test_unique_id_from_discovery_in_setup_retry( async def test_unique_id_not_update_existing_entry( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that we do not update an entry if existing entry has the data.""" hass.config.components.add("comp") @@ -1895,7 +1960,9 @@ async def test_unique_id_not_update_existing_entry( assert len(async_reload.mock_calls) == 0 -async def test_unique_id_in_progress(hass: HomeAssistant, manager) -> None: +async def test_unique_id_in_progress( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that we abort if there is already a flow in progress with same unique id.""" mock_integration(hass, MockModule("comp")) mock_entity_platform(hass, "config_flow.comp", None) @@ -1926,7 +1993,9 @@ async def test_unique_id_in_progress(hass: HomeAssistant, manager) -> None: assert result2["reason"] == "already_in_progress" -async def test_finish_flow_aborts_progress(hass: HomeAssistant, manager) -> None: +async def test_finish_flow_aborts_progress( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that when finishing a flow, we abort other flows in progress with unique ID.""" mock_integration( hass, @@ -1965,7 +2034,9 @@ async def test_finish_flow_aborts_progress(hass: HomeAssistant, manager) -> None assert len(hass.config_entries.flow.async_progress()) == 0 -async def test_unique_id_ignore(hass: HomeAssistant, manager) -> None: +async def test_unique_id_ignore( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that we can ignore flows that are in progress and have a unique ID.""" async_setup_entry = AsyncMock(return_value=False) mock_integration(hass, MockModule("comp", async_setup_entry=async_setup_entry)) @@ -2008,7 +2079,9 @@ async def test_unique_id_ignore(hass: HomeAssistant, manager) -> None: assert entry.title == "Ignored Title" -async def test_manual_add_overrides_ignored_entry(hass: HomeAssistant, manager) -> None: +async def test_manual_add_overrides_ignored_entry( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that we can ignore manually add entry, overriding ignored entry.""" hass.config.components.add("comp") entry = MockConfigEntry( @@ -2054,7 +2127,7 @@ async def test_manual_add_overrides_ignored_entry(hass: HomeAssistant, manager) async def test_manual_add_overrides_ignored_entry_singleton( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that we can ignore manually add entry, overriding ignored entry.""" hass.config.components.add("comp") @@ -2095,7 +2168,7 @@ async def test_manual_add_overrides_ignored_entry_singleton( async def test__async_current_entries_does_not_skip_ignore_non_user( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that _async_current_entries does not skip ignore by default for non user step.""" hass.config.components.add("comp") @@ -2132,7 +2205,7 @@ async def test__async_current_entries_does_not_skip_ignore_non_user( async def test__async_current_entries_explicit_skip_ignore( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that _async_current_entries can explicitly include ignore.""" hass.config.components.add("comp") @@ -2173,7 +2246,7 @@ async def test__async_current_entries_explicit_skip_ignore( async def test__async_current_entries_explicit_include_ignore( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that _async_current_entries can explicitly include ignore.""" hass.config.components.add("comp") @@ -2209,7 +2282,9 @@ async def test__async_current_entries_explicit_include_ignore( assert len(mock_setup_entry.mock_calls) == 0 -async def test_unignore_step_form(hass: HomeAssistant, manager) -> None: +async def test_unignore_step_form( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that we can ignore flows that are in progress and have a unique ID, then rediscover them.""" async_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=async_setup_entry)) @@ -2254,7 +2329,9 @@ async def test_unignore_step_form(hass: HomeAssistant, manager) -> None: assert len(hass.config_entries.async_entries("comp")) == 0 -async def test_unignore_create_entry(hass: HomeAssistant, manager) -> None: +async def test_unignore_create_entry( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that we can ignore flows that are in progress and have a unique ID, then rediscover them.""" async_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=async_setup_entry)) @@ -2302,7 +2379,9 @@ async def test_unignore_create_entry(hass: HomeAssistant, manager) -> None: assert len(hass.config_entries.flow.async_progress_by_handler("comp")) == 0 -async def test_unignore_default_impl(hass: HomeAssistant, manager) -> None: +async def test_unignore_default_impl( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that resdicovery is a no-op by default.""" async_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=async_setup_entry)) @@ -2334,7 +2413,9 @@ async def test_unignore_default_impl(hass: HomeAssistant, manager) -> None: assert len(hass.config_entries.flow.async_progress()) == 0 -async def test_partial_flows_hidden(hass: HomeAssistant, manager) -> None: +async def test_partial_flows_hidden( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that flows that don't have a cur_step and haven't finished initing are hidden.""" async_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=async_setup_entry)) @@ -2395,7 +2476,7 @@ async def test_partial_flows_hidden(hass: HomeAssistant, manager) -> None: async def test_async_setup_init_entry(hass: HomeAssistant) -> None: """Test a config entry being initialized during integration setup.""" - async def mock_async_setup(hass, config): + async def mock_async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Mock setup.""" hass.async_create_task( hass.config_entries.flow.async_init( @@ -2576,7 +2657,9 @@ async def test_async_setup_update_entry(hass: HomeAssistant) -> None: ), ) async def test_flow_with_default_discovery( - hass: HomeAssistant, manager, discovery_source + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + discovery_source: tuple[str, dict | BaseServiceInfo], ) -> None: """Test that finishing a default discovery flow removes the unique ID in the entry.""" mock_integration( @@ -2626,7 +2709,7 @@ async def test_flow_with_default_discovery( async def test_flow_with_default_discovery_with_unique_id( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test discovery flow using the default discovery is ignored when unique ID is set.""" mock_integration(hass, MockModule("comp")) @@ -2656,7 +2739,7 @@ async def test_flow_with_default_discovery_with_unique_id( async def test_default_discovery_abort_existing_entries( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that a flow without discovery implementation aborts when a config entry exists.""" hass.config.components.add("comp") @@ -2679,7 +2762,9 @@ async def test_default_discovery_abort_existing_entries( assert result["reason"] == "already_configured" -async def test_default_discovery_in_progress(hass: HomeAssistant, manager) -> None: +async def test_default_discovery_in_progress( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that a flow using default discovery can only be triggered once.""" mock_integration(hass, MockModule("comp")) mock_entity_platform(hass, "config_flow.comp", None) @@ -2715,7 +2800,7 @@ async def test_default_discovery_in_progress(hass: HomeAssistant, manager) -> No async def test_default_discovery_abort_on_new_unique_flow( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that a flow using default discovery is aborted when a second flow with unique ID is created.""" mock_integration(hass, MockModule("comp")) @@ -2754,7 +2839,7 @@ async def test_default_discovery_abort_on_new_unique_flow( async def test_default_discovery_abort_on_user_flow_complete( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that a flow using default discovery is aborted when a second flow completes.""" mock_integration(hass, MockModule("comp")) @@ -2804,7 +2889,9 @@ async def test_default_discovery_abort_on_user_flow_complete( assert len(flows) == 0 -async def test_flow_same_device_multiple_sources(hass: HomeAssistant, manager) -> None: +async def test_flow_same_device_multiple_sources( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test discovery of the same devices from multiple discovery sources.""" mock_integration( hass, @@ -2872,7 +2959,9 @@ async def test_flow_same_device_multiple_sources(hass: HomeAssistant, manager) - assert entry.unique_id == "thisid" -async def test_updating_entry_with_and_without_changes(manager) -> None: +async def test_updating_entry_with_and_without_changes( + manager: config_entries.ConfigEntries, +) -> None: """Test that we can update an entry data.""" entry = MockConfigEntry( domain="test", @@ -2900,7 +2989,7 @@ async def test_updating_entry_with_and_without_changes(manager) -> None: async def test_entry_reload_calls_on_unload_listeners( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test reload calls the on unload listeners.""" entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) @@ -3239,7 +3328,10 @@ async def test_setup_retrying_during_shutdown(hass: HomeAssistant) -> None: ], ) async def test__async_abort_entries_match( - hass: HomeAssistant, manager, matchers, reason + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + matchers: dict[str, str], + reason: str, ) -> None: """Test aborting if matching config entries exist.""" MockConfigEntry( @@ -3336,7 +3428,9 @@ async def test_deprecated_disabled_by_str_ctor( async def test_deprecated_disabled_by_str_set( - hass: HomeAssistant, manager, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + caplog: pytest.LogCaptureFixture, ) -> None: """Test deprecated str set disabled_by enumizes and logs a warning.""" entry = MockConfigEntry() @@ -3348,7 +3442,9 @@ async def test_deprecated_disabled_by_str_set( assert " str for config entry disabled_by. This is deprecated " in caplog.text -async def test_entry_reload_concurrency(hass: HomeAssistant, manager) -> None: +async def test_entry_reload_concurrency( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test multiple reload calls do not cause a reload race.""" entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) entry.add_to_hass(hass) @@ -3457,7 +3553,7 @@ async def test_unique_id_update_while_setup_in_progress( async def test_disallow_entry_reload_with_setup_in_progresss( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test we do not allow reload while the config entry is still setting up.""" entry = MockConfigEntry( @@ -3638,7 +3734,7 @@ async def test_options_flow_options_not_mutated() -> None: async def test_initializing_flows_canceled_on_shutdown( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that initializing flows are canceled on shutdown.""" From 69e85b3216ce96216b9821468bce96838418f997 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 16 Mar 2023 11:09:54 +0100 Subject: [PATCH 0985/2810] Fix SFR Box diagnostics (#89783) --- .../components/sfr_box/diagnostics.py | 10 ++- .../sfr_box/snapshots/test_diagnostics.ambr | 61 ++++++++++++++++++- tests/components/sfr_box/test_diagnostics.py | 5 ++ 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sfr_box/diagnostics.py b/homeassistant/components/sfr_box/diagnostics.py index e85bd602b7..1fb9805326 100644 --- a/homeassistant/components/sfr_box/diagnostics.py +++ b/homeassistant/components/sfr_box/diagnostics.py @@ -26,13 +26,17 @@ async def async_get_config_entry_diagnostics( "data": dict(entry.data), }, "data": { - "dsl": async_redact_data(dataclasses.asdict(data.dsl.data), TO_REDACT), + "dsl": async_redact_data( + dataclasses.asdict(await data.system.box.dsl_get_info()), TO_REDACT + ), "ftth": async_redact_data( dataclasses.asdict(await data.system.box.ftth_get_info()), TO_REDACT ), "system": async_redact_data( - dataclasses.asdict(data.system.data), TO_REDACT + dataclasses.asdict(await data.system.box.system_get_info()), TO_REDACT + ), + "wan": async_redact_data( + dataclasses.asdict(await data.system.box.wan_get_info()), TO_REDACT ), - "wan": async_redact_data(dataclasses.asdict(data.wan.data), TO_REDACT), }, } diff --git a/tests/components/sfr_box/snapshots/test_diagnostics.ambr b/tests/components/sfr_box/snapshots/test_diagnostics.ambr index fe9268358f..22a914f8a7 100644 --- a/tests/components/sfr_box/snapshots/test_diagnostics.ambr +++ b/tests/components/sfr_box/snapshots/test_diagnostics.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_entry_diagnostics +# name: test_entry_diagnostics[adsl] dict({ 'data': dict({ 'dsl': dict({ @@ -58,3 +58,62 @@ }), }) # --- +# name: test_entry_diagnostics[ftth] + dict({ + 'data': dict({ + 'dsl': dict({ + 'attenuation_down': 28.5, + 'attenuation_up': 20.8, + 'counter': 16, + 'crc': 0, + 'line_status': 'No Defect', + 'linemode': 'ADSL2+', + 'noise_down': 5.8, + 'noise_up': 6.0, + 'rate_down': 5549, + 'rate_up': 187, + 'status': 'up', + 'training': 'Showtime', + 'uptime': 450796, + }), + 'ftth': dict({ + 'status': 'down', + 'wanfibre': 'out', + }), + 'system': dict({ + 'alimvoltage': 12251, + 'current_datetime': '202212282233', + 'idur': 'RP3P85K', + 'mac_addr': '**REDACTED**', + 'net_infra': 'ftth', + 'net_mode': 'router', + 'product_id': 'NB6VAC-FXC-r0', + 'refclient': '', + 'serial_number': '**REDACTED**', + 'temperature': 27560, + 'uptime': 2353575, + 'version_bootloader': 'NB6VAC-BOOTLOADER-R4.0.8', + 'version_dsldriver': 'NB6VAC-XDSL-A2pv6F039p', + 'version_mainfirmware': 'NB6VAC-MAIN-R4.0.44k', + 'version_rescuefirmware': 'NB6VAC-MAIN-R4.0.44k', + }), + 'wan': dict({ + 'infra': 'adsl', + 'infra6': '', + 'ip_addr': '**REDACTED**', + 'ipv6_addr': '', + 'mode': 'adsl/routed', + 'status': 'up', + 'status6': 'down', + 'uptime': 297464, + 'uptime6': None, + }), + }), + 'entry': dict({ + 'data': dict({ + 'host': '192.168.0.1', + }), + 'title': 'Mock Title', + }), + }) +# --- diff --git a/tests/components/sfr_box/test_diagnostics.py b/tests/components/sfr_box/test_diagnostics.py index 37e3ba9487..a433236ab7 100644 --- a/tests/components/sfr_box/test_diagnostics.py +++ b/tests/components/sfr_box/test_diagnostics.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import patch import pytest +from sfrbox_api.models import SystemInfo from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry @@ -23,13 +24,17 @@ def override_platforms() -> Generator[None, None, None]: yield +@pytest.mark.parametrize("net_infra", ["adsl", "ftth"]) async def test_entry_diagnostics( hass: HomeAssistant, config_entry: ConfigEntry, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, + system_get_info: SystemInfo, + net_infra: str, ) -> None: """Test config entry diagnostics.""" + system_get_info.net_infra = net_infra await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() From 46a5aa71eca10c0fd43ffa129dfad299a08b7988 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 16 Mar 2023 11:10:56 +0100 Subject: [PATCH 0986/2810] Add type hints to helper tests (#89784) --- tests/helpers/test_config_entry_flow.py | 63 ++++++++++++++++--------- tests/helpers/test_entity.py | 5 +- tests/helpers/test_entity_component.py | 22 +++++++-- tests/helpers/test_entity_platform.py | 25 +++++++--- tests/helpers/test_event.py | 21 ++++++--- 5 files changed, 97 insertions(+), 39 deletions(-) diff --git a/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py index 8909f18e54..90d8030be7 100644 --- a/tests/helpers/test_config_entry_flow.py +++ b/tests/helpers/test_config_entry_flow.py @@ -1,4 +1,5 @@ """Tests for the Config Entry Flow helper.""" +from collections.abc import Generator from unittest.mock import Mock, PropertyMock, patch import pytest @@ -17,11 +18,11 @@ from tests.common import ( @pytest.fixture -def discovery_flow_conf(hass): +def discovery_flow_conf(hass: HomeAssistant) -> Generator[dict[str, bool], None, None]: """Register a handler.""" handler_conf = {"discovered": False} - async def has_discovered_devices(hass): + async def has_discovered_devices(hass: HomeAssistant) -> bool: """Mock if we have discovered devices.""" return handler_conf["discovered"] @@ -33,17 +34,19 @@ def discovery_flow_conf(hass): @pytest.fixture -def webhook_flow_conf(hass): +def webhook_flow_conf(hass: HomeAssistant) -> Generator[None, None, None]: """Register a handler.""" with patch.dict(config_entries.HANDLERS): config_entry_flow.register_webhook_flow("test_single", "Test Single", {}, False) config_entry_flow.register_webhook_flow( "test_multiple", "Test Multiple", {}, True ) - yield {} + yield -async def test_single_entry_allowed(hass: HomeAssistant, discovery_flow_conf) -> None: +async def test_single_entry_allowed( + hass: HomeAssistant, discovery_flow_conf: dict[str, bool] +) -> None: """Test only a single entry is allowed.""" flow = config_entries.HANDLERS["test"]() flow.hass = hass @@ -56,7 +59,9 @@ async def test_single_entry_allowed(hass: HomeAssistant, discovery_flow_conf) -> assert result["reason"] == "single_instance_allowed" -async def test_user_no_devices_found(hass: HomeAssistant, discovery_flow_conf) -> None: +async def test_user_no_devices_found( + hass: HomeAssistant, discovery_flow_conf: dict[str, bool] +) -> None: """Test if no devices found.""" flow = config_entries.HANDLERS["test"]() flow.hass = hass @@ -67,7 +72,9 @@ async def test_user_no_devices_found(hass: HomeAssistant, discovery_flow_conf) - assert result["reason"] == "no_devices_found" -async def test_user_has_confirmation(hass: HomeAssistant, discovery_flow_conf) -> None: +async def test_user_has_confirmation( + hass: HomeAssistant, discovery_flow_conf: dict[str, bool] +) -> None: """Test user requires confirmation to setup.""" discovery_flow_conf["discovered"] = True mock_entity_platform(hass, "config_flow.test", None) @@ -104,7 +111,7 @@ async def test_user_has_confirmation(hass: HomeAssistant, discovery_flow_conf) - ], ) async def test_discovery_single_instance( - hass: HomeAssistant, discovery_flow_conf, source + hass: HomeAssistant, discovery_flow_conf: dict[str, bool], source: str ) -> None: """Test we not allow duplicates.""" flow = config_entries.HANDLERS["test"]() @@ -130,7 +137,7 @@ async def test_discovery_single_instance( ], ) async def test_discovery_confirmation( - hass: HomeAssistant, discovery_flow_conf, source + hass: HomeAssistant, discovery_flow_conf: dict[str, bool], source: str ) -> None: """Test we ask for confirmation via discovery.""" flow = config_entries.HANDLERS["test"]() @@ -158,7 +165,7 @@ async def test_discovery_confirmation( ], ) async def test_discovery_during_onboarding( - hass: HomeAssistant, discovery_flow_conf, source + hass: HomeAssistant, discovery_flow_conf: dict[str, bool], source: str ) -> None: """Test we create config entry via discovery during onboarding.""" flow = config_entries.HANDLERS["test"]() @@ -173,7 +180,9 @@ async def test_discovery_during_onboarding( assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY -async def test_multiple_discoveries(hass: HomeAssistant, discovery_flow_conf) -> None: +async def test_multiple_discoveries( + hass: HomeAssistant, discovery_flow_conf: dict[str, bool] +) -> None: """Test we only create one instance for multiple discoveries.""" mock_entity_platform(hass, "config_flow.test", None) @@ -189,7 +198,9 @@ async def test_multiple_discoveries(hass: HomeAssistant, discovery_flow_conf) -> assert result["type"] == data_entry_flow.FlowResultType.ABORT -async def test_only_one_in_progress(hass: HomeAssistant, discovery_flow_conf) -> None: +async def test_only_one_in_progress( + hass: HomeAssistant, discovery_flow_conf: dict[str, bool] +) -> None: """Test a user initialized one will finish and cancel discovered one.""" mock_entity_platform(hass, "config_flow.test", None) @@ -215,7 +226,9 @@ async def test_only_one_in_progress(hass: HomeAssistant, discovery_flow_conf) -> assert len(hass.config_entries.flow.async_progress()) == 0 -async def test_import_abort_discovery(hass: HomeAssistant, discovery_flow_conf) -> None: +async def test_import_abort_discovery( + hass: HomeAssistant, discovery_flow_conf: dict[str, bool] +) -> None: """Test import will finish and cancel discovered one.""" mock_entity_platform(hass, "config_flow.test", None) @@ -236,7 +249,9 @@ async def test_import_abort_discovery(hass: HomeAssistant, discovery_flow_conf) assert len(hass.config_entries.flow.async_progress()) == 0 -async def test_import_no_confirmation(hass: HomeAssistant, discovery_flow_conf) -> None: +async def test_import_no_confirmation( + hass: HomeAssistant, discovery_flow_conf: dict[str, bool] +) -> None: """Test import requires no confirmation to set up.""" flow = config_entries.HANDLERS["test"]() flow.hass = hass @@ -247,7 +262,9 @@ async def test_import_no_confirmation(hass: HomeAssistant, discovery_flow_conf) assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY -async def test_import_single_instance(hass: HomeAssistant, discovery_flow_conf) -> None: +async def test_import_single_instance( + hass: HomeAssistant, discovery_flow_conf: dict[str, bool] +) -> None: """Test import doesn't create second instance.""" flow = config_entries.HANDLERS["test"]() flow.hass = hass @@ -259,7 +276,9 @@ async def test_import_single_instance(hass: HomeAssistant, discovery_flow_conf) assert result["type"] == data_entry_flow.FlowResultType.ABORT -async def test_ignored_discoveries(hass: HomeAssistant, discovery_flow_conf) -> None: +async def test_ignored_discoveries( + hass: HomeAssistant, discovery_flow_conf: dict[str, bool] +) -> None: """Test we can ignore discovered entries.""" mock_entity_platform(hass, "config_flow.test", None) @@ -292,7 +311,7 @@ async def test_ignored_discoveries(hass: HomeAssistant, discovery_flow_conf) -> async def test_webhook_single_entry_allowed( - hass: HomeAssistant, webhook_flow_conf + hass: HomeAssistant, webhook_flow_conf: None ) -> None: """Test only a single entry is allowed.""" flow = config_entries.HANDLERS["test_single"]() @@ -306,7 +325,7 @@ async def test_webhook_single_entry_allowed( async def test_webhook_multiple_entries_allowed( - hass: HomeAssistant, webhook_flow_conf + hass: HomeAssistant, webhook_flow_conf: None ) -> None: """Test multiple entries are allowed when specified.""" flow = config_entries.HANDLERS["test_multiple"]() @@ -320,7 +339,7 @@ async def test_webhook_multiple_entries_allowed( async def test_webhook_config_flow_registers_webhook( - hass: HomeAssistant, webhook_flow_conf + hass: HomeAssistant, webhook_flow_conf: None ) -> None: """Test setting up an entry creates a webhook.""" flow = config_entries.HANDLERS["test_single"]() @@ -336,7 +355,9 @@ async def test_webhook_config_flow_registers_webhook( assert result["data"]["webhook_id"] is not None -async def test_webhook_create_cloudhook(hass: HomeAssistant, webhook_flow_conf) -> None: +async def test_webhook_create_cloudhook( + hass: HomeAssistant, webhook_flow_conf: None +) -> None: """Test cloudhook will be created if subscribed.""" assert await setup.async_setup_component(hass, "cloud", {}) @@ -390,7 +411,7 @@ async def test_webhook_create_cloudhook(hass: HomeAssistant, webhook_flow_conf) async def test_webhook_create_cloudhook_aborts_not_connected( - hass: HomeAssistant, webhook_flow_conf + hass: HomeAssistant, webhook_flow_conf: None ) -> None: """Test cloudhook aborts if subscribed but not connected.""" assert await setup.async_setup_component(hass, "cloud", {}) diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 4b505c894b..2b9a332e82 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -914,7 +914,10 @@ async def test_entity_description_fallback() -> None: ), ) async def test_friendly_name( - hass: HomeAssistant, has_entity_name, entity_name, expected_friendly_name + hass: HomeAssistant, + has_entity_name: bool, + entity_name: str | None, + expected_friendly_name: str | None, ) -> None: """Test entity_id is influenced by entity name.""" diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index ac5108cb28..018fb6c937 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -18,6 +18,8 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import discovery from homeassistant.helpers.entity_component import EntityComponent, async_update_entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -90,7 +92,7 @@ async def test_setup_recovers_when_setup_raises(hass: HomeAssistant) -> None: ) @patch("homeassistant.setup.async_setup_component", return_value=True) async def test_setup_does_discovery( - mock_setup_component, mock_setup, hass: HomeAssistant + mock_setup_component: AsyncMock, mock_setup: AsyncMock, hass: HomeAssistant ) -> None: """Test setup for discovery.""" component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -108,10 +110,17 @@ async def test_setup_does_discovery( @patch("homeassistant.helpers.entity_platform.async_track_time_interval") -async def test_set_scan_interval_via_config(mock_track, hass: HomeAssistant) -> None: +async def test_set_scan_interval_via_config( + mock_track: Mock, hass: HomeAssistant +) -> None: """Test the setting of the scan interval via configuration.""" - def platform_setup(hass, config, add_entities, discovery_info=None): + def platform_setup( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, + ) -> None: """Test the platform setup.""" add_entities([MockEntity(should_poll=True)]) @@ -131,7 +140,12 @@ async def test_set_scan_interval_via_config(mock_track, hass: HomeAssistant) -> async def test_set_entity_namespace_via_config(hass: HomeAssistant) -> None: """Test setting an entity namespace.""" - def platform_setup(hass, config, add_entities, discovery_info=None): + def platform_setup( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, + ) -> None: """Test the platform setup.""" add_entities([MockEntity(name="beer"), MockEntity(name=None)]) diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 597045f557..7163461ae1 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -23,6 +23,7 @@ from homeassistant.helpers.entity_component import ( DEFAULT_SCAN_INTERVAL, EntityComponent, ) +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util from tests.common import ( @@ -81,11 +82,11 @@ async def test_polling_updates_entities_with_exception(hass: HomeAssistant) -> N update_ok = [] update_err = [] - def update_mock(): + def update_mock() -> None: """Mock normal update.""" update_ok.append(None) - def update_mock_err(): + def update_mock_err() -> None: """Mock error update.""" update_err.append(None) raise AssertionError("Fake error update") @@ -161,10 +162,17 @@ async def test_update_state_adds_entities_with_update_before_add_false( @patch("homeassistant.helpers.entity_platform.async_track_time_interval") -async def test_set_scan_interval_via_platform(mock_track, hass: HomeAssistant) -> None: +async def test_set_scan_interval_via_platform( + mock_track: Mock, hass: HomeAssistant +) -> None: """Test the setting of the scan interval via platform.""" - def platform_setup(hass, config, add_entities, discovery_info=None): + def platform_setup( + hass: HomeAssistant, + config: ConfigType, + add_entities: entity_platform.AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, + ) -> None: """Test the platform setup.""" add_entities([MockEntity(should_poll=True)]) @@ -192,7 +200,7 @@ async def test_adding_entities_with_generator_and_thread_callback( """ component = EntityComponent(_LOGGER, DOMAIN, hass) - def create_entity(number): + def create_entity(number: int) -> MockEntity: """Create entity helper.""" entity = MockEntity(unique_id=f"unique{number}") entity.entity_id = async_generate_entity_id(DOMAIN + ".{}", "Number", hass=hass) @@ -402,7 +410,7 @@ async def test_raise_error_on_update(hass: HomeAssistant) -> None: entity1 = MockEntity(name="test_1") entity2 = MockEntity(name="test_2") - def _raise(): + def _raise() -> None: """Raise an exception.""" raise AssertionError @@ -1490,7 +1498,10 @@ class SlowEntity(MockEntity): ), ) async def test_entity_name_influences_entity_id( - hass: HomeAssistant, has_entity_name, entity_name, expected_entity_id + hass: HomeAssistant, + has_entity_name: bool, + entity_name: str | None, + expected_entity_id: str, ) -> None: """Test entity_id is influenced by entity name.""" registry = er.async_get(hass) diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 066460c90d..d9ad81561c 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -10,6 +10,7 @@ from astral import LocationInfo import astral.sun import async_timeout from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory import jinja2 import pytest @@ -1406,7 +1407,7 @@ async def test_track_template_result_super_template_initially_false( ], ) async def test_track_template_result_super_template_2( - hass: HomeAssistant, availability_template + hass: HomeAssistant, availability_template: str ) -> None: """Test tracking template with super template listening to different entities.""" specific_runs = [] @@ -1545,7 +1546,7 @@ async def test_track_template_result_super_template_2( ], ) async def test_track_template_result_super_template_2_initially_false( - hass: HomeAssistant, availability_template + hass: HomeAssistant, availability_template: str ) -> None: """Test tracking template with super template listening to different entities.""" specific_runs = [] @@ -3898,7 +3899,9 @@ async def test_periodic_task_duplicate_time(hass: HomeAssistant) -> None: # DST starts early morning March 28th 2021 @pytest.mark.freeze_time("2021-03-28 01:28:00+01:00") -async def test_periodic_task_entering_dst(hass: HomeAssistant, freezer) -> None: +async def test_periodic_task_entering_dst( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test periodic task behavior when entering dst.""" hass.config.set_time_zone("Europe/Vienna") specific_runs = [] @@ -3944,7 +3947,9 @@ async def test_periodic_task_entering_dst(hass: HomeAssistant, freezer) -> None: # DST starts early morning March 28th 2021 @pytest.mark.freeze_time("2021-03-28 01:59:59+01:00") -async def test_periodic_task_entering_dst_2(hass: HomeAssistant, freezer) -> None: +async def test_periodic_task_entering_dst_2( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test periodic task behavior when entering dst. This tests a task firing every second in the range 0..58 (not *:*:59) @@ -3995,7 +4000,9 @@ async def test_periodic_task_entering_dst_2(hass: HomeAssistant, freezer) -> Non # DST ends early morning October 31st 2021 @pytest.mark.freeze_time("2021-10-31 02:28:00+02:00") -async def test_periodic_task_leaving_dst(hass: HomeAssistant, freezer) -> None: +async def test_periodic_task_leaving_dst( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test periodic task behavior when leaving dst.""" hass.config.set_time_zone("Europe/Vienna") specific_runs = [] @@ -4069,7 +4076,9 @@ async def test_periodic_task_leaving_dst(hass: HomeAssistant, freezer) -> None: # DST ends early morning October 31st 2021 @pytest.mark.freeze_time("2021-10-31 02:28:00+02:00") -async def test_periodic_task_leaving_dst_2(hass: HomeAssistant, freezer) -> None: +async def test_periodic_task_leaving_dst_2( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test periodic task behavior when leaving dst.""" hass.config.set_time_zone("Europe/Vienna") specific_runs = [] From 273d794f7ade9f12a195d2de24586e47c6ee98c4 Mon Sep 17 00:00:00 2001 From: Vincent Knoop Pathuis <48653141+vpathuis@users.noreply.github.com> Date: Thu, 16 Mar 2023 11:12:05 +0100 Subject: [PATCH 0987/2810] Add device class for Landis+Gyr GJ energy sensor (#89522) --- .../components/landisgyr_heat_meter/sensor.py | 37 +------------------ .../landisgyr_heat_meter/test_sensor.py | 32 ++++++++-------- 2 files changed, 18 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/landisgyr_heat_meter/sensor.py b/homeassistant/components/landisgyr_heat_meter/sensor.py index 8ded9e4d72..508ae43b8e 100644 --- a/homeassistant/components/landisgyr_heat_meter/sensor.py +++ b/homeassistant/components/landisgyr_heat_meter/sensor.py @@ -32,20 +32,11 @@ from homeassistant.helpers.update_coordinator import ( from homeassistant.util import dt as dt_util from . import DOMAIN -from .const import GJ_TO_MWH _LOGGER = logging.getLogger(__name__) HEAT_METER_SENSOR_TYPES = ( - SensorEntityDescription( - key="heat_usage", - icon="mdi:fire", - name="Heat usage", - native_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, - ), SensorEntityDescription( key="volume_usage_m3", icon="mdi:fire", @@ -54,23 +45,14 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, state_class=SensorStateClass.TOTAL, ), - # Diagnostic entity for debugging, this will match the value in GJ indicated on the meter's display SensorEntityDescription( key="heat_usage_gj", icon="mdi:fire", name="Heat usage GJ", - native_unit_of_measurement="GJ", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="heat_previous_year", - icon="mdi:fire", - name="Heat usage previous year", - native_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.GIGA_JOULE, device_class=SensorDeviceClass.ENERGY, - entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL, ), - # Diagnostic entity for debugging, this will match the value in GJ of previous year indicated on the meter's display SensorEntityDescription( key="heat_previous_year_gj", icon="mdi:fire", @@ -293,19 +275,4 @@ class HeatMeterSensor( else: self._attr_native_value = asdict(self.coordinator.data)[self.key] - if self.key == "heat_usage": - self._attr_native_value = convert_gj_to_mwh( - self.coordinator.data.heat_usage_gj - ) - - if self.key == "heat_previous_year": - self._attr_native_value = convert_gj_to_mwh( - self.coordinator.data.heat_previous_year_gj - ) - self.async_write_ha_state() - - -def convert_gj_to_mwh(gigajoule) -> float: - """Convert GJ to MWh using the conversion value.""" - return round(gigajoule * GJ_TO_MWH, 5) diff --git a/tests/components/landisgyr_heat_meter/test_sensor.py b/tests/components/landisgyr_heat_meter/test_sensor.py index 854ead82b3..6296fadd11 100644 --- a/tests/components/landisgyr_heat_meter/test_sensor.py +++ b/tests/components/landisgyr_heat_meter/test_sensor.py @@ -83,18 +83,18 @@ async def test_create_sensors( await hass.services.async_call( HA_DOMAIN, SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: "sensor.heat_meter_heat_usage"}, + {ATTR_ENTITY_ID: "sensor.heat_meter_heat_usage_gj"}, blocking=True, ) await hass.async_block_till_done() # check if 26 attributes have been created - assert len(hass.states.async_all()) == 27 + assert len(hass.states.async_all()) == 25 - state = hass.states.get("sensor.heat_meter_heat_usage") + state = hass.states.get("sensor.heat_meter_heat_usage_gj") assert state - assert state.state == "34.16669" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.MEGA_WATT_HOUR + assert state.state == "123.0" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.GIGA_JOULE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY @@ -132,17 +132,17 @@ async def test_restore_state(mock_heat_meter, hass: HomeAssistant) -> None: [ ( State( - "sensor.heat_meter_heat_usage", + "sensor.heat_meter_heat_usage_gj", "34167", attributes={ ATTR_LAST_RESET: last_reset, - ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.MEGA_WATT_HOUR, + ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.GIGA_JOULE, ATTR_STATE_CLASS: SensorStateClass.TOTAL, }, ), { "native_value": 34167, - "native_unit_of_measurement": UnitOfEnergy.MEGA_WATT_HOUR, + "native_unit_of_measurement": UnitOfEnergy.GIGA_JOULE, "icon": "mdi:fire", "last_reset": last_reset, }, @@ -194,10 +194,10 @@ async def test_restore_state(mock_heat_meter, hass: HomeAssistant) -> None: await hass.async_block_till_done() # restore from cache - state = hass.states.get("sensor.heat_meter_heat_usage") + state = hass.states.get("sensor.heat_meter_heat_usage_gj") assert state assert state.state == "34167" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.MEGA_WATT_HOUR + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.GIGA_JOULE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL state = hass.states.get("sensor.heat_meter_volume_usage") @@ -240,21 +240,21 @@ async def test_exception_on_polling(mock_heat_meter, hass: HomeAssistant) -> Non await hass.services.async_call( HA_DOMAIN, SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: "sensor.heat_meter_heat_usage"}, + {ATTR_ENTITY_ID: "sensor.heat_meter_heat_usage_gj"}, blocking=True, ) await hass.async_block_till_done() # check if initial setup succeeded - state = hass.states.get("sensor.heat_meter_heat_usage") + state = hass.states.get("sensor.heat_meter_heat_usage_gj") assert state - assert state.state == "34.16669" + assert state.state == "123.0" # Now 'disable' the connection and wait for polling and see if it fails mock_heat_meter().read.side_effect = serial.serialutil.SerialException async_fire_time_changed(hass, dt_util.utcnow() + POLLING_INTERVAL) await hass.async_block_till_done() - state = hass.states.get("sensor.heat_meter_heat_usage") + state = hass.states.get("sensor.heat_meter_heat_usage_gj") assert state.state == STATE_UNAVAILABLE # Now 'enable' and see if next poll succeeds @@ -270,6 +270,6 @@ async def test_exception_on_polling(mock_heat_meter, hass: HomeAssistant) -> Non mock_heat_meter().read.side_effect = None async_fire_time_changed(hass, dt_util.utcnow() + POLLING_INTERVAL) await hass.async_block_till_done() - state = hass.states.get("sensor.heat_meter_heat_usage") + state = hass.states.get("sensor.heat_meter_heat_usage_gj") assert state - assert state.state == "34.44447" + assert state.state == "124.0" From f55aaf7664dbb43cb7c20a72753033d634c581d6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 16 Mar 2023 11:15:38 +0100 Subject: [PATCH 0988/2810] Drop unused Google entity settings from cloud (#89786) --- homeassistant/components/cloud/const.py | 2 -- homeassistant/components/cloud/http_api.py | 2 -- homeassistant/components/cloud/prefs.py | 6 ------ tests/components/cloud/test_http_api.py | 6 ------ 4 files changed, 16 deletions(-) diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 9fb4ffc704..9d5ed2ca28 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -12,9 +12,7 @@ PREF_GOOGLE_ENTITY_CONFIGS = "google_entity_configs" PREF_GOOGLE_REPORT_STATE = "google_report_state" PREF_ALEXA_ENTITY_CONFIGS = "alexa_entity_configs" PREF_ALEXA_REPORT_STATE = "alexa_report_state" -PREF_OVERRIDE_NAME = "override_name" PREF_DISABLE_2FA = "disable_2fa" -PREF_ALIASES = "aliases" PREF_SHOULD_EXPOSE = "should_expose" PREF_GOOGLE_LOCAL_WEBHOOK_ID = "google_local_webhook_id" PREF_USERNAME = "username" diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index ea1a0aa27e..6c4115ae28 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -559,8 +559,6 @@ async def google_assistant_list( "type": "cloud/google_assistant/entities/update", "entity_id": str, vol.Optional("should_expose"): vol.Any(None, bool), - vol.Optional("override_name"): str, - vol.Optional("aliases"): [str], vol.Optional("disable_2fa"): bool, } ) diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 17ec00026b..7f27e7cf39 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -18,7 +18,6 @@ from .const import ( PREF_ALEXA_DEFAULT_EXPOSE, PREF_ALEXA_ENTITY_CONFIGS, PREF_ALEXA_REPORT_STATE, - PREF_ALIASES, PREF_CLOUD_USER, PREF_CLOUDHOOKS, PREF_DISABLE_2FA, @@ -30,7 +29,6 @@ from .const import ( PREF_GOOGLE_LOCAL_WEBHOOK_ID, PREF_GOOGLE_REPORT_STATE, PREF_GOOGLE_SECURE_DEVICES_PIN, - PREF_OVERRIDE_NAME, PREF_REMOTE_DOMAIN, PREF_SHOULD_EXPOSE, PREF_TTS_DEFAULT_VOICE, @@ -118,9 +116,7 @@ class CloudPreferences: self, *, entity_id, - override_name=UNDEFINED, disable_2fa=UNDEFINED, - aliases=UNDEFINED, should_expose=UNDEFINED, ): """Update config for a Google entity.""" @@ -129,9 +125,7 @@ class CloudPreferences: changes = {} for key, value in ( - (PREF_OVERRIDE_NAME, override_name), (PREF_DISABLE_2FA, disable_2fa), - (PREF_ALIASES, aliases), (PREF_SHOULD_EXPOSE, should_expose), ): if value is not UNDEFINED: diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index c6222314a7..92c0ca70a1 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -727,8 +727,6 @@ async def test_update_google_entity( "type": "cloud/google_assistant/entities/update", "entity_id": "light.kitchen", "should_expose": False, - "override_name": "updated name", - "aliases": ["lefty", "righty"], "disable_2fa": False, } ) @@ -738,8 +736,6 @@ async def test_update_google_entity( prefs = hass.data[DOMAIN].client.prefs assert prefs.google_entity_configs["light.kitchen"] == { "should_expose": False, - "override_name": "updated name", - "aliases": ["lefty", "righty"], "disable_2fa": False, } @@ -757,8 +753,6 @@ async def test_update_google_entity( prefs = hass.data[DOMAIN].client.prefs assert prefs.google_entity_configs["light.kitchen"] == { "should_expose": None, - "override_name": "updated name", - "aliases": ["lefty", "righty"], "disable_2fa": False, } From f32b7859b8545e93aed801e69b35b2d078c0da1c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 16 Mar 2023 12:16:08 +0100 Subject: [PATCH 0989/2810] Restructure translations for entity components (#89702) --- .../alarm_control_panel/strings.json | 26 +-- homeassistant/components/alert/strings.json | 10 +- .../components/automation/strings.json | 8 +- .../components/binary_sensor/strings.json | 168 ++++++++++++------ .../components/calendar/strings.json | 8 +- homeassistant/components/camera/strings.json | 10 +- homeassistant/components/climate/strings.json | 168 +++++++++--------- .../components/configurator/strings.json | 8 +- homeassistant/components/cover/strings.json | 14 +- .../components/device_tracker/strings.json | 8 +- homeassistant/components/fan/strings.json | 8 +- homeassistant/components/group/strings.json | 24 +-- .../components/humidifier/strings.json | 8 +- .../components/input_boolean/strings.json | 8 +- homeassistant/components/light/strings.json | 8 +- homeassistant/components/lock/strings.json | 8 +- .../components/media_player/strings.json | 18 +- homeassistant/components/person/strings.json | 8 +- homeassistant/components/plant/strings.json | 8 +- homeassistant/components/remote/strings.json | 8 +- .../components/schedule/strings.json | 8 +- homeassistant/components/script/strings.json | 8 +- homeassistant/components/sensor/strings.json | 8 +- homeassistant/components/sun/strings.json | 8 +- homeassistant/components/switch/strings.json | 8 +- homeassistant/components/timer/strings.json | 10 +- homeassistant/components/vacuum/strings.json | 20 ++- .../components/water_heater/strings.json | 18 +- homeassistant/components/weather/strings.json | 34 ++-- homeassistant/helpers/translation.py | 17 +- script/hassfest/translations.py | 65 +++---- tests/helpers/test_translation.py | 18 +- 32 files changed, 435 insertions(+), 321 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/strings.json b/homeassistant/components/alarm_control_panel/strings.json index 5126f49d92..1d8a29f7dd 100644 --- a/homeassistant/components/alarm_control_panel/strings.json +++ b/homeassistant/components/alarm_control_panel/strings.json @@ -26,19 +26,21 @@ "armed_vacation": "{entity_name} armed vacation" } }, - "state": { + "entity_component": { "_": { - "armed": "Armed", - "disarmed": "Disarmed", - "armed_home": "Armed home", - "armed_away": "Armed away", - "armed_night": "Armed night", - "armed_vacation": "Armed vacation", - "armed_custom_bypass": "Armed custom bypass", - "pending": "Pending", - "arming": "Arming", - "disarming": "Disarming", - "triggered": "Triggered" + "state": { + "armed": "Armed", + "disarmed": "Disarmed", + "armed_home": "Armed home", + "armed_away": "Armed away", + "armed_night": "Armed night", + "armed_vacation": "Armed vacation", + "armed_custom_bypass": "Armed custom bypass", + "pending": "Pending", + "arming": "Arming", + "disarming": "Disarming", + "triggered": "Triggered" + } } } } diff --git a/homeassistant/components/alert/strings.json b/homeassistant/components/alert/strings.json index fb31ecd057..9975e6ee0d 100644 --- a/homeassistant/components/alert/strings.json +++ b/homeassistant/components/alert/strings.json @@ -1,10 +1,12 @@ { "title": "Alert", - "state": { + "entity_component": { "_": { - "idle": "[%key:common::state::idle%]", - "off": "Acknowledged", - "on": "[%key:common::state::active%]" + "state": { + "idle": "[%key:common::state::idle%]", + "off": "Acknowledged", + "on": "[%key:common::state::active%]" + } } } } diff --git a/homeassistant/components/automation/strings.json b/homeassistant/components/automation/strings.json index ea03868e63..ffadc47a48 100644 --- a/homeassistant/components/automation/strings.json +++ b/homeassistant/components/automation/strings.json @@ -1,9 +1,11 @@ { "title": "Automation", - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } } }, "issues": { diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json index 5d17fb92cb..8d787204db 100644 --- a/homeassistant/components/binary_sensor/strings.json +++ b/homeassistant/components/binary_sensor/strings.json @@ -106,114 +106,168 @@ "turned_off": "{entity_name} turned off" } }, - "state": { + "entity_component": { + "_": { + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } + }, "battery": { - "off": "Normal", - "on": "Low" + "state": { + "off": "Normal", + "on": "Low" + } }, "battery_charging": { - "off": "Not charging", - "on": "Charging" + "state": { + "off": "Not charging", + "on": "Charging" + } }, "carbon_monoxide": { - "off": "[%key:component::binary_sensor::state::gas::off%]", - "on": "[%key:component::binary_sensor::state::gas::on%]" + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "cold": { - "off": "[%key:component::binary_sensor::state::battery::off%]", - "on": "Cold" + "state": { + "off": "[%key:component::binary_sensor::entity_component::battery::state::off%]", + "on": "Cold" + } }, "connectivity": { - "off": "[%key:common::state::disconnected%]", - "on": "[%key:common::state::connected%]" + "state": { + "off": "[%key:common::state::disconnected%]", + "on": "[%key:common::state::connected%]" + } }, "door": { - "off": "[%key:common::state::closed%]", - "on": "[%key:common::state::open%]" + "state": { + "off": "[%key:common::state::closed%]", + "on": "[%key:common::state::open%]" + } }, "garage_door": { - "off": "[%key:common::state::closed%]", - "on": "[%key:common::state::open%]" + "state": { + "off": "[%key:common::state::closed%]", + "on": "[%key:common::state::open%]" + } }, "gas": { - "off": "Clear", - "on": "Detected" + "state": { + "off": "Clear", + "on": "Detected" + } }, "heat": { - "off": "[%key:component::binary_sensor::state::battery::off%]", - "on": "Hot" + "state": { + "off": "[%key:component::binary_sensor::entity_component::battery::state::off%]", + "on": "Hot" + } }, "light": { - "off": "No light", - "on": "Light detected" + "state": { + "off": "No light", + "on": "Light detected" + } }, "lock": { - "off": "[%key:common::state::locked%]", - "on": "[%key:common::state::unlocked%]" + "state": { + "off": "[%key:common::state::locked%]", + "on": "[%key:common::state::unlocked%]" + } }, "moisture": { - "off": "Dry", - "on": "Wet" + "state": { + "off": "Dry", + "on": "Wet" + } }, "motion": { - "off": "[%key:component::binary_sensor::state::gas::off%]", - "on": "[%key:component::binary_sensor::state::gas::on%]" + "state": { + "off": "[key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "moving": { - "off": "Not moving", - "on": "Moving" + "state": { + "off": "Not moving", + "on": "Moving" + } }, "occupancy": { - "off": "[%key:component::binary_sensor::state::gas::off%]", - "on": "[%key:component::binary_sensor::state::gas::on%]" + "state": { + "off": "[key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "opening": { - "off": "[%key:common::state::closed%]", - "on": "[%key:common::state::open%]" + "state": { + "off": "[%key:common::state::closed%]", + "on": "[%key:common::state::open%]" + } }, "plug": { - "off": "Unplugged", - "on": "Plugged in" + "state": { + "off": "Unplugged", + "on": "Plugged in" + } }, "presence": { - "off": "[%key:component::device_tracker::state::_::not_home%]", - "on": "[%key:component::device_tracker::state::_::home%]" + "state": { + "off": "[%key:component::device_tracker::entity_component::_::state::not_home%]", + "on": "[%key:component::device_tracker::entity_component::_::state::home%]" + } }, "problem": { - "off": "OK", - "on": "Problem" + "state": { + "off": "OK", + "on": "Problem" + } }, "running": { - "off": "Not running", - "on": "Running" + "state": { + "off": "Not running", + "on": "Running" + } }, "safety": { - "off": "Safe", - "on": "Unsafe" + "state": { + "off": "Safe", + "on": "Unsafe" + } }, "smoke": { - "off": "[%key:component::binary_sensor::state::gas::off%]", - "on": "[%key:component::binary_sensor::state::gas::on%]" + "state": { + "off": "[key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "sound": { - "off": "[%key:component::binary_sensor::state::gas::off%]", - "on": "[%key:component::binary_sensor::state::gas::on%]" + "state": { + "off": "[key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "update": { - "off": "Up-to-date", - "on": "Update available" + "state": { + "off": "Up-to-date", + "on": "Update available" + } }, "vibration": { - "off": "[%key:component::binary_sensor::state::gas::off%]", - "on": "[%key:component::binary_sensor::state::gas::on%]" + "state": { + "off": "[key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "window": { - "off": "[%key:common::state::closed%]", - "on": "[%key:common::state::open%]" - }, - "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "state": { + "off": "[%key:common::state::closed%]", + "on": "[%key:common::state::open%]" + } } }, "device_class": { diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index 3af9a78e60..bcc07bb7fc 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -1,9 +1,11 @@ { "title": "Calendar", - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } } } } diff --git a/homeassistant/components/camera/strings.json b/homeassistant/components/camera/strings.json index 3b8767ec8c..5bde2ed251 100644 --- a/homeassistant/components/camera/strings.json +++ b/homeassistant/components/camera/strings.json @@ -1,10 +1,12 @@ { "title": "Camera", - "state": { + "entity_component": { "_": { - "recording": "Recording", - "streaming": "Streaming", - "idle": "[%key:common::state::idle%]" + "state": { + "recording": "Recording", + "streaming": "Streaming", + "idle": "[%key:common::state::idle%]" + } } } } diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index 8c6c8f2d97..16cf9a130b 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -15,92 +15,92 @@ "set_preset_mode": "Change preset on {entity_name}" } }, - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "heat": "Heat", - "cool": "Cool", - "heat_cool": "Heat/Cool", - "auto": "Auto", - "dry": "Dry", - "fan_only": "Fan only" - } - }, - "state_attributes": { - "_": { - "aux_heat": { "name": "Aux heat" }, - "current_humidity": { "name": "Current humidity" }, - "current_temperature": { "name": "Current temperature" }, - "fan_mode": { - "name": "Fan mode", - "state": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]", - "auto": "Auto", - "low": "Low", - "medium": "Medium", - "high": "High", - "top": "Top", - "middle": "Middle", - "focus": "Focus", - "diffuse": "Diffuse" - } + "state": { + "off": "[%key:common::state::off%]", + "heat": "Heat", + "cool": "Cool", + "heat_cool": "Heat/Cool", + "auto": "Auto", + "dry": "Dry", + "fan_only": "Fan only" }, - "fan_modes": { - "name": "Fan modes" - }, - "humidity": { "name": "Target humidity" }, - "hvac_action": { - "name": "Current action", - "state": { - "off": "Off", - "heating": "Heating", - "cooling": "Cooling", - "drying": "Drying", - "idle": "Idle", - "fan": "Fan" - } - }, - "hvac_modes": { - "name": "HVAC modes" - }, - "max_humidity": { "name": "Max target humidity" }, - "max_temp": { "name": "Max target temperature" }, - "min_humidity": { "name": "Min target humidity" }, - "min_temp": { "name": "Min target temperature" }, - "preset_mode": { - "name": "Preset", - "state": { - "none": "None", - "eco": "Eco", - "away": "Away", - "boost": "Boost", - "comfort": "Comfort", - "home": "Home", - "sleep": "Sleep", - "activity": "Activity" - } - }, - "preset_modes": { - "name": "Presets" - }, - "swing_mode": { - "name": "Swing mode", - "state": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]", - "both": "Both", - "vertical": "Vertical", - "horizontal": "Horizontal" - } - }, - "swing_modes": { - "name": "Swing modes" - }, - "target_temp_high": { "name": "Upper target temperature" }, - "target_temp_low": { "name": "Lower target temperature" }, - "target_temp_step": { "name": "Target temperature step" }, - "temperature": { "name": "Target temperature" } + "state_attributes": { + "aux_heat": { "name": "Aux heat" }, + "current_humidity": { "name": "Current humidity" }, + "current_temperature": { "name": "Current temperature" }, + "fan_mode": { + "name": "Fan mode", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "auto": "Auto", + "low": "Low", + "medium": "Medium", + "high": "High", + "top": "Top", + "middle": "Middle", + "focus": "Focus", + "diffuse": "Diffuse" + } + }, + "fan_modes": { + "name": "Fan modes" + }, + "humidity": { "name": "Target humidity" }, + "hvac_action": { + "name": "Current action", + "state": { + "off": "Off", + "heating": "Heating", + "cooling": "Cooling", + "drying": "Drying", + "idle": "Idle", + "fan": "Fan" + } + }, + "hvac_modes": { + "name": "HVAC modes" + }, + "max_humidity": { "name": "Max target humidity" }, + "max_temp": { "name": "Max target temperature" }, + "min_humidity": { "name": "Min target humidity" }, + "min_temp": { "name": "Min target temperature" }, + "preset_mode": { + "name": "Preset", + "state": { + "none": "None", + "eco": "Eco", + "away": "Away", + "boost": "Boost", + "comfort": "Comfort", + "home": "Home", + "sleep": "Sleep", + "activity": "Activity" + } + }, + "preset_modes": { + "name": "Presets" + }, + "swing_mode": { + "name": "Swing mode", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "both": "Both", + "vertical": "Vertical", + "horizontal": "Horizontal" + } + }, + "swing_modes": { + "name": "Swing modes" + }, + "target_temp_high": { "name": "Upper target temperature" }, + "target_temp_low": { "name": "Lower target temperature" }, + "target_temp_step": { "name": "Target temperature step" }, + "temperature": { "name": "Target temperature" } + } } } } diff --git a/homeassistant/components/configurator/strings.json b/homeassistant/components/configurator/strings.json index 570c18d3cd..c48f1d8385 100644 --- a/homeassistant/components/configurator/strings.json +++ b/homeassistant/components/configurator/strings.json @@ -1,9 +1,11 @@ { "title": "Configurator", - "state": { + "entity_component": { "_": { - "configure": "Configure", - "configured": "Configured" + "state": { + "configure": "Configure", + "configured": "Configured" + } } } } diff --git a/homeassistant/components/cover/strings.json b/homeassistant/components/cover/strings.json index cb98c542d4..d17508b71d 100644 --- a/homeassistant/components/cover/strings.json +++ b/homeassistant/components/cover/strings.json @@ -27,13 +27,15 @@ "tilt_position": "{entity_name} tilt position changes" } }, - "state": { + "entity_component": { "_": { - "open": "[%key:common::state::open%]", - "opening": "Opening", - "closed": "[%key:common::state::closed%]", - "closing": "Closing", - "stopped": "Stopped" + "state": { + "open": "[%key:common::state::open%]", + "opening": "Opening", + "closed": "[%key:common::state::closed%]", + "closing": "Closing", + "stopped": "Stopped" + } } } } diff --git a/homeassistant/components/device_tracker/strings.json b/homeassistant/components/device_tracker/strings.json index 48cb667e73..a1c50c88f8 100644 --- a/homeassistant/components/device_tracker/strings.json +++ b/homeassistant/components/device_tracker/strings.json @@ -10,10 +10,12 @@ "leaves": "{entity_name} leaves a zone" } }, - "state": { + "entity_component": { "_": { - "home": "[%key:common::state::home%]", - "not_home": "[%key:common::state::not_home%]" + "state": { + "home": "[%key:common::state::home%]", + "not_home": "[%key:common::state::not_home%]" + } } } } diff --git a/homeassistant/components/fan/strings.json b/homeassistant/components/fan/strings.json index fdd95a822d..670d11b76b 100644 --- a/homeassistant/components/fan/strings.json +++ b/homeassistant/components/fan/strings.json @@ -16,10 +16,12 @@ "turn_off": "Turn off {entity_name}" } }, - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } } } } diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index 75a2423d93..17f63167db 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -155,18 +155,20 @@ } } }, - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]", - "home": "[%key:component::device_tracker::state::_::home%]", - "not_home": "[%key:component::device_tracker::state::_::not_home%]", - "open": "[%key:common::state::open%]", - "closed": "[%key:common::state::closed%]", - "locked": "[%key:common::state::locked%]", - "unlocked": "[%key:common::state::unlocked%]", - "ok": "[%key:component::binary_sensor::state::problem::off%]", - "problem": "[%key:component::binary_sensor::state::problem::on%]" + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "home": "[%key:component::device_tracker::entity_component::_::state::home%]", + "not_home": "[%key:component::device_tracker::entity_component::_::state::not_home%]", + "open": "[%key:common::state::open%]", + "closed": "[%key:common::state::closed%]", + "locked": "[%key:common::state::locked%]", + "unlocked": "[%key:common::state::unlocked%]", + "ok": "[%key:component::binary_sensor::entity_component::problem::state::off%]", + "problem": "[%key:component::binary_sensor::entity_component::problem::state::on%]" + } } } } diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index 46e2fc1605..e536def167 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -20,10 +20,12 @@ "turn_off": "Turn off {entity_name}" } }, - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } } } } diff --git a/homeassistant/components/input_boolean/strings.json b/homeassistant/components/input_boolean/strings.json index a32958592f..509799b5ed 100644 --- a/homeassistant/components/input_boolean/strings.json +++ b/homeassistant/components/input_boolean/strings.json @@ -1,9 +1,11 @@ { "title": "Input boolean", - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } } } } diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index ee1f8e13b6..38f843ab1d 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -19,10 +19,12 @@ "turned_off": "{entity_name} turned off" } }, - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } } } } diff --git a/homeassistant/components/lock/strings.json b/homeassistant/components/lock/strings.json index 9e4c4ea726..ab7a1632ea 100644 --- a/homeassistant/components/lock/strings.json +++ b/homeassistant/components/lock/strings.json @@ -15,10 +15,12 @@ "unlocked": "{entity_name} unlocked" } }, - "state": { + "entity_component": { "_": { - "locked": "[%key:common::state::locked%]", - "unlocked": "[%key:common::state::unlocked%]" + "state": { + "locked": "[%key:common::state::locked%]", + "unlocked": "[%key:common::state::unlocked%]" + } } } } diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index bb6c7d16f5..2c8f3d3d2b 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -19,15 +19,17 @@ "changed_states": "{entity_name} changed states" } }, - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]", - "playing": "Playing", - "paused": "[%key:common::state::paused%]", - "idle": "[%key:common::state::idle%]", - "standby": "[%key:common::state::standby%]", - "buffering": "Buffering" + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "playing": "Playing", + "paused": "[%key:common::state::paused%]", + "idle": "[%key:common::state::idle%]", + "standby": "[%key:common::state::standby%]", + "buffering": "Buffering" + } } } } diff --git a/homeassistant/components/person/strings.json b/homeassistant/components/person/strings.json index c94499d92f..7bba0198a1 100644 --- a/homeassistant/components/person/strings.json +++ b/homeassistant/components/person/strings.json @@ -1,9 +1,11 @@ { "title": "Person", - "state": { + "entity_component": { "_": { - "home": "[%key:common::state::home%]", - "not_home": "[%key:common::state::not_home%]" + "state": { + "home": "[%key:common::state::home%]", + "not_home": "[%key:common::state::not_home%]" + } } } } diff --git a/homeassistant/components/plant/strings.json b/homeassistant/components/plant/strings.json index 2478564ca8..5ece766c71 100644 --- a/homeassistant/components/plant/strings.json +++ b/homeassistant/components/plant/strings.json @@ -1,9 +1,11 @@ { "title": "Plant Monitor", - "state": { + "entity_component": { "_": { - "ok": "[%key:component::binary_sensor::state::problem::off%]", - "problem": "[%key:component::binary_sensor::state::problem::on%]" + "state": { + "ok": "[%key:component::binary_sensor::entity_component::problem::state::off%]", + "problem": "[%key:component::binary_sensor::entity_component::problem::state::on%]" + } } } } diff --git a/homeassistant/components/remote/strings.json b/homeassistant/components/remote/strings.json index 4a2b20c65d..a558cc76fe 100644 --- a/homeassistant/components/remote/strings.json +++ b/homeassistant/components/remote/strings.json @@ -16,10 +16,12 @@ "turned_off": "{entity_name} turned off" } }, - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } } } } diff --git a/homeassistant/components/schedule/strings.json b/homeassistant/components/schedule/strings.json index fdcb8c4ffd..ecc673805c 100644 --- a/homeassistant/components/schedule/strings.json +++ b/homeassistant/components/schedule/strings.json @@ -1,9 +1,11 @@ { "title": "Schedule", - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } } } } diff --git a/homeassistant/components/script/strings.json b/homeassistant/components/script/strings.json index 2d39b6ac63..a4ea086006 100644 --- a/homeassistant/components/script/strings.json +++ b/homeassistant/components/script/strings.json @@ -1,9 +1,11 @@ { "title": "Script", - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } } } } diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 2396bbf295..4b764c609c 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -94,10 +94,12 @@ "wind_speed": "{entity_name} wind speed changes" } }, - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } } } } diff --git a/homeassistant/components/sun/strings.json b/homeassistant/components/sun/strings.json index cdcaa416ed..d8a75224f6 100644 --- a/homeassistant/components/sun/strings.json +++ b/homeassistant/components/sun/strings.json @@ -10,10 +10,12 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } }, - "state": { + "entity_component": { "_": { - "above_horizon": "Above horizon", - "below_horizon": "Below horizon" + "state": { + "above_horizon": "Above horizon", + "below_horizon": "Below horizon" + } } } } diff --git a/homeassistant/components/switch/strings.json b/homeassistant/components/switch/strings.json index 7ea84e649e..ba7c5c6848 100644 --- a/homeassistant/components/switch/strings.json +++ b/homeassistant/components/switch/strings.json @@ -16,10 +16,12 @@ "turned_off": "{entity_name} turned off" } }, - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } } } } diff --git a/homeassistant/components/timer/strings.json b/homeassistant/components/timer/strings.json index 985cea0aa6..914ee73835 100644 --- a/homeassistant/components/timer/strings.json +++ b/homeassistant/components/timer/strings.json @@ -1,9 +1,11 @@ { - "state": { + "entity_component": { "_": { - "active": "[%key:common::state::active%]", - "idle": "[%key:common::state::idle%]", - "paused": "[%key:common::state::paused%]" + "state": { + "active": "[%key:common::state::active%]", + "idle": "[%key:common::state::idle%]", + "paused": "[%key:common::state::paused%]" + } } } } diff --git a/homeassistant/components/vacuum/strings.json b/homeassistant/components/vacuum/strings.json index 033946735f..eb84b910b4 100644 --- a/homeassistant/components/vacuum/strings.json +++ b/homeassistant/components/vacuum/strings.json @@ -14,16 +14,18 @@ "dock": "Let {entity_name} return to the dock" } }, - "state": { + "entity_component": { "_": { - "cleaning": "Cleaning", - "docked": "Docked", - "error": "Error", - "idle": "[%key:common::state::idle%]", - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]", - "paused": "[%key:common::state::paused%]", - "returning": "Returning to dock" + "state": { + "cleaning": "Cleaning", + "docked": "Docked", + "error": "Error", + "idle": "[%key:common::state::idle%]", + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "paused": "[%key:common::state::paused%]", + "returning": "Returning to dock" + } } } } diff --git a/homeassistant/components/water_heater/strings.json b/homeassistant/components/water_heater/strings.json index 3d9ab67eab..9e3eec8604 100644 --- a/homeassistant/components/water_heater/strings.json +++ b/homeassistant/components/water_heater/strings.json @@ -5,15 +5,17 @@ "turn_off": "Turn off {entity_name}" } }, - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "eco": "Eco", - "electric": "Electric", - "gas": "Gas", - "high_demand": "High Demand", - "heat_pump": "Heat Pump", - "performance": "Performance" + "state": { + "off": "[%key:common::state::off%]", + "eco": "Eco", + "electric": "Electric", + "gas": "Gas", + "high_demand": "High Demand", + "heat_pump": "Heat Pump", + "performance": "Performance" + } } } } diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json index c4764beb5b..b3af53a91c 100644 --- a/homeassistant/components/weather/strings.json +++ b/homeassistant/components/weather/strings.json @@ -1,21 +1,23 @@ { - "state": { + "entity_component": { "_": { - "clear-night": "Clear, night", - "cloudy": "Cloudy", - "exceptional": "Exceptional", - "fog": "Fog", - "hail": "Hail", - "lightning": "Lightning", - "lightning-rainy": "Lightning, rainy", - "partlycloudy": "Partly cloudy", - "pouring": "Pouring", - "rainy": "Rainy", - "snowy": "Snowy", - "snowy-rainy": "Snowy, rainy", - "sunny": "Sunny", - "windy": "Windy", - "windy-variant": "Windy" + "state": { + "clear-night": "Clear, night", + "cloudy": "Cloudy", + "exceptional": "Exceptional", + "fog": "Fog", + "hail": "Hail", + "lightning": "Lightning", + "lightning-rainy": "Lightning, rainy", + "partlycloudy": "Partly cloudy", + "pouring": "Pouring", + "rainy": "Rainy", + "snowy": "Snowy", + "snowy-rainy": "Snowy, rainy", + "sunny": "Sunny", + "windy": "Windy", + "windy-variant": "Windy" + } } } } diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 7ccbea3653..96ce9b618c 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -255,13 +255,16 @@ class _TranslationCache: categories.update(resource) for category in categories: - resource_func = ( - _merge_resources if category == "state" else _build_resources - ) new_resources: Mapping[str, dict[str, Any] | str] - new_resources = resource_func( # type: ignore[assignment] - translation_strings, components, category - ) + + if category in ("state", "entity_component"): + new_resources = _merge_resources( + translation_strings, components, category + ) + else: + new_resources = _build_resources( + translation_strings, components, category + ) for component, resource in new_resources.items(): category_cache: dict[str, Any] = cached.setdefault( @@ -299,7 +302,7 @@ async def async_get_translations( components = set(integrations) elif config_flow: components = (await async_get_config_flows(hass)) - hass.config.components - elif category == "state": + elif category in ("state", "entity_component"): components = set(hass.config.components) else: # Only 'state' supports merging, so remove platforms from selection diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 92a1047c30..eb51d12c37 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -231,25 +231,6 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: vol.Optional("trigger_type"): {str: cv.string_with_no_html}, vol.Optional("trigger_subtype"): {str: cv.string_with_no_html}, }, - vol.Optional("state"): cv.schema_with_slug_keys( - cv.schema_with_slug_keys( - cv.string_with_no_html, slug_validator=translation_key_validator - ), - slug_validator=vol.Any("_", cv.slug), - ), - vol.Optional("state_attributes"): cv.schema_with_slug_keys( - cv.schema_with_slug_keys( - { - vol.Optional("name"): str, - vol.Optional("state"): cv.schema_with_slug_keys( - cv.string_with_no_html, - slug_validator=translation_key_validator, - ), - }, - slug_validator=translation_key_validator, - ), - slug_validator=vol.Any("_", cv.slug), - ), vol.Optional("system_health"): { vol.Optional("info"): cv.schema_with_slug_keys( cv.string_with_no_html, slug_validator=translation_key_validator @@ -283,26 +264,48 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: ), ) }, - vol.Optional("entity"): { - str: { - str: { + vol.Optional("entity_component"): cv.schema_with_slug_keys( + { + vol.Optional("state"): cv.schema_with_slug_keys( + cv.string_with_no_html, + slug_validator=translation_key_validator, + ), + vol.Optional("state_attributes"): cv.schema_with_slug_keys( + { + vol.Optional("name"): str, + vol.Optional("state"): cv.schema_with_slug_keys( + cv.string_with_no_html, + slug_validator=translation_key_validator, + ), + }, + slug_validator=translation_key_validator, + ), + }, + slug_validator=vol.Any("_", cv.slug), + ), + vol.Optional("entity"): cv.schema_with_slug_keys( + cv.schema_with_slug_keys( + { vol.Optional("name"): cv.string_with_no_html, - vol.Optional("state_attributes"): { - str: { + vol.Optional("state"): cv.schema_with_slug_keys( + cv.string_with_no_html, + slug_validator=translation_key_validator, + ), + vol.Optional("state_attributes"): cv.schema_with_slug_keys( + { vol.Optional("name"): cv.string_with_no_html, vol.Optional("state"): cv.schema_with_slug_keys( cv.string_with_no_html, slug_validator=translation_key_validator, ), - } - }, - vol.Optional("state"): cv.schema_with_slug_keys( - cv.string_with_no_html, + }, slug_validator=translation_key_validator, ), - } - } - }, + }, + slug_validator=translation_key_validator, + ), + slug_validator=cv.slug, + ), } ) diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index 25f1a74266..197053ba2b 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -373,32 +373,32 @@ async def test_caching(hass: HomeAssistant) -> None: "homeassistant.helpers.translation._merge_resources", side_effect=translation._merge_resources, ) as mock_merge: - load1 = await translation.async_get_translations(hass, "en", "state") + load1 = await translation.async_get_translations(hass, "en", "entity_component") assert len(mock_merge.mock_calls) == 1 - load2 = await translation.async_get_translations(hass, "en", "state") + load2 = await translation.async_get_translations(hass, "en", "entity_component") assert len(mock_merge.mock_calls) == 1 assert load1 == load2 for key in load1: - assert key.startswith("component.sensor.state.") or key.startswith( - "component.light.state." - ) + assert key.startswith( + "component.sensor.entity_component._.state." + ) or key.startswith("component.light.entity_component._.state.") load_sensor_only = await translation.async_get_translations( - hass, "en", "state", integrations={"sensor"} + hass, "en", "entity_component", integrations={"sensor"} ) assert load_sensor_only for key in load_sensor_only: - assert key.startswith("component.sensor.state.") + assert key.startswith("component.sensor.entity_component._.state.") load_light_only = await translation.async_get_translations( - hass, "en", "state", integrations={"light"} + hass, "en", "entity_component", integrations={"light"} ) assert load_light_only for key in load_light_only: - assert key.startswith("component.light.state.") + assert key.startswith("component.light.entity_component._.state.") hass.config.components.add("media_player") From e57031b1b5bb2bd23d712d98450277daefd0ab4d Mon Sep 17 00:00:00 2001 From: Kevin Siml Date: Thu, 16 Mar 2023 13:03:05 +0100 Subject: [PATCH 0990/2810] Add Pushsafer notify parameters (#89555) --- homeassistant/components/pushsafer/notify.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/pushsafer/notify.py b/homeassistant/components/pushsafer/notify.py index ddf4ca5ef4..5411db05e2 100644 --- a/homeassistant/components/pushsafer/notify.py +++ b/homeassistant/components/pushsafer/notify.py @@ -40,7 +40,10 @@ ATTR_TIME2LIVE = "time2live" ATTR_PRIORITY = "priority" ATTR_RETRY = "retry" ATTR_EXPIRE = "expire" +ATTR_CONFIRM = "confirm" ATTR_ANSWER = "answer" +ATTR_ANSWEROPTIONS = "answeroptions" +ATTR_ANSWERFORCE = "answerforce" ATTR_PICTURE1 = "picture1" # Attributes contained in picture1 @@ -120,7 +123,10 @@ class PushsaferNotificationService(BaseNotificationService): "pr": data.get(ATTR_PRIORITY, ""), "re": data.get(ATTR_RETRY, ""), "ex": data.get(ATTR_EXPIRE, ""), + "cr": data.get(ATTR_CONFIRM, ""), "a": data.get(ATTR_ANSWER, ""), + "ao": data.get(ATTR_ANSWEROPTIONS, ""), + "af": data.get(ATTR_ANSWERFORCE, ""), "p": picture1_encoded, } From b3bd80d905b32c2a20ab5e6ab2d1a2483123bec1 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Thu, 16 Mar 2023 13:26:56 +0100 Subject: [PATCH 0991/2810] Handle int or mapping for off case in nibe cooling (#89680) Handle int or mapping for off case in nibe --- homeassistant/components/nibe_heatpump/climate.py | 10 +++++++--- homeassistant/components/nibe_heatpump/const.py | 1 + 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nibe_heatpump/climate.py b/homeassistant/components/nibe_heatpump/climate.py index a68aabacf4..0df787de98 100644 --- a/homeassistant/components/nibe_heatpump/climate.py +++ b/homeassistant/components/nibe_heatpump/climate.py @@ -31,6 +31,7 @@ from . import Coordinator from .const import ( DOMAIN, LOGGER, + VALUES_COOL_WITH_ROOM_SENSOR_OFF, VALUES_MIXING_VALVE_CLOSED_STATE, VALUES_PRIORITY_COOLING, VALUES_PRIORITY_HEATING, @@ -139,10 +140,13 @@ class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): mode = HVACMode.OFF if _get_value(self._coil_use_room_sensor) == "ON": - if _get_value(self._coil_cooling_with_room_sensor) != "OFF": - mode = HVACMode.HEAT_COOL - else: + if ( + _get_value(self._coil_cooling_with_room_sensor) + in VALUES_COOL_WITH_ROOM_SENSOR_OFF + ): mode = HVACMode.HEAT + else: + mode = HVACMode.HEAT_COOL self._attr_hvac_mode = mode setpoint_heat = _get_float(self._coil_setpoint_heat) diff --git a/homeassistant/components/nibe_heatpump/const.py b/homeassistant/components/nibe_heatpump/const.py index 7d9bf58709..dc6b4b1899 100644 --- a/homeassistant/components/nibe_heatpump/const.py +++ b/homeassistant/components/nibe_heatpump/const.py @@ -17,3 +17,4 @@ CONF_MODBUS_UNIT = "modbus_unit" VALUES_MIXING_VALVE_CLOSED_STATE = (30, "CLOSED", "SHUNT CLOSED") VALUES_PRIORITY_HEATING = (30, "HEAT") VALUES_PRIORITY_COOLING = (60, "COOLING") +VALUES_COOL_WITH_ROOM_SENSOR_OFF = (0, "OFF") From c6568ffb6240873cf2de81050a9e8733fbffa87a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 16 Mar 2023 13:38:22 +0100 Subject: [PATCH 0992/2810] Fix lingering timer in collection helper tests (#89793) * Fix lingering timer in collection helper tests * One more --- tests/helpers/test_collection.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/helpers/test_collection.py b/tests/helpers/test_collection.py index fe403f8db1..64c83757a7 100644 --- a/tests/helpers/test_collection.py +++ b/tests/helpers/test_collection.py @@ -256,6 +256,7 @@ async def test_storage_collection(hass: HomeAssistant) -> None: async def test_attach_entity_component_collection(hass: HomeAssistant) -> None: """Test attaching collection to entity component.""" ent_comp = entity_component.EntityComponent(_LOGGER, "test", hass) + await ent_comp.async_setup({}) coll = MockObservableCollection(_LOGGER) collection.sync_entity_lifecycle(hass, "test", "test", ent_comp, coll, MockEntity) @@ -295,6 +296,7 @@ async def test_attach_entity_component_collection(hass: HomeAssistant) -> None: async def test_entity_component_collection_abort(hass: HomeAssistant) -> None: """Test aborted entity adding is handled.""" ent_comp = entity_component.EntityComponent(_LOGGER, "test", hass) + await ent_comp.async_setup({}) coll = MockObservableCollection(_LOGGER) async_update_config_calls = [] @@ -361,6 +363,7 @@ async def test_entity_component_collection_abort(hass: HomeAssistant) -> None: async def test_entity_component_collection_entity_removed(hass: HomeAssistant) -> None: """Test entity removal is handled.""" ent_comp = entity_component.EntityComponent(_LOGGER, "test", hass) + await ent_comp.async_setup({}) coll = MockObservableCollection(_LOGGER) async_update_config_calls = [] From 886c2635ad34fa20a088b9adc955145a4c7c880a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 16 Mar 2023 14:02:26 +0100 Subject: [PATCH 0993/2810] Add support for constant selector (#89573) * Add support for constant selector * Adapt to frontend PR changes --- homeassistant/helpers/selector.py | 32 ++++++++++++++++++ tests/helpers/test_selector.py | 54 +++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index fe4709a302..865ea6374e 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -358,6 +358,38 @@ class ConfigEntrySelector(Selector[ConfigEntrySelectorConfig]): return config +class ConstantSelectorConfig(TypedDict, total=False): + """Class to represent a constant selector config.""" + + label: str + translation_key: str + value: str | int | bool + + +@SELECTORS.register("constant") +class ConstantSelector(Selector[ConstantSelectorConfig]): + """Constant selector.""" + + selector_type = "constant" + + CONFIG_SCHEMA = vol.Schema( + { + vol.Optional("label"): str, + vol.Optional("translation_key"): cv.string, + vol.Required("value"): vol.Any(str, int, bool), + } + ) + + def __init__(self, config: ConstantSelectorConfig | None = None) -> None: + """Instantiate a selector.""" + super().__init__(config) + + def __call__(self, data: Any) -> Any: + """Validate the passed selection.""" + vol.Schema(self.config["value"])(data) + return self.config["value"] + + class DateSelectorConfig(TypedDict): """Class to represent a date selector config.""" diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index a5fa5c7a50..6f1cb2baef 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -836,3 +836,57 @@ def test_file_selector_schema(schema, valid_selections, invalid_selections) -> N """Test file selector.""" _test_selector("file", schema, valid_selections, invalid_selections) + + +@pytest.mark.parametrize( + ("schema", "valid_selections", "invalid_selections"), + ( + ( + {"value": True, "label": "Blah"}, + (True, 1), + (None, False, 0, "abc", "def"), + ), + ( + {"value": False}, + (False, 0), + (None, True, 1, "abc", "def"), + ), + ( + {"value": 0}, + (0, False), + (None, True, 1, "abc", "def"), + ), + ( + {"value": 1}, + (1, True), + (None, False, 0, "abc", "def"), + ), + ( + {"value": 4}, + (4,), + (None, False, True, 0, 1, "abc", "def"), + ), + ( + {"value": "dog"}, + ("dog",), + (None, False, True, 0, 1, "abc", "def"), + ), + ), +) +def test_constant_selector_schema(schema, valid_selections, invalid_selections) -> None: + """Test constant selector.""" + _test_selector("constant", schema, valid_selections, invalid_selections) + + +@pytest.mark.parametrize( + "schema", + ( + {}, # Value is mandatory + {"value": []}, # Value must be str, int or bool + {"value": 123, "label": 123}, # Label must be str + ), +) +def test_constant_selector_schema_error(schema) -> None: + """Test constant selector.""" + with pytest.raises(vol.Invalid): + selector.validate_selector({"constant": schema}) From c81a38effb8d3d9fd56dd8f46d65012f6974444c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 16 Mar 2023 15:57:01 +0100 Subject: [PATCH 0994/2810] Mqtt prepare test base part1 (#89796) * Refactor test_reloadable * Refactor test_disabling_and_enabling_entry * optimize test_unload_config_entry * Cleanup help_test_unload_config_entry * cleanup test_unload_entry * Update test tls_version * More tests to entry only * Add validate and hassconfig patch * Revert fixture changes patch_hass_config * Follow up comments --- tests/components/mqtt/conftest.py | 8 + .../mqtt/test_alarm_control_panel.py | 35 +- tests/components/mqtt/test_binary_sensor.py | 15 +- tests/components/mqtt/test_button.py | 16 +- tests/components/mqtt/test_camera.py | 20 +- tests/components/mqtt/test_climate.py | 16 +- tests/components/mqtt/test_common.py | 99 ++-- tests/components/mqtt/test_cover.py | 16 +- tests/components/mqtt/test_device_trigger.py | 13 +- tests/components/mqtt/test_discovery.py | 2 +- tests/components/mqtt/test_fan.py | 16 +- tests/components/mqtt/test_humidifier.py | 16 +- tests/components/mqtt/test_init.py | 422 +++++++++--------- tests/components/mqtt/test_legacy_vacuum.py | 11 +- tests/components/mqtt/test_light.py | 16 +- tests/components/mqtt/test_light_json.py | 11 +- tests/components/mqtt/test_light_template.py | 16 +- tests/components/mqtt/test_lock.py | 15 +- tests/components/mqtt/test_number.py | 16 +- tests/components/mqtt/test_scene.py | 16 +- tests/components/mqtt/test_select.py | 16 +- tests/components/mqtt/test_sensor.py | 15 +- tests/components/mqtt/test_siren.py | 16 +- tests/components/mqtt/test_state_vacuum.py | 11 +- tests/components/mqtt/test_switch.py | 16 +- tests/components/mqtt/test_tag.py | 4 +- tests/components/mqtt/test_text.py | 16 +- tests/components/mqtt/test_update.py | 16 +- 28 files changed, 389 insertions(+), 516 deletions(-) diff --git a/tests/components/mqtt/conftest.py b/tests/components/mqtt/conftest.py index c88988fa67..696ad28b73 100644 --- a/tests/components/mqtt/conftest.py +++ b/tests/components/mqtt/conftest.py @@ -1,3 +1,11 @@ """Test fixtures for mqtt component.""" + +import pytest + from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 from tests.components.light.conftest import mock_light_profiles # noqa: F401 + + +@pytest.fixture(autouse=True) +def patch_hass_config(mock_hass_config: None) -> None: + """Patch configuration].yaml.""" diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index be0e19d355..8056e98a3c 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -1,7 +1,6 @@ """The tests the MQTT alarm control panel component.""" import copy import json -from pathlib import Path from unittest.mock import patch import pytest @@ -64,11 +63,12 @@ from .test_common import ( help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, help_test_update_with_json_attrs_not_dict, + help_test_validate_platform_config, ) from tests.common import async_fire_mqtt_message from tests.components.alarm_control_panel import common -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient CODE_NUMBER = "1234" CODE_TEXT = "HELLO_CODE" @@ -173,26 +173,18 @@ async def test_fail_setup_without_state_or_command_topic( ) -> None: """Test for failing setup with no state or command topic.""" assert ( - await async_setup_component( - hass, - mqtt.DOMAIN, - config, - ) - is valid + help_test_validate_platform_config(hass, alarm_control_panel.DOMAIN, config) + == valid ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_update_state_via_state_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test updating with via state topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - DEFAULT_CONFIG, - ) + await mqtt_mock_entry_no_yaml_config() await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() entity_id = "alarm_control_panel.test" @@ -1057,16 +1049,12 @@ async def test_publishing_with_custom_encoding( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = alarm_control_panel.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: @@ -1078,12 +1066,11 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = alarm_control_panel.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index ccd0e7040e..a088d2ac64 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -54,7 +54,7 @@ from tests.common import ( async_fire_time_changed, mock_restore_cache, ) -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: { @@ -1071,16 +1071,12 @@ async def test_entity_debug_info_message( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = binary_sensor.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) @pytest.mark.parametrize( @@ -1187,12 +1183,11 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = binary_sensor.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_button.py b/tests/components/mqtt/test_button.py index 17e082f294..c4aa5a8606 100644 --- a/tests/components/mqtt/test_button.py +++ b/tests/components/mqtt/test_button.py @@ -1,6 +1,5 @@ """The tests for the MQTT button platform.""" import copy -from pathlib import Path from unittest.mock import patch import pytest @@ -43,7 +42,7 @@ from .test_common import ( help_test_update_with_json_attrs_not_dict, ) -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: {button.DOMAIN: {"name": "test", "command_topic": "test-topic"}} @@ -525,16 +524,12 @@ async def test_publishing_with_custom_encoding( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = button.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: @@ -546,12 +541,11 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = button.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_camera.py b/tests/components/mqtt/test_camera.py index 613e522321..dc96d3e9cd 100644 --- a/tests/components/mqtt/test_camera.py +++ b/tests/components/mqtt/test_camera.py @@ -2,7 +2,6 @@ from base64 import b64encode from http import HTTPStatus import json -from pathlib import Path from unittest.mock import patch import pytest @@ -42,7 +41,11 @@ from .test_common import ( ) from tests.common import async_fire_mqtt_message -from tests.typing import ClientSessionGenerator, MqttMockHAClientGenerator +from tests.typing import ( + ClientSessionGenerator, + MqttMockHAClientGenerator, + MqttMockPahoClient, +) DEFAULT_CONFIG = {mqtt.DOMAIN: {camera.DOMAIN: {"name": "test", "topic": "test_topic"}}} @@ -427,16 +430,12 @@ async def test_entity_debug_info_message( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = camera.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: @@ -448,12 +447,11 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = camera.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 4e21481c9a..f9f13034ea 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -1,7 +1,6 @@ """The tests for the mqtt climate component.""" import copy import json -from pathlib import Path from unittest.mock import call, patch import pytest @@ -64,7 +63,7 @@ from .test_common import ( from tests.common import async_fire_mqtt_message from tests.components.climate import common -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient ENTITY_CLIMATE = "climate.test" @@ -2007,16 +2006,12 @@ async def test_humidity_configuration_validity( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = climate.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: @@ -2028,12 +2023,11 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = climate.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index 2cf8646d92..0f54e83949 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -8,13 +8,16 @@ from typing import Any from unittest.mock import ANY, MagicMock, patch import pytest +import voluptuous as vol import yaml -from homeassistant import config as hass_config +from homeassistant import config as module_hass_config from homeassistant.components import mqtt from homeassistant.components.mqtt import debug_info +from homeassistant.components.mqtt.config_integration import PLATFORM_CONFIG_SCHEMA_BASE from homeassistant.components.mqtt.const import MQTT_DISCONNECTED from homeassistant.components.mqtt.mixins import MQTT_ATTRIBUTES_BLOCKED +from homeassistant.config import async_log_exception from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -30,7 +33,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, async_fire_mqtt_message -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG_DEVICE_INFO_ID = { "identifiers": ["helloworld"], @@ -62,6 +65,22 @@ _MqttMessageType = list[tuple[str, str]] _AttributesType = list[tuple[str, Any]] _StateDataType = list[tuple[_MqttMessageType, str | None, _AttributesType | None]] +MQTT_YAML_SCHEMA = vol.Schema({mqtt.DOMAIN: PLATFORM_CONFIG_SCHEMA_BASE}) + + +def help_test_validate_platform_config( + hass: HomeAssistant, domain: str, config: ConfigType +) -> ConfigType | None: + """Test the schema validation.""" + try: + # validate the schema + MQTT_YAML_SCHEMA(config) + return True + except vol.Error as exc: + # log schema exceptions + async_log_exception(exc, domain, config, hass) + return False + async def help_test_availability_when_connection_lost( hass: HomeAssistant, @@ -1764,7 +1783,7 @@ async def help_test_reload_with_config( new_yaml_config_file.write_text(new_yaml_config) assert new_yaml_config_file.read_text() == new_yaml_config - with patch.object(hass_config, "YAML_CONFIG_FILE", new_yaml_config_file): + with patch.object(module_hass_config, "YAML_CONFIG_FILE", new_yaml_config_file): await hass.services.async_call( "mqtt", SERVICE_RELOAD, @@ -1785,9 +1804,9 @@ async def help_test_entry_reload_with_new_config( new_yaml_config_file.write_text(new_yaml_config) assert new_yaml_config_file.read_text() == new_yaml_config - with patch.object(hass_config, "YAML_CONFIG_FILE", new_yaml_config_file), patch( - "paho.mqtt.client.Client" - ) as mock_client: + with patch.object( + module_hass_config, "YAML_CONFIG_FILE", new_yaml_config_file + ), patch("paho.mqtt.client.Client") as mock_client: mock_client().connect = lambda *args: 0 # reload the config entry assert await hass.config_entries.async_reload(mqtt_config_entry.entry_id) @@ -1797,13 +1816,12 @@ async def help_test_entry_reload_with_new_config( async def help_test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, domain: str, config: ConfigType, ) -> None: """Test reloading an MQTT platform.""" + # Set up with empty config config = copy.deepcopy(config[mqtt.DOMAIN][domain]) # Create and test an old config of 2 entities based on the config supplied old_config_1 = copy.deepcopy(config) @@ -1814,10 +1832,15 @@ async def help_test_reloadable( old_config = { mqtt.DOMAIN: {domain: [old_config_1, old_config_2]}, } - - assert await async_setup_component(hass, mqtt.DOMAIN, old_config) + # Start the MQTT entry with the old config + entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) + entry.add_to_hass(hass) + mqtt_client_mock.connect.return_value = 0 + # We should call await mqtt.async_setup_entry(hass, entry) when async_setup + # is removed (this is planned with #87987). Until then we set up the mqtt component + # to test reload after the async_setup setup has set the initial config + await async_setup_component(hass, mqtt.DOMAIN, old_config) await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() assert hass.states.get(f"{domain}.test_old_1") assert hass.states.get(f"{domain}.test_old_2") @@ -1835,8 +1858,15 @@ async def help_test_reloadable( new_config = { mqtt.DOMAIN: {domain: [new_config_1, new_config_2, new_config_extra]}, } - - await help_test_reload_with_config(hass, caplog, tmp_path, new_config) + module_hass_config.load_yaml_config_file.return_value = new_config + # Reload the mqtt entry with the new config + await hass.services.async_call( + "mqtt", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() assert len(hass.states.async_all(domain)) == 3 @@ -1849,49 +1879,34 @@ async def help_test_setup_manual_entity_from_yaml( hass: HomeAssistant, config: ConfigType ) -> None: """Help to test setup from yaml through configuration entry.""" - calls = MagicMock() - - async def mock_reload(hass: HomeAssistant) -> None: - """Mock reload.""" - calls() - + # until `async_setup` does the initial config setup, we need to use + # async_setup_component to test with other yaml config assert await async_setup_component(hass, mqtt.DOMAIN, config) # Mock config entry entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) entry.add_to_hass(hass) - with patch( - "homeassistant.components.mqtt.async_reload_manual_mqtt_items", - side_effect=mock_reload, - ), patch("paho.mqtt.client.Client") as mock_client: + with patch("paho.mqtt.client.Client") as mock_client: mock_client().connect = lambda *args: 0 assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - calls.assert_called_once() + await hass.async_block_till_done() -async def help_test_unload_config_entry( - hass: HomeAssistant, tmp_path: Path, newconfig: ConfigType -) -> None: +async def help_test_unload_config_entry(hass: HomeAssistant) -> None: """Test unloading the MQTT config entry.""" mqtt_config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] assert mqtt_config_entry.state is ConfigEntryState.LOADED - new_yaml_config_file = tmp_path / "configuration.yaml" - new_yaml_config = yaml.dump(newconfig) - new_yaml_config_file.write_text(new_yaml_config) - with patch.object(hass_config, "YAML_CONFIG_FILE", new_yaml_config_file): - assert await hass.config_entries.async_unload(mqtt_config_entry.entry_id) - # work-a-round mypy bug https://github.com/python/mypy/issues/9005#issuecomment-1280985006 - updated_config_entry = mqtt_config_entry - assert updated_config_entry.state is ConfigEntryState.NOT_LOADED - await hass.async_block_till_done() + assert await hass.config_entries.async_unload(mqtt_config_entry.entry_id) + # work-a-round mypy bug https://github.com/python/mypy/issues/9005#issuecomment-1280985006 + updated_config_entry = mqtt_config_entry + assert updated_config_entry.state is ConfigEntryState.NOT_LOADED + await hass.async_block_till_done() async def help_test_unload_config_entry_with_platform( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, domain: str, config: dict[str, dict[str, Any]], ) -> None: @@ -1900,9 +1915,9 @@ async def help_test_unload_config_entry_with_platform( config_setup: dict[str, dict[str, Any]] = copy.deepcopy(config) config_setup[mqtt.DOMAIN][domain]["name"] = "config_setup" config_name = config_setup + # To be replaced with entry setup when `async_setup` is removed. assert await async_setup_component(hass, mqtt.DOMAIN, config_setup) await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() # prepare setup through discovery discovery_setup = copy.deepcopy(config[mqtt.DOMAIN][domain]) @@ -1919,7 +1934,7 @@ async def help_test_unload_config_entry_with_platform( discovery_setup_entity = hass.states.get(f"{domain}.discovery_setup") assert discovery_setup_entity - await help_test_unload_config_entry(hass, tmp_path, config_setup) + await help_test_unload_config_entry(hass) async_fire_mqtt_message( hass, f"homeassistant/{domain}/bla/config", json.dumps(discovery_setup) diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index 9b751c8536..27eac0842c 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -1,5 +1,4 @@ """The tests for the MQTT cover platform.""" -from pathlib import Path from unittest.mock import patch import pytest @@ -78,7 +77,7 @@ from .test_common import ( ) from tests.common import async_fire_mqtt_message -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: {cover.DOMAIN: {"name": "test", "state_topic": "test-topic"}} @@ -3497,16 +3496,12 @@ async def test_publishing_with_custom_encoding( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = cover.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) @pytest.mark.parametrize( @@ -3551,12 +3546,11 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = cover.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index fc987eac93..feb8159239 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -1,11 +1,9 @@ """The tests for MQTT device triggers.""" import json -from pathlib import Path from unittest.mock import patch import pytest -from homeassistant import config as hass_config import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.mqtt import _LOGGER, DOMAIN, debug_info @@ -1443,7 +1441,6 @@ async def test_unload_entry( calls, device_registry: dr.DeviceRegistry, mqtt_mock: MqttMockHAClient, - tmp_path: Path, ) -> None: """Test unloading the MQTT entry.""" @@ -1486,7 +1483,7 @@ async def test_unload_entry( await hass.async_block_till_done() assert len(calls) == 1 - await help_test_unload_config_entry(hass, tmp_path, {}) + await help_test_unload_config_entry(hass) # Rediscover message and fake short press 2 (non impact) async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1) @@ -1495,13 +1492,9 @@ async def test_unload_entry( await hass.async_block_till_done() assert len(calls) == 1 + # Start entry again mqtt_entry = hass.config_entries.async_entries("mqtt")[0] - - # Load the entry again - new_yaml_config_file = tmp_path / "configuration.yaml" - new_yaml_config_file.write_text("") - with patch.object(hass_config, "YAML_CONFIG_FILE", new_yaml_config_file): - await hass.config_entries.async_setup(mqtt_entry.entry_id) + await hass.config_entries.async_setup(mqtt_entry.entry_id) # Rediscover and fake short press 3 async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1) diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index a21f69544e..d05c710984 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -1588,7 +1588,7 @@ async def test_clean_up_registry_monitoring( # Enload the entry # The monitoring should be cleared - await help_test_unload_config_entry(hass, tmp_path, {}) + await help_test_unload_config_entry(hass) assert len(hooks) == 0 diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index fcdf887fbc..1a0bc4faf5 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -1,6 +1,5 @@ """Test MQTT fans.""" import copy -from pathlib import Path from unittest.mock import patch import pytest @@ -66,7 +65,7 @@ from .test_common import ( from tests.common import async_fire_mqtt_message from tests.components.fan import common -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: { @@ -2004,16 +2003,12 @@ async def test_publishing_with_custom_encoding( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = fan.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: @@ -2025,12 +2020,11 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = fan.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_humidifier.py b/tests/components/mqtt/test_humidifier.py index 761a0a81e7..653f5ea781 100644 --- a/tests/components/mqtt/test_humidifier.py +++ b/tests/components/mqtt/test_humidifier.py @@ -1,6 +1,5 @@ """Test MQTT humidifiers.""" import copy -from pathlib import Path from unittest.mock import patch import pytest @@ -67,7 +66,7 @@ from .test_common import ( ) from tests.common import async_fire_mqtt_message -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: { @@ -1368,16 +1367,12 @@ async def test_publishing_with_custom_encoding( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = humidifier.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: @@ -1400,12 +1395,11 @@ async def test_config_schema_validation(hass: HomeAssistant) -> None: async def test_unload_config_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = humidifier.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index ec373aab0d..2209006428 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -14,7 +14,7 @@ import pytest import voluptuous as vol import yaml -from homeassistant import config as hass_config +from homeassistant import config as module_hass_config from homeassistant.components import mqtt from homeassistant.components.mqtt import CONFIG_SCHEMA, debug_info from homeassistant.components.mqtt.client import EnsureJobAfterCooldown @@ -25,6 +25,7 @@ from homeassistant.const import ( ATTR_ASSUMED_STATE, EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, + SERVICE_RELOAD, Platform, UnitOfTemperature, ) @@ -41,7 +42,6 @@ from homeassistant.util.dt import utcnow from .test_common import ( help_test_entry_reload_with_new_config, - help_test_reload_with_config, help_test_setup_manual_entity_from_yaml, ) @@ -121,20 +121,6 @@ def record_calls(calls: list[ReceiveMessage]) -> MessageCallbackType: return record_calls -@pytest.fixture -def empty_mqtt_config( - hass: HomeAssistant, tmp_path: Path -) -> Generator[Path, None, None]: - """Fixture to provide an empty config from yaml.""" - new_yaml_config_file = tmp_path / "configuration.yaml" - new_yaml_config_file.write_text("") - - with patch.object( - hass_config, "YAML_CONFIG_FILE", new_yaml_config_file - ) as empty_config: - yield empty_config - - async def test_mqtt_connects_on_home_assistant_mqtt_setup( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, @@ -303,8 +289,21 @@ async def test_command_template_value(hass: HomeAssistant) -> None: @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SELECT]) +@pytest.mark.parametrize( + "config", + [ + { + "command_topic": "test/select", + "name": "Test Select", + "options": ["milk", "beer"], + "command_template": '{"option": "{{ value }}", "entity_id": "{{ entity_id }}", "name": "{{ name }}", "this_object_state": "{{ this.state }}"}', + } + ], +) async def test_command_template_variables( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + config: ConfigType, ) -> None: """Test the rendering of entity variables.""" topic = "test/select" @@ -312,22 +311,10 @@ async def test_command_template_variables( fake_state = ha.State("select.test_select", "milk") mock_restore_cache(hass, (fake_state,)) - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - "select": { - "command_topic": topic, - "name": "Test Select", - "options": ["milk", "beer"], - "command_template": '{"option": "{{ value }}", "entity_id": "{{ entity_id }}", "name": "{{ name }}", "this_object_state": "{{ this.state }}"}', - } - } - }, - ) + mqtt_mock = await mqtt_mock_entry_no_yaml_config() + await hass.async_block_till_done() + async_fire_mqtt_message(hass, "homeassistant/select/bla/config", json.dumps(config)) await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("select.test_select") assert state and state.state == "milk" @@ -1694,7 +1681,6 @@ async def test_initial_setup_logs_error( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mqtt_client_mock: MqttMockPahoClient, - empty_mqtt_config: Path, ) -> None: """Test for setup failure if initial client connection fails.""" entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) @@ -1838,7 +1824,7 @@ async def test_setup_override_configuration( new_yaml_config_file.write_text(new_yaml_config) assert new_yaml_config_file.read_text() == new_yaml_config - with patch.object(hass_config, "YAML_CONFIG_FILE", new_yaml_config_file): + with patch.object(module_hass_config, "YAML_CONFIG_FILE", new_yaml_config_file): # Mock config entry entry = MockConfigEntry( domain=mqtt.DOMAIN, @@ -1914,26 +1900,43 @@ async def test_setup_manual_mqtt_empty_platform( @patch("homeassistant.components.mqtt.PLATFORMS", []) +@pytest.mark.parametrize( + ("mqtt_config_entry_data", "protocol"), + [ + ( + { + mqtt.CONF_BROKER: "mock-broker", + mqtt.CONF_PROTOCOL: "3.1", + }, + 3, + ), + ( + { + mqtt.CONF_BROKER: "mock-broker", + mqtt.CONF_PROTOCOL: "3.1.1", + }, + 4, + ), + ( + { + mqtt.CONF_BROKER: "mock-broker", + mqtt.CONF_PROTOCOL: "5", + }, + 5, + ), + ], +) async def test_setup_mqtt_client_protocol( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + protocol: int, ) -> None: """Test MQTT client protocol setup.""" with patch("paho.mqtt.client.Client") as mock_client: - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - mqtt.config_integration.CONF_PROTOCOL: "3.1", - } - }, - ) - mock_client.on_connect(return_value=0) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() - # check if protocol setup was correctly - assert mock_client.call_args[1]["protocol"] == 3 + # check if protocol setup was correctly + assert mock_client.call_args[1]["protocol"] == protocol @patch("homeassistant.components.mqtt.client.TIMEOUT_ACK", 0.2) @@ -1999,18 +2002,20 @@ async def test_setup_raises_config_entry_not_ready_if_no_connect_broker( @pytest.mark.parametrize( - ("config", "insecure_param"), + ("mqtt_config_entry_data", "insecure_param"), [ - ({"certificate": "auto"}, "not set"), - ({"certificate": "auto", "tls_insecure": False}, False), - ({"certificate": "auto", "tls_insecure": True}, True), + ({"broker": "test-broker", "certificate": "auto"}, "not set"), + ( + {"broker": "test-broker", "certificate": "auto", "tls_insecure": False}, + False, + ), + ({"broker": "test-broker", "certificate": "auto", "tls_insecure": True}, True), ], ) @patch("homeassistant.components.mqtt.PLATFORMS", []) async def test_setup_uses_certificate_on_certificate_set_to_auto_and_insecure( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - config: ConfigType, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, insecure_param: bool | str, ) -> None: """Test setup uses bundled certs when certificate is set to auto and insecure.""" @@ -2028,48 +2033,41 @@ async def test_setup_uses_certificate_on_certificate_set_to_auto_and_insecure( with patch("paho.mqtt.client.Client") as mock_client: mock_client().tls_set = mock_tls_set mock_client().tls_insecure_set = mock_tls_insecure_set - assert await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: config}, - ) + await mqtt_mock_entry_no_yaml_config() await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() - assert calls + assert calls - import certifi + import certifi - expected_certificate = certifi.where() - assert calls[0][0] == expected_certificate + expected_certificate = certifi.where() + assert calls[0][0] == expected_certificate - # test if insecure is set - assert insecure_check["insecure"] == insecure_param + # test if insecure is set + assert insecure_check["insecure"] == insecure_param +@pytest.mark.parametrize( + "mqtt_config_entry_data", + [ + { + mqtt.CONF_BROKER: "mock-broker", + mqtt.CONF_CERTIFICATE: "auto", + } + ], +) async def test_tls_version( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test setup defaults for tls.""" - calls = [] - - def mock_tls_set( - certificate, certfile=None, keyfile=None, tls_version=None - ) -> None: - calls.append((certificate, certfile, keyfile, tls_version)) - - with patch("paho.mqtt.client.Client") as mock_client: - mock_client().tls_set = mock_tls_set - assert await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {"certificate": "auto"}}, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() - - assert calls - assert calls[0][3] == ssl.PROTOCOL_TLS_CLIENT + await mqtt_mock_entry_no_yaml_config() + await hass.async_block_till_done() + assert ( + mqtt_client_mock.tls_set.mock_calls[0][2]["tls_version"] + == ssl.PROTOCOL_TLS_CLIENT + ) @pytest.mark.parametrize( @@ -3258,7 +3256,6 @@ async def test_unload_config_entry( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, mqtt_client_mock: MqttMockPahoClient, - tmp_path: Path, caplog: pytest.LogCaptureFixture, ) -> None: """Test unloading the MQTT entry.""" @@ -3272,15 +3269,11 @@ async def test_unload_config_entry( mqtt_client_mock.reset_mock() mqtt.publish(hass, "just_in_time", "published", qos=0, retain=False) - new_yaml_config_file = tmp_path / "configuration.yaml" - new_yaml_config = yaml.dump({}) - new_yaml_config_file.write_text(new_yaml_config) - with patch.object(hass_config, "YAML_CONFIG_FILE", new_yaml_config_file): - assert await hass.config_entries.async_unload(mqtt_config_entry.entry_id) - new_mqtt_config_entry = mqtt_config_entry - mqtt_client_mock.publish.assert_any_call("just_in_time", "published", 0, False) - assert new_mqtt_config_entry.state is ConfigEntryState.NOT_LOADED - await hass.async_block_till_done() + assert await hass.config_entries.async_unload(mqtt_config_entry.entry_id) + new_mqtt_config_entry = mqtt_config_entry + mqtt_client_mock.publish.assert_any_call("just_in_time", "published", 0, False) + assert new_mqtt_config_entry.state is ConfigEntryState.NOT_LOADED + await hass.async_block_till_done() assert not hass.services.has_service(mqtt.DOMAIN, "dump") assert not hass.services.has_service(mqtt.DOMAIN, "publish") assert "No ACK from MQTT server" not in caplog.text @@ -3318,131 +3311,108 @@ async def test_publish_or_subscribe_without_valid_config_entry( @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT]) -async def test_reload_entry_with_new_config( - hass: HomeAssistant, tmp_path: Path +@pytest.mark.parametrize( + "hass_config", + [ + { + "mqtt": { + "light": [ + {"name": "test_new_modern", "command_topic": "test-topic_new"} + ] + } + } + ], +) +async def test_disabling_and_enabling_entry( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: - """Test reloading the config entry with a new yaml config.""" - config_old = { - "mqtt": {"light": [{"name": "test_old1", "command_topic": "test-topic_old"}]} - } - config_yaml_new = { - "mqtt": { - "light": [{"name": "test_new_modern", "command_topic": "test-topic_new"}] - }, - } - await help_test_setup_manual_entity_from_yaml(hass, config_old) - assert hass.states.get("light.test_old1") is not None + """Test disabling and enabling the config entry.""" + await mqtt_mock_entry_no_yaml_config() + entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + assert entry.state is ConfigEntryState.LOADED + # Late discovery of a light + config = '{"name": "abc", "command_topic": "test-topic"}' + async_fire_mqtt_message(hass, "homeassistant/light/abc/config", config) + + # Disable MQTT config entry + await hass.config_entries.async_set_disabled_by( + entry.entry_id, ConfigEntryDisabler.USER + ) + + await hass.async_block_till_done() + await hass.async_block_till_done() + assert ( + "MQTT integration is disabled, skipping setup of discovered item MQTT light" + in caplog.text + ) + + new_mqtt_config_entry = entry + assert new_mqtt_config_entry.state is ConfigEntryState.NOT_LOADED + + # Enable the entry again + await hass.config_entries.async_set_disabled_by(entry.entry_id, None) + await hass.async_block_till_done() + await hass.async_block_till_done() + new_mqtt_config_entry = entry + assert new_mqtt_config_entry.state is ConfigEntryState.LOADED - await help_test_entry_reload_with_new_config(hass, tmp_path, config_yaml_new) - assert hass.states.get("light.test_old1") is None assert hass.states.get("light.test_new_modern") is not None -@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT]) -async def test_disabling_and_enabling_entry( - hass: HomeAssistant, tmp_path: Path -) -> None: - """Test disabling and enabling the config entry.""" - config_old = { - "mqtt": {"light": [{"name": "test_old1", "command_topic": "test-topic_old"}]} - } - config_yaml_new = { - "mqtt": { - "light": [{"name": "test_new_modern", "command_topic": "test-topic_new"}] - }, - } - await help_test_setup_manual_entity_from_yaml(hass, config_old) - assert hass.states.get("light.test_old1") is not None - - mqtt_config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] - - assert mqtt_config_entry.state is ConfigEntryState.LOADED - new_yaml_config_file = tmp_path / "configuration.yaml" - new_yaml_config = yaml.dump(config_yaml_new) - new_yaml_config_file.write_text(new_yaml_config) - assert new_yaml_config_file.read_text() == new_yaml_config - - with patch.object(hass_config, "YAML_CONFIG_FILE", new_yaml_config_file), patch( - "paho.mqtt.client.Client" - ) as mock_client: - mock_client().connect = lambda *args: 0 - - # Late discovery of a light - config = '{"name": "abc", "command_topic": "test-topic"}' - async_fire_mqtt_message(hass, "homeassistant/light/abc/config", config) - - # Disable MQTT config entry - await hass.config_entries.async_set_disabled_by( - mqtt_config_entry.entry_id, ConfigEntryDisabler.USER - ) - - await hass.async_block_till_done() - await hass.async_block_till_done() - - new_mqtt_config_entry = mqtt_config_entry - assert new_mqtt_config_entry.state is ConfigEntryState.NOT_LOADED - assert hass.states.get("light.test_old1") is None - - # Enable the entry again - await hass.config_entries.async_set_disabled_by( - mqtt_config_entry.entry_id, None - ) - await hass.async_block_till_done() - await hass.async_block_till_done() - new_mqtt_config_entry = mqtt_config_entry - assert new_mqtt_config_entry.state is ConfigEntryState.LOADED - - assert hass.states.get("light.test_old1") is None - assert hass.states.get("light.test_new_modern") is not None - - @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT]) @pytest.mark.parametrize( - ("config", "unique"), + ("hass_config", "unique"), [ ( - [ - { - "name": "test1", - "unique_id": "very_not_unique_deadbeef", - "command_topic": "test-topic_unique", - }, - { - "name": "test2", - "unique_id": "very_not_unique_deadbeef", - "command_topic": "test-topic_unique", - }, - ], + { + mqtt.DOMAIN: { + "light": [ + { + "name": "test1", + "unique_id": "very_not_unique_deadbeef", + "command_topic": "test-topic_unique", + }, + { + "name": "test2", + "unique_id": "very_not_unique_deadbeef", + "command_topic": "test-topic_unique", + }, + ] + } + }, False, ), ( - [ - { - "name": "test1", - "unique_id": "very_unique_deadbeef1", - "command_topic": "test-topic_unique", - }, - { - "name": "test2", - "unique_id": "very_unique_deadbeef2", - "command_topic": "test-topic_unique", - }, - ], + { + mqtt.DOMAIN: { + "light": [ + { + "name": "test1", + "unique_id": "very_unique_deadbeef1", + "command_topic": "test-topic_unique", + }, + { + "name": "test2", + "unique_id": "very_unique_deadbeef2", + "command_topic": "test-topic_unique", + }, + ] + } + }, True, ), ], ) async def test_setup_manual_items_with_unique_ids( hass: HomeAssistant, - tmp_path: Path, caplog: pytest.LogCaptureFixture, - config: ConfigType, + hass_config: ConfigType, unique: bool, ) -> None: """Test setup manual items is generating unique id's.""" - await help_test_setup_manual_entity_from_yaml( - hass, {mqtt.DOMAIN: {"light": config}} - ) + await help_test_setup_manual_entity_from_yaml(hass, hass_config) assert hass.states.get("light.test1") is not None assert (hass.states.get("light.test2") is not None) == unique @@ -3450,9 +3420,13 @@ async def test_setup_manual_items_with_unique_ids( # reload and assert again caplog.clear() - await help_test_entry_reload_with_new_config( - hass, tmp_path, {"mqtt": {"light": config}} + await hass.services.async_call( + "mqtt", + SERVICE_RELOAD, + {}, + blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("light.test1") is not None assert (hass.states.get("light.test2") is not None) == unique @@ -3489,21 +3463,26 @@ async def test_remove_unknown_conf_entry_options( @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT]) +@pytest.mark.parametrize( + "hass_config", + [ + { + "mqtt": { + "light": [ + { + "name": "test_manual", + "unique_id": "test_manual_unique_id123", + "command_topic": "test-topic_manual", + } + ] + } + } + ], +) async def test_link_config_entry( - hass: HomeAssistant, tmp_path: Path, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, hass_config: ConfigType, caplog: pytest.LogCaptureFixture ) -> None: """Test manual and dynamically setup entities are linked to the config entry.""" - config_manual = { - "mqtt": { - "light": [ - { - "name": "test_manual", - "unique_id": "test_manual_unique_id123", - "command_topic": "test-topic_manual", - } - ] - } - } config_discovery = { "name": "test_discovery", "unique_id": "test_discovery_unique456", @@ -3511,7 +3490,7 @@ async def test_link_config_entry( } # set up manual item - await help_test_setup_manual_entity_from_yaml(hass, config_manual) + await help_test_setup_manual_entity_from_yaml(hass, hass_config) # set up item through discovery async_fire_mqtt_message( @@ -3540,7 +3519,10 @@ async def test_link_config_entry( assert _check_entities() == 2 # reload entry and assert again - await help_test_entry_reload_with_new_config(hass, tmp_path, config_manual) + with patch("paho.mqtt.client.Client"): + await hass.config_entries.async_reload(mqtt_config_entry.entry_id) + await hass.async_block_till_done() + # manual set up item should remain assert _check_entities() == 1 # set up item through discovery @@ -3551,7 +3533,13 @@ async def test_link_config_entry( assert _check_entities() == 2 # reload manual configured items and assert again - await help_test_reload_with_config(hass, caplog, tmp_path, config_manual) + await hass.services.async_call( + "mqtt", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() assert _check_entities() == 2 diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index 70bf1deeb6..6d45cd4898 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -1,7 +1,6 @@ """The tests for the Legacy Mqtt vacuum platform.""" from copy import deepcopy import json -from pathlib import Path from unittest.mock import patch import pytest @@ -65,7 +64,7 @@ from .test_common import ( from tests.common import async_fire_mqtt_message from tests.components.vacuum import common -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: { @@ -998,16 +997,12 @@ async def test_publishing_with_custom_encoding( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = vacuum.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index fcdec1fbfe..a947bfc797 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -169,7 +169,6 @@ mqtt: """ import copy -from pathlib import Path from unittest.mock import call, patch import pytest @@ -229,7 +228,7 @@ from .test_common import ( from tests.common import async_fire_mqtt_message, mock_restore_cache from tests.components.light import common -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: {light.DOMAIN: {"name": "test", "command_topic": "test-topic"}} @@ -3034,16 +3033,12 @@ async def test_publishing_with_custom_encoding( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = light.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) @pytest.mark.parametrize( @@ -3310,12 +3305,11 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = light.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index dc73d7e6d1..de8ba889e2 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -79,7 +79,6 @@ light: brightness_scale: 99 """ import copy -from pathlib import Path from unittest.mock import call, patch import pytest @@ -131,7 +130,7 @@ from .test_common import ( from tests.common import async_fire_mqtt_message, mock_restore_cache from tests.components.light import common -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: { @@ -2280,16 +2279,12 @@ async def test_publishing_with_custom_encoding( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = light.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index 2ad156d185..a5d0f009ca 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -25,7 +25,6 @@ If your light doesn't support color temp feature, omit `color_temp_template`. If your light doesn't support RGB feature, omit `(red|green|blue)_template`. """ import copy -from pathlib import Path from unittest.mock import patch import pytest @@ -77,7 +76,7 @@ from .test_common import ( from tests.common import async_fire_mqtt_message, mock_restore_cache from tests.components.light import common -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: { @@ -1252,16 +1251,12 @@ async def test_publishing_with_custom_encoding( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = light.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) @pytest.mark.parametrize( @@ -1306,12 +1301,11 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = light.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index d186fe8767..1d48640011 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -58,7 +58,7 @@ from .test_common import ( ) from tests.common import async_fire_mqtt_message -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: {lock.DOMAIN: {"name": "test", "command_topic": "test-topic"}} @@ -970,16 +970,12 @@ async def test_publishing_with_custom_encoding( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = lock.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) @pytest.mark.parametrize( @@ -1022,12 +1018,11 @@ async def test_setup_manual_entity_from_yaml( async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = lock.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index a93cbc6790..da7e278e03 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -1,6 +1,5 @@ """The tests for mqtt number component.""" import json -from pathlib import Path from unittest.mock import patch import pytest @@ -63,7 +62,7 @@ from .test_common import ( ) from tests.common import async_fire_mqtt_message, mock_restore_cache_with_extra_data -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: {number.DOMAIN: {"name": "test", "command_topic": "test-topic"}} @@ -965,16 +964,12 @@ async def test_publishing_with_custom_encoding( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = number.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) @pytest.mark.parametrize( @@ -1016,12 +1011,11 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = number.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_scene.py b/tests/components/mqtt/test_scene.py index 57816e6a85..b6899062d2 100644 --- a/tests/components/mqtt/test_scene.py +++ b/tests/components/mqtt/test_scene.py @@ -1,6 +1,5 @@ """The tests for the MQTT scene platform.""" import copy -from pathlib import Path from unittest.mock import patch import pytest @@ -26,7 +25,7 @@ from .test_common import ( ) from tests.common import mock_restore_cache -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: { @@ -244,16 +243,12 @@ async def test_discovery_broken( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = scene.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: @@ -265,12 +260,11 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = scene.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_select.py b/tests/components/mqtt/test_select.py index e5599130ad..31eb7daff8 100644 --- a/tests/components/mqtt/test_select.py +++ b/tests/components/mqtt/test_select.py @@ -1,7 +1,6 @@ """The tests for mqtt select component.""" import copy import json -from pathlib import Path from unittest.mock import patch import pytest @@ -55,7 +54,7 @@ from .test_common import ( ) from tests.common import async_fire_mqtt_message, mock_restore_cache -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: { @@ -711,16 +710,12 @@ async def test_publishing_with_custom_encoding( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = select.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) @pytest.mark.parametrize( @@ -764,14 +759,13 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = select.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 3112564cb8..b40c2f43d0 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -67,7 +67,7 @@ from tests.common import ( async_fire_time_changed, mock_restore_cache_with_extra_data, ) -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: {sensor.DOMAIN: {"name": "test", "state_topic": "test-topic"}} @@ -1265,16 +1265,12 @@ async def test_value_template_with_entity_id( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = sensor.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) async def test_cleanup_triggers_and_restoring_state( @@ -1410,12 +1406,11 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = sensor.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_siren.py b/tests/components/mqtt/test_siren.py index 2683ad2e16..329a9150f7 100644 --- a/tests/components/mqtt/test_siren.py +++ b/tests/components/mqtt/test_siren.py @@ -1,6 +1,5 @@ """The tests for the MQTT siren platform.""" import copy -from pathlib import Path from typing import Any from unittest.mock import patch @@ -53,7 +52,7 @@ from .test_common import ( ) from tests.common import async_fire_mqtt_message -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: {siren.DOMAIN: {"name": "test", "command_topic": "test-topic"}} @@ -1005,16 +1004,12 @@ async def test_publishing_with_custom_encoding( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = siren.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) @pytest.mark.parametrize( @@ -1055,12 +1050,11 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = siren.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_state_vacuum.py index 5162354e7c..55a2a773f6 100644 --- a/tests/components/mqtt/test_state_vacuum.py +++ b/tests/components/mqtt/test_state_vacuum.py @@ -1,7 +1,6 @@ """The tests for the State vacuum Mqtt platform.""" from copy import deepcopy import json -from pathlib import Path from unittest.mock import patch import pytest @@ -62,7 +61,7 @@ from .test_common import ( from tests.common import async_fire_mqtt_message from tests.components.vacuum import common -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient COMMAND_TOPIC = "vacuum/command" SEND_COMMAND_TOPIC = "vacuum/send_command" @@ -718,16 +717,12 @@ async def test_publishing_with_custom_encoding( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = vacuum.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py index ccc26599c6..1eb2294618 100644 --- a/tests/components/mqtt/test_switch.py +++ b/tests/components/mqtt/test_switch.py @@ -1,6 +1,5 @@ """The tests for the MQTT switch platform.""" import copy -from pathlib import Path from unittest.mock import patch import pytest @@ -49,7 +48,7 @@ from .test_common import ( from tests.common import async_fire_mqtt_message, mock_restore_cache from tests.components.switch import common -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: {switch.DOMAIN: {"name": "test", "command_topic": "test-topic"}} @@ -681,16 +680,12 @@ async def test_publishing_with_custom_encoding( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = switch.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) @pytest.mark.parametrize( @@ -731,12 +726,11 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = switch.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_tag.py b/tests/components/mqtt/test_tag.py index 6c0472a11b..2f7a9d0cc0 100644 --- a/tests/components/mqtt/test_tag.py +++ b/tests/components/mqtt/test_tag.py @@ -1,7 +1,6 @@ """The tests for MQTT tag scanner.""" import copy import json -from pathlib import Path from unittest.mock import ANY, patch import pytest @@ -893,7 +892,6 @@ async def test_unload_entry( device_registry: dr.DeviceRegistry, mqtt_mock: MqttMockHAClient, tag_mock, - tmp_path: Path, ) -> None: """Test unloading the MQTT entry.""" @@ -910,7 +908,7 @@ async def test_unload_entry( tag_mock.reset_mock() - await help_test_unload_config_entry(hass, tmp_path, {}) + await help_test_unload_config_entry(hass) await hass.async_block_till_done() # Fake tag scan, should not be processed diff --git a/tests/components/mqtt/test_text.py b/tests/components/mqtt/test_text.py index a5209e3f5f..83b2a0d55e 100644 --- a/tests/components/mqtt/test_text.py +++ b/tests/components/mqtt/test_text.py @@ -1,7 +1,6 @@ """The tests for the MQTT text platform.""" from __future__ import annotations -from pathlib import Path from unittest.mock import patch import pytest @@ -47,7 +46,7 @@ from .test_common import ( ) from tests.common import async_fire_mqtt_message -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: {text.DOMAIN: {"name": "test", "command_topic": "test-topic"}} @@ -695,16 +694,12 @@ async def test_publishing_with_custom_encoding( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = text.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) @pytest.mark.parametrize( @@ -745,12 +740,11 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = text.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_update.py b/tests/components/mqtt/test_update.py index d815dbc8b1..4821aeca8e 100644 --- a/tests/components/mqtt/test_update.py +++ b/tests/components/mqtt/test_update.py @@ -1,6 +1,5 @@ """The tests for mqtt update component.""" import json -from pathlib import Path from unittest.mock import patch import pytest @@ -43,7 +42,7 @@ from .test_common import ( ) from tests.common import async_fire_mqtt_message -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: { @@ -678,26 +677,21 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = update.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = update.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) From 9384ec18f847226276f0189f6e7039ee6fcd8c74 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 16 Mar 2023 15:59:51 +0100 Subject: [PATCH 0995/2810] Add filters to climate and light service descriptions (#86162) * Add filters to climate and light service descriptions * Allow specifying enums directly * Update service descriptions * Adjust test * Cache entity features * Lint * Improve error handling, add list of known base components * Don't allow specifying an entity feature as int --- .yamllint | 2 +- .../components/climate/services.yaml | 22 ++ homeassistant/components/light/services.yaml | 204 ++++++++++++++++++ homeassistant/helpers/selector.py | 68 ++++++ homeassistant/helpers/service.py | 120 ++++++++++- script/hassfest/services.py | 14 +- tests/helpers/test_selector.py | 51 ++++- 7 files changed, 467 insertions(+), 14 deletions(-) diff --git a/.yamllint b/.yamllint index c2f877a2b7..e587d75d79 100644 --- a/.yamllint +++ b/.yamllint @@ -25,7 +25,7 @@ rules: comments: level: error require-starting-space: true - min-spaces-from-content: 2 + min-spaces-from-content: 1 comments-indentation: level: error document-end: diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index 40d518456b..33e114c87f 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -6,6 +6,8 @@ set_aux_heat: target: entity: domain: climate + supported_features: + - climate.ClimateEntityFeature.AUX_HEAT fields: aux_heat: name: Auxiliary heating @@ -20,6 +22,8 @@ set_preset_mode: target: entity: domain: climate + supported_features: + - climate.ClimateEntityFeature.PRESET_MODE fields: preset_mode: name: Preset mode @@ -35,10 +39,16 @@ set_temperature: target: entity: domain: climate + supported_features: + - climate.ClimateEntityFeature.TARGET_TEMPERATURE + - climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE fields: temperature: name: Temperature description: New target temperature for HVAC. + filter: + supported_features: + - climate.ClimateEntityFeature.TARGET_TEMPERATURE selector: number: min: 0 @@ -48,6 +58,9 @@ set_temperature: target_temp_high: name: Target temperature high description: New target high temperature for HVAC. + filter: + supported_features: + - climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE advanced: true selector: number: @@ -58,6 +71,9 @@ set_temperature: target_temp_low: name: Target temperature low description: New target low temperature for HVAC. + filter: + supported_features: + - climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE advanced: true selector: number: @@ -92,6 +108,8 @@ set_humidity: target: entity: domain: climate + supported_features: + - climate.ClimateEntityFeature.TARGET_HUMIDITY fields: humidity: name: Humidity @@ -109,6 +127,8 @@ set_fan_mode: target: entity: domain: climate + supported_features: + - climate.ClimateEntityFeature.FAN_MODE fields: fan_mode: name: Fan mode @@ -152,6 +172,8 @@ set_swing_mode: target: entity: domain: climate + supported_features: + - climate.ClimateEntityFeature.SWING_MODE fields: swing_mode: name: Swing mode diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index b7843a2f0e..65bf77f15c 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -12,6 +12,9 @@ turn_on: transition: name: Transition description: Duration it takes to get to next state. + filter: + supported_features: + - light.LightEntityFeature.TRANSITION selector: number: min: 0 @@ -20,11 +23,27 @@ turn_on: rgb_color: name: Color description: The color for the light (based on RGB - red, green, blue). + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW selector: color_rgb: rgbw_color: name: RGBW-color description: A list containing four integers between 0 and 255 representing the RGBW (red, green, blue, white) color for the light. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true example: "[255, 100, 100, 50]" selector: @@ -32,6 +51,14 @@ turn_on: rgbww_color: name: RGBWW-color description: A list containing five integers between 0 and 255 representing the RGBWW (red, green, blue, cold white, warm white) color for the light. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true example: "[255, 100, 100, 50, 70]" selector: @@ -39,6 +66,14 @@ turn_on: color_name: name: Color name description: A human readable color name. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true selector: select: @@ -195,6 +230,14 @@ turn_on: hs_color: name: Hue/Sat color description: Color for the light in hue/sat format. Hue is 0-360 and Sat is 0-100. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true example: "[300, 70]" selector: @@ -202,6 +245,14 @@ turn_on: xy_color: name: XY-color description: Color for the light in XY-format. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true example: "[0.52, 0.43]" selector: @@ -209,6 +260,15 @@ turn_on: color_temp: name: Color temperature description: Color temperature for the light in mireds. + filter: + attribute: + supported_color_modes: + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW selector: color_temp: min_mireds: 153 @@ -216,6 +276,15 @@ turn_on: kelvin: name: Color temperature (Kelvin) description: Color temperature for the light in Kelvin. + filter: + attribute: + supported_color_modes: + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true selector: number: @@ -228,6 +297,16 @@ turn_on: description: Number indicating brightness, where 0 turns the light off, 1 is the minimum brightness and 255 is the maximum brightness supported by the light. + filter: + attribute: + supported_color_modes: + - light.ColorMode.BRIGHTNESS + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true selector: number: @@ -238,6 +317,16 @@ turn_on: description: Number indicating percentage of full brightness, where 0 turns the light off, 1 is the minimum brightness and 100 is the maximum brightness supported by the light. + filter: + attribute: + supported_color_modes: + - light.ColorMode.BRIGHTNESS + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW selector: number: min: 0 @@ -246,6 +335,16 @@ turn_on: brightness_step: name: Brightness step value description: Change brightness by an amount. + filter: + attribute: + supported_color_modes: + - light.ColorMode.BRIGHTNESS + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true selector: number: @@ -254,6 +353,16 @@ turn_on: brightness_step_pct: name: Brightness step description: Change brightness by a percentage. + filter: + attribute: + supported_color_modes: + - light.ColorMode.BRIGHTNESS + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW selector: number: min: -100 @@ -265,6 +374,10 @@ turn_on: Set the light to white mode and change its brightness, where 0 turns the light off, 1 is the minimum brightness and 255 is the maximum brightness supported by the light. + filter: + attribute: + supported_color_modes: + - light.ColorMode.WHITE advanced: true selector: number: @@ -280,6 +393,9 @@ turn_on: flash: name: Flash description: If the light should flash. + filter: + supported_features: + - light.LightEntityFeature.FLASH advanced: true selector: select: @@ -291,6 +407,9 @@ turn_on: effect: name: Effect description: Light effect. + filter: + supported_features: + - light.LightEntityFeature.EFFECT selector: text: @@ -304,6 +423,9 @@ turn_off: transition: name: Transition description: Duration it takes to get to next state. + filter: + supported_features: + - light.LightEntityFeature.TRANSITION selector: number: min: 0 @@ -312,6 +434,9 @@ turn_off: flash: name: Flash description: If the light should flash. + filter: + supported_features: + - light.LightEntityFeature.FLASH advanced: true selector: select: @@ -333,6 +458,9 @@ toggle: transition: name: Transition description: Duration it takes to get to next state. + filter: + supported_features: + - light.LightEntityFeature.TRANSITION selector: number: min: 0 @@ -341,6 +469,14 @@ toggle: rgb_color: name: RGB-color description: Color for the light in RGB-format. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true example: "[255, 100, 100]" selector: @@ -348,6 +484,14 @@ toggle: color_name: name: Color name description: A human readable color name. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true selector: select: @@ -504,6 +648,14 @@ toggle: hs_color: name: Hue/Sat color description: Color for the light in hue/sat format. Hue is 0-360 and Sat is 0-100. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true example: "[300, 70]" selector: @@ -511,6 +663,14 @@ toggle: xy_color: name: XY-color description: Color for the light in XY-format. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true example: "[0.52, 0.43]" selector: @@ -518,12 +678,30 @@ toggle: color_temp: name: Color temperature (mireds) description: Color temperature for the light in mireds. + filter: + attribute: + supported_color_modes: + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true selector: color_temp: kelvin: name: Color temperature (Kelvin) description: Color temperature for the light in Kelvin. + filter: + attribute: + supported_color_modes: + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true selector: number: @@ -536,6 +714,16 @@ toggle: description: Number indicating brightness, where 0 turns the light off, 1 is the minimum brightness and 255 is the maximum brightness supported by the light. + filter: + attribute: + supported_color_modes: + - light.ColorMode.BRIGHTNESS + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true selector: number: @@ -546,6 +734,16 @@ toggle: description: Number indicating percentage of full brightness, where 0 turns the light off, 1 is the minimum brightness and 100 is the maximum brightness supported by the light. + filter: + attribute: + supported_color_modes: + - light.ColorMode.BRIGHTNESS + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW selector: number: min: 0 @@ -561,6 +759,9 @@ toggle: flash: name: Flash description: If the light should flash. + filter: + supported_features: + - light.LightEntityFeature.FLASH advanced: true selector: select: @@ -572,5 +773,8 @@ toggle: effect: name: Effect description: Light effect. + filter: + supported_features: + - light.LightEntityFeature.EFFECT selector: text: diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 865ea6374e..e2f58e357e 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -2,6 +2,8 @@ from __future__ import annotations from collections.abc import Callable, Mapping, Sequence +from enum import IntFlag +from functools import cache from typing import Any, Generic, Literal, TypedDict, TypeVar, cast from uuid import UUID @@ -79,6 +81,69 @@ class Selector(Generic[_T]): return {"selector": {self.selector_type: self.config}} +@cache +def _entity_features() -> dict[str, type[IntFlag]]: + """Return a cached lookup of entity feature enums.""" + # pylint: disable=import-outside-toplevel + from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntityFeature, + ) + from homeassistant.components.calendar import CalendarEntityFeature + from homeassistant.components.camera import CameraEntityFeature + from homeassistant.components.climate import ClimateEntityFeature + from homeassistant.components.cover import CoverEntityFeature + from homeassistant.components.fan import FanEntityFeature + from homeassistant.components.humidifier import HumidifierEntityFeature + from homeassistant.components.light import LightEntityFeature + from homeassistant.components.lock import LockEntityFeature + from homeassistant.components.media_player import MediaPlayerEntityFeature + from homeassistant.components.remote import RemoteEntityFeature + from homeassistant.components.siren import SirenEntityFeature + from homeassistant.components.update import UpdateEntityFeature + from homeassistant.components.vacuum import VacuumEntityFeature + from homeassistant.components.water_heater import WaterHeaterEntityFeature + + return { + "AlarmControlPanelEntityFeature": AlarmControlPanelEntityFeature, + "CalendarEntityFeature": CalendarEntityFeature, + "CameraEntityFeature": CameraEntityFeature, + "ClimateEntityFeature": ClimateEntityFeature, + "CoverEntityFeature": CoverEntityFeature, + "FanEntityFeature": FanEntityFeature, + "HumidifierEntityFeature": HumidifierEntityFeature, + "LightEntityFeature": LightEntityFeature, + "LockEntityFeature": LockEntityFeature, + "MediaPlayerEntityFeature": MediaPlayerEntityFeature, + "RemoteEntityFeature": RemoteEntityFeature, + "SirenEntityFeature": SirenEntityFeature, + "UpdateEntityFeature": UpdateEntityFeature, + "VacuumEntityFeature": VacuumEntityFeature, + "WaterHeaterEntityFeature": WaterHeaterEntityFeature, + } + + +def _validate_supported_feature(supported_feature: int | str) -> int: + """Validate a supported feature and resolve an enum string to its value.""" + + if isinstance(supported_feature, int): + return supported_feature + + known_entity_features = _entity_features() + + try: + _, enum, feature = supported_feature.split(".", 2) + except ValueError as exc: + raise vol.Invalid( + f"Invalid supported feature '{supported_feature}', expected " + ".." + ) from exc + + try: + return cast(int, getattr(known_entity_features[enum], feature).value) + except (AttributeError, KeyError) as exc: + raise vol.Invalid(f"Unknown supported feature '{supported_feature}'") from exc + + ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( { # Integration that provided the entity @@ -87,6 +152,8 @@ ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( vol.Optional("domain"): vol.All(cv.ensure_list, [str]), # Device class of the entity vol.Optional("device_class"): vol.All(cv.ensure_list, [str]), + # Features supported by the entity + vol.Optional("supported_features"): [vol.All(str, _validate_supported_feature)], } ) @@ -97,6 +164,7 @@ class EntityFilterSelectorConfig(TypedDict, total=False): integration: str domain: str | list[str] device_class: str | list[str] + supported_features: list[str] DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 9f6f65f1d2..33c677454b 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -4,9 +4,11 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable, Iterable import dataclasses -from functools import partial, wraps +from enum import Enum +from functools import cache, partial, wraps import logging -from typing import TYPE_CHECKING, Any, TypedDict, TypeGuard, TypeVar +from types import ModuleType +from typing import TYPE_CHECKING, Any, TypedDict, TypeGuard, TypeVar, cast import voluptuous as vol @@ -42,6 +44,7 @@ from . import ( entity_registry, template, ) +from .selector import TargetSelector from .typing import ConfigType, TemplateVarsType if TYPE_CHECKING: @@ -58,6 +61,112 @@ _LOGGER = logging.getLogger(__name__) SERVICE_DESCRIPTION_CACHE = "service_description_cache" +@cache +def _base_components() -> dict[str, ModuleType]: + """Return a cached lookup of base components.""" + # pylint: disable=import-outside-toplevel + from homeassistant.components import ( + alarm_control_panel, + calendar, + camera, + climate, + cover, + fan, + humidifier, + light, + lock, + media_player, + remote, + siren, + update, + vacuum, + water_heater, + ) + + return { + "alarm_control_panel": alarm_control_panel, + "calendar": calendar, + "camera": camera, + "climate": climate, + "cover": cover, + "fan": fan, + "humidifier": humidifier, + "light": light, + "lock": lock, + "media_player": media_player, + "remote": remote, + "siren": siren, + "update": update, + "vacuum": vacuum, + "water_heater": water_heater, + } + + +def _validate_option_or_feature(option_or_feature: str, label: str) -> Any: + """Validate attribute option or supported feature.""" + try: + domain, enum, option = option_or_feature.split(".", 2) + except ValueError as exc: + raise vol.Invalid( + f"Invalid {label} '{option_or_feature}', expected " + ".." + ) from exc + + base_components = _base_components() + if not (base_component := base_components.get(domain)): + raise vol.Invalid(f"Unknown base component '{domain}'") + + try: + attribute_enum = getattr(base_component, enum) + except AttributeError as exc: + raise vol.Invalid(f"Unknown {label} enum '{domain}.{enum}'") from exc + + if not issubclass(attribute_enum, Enum): + raise vol.Invalid(f"Expected {label} '{domain}.{enum}' to be an enum") + + try: + return getattr(attribute_enum, option).value + except AttributeError as exc: + raise vol.Invalid(f"Unknown {label} '{enum}.{option}'") from exc + + +def validate_attribute_option(attribute_option: str) -> Any: + """Validate attribute option.""" + return _validate_option_or_feature(attribute_option, "attribute option") + + +def validate_supported_feature(supported_feature: str) -> Any: + """Validate supported feature.""" + return _validate_option_or_feature(supported_feature, "supported feature") + + +# Basic schemas which translate attribute and supported feature enum names +# to their values. Full validation is done by hassfest.services +_FIELD_SCHEMA = vol.Schema( + { + vol.Optional("filter"): { + vol.Optional("attribute"): { + vol.Required(str): [vol.All(str, validate_attribute_option)], + }, + vol.Optional("supported_features"): [ + vol.All(str, validate_supported_feature) + ], + }, + }, + extra=vol.ALLOW_EXTRA, +) + +_SERVICE_SCHEMA = vol.Schema( + { + vol.Optional("target"): vol.Any(TargetSelector.CONFIG_SCHEMA, None), + vol.Optional("fields"): vol.Schema({str: _FIELD_SCHEMA}), + }, + extra=vol.ALLOW_EXTRA, +) + +_SERVICES_SCHEMA = vol.Schema({cv.slug: _SERVICE_SCHEMA}) + + class ServiceParams(TypedDict): """Type for service call parameters.""" @@ -421,13 +530,16 @@ async def async_extract_config_entry_ids( def _load_services_file(hass: HomeAssistant, integration: Integration) -> JSON_TYPE: """Load services file for an integration.""" try: - return load_yaml(str(integration.file_path / "services.yaml")) + return cast( + JSON_TYPE, + _SERVICES_SCHEMA(load_yaml(str(integration.file_path / "services.yaml"))), + ) except FileNotFoundError: _LOGGER.warning( "Unable to find services.yaml for the %s integration", integration.domain ) return {} - except HomeAssistantError: + except (HomeAssistantError, vol.Invalid): _LOGGER.warning( "Unable to parse services.yaml for the %s integration", integration.domain ) diff --git a/script/hassfest/services.py b/script/hassfest/services.py index d9351b8037..bb96931396 100644 --- a/script/hassfest/services.py +++ b/script/hassfest/services.py @@ -10,7 +10,7 @@ from voluptuous.humanize import humanize_error from homeassistant.const import CONF_SELECTOR from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, selector +from homeassistant.helpers import config_validation as cv, selector, service from homeassistant.util.yaml import load_yaml from .model import Config, Integration @@ -33,6 +33,14 @@ FIELD_SCHEMA = vol.Schema( vol.Optional("required"): bool, vol.Optional("advanced"): bool, vol.Optional(CONF_SELECTOR): selector.validate_selector, + vol.Optional("filter"): { + vol.Optional("attribute"): { + vol.Required(str): [vol.All(str, service.validate_attribute_option)], + }, + vol.Optional("supported_features"): [ + vol.All(str, service.validate_supported_feature) + ], + }, } ) @@ -40,9 +48,7 @@ SERVICE_SCHEMA = vol.Schema( { vol.Required("description"): str, vol.Optional("name"): str, - vol.Optional("target"): vol.Any( - selector.TargetSelector.CONFIG_SCHEMA, None # pylint: disable=no-member - ), + vol.Optional("target"): vol.Any(selector.TargetSelector.CONFIG_SCHEMA, None), vol.Optional("fields"): vol.Schema({str: FIELD_SCHEMA}), } ) diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 6f1cb2baef..0a24eac38c 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -69,10 +69,9 @@ def _test_selector( # Serialize selector selector_instance = selector.selector({selector_type: schema}) - assert ( - selector.selector(selector_instance.serialize()["selector"]).config - == selector_instance.config - ) + assert selector_instance.serialize() == { + "selector": {selector_type: selector_instance.config} + } # Test serialized selector can be dumped to YAML yaml.dump(selector_instance.serialize()) @@ -227,6 +226,29 @@ def test_device_selector_schema(schema, valid_selections, invalid_selections) -> ("light.abc123", "binary_sensor.abc123", FAKE_UUID), (None,), ), + ( + { + "filter": [ + {"supported_features": ["light.LightEntityFeature.EFFECT"]}, + ] + }, + ("light.abc123", "blah.blah", FAKE_UUID), + (None,), + ), + ( + { + "filter": [ + { + "supported_features": [ + "light.LightEntityFeature.EFFECT", + "light.LightEntityFeature.TRANSITION", + ] + }, + ] + }, + ("light.abc123", "blah.blah", FAKE_UUID), + (None,), + ), ), ) def test_entity_selector_schema(schema, valid_selections, invalid_selections) -> None: @@ -234,6 +256,25 @@ def test_entity_selector_schema(schema, valid_selections, invalid_selections) -> _test_selector("entity", schema, valid_selections, invalid_selections) +@pytest.mark.parametrize( + "schema", + ( + # Feature should be string specifying an enum member, not an int + {"filter": [{"supported_features": [1]}]}, + # Invalid feature + {"filter": [{"supported_features": ["blah"]}]}, + # Unknown feature enum + {"filter": [{"supported_features": ["blah.FooEntityFeature.blah"]}]}, + # Unknown feature enum member + {"filter": [{"supported_features": ["light.LightEntityFeature.blah"]}]}, + ), +) +def test_entity_selector_schema_error(schema) -> None: + """Test number selector.""" + with pytest.raises(vol.Invalid): + selector.validate_selector({"entity": schema}) + + @pytest.mark.parametrize( ("schema", "valid_selections", "invalid_selections"), ( @@ -359,7 +400,7 @@ def test_addon_selector_schema(schema, valid_selections, invalid_selections) -> @pytest.mark.parametrize( ("schema", "valid_selections", "invalid_selections"), - (({}, (1, "one", None), ()),), # Everything can be coarced to bool + (({}, (1, "one", None), ()),), # Everything can be coerced to bool ) def test_boolean_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test boolean selector.""" From 6e25abfdccf987d72e6302177321f7f622e275e6 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 16 Mar 2023 16:54:26 +0100 Subject: [PATCH 0996/2810] Fix typo in docstr (#89804) --- tests/components/mqtt/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/mqtt/conftest.py b/tests/components/mqtt/conftest.py index 696ad28b73..9fe04c459f 100644 --- a/tests/components/mqtt/conftest.py +++ b/tests/components/mqtt/conftest.py @@ -8,4 +8,4 @@ from tests.components.light.conftest import mock_light_profiles # noqa: F401 @pytest.fixture(autouse=True) def patch_hass_config(mock_hass_config: None) -> None: - """Patch configuration].yaml.""" + """Patch configuration.yaml.""" From 1a7e316b51a099e58971935417f9b169e34a1dd1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 16 Mar 2023 18:19:29 +0100 Subject: [PATCH 0997/2810] Fix lingering timer in condition tests (#89807) --- tests/helpers/test_condition.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index e345da1538..fdbe9eb316 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -6,7 +6,6 @@ from unittest.mock import AsyncMock, patch import pytest import voluptuous as vol -from homeassistant.components import sun import homeassistant.components.automation as automation from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( @@ -39,15 +38,6 @@ def calls(hass: HomeAssistant) -> list[ServiceCall]: return async_mock_service(hass, "test", "automation") -@pytest.fixture(autouse=True) -def setup_comp(hass: HomeAssistant) -> None: - """Initialize components.""" - hass.config.set_time_zone(hass.config.time_zone) - hass.loop.run_until_complete( - async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) - ) - - def assert_element(trace_element, expected_element, path): """Assert a trace element is as expected. From ba4a638b398096cf58164357a005e82a3a7e6f7e Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 16 Mar 2023 18:32:07 +0100 Subject: [PATCH 0998/2810] Update frontend to 20230309.1 (#89802) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index a4d97201c5..2c13e81ee3 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20230309.0"] + "requirements": ["home-assistant-frontend==20230309.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4bcddab1f8..17693b31f6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ fnvhash==0.1.0 hass-nabucasa==0.61.0 hassil==1.0.6 home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230309.0 +home-assistant-frontend==20230309.1 home-assistant-intents==2023.2.28 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index fb76c21073..6c7fbf82bc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -907,7 +907,7 @@ hole==0.8.0 holidays==0.18.0 # homeassistant.components.frontend -home-assistant-frontend==20230309.0 +home-assistant-frontend==20230309.1 # homeassistant.components.conversation home-assistant-intents==2023.2.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6e88193d56..53477f0912 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -693,7 +693,7 @@ hole==0.8.0 holidays==0.18.0 # homeassistant.components.frontend -home-assistant-frontend==20230309.0 +home-assistant-frontend==20230309.1 # homeassistant.components.conversation home-assistant-intents==2023.2.28 From 69aa3a75c5575e30513f7c27c971d540585f8fe1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 16 Mar 2023 18:32:34 +0100 Subject: [PATCH 0999/2810] Fix lingering timer in event sun tests (#89808) --- tests/helpers/test_event.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index d9ad81561c..afc960f4e1 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -14,7 +14,6 @@ from freezegun.api import FrozenDateTimeFactory import jinja2 import pytest -from homeassistant.components import sun from homeassistant.const import MATCH_ALL import homeassistant.core as ha from homeassistant.core import HomeAssistant, callback @@ -3421,7 +3420,6 @@ async def test_track_sunrise(hass: HomeAssistant) -> None: # Setup sun component hass.config.latitude = latitude hass.config.longitude = longitude - assert await async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) location = LocationInfo( latitude=hass.config.latitude, longitude=hass.config.longitude @@ -3486,7 +3484,6 @@ async def test_track_sunrise_update_location(hass: HomeAssistant) -> None: # Setup sun component hass.config.latitude = 32.87336 hass.config.longitude = 117.22743 - assert await async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) location = LocationInfo( latitude=hass.config.latitude, longitude=hass.config.longitude @@ -3508,7 +3505,7 @@ async def test_track_sunrise_update_location(hass: HomeAssistant) -> None: # Track sunrise runs = [] with freeze_time(utc_now): - async_track_sunrise(hass, callback(lambda: runs.append(1))) + unsub = async_track_sunrise(hass, callback(lambda: runs.append(1))) # Mimic sunrise with freeze_time(next_rising): @@ -3549,6 +3546,8 @@ async def test_track_sunrise_update_location(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(runs) == 2 + unsub() + async def test_track_sunset(hass: HomeAssistant) -> None: """Test track the sunset.""" @@ -3560,7 +3559,6 @@ async def test_track_sunset(hass: HomeAssistant) -> None: # Setup sun component hass.config.latitude = latitude hass.config.longitude = longitude - assert await async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) # Get next sunrise/sunset utc_now = datetime(2014, 5, 24, 12, 0, 0, tzinfo=dt_util.UTC) From fdd9c5383f8f7c95626c163191f60f42ef1f5212 Mon Sep 17 00:00:00 2001 From: zhangshengdong29 <435878393@qq.com> Date: Thu, 16 Mar 2023 02:13:32 +0800 Subject: [PATCH 1000/2810] ArestData does not have available (#88631) --- homeassistant/components/arest/sensor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/arest/sensor.py b/homeassistant/components/arest/sensor.py index 5c95fd63c3..2e6012e0e6 100644 --- a/homeassistant/components/arest/sensor.py +++ b/homeassistant/components/arest/sensor.py @@ -180,7 +180,7 @@ class ArestData: self._resource = resource self._pin = pin self.data = {} - self._attr_available = True + self.available = True @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): @@ -201,7 +201,7 @@ class ArestData: f"{self._resource}/digital/{self._pin}", timeout=10 ) self.data = {"value": response.json()["return_value"]} - self._attr_available = True + self.available = True except requests.exceptions.ConnectionError: _LOGGER.error("No route to device %s", self._resource) - self._attr_available = False + self.available = False From a9a6ff50ccfb228b07bb242a253a2a1057eaba5c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Mar 2023 14:04:10 -1000 Subject: [PATCH 1001/2810] Bump aioesphomeapi to 13.5.0 (#89262) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index e8e4e4876f..54cdc23355 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -14,6 +14,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], - "requirements": ["aioesphomeapi==13.4.2", "esphome-dashboard-api==1.2.3"], + "requirements": ["aioesphomeapi==13.5.0", "esphome-dashboard-api==1.2.3"], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 38c2675815..c657f0202a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -156,7 +156,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.4.2 +aioesphomeapi==13.5.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 72fb11f485..b930f465c3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -143,7 +143,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.4.2 +aioesphomeapi==13.5.0 # homeassistant.components.flo aioflo==2021.11.0 From 02738fb9d4cced2886085f559f9f21eae72e1889 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Thu, 16 Mar 2023 13:26:56 +0100 Subject: [PATCH 1002/2810] Handle int or mapping for off case in nibe cooling (#89680) Handle int or mapping for off case in nibe --- homeassistant/components/nibe_heatpump/climate.py | 10 +++++++--- homeassistant/components/nibe_heatpump/const.py | 1 + 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nibe_heatpump/climate.py b/homeassistant/components/nibe_heatpump/climate.py index a68aabacf4..0df787de98 100644 --- a/homeassistant/components/nibe_heatpump/climate.py +++ b/homeassistant/components/nibe_heatpump/climate.py @@ -31,6 +31,7 @@ from . import Coordinator from .const import ( DOMAIN, LOGGER, + VALUES_COOL_WITH_ROOM_SENSOR_OFF, VALUES_MIXING_VALVE_CLOSED_STATE, VALUES_PRIORITY_COOLING, VALUES_PRIORITY_HEATING, @@ -139,10 +140,13 @@ class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): mode = HVACMode.OFF if _get_value(self._coil_use_room_sensor) == "ON": - if _get_value(self._coil_cooling_with_room_sensor) != "OFF": - mode = HVACMode.HEAT_COOL - else: + if ( + _get_value(self._coil_cooling_with_room_sensor) + in VALUES_COOL_WITH_ROOM_SENSOR_OFF + ): mode = HVACMode.HEAT + else: + mode = HVACMode.HEAT_COOL self._attr_hvac_mode = mode setpoint_heat = _get_float(self._coil_setpoint_heat) diff --git a/homeassistant/components/nibe_heatpump/const.py b/homeassistant/components/nibe_heatpump/const.py index 7d9bf58709..dc6b4b1899 100644 --- a/homeassistant/components/nibe_heatpump/const.py +++ b/homeassistant/components/nibe_heatpump/const.py @@ -17,3 +17,4 @@ CONF_MODBUS_UNIT = "modbus_unit" VALUES_MIXING_VALVE_CLOSED_STATE = (30, "CLOSED", "SHUNT CLOSED") VALUES_PRIORITY_HEATING = (30, "HEAT") VALUES_PRIORITY_COOLING = (60, "COOLING") +VALUES_COOL_WITH_ROOM_SENSOR_OFF = (0, "OFF") From f120bac17f75724a8a9dc3005ca07b672baa741c Mon Sep 17 00:00:00 2001 From: jan iversen Date: Thu, 16 Mar 2023 04:48:00 +0100 Subject: [PATCH 1003/2810] Secure modbus hub_collect remains valid (#89684) Secure hub_collect remains valid. --- homeassistant/components/modbus/modbus.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 627950fe00..c40a05c1ef 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -137,8 +137,10 @@ async def async_modbus_setup( for name in hubs: if not await hubs[name].async_setup(): return False + hub_collect = hass.data[DOMAIN] + else: + hass.data[DOMAIN] = hub_collect = {} - hass.data[DOMAIN] = hub_collect = {} for conf_hub in config[DOMAIN]: my_hub = ModbusHub(hass, conf_hub) hub_collect[conf_hub[CONF_NAME]] = my_hub From c3d7696c2e41fe4f55860c26bdcf3c75a67694aa Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Wed, 15 Mar 2023 06:01:34 +0100 Subject: [PATCH 1004/2810] Update to nibe 2.1.4 (#89686) --- homeassistant/components/nibe_heatpump/config_flow.py | 1 + homeassistant/components/nibe_heatpump/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nibe_heatpump/config_flow.py b/homeassistant/components/nibe_heatpump/config_flow.py index 434a9a50ea..6680ca6e32 100644 --- a/homeassistant/components/nibe_heatpump/config_flow.py +++ b/homeassistant/components/nibe_heatpump/config_flow.py @@ -89,6 +89,7 @@ async def validate_nibegw_input( """Validate the user input allows us to connect.""" heatpump = HeatPump(Model[data[CONF_MODEL]]) + heatpump.word_swap = True await heatpump.initialize() connection = NibeGW( diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json index 5114cc222e..81c23437bb 100644 --- a/homeassistant/components/nibe_heatpump/manifest.json +++ b/homeassistant/components/nibe_heatpump/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nibe_heatpump", "iot_class": "local_polling", - "requirements": ["nibe==2.0.0"] + "requirements": ["nibe==2.1.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index c657f0202a..4db6706449 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1201,7 +1201,7 @@ nextcord==2.0.0a8 nextdns==1.3.0 # homeassistant.components.nibe_heatpump -nibe==2.0.0 +nibe==2.1.4 # homeassistant.components.niko_home_control niko-home-control==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b930f465c3..5f79cfb759 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==2.0.0 +nibe==2.1.4 # homeassistant.components.nfandroidtv notifications-android-tv==0.1.5 From 52981699cf3348a38fef0df7bd14705d1c52096c Mon Sep 17 00:00:00 2001 From: Marcio Granzotto Rodrigues Date: Tue, 14 Mar 2023 15:44:55 -0300 Subject: [PATCH 1005/2810] Bump bond-async to 0.1.23 (#89697) --- homeassistant/components/bond/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json index bf343673fd..fc91f8eb72 100644 --- a/homeassistant/components/bond/manifest.json +++ b/homeassistant/components/bond/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_push", "loggers": ["bond_async"], "quality_scale": "platinum", - "requirements": ["bond-async==0.1.22"], + "requirements": ["bond-async==0.1.23"], "zeroconf": ["_bond._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 4db6706449..0b8a7d2207 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -467,7 +467,7 @@ bluetooth-auto-recovery==1.0.3 bluetooth-data-tools==0.3.1 # homeassistant.components.bond -bond-async==0.1.22 +bond-async==0.1.23 # homeassistant.components.bosch_shc boschshcpy==0.2.35 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5f79cfb759..bd4f88b7a4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -384,7 +384,7 @@ bluetooth-auto-recovery==1.0.3 bluetooth-data-tools==0.3.1 # homeassistant.components.bond -bond-async==0.1.22 +bond-async==0.1.23 # homeassistant.components.bosch_shc boschshcpy==0.2.35 From 90a4afb6faa358bf96eba12f6ae29dc746fa907f Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 15 Mar 2023 12:27:45 +0100 Subject: [PATCH 1006/2810] Correct modbus serial method parameter (#89738) --- homeassistant/components/modbus/modbus.py | 9 +++++++-- tests/components/modbus/test_init.py | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index c40a05c1ef..b53cfda104 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -16,7 +16,7 @@ from pymodbus.client import ( from pymodbus.constants import Defaults from pymodbus.exceptions import ModbusException from pymodbus.pdu import ModbusResponse -from pymodbus.transaction import ModbusRtuFramer +from pymodbus.transaction import ModbusAsciiFramer, ModbusRtuFramer, ModbusSocketFramer import voluptuous as vol from homeassistant.const import ( @@ -281,9 +281,12 @@ class ModbusHub: } if self._config_type == SERIAL: # serial configuration + if client_config[CONF_METHOD] == "ascii": + self._pb_params["framer"] = ModbusAsciiFramer + else: + self._pb_params["framer"] = ModbusRtuFramer self._pb_params.update( { - "method": client_config[CONF_METHOD], "baudrate": client_config[CONF_BAUDRATE], "stopbits": client_config[CONF_STOPBITS], "bytesize": client_config[CONF_BYTESIZE], @@ -295,6 +298,8 @@ class ModbusHub: self._pb_params["host"] = client_config[CONF_HOST] if self._config_type == RTUOVERTCP: self._pb_params["framer"] = ModbusRtuFramer + else: + self._pb_params["framer"] = ModbusSocketFramer Defaults.Timeout = client_config[CONF_TIMEOUT] if CONF_MSG_WAIT in client_config: diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 75f2f9d3e6..7a06923404 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -378,7 +378,7 @@ async def test_duplicate_entity_validator(do_config) -> None: CONF_TYPE: SERIAL, CONF_BAUDRATE: 9600, CONF_BYTESIZE: 8, - CONF_METHOD: "rtu", + CONF_METHOD: "ascii", CONF_PORT: TEST_PORT_SERIAL, CONF_PARITY: "E", CONF_STOPBITS: 1, From abda7b8a5bf95fcdb787decd1bc9541c8424f158 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 15 Mar 2023 21:22:13 +0100 Subject: [PATCH 1007/2810] Fix imap server push holding HA startup (#89750) --- homeassistant/components/imap/coordinator.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 8a716fe478..e170f79e7f 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -77,7 +77,9 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int]): f"Invalid response for search '{self.config_entry.data[CONF_SEARCH]}': {result} / {lines[0]}" ) if self.support_push: - self.hass.async_create_task(self.async_wait_server_push()) + self.hass.async_create_background_task( + self.async_wait_server_push(), "Wait for IMAP data push" + ) return len(lines[0].split()) async def async_wait_server_push(self) -> None: @@ -100,5 +102,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int]): async def shutdown(self, *_) -> None: """Close resources.""" if self.imap_client: + if self.imap_client.has_pending_idle(): + self.imap_client.idle_done() await self.imap_client.stop_wait_server_push() await self.imap_client.logout() From 9fa73fe3a9218b66905c9ffb91ba8cc3c3b3e7c3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Mar 2023 20:00:47 -1000 Subject: [PATCH 1008/2810] Bump aioesphomeapi to 13.5.1 (#89777) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 54cdc23355..95b6c091d5 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -14,6 +14,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], - "requirements": ["aioesphomeapi==13.5.0", "esphome-dashboard-api==1.2.3"], + "requirements": ["aioesphomeapi==13.5.1", "esphome-dashboard-api==1.2.3"], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 0b8a7d2207..9bda92bc7f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -156,7 +156,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.5.0 +aioesphomeapi==13.5.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bd4f88b7a4..7e16fea877 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.5.0 +aioesphomeapi==13.5.1 # homeassistant.components.flo aioflo==2021.11.0 From e651ca747bc5ea34920397dbe7aa347a3da38e77 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 16 Mar 2023 18:32:07 +0100 Subject: [PATCH 1009/2810] Update frontend to 20230309.1 (#89802) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index a4d97201c5..2c13e81ee3 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20230309.0"] + "requirements": ["home-assistant-frontend==20230309.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e290d4730d..39b1a1b3dd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ fnvhash==0.1.0 hass-nabucasa==0.61.0 hassil==1.0.6 home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230309.0 +home-assistant-frontend==20230309.1 home-assistant-intents==2023.2.28 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index 9bda92bc7f..cdffe860f7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -907,7 +907,7 @@ hole==0.8.0 holidays==0.18.0 # homeassistant.components.frontend -home-assistant-frontend==20230309.0 +home-assistant-frontend==20230309.1 # homeassistant.components.conversation home-assistant-intents==2023.2.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7e16fea877..c8f2b57a76 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==20230309.0 +home-assistant-frontend==20230309.1 # homeassistant.components.conversation home-assistant-intents==2023.2.28 From 7e18e15cacc4bd00db1165de995f2b1c2ce68565 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 16 Mar 2023 18:48:17 +0100 Subject: [PATCH 1010/2810] Bumped version to 2023.3.5 --- 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 040794ed21..f8e7859bf4 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 = "4" +PATCH_VERSION: Final = "5" __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 eed3a9b967..a9c70089c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.3.4" +version = "2023.3.5" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 3e89b81e1da1e88c7345662edd3581f636a4de3e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 16 Mar 2023 19:03:23 +0100 Subject: [PATCH 1011/2810] Add state attribute translations for calendars (#89811) --- .../components/calendar/strings.json | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index bcc07bb7fc..73c54bbba6 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -5,6 +5,26 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" + }, + "state_attributes": { + "all_day": { + "name": "All day" + }, + "description": { + "name": "Description" + }, + "end_time": { + "name": "End time" + }, + "location": { + "name": "Location" + }, + "messages": { + "name": "Message" + }, + "start_time": { + "name": "Start time" + } } } } From cb8ed4a1cc33ed87aad53a686edf903b1e978058 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 16 Mar 2023 19:04:11 +0100 Subject: [PATCH 1012/2810] Add state attribute translations for alarm control panel (#89809) --- .../components/alarm_control_panel/strings.json | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/homeassistant/components/alarm_control_panel/strings.json b/homeassistant/components/alarm_control_panel/strings.json index 1d8a29f7dd..f05d04dd78 100644 --- a/homeassistant/components/alarm_control_panel/strings.json +++ b/homeassistant/components/alarm_control_panel/strings.json @@ -40,6 +40,17 @@ "arming": "Arming", "disarming": "Disarming", "triggered": "Triggered" + }, + "state_attributes": { + "code_format": { + "name": "Code format" + }, + "changed_by": { + "name": "Changed by" + }, + "code_arm_required": { + "name": "Code for arming required" + } } } } From d99c02b46f40e38e9ad890bec0c29a588f880142 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 16 Mar 2023 19:04:25 +0100 Subject: [PATCH 1013/2810] Add state attribute translations for covers (#89812) --- homeassistant/components/cover/strings.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/cover/strings.json b/homeassistant/components/cover/strings.json index d17508b71d..dd728b4113 100644 --- a/homeassistant/components/cover/strings.json +++ b/homeassistant/components/cover/strings.json @@ -35,6 +35,14 @@ "closed": "[%key:common::state::closed%]", "closing": "Closing", "stopped": "Stopped" + }, + "state_attributes": { + "current_position": { + "name": "Position" + }, + "current_tilt_position": { + "name": "Tilt position" + } } } } From 298dae55fae2eea30bab0a789e41e8e3bb78891b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 16 Mar 2023 19:06:35 +0100 Subject: [PATCH 1014/2810] Add missing state translations for Lock entities (#89795) --- homeassistant/components/lock/strings.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lock/strings.json b/homeassistant/components/lock/strings.json index ab7a1632ea..e6c9884457 100644 --- a/homeassistant/components/lock/strings.json +++ b/homeassistant/components/lock/strings.json @@ -18,8 +18,11 @@ "entity_component": { "_": { "state": { + "jammed": "Jammed", "locked": "[%key:common::state::locked%]", - "unlocked": "[%key:common::state::unlocked%]" + "locking": "Locking", + "unlocked": "[%key:common::state::unlocked%]", + "unlocking": "Unlocking" } } } From e6f280cf7a9c93e878febc2f0090031159ad1133 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 16 Mar 2023 19:58:52 +0100 Subject: [PATCH 1015/2810] Add state attribute translations for device trackers (#89810) --- .../components/device_tracker/strings.json | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/homeassistant/components/device_tracker/strings.json b/homeassistant/components/device_tracker/strings.json index a1c50c88f8..e9232a72bb 100644 --- a/homeassistant/components/device_tracker/strings.json +++ b/homeassistant/components/device_tracker/strings.json @@ -15,6 +15,29 @@ "state": { "home": "[%key:common::state::home%]", "not_home": "[%key:common::state::not_home%]" + }, + "state_attributes": { + "battery": { + "name": "Battery" + }, + "gps_accuracy": { + "name": "GPS accuracy" + }, + "latitude": { + "name": "Latitude" + }, + "longitude": { + "name": "Longitude" + }, + "source_type": { + "name": "Source", + "state": { + "bluetooth_le": "Bluetooth LE", + "bluetooth": "Bluetooth", + "gps": "GPS", + "router": "Router" + } + } } } } From f9919bb7cfd58ea569152bd866c9ba28fa263483 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 16 Mar 2023 21:10:20 +0100 Subject: [PATCH 1016/2810] Add pre-defined entity name translations (#89792) --- .../alarm_control_panel/strings.json | 1 + homeassistant/components/alert/strings.json | 1 + .../components/automation/strings.json | 1 + .../components/binary_sensor/strings.json | 27 ++++ homeassistant/components/button/strings.json | 11 ++ .../components/calendar/strings.json | 1 + homeassistant/components/camera/strings.json | 1 + homeassistant/components/climate/strings.json | 1 + .../components/configurator/strings.json | 1 + homeassistant/components/cover/strings.json | 31 ++++ .../components/device_tracker/strings.json | 1 + homeassistant/components/fan/strings.json | 1 + homeassistant/components/group/strings.json | 1 + .../components/humidifier/strings.json | 7 + .../components/input_boolean/strings.json | 1 + homeassistant/components/light/strings.json | 1 + homeassistant/components/lock/strings.json | 1 + .../components/media_player/strings.json | 10 ++ homeassistant/components/number/strings.json | 131 +++++++++++++++++ homeassistant/components/person/strings.json | 1 + homeassistant/components/plant/strings.json | 1 + homeassistant/components/remote/strings.json | 1 + .../components/schedule/strings.json | 1 + homeassistant/components/script/strings.json | 1 + homeassistant/components/sensor/strings.json | 139 ++++++++++++++++++ homeassistant/components/sun/strings.json | 1 + homeassistant/components/switch/strings.json | 7 + homeassistant/components/timer/strings.json | 1 + homeassistant/components/update/strings.json | 8 + homeassistant/components/vacuum/strings.json | 1 + .../components/water_heater/strings.json | 1 + homeassistant/components/weather/strings.json | 1 + script/hassfest/translations.py | 1 + tests/helpers/test_translation.py | 11 +- 34 files changed, 402 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/strings.json b/homeassistant/components/alarm_control_panel/strings.json index f05d04dd78..f055d6646b 100644 --- a/homeassistant/components/alarm_control_panel/strings.json +++ b/homeassistant/components/alarm_control_panel/strings.json @@ -28,6 +28,7 @@ }, "entity_component": { "_": { + "name": "[%key:component::alarm_control_panel::title%]", "state": { "armed": "Armed", "disarmed": "Disarmed", diff --git a/homeassistant/components/alert/strings.json b/homeassistant/components/alert/strings.json index 9975e6ee0d..4d948b2f4d 100644 --- a/homeassistant/components/alert/strings.json +++ b/homeassistant/components/alert/strings.json @@ -2,6 +2,7 @@ "title": "Alert", "entity_component": { "_": { + "name": "[%key:component::alert::title%]", "state": { "idle": "[%key:common::state::idle%]", "off": "Acknowledged", diff --git a/homeassistant/components/automation/strings.json b/homeassistant/components/automation/strings.json index ffadc47a48..1663443f04 100644 --- a/homeassistant/components/automation/strings.json +++ b/homeassistant/components/automation/strings.json @@ -2,6 +2,7 @@ "title": "Automation", "entity_component": { "_": { + "name": "[%key:component::automation::title%]", "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json index 8d787204db..a0dc2020f0 100644 --- a/homeassistant/components/binary_sensor/strings.json +++ b/homeassistant/components/binary_sensor/strings.json @@ -108,162 +108,189 @@ }, "entity_component": { "_": { + "name": "[%key:component::binary_sensor::title%]", "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" } }, "battery": { + "name": "Battery", "state": { "off": "Normal", "on": "Low" } }, "battery_charging": { + "name": "Charging", "state": { "off": "Not charging", "on": "Charging" } }, "carbon_monoxide": { + "name": "Carbon monoxide", "state": { "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" } }, "cold": { + "name": "Cold", "state": { "off": "[%key:component::binary_sensor::entity_component::battery::state::off%]", "on": "Cold" } }, "connectivity": { + "name": "Connectivity", "state": { "off": "[%key:common::state::disconnected%]", "on": "[%key:common::state::connected%]" } }, "door": { + "name": "Door", "state": { "off": "[%key:common::state::closed%]", "on": "[%key:common::state::open%]" } }, "garage_door": { + "name": "Garage door", "state": { "off": "[%key:common::state::closed%]", "on": "[%key:common::state::open%]" } }, "gas": { + "name": "Gas", "state": { "off": "Clear", "on": "Detected" } }, "heat": { + "name": "Heat", "state": { "off": "[%key:component::binary_sensor::entity_component::battery::state::off%]", "on": "Hot" } }, "light": { + "name": "Light", "state": { "off": "No light", "on": "Light detected" } }, "lock": { + "name": "Lock", "state": { "off": "[%key:common::state::locked%]", "on": "[%key:common::state::unlocked%]" } }, "moisture": { + "name": "Moisture", "state": { "off": "Dry", "on": "Wet" } }, "motion": { + "name": "Motion", "state": { "off": "[key:component::binary_sensor::entity_component::gas::state::off%]", "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" } }, "moving": { + "name": "Moving", "state": { "off": "Not moving", "on": "Moving" } }, "occupancy": { + "name": "Occupancy", "state": { "off": "[key:component::binary_sensor::entity_component::gas::state::off%]", "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" } }, "opening": { + "name": "Opening", "state": { "off": "[%key:common::state::closed%]", "on": "[%key:common::state::open%]" } }, "plug": { + "name": "Plug", "state": { "off": "Unplugged", "on": "Plugged in" } }, "presence": { + "name": "Presence", "state": { "off": "[%key:component::device_tracker::entity_component::_::state::not_home%]", "on": "[%key:component::device_tracker::entity_component::_::state::home%]" } }, "problem": { + "name": "Problem", "state": { "off": "OK", "on": "Problem" } }, "running": { + "name": "Running", "state": { "off": "Not running", "on": "Running" } }, "safety": { + "name": "Safety", "state": { "off": "Safe", "on": "Unsafe" } }, "smoke": { + "name": "Smoke", "state": { "off": "[key:component::binary_sensor::entity_component::gas::state::off%]", "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" } }, "sound": { + "name": "Sound", "state": { "off": "[key:component::binary_sensor::entity_component::gas::state::off%]", "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" } }, "update": { + "name": "Update", "state": { "off": "Up-to-date", "on": "Update available" } }, "vibration": { + "name": "Vibration", "state": { "off": "[key:component::binary_sensor::entity_component::gas::state::off%]", "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" } }, "window": { + "name": "Window", "state": { "off": "[%key:common::state::closed%]", "on": "[%key:common::state::open%]" diff --git a/homeassistant/components/button/strings.json b/homeassistant/components/button/strings.json index ca774c57d7..4fd8885389 100644 --- a/homeassistant/components/button/strings.json +++ b/homeassistant/components/button/strings.json @@ -7,5 +7,16 @@ "action_type": { "press": "Press {entity_name} button" } + }, + "entity_component": { + "_": { + "name": "[%key:component::button::title%]" + }, + "restart": { + "name": "Restart" + }, + "update": { + "name": "Update" + } } } diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index 73c54bbba6..2663408cfd 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -2,6 +2,7 @@ "title": "Calendar", "entity_component": { "_": { + "name": "[%key:component::calendar::title%]", "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" diff --git a/homeassistant/components/camera/strings.json b/homeassistant/components/camera/strings.json index 5bde2ed251..06ddaeeb09 100644 --- a/homeassistant/components/camera/strings.json +++ b/homeassistant/components/camera/strings.json @@ -2,6 +2,7 @@ "title": "Camera", "entity_component": { "_": { + "name": "[%key:component::camera::title%]", "state": { "recording": "Recording", "streaming": "Streaming", diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index 16cf9a130b..5e3fe15d56 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -17,6 +17,7 @@ }, "entity_component": { "_": { + "name": "Thermostat", "state": { "off": "[%key:common::state::off%]", "heat": "Heat", diff --git a/homeassistant/components/configurator/strings.json b/homeassistant/components/configurator/strings.json index c48f1d8385..0574e4bfce 100644 --- a/homeassistant/components/configurator/strings.json +++ b/homeassistant/components/configurator/strings.json @@ -2,6 +2,7 @@ "title": "Configurator", "entity_component": { "_": { + "name": "[%key:component::configurator::title%]", "state": { "configure": "Configure", "configured": "Configured" diff --git a/homeassistant/components/cover/strings.json b/homeassistant/components/cover/strings.json index dd728b4113..2f61bd9508 100644 --- a/homeassistant/components/cover/strings.json +++ b/homeassistant/components/cover/strings.json @@ -29,6 +29,7 @@ }, "entity_component": { "_": { + "name": "[%key:component::cover::title%]", "state": { "open": "[%key:common::state::open%]", "opening": "Opening", @@ -44,6 +45,36 @@ "name": "Tilt position" } } + }, + "awning": { + "name": "Awning" + }, + "blind": { + "name": "Blind" + }, + "curtain": { + "name": "Curtain" + }, + "damper": { + "name": "Damper" + }, + "door": { + "name": "Door" + }, + "garage": { + "name": "Garage" + }, + "gate": { + "name": "Gate" + }, + "shade": { + "name": "Shade" + }, + "shutter": { + "name": "Shutter" + }, + "window": { + "name": "Window" } } } diff --git a/homeassistant/components/device_tracker/strings.json b/homeassistant/components/device_tracker/strings.json index e9232a72bb..c15b9723c9 100644 --- a/homeassistant/components/device_tracker/strings.json +++ b/homeassistant/components/device_tracker/strings.json @@ -12,6 +12,7 @@ }, "entity_component": { "_": { + "name": "[%key:component::device_tracker::title%]", "state": { "home": "[%key:common::state::home%]", "not_home": "[%key:common::state::not_home%]" diff --git a/homeassistant/components/fan/strings.json b/homeassistant/components/fan/strings.json index 670d11b76b..9aae5cf164 100644 --- a/homeassistant/components/fan/strings.json +++ b/homeassistant/components/fan/strings.json @@ -18,6 +18,7 @@ }, "entity_component": { "_": { + "name": "[%key:component::fan::title%]", "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index 17f63167db..e78fe982d5 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -157,6 +157,7 @@ }, "entity_component": { "_": { + "name": "[%key:component::group::title%]", "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]", diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index e536def167..0fca7c0a0a 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -22,10 +22,17 @@ }, "entity_component": { "_": { + "name": "[%key:component::humidifier::title%]", "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" } + }, + "dehumidifier": { + "name": "Dehumidifier" + }, + "humidifier": { + "name": "[%key:component::humidifier::entity_component::_::name%]" } } } diff --git a/homeassistant/components/input_boolean/strings.json b/homeassistant/components/input_boolean/strings.json index 509799b5ed..8294d72875 100644 --- a/homeassistant/components/input_boolean/strings.json +++ b/homeassistant/components/input_boolean/strings.json @@ -2,6 +2,7 @@ "title": "Input boolean", "entity_component": { "_": { + "name": "[%key:component::input_boolean::title%]", "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index 38f843ab1d..ef3fa74bd9 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -21,6 +21,7 @@ }, "entity_component": { "_": { + "name": "[%key:component::light::title%]", "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" diff --git a/homeassistant/components/lock/strings.json b/homeassistant/components/lock/strings.json index e6c9884457..497dec1a40 100644 --- a/homeassistant/components/lock/strings.json +++ b/homeassistant/components/lock/strings.json @@ -17,6 +17,7 @@ }, "entity_component": { "_": { + "name": "[%key:component::lock::title%]", "state": { "jammed": "Jammed", "locked": "[%key:common::state::locked%]", diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index 2c8f3d3d2b..8627a31307 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -21,6 +21,7 @@ }, "entity_component": { "_": { + "name": "[%key:component::media_player::title%]", "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]", @@ -30,6 +31,15 @@ "standby": "[%key:common::state::standby%]", "buffering": "Buffering" } + }, + "tv": { + "name": "TV" + }, + "speaker": { + "name": "Speaker" + }, + "receiver": { + "name": "Receiver" } } } diff --git a/homeassistant/components/number/strings.json b/homeassistant/components/number/strings.json index 77ba7e7a91..d265b84c74 100644 --- a/homeassistant/components/number/strings.json +++ b/homeassistant/components/number/strings.json @@ -4,5 +4,136 @@ "action_type": { "set_value": "Set value for {entity_name}" } + }, + "entity_component": { + "_": { + "name": "[%key:component::number::title%]" + }, + "apparent_power": { + "name": "[%key:component::sensor::entity_component::apparent_power::name%]" + }, + "aqi": { + "name": "[%key:component::sensor::entity_component::aqi::name%]" + }, + "atmospheric_pressure": { + "name": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]" + }, + "battery": { + "name": "[%key:component::sensor::entity_component::battery::name%]" + }, + "carbon_dioxide": { + "name": "[%key:component::sensor::entity_component::carbon_dioxide::name%]" + }, + "carbon_monoxide": { + "name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]" + }, + "current": { + "name": "[%key:component::sensor::entity_component::current::name%]" + }, + "data_rate": { + "name": "[%key:component::sensor::entity_component::data_rate::name%]" + }, + "distance": { + "name": "[%key:component::sensor::entity_component::distance::name%]" + }, + "energy": { + "name": "[%key:component::sensor::entity_component::energy::name%]" + }, + "energy_storage": { + "name": "[%key:component::sensor::entity_component::energy_storage::name%]" + }, + "frequency": { + "name": "[%key:component::sensor::entity_component::frequency::name%]" + }, + "gas": { + "name": "[%key:component::sensor::entity_component::gas::name%]" + }, + "humidity": { + "name": "[%key:component::sensor::entity_component::humidity::name%]" + }, + "illuminance": { + "name": "[%key:component::sensor::entity_component::illuminance::name%]" + }, + "irradiance": { + "name": "[%key:component::sensor::entity_component::irradiance::name%]" + }, + "moisture": { + "name": "[%key:component::sensor::entity_component::moisture::name%]" + }, + "nitrogen_dioxide": { + "name": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]" + }, + "nitrogen_monoxide": { + "name": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]" + }, + "nitrous_oxide": { + "name": "[%key:component::sensor::entity_component::nitrous_oxide::name%]" + }, + "ozone": { + "name": "[%key:component::sensor::entity_component::ozone::name%]" + }, + "pm1": { + "name": "[%key:component::sensor::entity_component::pm1::name%]" + }, + "pm10": { + "name": "[%key:component::sensor::entity_component::pm10::name%]" + }, + "pm25": { + "name": "[%key:component::sensor::entity_component::pm25::name%]" + }, + "power_factor": { + "name": "[%key:component::sensor::entity_component::power_factor::name%]" + }, + "power": { + "name": "[%key:component::sensor::entity_component::power::name%]" + }, + "precipitation": { + "name": "[%key:component::sensor::entity_component::precipitation::name%]" + }, + "precipitation_intensity": { + "name": "[%key:component::sensor::entity_component::precipitation_intensity::name%]" + }, + "pressure": { + "name": "[%key:component::sensor::entity_component::pressure::name%]" + }, + "reactive_power": { + "name": "[%key:component::sensor::entity_component::reactive_power::name%]" + }, + "signal_strength": { + "name": "[%key:component::sensor::entity_component::signal_strength::name%]" + }, + "sound_pressure": { + "name": "[%key:component::sensor::entity_component::sound_pressure::name%]" + }, + "speed": { + "name": "[%key:component::sensor::entity_component::speed::name%]" + }, + "sulphur_dioxide": { + "name": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]" + }, + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "volatile_organic_compounds": { + "name": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]" + }, + "voltage": { + "name": "[%key:component::sensor::entity_component::voltage::name%]" + }, + "volume": { + "name": "[%key:component::sensor::entity_component::volume::name%]" + }, + "volume_storage": { + "name": "[%key:component::sensor::entity_component::volume_storage::name%]" + }, + "water": { + "name": "[%key:component::sensor::entity_component::water::name%]" + }, + "weight": { + "name": "[%key:component::sensor::entity_component::weight::name%]" + }, + "wind_speed": { + "name": "[%key:component::sensor::entity_component::wind_speed::name%]" + } } } diff --git a/homeassistant/components/person/strings.json b/homeassistant/components/person/strings.json index 7bba0198a1..8ee8c3a56a 100644 --- a/homeassistant/components/person/strings.json +++ b/homeassistant/components/person/strings.json @@ -2,6 +2,7 @@ "title": "Person", "entity_component": { "_": { + "name": "[%key:component::person::title%]", "state": { "home": "[%key:common::state::home%]", "not_home": "[%key:common::state::not_home%]" diff --git a/homeassistant/components/plant/strings.json b/homeassistant/components/plant/strings.json index 5ece766c71..853e5daee1 100644 --- a/homeassistant/components/plant/strings.json +++ b/homeassistant/components/plant/strings.json @@ -2,6 +2,7 @@ "title": "Plant Monitor", "entity_component": { "_": { + "name": "[%key:component::plant::title%]", "state": { "ok": "[%key:component::binary_sensor::entity_component::problem::state::off%]", "problem": "[%key:component::binary_sensor::entity_component::problem::state::on%]" diff --git a/homeassistant/components/remote/strings.json b/homeassistant/components/remote/strings.json index a558cc76fe..f0d2787b65 100644 --- a/homeassistant/components/remote/strings.json +++ b/homeassistant/components/remote/strings.json @@ -18,6 +18,7 @@ }, "entity_component": { "_": { + "name": "[%key:component::remote::title%]", "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" diff --git a/homeassistant/components/schedule/strings.json b/homeassistant/components/schedule/strings.json index ecc673805c..f8da366887 100644 --- a/homeassistant/components/schedule/strings.json +++ b/homeassistant/components/schedule/strings.json @@ -2,6 +2,7 @@ "title": "Schedule", "entity_component": { "_": { + "name": "[%key:component::schedule::title%]", "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" diff --git a/homeassistant/components/script/strings.json b/homeassistant/components/script/strings.json index a4ea086006..c78e4265cb 100644 --- a/homeassistant/components/script/strings.json +++ b/homeassistant/components/script/strings.json @@ -2,6 +2,7 @@ "title": "Script", "entity_component": { "_": { + "name": "[%key:component::script::title%]", "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 4b764c609c..a579be6720 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -96,10 +96,149 @@ }, "entity_component": { "_": { + "name": "[%key:component::sensor::title%]", "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" } + }, + "date": { + "name": "Date" + }, + "duration": { + "name": "Duration" + }, + "apparent_power": { + "name": "Apparent power" + }, + "aqi": { + "name": "Air quality index" + }, + "atmospheric_pressure": { + "name": "Atmospheric pressure" + }, + "battery": { + "name": "Battery" + }, + "carbon_monoxide": { + "name": "Carbon monoxide" + }, + "carbon_dioxide": { + "name": "Carbon dioxide" + }, + "current": { + "name": "Current" + }, + "data_rate": { + "name": "Data rate" + }, + "data_size": { + "name": "Data size" + }, + "distance": { + "name": "Distance" + }, + "energy": { + "name": "Energy" + }, + "energy_storage": { + "name": "Stored energy" + }, + "frequency": { + "name": "Frequency" + }, + "gas": { + "name": "Gas" + }, + "humidity": { + "name": "Humidity" + }, + "illuminance": { + "name": "Illuminance" + }, + "irradiance": { + "name": "Irradiance" + }, + "moisture": { + "name": "Moisture" + }, + "monetary": { + "name": "Balance" + }, + "nitrogen_dioxide": { + "name": "Nitrogen dioxide" + }, + "nitrogen_monoxide": { + "name": "Nitrogen monoxide" + }, + "nitrous_oxide": { + "name": "Nitrous oxide" + }, + "ozone": { + "name": "Ozone" + }, + "pm1": { + "name": "Particulate matter 0.1 μm" + }, + "pm10": { + "name": "Particulate matter 10 μm" + }, + "pm25": { + "name": "Particulate matter 2.5 μm" + }, + "power_factor": { + "name": "Power factor" + }, + "power": { + "name": "Power" + }, + "precipitation": { + "name": "Precipitation" + }, + "precipitation_intensity": { + "name": "Precipitation intensity" + }, + "pressure": { + "name": "Pressure" + }, + "reactive_power": { + "name": "Reactive power" + }, + "signal_strength": { + "name": "Signal strength" + }, + "sound_pressure": { + "name": "Sound pressure" + }, + "speed": { + "name": "Speed" + }, + "sulphur_dioxide": { + "name": "Sulphur dioxide" + }, + "temperature": { + "name": "Temperature" + }, + "volatile_organic_compounds": { + "name": "VOCs" + }, + "voltage": { + "name": "Voltage" + }, + "volume": { + "name": "Volume" + }, + "volume_storage": { + "name": "Stored volume" + }, + "water": { + "name": "Water" + }, + "weight": { + "name": "Weight" + }, + "wind_speed": { + "name": "Wind speed" } } } diff --git a/homeassistant/components/sun/strings.json b/homeassistant/components/sun/strings.json index d8a75224f6..9a49a061c1 100644 --- a/homeassistant/components/sun/strings.json +++ b/homeassistant/components/sun/strings.json @@ -12,6 +12,7 @@ }, "entity_component": { "_": { + "name": "[%key:component::sun::title%]", "state": { "above_horizon": "Above horizon", "below_horizon": "Below horizon" diff --git a/homeassistant/components/switch/strings.json b/homeassistant/components/switch/strings.json index ba7c5c6848..a7934ba420 100644 --- a/homeassistant/components/switch/strings.json +++ b/homeassistant/components/switch/strings.json @@ -18,10 +18,17 @@ }, "entity_component": { "_": { + "name": "[%key:component::switch::title%]", "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" } + }, + "switch": { + "name": "[%key:component::switch::entity_component::_::name%]" + }, + "outlet": { + "name": "Outlet" } } } diff --git a/homeassistant/components/timer/strings.json b/homeassistant/components/timer/strings.json index 914ee73835..b6dd2418ad 100644 --- a/homeassistant/components/timer/strings.json +++ b/homeassistant/components/timer/strings.json @@ -1,6 +1,7 @@ { "entity_component": { "_": { + "name": "Timer", "state": { "active": "[%key:common::state::active%]", "idle": "[%key:common::state::idle%]", diff --git a/homeassistant/components/update/strings.json b/homeassistant/components/update/strings.json index c26d3968ae..776c2c59a3 100644 --- a/homeassistant/components/update/strings.json +++ b/homeassistant/components/update/strings.json @@ -6,5 +6,13 @@ "turned_on": "{entity_name} got an update available", "turned_off": "{entity_name} became up-to-date" } + }, + "entity_component": { + "_": { + "name": "[%key:component::update::title%]" + }, + "firmware": { + "name": "firmware" + } } } diff --git a/homeassistant/components/vacuum/strings.json b/homeassistant/components/vacuum/strings.json index eb84b910b4..a27a60bba4 100644 --- a/homeassistant/components/vacuum/strings.json +++ b/homeassistant/components/vacuum/strings.json @@ -16,6 +16,7 @@ }, "entity_component": { "_": { + "name": "[%key:component::vacuum::title%]", "state": { "cleaning": "Cleaning", "docked": "Docked", diff --git a/homeassistant/components/water_heater/strings.json b/homeassistant/components/water_heater/strings.json index 9e3eec8604..6344b5a847 100644 --- a/homeassistant/components/water_heater/strings.json +++ b/homeassistant/components/water_heater/strings.json @@ -7,6 +7,7 @@ }, "entity_component": { "_": { + "name": "Water heater", "state": { "off": "[%key:common::state::off%]", "eco": "Eco", diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json index b3af53a91c..0f88f1ae7e 100644 --- a/homeassistant/components/weather/strings.json +++ b/homeassistant/components/weather/strings.json @@ -1,6 +1,7 @@ { "entity_component": { "_": { + "name": "Weather", "state": { "clear-night": "Clear, night", "cloudy": "Cloudy", diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index eb51d12c37..bf2697644d 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -266,6 +266,7 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: }, vol.Optional("entity_component"): cv.schema_with_slug_keys( { + vol.Optional("name"): str, vol.Optional("state"): cv.schema_with_slug_keys( cv.string_with_no_html, slug_validator=translation_key_validator, diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index 197053ba2b..6f5b425321 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -383,22 +383,25 @@ async def test_caching(hass: HomeAssistant) -> None: for key in load1: assert key.startswith( - "component.sensor.entity_component._.state." - ) or key.startswith("component.light.entity_component._.state.") + ( + "component.sensor.entity_component.", + "component.light.entity_component.", + ) + ) load_sensor_only = await translation.async_get_translations( hass, "en", "entity_component", integrations={"sensor"} ) assert load_sensor_only for key in load_sensor_only: - assert key.startswith("component.sensor.entity_component._.state.") + assert key.startswith("component.sensor.entity_component.") load_light_only = await translation.async_get_translations( hass, "en", "entity_component", integrations={"light"} ) assert load_light_only for key in load_light_only: - assert key.startswith("component.light.entity_component._.state.") + assert key.startswith("component.light.entity_component.") hass.config.components.add("media_player") From 9893b1cf4ae6b878791cf0cbeedf64120f5794cd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 16 Mar 2023 22:03:06 +0100 Subject: [PATCH 1017/2810] Cleanup get_local_ip in global conftest (#89826) --- tests/conftest.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index c4530bd538..08ca75829c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -31,7 +31,7 @@ import pytest_socket import requests_mock from syrupy.assertion import SnapshotAssertion -from homeassistant import core as ha, loader, runner, util +from homeassistant import core as ha, loader, runner from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY from homeassistant.auth.models import Credentials from homeassistant.auth.providers import homeassistant, legacy_api_password @@ -228,7 +228,6 @@ def check_real(func: Callable[_P, Coroutine[Any, Any, _R]]): # Guard a few functions that would make network connections location.async_detect_location_info = check_real(location.async_detect_location_info) -util.get_local_ip = lambda: "127.0.0.1" @pytest.fixture(name="caplog") From 81c0382e4b38d6d0045766684c2a0fa6ffb28ad6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 16 Mar 2023 22:20:27 +0100 Subject: [PATCH 1018/2810] Fix lingering timer in bootstrap tests (#89790) * Fix lingering timer in bootstrap test * Adjust comment * Use a constant --- homeassistant/components/cloud/__init__.py | 3 ++- tests/test_bootstrap.py | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index e9b852ada8..8352b566af 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -68,6 +68,7 @@ SERVICE_REMOTE_DISCONNECT = "remote_disconnect" SIGNAL_CLOUD_CONNECTION_STATE = "CLOUD_CONNECTION_STATE" +STARTUP_REPAIR_DELAY = 1 # 1 hour ALEXA_ENTITY_SCHEMA = vol.Schema( { @@ -309,7 +310,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_call_later( hass=hass, - delay=timedelta(hours=1), + delay=timedelta(hours=STARTUP_REPAIR_DELAY), action=async_startup_repairs, ) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 1ee60b71da..9f02d6394e 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -551,6 +551,7 @@ async def test_setup_hass_takes_longer_than_log_slow_startup( assert "Waiting on integrations to complete setup" in caplog.text +@patch("homeassistant.components.cloud.STARTUP_REPAIR_DELAY", 0) async def test_setup_hass_invalid_yaml( mock_enable_logging: Mock, mock_is_virtual_env: Mock, @@ -606,6 +607,7 @@ async def test_setup_hass_config_dir_nonexistent( ) +@patch("homeassistant.components.cloud.STARTUP_REPAIR_DELAY", 0) async def test_setup_hass_safe_mode( mock_enable_logging: Mock, mock_is_virtual_env: Mock, @@ -640,6 +642,7 @@ async def test_setup_hass_safe_mode( @pytest.mark.parametrize("hass_config", [{"homeassistant": {"non-existing": 1}}]) +@patch("homeassistant.components.cloud.STARTUP_REPAIR_DELAY", 0) async def test_setup_hass_invalid_core_config( mock_hass_config: None, mock_enable_logging: Mock, @@ -678,6 +681,7 @@ async def test_setup_hass_invalid_core_config( } ], ) +@patch("homeassistant.components.cloud.STARTUP_REPAIR_DELAY", 0) async def test_setup_safe_mode_if_no_frontend( mock_hass_config: None, mock_enable_logging: Mock, From e16f17f5a8c4c6ee86475272a32170bddaf631e6 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 16 Mar 2023 19:42:26 -0500 Subject: [PATCH 1019/2810] Voice assistant integration with pipelines (#89822) * Initial commit * Add websocket test tool * Small tweak * Tiny cleanup * Make pipeline work with frontend branch * Add some more info to start event * Fixes * First voice assistant tests * Remove run_task * Clean up for PR * Add config_flow.py * Remove CLI tool * Simplify by removing stt/tts for now * Clean up and fix tests * More clean up and API changes * Add quality_scale * Remove data from run-finish * Use StrEnum backport --------- Co-authored-by: Paulus Schoutsen --- CODEOWNERS | 2 + .../components/voice_assistant/__init__.py | 23 +++ .../components/voice_assistant/const.py | 3 + .../components/voice_assistant/manifest.json | 9 ++ .../components/voice_assistant/pipeline.py | 124 ++++++++++++++ .../voice_assistant/websocket_api.py | 67 ++++++++ .../components/websocket_api/connection.py | 5 + homeassistant/generated/integrations.json | 6 + tests/components/voice_assistant/__init__.py | 1 + .../voice_assistant/test_websocket.py | 152 ++++++++++++++++++ 10 files changed, 392 insertions(+) create mode 100644 homeassistant/components/voice_assistant/__init__.py create mode 100644 homeassistant/components/voice_assistant/const.py create mode 100644 homeassistant/components/voice_assistant/manifest.json create mode 100644 homeassistant/components/voice_assistant/pipeline.py create mode 100644 homeassistant/components/voice_assistant/websocket_api.py create mode 100644 tests/components/voice_assistant/__init__.py create mode 100644 tests/components/voice_assistant/test_websocket.py diff --git a/CODEOWNERS b/CODEOWNERS index f95d89fec4..afab5f8885 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1309,6 +1309,8 @@ build.json @home-assistant/supervisor /tests/components/vizio/ @raman325 /homeassistant/components/vlc_telnet/ @rodripf @MartinHjelmare /tests/components/vlc_telnet/ @rodripf @MartinHjelmare +/homeassistant/components/voice_assistant/ @balloob @synesthesiam +/tests/components/voice_assistant/ @balloob @synesthesiam /homeassistant/components/volumio/ @OnFreund /tests/components/volumio/ @OnFreund /homeassistant/components/volvooncall/ @molobrakos diff --git a/homeassistant/components/voice_assistant/__init__.py b/homeassistant/components/voice_assistant/__init__.py new file mode 100644 index 0000000000..d06176847e --- /dev/null +++ b/homeassistant/components/voice_assistant/__init__.py @@ -0,0 +1,23 @@ +"""The Voice Assistant integration.""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType + +from .const import DEFAULT_PIPELINE, DOMAIN +from .pipeline import Pipeline +from .websocket_api import async_register_websocket_api + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Voice Assistant integration.""" + hass.data[DOMAIN] = { + DEFAULT_PIPELINE: Pipeline( + name=DEFAULT_PIPELINE, + language=None, + conversation_engine=None, + ) + } + async_register_websocket_api(hass) + + return True diff --git a/homeassistant/components/voice_assistant/const.py b/homeassistant/components/voice_assistant/const.py new file mode 100644 index 0000000000..86572fb459 --- /dev/null +++ b/homeassistant/components/voice_assistant/const.py @@ -0,0 +1,3 @@ +"""Constants for the Voice Assistant integration.""" +DOMAIN = "voice_assistant" +DEFAULT_PIPELINE = "default" diff --git a/homeassistant/components/voice_assistant/manifest.json b/homeassistant/components/voice_assistant/manifest.json new file mode 100644 index 0000000000..6d353660b3 --- /dev/null +++ b/homeassistant/components/voice_assistant/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "voice_assistant", + "name": "Voice Assistant", + "codeowners": ["@balloob", "@synesthesiam"], + "dependencies": ["conversation"], + "documentation": "https://www.home-assistant.io/integrations/voice_assistant", + "iot_class": "local_push", + "quality_scale": "internal" +} diff --git a/homeassistant/components/voice_assistant/pipeline.py b/homeassistant/components/voice_assistant/pipeline.py new file mode 100644 index 0000000000..8c7d22981a --- /dev/null +++ b/homeassistant/components/voice_assistant/pipeline.py @@ -0,0 +1,124 @@ +"""Classes for voice assistant pipelines.""" +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any + +from homeassistant.backports.enum import StrEnum +from homeassistant.components import conversation +from homeassistant.core import Context, HomeAssistant +from homeassistant.util.dt import utcnow + +DEFAULT_TIMEOUT = 30 # seconds + + +@dataclass +class PipelineRequest: + """Request to start a pipeline run.""" + + intent_input: str + conversation_id: str | None = None + + +class PipelineEventType(StrEnum): + """Event types emitted during a pipeline run.""" + + RUN_START = "run-start" + RUN_FINISH = "run-finish" + INTENT_START = "intent-start" + INTENT_FINISH = "intent-finish" + ERROR = "error" + + +@dataclass +class PipelineEvent: + """Events emitted during a pipeline run.""" + + type: PipelineEventType + data: dict[str, Any] | None = None + timestamp: str = field(default_factory=lambda: utcnow().isoformat()) + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the event.""" + return { + "type": self.type, + "timestamp": self.timestamp, + "data": self.data or {}, + } + + +@dataclass +class Pipeline: + """A voice assistant pipeline.""" + + name: str + language: str | None + conversation_engine: str | None + + async def run( + self, + hass: HomeAssistant, + context: Context, + request: PipelineRequest, + event_callback: Callable[[PipelineEvent], None], + timeout: int | float | None = DEFAULT_TIMEOUT, + ) -> None: + """Run a pipeline with an optional timeout.""" + await asyncio.wait_for( + self._run(hass, context, request, event_callback), timeout=timeout + ) + + async def _run( + self, + hass: HomeAssistant, + context: Context, + request: PipelineRequest, + event_callback: Callable[[PipelineEvent], None], + ) -> None: + """Run a pipeline.""" + language = self.language or hass.config.language + event_callback( + PipelineEvent( + PipelineEventType.RUN_START, + { + "pipeline": self.name, + "language": language, + }, + ) + ) + + intent_input = request.intent_input + + event_callback( + PipelineEvent( + PipelineEventType.INTENT_START, + { + "engine": self.conversation_engine or "default", + "intent_input": intent_input, + }, + ) + ) + + conversation_result = await conversation.async_converse( + hass=hass, + text=intent_input, + conversation_id=request.conversation_id, + context=context, + language=language, + agent_id=self.conversation_engine, + ) + + event_callback( + PipelineEvent( + PipelineEventType.INTENT_FINISH, + {"intent_output": conversation_result.as_dict()}, + ) + ) + + event_callback( + PipelineEvent( + PipelineEventType.RUN_FINISH, + ) + ) diff --git a/homeassistant/components/voice_assistant/websocket_api.py b/homeassistant/components/voice_assistant/websocket_api.py new file mode 100644 index 0000000000..4ea88c3da0 --- /dev/null +++ b/homeassistant/components/voice_assistant/websocket_api.py @@ -0,0 +1,67 @@ +"""Voice Assistant Websocket API.""" +from typing import Any + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback + +from .const import DOMAIN +from .pipeline import DEFAULT_TIMEOUT, PipelineRequest + + +@callback +def async_register_websocket_api(hass: HomeAssistant) -> None: + """Register the websocket API.""" + websocket_api.async_register_command(hass, websocket_run) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "voice_assistant/run", + vol.Optional("pipeline", default="default"): str, + vol.Required("intent_input"): str, + vol.Optional("conversation_id"): vol.Any(str, None), + vol.Optional("timeout"): vol.Any(float, int), + } +) +@websocket_api.async_response +async def websocket_run( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Run a pipeline.""" + pipeline_id = msg["pipeline"] + pipeline = hass.data[DOMAIN].get(pipeline_id) + if pipeline is None: + connection.send_error( + msg["id"], "pipeline_not_found", f"Pipeline not found: {pipeline_id}" + ) + return + + # Run pipeline with a timeout. + # Events are sent over the websocket connection. + timeout = msg.get("timeout", DEFAULT_TIMEOUT) + run_task = hass.async_create_task( + pipeline.run( + hass, + connection.context(msg), + request=PipelineRequest( + intent_input=msg["intent_input"], + conversation_id=msg.get("conversation_id"), + ), + event_callback=lambda event: connection.send_event( + msg["id"], event.as_dict() + ), + timeout=timeout, + ) + ) + + # Cancel pipeline if user unsubscribes + connection.subscriptions[msg["id"]] = run_task.cancel + + connection.send_result(msg["id"]) + + # Task contains a timeout + await run_task diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 90c7e9906e..08d0531452 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -65,6 +65,11 @@ class ActiveConnection: """Send a result message.""" self.send_message(messages.result_message(msg_id, result)) + @callback + def send_event(self, msg_id: int, event: Any | None = None) -> None: + """Send a event message.""" + self.send_message(messages.event_message(msg_id, event)) + @callback def send_error(self, msg_id: int, code: str, message: str) -> None: """Send a error message.""" diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 4adde5b244..8dbdd5a785 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6068,6 +6068,12 @@ } } }, + "voice_assistant": { + "name": "Voice Assistant", + "integration_type": "hub", + "config_flow": false, + "iot_class": "local_push" + }, "voicerss": { "name": "VoiceRSS", "integration_type": "hub", diff --git a/tests/components/voice_assistant/__init__.py b/tests/components/voice_assistant/__init__.py new file mode 100644 index 0000000000..6838f353c4 --- /dev/null +++ b/tests/components/voice_assistant/__init__.py @@ -0,0 +1 @@ +"""Tests for the Voice Assistant integration.""" diff --git a/tests/components/voice_assistant/test_websocket.py b/tests/components/voice_assistant/test_websocket.py new file mode 100644 index 0000000000..e862da6f54 --- /dev/null +++ b/tests/components/voice_assistant/test_websocket.py @@ -0,0 +1,152 @@ +"""Websocket tests for Voice Assistant integration.""" +import asyncio +from unittest.mock import patch + +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.typing import WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def init_components(hass): + """Initialize relevant components with empty configs.""" + assert await async_setup_component(hass, "voice_assistant", {}) + + +async def test_text_only_pipeline( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test events from a pipeline run with text input (no STT/TTS).""" + client = await hass_ws_client(hass) + + await client.send_json( + {"id": 5, "type": "voice_assistant/run", "intent_input": "Are the lights on?"} + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + assert msg["event"]["data"] == { + "pipeline": "default", + "language": hass.config.language, + } + + # intent + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-start" + assert msg["event"]["data"] == { + "engine": "default", + "intent_input": "Are the lights on?", + } + + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-finish" + assert msg["event"]["data"] == { + "intent_output": { + "response": { + "speech": { + "plain": { + "speech": "Sorry, I couldn't understand that", + "extra_data": None, + } + }, + "card": {}, + "language": "en", + "response_type": "error", + "data": {"code": "no_intent_match"}, + }, + "conversation_id": None, + } + } + + # run finish + msg = await client.receive_json() + assert msg["event"]["type"] == "run-finish" + assert msg["event"]["data"] == {} + + +async def test_conversation_timeout( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components +) -> None: + """Test partial pipeline run with conversation agent timeout.""" + client = await hass_ws_client(hass) + + async def sleepy_converse(*args, **kwargs): + await asyncio.sleep(3600) + + with patch( + "homeassistant.components.conversation.async_converse", new=sleepy_converse + ): + await client.send_json( + { + "id": 5, + "type": "voice_assistant/run", + "intent_input": "Are the lights on?", + "timeout": 0.00001, + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + assert msg["event"]["data"] == { + "pipeline": "default", + "language": hass.config.language, + } + + # intent + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-start" + assert msg["event"]["data"] == { + "engine": "default", + "intent_input": "Are the lights on?", + } + + # timeout error + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "timeout" + + +async def test_pipeline_timeout( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components +) -> None: + """Test pipeline run with immediate timeout.""" + client = await hass_ws_client(hass) + + async def sleepy_run(*args, **kwargs): + await asyncio.sleep(3600) + + with patch( + "homeassistant.components.voice_assistant.pipeline.Pipeline._run", + new=sleepy_run, + ): + await client.send_json( + { + "id": 5, + "type": "voice_assistant/run", + "intent_input": "Are the lights on?", + "timeout": 0.0001, + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # timeout error + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "timeout" From ff8b91aeea5bbb9709164d4218a110ef42bd87b1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 17 Mar 2023 03:39:41 +0100 Subject: [PATCH 1020/2810] Add freezer to known test fixtures in pylint (#89825) Add freezer to known fixtures in pylint --- pylint/plugins/hass_enforce_type_hints.py | 1 + tests/components/calendar/test_trigger.py | 3 +- tests/components/demo/test_sensor.py | 9 ++++-- .../devolo_home_control/test_init.py | 2 +- .../components/hardware/test_websocket_api.py | 5 +++- .../homeassistant_alerts/test_init.py | 3 +- tests/components/mqtt/test_binary_sensor.py | 5 ++-- tests/components/mqtt/test_sensor.py | 5 ++-- tests/components/onewire/test_init.py | 2 +- tests/components/recorder/test_init.py | 3 +- tests/components/repairs/test_init.py | 5 +++- tests/components/sql/test_util.py | 10 +++---- .../components/template/test_binary_sensor.py | 10 +++++-- tests/components/tod/test_binary_sensor.py | 29 ++++++++++++------- 14 files changed, 61 insertions(+), 31 deletions(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 3e390e2810..4092beb3da 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -104,6 +104,7 @@ _TEST_FIXTURES: dict[str, list[str] | str] = { "enable_statistics": "bool", "enable_statistics_table_validation": "bool", "entity_registry": "EntityRegistry", + "freezer": "FrozenDateTimeFactory", "hass_access_token": "str", "hass_admin_credential": "Credentials", "hass_admin_user": "MockUser", diff --git a/tests/components/calendar/test_trigger.py b/tests/components/calendar/test_trigger.py index a5bba8794d..7885a4524c 100644 --- a/tests/components/calendar/test_trigger.py +++ b/tests/components/calendar/test_trigger.py @@ -15,6 +15,7 @@ import secrets from typing import Any from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components import calendar @@ -642,7 +643,7 @@ async def test_event_payload( async def test_trigger_timestamp_window_edge( - hass: HomeAssistant, calls, fake_schedule, freezer + hass: HomeAssistant, calls, fake_schedule, freezer: FrozenDateTimeFactory ) -> None: """Test that events in the edge of a scan are included.""" freezer.move_to("2022-04-19 11:00:00+00:00") diff --git a/tests/components/demo/test_sensor.py b/tests/components/demo/test_sensor.py index 3d96e9f934..71c212694c 100644 --- a/tests/components/demo/test_sensor.py +++ b/tests/components/demo/test_sensor.py @@ -1,6 +1,7 @@ """The tests for the demo sensor component.""" from datetime import timedelta +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant import core as ha @@ -13,7 +14,9 @@ from tests.common import mock_restore_cache_with_extra_data @pytest.mark.parametrize(("entity_id", "delta"), (("sensor.total_energy_kwh", 0.5),)) -async def test_energy_sensor(hass: HomeAssistant, entity_id, delta, freezer) -> None: +async def test_energy_sensor( + hass: HomeAssistant, entity_id, delta, freezer: FrozenDateTimeFactory +) -> None: """Test energy sensors increase periodically.""" assert await async_setup_component( hass, SENSOR_DOMAIN, {SENSOR_DOMAIN: {"platform": DOMAIN}} @@ -32,7 +35,9 @@ async def test_energy_sensor(hass: HomeAssistant, entity_id, delta, freezer) -> @pytest.mark.parametrize(("entity_id", "delta"), (("sensor.total_energy_kwh", 0.5),)) -async def test_restore_state(hass: HomeAssistant, entity_id, delta, freezer) -> None: +async def test_restore_state( + hass: HomeAssistant, entity_id, delta, freezer: FrozenDateTimeFactory +) -> None: """Test energy sensors restore state.""" fake_state = ha.State( entity_id, diff --git a/tests/components/devolo_home_control/test_init.py b/tests/components/devolo_home_control/test_init.py index 0eb011d5ef..29572f2ece 100644 --- a/tests/components/devolo_home_control/test_init.py +++ b/tests/components/devolo_home_control/test_init.py @@ -65,7 +65,7 @@ async def test_unload_entry(hass: HomeAssistant) -> None: async def test_remove_device( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, -): +) -> None: """Test removing a device.""" assert await async_setup_component(hass, "config", {}) entry = configure_integration(hass) diff --git a/tests/components/hardware/test_websocket_api.py b/tests/components/hardware/test_websocket_api.py index b3a3e9ba11..98fa00486f 100644 --- a/tests/components/hardware/test_websocket_api.py +++ b/tests/components/hardware/test_websocket_api.py @@ -3,6 +3,7 @@ from collections import namedtuple import datetime from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import psutil_home_assistant as ha_psutil from homeassistant.components.hardware.const import DOMAIN @@ -33,7 +34,9 @@ TEST_TIME_ADVANCE_INTERVAL = datetime.timedelta(seconds=5 + 1) async def test_system_status_subscription( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, freezer + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, ) -> None: """Test websocket system status subscription.""" diff --git a/tests/components/homeassistant_alerts/test_init.py b/tests/components/homeassistant_alerts/test_init.py index 36f0cad758..7f060b09cf 100644 --- a/tests/components/homeassistant_alerts/test_init.py +++ b/tests/components/homeassistant_alerts/test_init.py @@ -5,6 +5,7 @@ from datetime import timedelta import json from unittest.mock import ANY, patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.homeassistant_alerts import ( @@ -291,7 +292,7 @@ async def test_alerts_refreshed_on_component_load( late_components, initial_alerts, late_alerts, - freezer, + freezer: FrozenDateTimeFactory, ) -> None: """Test alerts are refreshed when components are loaded.""" diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index a088d2ac64..73176de9ed 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -5,6 +5,7 @@ import json from pathlib import Path from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components import binary_sensor, mqtt @@ -1088,7 +1089,7 @@ async def test_cleanup_triggers_and_restoring_state( mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, tmp_path: Path, - freezer, + freezer: FrozenDateTimeFactory, payload1, state1, payload2, @@ -1147,7 +1148,7 @@ async def test_cleanup_triggers_and_restoring_state( async def test_skip_restoring_state_with_over_due_expire_trigger( hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - freezer, + freezer: FrozenDateTimeFactory, ) -> None: """Test restoring a state with over due expire timer.""" diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index b40c2f43d0..c1bc07f77a 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -5,6 +5,7 @@ import json from pathlib import Path from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components import mqtt, sensor @@ -1278,7 +1279,7 @@ async def test_cleanup_triggers_and_restoring_state( mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, tmp_path: Path, - freezer, + freezer: FrozenDateTimeFactory, ) -> None: """Test cleanup old triggers at reloading and restoring the state.""" domain = sensor.DOMAIN @@ -1338,7 +1339,7 @@ async def test_cleanup_triggers_and_restoring_state( async def test_skip_restoring_state_with_over_due_expire_trigger( hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - freezer, + freezer: FrozenDateTimeFactory, ) -> None: """Test restoring a state with over due expire timer.""" diff --git a/tests/components/onewire/test_init.py b/tests/components/onewire/test_init.py index 9382b95521..5a69fb95e1 100644 --- a/tests/components/onewire/test_init.py +++ b/tests/components/onewire/test_init.py @@ -80,7 +80,7 @@ async def test_registry_cleanup( config_entry: ConfigEntry, owproxy: MagicMock, hass_ws_client: WebSocketGenerator, -): +) -> None: """Test being able to remove a disconnected device.""" assert await async_setup_component(hass, "config", {}) diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index ed804087d8..e3b9145dc8 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -10,6 +10,7 @@ import threading from typing import cast from unittest.mock import Mock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from sqlalchemy.exc import DatabaseError, OperationalError, SQLAlchemyError @@ -1219,7 +1220,7 @@ def test_statistics_runs_initiated(hass_recorder: Callable[..., HomeAssistant]) @pytest.mark.freeze_time("2022-09-13 09:00:00+02:00") -def test_compile_missing_statistics(tmpdir, freezer) -> None: +def test_compile_missing_statistics(tmpdir, freezer: FrozenDateTimeFactory) -> None: """Test missing statistics are compiled on startup.""" now = dt_util.utcnow().replace(minute=0, second=0, microsecond=0) test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") diff --git a/tests/components/repairs/test_init.py b/tests/components/repairs/test_init.py index 87acc96db2..bae71e71e2 100644 --- a/tests/components/repairs/test_init.py +++ b/tests/components/repairs/test_init.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, Mock from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.repairs import repairs_flow_manager @@ -335,7 +336,9 @@ async def test_ignore_issue( async def test_delete_issue( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, freezer + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, ) -> None: """Test we can delete an issue.""" freezer.move_to("2022-07-19 07:53:05") diff --git a/tests/components/sql/test_util.py b/tests/components/sql/test_util.py index 31adbe076e..5211a47c4d 100644 --- a/tests/components/sql/test_util.py +++ b/tests/components/sql/test_util.py @@ -1,15 +1,13 @@ """Test the sql utils.""" -from unittest.mock import AsyncMock - -from homeassistant.components.recorder import get_instance +from homeassistant.components.recorder import Recorder, get_instance from homeassistant.components.sql.util import resolve_db_url from homeassistant.core import HomeAssistant async def test_resolve_db_url_when_none_configured( - recorder_mock: AsyncMock, + recorder_mock: Recorder, hass: HomeAssistant, -): +) -> None: """Test return recorder db_url if provided db_url is None.""" db_url = None resolved_url = resolve_db_url(hass, db_url) @@ -17,7 +15,7 @@ async def test_resolve_db_url_when_none_configured( assert resolved_url == get_instance(hass).db_url -async def test_resolve_db_url_when_configured(hass: HomeAssistant): +async def test_resolve_db_url_when_configured(hass: HomeAssistant) -> None: """Test return provided db_url if it's set.""" db_url = "mssql://" resolved_url = resolve_db_url(hass, db_url) diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index 6483524545..1e6b2cc384 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta, timezone import logging from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant import setup @@ -1257,7 +1258,12 @@ async def test_trigger_entity_restore_state( ) @pytest.mark.parametrize("restored_state", [ON, OFF]) async def test_trigger_entity_restore_state_auto_off( - hass: HomeAssistant, count, domain, config, restored_state, freezer + hass: HomeAssistant, + count, + domain, + config, + restored_state, + freezer: FrozenDateTimeFactory, ) -> None: """Test restoring trigger template binary sensor.""" @@ -1317,7 +1323,7 @@ async def test_trigger_entity_restore_state_auto_off( ], ) async def test_trigger_entity_restore_state_auto_off_expired( - hass: HomeAssistant, count, domain, config, freezer + hass: HomeAssistant, count, domain, config, freezer: FrozenDateTimeFactory ) -> None: """Test restoring trigger template binary sensor.""" diff --git a/tests/components/tod/test_binary_sensor.py b/tests/components/tod/test_binary_sensor.py index 23b7625016..0f0a145645 100644 --- a/tests/components/tod/test_binary_sensor.py +++ b/tests/components/tod/test_binary_sensor.py @@ -2,6 +2,7 @@ from datetime import datetime, timedelta from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.const import STATE_OFF, STATE_ON @@ -104,7 +105,7 @@ async def test_midnight_turnover_before_midnight_inside_period( async def test_midnight_turnover_after_midnight_inside_period( - hass: HomeAssistant, freezer, hass_tz_info + hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_tz_info ) -> None: """Test midnight turnover setting before midnight inside period .""" test_time = datetime(2019, 1, 10, 21, 0, 0, tzinfo=hass_tz_info) @@ -163,7 +164,7 @@ async def test_after_happens_tomorrow(hass: HomeAssistant) -> None: async def test_midnight_turnover_after_midnight_outside_period( - hass: HomeAssistant, freezer, hass_tz_info + hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_tz_info ) -> None: """Test midnight turnover setting before midnight inside period .""" test_time = datetime(2019, 1, 10, 20, 0, 0, tzinfo=hass_tz_info) @@ -197,7 +198,7 @@ async def test_midnight_turnover_after_midnight_outside_period( async def test_from_sunrise_to_sunset( - hass: HomeAssistant, freezer, hass_tz_info + hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_tz_info ) -> None: """Test period from sunrise to sunset.""" test_time = datetime(2019, 1, 12, tzinfo=hass_tz_info) @@ -256,7 +257,7 @@ async def test_from_sunrise_to_sunset( async def test_from_sunset_to_sunrise( - hass: HomeAssistant, freezer, hass_tz_info + hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_tz_info ) -> None: """Test period from sunset to sunrise.""" test_time = datetime(2019, 1, 12, tzinfo=hass_tz_info) @@ -311,7 +312,9 @@ async def test_from_sunset_to_sunrise( assert state.state == STATE_OFF -async def test_offset(hass: HomeAssistant, freezer, hass_tz_info) -> None: +async def test_offset( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_tz_info +) -> None: """Test offset.""" after = datetime(2019, 1, 10, 18, 0, 0, tzinfo=hass_tz_info) + timedelta( hours=1, minutes=34 @@ -365,7 +368,9 @@ async def test_offset(hass: HomeAssistant, freezer, hass_tz_info) -> None: assert state.state == STATE_OFF -async def test_offset_overnight(hass: HomeAssistant, freezer, hass_tz_info) -> None: +async def test_offset_overnight( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_tz_info +) -> None: """Test offset overnight.""" after = datetime(2019, 1, 10, 18, 0, 0, tzinfo=hass_tz_info) + timedelta( hours=1, minutes=34 @@ -397,7 +402,7 @@ async def test_offset_overnight(hass: HomeAssistant, freezer, hass_tz_info) -> N async def test_norwegian_case_winter( - hass: HomeAssistant, freezer, hass_tz_info + hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_tz_info ) -> None: """Test location in Norway where the sun doesn't set in summer.""" hass.config.latitude = 69.6 @@ -465,7 +470,7 @@ async def test_norwegian_case_winter( async def test_norwegian_case_summer( - hass: HomeAssistant, freezer, hass_tz_info + hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_tz_info ) -> None: """Test location in Norway where the sun doesn't set in summer.""" hass.config.latitude = 69.6 @@ -534,7 +539,9 @@ async def test_norwegian_case_summer( assert state.state == STATE_OFF -async def test_sun_offset(hass: HomeAssistant, freezer, hass_tz_info) -> None: +async def test_sun_offset( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_tz_info +) -> None: """Test sun event with offset.""" test_time = datetime(2019, 1, 12, tzinfo=hass_tz_info) sunrise = dt_util.as_local( @@ -608,7 +615,9 @@ async def test_sun_offset(hass: HomeAssistant, freezer, hass_tz_info) -> None: assert state.state == STATE_ON -async def test_dst(hass: HomeAssistant, freezer, hass_tz_info) -> None: +async def test_dst( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_tz_info +) -> None: """Test sun event with offset.""" hass.config.time_zone = "CET" dt_util.set_default_time_zone(dt_util.get_time_zone("CET")) From 350e967a894b93a509658618d6c1955e05e00eb1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Mar 2023 16:44:49 -1000 Subject: [PATCH 1021/2810] Bump aioharmony to 0.2.10 (#89831) fixes #89823 --- homeassistant/components/harmony/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json index 2603ee613a..c6a6327046 100644 --- a/homeassistant/components/harmony/manifest.json +++ b/homeassistant/components/harmony/manifest.json @@ -13,7 +13,7 @@ "documentation": "https://www.home-assistant.io/integrations/harmony", "iot_class": "local_push", "loggers": ["aioharmony", "slixmpp"], - "requirements": ["aioharmony==0.2.9"], + "requirements": ["aioharmony==0.2.10"], "ssdp": [ { "manufacturer": "Logitech", diff --git a/requirements_all.txt b/requirements_all.txt index 6c7fbf82bc..5d1a0b634d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -171,7 +171,7 @@ aiogithubapi==22.10.1 aioguardian==2022.07.0 # homeassistant.components.harmony -aioharmony==0.2.9 +aioharmony==0.2.10 # homeassistant.components.homekit_controller aiohomekit==2.6.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 53477f0912..ffca3813dd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -158,7 +158,7 @@ aiogithubapi==22.10.1 aioguardian==2022.07.0 # homeassistant.components.harmony -aioharmony==0.2.9 +aioharmony==0.2.10 # homeassistant.components.homekit_controller aiohomekit==2.6.1 From f8da3ee50edfc5247e2cd48e61b2473b38317127 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 17 Mar 2023 04:00:45 +0100 Subject: [PATCH 1022/2810] Add state attribute translations for locks (#89820) --- homeassistant/components/lock/strings.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/lock/strings.json b/homeassistant/components/lock/strings.json index 497dec1a40..da4b5217b8 100644 --- a/homeassistant/components/lock/strings.json +++ b/homeassistant/components/lock/strings.json @@ -24,6 +24,14 @@ "locking": "Locking", "unlocked": "[%key:common::state::unlocked%]", "unlocking": "Unlocking" + }, + "state_attributes": { + "code_format": { + "name": "[%key:component::alarm_control_panel::entity_component::_::state_attributes::code_format::name%]" + }, + "changed_by": { + "name": "[%key:component::alarm_control_panel::entity_component::_::state_attributes::changed_by::name%]" + } } } } From 79c9447770a1cfcbdf3643e3ab9ea633f52887d5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 17 Mar 2023 04:01:23 +0100 Subject: [PATCH 1023/2810] Fix lingering timer in event helper tests (#89819) Fix lingering timer in event tests --- tests/helpers/test_event.py | 40 ++++++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index afc960f4e1..211663babc 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -935,7 +935,7 @@ async def test_track_template_time_change( with patch( "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away ): - async_track_template(hass, template_error, error_callback) + unsub = async_track_template(hass, template_error, error_callback) await hass.async_block_till_done() assert not calls @@ -947,6 +947,8 @@ async def test_track_template_time_change( assert len(calls) == 1 assert calls[0] == (None, None, None) + unsub() + async def test_track_template_result(hass: HomeAssistant) -> None: """Test tracking template.""" @@ -1437,7 +1439,7 @@ async def test_track_template_result_super_template_2( _super_template_as_boolean(track_result.result) ) - async_track_template_result( + info = async_track_template_result( hass, [ TrackTemplate(template_availability, None), @@ -1459,7 +1461,7 @@ async def test_track_template_result_super_template_2( _super_template_as_boolean(track_result.result) ) - async_track_template_result( + info2 = async_track_template_result( hass, [ TrackTemplate(template_availability, None), @@ -1480,7 +1482,7 @@ async def test_track_template_result_super_template_2( _super_template_as_boolean(track_result.result) ) - async_track_template_result( + info3 = async_track_template_result( hass, [ TrackTemplate(template_availability, None), @@ -1530,6 +1532,10 @@ async def test_track_template_result_super_template_2( assert wildcard_runs == [(0, 5), (5, 30)] assert wildercard_runs == [(0, 10), (10, 35)] + info.async_remove() + info2.async_remove() + info3.async_remove() + @pytest.mark.parametrize( "availability_template", @@ -1579,7 +1585,7 @@ async def test_track_template_result_super_template_2_initially_false( _super_template_as_boolean(track_result.result) ) - async_track_template_result( + info = async_track_template_result( hass, [ TrackTemplate(template_availability, None), @@ -1601,7 +1607,7 @@ async def test_track_template_result_super_template_2_initially_false( _super_template_as_boolean(track_result.result) ) - async_track_template_result( + info2 = async_track_template_result( hass, [ TrackTemplate(template_availability, None), @@ -1622,7 +1628,7 @@ async def test_track_template_result_super_template_2_initially_false( _super_template_as_boolean(track_result.result) ) - async_track_template_result( + info3 = async_track_template_result( hass, [ TrackTemplate(template_availability, None), @@ -1669,6 +1675,10 @@ async def test_track_template_result_super_template_2_initially_false( assert wildcard_runs == [(0, 5), (5, 30)] assert wildercard_runs == [(0, 10), (10, 35)] + info.async_remove() + info2.async_remove() + info3.async_remove() + async def test_track_template_result_complex(hass: HomeAssistant) -> None: """Test tracking template.""" @@ -2351,6 +2361,8 @@ async def test_track_template_rate_limit(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert refresh_runs == [0, 1, 2, 4] + info.async_remove() + async def test_track_template_rate_limit_super(hass: HomeAssistant) -> None: """Test template rate limit with super template.""" @@ -2423,6 +2435,8 @@ async def test_track_template_rate_limit_super(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert refresh_runs == [0, 1, 4] + info.async_remove() + async def test_track_template_rate_limit_super_2(hass: HomeAssistant) -> None: """Test template rate limit with rate limited super template.""" @@ -2490,6 +2504,8 @@ async def test_track_template_rate_limit_super_2(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert refresh_runs == [1, 5] + info.async_remove() + async def test_track_template_rate_limit_super_3(hass: HomeAssistant) -> None: """Test template with rate limited super template.""" @@ -2562,6 +2578,8 @@ async def test_track_template_rate_limit_super_3(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert refresh_runs == [1, 2, 5, 6, 7] + info.async_remove() + async def test_track_template_rate_limit_suppress_listener(hass: HomeAssistant) -> None: """Test template rate limit will suppress the listener during the rate limit.""" @@ -2657,6 +2675,8 @@ async def test_track_template_rate_limit_suppress_listener(hass: HomeAssistant) } assert refresh_runs == [0, 1, 2, 4] + info.async_remove() + async def test_track_template_rate_limit_five(hass: HomeAssistant) -> None: """Test template rate limit of 5 seconds.""" @@ -2690,6 +2710,8 @@ async def test_track_template_rate_limit_five(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert refresh_runs == [0, 1] + info.async_remove() + async def test_track_template_has_default_rate_limit(hass: HomeAssistant) -> None: """Test template has a rate limit by default.""" @@ -2724,6 +2746,8 @@ async def test_track_template_has_default_rate_limit(hass: HomeAssistant) -> Non await hass.async_block_till_done() assert refresh_runs == [1, 2] + info.async_remove() + async def test_track_template_unavailable_states_has_default_rate_limit( hass: HomeAssistant, @@ -3311,6 +3335,8 @@ async def test_async_track_template_result_multiple_templates_mixing_listeners( ] ] + info.async_remove() + async def test_track_same_state_simple_no_trigger(hass: HomeAssistant) -> None: """Test track_same_change with no trigger.""" From a2b6ef3d7b4ad97308eb39f63fa2fa961772613d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 17 Mar 2023 04:01:47 +0100 Subject: [PATCH 1024/2810] Add state attribute translations for fans (#89816) --- homeassistant/components/fan/strings.json | 24 +++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/homeassistant/components/fan/strings.json b/homeassistant/components/fan/strings.json index 9aae5cf164..e9a808e0ae 100644 --- a/homeassistant/components/fan/strings.json +++ b/homeassistant/components/fan/strings.json @@ -22,6 +22,30 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" + }, + "state_attributes": { + "direction": { + "name": "Direction", + "state": { + "forward": "Forward", + "reverse": "Reverse" + } + }, + "oscillating": { + "name": "Oscillating" + }, + "percentage": { + "name": "Speed" + }, + "percentage_step": { + "name": "Speed step" + }, + "preset_modes": { + "name": "Available preset modes" + }, + "preset_mode": { + "name": "Preset mode" + } } } } From a153720599820d0a7101f4c6248548deb71387fb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 17 Mar 2023 04:02:11 +0100 Subject: [PATCH 1025/2810] Add state attribute translations for automations (#89815) --- .../components/automation/strings.json | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/homeassistant/components/automation/strings.json b/homeassistant/components/automation/strings.json index 1663443f04..4e433119a2 100644 --- a/homeassistant/components/automation/strings.json +++ b/homeassistant/components/automation/strings.json @@ -6,6 +6,29 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" + }, + "state_attributes": { + "current": { + "name": "Running automations" + }, + "id": { + "name": "ID" + }, + "last_triggered": { + "name": "Last triggered" + }, + "max": { + "name": "Max running automations" + }, + "mode": { + "name": "Run mode", + "state": { + "parallel": "Parallel", + "queued": "Queued", + "restart": "Restart", + "single": "Single" + } + } } } }, From ae127e76876a8c75387e98c3de63d87f21f8e9af Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Mar 2023 04:02:56 +0100 Subject: [PATCH 1026/2810] Change light white service call attribute to accept True (#89803) --- homeassistant/components/light/__init__.py | 8 ++++++- homeassistant/components/light/services.yaml | 23 ++++++++++++++------ tests/components/light/test_init.py | 20 +++++++++++++++++ 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index d8543946df..2af959d22f 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -276,7 +276,7 @@ LIGHT_TURN_ON_SCHEMA = { vol.Exclusive(ATTR_XY_COLOR, COLOR_GROUP): vol.All( vol.Coerce(tuple), vol.ExactSequence((cv.small_float, cv.small_float)) ), - vol.Exclusive(ATTR_WHITE, COLOR_GROUP): VALID_BRIGHTNESS, + vol.Exclusive(ATTR_WHITE, COLOR_GROUP): vol.Any(True, VALID_BRIGHTNESS), ATTR_FLASH: VALID_FLASH, ATTR_EFFECT: cv.string, } @@ -557,6 +557,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: elif ColorMode.XY in supported_color_modes: params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) + # If white is set to True, set it to the light's brightness + # Add a warning in Home Assistant Core 2023.5 if the brightness is set to an + # integer. + if params.get(ATTR_WHITE) is True: + params[ATTR_WHITE] = light.brightness + # If both white and brightness are specified, override white if ( supported_color_modes diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index 65bf77f15c..d1221dd121 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -370,19 +370,16 @@ turn_on: unit_of_measurement: "%" white: name: White - description: - Set the light to white mode and change its brightness, where 0 turns - the light off, 1 is the minimum brightness and 255 is the maximum - brightness supported by the light. + description: Set the light to white mode. filter: attribute: supported_color_modes: - light.ColorMode.WHITE advanced: true selector: - number: - min: 0 - max: 255 + constant: + value: true + label: Enabled profile: name: Profile description: Name of a light profile to use. @@ -749,6 +746,18 @@ toggle: min: 0 max: 100 unit_of_measurement: "%" + white: + name: White + description: Set the light to white mode. + filter: + attribute: + supported_color_modes: + - light.ColorMode.WHITE + advanced: true + selector: + constant: + value: true + label: Enabled profile: name: Profile description: Name of a light profile to use. diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 347dfb82bb..4b4c027541 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -2159,6 +2159,26 @@ async def test_light_service_call_white_mode( _, data = entity0.last_call("turn_off") assert data == {} + entity0.calls = [] + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": [entity0.entity_id], "white": True}, + blocking=True, + ) + _, data = entity0.last_call("turn_on") + assert data == {"white": 100} + + entity0.calls = [] + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": [entity0.entity_id], "brightness_pct": 50, "white": True}, + blocking=True, + ) + _, data = entity0.last_call("turn_on") + assert data == {"white": 128} + async def test_light_state_color_conversion( hass: HomeAssistant, enable_custom_integrations: None From 04a99fdbfc8a8aa31719abe263e3c7ec1e8d77a7 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 16 Mar 2023 20:05:01 -0700 Subject: [PATCH 1027/2810] Add local calendar diagnostics platform (#89776) * Add local calendar diagnostics platform * Use redaction from ical * Update diagnostics for new ical version * Apply suggestions from code review Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Use snapshot tests for local calendar diagnostics * Setup diagnostics directly in tests rather than via dependencies --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/local_calendar/diagnostics.py | 27 ++++++++ .../snapshots/test_diagnostics.ambr | 32 ++++++++++ .../local_calendar/test_diagnostics.py | 62 +++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 homeassistant/components/local_calendar/diagnostics.py create mode 100644 tests/components/local_calendar/snapshots/test_diagnostics.ambr create mode 100644 tests/components/local_calendar/test_diagnostics.py diff --git a/homeassistant/components/local_calendar/diagnostics.py b/homeassistant/components/local_calendar/diagnostics.py new file mode 100644 index 0000000000..51b53ff007 --- /dev/null +++ b/homeassistant/components/local_calendar/diagnostics.py @@ -0,0 +1,27 @@ +"""Provides diagnostics for local calendar.""" + +import datetime +from typing import Any + +from ical.diagnostics import redact_ics + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from .const import DOMAIN + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + payload: dict[str, Any] = { + "now": dt_util.now().isoformat(), + "timezone": str(dt_util.DEFAULT_TIME_ZONE), + "system_timezone": str(datetime.datetime.utcnow().astimezone().tzinfo), + } + store = hass.data[DOMAIN][config_entry.entry_id] + ics = await store.async_load() + payload["ics"] = "\n".join(redact_ics(ics)) + return payload diff --git a/tests/components/local_calendar/snapshots/test_diagnostics.ambr b/tests/components/local_calendar/snapshots/test_diagnostics.ambr new file mode 100644 index 0000000000..e61b9da7a9 --- /dev/null +++ b/tests/components/local_calendar/snapshots/test_diagnostics.ambr @@ -0,0 +1,32 @@ +# serializer version: 1 +# name: test_api_date_time_event + dict({ + 'ics': ''' + BEGIN:VCALENDAR + PRODID:-//github.com/allenporter/ical//4.5.0//EN + VERSION:*** + BEGIN:VEVENT + DTSTAMP:20230313T190500 + UID:*** + DTSTART:19970714T110000 + DTEND:19970714T220000 + SUMMARY:*** + CREATED:20230313T190500 + RRULE:FREQ=DAILY + SEQUENCE:*** + END:VEVENT + END:VCALENDAR + ''', + 'now': '2023-03-13T13:05:00-06:00', + 'system_timezone': 'tzlocal()', + 'timezone': 'America/Regina', + }) +# --- +# name: test_empty_calendar + dict({ + 'ics': '', + 'now': '2023-03-13T13:05:00-06:00', + 'system_timezone': 'tzlocal()', + 'timezone': 'America/Regina', + }) +# --- diff --git a/tests/components/local_calendar/test_diagnostics.py b/tests/components/local_calendar/test_diagnostics.py new file mode 100644 index 0000000000..8b033cf4fd --- /dev/null +++ b/tests/components/local_calendar/test_diagnostics.py @@ -0,0 +1,62 @@ +"""Tests for diagnostics platform of local calendar.""" + +from freezegun import freeze_time +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .conftest import TEST_ENTITY, ClientFixture + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.fixture(autouse=True) +async def setup_diag(hass): + """Set up diagnostics platform.""" + assert await async_setup_component(hass, "diagnostics", {}) + + +@freeze_time("2023-03-13 12:05:00-07:00") +async def test_empty_calendar( + hass: HomeAssistant, + setup_integration: None, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics against an empty calendar.""" + data = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert data == snapshot + + +@freeze_time("2023-03-13 12:05:00-07:00") +async def test_api_date_time_event( + hass: HomeAssistant, + setup_integration: None, + config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, + ws_client: ClientFixture, + snapshot: SnapshotAssertion, +) -> None: + """Test an event with a start/end date time.""" + + client = await ws_client() + await client.cmd_result( + "create", + { + "entity_id": TEST_ENTITY, + "event": { + "summary": "Bastille Day Party", + "dtstart": "1997-07-14T17:00:00+00:00", + "dtend": "1997-07-15T04:00:00+00:00", + "rrule": "FREQ=DAILY", + }, + }, + ) + + data = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert data == snapshot From f6f35657963df04f01c6d4f65357de8842bb5b46 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Mar 2023 19:00:02 -1000 Subject: [PATCH 1028/2810] Reduce latency to find stats metadata (#89824) --- homeassistant/components/recorder/core.py | 25 +- .../components/recorder/migration.py | 2 +- .../components/recorder/statistics.py | 212 ++++-------- .../recorder/table_managers/event_data.py | 6 +- .../recorder/table_managers/event_types.py | 6 +- .../table_managers/state_attributes.py | 6 +- .../recorder/table_managers/states_meta.py | 6 +- .../table_managers/statistics_meta.py | 322 ++++++++++++++++++ homeassistant/components/sensor/recorder.py | 106 +++--- .../recorder/table_managers/__init__.py | 1 + .../table_managers/test_statistics_meta.py | 53 +++ tests/components/recorder/test_init.py | 86 ++--- tests/components/recorder/test_statistics.py | 13 +- 13 files changed, 589 insertions(+), 255 deletions(-) create mode 100644 homeassistant/components/recorder/table_managers/statistics_meta.py create mode 100644 tests/components/recorder/table_managers/__init__.py create mode 100644 tests/components/recorder/table_managers/test_statistics_meta.py diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 3468acaed4..32a6e9c24d 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -86,6 +86,7 @@ from .table_managers.event_types import EventTypeManager from .table_managers.state_attributes import StateAttributesManager from .table_managers.states import StatesManager from .table_managers.states_meta import StatesMetaManager +from .table_managers.statistics_meta import StatisticsMetaManager from .tasks import ( AdjustLRUSizeTask, AdjustStatisticsTask, @@ -172,6 +173,7 @@ class Recorder(threading.Thread): threading.Thread.__init__(self, name="Recorder") self.hass = hass + self.thread_id: int | None = None self.auto_purge = auto_purge self.auto_repack = auto_repack self.keep_days = keep_days @@ -208,6 +210,7 @@ class Recorder(threading.Thread): self.state_attributes_manager = StateAttributesManager( self, exclude_attributes_by_domain ) + self.statistics_meta_manager = StatisticsMetaManager(self) self.event_session: Session | None = None self._get_session: Callable[[], Session] | None = None self._completed_first_database_setup: bool | None = None @@ -613,6 +616,7 @@ class Recorder(threading.Thread): def run(self) -> None: """Start processing events to save.""" + self.thread_id = threading.get_ident() setup_result = self._setup_recorder() if not setup_result: @@ -668,7 +672,7 @@ class Recorder(threading.Thread): "Database Migration Failed", "recorder_database_migration", ) - self._activate_and_set_db_ready() + self.hass.add_job(self.async_set_db_ready) self._shutdown() return @@ -687,7 +691,14 @@ class Recorder(threading.Thread): def _activate_and_set_db_ready(self) -> None: """Activate the table managers or schedule migrations and mark the db as ready.""" - with session_scope(session=self.get_session()) as session: + with session_scope(session=self.get_session(), read_only=True) as session: + # Prime the statistics meta manager as soon as possible + # since we want the frontend queries to avoid a thundering + # herd of queries to find the statistics meta data if + # there are a lot of statistics graphs on the frontend. + if self.schema_version >= 23: + self.statistics_meta_manager.load(session) + if ( self.schema_version < 36 or session.execute(has_events_context_ids_to_migrate()).scalar() @@ -758,10 +769,11 @@ class Recorder(threading.Thread): non_state_change_events.append(event_) assert self.event_session is not None - self.event_data_manager.load(non_state_change_events, self.event_session) - self.event_type_manager.load(non_state_change_events, self.event_session) - self.states_meta_manager.load(state_change_events, self.event_session) - self.state_attributes_manager.load(state_change_events, self.event_session) + session = self.event_session + self.event_data_manager.load(non_state_change_events, session) + self.event_type_manager.load(non_state_change_events, session) + self.states_meta_manager.load(state_change_events, session) + self.state_attributes_manager.load(state_change_events, session) def _guarded_process_one_task_or_recover(self, task: RecorderTask) -> None: """Process a task, guarding against exceptions to ensure the loop does not collapse.""" @@ -1077,6 +1089,7 @@ class Recorder(threading.Thread): self.event_data_manager.reset() self.event_type_manager.reset() self.states_meta_manager.reset() + self.statistics_meta_manager.reset() if not self.event_session: return diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index d594118ea5..08f5f21b89 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -873,7 +873,7 @@ def _apply_update( # noqa: C901 # There may be duplicated statistics_meta entries, delete duplicates # and try again with session_scope(session=session_maker()) as session: - delete_statistics_meta_duplicates(session) + delete_statistics_meta_duplicates(instance, session) _create_index( session_maker, "statistics_meta", "ix_statistics_meta_statistic_id" ) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 645b7d4f04..36874869bf 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -21,7 +21,7 @@ from sqlalchemy.engine import Engine from sqlalchemy.engine.row import Row from sqlalchemy.exc import OperationalError, SQLAlchemyError, StatementError from sqlalchemy.orm.session import Session -from sqlalchemy.sql.expression import literal_column, true +from sqlalchemy.sql.expression import literal_column from sqlalchemy.sql.lambdas import StatementLambdaElement import voluptuous as vol @@ -132,16 +132,6 @@ QUERY_STATISTICS_SUMMARY_SUM = ( .label("rownum"), ) -QUERY_STATISTIC_META = ( - StatisticsMeta.id, - StatisticsMeta.statistic_id, - StatisticsMeta.source, - StatisticsMeta.unit_of_measurement, - StatisticsMeta.has_mean, - StatisticsMeta.has_sum, - StatisticsMeta.name, -) - STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { **{unit: DataRateConverter for unit in DataRateConverter.VALID_UNITS}, @@ -373,56 +363,6 @@ def get_start_time() -> datetime: return last_period -def _update_or_add_metadata( - session: Session, - new_metadata: StatisticMetaData, - old_metadata_dict: dict[str, tuple[int, StatisticMetaData]], -) -> int: - """Get metadata_id for a statistic_id. - - If the statistic_id is previously unknown, add it. If it's already known, update - metadata if needed. - - Updating metadata source is not possible. - """ - statistic_id = new_metadata["statistic_id"] - if statistic_id not in old_metadata_dict: - meta = StatisticsMeta.from_meta(new_metadata) - session.add(meta) - session.flush() # Flush to get the metadata id assigned - _LOGGER.debug( - "Added new statistics metadata for %s, new_metadata: %s", - statistic_id, - new_metadata, - ) - return meta.id - - metadata_id, old_metadata = old_metadata_dict[statistic_id] - if ( - old_metadata["has_mean"] != new_metadata["has_mean"] - or old_metadata["has_sum"] != new_metadata["has_sum"] - or old_metadata["name"] != new_metadata["name"] - or old_metadata["unit_of_measurement"] != new_metadata["unit_of_measurement"] - ): - session.query(StatisticsMeta).filter_by(statistic_id=statistic_id).update( - { - StatisticsMeta.has_mean: new_metadata["has_mean"], - StatisticsMeta.has_sum: new_metadata["has_sum"], - StatisticsMeta.name: new_metadata["name"], - StatisticsMeta.unit_of_measurement: new_metadata["unit_of_measurement"], - }, - synchronize_session=False, - ) - _LOGGER.debug( - "Updated statistics metadata for %s, old_metadata: %s, new_metadata: %s", - statistic_id, - old_metadata, - new_metadata, - ) - - return metadata_id - - def _find_duplicates( session: Session, table: type[StatisticsBase] ) -> tuple[list[int], list[dict]]: @@ -642,13 +582,16 @@ def _delete_statistics_meta_duplicates(session: Session) -> int: return total_deleted_rows -def delete_statistics_meta_duplicates(session: Session) -> None: +def delete_statistics_meta_duplicates(instance: Recorder, session: Session) -> None: """Identify and delete duplicated statistics_meta. This is used when migrating from schema version 28 to schema version 29. """ deleted_statistics_rows = _delete_statistics_meta_duplicates(session) if deleted_statistics_rows: + statistics_meta_manager = instance.statistics_meta_manager + statistics_meta_manager.reset() + statistics_meta_manager.load(session) _LOGGER.info( "Deleted %s duplicated statistics_meta rows", deleted_statistics_rows ) @@ -750,6 +693,7 @@ def compile_statistics(instance: Recorder, start: datetime, fire_events: bool) - """ start = dt_util.as_utc(start) end = start + timedelta(minutes=5) + statistics_meta_manager = instance.statistics_meta_manager # Return if we already have 5-minute statistics for the requested period with session_scope( @@ -782,7 +726,7 @@ def compile_statistics(instance: Recorder, start: datetime, fire_events: bool) - # Insert collected statistics in the database for stats in platform_stats: - metadata_id = _update_or_add_metadata( + _, metadata_id = statistics_meta_manager.update_or_add( session, stats["meta"], current_metadata ) _insert_statistics( @@ -877,28 +821,8 @@ def _update_statistics( ) -def _generate_get_metadata_stmt( - statistic_ids: list[str] | None = None, - statistic_type: Literal["mean"] | Literal["sum"] | None = None, - statistic_source: str | None = None, -) -> StatementLambdaElement: - """Generate a statement to fetch metadata.""" - stmt = lambda_stmt(lambda: select(*QUERY_STATISTIC_META)) - if statistic_ids: - stmt += lambda q: q.where( - # https://github.com/python/mypy/issues/2608 - StatisticsMeta.statistic_id.in_(statistic_ids) # type:ignore[arg-type] - ) - if statistic_source is not None: - stmt += lambda q: q.where(StatisticsMeta.source == statistic_source) - if statistic_type == "mean": - stmt += lambda q: q.where(StatisticsMeta.has_mean == true()) - elif statistic_type == "sum": - stmt += lambda q: q.where(StatisticsMeta.has_sum == true()) - return stmt - - def get_metadata_with_session( + instance: Recorder, session: Session, *, statistic_ids: list[str] | None = None, @@ -908,31 +832,15 @@ def get_metadata_with_session( """Fetch meta data. Returns a dict of (metadata_id, StatisticMetaData) tuples indexed by statistic_id. - If statistic_ids is given, fetch metadata only for the listed statistics_ids. If statistic_type is given, fetch metadata only for statistic_ids supporting it. """ - - # Fetch metatadata from the database - stmt = _generate_get_metadata_stmt(statistic_ids, statistic_type, statistic_source) - result = execute_stmt_lambda_element(session, stmt) - if not result: - return {} - - return { - meta.statistic_id: ( - meta.id, - { - "has_mean": meta.has_mean, - "has_sum": meta.has_sum, - "name": meta.name, - "source": meta.source, - "statistic_id": meta.statistic_id, - "unit_of_measurement": meta.unit_of_measurement, - }, - ) - for meta in result - } + return instance.statistics_meta_manager.get_many( + session, + statistic_ids=statistic_ids, + statistic_type=statistic_type, + statistic_source=statistic_source, + ) def get_metadata( @@ -945,6 +853,7 @@ def get_metadata( """Return metadata for statistic_ids.""" with session_scope(hass=hass, read_only=True) as session: return get_metadata_with_session( + get_instance(hass), session, statistic_ids=statistic_ids, statistic_type=statistic_type, @@ -952,17 +861,10 @@ def get_metadata( ) -def _clear_statistics_with_session(session: Session, statistic_ids: list[str]) -> None: - """Clear statistics for a list of statistic_ids.""" - session.query(StatisticsMeta).filter( - StatisticsMeta.statistic_id.in_(statistic_ids) - ).delete(synchronize_session=False) - - def clear_statistics(instance: Recorder, statistic_ids: list[str]) -> None: """Clear statistics for a list of statistic_ids.""" with session_scope(session=instance.get_session()) as session: - _clear_statistics_with_session(session, statistic_ids) + instance.statistics_meta_manager.delete(session, statistic_ids) def update_statistics_metadata( @@ -972,20 +874,20 @@ def update_statistics_metadata( new_unit_of_measurement: str | None | UndefinedType, ) -> None: """Update statistics metadata for a statistic_id.""" + statistics_meta_manager = instance.statistics_meta_manager if new_unit_of_measurement is not UNDEFINED: with session_scope(session=instance.get_session()) as session: - session.query(StatisticsMeta).filter( - StatisticsMeta.statistic_id == statistic_id - ).update({StatisticsMeta.unit_of_measurement: new_unit_of_measurement}) - if new_statistic_id is not UNDEFINED: + statistics_meta_manager.update_unit_of_measurement( + session, statistic_id, new_unit_of_measurement + ) + if new_statistic_id is not UNDEFINED and new_statistic_id is not None: with session_scope( session=instance.get_session(), exception_filter=_filter_unique_constraint_integrity_error(instance), ) as session: - session.query(StatisticsMeta).filter( - (StatisticsMeta.statistic_id == statistic_id) - & (StatisticsMeta.source == DOMAIN) - ).update({StatisticsMeta.statistic_id: new_statistic_id}) + statistics_meta_manager.update_statistic_id( + session, DOMAIN, statistic_id, new_statistic_id + ) def list_statistic_ids( @@ -1004,7 +906,7 @@ def list_statistic_ids( # Query the database with session_scope(hass=hass, read_only=True) as session: - metadata = get_metadata_with_session( + metadata = get_instance(hass).statistics_meta_manager.get_many( session, statistic_type=statistic_type, statistic_ids=statistic_ids ) @@ -1609,11 +1511,13 @@ def statistic_during_period( with session_scope(hass=hass, read_only=True) as session: # Fetch metadata for the given statistic_id if not ( - metadata := get_metadata_with_session(session, statistic_ids=[statistic_id]) + metadata := get_instance(hass).statistics_meta_manager.get( + session, statistic_id + ) ): return result - metadata_id = metadata[statistic_id][0] + metadata_id = metadata[0] oldest_stat = _first_statistic(session, Statistics, metadata_id) oldest_5_min_stat = None @@ -1724,7 +1628,7 @@ def statistic_during_period( else: result["change"] = None - state_unit = unit = metadata[statistic_id][1]["unit_of_measurement"] + state_unit = unit = metadata[1]["unit_of_measurement"] if state := hass.states.get(statistic_id): state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) convert = _get_statistic_to_display_unit_converter(unit, state_unit, units) @@ -1749,7 +1653,9 @@ def _statistics_during_period_with_session( """ metadata = None # Fetch metadata for the given (or all) statistic_ids - metadata = get_metadata_with_session(session, statistic_ids=statistic_ids) + metadata = get_instance(hass).statistics_meta_manager.get_many( + session, statistic_ids=statistic_ids + ) if not metadata: return {} @@ -1885,7 +1791,9 @@ def _get_last_statistics( statistic_ids = [statistic_id] with session_scope(hass=hass, read_only=True) as session: # Fetch metadata for the given statistic_id - metadata = get_metadata_with_session(session, statistic_ids=statistic_ids) + metadata = get_instance(hass).statistics_meta_manager.get_many( + session, statistic_ids=statistic_ids + ) if not metadata: return {} metadata_id = metadata[statistic_id][0] @@ -1973,7 +1881,9 @@ def get_latest_short_term_statistics( with session_scope(hass=hass, read_only=True) as session: # Fetch metadata for the given statistic_ids if not metadata: - metadata = get_metadata_with_session(session, statistic_ids=statistic_ids) + metadata = get_instance(hass).statistics_meta_manager.get_many( + session, statistic_ids=statistic_ids + ) if not metadata: return {} metadata_ids = [ @@ -2318,16 +2228,20 @@ def _filter_unique_constraint_integrity_error( def _import_statistics_with_session( + instance: Recorder, session: Session, metadata: StatisticMetaData, statistics: Iterable[StatisticData], table: type[StatisticsBase], ) -> bool: """Import statistics to the database.""" - old_metadata_dict = get_metadata_with_session( + statistics_meta_manager = instance.statistics_meta_manager + old_metadata_dict = statistics_meta_manager.get_many( session, statistic_ids=[metadata["statistic_id"]] ) - metadata_id = _update_or_add_metadata(session, metadata, old_metadata_dict) + _, metadata_id = statistics_meta_manager.update_or_add( + session, metadata, old_metadata_dict + ) for stat in statistics: if stat_id := _statistics_exists(session, table, metadata_id, stat["start"]): _update_statistics(session, table, stat_id, stat) @@ -2350,7 +2264,9 @@ def import_statistics( session=instance.get_session(), exception_filter=_filter_unique_constraint_integrity_error(instance), ) as session: - return _import_statistics_with_session(session, metadata, statistics, table) + return _import_statistics_with_session( + instance, session, metadata, statistics, table + ) @retryable_database_job("adjust_statistics") @@ -2364,7 +2280,9 @@ def adjust_statistics( """Process an add_statistics job.""" with session_scope(session=instance.get_session()) as session: - metadata = get_metadata_with_session(session, statistic_ids=[statistic_id]) + metadata = instance.statistics_meta_manager.get_many( + session, statistic_ids=[statistic_id] + ) if statistic_id not in metadata: return True @@ -2423,10 +2341,9 @@ def change_statistics_unit( old_unit: str, ) -> None: """Change statistics unit for a statistic_id.""" + statistics_meta_manager = instance.statistics_meta_manager with session_scope(session=instance.get_session()) as session: - metadata = get_metadata_with_session(session, statistic_ids=[statistic_id]).get( - statistic_id - ) + metadata = statistics_meta_manager.get(session, statistic_id) # Guard against the statistics being removed or updated before the # change_statistics_unit job executes @@ -2447,9 +2364,10 @@ def change_statistics_unit( ) for table in tables: _change_statistics_unit_for_table(session, table, metadata_id, convert) - session.query(StatisticsMeta).filter( - StatisticsMeta.statistic_id == statistic_id - ).update({StatisticsMeta.unit_of_measurement: new_unit}) + + statistics_meta_manager.update_unit_of_measurement( + session, statistic_id, new_unit + ) @callback @@ -2495,16 +2413,19 @@ def _validate_db_schema_utf8( "statistic_id": statistic_id, "unit_of_measurement": None, } + statistics_meta_manager = instance.statistics_meta_manager # Try inserting some metadata which needs utfmb4 support try: with session_scope(session=session_maker()) as session: - old_metadata_dict = get_metadata_with_session( + old_metadata_dict = statistics_meta_manager.get_many( session, statistic_ids=[statistic_id] ) try: - _update_or_add_metadata(session, metadata, old_metadata_dict) - _clear_statistics_with_session(session, statistic_ids=[statistic_id]) + statistics_meta_manager.update_or_add( + session, metadata, old_metadata_dict + ) + statistics_meta_manager.delete(session, statistic_ids=[statistic_id]) except OperationalError as err: if err.orig and err.orig.args[0] == 1366: _LOGGER.debug( @@ -2524,6 +2445,7 @@ def _validate_db_schema( ) -> set[str]: """Do some basic checks for common schema errors caused by manual migration.""" schema_errors: set[str] = set() + statistics_meta_manager = instance.statistics_meta_manager # Wrong precision is only an issue for MySQL / MariaDB / PostgreSQL if instance.dialect_name not in ( @@ -2586,7 +2508,9 @@ def _validate_db_schema( try: with session_scope(session=session_maker()) as session: for table in tables: - _import_statistics_with_session(session, metadata, (statistics,), table) + _import_statistics_with_session( + instance, session, metadata, (statistics,), table + ) stored_statistics = _statistics_during_period_with_session( hass, session, @@ -2625,7 +2549,7 @@ def _validate_db_schema( table.__tablename__, "µs precision", ) - _clear_statistics_with_session(session, statistic_ids=[statistic_id]) + statistics_meta_manager.delete(session, statistic_ids=[statistic_id]) except Exception as exc: # pylint: disable=broad-except _LOGGER.exception("Error when validating DB schema: %s", exc) diff --git a/homeassistant/components/recorder/table_managers/event_data.py b/homeassistant/components/recorder/table_managers/event_data.py index a99b25fe0b..4c661e3dc2 100644 --- a/homeassistant/components/recorder/table_managers/event_data.py +++ b/homeassistant/components/recorder/table_managers/event_data.py @@ -14,7 +14,7 @@ from . import BaseLRUTableManager from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import EventData from ..queries import get_shared_event_datas -from ..util import chunked +from ..util import chunked, execute_stmt_lambda_element if TYPE_CHECKING: from ..core import Recorder @@ -96,8 +96,8 @@ class EventDataManager(BaseLRUTableManager[EventData]): results: dict[str, int | None] = {} with session.no_autoflush: for hashs_chunk in chunked(hashes, SQLITE_MAX_BIND_VARS): - for data_id, shared_data in session.execute( - get_shared_event_datas(hashs_chunk) + for data_id, shared_data in execute_stmt_lambda_element( + session, get_shared_event_datas(hashs_chunk) ): results[shared_data] = self._id_map[shared_data] = cast( int, data_id diff --git a/homeassistant/components/recorder/table_managers/event_types.py b/homeassistant/components/recorder/table_managers/event_types.py index 3cb3d9fad9..5b77e9116c 100644 --- a/homeassistant/components/recorder/table_managers/event_types.py +++ b/homeassistant/components/recorder/table_managers/event_types.py @@ -12,7 +12,7 @@ from . import BaseLRUTableManager from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import EventTypes from ..queries import find_event_type_ids -from ..util import chunked +from ..util import chunked, execute_stmt_lambda_element if TYPE_CHECKING: from ..core import Recorder @@ -68,8 +68,8 @@ class EventTypeManager(BaseLRUTableManager[EventTypes]): with session.no_autoflush: for missing_chunk in chunked(missing, SQLITE_MAX_BIND_VARS): - for event_type_id, event_type in session.execute( - find_event_type_ids(missing_chunk) + for event_type_id, event_type in execute_stmt_lambda_element( + session, find_event_type_ids(missing_chunk) ): results[event_type] = self._id_map[event_type] = cast( int, event_type_id diff --git a/homeassistant/components/recorder/table_managers/state_attributes.py b/homeassistant/components/recorder/table_managers/state_attributes.py index 7489a6f165..51c626bd36 100644 --- a/homeassistant/components/recorder/table_managers/state_attributes.py +++ b/homeassistant/components/recorder/table_managers/state_attributes.py @@ -15,7 +15,7 @@ from . import BaseLRUTableManager from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import StateAttributes from ..queries import get_shared_attributes -from ..util import chunked +from ..util import chunked, execute_stmt_lambda_element if TYPE_CHECKING: from ..core import Recorder @@ -113,8 +113,8 @@ class StateAttributesManager(BaseLRUTableManager[StateAttributes]): results: dict[str, int | None] = {} with session.no_autoflush: for hashs_chunk in chunked(hashes, SQLITE_MAX_BIND_VARS): - for attributes_id, shared_attrs in session.execute( - get_shared_attributes(hashs_chunk) + for attributes_id, shared_attrs in execute_stmt_lambda_element( + session, get_shared_attributes(hashs_chunk) ): results[shared_attrs] = self._id_map[shared_attrs] = cast( int, attributes_id diff --git a/homeassistant/components/recorder/table_managers/states_meta.py b/homeassistant/components/recorder/table_managers/states_meta.py index b8b763aae3..76b748d469 100644 --- a/homeassistant/components/recorder/table_managers/states_meta.py +++ b/homeassistant/components/recorder/table_managers/states_meta.py @@ -12,7 +12,7 @@ from . import BaseLRUTableManager from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import StatesMeta from ..queries import find_all_states_metadata_ids, find_states_metadata_ids -from ..util import chunked +from ..util import chunked, execute_stmt_lambda_element if TYPE_CHECKING: from ..core import Recorder @@ -98,8 +98,8 @@ class StatesMetaManager(BaseLRUTableManager[StatesMeta]): with session.no_autoflush: for missing_chunk in chunked(missing, SQLITE_MAX_BIND_VARS): - for metadata_id, entity_id in session.execute( - find_states_metadata_ids(missing_chunk) + for metadata_id, entity_id in execute_stmt_lambda_element( + session, find_states_metadata_ids(missing_chunk) ): metadata_id = cast(int, metadata_id) results[entity_id] = metadata_id diff --git a/homeassistant/components/recorder/table_managers/statistics_meta.py b/homeassistant/components/recorder/table_managers/statistics_meta.py new file mode 100644 index 0000000000..93417b4325 --- /dev/null +++ b/homeassistant/components/recorder/table_managers/statistics_meta.py @@ -0,0 +1,322 @@ +"""Support managing StatesMeta.""" +from __future__ import annotations + +import logging +import threading +from typing import TYPE_CHECKING, Literal, cast + +from lru import LRU # pylint: disable=no-name-in-module +from sqlalchemy import lambda_stmt, select +from sqlalchemy.orm.session import Session +from sqlalchemy.sql.expression import true +from sqlalchemy.sql.lambdas import StatementLambdaElement + +from ..db_schema import StatisticsMeta +from ..models import StatisticMetaData +from ..util import execute_stmt_lambda_element + +if TYPE_CHECKING: + from ..core import Recorder + +CACHE_SIZE = 8192 + +_LOGGER = logging.getLogger(__name__) + +QUERY_STATISTIC_META = ( + StatisticsMeta.id, + StatisticsMeta.statistic_id, + StatisticsMeta.source, + StatisticsMeta.unit_of_measurement, + StatisticsMeta.has_mean, + StatisticsMeta.has_sum, + StatisticsMeta.name, +) + + +def _generate_get_metadata_stmt( + statistic_ids: list[str] | None = None, + statistic_type: Literal["mean"] | Literal["sum"] | None = None, + statistic_source: str | None = None, +) -> StatementLambdaElement: + """Generate a statement to fetch metadata.""" + stmt = lambda_stmt(lambda: select(*QUERY_STATISTIC_META)) + if statistic_ids: + stmt += lambda q: q.where( + # https://github.com/python/mypy/issues/2608 + StatisticsMeta.statistic_id.in_(statistic_ids) # type:ignore[arg-type] + ) + if statistic_source is not None: + stmt += lambda q: q.where(StatisticsMeta.source == statistic_source) + if statistic_type == "mean": + stmt += lambda q: q.where(StatisticsMeta.has_mean == true()) + elif statistic_type == "sum": + stmt += lambda q: q.where(StatisticsMeta.has_sum == true()) + return stmt + + +def _statistics_meta_to_id_statistics_metadata( + meta: StatisticsMeta, +) -> tuple[int, StatisticMetaData]: + """Convert StatisticsMeta tuple of metadata_id and StatisticMetaData.""" + return ( + meta.id, + { + "has_mean": meta.has_mean, # type: ignore[typeddict-item] + "has_sum": meta.has_sum, # type: ignore[typeddict-item] + "name": meta.name, + "source": meta.source, # type: ignore[typeddict-item] + "statistic_id": meta.statistic_id, # type: ignore[typeddict-item] + "unit_of_measurement": meta.unit_of_measurement, + }, + ) + + +class StatisticsMetaManager: + """Manage the StatisticsMeta table.""" + + def __init__(self, recorder: Recorder) -> None: + """Initialize the statistics meta manager.""" + self.recorder = recorder + self._stat_id_to_id_meta: dict[str, tuple[int, StatisticMetaData]] = LRU( + CACHE_SIZE + ) + + def _clear_cache(self, statistic_ids: list[str]) -> None: + """Clear the cache.""" + for statistic_id in statistic_ids: + self._stat_id_to_id_meta.pop(statistic_id, None) + + def _get_from_database( + self, + session: Session, + statistic_ids: list[str] | None = None, + statistic_type: Literal["mean"] | Literal["sum"] | None = None, + statistic_source: str | None = None, + ) -> dict[str, tuple[int, StatisticMetaData]]: + """Fetch meta data and process it into results and/or cache.""" + # Only update the cache if we are in the recorder thread and there are no + # new objects that are not yet committed to the database in the session. + update_cache = ( + not session.new + and not session.dirty + and self.recorder.thread_id == threading.get_ident() + ) + results: dict[str, tuple[int, StatisticMetaData]] = {} + with session.no_autoflush: + stat_id_to_id_meta = self._stat_id_to_id_meta + for row in execute_stmt_lambda_element( + session, + _generate_get_metadata_stmt( + statistic_ids, statistic_type, statistic_source + ), + ): + statistics_meta = cast(StatisticsMeta, row) + id_meta = _statistics_meta_to_id_statistics_metadata(statistics_meta) + statistic_id = cast(str, statistics_meta.statistic_id) + results[statistic_id] = id_meta + if update_cache: + stat_id_to_id_meta[statistic_id] = id_meta + return results + + def _assert_in_recorder_thread(self) -> None: + """Assert that we are in the recorder thread.""" + if self.recorder.thread_id != threading.get_ident(): + raise RuntimeError("Detected unsafe call not in recorder thread") + + def _add_metadata( + self, session: Session, statistic_id: str, new_metadata: StatisticMetaData + ) -> int: + """Add metadata to the database. + + This call is not thread-safe and must be called from the + recorder thread. + """ + self._assert_in_recorder_thread() + meta = StatisticsMeta.from_meta(new_metadata) + session.add(meta) + # Flush to assign an ID + session.flush() + _LOGGER.debug( + "Added new statistics metadata for %s, new_metadata: %s", + statistic_id, + new_metadata, + ) + return meta.id + + def _update_metadata( + self, + session: Session, + statistic_id: str, + new_metadata: StatisticMetaData, + old_metadata_dict: dict[str, tuple[int, StatisticMetaData]], + ) -> tuple[bool, int]: + """Update metadata in the database. + + This call is not thread-safe and must be called from the + recorder thread. + """ + metadata_id, old_metadata = old_metadata_dict[statistic_id] + if not ( + old_metadata["has_mean"] != new_metadata["has_mean"] + or old_metadata["has_sum"] != new_metadata["has_sum"] + or old_metadata["name"] != new_metadata["name"] + or old_metadata["unit_of_measurement"] + != new_metadata["unit_of_measurement"] + ): + return False, metadata_id + + self._assert_in_recorder_thread() + session.query(StatisticsMeta).filter_by(statistic_id=statistic_id).update( + { + StatisticsMeta.has_mean: new_metadata["has_mean"], + StatisticsMeta.has_sum: new_metadata["has_sum"], + StatisticsMeta.name: new_metadata["name"], + StatisticsMeta.unit_of_measurement: new_metadata["unit_of_measurement"], + }, + synchronize_session=False, + ) + self._clear_cache([statistic_id]) + _LOGGER.debug( + "Updated statistics metadata for %s, old_metadata: %s, new_metadata: %s", + statistic_id, + old_metadata, + new_metadata, + ) + return True, metadata_id + + def load(self, session: Session) -> None: + """Load the statistic_id to metadata_id mapping into memory. + + This call is not thread-safe and must be called from the + recorder thread. + """ + self.get_many(session) + + def get( + self, session: Session, statistic_id: str + ) -> tuple[int, StatisticMetaData] | None: + """Resolve statistic_id to the metadata_id.""" + return self.get_many(session, [statistic_id]).get(statistic_id) + + def get_many( + self, + session: Session, + statistic_ids: list[str] | None = None, + statistic_type: Literal["mean"] | Literal["sum"] | None = None, + statistic_source: str | None = None, + ) -> dict[str, tuple[int, StatisticMetaData]]: + """Fetch meta data. + + Returns a dict of (metadata_id, StatisticMetaData) tuples indexed by statistic_id. + + If statistic_ids is given, fetch metadata only for the listed statistics_ids. + If statistic_type is given, fetch metadata only for statistic_ids supporting it. + """ + if statistic_ids is None: + # Fetch metadata from the database + return self._get_from_database( + session, + statistic_type=statistic_type, + statistic_source=statistic_source, + ) + + if statistic_type is not None or statistic_source is not None: + # This was originally implemented but we never used it + # so the code was ripped out to reduce the maintenance + # burden. + raise ValueError( + "Providing statistic_type and statistic_source is mutually exclusive of statistic_ids" + ) + + results: dict[str, tuple[int, StatisticMetaData]] = {} + missing_statistic_id: list[str] = [] + + for statistic_id in statistic_ids: + if id_meta := self._stat_id_to_id_meta.get(statistic_id): + results[statistic_id] = id_meta + else: + missing_statistic_id.append(statistic_id) + + if not missing_statistic_id: + return results + + # Fetch metadata from the database + return results | self._get_from_database( + session, statistic_ids=missing_statistic_id + ) + + def update_or_add( + self, + session: Session, + new_metadata: StatisticMetaData, + old_metadata_dict: dict[str, tuple[int, StatisticMetaData]], + ) -> tuple[bool, int]: + """Get metadata_id for a statistic_id. + + If the statistic_id is previously unknown, add it. If it's already known, update + metadata if needed. + + Updating metadata source is not possible. + + Returns a tuple of (updated, metadata_id). + + updated is True if the metadata was updated, False if it was not updated. + + This call is not thread-safe and must be called from the + recorder thread. + """ + statistic_id = new_metadata["statistic_id"] + if statistic_id not in old_metadata_dict: + return True, self._add_metadata(session, statistic_id, new_metadata) + return self._update_metadata( + session, statistic_id, new_metadata, old_metadata_dict + ) + + def update_unit_of_measurement( + self, session: Session, statistic_id: str, new_unit: str | None + ) -> None: + """Update the unit of measurement for a statistic_id. + + This call is not thread-safe and must be called from the + recorder thread. + """ + self._assert_in_recorder_thread() + session.query(StatisticsMeta).filter( + StatisticsMeta.statistic_id == statistic_id + ).update({StatisticsMeta.unit_of_measurement: new_unit}) + self._clear_cache([statistic_id]) + + def update_statistic_id( + self, + session: Session, + source: str, + old_statistic_id: str, + new_statistic_id: str, + ) -> None: + """Update the statistic_id for a statistic_id. + + This call is not thread-safe and must be called from the + recorder thread. + """ + self._assert_in_recorder_thread() + session.query(StatisticsMeta).filter( + (StatisticsMeta.statistic_id == old_statistic_id) + & (StatisticsMeta.source == source) + ).update({StatisticsMeta.statistic_id: new_statistic_id}) + self._clear_cache([old_statistic_id, new_statistic_id]) + + def delete(self, session: Session, statistic_ids: list[str]) -> None: + """Clear statistics for a list of statistic_ids. + + This call is not thread-safe and must be called from the + recorder thread. + """ + self._assert_in_recorder_thread() + session.query(StatisticsMeta).filter( + StatisticsMeta.statistic_id.in_(statistic_ids) + ).delete(synchronize_session=False) + self._clear_cache(statistic_ids) + + def reset(self) -> None: + """Reset the cache.""" + self._stat_id_to_id_meta = {} diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index bd4facbea1..8d5af155fd 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -145,31 +145,36 @@ def _parse_float(state: str) -> float: return fstate +def _float_or_none(state: str) -> float | None: + """Return a float or None.""" + try: + return _parse_float(state) + except (ValueError, TypeError): + return None + + +def _entity_history_to_float_and_state( + entity_history: Iterable[State], +) -> list[tuple[float, State]]: + """Return a list of (float, state) tuples for the given entity.""" + return [ + (fstate, state) + for state in entity_history + if (fstate := _float_or_none(state.state)) is not None + ] + + def _normalize_states( hass: HomeAssistant, - session: Session, old_metadatas: dict[str, tuple[int, StatisticMetaData]], - entity_history: Iterable[State], + fstates: list[tuple[float, State]], entity_id: str, ) -> tuple[str | None, list[tuple[float, State]]]: """Normalize units.""" - old_metadata = old_metadatas[entity_id][1] if entity_id in old_metadatas else None state_unit: str | None = None - - fstates: list[tuple[float, State]] = [] - for state in entity_history: - try: - fstate = _parse_float(state.state) - except (ValueError, TypeError): # TypeError to guard for NULL state in DB - continue - fstates.append((fstate, state)) - - if not fstates: - return None, fstates - - state_unit = fstates[0][1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) - statistics_unit: str | None + state_unit = fstates[0][1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) + old_metadata = old_metadatas[entity_id][1] if entity_id in old_metadatas else None if not old_metadata: # We've not seen this sensor before, the first valid state determines the unit # used for statistics @@ -379,7 +384,15 @@ def compile_statistics( Note: This will query the database and must not be run in the event loop """ - with recorder_util.session_scope(hass=hass) as session: + # There is already an active session when this code is called since + # it is called from the recorder statistics. We need to make sure + # this session never gets committed since it would be out of sync + # with the recorder statistics session so we mark it as read only. + # + # If we ever need to write to the database from this function we + # will need to refactor the recorder statistics to use a single + # session. + with recorder_util.session_scope(hass=hass, read_only=True) as session: compiled = _compile_statistics(hass, session, start, end) return compiled @@ -395,10 +408,6 @@ def _compile_statistics( # noqa: C901 sensor_states = _get_sensor_states(hass) wanted_statistics = _wanted_statistics(sensor_states) - old_metadatas = statistics.get_metadata_with_session( - session, statistic_ids=[i.entity_id for i in sensor_states] - ) - # Get history between start and end entities_full_history = [ i.entity_id for i in sensor_states if "sum" in wanted_statistics[i.entity_id] @@ -427,34 +436,41 @@ def _compile_statistics( # noqa: C901 entity_ids=entities_significant_history, ) history_list = {**history_list, **_history_list} - # If there are no recent state changes, the sensor's state may already be pruned - # from the recorder. Get the state from the state machine instead. - for _state in sensor_states: - if _state.entity_id not in history_list: - history_list[_state.entity_id] = [_state] - to_process = [] - to_query = [] + entities_with_float_states: dict[str, list[tuple[float, State]]] = {} for _state in sensor_states: entity_id = _state.entity_id - if entity_id not in history_list: + # If there are no recent state changes, the sensor's state may already be pruned + # from the recorder. Get the state from the state machine instead. + if not (entity_history := history_list.get(entity_id, [_state])): continue + if not (float_states := _entity_history_to_float_and_state(entity_history)): + continue + entities_with_float_states[entity_id] = float_states - entity_history = history_list[entity_id] - statistics_unit, fstates = _normalize_states( + # Only lookup metadata for entities that have valid float states + # since it will result in cache misses for statistic_ids + # that are not in the metadata table and we are not working + # with them anyway. + old_metadatas = statistics.get_metadata_with_session( + get_instance(hass), session, statistic_ids=list(entities_with_float_states) + ) + to_process: list[tuple[str, str | None, str, list[tuple[float, State]]]] = [] + to_query: list[str] = [] + for _state in sensor_states: + entity_id = _state.entity_id + if not (maybe_float_states := entities_with_float_states.get(entity_id)): + continue + statistics_unit, valid_float_states = _normalize_states( hass, - session, old_metadatas, - entity_history, + maybe_float_states, entity_id, ) - - if not fstates: + if not valid_float_states: continue - - state_class = _state.attributes[ATTR_STATE_CLASS] - - to_process.append((entity_id, statistics_unit, state_class, fstates)) + state_class: str = _state.attributes[ATTR_STATE_CLASS] + to_process.append((entity_id, statistics_unit, state_class, valid_float_states)) if "sum" in wanted_statistics[entity_id]: to_query.append(entity_id) @@ -465,7 +481,7 @@ def _compile_statistics( # noqa: C901 entity_id, statistics_unit, state_class, - fstates, + valid_float_states, ) in to_process: # Check metadata if old_metadata := old_metadatas.get(entity_id): @@ -507,20 +523,20 @@ def _compile_statistics( # noqa: C901 if "max" in wanted_statistics[entity_id]: stat["max"] = max( *itertools.islice( - zip(*fstates), # type: ignore[typeddict-item] + zip(*valid_float_states), # type: ignore[typeddict-item] 1, ) ) if "min" in wanted_statistics[entity_id]: stat["min"] = min( *itertools.islice( - zip(*fstates), # type: ignore[typeddict-item] + zip(*valid_float_states), # type: ignore[typeddict-item] 1, ) ) if "mean" in wanted_statistics[entity_id]: - stat["mean"] = _time_weighted_average(fstates, start, end) + stat["mean"] = _time_weighted_average(valid_float_states, start, end) if "sum" in wanted_statistics[entity_id]: last_reset = old_last_reset = None @@ -535,7 +551,7 @@ def _compile_statistics( # noqa: C901 new_state = old_state = last_stat["state"] _sum = last_stat["sum"] or 0.0 - for fstate, state in fstates: + for fstate, state in valid_float_states: reset = False if ( state_class != SensorStateClass.TOTAL_INCREASING diff --git a/tests/components/recorder/table_managers/__init__.py b/tests/components/recorder/table_managers/__init__.py new file mode 100644 index 0000000000..52685ec18f --- /dev/null +++ b/tests/components/recorder/table_managers/__init__.py @@ -0,0 +1 @@ +"""Tests for the recorder table managers.""" diff --git a/tests/components/recorder/table_managers/test_statistics_meta.py b/tests/components/recorder/table_managers/test_statistics_meta.py new file mode 100644 index 0000000000..8ec3f9367d --- /dev/null +++ b/tests/components/recorder/table_managers/test_statistics_meta.py @@ -0,0 +1,53 @@ +"""The tests for the Recorder component.""" +from __future__ import annotations + +import pytest + +from homeassistant.components import recorder +from homeassistant.components.recorder.util import session_scope +from homeassistant.core import HomeAssistant + +from tests.typing import RecorderInstanceGenerator + + +async def test_passing_mutually_exclusive_options_to_get_many( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test passing mutually exclusive options to get_many.""" + instance = await async_setup_recorder_instance( + hass, {recorder.CONF_COMMIT_INTERVAL: 0} + ) + with session_scope(session=instance.get_session()) as session: + with pytest.raises(ValueError): + instance.statistics_meta_manager.get_many( + session, + statistic_ids=["light.kitchen"], + statistic_type="mean", + ) + with pytest.raises(ValueError): + instance.statistics_meta_manager.get_many( + session, statistic_ids=["light.kitchen"], statistic_source="sensor" + ) + assert ( + instance.statistics_meta_manager.get_many( + session, + statistic_ids=["light.kitchen"], + ) + == {} + ) + + +async def test_unsafe_calls_to_statistics_meta_manager( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test we raise when trying to call non-threadsafe functions on statistics_meta_manager.""" + instance = await async_setup_recorder_instance( + hass, {recorder.CONF_COMMIT_INTERVAL: 0} + ) + with session_scope(session=instance.get_session()) as session, pytest.raises( + RuntimeError, match="Detected unsafe call not in recorder thread" + ): + instance.statistics_meta_manager.delete( + session, + statistic_ids=["light.kitchen"], + ) diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index e3b9145dc8..8c1d8ef00a 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -564,32 +564,6 @@ def _add_entities(hass, entity_ids): return states -def _add_events(hass, events): - with session_scope(hass=hass) as session: - session.query(Events).delete(synchronize_session=False) - for event_type in events: - hass.bus.fire(event_type) - wait_recording_done(hass) - - with session_scope(hass=hass) as session: - events = [] - for event, event_data, event_types in ( - session.query(Events, EventData, EventTypes) - .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) - .outerjoin(EventData, Events.data_id == EventData.data_id) - ): - event = cast(Events, event) - event_data = cast(EventData, event_data) - event_types = cast(EventTypes, event_types) - - native_event = event.to_native() - if event_data: - native_event.data = event_data.to_native() - native_event.event_type = event_types.event_type - events.append(native_event) - return events - - def _state_with_context(hass, entity_id): # We don't restore context unless we need it by joining the # events table on the event_id for state_changed events @@ -646,25 +620,53 @@ def test_saving_state_incl_entities( assert _state_with_context(hass, "test2.recorder").as_dict() == states[0].as_dict() -def test_saving_event_exclude_event_type( - hass_recorder: Callable[..., HomeAssistant] +async def test_saving_event_exclude_event_type( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, ) -> None: """Test saving and restoring an event.""" - hass = hass_recorder( - { - "exclude": { - "event_types": [ - "service_registered", - "homeassistant_start", - "component_loaded", - "core_config_updated", - "homeassistant_started", - "test", - ] - } + config = { + "exclude": { + "event_types": [ + "service_registered", + "homeassistant_start", + "component_loaded", + "core_config_updated", + "homeassistant_started", + "test", + ] } - ) - events = _add_events(hass, ["test", "test2"]) + } + instance = await async_setup_recorder_instance(hass, config) + events = ["test", "test2"] + for event_type in events: + hass.bus.async_fire(event_type) + + await async_wait_recording_done(hass) + + def _get_events(hass: HomeAssistant, event_types: list[str]) -> list[Event]: + with session_scope(hass=hass) as session: + events = [] + for event, event_data, event_types in ( + session.query(Events, EventData, EventTypes) + .outerjoin( + EventTypes, (Events.event_type_id == EventTypes.event_type_id) + ) + .outerjoin(EventData, Events.data_id == EventData.data_id) + .where(EventTypes.event_type.in_(event_types)) + ): + event = cast(Events, event) + event_data = cast(EventData, event_data) + event_types = cast(EventTypes, event_types) + + native_event = event.to_native() + if event_data: + native_event.data = event_data.to_native() + native_event.event_type = event_types.event_type + events.append(native_event) + return events + + events = await instance.async_add_executor_job(_get_events, hass, ["test", "test2"]) assert len(events) == 1 assert events[0].event_type == "test2" diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 7c064a03ed..46d2c92e46 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -22,12 +22,10 @@ from homeassistant.components.recorder.models import ( ) from homeassistant.components.recorder.statistics import ( STATISTIC_UNIT_TO_UNIT_CONVERTER, - _generate_get_metadata_stmt, _generate_max_mean_min_statistic_in_sub_period_stmt, _generate_statistics_at_time_stmt, _generate_statistics_during_period_stmt, _statistics_during_period_with_session, - _update_or_add_metadata, async_add_external_statistics, async_import_statistics, delete_statistics_duplicates, @@ -38,6 +36,10 @@ from homeassistant.components.recorder.statistics import ( get_metadata, list_statistic_ids, ) +from homeassistant.components.recorder.table_managers.statistics_meta import ( + StatisticsMetaManager, + _generate_get_metadata_stmt, +) from homeassistant.components.recorder.util import session_scope from homeassistant.components.sensor import UNIT_CONVERTERS from homeassistant.const import UnitOfTemperature @@ -1520,7 +1522,8 @@ def test_delete_metadata_duplicates_no_duplicates( hass = hass_recorder() wait_recording_done(hass) with session_scope(hass=hass) as session: - delete_statistics_meta_duplicates(session) + instance = recorder.get_instance(hass) + delete_statistics_meta_duplicates(instance, session) assert "duplicated statistics_meta rows" not in caplog.text @@ -1562,9 +1565,9 @@ async def test_validate_db_schema_fix_utf8_issue( with patch( "homeassistant.components.recorder.core.Recorder.dialect_name", "mysql" ), patch( - "homeassistant.components.recorder.statistics._update_or_add_metadata", + "homeassistant.components.recorder.table_managers.statistics_meta.StatisticsMetaManager.update_or_add", + wraps=StatisticsMetaManager.update_or_add, side_effect=[utf8_error, DEFAULT, DEFAULT], - wraps=_update_or_add_metadata, ): await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) From 5a6234d60e430a70cd05a2b9e0b271b9f2121389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20Roy?= Date: Thu, 16 Mar 2023 22:13:23 -0700 Subject: [PATCH 1029/2810] Bump aiobafi6 to 0.8.0 (#89840) --- homeassistant/components/baf/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/baf/manifest.json b/homeassistant/components/baf/manifest.json index 8229a912fb..b5b5b76967 100644 --- a/homeassistant/components/baf/manifest.json +++ b/homeassistant/components/baf/manifest.json @@ -5,7 +5,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/baf", "iot_class": "local_push", - "requirements": ["aiobafi6==0.7.3"], + "requirements": ["aiobafi6==0.8.0"], "zeroconf": [ { "type": "_api._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 5d1a0b634d..fc2fd2c26a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -131,7 +131,7 @@ aioasuswrt==1.4.0 aioazuredevops==1.3.5 # homeassistant.components.baf -aiobafi6==0.7.3 +aiobafi6==0.8.0 # homeassistant.components.aws aiobotocore==2.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ffca3813dd..042f2dd492 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -121,7 +121,7 @@ aioasuswrt==1.4.0 aioazuredevops==1.3.5 # homeassistant.components.baf -aiobafi6==0.7.3 +aiobafi6==0.8.0 # homeassistant.components.aws aiobotocore==2.1.0 From d671d7fc1fce4f6c41222dd66fb04ce59a543a41 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Mar 2023 19:13:42 -1000 Subject: [PATCH 1030/2810] Add native_step to baf (#89780) --- homeassistant/components/baf/number.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/baf/number.py b/homeassistant/components/baf/number.py index ca4b591cf9..91fe110d38 100644 --- a/homeassistant/components/baf/number.py +++ b/homeassistant/components/baf/number.py @@ -39,6 +39,7 @@ AUTO_COMFORT_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="comfort_min_speed", name="Auto Comfort Minimum Speed", + native_step=1, native_min_value=0, native_max_value=SPEED_RANGE[1] - 1, entity_category=EntityCategory.CONFIG, @@ -48,6 +49,7 @@ AUTO_COMFORT_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="comfort_max_speed", name="Auto Comfort Maximum Speed", + native_step=1, native_min_value=1, native_max_value=SPEED_RANGE[1], entity_category=EntityCategory.CONFIG, @@ -57,6 +59,7 @@ AUTO_COMFORT_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="comfort_heat_assist_speed", name="Auto Comfort Heat Assist Speed", + native_step=1, native_min_value=SPEED_RANGE[0], native_max_value=SPEED_RANGE[1], entity_category=EntityCategory.CONFIG, @@ -69,6 +72,7 @@ FAN_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="return_to_auto_timeout", name="Return to Auto Timeout", + native_step=1, native_min_value=ONE_MIN_SECS, native_max_value=HALF_DAY_SECS, entity_category=EntityCategory.CONFIG, @@ -79,6 +83,7 @@ FAN_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="motion_sense_timeout", name="Motion Sense Timeout", + native_step=1, native_min_value=ONE_MIN_SECS, native_max_value=ONE_DAY_SECS, entity_category=EntityCategory.CONFIG, @@ -92,6 +97,7 @@ LIGHT_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="light_return_to_auto_timeout", name="Light Return to Auto Timeout", + native_step=1, native_min_value=ONE_MIN_SECS, native_max_value=HALF_DAY_SECS, entity_category=EntityCategory.CONFIG, @@ -102,6 +108,7 @@ LIGHT_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="light_auto_motion_timeout", name="Light Motion Sense Timeout", + native_step=1, native_min_value=ONE_MIN_SECS, native_max_value=ONE_DAY_SECS, entity_category=EntityCategory.CONFIG, From dbb2706c76de3c04958c3d0d97cd052106805767 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Mar 2023 21:07:14 -1000 Subject: [PATCH 1031/2810] Reduce number of tasks created by compiling missing statistics (#89835) --- homeassistant/components/recorder/core.py | 37 +---- .../components/recorder/statistics.py | 143 ++++++++++++------ homeassistant/components/recorder/tasks.py | 12 ++ 3 files changed, 118 insertions(+), 74 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 32a6e9c24d..db44baee06 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -14,7 +14,7 @@ import time from typing import Any, TypeVar import async_timeout -from sqlalchemy import create_engine, event as sqlalchemy_event, exc, func, select +from sqlalchemy import create_engine, event as sqlalchemy_event, exc, select from sqlalchemy.engine import Engine from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import scoped_session, sessionmaker @@ -62,17 +62,10 @@ from .db_schema import ( States, StatesMeta, Statistics, - StatisticsRuns, StatisticsShortTerm, ) from .executor import DBInterruptibleThreadPoolExecutor -from .models import ( - DatabaseEngine, - StatisticData, - StatisticMetaData, - UnsupportedDialect, - process_timestamp, -) +from .models import DatabaseEngine, StatisticData, StatisticMetaData, UnsupportedDialect from .pool import POOL_SIZE, MutexPool, RecorderPool from .queries import ( has_entity_ids_to_migrate, @@ -93,6 +86,7 @@ from .tasks import ( ChangeStatisticsUnitTask, ClearStatisticsTask, CommitTask, + CompileMissingStatisticsTask, DatabaseLockTask, EntityIDMigrationTask, EventsContextIDMigrationTask, @@ -680,9 +674,7 @@ class Recorder(threading.Thread): self._activate_and_set_db_ready() # Catch up with missed statistics - with session_scope(session=self.get_session()) as session: - self._schedule_compile_missing_statistics(session) - + self._schedule_compile_missing_statistics() _LOGGER.debug("Recorder processing the queue") self._adjust_lru_size() self.hass.add_job(self._async_set_recorder_ready_migration_done) @@ -1295,26 +1287,9 @@ class Recorder(threading.Thread): self._open_event_session() - def _schedule_compile_missing_statistics(self, session: Session) -> None: + def _schedule_compile_missing_statistics(self) -> None: """Add tasks for missing statistics runs.""" - now = dt_util.utcnow() - last_period_minutes = now.minute - now.minute % 5 - last_period = now.replace(minute=last_period_minutes, second=0, microsecond=0) - start = now - timedelta(days=self.keep_days) - start = start.replace(minute=0, second=0, microsecond=0) - - # Find the newest statistics run, if any - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - if last_run := session.query(func.max(StatisticsRuns.start)).scalar(): - start = max(start, process_timestamp(last_run) + timedelta(minutes=5)) - - # Add tasks - while start < last_period: - end = start + timedelta(minutes=5) - _LOGGER.debug("Compiling missing statistics for %s-%s", start, end) - self.queue_task(StatisticsTask(start, end >= last_period)) - start = end + self.queue_task(CompileMissingStatisticsTask()) def _end_session(self) -> None: """End the recorder session.""" diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 36874869bf..fcd934270d 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -72,6 +72,7 @@ from .models import ( StatisticMetaData, StatisticResult, datetime_to_timestamp_or_none, + process_timestamp, ) from .util import ( database_job_retry_wrapper, @@ -685,69 +686,125 @@ def _compile_hourly_statistics(session: Session, start: datetime) -> None: ) -@retryable_database_job("statistics") +@retryable_database_job("compile missing statistics") +def compile_missing_statistics(instance: Recorder) -> bool: + """Compile missing statistics.""" + now = dt_util.utcnow() + period_size = 5 + last_period_minutes = now.minute - now.minute % period_size + last_period = now.replace(minute=last_period_minutes, second=0, microsecond=0) + start = now - timedelta(days=instance.keep_days) + start = start.replace(minute=0, second=0, microsecond=0) + # Commit every 12 hours of data + commit_interval = 60 / period_size * 12 + + with session_scope( + session=instance.get_session(), + exception_filter=_filter_unique_constraint_integrity_error(instance), + ) as session: + # Find the newest statistics run, if any + # https://github.com/sqlalchemy/sqlalchemy/issues/9189 + # pylint: disable-next=not-callable + if last_run := session.query(func.max(StatisticsRuns.start)).scalar(): + start = max(start, process_timestamp(last_run) + timedelta(minutes=5)) + + periods_without_commit = 0 + while start < last_period: + periods_without_commit += 1 + end = start + timedelta(minutes=period_size) + _LOGGER.debug("Compiling missing statistics for %s-%s", start, end) + metadata_modified = _compile_statistics( + instance, session, start, end >= last_period + ) + if periods_without_commit == commit_interval or metadata_modified: + session.commit() + session.expunge_all() + periods_without_commit = 0 + start = end + + return True + + +@retryable_database_job("compile statistics") def compile_statistics(instance: Recorder, start: datetime, fire_events: bool) -> bool: """Compile 5-minute statistics for all integrations with a recorder platform. The actual calculation is delegated to the platforms. """ - start = dt_util.as_utc(start) - end = start + timedelta(minutes=5) - statistics_meta_manager = instance.statistics_meta_manager - # Return if we already have 5-minute statistics for the requested period with session_scope( session=instance.get_session(), exception_filter=_filter_unique_constraint_integrity_error(instance), ) as session: - if session.query(StatisticsRuns).filter_by(start=start).first(): - _LOGGER.debug("Statistics already compiled for %s-%s", start, end) - return True + _compile_statistics(instance, session, start, fire_events) + return True - _LOGGER.debug("Compiling statistics for %s-%s", start, end) - platform_stats: list[StatisticResult] = [] - current_metadata: dict[str, tuple[int, StatisticMetaData]] = {} - # Collect statistics from all platforms implementing support - for domain, platform in instance.hass.data[DOMAIN].recorder_platforms.items(): - if not hasattr(platform, "compile_statistics"): - continue - compiled: PlatformCompiledStatistics = platform.compile_statistics( - instance.hass, start, end - ) - _LOGGER.debug( - "Statistics for %s during %s-%s: %s", - domain, - start, - end, - compiled.platform_stats, - ) - platform_stats.extend(compiled.platform_stats) - current_metadata.update(compiled.current_metadata) - # Insert collected statistics in the database - for stats in platform_stats: - _, metadata_id = statistics_meta_manager.update_or_add( - session, stats["meta"], current_metadata - ) - _insert_statistics( - session, - StatisticsShortTerm, - metadata_id, - stats["stat"], - ) +def _compile_statistics( + instance: Recorder, session: Session, start: datetime, fire_events: bool +) -> bool: + """Compile 5-minute statistics for all integrations with a recorder platform. - if start.minute == 55: - # A full hour is ready, summarize it - _compile_hourly_statistics(session, start) + This is a helper function for compile_statistics and compile_missing_statistics + that does not retry on database errors since both callers already retry. - session.add(StatisticsRuns(start=start)) + returns True if metadata was modified, False otherwise + """ + assert start.tzinfo == dt_util.UTC, "start must be in UTC" + end = start + timedelta(minutes=5) + statistics_meta_manager = instance.statistics_meta_manager + metadata_modified = False + + # Return if we already have 5-minute statistics for the requested period + if session.query(StatisticsRuns).filter_by(start=start).first(): + _LOGGER.debug("Statistics already compiled for %s-%s", start, end) + return metadata_modified + + _LOGGER.debug("Compiling statistics for %s-%s", start, end) + platform_stats: list[StatisticResult] = [] + current_metadata: dict[str, tuple[int, StatisticMetaData]] = {} + # Collect statistics from all platforms implementing support + for domain, platform in instance.hass.data[DOMAIN].recorder_platforms.items(): + if not hasattr(platform, "compile_statistics"): + continue + compiled: PlatformCompiledStatistics = platform.compile_statistics( + instance.hass, start, end + ) + _LOGGER.debug( + "Statistics for %s during %s-%s: %s", + domain, + start, + end, + compiled.platform_stats, + ) + platform_stats.extend(compiled.platform_stats) + current_metadata.update(compiled.current_metadata) + + # Insert collected statistics in the database + for stats in platform_stats: + updated, metadata_id = statistics_meta_manager.update_or_add( + session, stats["meta"], current_metadata + ) + metadata_modified |= updated + _insert_statistics( + session, + StatisticsShortTerm, + metadata_id, + stats["stat"], + ) + + if start.minute == 55: + # A full hour is ready, summarize it + _compile_hourly_statistics(session, start) + + session.add(StatisticsRuns(start=start)) if fire_events: instance.hass.bus.fire(EVENT_RECORDER_5MIN_STATISTICS_GENERATED) if start.minute == 55: instance.hass.bus.fire(EVENT_RECORDER_HOURLY_STATISTICS_GENERATED) - return True + return metadata_modified def _adjust_sum_statistics( diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index f2ba42bdea..5762a9ab69 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -151,6 +151,18 @@ class StatisticsTask(RecorderTask): instance.queue_task(StatisticsTask(self.start, self.fire_events)) +@dataclass +class CompileMissingStatisticsTask(RecorderTask): + """An object to insert into the recorder queue to run a compile missing statistics.""" + + def run(self, instance: Recorder) -> None: + """Run statistics task to compile missing statistics.""" + if statistics.compile_missing_statistics(instance): + return + # Schedule a new statistics task if this one didn't finish + instance.queue_task(CompileMissingStatisticsTask()) + + @dataclass class ImportStatisticsTask(RecorderTask): """An object to insert into the recorder queue to run an import statistics task.""" From ab4a726e6cfcf171711f416c70d77920816c364b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 17 Mar 2023 10:22:02 +0100 Subject: [PATCH 1032/2810] Add tmpdir to known fixtures in pylint (#89844) --- pylint/plugins/hass_enforce_type_hints.py | 1 + tests/components/filesize/test_init.py | 8 +++--- tests/components/filesize/test_sensor.py | 6 +++-- tests/components/http/test_init.py | 25 ++++++++++++------- .../lutron_caseta/test_config_flow.py | 15 +++++++---- tests/components/profiler/test_init.py | 7 +++--- tests/components/recorder/test_init.py | 9 ++++--- tests/components/recorder/test_statistics.py | 7 ++++-- .../recorder/test_statistics_v23_migration.py | 15 ++++++++--- tests/components/recorder/test_util.py | 3 ++- .../components/recorder/test_v32_migration.py | 5 +++- tests/helpers/test_storage.py | 3 ++- tests/helpers/test_storage_remove.py | 4 ++- tests/test_runner.py | 11 +++++--- tests/util/test_file.py | 11 ++++---- 15 files changed, 86 insertions(+), 44 deletions(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 4092beb3da..c63fde19c8 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -142,6 +142,7 @@ _TEST_FIXTURES: dict[str, list[str] | str] = { "requests_mock": "requests_mock.Mocker", "snapshot": "SnapshotAssertion", "tmp_path": "Path", + "tmpdir": "py.path.local", } _TEST_FUNCTION_MATCH = TypeHintMatch( function_name="test_*", diff --git a/tests/components/filesize/test_init.py b/tests/components/filesize/test_init.py index effab8f75d..7c0526b819 100644 --- a/tests/components/filesize/test_init.py +++ b/tests/components/filesize/test_init.py @@ -1,4 +1,6 @@ """Tests for the Filesize integration.""" +import py + from homeassistant.components.filesize.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_FILE_PATH @@ -10,7 +12,7 @@ from tests.common import MockConfigEntry async def test_load_unload_config_entry( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, tmpdir: str + hass: HomeAssistant, mock_config_entry: MockConfigEntry, tmpdir: py.path.local ) -> None: """Test the Filesize configuration entry loading/unloading.""" testfile = f"{tmpdir}/file.txt" @@ -33,7 +35,7 @@ async def test_load_unload_config_entry( async def test_cannot_access_file( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, tmpdir: str + hass: HomeAssistant, mock_config_entry: MockConfigEntry, tmpdir: py.path.local ) -> None: """Test that an file not exist is caught.""" mock_config_entry.add_to_hass(hass) @@ -50,7 +52,7 @@ async def test_cannot_access_file( async def test_not_valid_path_to_file( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, tmpdir: str + hass: HomeAssistant, mock_config_entry: MockConfigEntry, tmpdir: py.path.local ) -> None: """Test that an invalid path is caught.""" testfile = f"{tmpdir}/file.txt" diff --git a/tests/components/filesize/test_sensor.py b/tests/components/filesize/test_sensor.py index 5b072769f5..bef0eceb65 100644 --- a/tests/components/filesize/test_sensor.py +++ b/tests/components/filesize/test_sensor.py @@ -1,6 +1,8 @@ """The tests for the filesize sensor.""" import os +import py + from homeassistant.const import CONF_FILE_PATH, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_component import async_update_entity @@ -24,7 +26,7 @@ async def test_invalid_path( async def test_valid_path( - hass: HomeAssistant, tmpdir: str, mock_config_entry: MockConfigEntry + hass: HomeAssistant, tmpdir: py.path.local, mock_config_entry: MockConfigEntry ) -> None: """Test for a valid path.""" testfile = f"{tmpdir}/file.txt" @@ -46,7 +48,7 @@ async def test_valid_path( async def test_state_unavailable( - hass: HomeAssistant, tmpdir: str, mock_config_entry: MockConfigEntry + hass: HomeAssistant, tmpdir: py.path.local, mock_config_entry: MockConfigEntry ) -> None: """Verify we handle state unavailable.""" testfile = f"{tmpdir}/file.txt" diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index f5a69bd864..578fcc60c7 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -6,6 +6,7 @@ import logging import pathlib from unittest.mock import Mock, patch +import py import pytest from homeassistant.auth.providers.legacy_api_password import ( @@ -150,7 +151,9 @@ async def test_proxy_config_only_trust_proxies(hass: HomeAssistant) -> None: ) -async def test_ssl_profile_defaults_modern(hass: HomeAssistant, tmpdir) -> None: +async def test_ssl_profile_defaults_modern( + hass: HomeAssistant, tmpdir: py.path.local +) -> None: """Test default ssl profile.""" cert_path, key_path, _ = await hass.async_add_executor_job( @@ -175,7 +178,9 @@ async def test_ssl_profile_defaults_modern(hass: HomeAssistant, tmpdir) -> None: assert len(mock_context.mock_calls) == 1 -async def test_ssl_profile_change_intermediate(hass: HomeAssistant, tmpdir) -> None: +async def test_ssl_profile_change_intermediate( + hass: HomeAssistant, tmpdir: py.path.local +) -> None: """Test setting ssl profile to intermediate.""" cert_path, key_path, _ = await hass.async_add_executor_job( @@ -206,7 +211,9 @@ async def test_ssl_profile_change_intermediate(hass: HomeAssistant, tmpdir) -> N assert len(mock_context.mock_calls) == 1 -async def test_ssl_profile_change_modern(hass: HomeAssistant, tmpdir) -> None: +async def test_ssl_profile_change_modern( + hass: HomeAssistant, tmpdir: py.path.local +) -> None: """Test setting ssl profile to modern.""" cert_path, key_path, _ = await hass.async_add_executor_job( @@ -237,7 +244,7 @@ async def test_ssl_profile_change_modern(hass: HomeAssistant, tmpdir) -> None: assert len(mock_context.mock_calls) == 1 -async def test_peer_cert(hass: HomeAssistant, tmpdir) -> None: +async def test_peer_cert(hass: HomeAssistant, tmpdir: py.path.local) -> None: """Test required peer cert.""" cert_path, key_path, peer_cert_path = await hass.async_add_executor_job( _setup_empty_ssl_pem_files, tmpdir @@ -272,7 +279,7 @@ async def test_peer_cert(hass: HomeAssistant, tmpdir) -> None: async def test_emergency_ssl_certificate_when_invalid( - hass: HomeAssistant, tmpdir, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, tmpdir: py.path.local, caplog: pytest.LogCaptureFixture ) -> None: """Test http can startup with an emergency self signed cert when the current one is broken.""" @@ -303,7 +310,7 @@ async def test_emergency_ssl_certificate_when_invalid( async def test_emergency_ssl_certificate_not_used_when_not_safe_mode( - hass: HomeAssistant, tmpdir, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, tmpdir: py.path.local, caplog: pytest.LogCaptureFixture ) -> None: """Test an emergency cert is only used in safe mode.""" @@ -320,7 +327,7 @@ async def test_emergency_ssl_certificate_not_used_when_not_safe_mode( async def test_emergency_ssl_certificate_when_invalid_get_url_fails( - hass: HomeAssistant, tmpdir, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, tmpdir: py.path.local, caplog: pytest.LogCaptureFixture ) -> None: """Test http falls back to no ssl when an emergency cert cannot be created when the configured one is broken. @@ -357,7 +364,7 @@ async def test_emergency_ssl_certificate_when_invalid_get_url_fails( async def test_invalid_ssl_and_cannot_create_emergency_cert( - hass: HomeAssistant, tmpdir, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, tmpdir: py.path.local, caplog: pytest.LogCaptureFixture ) -> None: """Test http falls back to no ssl when an emergency cert cannot be created when the configured one is broken.""" @@ -388,7 +395,7 @@ async def test_invalid_ssl_and_cannot_create_emergency_cert( async def test_invalid_ssl_and_cannot_create_emergency_cert_with_ssl_peer_cert( - hass: HomeAssistant, tmpdir, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, tmpdir: py.path.local, caplog: pytest.LogCaptureFixture ) -> None: """Test http falls back to no ssl when an emergency cert cannot be created when the configured one is broken. diff --git a/tests/components/lutron_caseta/test_config_flow.py b/tests/components/lutron_caseta/test_config_flow.py index cc71eb5910..9518528714 100644 --- a/tests/components/lutron_caseta/test_config_flow.py +++ b/tests/components/lutron_caseta/test_config_flow.py @@ -3,6 +3,7 @@ import asyncio import ssl from unittest.mock import AsyncMock, patch +import py from pylutron_caseta.pairing import PAIR_CA, PAIR_CERT, PAIR_KEY from pylutron_caseta.smartbridge import Smartbridge import pytest @@ -192,7 +193,7 @@ async def test_already_configured_with_ignored(hass: HomeAssistant) -> None: assert result["type"] == "form" -async def test_form_user(hass: HomeAssistant, tmpdir) -> None: +async def test_form_user(hass: HomeAssistant, tmpdir: py.path.local) -> None: """Test we get the form and can pair.""" hass.config.config_dir = await hass.async_add_executor_job( @@ -243,7 +244,9 @@ async def test_form_user(hass: HomeAssistant, tmpdir) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_user_pairing_fails(hass: HomeAssistant, tmpdir) -> None: +async def test_form_user_pairing_fails( + hass: HomeAssistant, tmpdir: py.path.local +) -> None: """Test we get the form and we handle pairing failure.""" hass.config.config_dir = await hass.async_add_executor_job( @@ -289,7 +292,7 @@ async def test_form_user_pairing_fails(hass: HomeAssistant, tmpdir) -> None: async def test_form_user_reuses_existing_assets_when_pairing_again( - hass: HomeAssistant, tmpdir + hass: HomeAssistant, tmpdir: py.path.local ) -> None: """Test the tls assets saved on disk are reused when pairing again.""" @@ -390,7 +393,9 @@ async def test_form_user_reuses_existing_assets_when_pairing_again( } -async def test_zeroconf_host_already_configured(hass: HomeAssistant, tmpdir) -> None: +async def test_zeroconf_host_already_configured( + hass: HomeAssistant, tmpdir: py.path.local +) -> None: """Test starting a flow from discovery when the host is already configured.""" hass.config.config_dir = await hass.async_add_executor_job( @@ -474,7 +479,7 @@ async def test_zeroconf_not_lutron_device(hass: HomeAssistant) -> None: @pytest.mark.parametrize( "source", (config_entries.SOURCE_ZEROCONF, config_entries.SOURCE_HOMEKIT) ) -async def test_zeroconf(hass: HomeAssistant, source, tmpdir) -> None: +async def test_zeroconf(hass: HomeAssistant, source, tmpdir: py.path.local) -> None: """Test starting a flow from discovery.""" hass.config.config_dir = await hass.async_add_executor_job( diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index 636067341e..0f46f306fe 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -4,6 +4,7 @@ import os import sys from unittest.mock import patch +import py import pytest from homeassistant.components.profiler import ( @@ -25,7 +26,7 @@ import homeassistant.util.dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed -async def test_basic_usage(hass: HomeAssistant, tmpdir) -> None: +async def test_basic_usage(hass: HomeAssistant, tmpdir: py.path.local) -> None: """Test we can setup and the service is registered.""" test_dir = tmpdir.mkdir("profiles") @@ -58,7 +59,7 @@ async def test_basic_usage(hass: HomeAssistant, tmpdir) -> None: @pytest.mark.skipif( sys.version_info >= (3, 11), reason="not yet available on python 3.11" ) -async def test_memory_usage(hass: HomeAssistant, tmpdir) -> None: +async def test_memory_usage(hass: HomeAssistant, tmpdir: py.path.local) -> None: """Test we can setup and the service is registered.""" test_dir = tmpdir.mkdir("profiles") @@ -89,7 +90,7 @@ async def test_memory_usage(hass: HomeAssistant, tmpdir) -> None: @pytest.mark.skipif(sys.version_info < (3, 11), reason="still works on python 3.10") -async def test_memory_usage_py311(hass: HomeAssistant, tmpdir) -> None: +async def test_memory_usage_py311(hass: HomeAssistant, tmpdir: py.path.local) -> None: """Test raise an error on python3.11.""" entry = MockConfigEntry(domain=DOMAIN) entry.add_to_hass(hass) diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 8c1d8ef00a..48429d7a11 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -11,6 +11,7 @@ from typing import cast from unittest.mock import Mock, patch from freezegun.api import FrozenDateTimeFactory +import py import pytest from sqlalchemy.exc import DatabaseError, OperationalError, SQLAlchemyError @@ -1222,7 +1223,9 @@ def test_statistics_runs_initiated(hass_recorder: Callable[..., HomeAssistant]) @pytest.mark.freeze_time("2022-09-13 09:00:00+02:00") -def test_compile_missing_statistics(tmpdir, freezer: FrozenDateTimeFactory) -> None: +def test_compile_missing_statistics( + tmpdir: py.path.local, freezer: FrozenDateTimeFactory +) -> None: """Test missing statistics are compiled on startup.""" now = dt_util.utcnow().replace(minute=0, second=0, microsecond=0) test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") @@ -1482,7 +1485,7 @@ def test_service_disable_states_not_recording( ) -def test_service_disable_run_information_recorded(tmpdir) -> None: +def test_service_disable_run_information_recorded(tmpdir: py.path.local) -> None: """Test that runs are still recorded when recorder is disabled.""" test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" @@ -1531,7 +1534,7 @@ class CannotSerializeMe: async def test_database_corruption_while_running( - hass: HomeAssistant, tmpdir, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, tmpdir: py.path.local, caplog: pytest.LogCaptureFixture ) -> None: """Test we can recover from sqlite3 db corruption.""" diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 46d2c92e46..522a3eff2e 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -7,6 +7,7 @@ import importlib import sys from unittest.mock import ANY, DEFAULT, MagicMock, patch, sentinel +import py import pytest from sqlalchemy import create_engine, select from sqlalchemy.exc import OperationalError @@ -1327,7 +1328,9 @@ def _create_engine_28(*args, **kwargs): return engine -def test_delete_metadata_duplicates(caplog: pytest.LogCaptureFixture, tmpdir) -> None: +def test_delete_metadata_duplicates( + caplog: pytest.LogCaptureFixture, tmpdir: py.path.local +) -> None: """Test removal of duplicated statistics.""" test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" @@ -1419,7 +1422,7 @@ def test_delete_metadata_duplicates(caplog: pytest.LogCaptureFixture, tmpdir) -> def test_delete_metadata_duplicates_many( - caplog: pytest.LogCaptureFixture, tmpdir + caplog: pytest.LogCaptureFixture, tmpdir: py.path.local ) -> None: """Test removal of duplicated statistics.""" test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") diff --git a/tests/components/recorder/test_statistics_v23_migration.py b/tests/components/recorder/test_statistics_v23_migration.py index ec36d7eb83..48db847869 100644 --- a/tests/components/recorder/test_statistics_v23_migration.py +++ b/tests/components/recorder/test_statistics_v23_migration.py @@ -9,6 +9,7 @@ import json import sys from unittest.mock import patch +import py import pytest from sqlalchemy import create_engine from sqlalchemy.orm import Session @@ -52,7 +53,9 @@ def _create_engine_test(*args, **kwargs): return engine -def test_delete_duplicates(caplog: pytest.LogCaptureFixture, tmpdir) -> None: +def test_delete_duplicates( + caplog: pytest.LogCaptureFixture, tmpdir: py.path.local +) -> None: """Test removal of duplicated statistics.""" test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" @@ -222,7 +225,9 @@ def test_delete_duplicates(caplog: pytest.LogCaptureFixture, tmpdir) -> None: assert "Found duplicated" not in caplog.text -def test_delete_duplicates_many(caplog: pytest.LogCaptureFixture, tmpdir) -> None: +def test_delete_duplicates_many( + caplog: pytest.LogCaptureFixture, tmpdir: py.path.local +) -> None: """Test removal of duplicated statistics.""" test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" @@ -400,7 +405,7 @@ def test_delete_duplicates_many(caplog: pytest.LogCaptureFixture, tmpdir) -> Non @pytest.mark.freeze_time("2021-08-01 00:00:00+00:00") def test_delete_duplicates_non_identical( - caplog: pytest.LogCaptureFixture, tmpdir + caplog: pytest.LogCaptureFixture, tmpdir: py.path.local ) -> None: """Test removal of duplicated statistics.""" test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") @@ -572,7 +577,9 @@ def test_delete_duplicates_non_identical( ] -def test_delete_duplicates_short_term(caplog: pytest.LogCaptureFixture, tmpdir) -> None: +def test_delete_duplicates_short_term( + caplog: pytest.LogCaptureFixture, tmpdir: py.path.local +) -> None: """Test removal of duplicated statistics.""" test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 6aad3e440d..383a083843 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -7,6 +7,7 @@ import sqlite3 from unittest.mock import MagicMock, Mock, patch from freezegun import freeze_time +import py import pytest from sqlalchemy import text from sqlalchemy.engine.result import ChunkedIteratorResult @@ -73,7 +74,7 @@ def test_recorder_bad_execute(hass_recorder: Callable[..., HomeAssistant]) -> No def test_validate_or_move_away_sqlite_database( - hass: HomeAssistant, tmpdir, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, tmpdir: py.path.local, caplog: pytest.LogCaptureFixture ) -> None: """Ensure a malformed sqlite database is moved away.""" diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index 467dc2961c..22aa96f8e2 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -6,6 +6,7 @@ import importlib import sys from unittest.mock import patch +import py import pytest from sqlalchemy import create_engine, inspect from sqlalchemy.orm import Session @@ -51,7 +52,9 @@ def _create_engine_test(*args, **kwargs): return engine -async def test_migrate_times(caplog: pytest.LogCaptureFixture, tmpdir) -> None: +async def test_migrate_times( + caplog: pytest.LogCaptureFixture, tmpdir: py.path.local +) -> None: """Test we can migrate times.""" test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" diff --git a/tests/helpers/test_storage.py b/tests/helpers/test_storage.py index 77e5fa4aef..b60e072d2f 100644 --- a/tests/helpers/test_storage.py +++ b/tests/helpers/test_storage.py @@ -5,6 +5,7 @@ import json from typing import Any, NamedTuple from unittest.mock import Mock, patch +import py import pytest from homeassistant.const import ( @@ -505,7 +506,7 @@ async def test_changing_delayed_written_data( } -async def test_saving_load_round_trip(tmpdir) -> None: +async def test_saving_load_round_trip(tmpdir: py.path.local) -> None: """Test saving and loading round trip.""" loop = asyncio.get_running_loop() hass = await async_test_home_assistant(loop) diff --git a/tests/helpers/test_storage_remove.py b/tests/helpers/test_storage_remove.py index b90d0df0d0..21eabe80e4 100644 --- a/tests/helpers/test_storage_remove.py +++ b/tests/helpers/test_storage_remove.py @@ -4,13 +4,15 @@ from datetime import timedelta import os from unittest.mock import patch +import py + from homeassistant.helpers import storage from homeassistant.util import dt from tests.common import async_fire_time_changed, async_test_home_assistant -async def test_removing_while_delay_in_progress(tmpdir) -> None: +async def test_removing_while_delay_in_progress(tmpdir: py.path.local) -> None: """Test removing while delay in progress.""" loop = asyncio.get_event_loop() diff --git a/tests/test_runner.py b/tests/test_runner.py index e4af1df2b8..f32321c578 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -3,6 +3,7 @@ import asyncio import threading from unittest.mock import patch +import py import pytest from homeassistant import core, runner @@ -28,7 +29,7 @@ async def test_cumulative_shutdown_timeout_less_than_supervisor() -> None: ) -async def test_setup_and_run_hass(hass: HomeAssistant, tmpdir) -> None: +async def test_setup_and_run_hass(hass: HomeAssistant, tmpdir: py.path.local) -> None: """Test we can setup and run.""" test_dir = tmpdir.mkdir("config") default_config = runner.RuntimeConfig(test_dir) @@ -42,7 +43,7 @@ async def test_setup_and_run_hass(hass: HomeAssistant, tmpdir) -> None: assert mock_run.called -def test_run(hass: HomeAssistant, tmpdir) -> None: +def test_run(hass: HomeAssistant, tmpdir: py.path.local) -> None: """Test we can run.""" test_dir = tmpdir.mkdir("config") default_config = runner.RuntimeConfig(test_dir) @@ -57,7 +58,9 @@ def test_run(hass: HomeAssistant, tmpdir) -> None: assert mock_run.called -def test_run_executor_shutdown_throws(hass: HomeAssistant, tmpdir) -> None: +def test_run_executor_shutdown_throws( + hass: HomeAssistant, tmpdir: py.path.local +) -> None: """Test we can run and we still shutdown if the executor shutdown throws.""" test_dir = tmpdir.mkdir("config") default_config = runner.RuntimeConfig(test_dir) @@ -79,7 +82,7 @@ def test_run_executor_shutdown_throws(hass: HomeAssistant, tmpdir) -> None: def test_run_does_not_block_forever_with_shielded_task( - hass: HomeAssistant, tmpdir, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, tmpdir: py.path.local, caplog: pytest.LogCaptureFixture ) -> None: """Test we can shutdown and not block forever.""" test_dir = tmpdir.mkdir("config") diff --git a/tests/util/test_file.py b/tests/util/test_file.py index 5934dc689f..0b87985fe1 100644 --- a/tests/util/test_file.py +++ b/tests/util/test_file.py @@ -3,13 +3,14 @@ import os from pathlib import Path from unittest.mock import patch +import py import pytest from homeassistant.util.file import WriteError, write_utf8_file, write_utf8_file_atomic @pytest.mark.parametrize("func", [write_utf8_file, write_utf8_file_atomic]) -def test_write_utf8_file_atomic_private(tmpdir, func) -> None: +def test_write_utf8_file_atomic_private(tmpdir: py.path.local, func) -> None: """Test files can be written as 0o600 or 0o644.""" test_dir = tmpdir.mkdir("files") test_file = Path(test_dir / "test.json") @@ -25,7 +26,7 @@ def test_write_utf8_file_atomic_private(tmpdir, func) -> None: assert os.stat(test_file).st_mode & 0o777 == 0o600 -def test_write_utf8_file_fails_at_creation(tmpdir) -> None: +def test_write_utf8_file_fails_at_creation(tmpdir: py.path.local) -> None: """Test that failed creation of the temp file does not create an empty file.""" test_dir = tmpdir.mkdir("files") test_file = Path(test_dir / "test.json") @@ -39,7 +40,7 @@ def test_write_utf8_file_fails_at_creation(tmpdir) -> None: def test_write_utf8_file_fails_at_rename( - tmpdir, caplog: pytest.LogCaptureFixture + tmpdir: py.path.local, caplog: pytest.LogCaptureFixture ) -> None: """Test that if rename fails not not remove, we do not log the failed cleanup.""" test_dir = tmpdir.mkdir("files") @@ -56,7 +57,7 @@ def test_write_utf8_file_fails_at_rename( def test_write_utf8_file_fails_at_rename_and_remove( - tmpdir, caplog: pytest.LogCaptureFixture + tmpdir: py.path.local, caplog: pytest.LogCaptureFixture ) -> None: """Test that if rename and remove both fail, we log the failed cleanup.""" test_dir = tmpdir.mkdir("files") @@ -70,7 +71,7 @@ def test_write_utf8_file_fails_at_rename_and_remove( assert "File replacement cleanup failed" in caplog.text -def test_write_utf8_file_atomic_fails(tmpdir) -> None: +def test_write_utf8_file_atomic_fails(tmpdir: py.path.local) -> None: """Test OSError from write_utf8_file_atomic is rethrown as WriteError.""" test_dir = tmpdir.mkdir("files") test_file = Path(test_dir / "test.json") From aa72b4872553e59f3a5f30354c185f4f0d969cf2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Mar 2023 23:22:21 -1000 Subject: [PATCH 1033/2810] Mark recorder system_health session read_only (#89842) --- homeassistant/components/recorder/system_health/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/recorder/system_health/__init__.py b/homeassistant/components/recorder/system_health/__init__.py index da463d3861..76542a0c17 100644 --- a/homeassistant/components/recorder/system_health/__init__.py +++ b/homeassistant/components/recorder/system_health/__init__.py @@ -33,7 +33,7 @@ def async_register( def _get_db_stats(instance: Recorder, database_name: str) -> dict[str, Any]: """Get the stats about the database.""" db_stats: dict[str, Any] = {} - with session_scope(session=instance.get_session()) as session: + with session_scope(session=instance.get_session(), read_only=True) as session: if ( (dialect_name := instance.dialect_name) and (get_size := DIALECT_TO_GET_SIZE.get(dialect_name)) From abd91dd9345cb3e99d857ced485444901deb09cb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 17 Mar 2023 10:22:43 +0100 Subject: [PATCH 1034/2810] Ensure MockEntityPlatform shuts down after tests (#89849) --- tests/common.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/common.py b/tests/common.py index 8b9a9c2401..569813d221 100644 --- a/tests/common.py +++ b/tests/common.py @@ -37,6 +37,7 @@ from homeassistant.config import async_process_component_config from homeassistant.const import ( DEVICE_DEFAULT_NAME, EVENT_HOMEASSISTANT_CLOSE, + EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED, STATE_OFF, STATE_ON, @@ -757,7 +758,7 @@ class MockEntityPlatform(entity_platform.EntityPlatform): def __init__( self, - hass, + hass: HomeAssistant, logger=None, domain="test_domain", platform_name="test_platform", @@ -783,6 +784,11 @@ class MockEntityPlatform(entity_platform.EntityPlatform): entity_namespace=entity_namespace, ) + async def _async_on_stop(_: Event) -> None: + await self.async_shutdown() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_on_stop) + class MockToggleEntity(entity.ToggleEntity): """Provide a mock toggle device.""" From ab6e929443068070e628eacdda763896c644982b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 17 Mar 2023 10:26:05 +0100 Subject: [PATCH 1035/2810] Fix EntityComponent lingering timer in helper tests (#89801) * Fix lingering timer in entity platform tests * Tweak * Fix entity and entity_component also * Remove async_shutdown * Adjust * Adjust --- tests/helpers/test_entity_component.py | 9 +++++++++ tests/helpers/test_entity_platform.py | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 018fb6c937..4c9847bb3d 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -168,6 +168,7 @@ async def test_set_entity_namespace_via_config(hass: HomeAssistant) -> None: async def test_extract_from_service_available_device(hass: HomeAssistant) -> None: """Test the extraction of entity from service and device is available.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) await component.async_add_entities( [ MockEntity(name="test_1"), @@ -236,6 +237,7 @@ async def test_platform_not_ready(hass: HomeAssistant) -> None: async def test_extract_from_service_fails_if_no_entity_id(hass: HomeAssistant) -> None: """Test the extraction of everything from service.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) await component.async_add_entities( [MockEntity(name="test_1"), MockEntity(name="test_2")] ) @@ -262,6 +264,7 @@ async def test_extract_from_service_filter_out_non_existing_entities( ) -> None: """Test the extraction of non existing entities from service.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) await component.async_add_entities( [MockEntity(name="test_1"), MockEntity(name="test_2")] ) @@ -280,6 +283,7 @@ async def test_extract_from_service_filter_out_non_existing_entities( async def test_extract_from_service_no_group_expand(hass: HomeAssistant) -> None: """Test not expanding a group.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) await component.async_add_entities([MockEntity(entity_id="group.test_group")]) call = ServiceCall("test", "service", {"entity_id": ["group.test_group"]}) @@ -395,6 +399,7 @@ async def test_unload_entry_fails_if_never_loaded(hass: HomeAssistant) -> None: async def test_update_entity(hass: HomeAssistant) -> None: """Test that we can update an entity with the helper.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) entity = MockEntity() entity.async_write_ha_state = Mock() entity.async_update_ha_state = AsyncMock(return_value=None) @@ -422,6 +427,7 @@ async def test_set_service_race(hass: HomeAssistant) -> None: await async_setup_component(hass, "group", {}) component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) for _ in range(2): hass.async_create_task(component.async_add_entities([MockEntity()])) @@ -435,6 +441,7 @@ async def test_extract_all_omit_entity_id( ) -> None: """Test extract all with None and *.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) await component.async_add_entities( [MockEntity(name="test_1"), MockEntity(name="test_2")] ) @@ -451,6 +458,7 @@ async def test_extract_all_use_match_all( ) -> None: """Test extract all with None and *.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) await component.async_add_entities( [MockEntity(name="test_1"), MockEntity(name="test_2")] ) @@ -477,6 +485,7 @@ async def test_register_entity_service(hass: HomeAssistant) -> None: entity.async_called_by_service = appender component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) await component.async_add_entities([entity]) component.async_register_entity_service( diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 7163461ae1..56872fe5b4 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -46,6 +46,7 @@ async def test_polling_only_updates_entities_it_should_poll( ) -> None: """Test the polling of only updated entities.""" component = EntityComponent(_LOGGER, DOMAIN, hass, timedelta(seconds=20)) + await component.async_setup({}) no_poll_ent = MockEntity(should_poll=False) no_poll_ent.async_update = Mock() @@ -78,6 +79,7 @@ async def test_polling_disabled_by_config_entry(hass: HomeAssistant) -> None: async def test_polling_updates_entities_with_exception(hass: HomeAssistant) -> None: """Test the updated entities that not break with an exception.""" component = EntityComponent(_LOGGER, DOMAIN, hass, timedelta(seconds=20)) + await component.async_setup({}) update_ok = [] update_err = [] @@ -115,6 +117,7 @@ async def test_polling_updates_entities_with_exception(hass: HomeAssistant) -> N async def test_update_state_adds_entities(hass: HomeAssistant) -> None: """Test if updating poll entities cause an entity to be added works.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) ent1 = MockEntity() ent2 = MockEntity(should_poll=True) @@ -134,6 +137,7 @@ async def test_update_state_adds_entities_with_update_before_add_true( ) -> None: """Test if call update before add to state machine.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) ent = MockEntity() ent.update = Mock(spec_set=True) @@ -150,6 +154,7 @@ async def test_update_state_adds_entities_with_update_before_add_false( ) -> None: """Test if not call update before add to state machine.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) ent = MockEntity() ent.update = Mock(spec_set=True) @@ -199,6 +204,7 @@ async def test_adding_entities_with_generator_and_thread_callback( it into an async context. """ component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) def create_entity(number: int) -> MockEntity: """Create entity helper.""" @@ -259,6 +265,7 @@ async def test_platform_error_slow_setup( async def test_updated_state_used_for_entity_id(hass: HomeAssistant) -> None: """Test that first update results used for entity ID generation.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) class MockEntityNameFetcher(MockEntity): """Mock entity that fetches a friendly name.""" @@ -407,6 +414,7 @@ async def test_raise_error_on_update(hass: HomeAssistant) -> None: """Test the add entity if they raise an error on update.""" updates = [] component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) entity1 = MockEntity(name="test_1") entity2 = MockEntity(name="test_2") @@ -431,6 +439,7 @@ async def test_raise_error_on_update(hass: HomeAssistant) -> None: async def test_async_remove_with_platform(hass: HomeAssistant) -> None: """Remove an entity from a platform.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) entity1 = MockEntity(name="test_1") await component.async_add_entities([entity1]) assert len(hass.states.async_entity_ids()) == 1 @@ -441,6 +450,7 @@ async def test_async_remove_with_platform(hass: HomeAssistant) -> None: async def test_async_remove_with_platform_update_finishes(hass: HomeAssistant) -> None: """Remove an entity when an update finishes after its been removed.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) entity1 = MockEntity(name="test_1") async def _delayed_update(*args, **kwargs): @@ -471,6 +481,7 @@ async def test_not_adding_duplicate_entities_with_unique_id( """ caplog.set_level(logging.ERROR) component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) ent1 = MockEntity(name="test1", unique_id="not_very_unique") await component.async_add_entities([ent1]) @@ -504,6 +515,7 @@ async def test_not_adding_duplicate_entities_with_unique_id( async def test_using_prescribed_entity_id(hass: HomeAssistant) -> None: """Test for using predefined entity ID.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) await component.async_add_entities( [MockEntity(name="bla", entity_id="hello.world")] ) @@ -513,6 +525,7 @@ async def test_using_prescribed_entity_id(hass: HomeAssistant) -> None: async def test_using_prescribed_entity_id_with_unique_id(hass: HomeAssistant) -> None: """Test for amending predefined entity ID because currently exists.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) await component.async_add_entities([MockEntity(entity_id="test_domain.world")]) await component.async_add_entities( @@ -527,6 +540,7 @@ async def test_using_prescribed_entity_id_which_is_registered( ) -> None: """Test not allowing predefined entity ID that already registered.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) # Register test_domain.world entity_registry.async_get_or_create( DOMAIN, "test", "1234", suggested_object_id="world" @@ -543,6 +557,7 @@ async def test_name_which_conflict_with_registered( ) -> None: """Test not generating conflicting entity ID based on name.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) # Register test_domain.world entity_registry.async_get_or_create( @@ -559,6 +574,7 @@ async def test_entity_with_name_and_entity_id_getting_registered( ) -> None: """Ensure that entity ID is used for registration.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) await component.async_add_entities( [MockEntity(unique_id="1234", name="bla", entity_id="test_domain.world")] ) @@ -568,6 +584,7 @@ async def test_entity_with_name_and_entity_id_getting_registered( async def test_overriding_name_from_registry(hass: HomeAssistant) -> None: """Test that we can override a name via the Entity Registry.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) mock_registry( hass, { @@ -625,6 +642,7 @@ async def test_unique_id_conflict_has_priority_over_disabled_entity( ) -> None: """Test that an entity that is not unique has priority over a disabled entity.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) entity1 = MockEntity( name="test1", unique_id="not_very_unique", enabled_by_default=False ) @@ -1192,6 +1210,7 @@ async def test_device_info_change_to_no_url( async def test_entity_disabled_by_integration(hass: HomeAssistant) -> None: """Test entity disabled by integration.""" component = EntityComponent(_LOGGER, DOMAIN, hass, timedelta(seconds=20)) + await component.async_setup({}) entity_default = MockEntity(unique_id="default") entity_disabled = MockEntity( @@ -1254,6 +1273,7 @@ async def test_entity_disabled_by_device(hass: HomeAssistant) -> None: async def test_entity_hidden_by_integration(hass: HomeAssistant) -> None: """Test entity hidden by integration.""" component = EntityComponent(_LOGGER, DOMAIN, hass, timedelta(seconds=20)) + await component.async_setup({}) entity_default = MockEntity(unique_id="default") entity_hidden = MockEntity( @@ -1273,6 +1293,7 @@ async def test_entity_hidden_by_integration(hass: HomeAssistant) -> None: async def test_entity_info_added_to_entity_registry(hass: HomeAssistant) -> None: """Test entity info is written to entity registry.""" component = EntityComponent(_LOGGER, DOMAIN, hass, timedelta(seconds=20)) + await component.async_setup({}) entity_default = MockEntity( capability_attributes={"max": 100}, @@ -1323,6 +1344,7 @@ async def test_override_restored_entities( hass.states.async_set("test_domain.world", "unavailable", {"restored": True}) component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) await component.async_add_entities( [MockEntity(unique_id="1234", state="on", entity_id="test_domain.world")], True From ed0a0590538a41768535d7e0aded2da52a9d3af4 Mon Sep 17 00:00:00 2001 From: Malte Franken Date: Fri, 17 Mar 2023 21:59:29 +1100 Subject: [PATCH 1036/2810] Refactor entity manager code in geo_json_events integration (#89847) * moved entity manager * fix circular reference * simplify new entity signal --- .../geo_json_events/geo_location.py | 89 ++++--------------- .../components/geo_json_events/manager.py | 84 +++++++++++++++++ 2 files changed, 102 insertions(+), 71 deletions(-) create mode 100644 homeassistant/components/geo_json_events/manager.py diff --git a/homeassistant/components/geo_json_events/geo_location.py b/homeassistant/components/geo_json_events/geo_location.py index 74951bc3a9..2df049dd9c 100644 --- a/homeassistant/components/geo_json_events/geo_location.py +++ b/homeassistant/components/geo_json_events/geo_location.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Callable -from datetime import datetime, timedelta +from datetime import timedelta import logging from typing import Any @@ -21,16 +21,13 @@ from homeassistant.const import ( UnitOfLength, ) from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers import aiohttp_client import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .manager import GeoJsonFeedEntityManager + _LOGGER = logging.getLogger(__name__) ATTR_EXTERNAL_ID = "external_id" @@ -67,8 +64,21 @@ async def async_setup_platform( radius_in_km: float = config[CONF_RADIUS] # Initialize the entity manager. manager = GeoJsonFeedEntityManager( - hass, async_add_entities, scan_interval, coordinates, url, radius_in_km + hass, scan_interval, coordinates, url, radius_in_km ) + + @callback + def async_add_geolocation( + feed_manager: GenericFeedManager, + external_id: str, + ) -> None: + """Add geolocation entity from feed.""" + new_entity = GeoJsonLocationEvent(feed_manager, external_id) + _LOGGER.debug("Adding geolocation %s", new_entity) + async_add_entities([new_entity], True) + + async_dispatcher_connect(hass, manager.signal_new_entity, async_add_geolocation) + await manager.async_init() async def start_feed_manager(event: Event) -> None: @@ -78,69 +88,6 @@ async def async_setup_platform( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_feed_manager) -class GeoJsonFeedEntityManager: - """Feed Entity Manager for GeoJSON feeds.""" - - def __init__( - self, - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - scan_interval: timedelta, - coordinates: tuple[float, float], - url: str, - radius_in_km: float, - ) -> None: - """Initialize the GeoJSON Feed Manager.""" - - self._hass = hass - websession = aiohttp_client.async_get_clientsession(hass) - self._feed_manager = GenericFeedManager( - websession, - self._generate_entity, - self._update_entity, - self._remove_entity, - coordinates, - url, - filter_radius=radius_in_km, - ) - self._async_add_entities = async_add_entities - self._scan_interval = scan_interval - - async def async_init(self) -> None: - """Schedule initial and regular updates based on configured time interval.""" - - async def update(event_time: datetime) -> None: - """Update.""" - await self.async_update() - - # Trigger updates at regular intervals. - async_track_time_interval(self._hass, update, self._scan_interval) - _LOGGER.debug("Feed entity manager initialized") - - async def async_update(self) -> None: - """Refresh data.""" - await self._feed_manager.update() - _LOGGER.debug("Feed entity manager updated") - - def get_entry(self, external_id: str) -> GenericFeedEntry | None: - """Get feed entry by external id.""" - return self._feed_manager.feed_entries.get(external_id) - - async def _generate_entity(self, external_id: str) -> None: - """Generate new entity.""" - new_entity = GeoJsonLocationEvent(self, external_id) - # Add new entities to HA. - self._async_add_entities([new_entity], True) - - async def _update_entity(self, external_id: str) -> None: - """Update entity.""" - async_dispatcher_send(self._hass, f"geo_json_events_update_{external_id}") - - async def _remove_entity(self, external_id: str) -> None: - """Remove entity.""" - async_dispatcher_send(self._hass, f"geo_json_events_delete_{external_id}") - - class GeoJsonLocationEvent(GeolocationEvent): """Represents an external event with GeoJSON data.""" diff --git a/homeassistant/components/geo_json_events/manager.py b/homeassistant/components/geo_json_events/manager.py new file mode 100644 index 0000000000..6c51e6dd72 --- /dev/null +++ b/homeassistant/components/geo_json_events/manager.py @@ -0,0 +1,84 @@ +"""Entity manager for generic GeoJSON events.""" +from __future__ import annotations + +from datetime import datetime, timedelta +import logging + +from aio_geojson_generic_client import GenericFeedManager +from aio_geojson_generic_client.feed_entry import GenericFeedEntry + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval + +DOMAIN = "geo_json_events" + +_LOGGER = logging.getLogger(__name__) + + +class GeoJsonFeedEntityManager: + """Feed Entity Manager for GeoJSON feeds.""" + + def __init__( + self, + hass: HomeAssistant, + scan_interval: timedelta, + coordinates: tuple[float, float], + url: str, + radius_in_km: float, + ) -> None: + """Initialize the GeoJSON Feed Manager.""" + + self._hass = hass + websession = aiohttp_client.async_get_clientsession(hass) + self._feed_manager = GenericFeedManager( + websession, + self._generate_entity, + self._update_entity, + self._remove_entity, + coordinates, + url, + filter_radius=radius_in_km, + ) + self._scan_interval = scan_interval + self.signal_new_entity = ( + f"{DOMAIN}_new_geolocation_{coordinates}-{url}-{radius_in_km}" + ) + + async def async_init(self) -> None: + """Schedule initial and regular updates based on configured time interval.""" + + async def update(event_time: datetime) -> None: + """Update.""" + await self.async_update() + + # Trigger updates at regular intervals. + async_track_time_interval(self._hass, update, self._scan_interval) + _LOGGER.debug("Feed entity manager initialized") + + async def async_update(self) -> None: + """Refresh data.""" + await self._feed_manager.update() + _LOGGER.debug("Feed entity manager updated") + + def get_entry(self, external_id: str) -> GenericFeedEntry | None: + """Get feed entry by external id.""" + return self._feed_manager.feed_entries.get(external_id) + + async def _generate_entity(self, external_id: str) -> None: + """Generate new entity.""" + async_dispatcher_send( + self._hass, + self.signal_new_entity, + self, + external_id, + ) + + async def _update_entity(self, external_id: str) -> None: + """Update entity.""" + async_dispatcher_send(self._hass, f"geo_json_events_update_{external_id}") + + async def _remove_entity(self, external_id: str) -> None: + """Remove entity.""" + async_dispatcher_send(self._hass, f"geo_json_events_delete_{external_id}") From cdb01146da1027dcda24321000a26036c17809ed Mon Sep 17 00:00:00 2001 From: lunmay <28674102+lunmay@users.noreply.github.com> Date: Fri, 17 Mar 2023 12:05:29 +0100 Subject: [PATCH 1037/2810] Fix misstype translation reference keynames (#89855) --- .../components/binary_sensor/strings.json | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json index a0dc2020f0..f2bbc72e7a 100644 --- a/homeassistant/components/binary_sensor/strings.json +++ b/homeassistant/components/binary_sensor/strings.json @@ -132,7 +132,7 @@ "name": "Carbon monoxide", "state": { "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", - "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" } }, "cold": { @@ -201,8 +201,8 @@ "motion": { "name": "Motion", "state": { - "off": "[key:component::binary_sensor::entity_component::gas::state::off%]", - "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" } }, "moving": { @@ -215,8 +215,8 @@ "occupancy": { "name": "Occupancy", "state": { - "off": "[key:component::binary_sensor::entity_component::gas::state::off%]", - "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" } }, "opening": { @@ -264,15 +264,15 @@ "smoke": { "name": "Smoke", "state": { - "off": "[key:component::binary_sensor::entity_component::gas::state::off%]", - "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" } }, "sound": { "name": "Sound", "state": { - "off": "[key:component::binary_sensor::entity_component::gas::state::off%]", - "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" } }, "update": { @@ -285,8 +285,8 @@ "vibration": { "name": "Vibration", "state": { - "off": "[key:component::binary_sensor::entity_component::gas::state::off%]", - "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" } }, "window": { From f4de050904b276c3439d4aac3550a77a484be5b5 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Fri, 17 Mar 2023 13:27:05 +0100 Subject: [PATCH 1038/2810] Bump hass-nabucasa to 0.61.1 (#89864) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index ce8377f187..7f8dfca144 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.61.0"] + "requirements": ["hass-nabucasa==0.61.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 17693b31f6..566dcc61b2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -20,7 +20,7 @@ ciso8601==2.3.0 cryptography==39.0.1 dbus-fast==1.84.1 fnvhash==0.1.0 -hass-nabucasa==0.61.0 +hass-nabucasa==0.61.1 hassil==1.0.6 home-assistant-bluetooth==1.9.3 home-assistant-frontend==20230309.1 diff --git a/requirements_all.txt b/requirements_all.txt index fc2fd2c26a..781df707b0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -868,7 +868,7 @@ ha-philipsjs==3.0.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.61.0 +hass-nabucasa==0.61.1 # homeassistant.components.splunk hass_splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 042f2dd492..69f545241c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -666,7 +666,7 @@ ha-philipsjs==3.0.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.61.0 +hass-nabucasa==0.61.1 # homeassistant.components.conversation hassil==1.0.6 From b1a3bfb298458257be442e6621ef92c781537033 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 17 Mar 2023 13:30:06 +0100 Subject: [PATCH 1039/2810] Drop flake8 in favor of Ruff (#89863) --- .devcontainer/devcontainer.json | 1 - .github/workflows/ci.yaml | 49 ---------------------- .github/workflows/matchers/flake8.json | 30 ------------- .pre-commit-config.yaml | 14 ------- .vscode/tasks.json | 14 ------- Dockerfile.dev | 1 - homeassistant/components/light/__init__.py | 2 +- homeassistant/components/mqtt/discovery.py | 2 +- pyproject.toml | 9 ---- requirements_test_pre_commit.txt | 8 ---- script/lint | 4 -- script/lint_and_test.py | 17 ++------ setup.cfg | 29 ------------- 13 files changed, 5 insertions(+), 175 deletions(-) delete mode 100644 .github/workflows/matchers/flake8.json diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 1711ab68fd..042eb94b19 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -20,7 +20,6 @@ "python.linting.enabled": true, "python.linting.pylintEnabled": true, "python.formatting.blackPath": "/usr/local/bin/black", - "python.linting.flake8Path": "/usr/local/bin/flake8", "python.linting.pycodestylePath": "/usr/local/bin/pycodestyle", "python.linting.pydocstylePath": "/usr/local/bin/pydocstyle", "python.linting.mypyPath": "/usr/local/bin/mypy", diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4ac1075fcb..f23b28f2c1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -286,55 +286,6 @@ jobs: shopt -s globstar pre-commit run --hook-stage manual black --files {homeassistant,tests}/components/${{ needs.info.outputs.integrations_glob }}/{*,**/*} --show-diff-on-failure - lint-flake8: - name: Check flake8 - runs-on: ubuntu-22.04 - needs: - - info - - pre-commit - steps: - - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.5.0 - id: python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - check-latest: true - - name: Restore base Python virtual environment - id: cache-venv - uses: actions/cache/restore@v3.3.1 - with: - path: venv - fail-on-cache-miss: true - key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ - needs.info.outputs.pre-commit_cache_key }} - - name: Restore pre-commit environment from cache - id: cache-precommit - uses: actions/cache/restore@v3.3.1 - with: - path: ${{ env.PRE_COMMIT_CACHE }} - fail-on-cache-miss: true - key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ - needs.info.outputs.pre-commit_cache_key }} - - name: Register flake8 problem matcher - run: | - echo "::add-matcher::.github/workflows/matchers/flake8.json" - - name: Run flake8 (fully) - if: needs.info.outputs.test_full_suite == 'true' - run: | - . venv/bin/activate - pre-commit run --hook-stage manual flake8 --all-files - - name: Run flake8 (partially) - if: needs.info.outputs.test_full_suite == 'false' - shell: bash - run: | - . venv/bin/activate - shopt -s globstar - pre-commit run --hook-stage manual flake8 --files {homeassistant,tests}/components/${{ needs.info.outputs.integrations_glob }}/{*,**/*} - lint-ruff: name: Check ruff runs-on: ubuntu-22.04 diff --git a/.github/workflows/matchers/flake8.json b/.github/workflows/matchers/flake8.json deleted file mode 100644 index e059a1cf5f..0000000000 --- a/.github/workflows/matchers/flake8.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "problemMatcher": [ - { - "owner": "flake8-error", - "severity": "error", - "pattern": [ - { - "regexp": "^(.*):(\\d+):(\\d+):\\s([EF]\\d{3}\\s.*)$", - "file": 1, - "line": 2, - "column": 3, - "message": 4 - } - ] - }, - { - "owner": "flake8-warning", - "severity": "warning", - "pattern": [ - { - "regexp": "^(.*):(\\d+):(\\d+):\\s([CDNW]\\d{3}\\s.*)$", - "file": 1, - "line": 2, - "column": 3, - "message": 4 - } - ] - } - ] -} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index af0d3b318e..8cedb60b8b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -36,20 +36,6 @@ repos: - --quiet-level=2 exclude_types: [csv, json] exclude: ^tests/fixtures/|homeassistant/generated/ - - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 - hooks: - - id: flake8 - additional_dependencies: - - pycodestyle==2.10.0 - - pyflakes==3.0.1 - - flake8-docstrings==1.6.0 - - pydocstyle==6.2.3 - - flake8-comprehensions==3.10.1 - - flake8-noqa==1.3.0 - - mccabe==0.7.0 - exclude: docs/source/conf.py - stages: [manual] - repo: https://github.com/PyCQA/bandit rev: 1.7.4 hooks: diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 849716d7ba..7af7a426d6 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -42,20 +42,6 @@ }, "problemMatcher": [] }, - { - "label": "Flake8", - "type": "shell", - "command": "pre-commit run flake8 --all-files", - "group": { - "kind": "test", - "isDefault": true - }, - "presentation": { - "reveal": "always", - "panel": "new" - }, - "problemMatcher": [] - }, { "label": "Ruff", "type": "shell", diff --git a/Dockerfile.dev b/Dockerfile.dev index 863ac5690b..116446d181 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -5,7 +5,6 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"] # Uninstall pre-installed formatting and linting tools # They would conflict with our pinned versions RUN pipx uninstall black -RUN pipx uninstall flake8 RUN pipx uninstall pydocstyle RUN pipx uninstall pycodestyle RUN pipx uninstall mypy diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 2af959d22f..02f6e44a70 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -405,7 +405,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: base["params"] = data return base - async def async_handle_light_on_service( + async def async_handle_light_on_service( # noqa: C901 light: LightEntity, call: ServiceCall ) -> None: """Handle turning a light on. diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index cf565b4239..a764b24b2e 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -99,7 +99,7 @@ async def async_start( # noqa: C901 mqtt_integrations = {} @callback - def async_discovery_message_received(msg: ReceiveMessage) -> None: + def async_discovery_message_received(msg: ReceiveMessage) -> None: # noqa: C901 """Process the received message.""" mqtt_data.last_discovery = time.time() payload = msg.payload diff --git a/pyproject.toml b/pyproject.toml index 082ae3ef2b..3ee9bc7be5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -292,15 +292,6 @@ keep-runtime-typing = true [tool.ruff.per-file-ignores] -# TODO: these files have functions that are too complex, but flake8's and ruff's -# complexity (and/or nested-function) handling differs; trying to add a noqa doesn't work -# because the flake8-noqa plugin then disagrees on whether there should be a C901 noqa -# on that line. So, for now, we just ignore C901s on these files as far as ruff is concerned. - -"homeassistant/components/light/__init__.py" = ["C901"] -"homeassistant/components/mqtt/discovery.py" = ["C901"] -"homeassistant/components/websocket_api/http.py" = ["C901"] - # Allow for main entry & scripts to write to stdout "homeassistant/__main__.py" = ["T201"] "homeassistant/scripts/*" = ["T201"] diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 0b7c52f85d..f64f422cc4 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -4,15 +4,7 @@ autoflake==2.0.0 bandit==1.7.4 black==23.1.0 codespell==2.2.2 -flake8-comprehensions==3.10.1 -flake8-docstrings==1.6.0 -flake8-noqa==1.3.0 -flake8==6.0.0 isort==5.12.0 -mccabe==0.7.0 -pycodestyle==2.10.0 -pydocstyle==6.2.3 -pyflakes==3.0.1 pyupgrade==3.3.1 ruff==0.0.256 yamllint==1.28.0 diff --git a/script/lint b/script/lint index 450733cecf..daafedb229 100755 --- a/script/lint +++ b/script/lint @@ -12,10 +12,6 @@ if [ -z "$files" ] ; then exit fi printf "%s\n" $files -echo "================" -echo "LINT with flake8" -echo "================" -pre-commit run flake8 --files $files echo "==============" echo "LINT with ruff" echo "==============" diff --git a/script/lint_and_test.py b/script/lint_and_test.py index 630e0eb996..5a3d448c1f 100755 --- a/script/lint_and_test.py +++ b/script/lint_and_test.py @@ -116,9 +116,9 @@ async def pylint(files): return res -async def _ruff_or_flake8(tool, files): - """Exec ruff or flake8.""" - _, log = await async_exec("pre-commit", "run", tool, "--files", *files) +async def ruff(files): + """Exec ruff.""" + _, log = await async_exec("pre-commit", "run", "ruff", "--files", *files) res = [] for line in log.splitlines(): line = line.split(":") @@ -129,23 +129,12 @@ async def _ruff_or_flake8(tool, files): return res -async def flake8(files): - """Exec flake8.""" - return await _ruff_or_flake8("flake8", files) - - -async def ruff(files): - """Exec ruff.""" - return await _ruff_or_flake8("ruff", files) - - async def lint(files): """Perform lint.""" files = [file for file in files if os.path.isfile(file)] res = sorted( itertools.chain( *await asyncio.gather( - flake8(files), pylint(files), ruff(files), ) diff --git a/setup.cfg b/setup.cfg index 323c4c10d2..29713b6df4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,32 +3,3 @@ [metadata] url = https://www.home-assistant.io/ - -[flake8] -exclude = .venv,.git,docs,venv,bin,lib,deps,build -max-complexity = 25 -doctests = True -# To work with Black -# E501: line too long -# W503: Line break occurred before a binary operator -# E203: Whitespace before ':' -# D202 No blank lines allowed after function docstring -# W504 line break after binary operator -ignore = - E501, - W503, - E203, - D202, - W504 -noqa-require-code = True - -# Ignores, that are currently caused by mismatching configurations -# between ruff and flake8 configurations. Once ruff becomes permanent flake8 -# will be removed, including these ignores below. -# In case we decide not to continue with ruff, we should remove these -# and probably need to clean up a couple of noqa comments. -per-file-ignores = - homeassistant/config.py:NQA102 - tests/components/august/mocks.py:NQA102 - tests/components/tts/conftest.py:NQA102 - tests/helpers/test_icon.py:NQA102 From a7a972fe964e50a0e00e969e8de133c330f18156 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 17 Mar 2023 13:43:16 +0100 Subject: [PATCH 1040/2810] Upgrade pytest-xdist to 3.2.1 (#89857) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 7601326cd5..e2db9d1e9f 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -28,7 +28,7 @@ pytest-sugar==0.9.6 pytest-timeout==2.1.0 pytest-unordered==0.5.2 pytest-picked==0.4.6 -pytest-xdist==3.2.0 +pytest-xdist==3.2.1 pytest==7.2.2 requests_mock==1.10.0 respx==0.20.1 From a15c45dbfe385b17b8870cee02f67d9c8667046c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 17 Mar 2023 14:17:05 +0100 Subject: [PATCH 1041/2810] Drop pyupgrade in favor of Ruff (#89865) --- .github/workflows/ci.yaml | 13 ------------- .pre-commit-config.yaml | 6 ------ requirements_test_pre_commit.txt | 1 - 3 files changed, 20 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f23b28f2c1..d0dafda421 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -407,19 +407,6 @@ jobs: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.pre-commit_cache_key }} - - name: Run pyupgrade (fully) - if: needs.info.outputs.test_full_suite == 'true' - run: | - . venv/bin/activate - pre-commit run --hook-stage manual pyupgrade --all-files --show-diff-on-failure - - name: Run pyupgrade (partially) - if: needs.info.outputs.test_full_suite == 'false' - shell: bash - run: | - . venv/bin/activate - shopt -s globstar - pre-commit run --hook-stage manual pyupgrade --files {homeassistant,tests}/components/${{ needs.info.outputs.integrations_glob }}/{*,**/*} --show-diff-on-failure - - name: Register yamllint problem matcher run: | echo "::add-matcher::.github/workflows/matchers/yamllint.json" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8cedb60b8b..155d966e0f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,12 +5,6 @@ repos: - id: ruff args: - --fix - - repo: https://github.com/asottile/pyupgrade - rev: v3.3.1 - hooks: - - id: pyupgrade - args: [--py310-plus] - stages: [manual] - repo: https://github.com/PyCQA/autoflake rev: v2.0.0 hooks: diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index f64f422cc4..410d74c9e3 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -5,6 +5,5 @@ bandit==1.7.4 black==23.1.0 codespell==2.2.2 isort==5.12.0 -pyupgrade==3.3.1 ruff==0.0.256 yamllint==1.28.0 From 5657fcd1e8c89228525c32bcc4299b5169532244 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 17 Mar 2023 18:52:19 +0100 Subject: [PATCH 1042/2810] Add state attribute translations for Number (#89881) --- homeassistant/components/number/strings.json | 21 +++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/number/strings.json b/homeassistant/components/number/strings.json index d265b84c74..46db471305 100644 --- a/homeassistant/components/number/strings.json +++ b/homeassistant/components/number/strings.json @@ -7,7 +7,26 @@ }, "entity_component": { "_": { - "name": "[%key:component::number::title%]" + "name": "[%key:component::number::title%]", + "state_attributes": { + "max": { + "name": "Maximum" + }, + "min": { + "name": "Minimum" + }, + "mode": { + "name": "Mode", + "state": { + "auto": "Automatic", + "box": "Box", + "slider": "Slider" + } + }, + "step": { + "name": "Step" + } + } }, "apparent_power": { "name": "[%key:component::sensor::entity_component::apparent_power::name%]" From db5a7b0e5e64e6733a71a518e41d83c541f33cc4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 17 Mar 2023 18:52:34 +0100 Subject: [PATCH 1043/2810] Add translations for Geolocation (#89880) --- .../components/geo_location/strings.json | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 homeassistant/components/geo_location/strings.json diff --git a/homeassistant/components/geo_location/strings.json b/homeassistant/components/geo_location/strings.json new file mode 100644 index 0000000000..4678790b52 --- /dev/null +++ b/homeassistant/components/geo_location/strings.json @@ -0,0 +1,19 @@ +{ + "title": "Geolocation", + "entity_component": { + "_": { + "name": "[%key:component::geo_location::title%]", + "state_attributes": { + "latitude": { + "name": "Latitude" + }, + "longitude": { + "name": "Longitude" + }, + "source": { + "name": "Source" + } + } + } + } +} From e402e733a0e6850e060a5f37c511844f33e53a26 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 17 Mar 2023 18:52:59 +0100 Subject: [PATCH 1044/2810] Add translations for Image processing (#89879) --- .../components/image_processing/strings.json | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/image_processing/strings.json b/homeassistant/components/image_processing/strings.json index b635fb6aae..861a2acc1f 100644 --- a/homeassistant/components/image_processing/strings.json +++ b/homeassistant/components/image_processing/strings.json @@ -1 +1,16 @@ -{ "title": "Image processing" } +{ + "title": "Image processing", + "entity_component": { + "_": { + "name": "[%key:component::image_processing::title%]", + "state_attributes": { + "faces": { + "name": "Faces" + }, + "total_faces": { + "name": "Total faces" + } + } + } + } +} From 95515fbe789bc5111de3ca85776fec3a45f518f3 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 17 Mar 2023 19:25:20 +0100 Subject: [PATCH 1045/2810] Improve/extend state translations for Alarm Control Panel (#89872) --- .../components/alarm_control_panel/strings.json | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/strings.json b/homeassistant/components/alarm_control_panel/strings.json index f055d6646b..6b01cab2be 100644 --- a/homeassistant/components/alarm_control_panel/strings.json +++ b/homeassistant/components/alarm_control_panel/strings.json @@ -44,13 +44,21 @@ }, "state_attributes": { "code_format": { - "name": "Code format" + "name": "Code format", + "state": { + "text": "Text", + "number": "Number" + } }, "changed_by": { "name": "Changed by" }, "code_arm_required": { - "name": "Code for arming required" + "name": "Code for arming", + "state": { + "true": "Required", + "false": "Not required" + } } } } From b403a96ea085d62abbdc4a8f45643f99f37022f1 Mon Sep 17 00:00:00 2001 From: Kevin Worrel <37058192+dieselrabbit@users.noreply.github.com> Date: Fri, 17 Mar 2023 16:10:37 -0400 Subject: [PATCH 1046/2810] Bump screenlogicpy to v0.8.2 (#89832) --- .../components/screenlogic/__init__.py | 32 +++++++------------ .../components/screenlogic/entity.py | 11 ++++++- .../components/screenlogic/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 24 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/screenlogic/__init__.py b/homeassistant/components/screenlogic/__init__.py index 5838031dc6..6662c20ad4 100644 --- a/homeassistant/components/screenlogic/__init__.py +++ b/homeassistant/components/screenlogic/__init__.py @@ -10,7 +10,6 @@ from screenlogicpy.const import ( SL_GATEWAY_IP, SL_GATEWAY_NAME, SL_GATEWAY_PORT, - ScreenLogicWarning, ) from homeassistant.config_entries import ConfigEntry @@ -52,8 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await gateway.async_connect(**connect_info) except ScreenLogicError as ex: - _LOGGER.error("Error while connecting to the gateway %s: %s", connect_info, ex) - raise ConfigEntryNotReady from ex + raise ConfigEntryNotReady(ex.msg) from ex coordinator = ScreenlogicDataUpdateCoordinator( hass, config_entry=entry, gateway=gateway @@ -157,25 +155,17 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): async def _async_update_data(self) -> None: """Fetch data from the Screenlogic gateway.""" - try: - await self._async_update_configured_data() - except (ScreenLogicError, ScreenLogicWarning) as ex: - _LOGGER.warning("Update error - attempting reconnect: %s", ex) - await self._async_reconnect_update_data() - - return None - - async def _async_reconnect_update_data(self) -> None: - """Attempt to reconnect to the gateway and fetch data.""" assert self.config_entry is not None try: - # Clean up the previous connection as we're about to create a new one - await self.gateway.async_disconnect() - - connect_info = await async_get_connect_info(self.hass, self.config_entry) - await self.gateway.async_connect(**connect_info) + if not self.gateway.is_connected: + connect_info = await async_get_connect_info( + self.hass, self.config_entry + ) + await self.gateway.async_connect(**connect_info) await self._async_update_configured_data() - - except (ScreenLogicError, ScreenLogicWarning) as ex: - raise UpdateFailed(ex) from ex + except ScreenLogicError as ex: + if self.gateway.is_connected: + await self.gateway.async_disconnect() + raise UpdateFailed(ex.msg) from ex + return None diff --git a/homeassistant/components/screenlogic/entity.py b/homeassistant/components/screenlogic/entity.py index 4ea23395c5..eb006b5536 100644 --- a/homeassistant/components/screenlogic/entity.py +++ b/homeassistant/components/screenlogic/entity.py @@ -101,21 +101,30 @@ class ScreenLogicPushEntity(ScreenlogicEntity): """Initialize the entity.""" super().__init__(coordinator, data_key, enabled) self._update_message_code = message_code + self._last_update_success = True @callback def _async_data_updated(self) -> None: """Handle data updates.""" + self._last_update_success = self.coordinator.last_update_success self.async_write_ha_state() async def async_added_to_hass(self) -> None: """When entity is added to hass.""" - + await super().async_added_to_hass() self.async_on_remove( await self.gateway.async_subscribe_client( self._async_data_updated, self._update_message_code ) ) + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + # For push entities, only take updates from the coordinator if availability changes. + if self.coordinator.last_update_success != self._last_update_success: + self._async_data_updated() + class ScreenLogicCircuitEntity(ScreenLogicPushEntity): """Base class for all ScreenLogic switch and light entities.""" diff --git a/homeassistant/components/screenlogic/manifest.json b/homeassistant/components/screenlogic/manifest.json index 977ef59f9e..5b8b836942 100644 --- a/homeassistant/components/screenlogic/manifest.json +++ b/homeassistant/components/screenlogic/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/screenlogic", "iot_class": "local_push", "loggers": ["screenlogicpy"], - "requirements": ["screenlogicpy==0.7.2"] + "requirements": ["screenlogicpy==0.8.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 781df707b0..6e834d0547 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2303,7 +2303,7 @@ satel_integra==0.3.7 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.7.2 +screenlogicpy==0.8.2 # homeassistant.components.scsgate scsgate==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 69f545241c..30bb11b745 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1636,7 +1636,7 @@ samsungtvws[async,encrypted]==2.5.0 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.7.2 +screenlogicpy==0.8.2 # homeassistant.components.backup securetar==2022.2.0 From fd5b57ae6c8ff68a9f86cae45657237cbf57d1d2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 17 Mar 2023 21:37:41 +0100 Subject: [PATCH 1047/2810] Drop autoflake in favor of Ruff (#89874) --- .pre-commit-config.yaml | 8 -------- requirements_test_pre_commit.txt | 1 - 2 files changed, 9 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 155d966e0f..fd196f19db 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,14 +5,6 @@ repos: - id: ruff args: - --fix - - repo: https://github.com/PyCQA/autoflake - rev: v2.0.0 - hooks: - - id: autoflake - args: - - --in-place - - --remove-all-unused-imports - stages: [manual] - repo: https://github.com/psf/black rev: 23.1.0 hooks: diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 410d74c9e3..a1faadfea4 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,6 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit -autoflake==2.0.0 bandit==1.7.4 black==23.1.0 codespell==2.2.2 From 377dff5ee4d15e413665945c133eef3ef70fd460 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Mar 2023 10:45:58 -1000 Subject: [PATCH 1048/2810] Ensure all recorder session executes use retries or the execute helper (#89888) --- homeassistant/components/recorder/const.py | 6 +++ homeassistant/components/recorder/core.py | 40 ++++++++++++------- .../recorder/table_managers/states_meta.py | 11 ++++- 3 files changed, 40 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index effecf15a8..d72666f76b 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -43,6 +43,12 @@ KEEPALIVE_TIME = 30 EXCLUDE_ATTRIBUTES = f"{DOMAIN}_exclude_attributes_by_domain" +STATISTICS_ROWS_SCHEMA_VERSION = 23 +CONTEXT_ID_AS_BINARY_SCHEMA_VERSION = 36 +EVENT_TYPE_IDS_SCHEMA_VERSION = 37 +STATES_META_SCHEMA_VERSION = 38 + + class SupportedDialect(StrEnum): """Supported dialects.""" diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index db44baee06..8d1e870865 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -41,8 +41,10 @@ from homeassistant.util.enum import try_parse_enum from . import migration, statistics from .const import ( + CONTEXT_ID_AS_BINARY_SCHEMA_VERSION, DB_WORKER_PREFIX, DOMAIN, + EVENT_TYPE_IDS_SCHEMA_VERSION, KEEPALIVE_TIME, MARIADB_PYMYSQL_URL_PREFIX, MARIADB_URL_PREFIX, @@ -50,6 +52,8 @@ from .const import ( MYSQLDB_PYMYSQL_URL_PREFIX, MYSQLDB_URL_PREFIX, SQLITE_URL_PREFIX, + STATES_META_SCHEMA_VERSION, + STATISTICS_ROWS_SCHEMA_VERSION, SupportedDialect, ) from .db_schema import ( @@ -108,6 +112,7 @@ from .util import ( build_mysqldb_conv, dburl_to_path, end_incomplete_runs, + execute_stmt_lambda_element, is_second_sunday, move_away_broken_database, session_scope, @@ -688,24 +693,28 @@ class Recorder(threading.Thread): # since we want the frontend queries to avoid a thundering # herd of queries to find the statistics meta data if # there are a lot of statistics graphs on the frontend. - if self.schema_version >= 23: + if self.schema_version >= STATISTICS_ROWS_SCHEMA_VERSION: self.statistics_meta_manager.load(session) if ( - self.schema_version < 36 - or session.execute(has_events_context_ids_to_migrate()).scalar() + self.schema_version < CONTEXT_ID_AS_BINARY_SCHEMA_VERSION + or execute_stmt_lambda_element( + session, has_events_context_ids_to_migrate() + ) ): self.queue_task(StatesContextIDMigrationTask()) if ( - self.schema_version < 36 - or session.execute(has_states_context_ids_to_migrate()).scalar() + self.schema_version < CONTEXT_ID_AS_BINARY_SCHEMA_VERSION + or execute_stmt_lambda_element( + session, has_states_context_ids_to_migrate() + ) ): self.queue_task(EventsContextIDMigrationTask()) if ( - self.schema_version < 37 - or session.execute(has_event_type_to_migrate()).scalar() + self.schema_version < EVENT_TYPE_IDS_SCHEMA_VERSION + or execute_stmt_lambda_element(session, has_event_type_to_migrate()) ): self.queue_task(EventTypeIDMigrationTask()) else: @@ -713,8 +722,8 @@ class Recorder(threading.Thread): self.event_type_manager.active = True if ( - self.schema_version < 38 - or session.execute(has_entity_ids_to_migrate()).scalar() + self.schema_version < STATES_META_SCHEMA_VERSION + or execute_stmt_lambda_element(session, has_entity_ids_to_migrate()) ): self.queue_task(EntityIDMigrationTask()) else: @@ -1002,7 +1011,7 @@ class Recorder(threading.Thread): if states_meta_manager.active: dbstate.entity_id = None - self.event_session.add(dbstate) + session.add(dbstate) def _handle_database_error(self, err: Exception) -> bool: """Handle a database error that may result in moving away the corrupt db.""" @@ -1015,9 +1024,9 @@ class Recorder(threading.Thread): return False def _event_session_has_pending_writes(self) -> bool: - return bool( - self.event_session and (self.event_session.new or self.event_session.dirty) - ) + """Return True if there are pending writes in the event session.""" + session = self.event_session + return bool(session and (session.new or session.dirty)) def _commit_event_session_or_retry(self) -> None: """Commit the event session if there is work to do.""" @@ -1043,9 +1052,10 @@ class Recorder(threading.Thread): def _commit_event_session(self) -> None: assert self.event_session is not None + session = self.event_session self._commits_without_expire += 1 - self.event_session.commit() + session.commit() # We just committed the state attributes to the database # and we now know the attributes_ids. We can save # many selects for matching attributes by loading them @@ -1061,7 +1071,7 @@ class Recorder(threading.Thread): # do it after EXPIRE_AFTER_COMMITS commits if self._commits_without_expire >= EXPIRE_AFTER_COMMITS: self._commits_without_expire = 0 - self.event_session.expire_all() + session.expire_all() def _handle_sqlite_corruption(self) -> None: """Handle the sqlite3 database being corrupt.""" diff --git a/homeassistant/components/recorder/table_managers/states_meta.py b/homeassistant/components/recorder/table_managers/states_meta.py index 76b748d469..c9c1ba9028 100644 --- a/homeassistant/components/recorder/table_managers/states_meta.py +++ b/homeassistant/components/recorder/table_managers/states_meta.py @@ -1,7 +1,7 @@ """Support managing StatesMeta.""" from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Iterable, Sequence from typing import TYPE_CHECKING, cast from sqlalchemy.orm.session import Session @@ -63,7 +63,14 @@ class StatesMetaManager(BaseLRUTableManager[StatesMeta]): This call is always thread-safe. """ with session.no_autoflush: - return dict(tuple(session.execute(find_all_states_metadata_ids()))) # type: ignore[arg-type] + return dict( + cast( + Sequence[tuple[int, str]], + execute_stmt_lambda_element( + session, find_all_states_metadata_ids() + ), + ) + ) def get_many( self, entity_ids: Iterable[str], session: Session, from_recorder: bool From 469dbec0899e0228699b34c1072a39f068960851 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 17 Mar 2023 22:14:24 +0100 Subject: [PATCH 1049/2810] Add type hints to plex data (#89221) * Add type hints to plex data * Rename method --- homeassistant/components/plex/__init__.py | 61 +++++++++++-------- homeassistant/components/plex/config_flow.py | 4 +- homeassistant/components/plex/const.py | 13 ++-- homeassistant/components/plex/helpers.py | 36 +++++++++++ .../components/plex/media_browser.py | 6 +- homeassistant/components/plex/media_player.py | 6 +- homeassistant/components/plex/sensor.py | 5 +- homeassistant/components/plex/server.py | 8 ++- homeassistant/components/plex/services.py | 5 +- homeassistant/components/plex/view.py | 5 +- 10 files changed, 98 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index 78e8fac23a..559f4440ae 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -35,8 +35,6 @@ from .const import ( CONF_SERVER_IDENTIFIER, DISPATCHERS, DOMAIN, - GDM_DEBOUNCER, - GDM_SCANNER, PLATFORMS, PLATFORMS_COMPLETED, PLEX_SERVER_CONFIG, @@ -47,6 +45,7 @@ from .const import ( WEBSOCKETS, ) from .errors import ShouldUpdateConfigEntry +from .helpers import PlexData, get_plex_data from .media_browser import browse_media from .server import PlexServer from .services import async_setup_services @@ -62,7 +61,7 @@ def is_plex_media_id(media_content_id): async def async_browse_media(hass, media_content_type, media_content_id, platform=None): """Browse Plex media.""" - plex_server = next(iter(hass.data[DOMAIN][SERVERS].values()), None) + plex_server = next(iter(get_plex_data(hass)[SERVERS].values()), None) if not plex_server: raise BrowseError("No Plex servers available") is_internal = is_internal_request(hass) @@ -80,22 +79,13 @@ async def async_browse_media(hass, media_content_type, media_content_id, platfor async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Plex component.""" - hass.data.setdefault( - DOMAIN, - {SERVERS: {}, DISPATCHERS: {}, WEBSOCKETS: {}, PLATFORMS_COMPLETED: {}}, - ) - - await async_setup_services(hass) - - hass.http.register_view(PlexImageView()) - - gdm = hass.data[DOMAIN][GDM_SCANNER] = GDM() + gdm = GDM() def gdm_scan(): _LOGGER.debug("Scanning for GDM clients") gdm.scan(scan_for_clients=True) - hass.data[DOMAIN][GDM_DEBOUNCER] = Debouncer[None]( + debouncer = Debouncer[None]( hass, _LOGGER, cooldown=10, @@ -103,6 +93,20 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: function=gdm_scan, ).async_call + hass_data = PlexData( + servers={}, + dispatchers={}, + websockets={}, + platforms_completed={}, + gdm_scanner=gdm, + gdm_debouncer=debouncer, + ) + hass.data.setdefault(DOMAIN, hass_data) + + await async_setup_services(hass) + + hass.http.register_view(PlexImageView()) + return True @@ -161,8 +165,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "Connected to: %s (%s)", plex_server.friendly_name, plex_server.url_in_use ) server_id = plex_server.machine_identifier - hass.data[DOMAIN][SERVERS][server_id] = plex_server - hass.data[DOMAIN][PLATFORMS_COMPLETED][server_id] = set() + hass_data = get_plex_data(hass) + hass_data[SERVERS][server_id] = plex_server + hass_data[PLATFORMS_COMPLETED][server_id] = set() entry.add_update_listener(async_options_updated) @@ -171,8 +176,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id), plex_server.async_update_platforms, ) - hass.data[DOMAIN][DISPATCHERS].setdefault(server_id, []) - hass.data[DOMAIN][DISPATCHERS][server_id].append(unsub) + hass_data[DISPATCHERS].setdefault(server_id, []) + hass_data[DISPATCHERS][server_id].append(unsub) @callback def plex_websocket_callback(msgtype, data, error): @@ -213,11 +218,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session=session, verify_ssl=verify_ssl, ) - hass.data[DOMAIN][WEBSOCKETS][server_id] = websocket + hass_data[WEBSOCKETS][server_id] = websocket def start_websocket_session(platform): - hass.data[DOMAIN][PLATFORMS_COMPLETED][server_id].add(platform) - if hass.data[DOMAIN][PLATFORMS_COMPLETED][server_id] == PLATFORMS: + hass_data[PLATFORMS_COMPLETED][server_id].add(platform) + if hass_data[PLATFORMS_COMPLETED][server_id] == PLATFORMS: hass.loop.create_task(websocket.listen()) def close_websocket_session(_): @@ -226,7 +231,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unsub = hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, close_websocket_session ) - hass.data[DOMAIN][DISPATCHERS][server_id].append(unsub) + hass_data[DISPATCHERS][server_id].append(unsub) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -263,16 +268,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" server_id = entry.data[CONF_SERVER_IDENTIFIER] - websocket = hass.data[DOMAIN][WEBSOCKETS].pop(server_id) + hass_data = get_plex_data(hass) + websocket = hass_data[WEBSOCKETS].pop(server_id) websocket.close() - dispatchers = hass.data[DOMAIN][DISPATCHERS].pop(server_id) + dispatchers = hass_data[DISPATCHERS].pop(server_id) for unsub in dispatchers: unsub() unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - hass.data[DOMAIN][SERVERS].pop(server_id) + hass_data[SERVERS].pop(server_id) return unload_ok @@ -281,9 +287,10 @@ async def async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None """Triggered by config entry options updates.""" server_id = entry.data[CONF_SERVER_IDENTIFIER] + hass_data = get_plex_data(hass) # Guard incomplete setup during reauth flows - if server_id in hass.data[DOMAIN][SERVERS]: - hass.data[DOMAIN][SERVERS][server_id].options = entry.options + if server_id in hass_data[SERVERS]: + hass_data[SERVERS][server_id].options = entry.options @callback diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index 1ebe439ff7..10ae380a08 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -49,13 +49,13 @@ from .const import ( DOMAIN, MANUAL_SETUP_STRING, PLEX_SERVER_CONFIG, - SERVERS, X_PLEX_DEVICE_NAME, X_PLEX_PLATFORM, X_PLEX_PRODUCT, X_PLEX_VERSION, ) from .errors import NoServersFound, ServerNotSpecified +from .helpers import get_plex_server from .server import PlexServer HEADER_FRONTEND_BASE = "HA-Frontend-Base" @@ -360,7 +360,7 @@ class PlexOptionsFlowHandler(config_entries.OptionsFlow): async def async_step_plex_mp_settings(self, user_input=None): """Manage the Plex media_player options.""" - plex_server = self.hass.data[DOMAIN][SERVERS][self.server_id] + plex_server = get_plex_server(self.hass, self.server_id) if user_input is not None: self.options[MP_DOMAIN][CONF_USE_EPISODE_ART] = user_input[ diff --git a/homeassistant/components/plex/const.py b/homeassistant/components/plex/const.py index dea976f46d..3f761c9748 100644 --- a/homeassistant/components/plex/const.py +++ b/homeassistant/components/plex/const.py @@ -1,5 +1,6 @@ """Constants for the Plex component.""" from datetime import timedelta +from typing import Final from homeassistant.const import Platform, __version__ @@ -16,14 +17,14 @@ PLEXTV_THROTTLE = 60 CLIENT_SCAN_INTERVAL = timedelta(minutes=10) DEBOUNCE_TIMEOUT = 1 -DISPATCHERS = "dispatchers" -GDM_DEBOUNCER = "gdm_debouncer" -GDM_SCANNER = "gdm_scanner" +DISPATCHERS: Final = "dispatchers" +GDM_DEBOUNCER: Final = "gdm_debouncer" +GDM_SCANNER: Final = "gdm_scanner" PLATFORMS = frozenset([Platform.BUTTON, Platform.MEDIA_PLAYER, Platform.SENSOR]) -PLATFORMS_COMPLETED = "platforms_completed" +PLATFORMS_COMPLETED: Final = "platforms_completed" PLAYER_SOURCE = "player_source" -SERVERS = "servers" -WEBSOCKETS = "websockets" +SERVERS: Final = "servers" +WEBSOCKETS: Final = "websockets" PLEX_SERVER_CONFIG = "server_config" diff --git a/homeassistant/components/plex/helpers.py b/homeassistant/components/plex/helpers.py index 6a0f0780e0..6a334c5ff6 100644 --- a/homeassistant/components/plex/helpers.py +++ b/homeassistant/components/plex/helpers.py @@ -1,4 +1,40 @@ """Helper methods for common Plex integration operations.""" +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from typing import TYPE_CHECKING, Any, TypedDict + +from plexapi.gdm import GDM +from plexwebsocket import PlexWebsocket + +from homeassistant.const import Platform +from homeassistant.core import CALLBACK_TYPE, HomeAssistant + +from .const import DOMAIN, SERVERS + +if TYPE_CHECKING: + from . import PlexServer + + +class PlexData(TypedDict): + """Typed description of plex data stored in `hass.data`.""" + + servers: dict[str, PlexServer] + dispatchers: dict[str, list[CALLBACK_TYPE]] + websockets: dict[str, PlexWebsocket] + platforms_completed: dict[str, set[Platform]] + gdm_scanner: GDM + gdm_debouncer: Callable[[], Coroutine[Any, Any, None]] + + +def get_plex_data(hass: HomeAssistant) -> PlexData: + """Get typed data from hass.data.""" + return hass.data[DOMAIN] + + +def get_plex_server(hass: HomeAssistant, server_id: str) -> PlexServer: + """Get Plex server from hass.data.""" + return get_plex_data(hass)[SERVERS][server_id] def pretty_title(media, short_name=False): diff --git a/homeassistant/components/plex/media_browser.py b/homeassistant/components/plex/media_browser.py index 95ad3f39c7..d3a0cc0fb2 100644 --- a/homeassistant/components/plex/media_browser.py +++ b/homeassistant/components/plex/media_browser.py @@ -7,7 +7,7 @@ from homeassistant.components.media_player import BrowseError, BrowseMedia, Medi from .const import DOMAIN, SERVERS from .errors import MediaNotFound -from .helpers import pretty_title +from .helpers import get_plex_data, get_plex_server, pretty_title class UnknownMediaType(BrowseError): @@ -42,7 +42,7 @@ def browse_media( # noqa: C901 if media_content_id: url = URL(media_content_id) server_id = url.host - plex_server = hass.data[DOMAIN][SERVERS][server_id] + plex_server = get_plex_server(hass, server_id) if media_content_type == "hub": _, hub_location, hub_identifier = url.parts elif media_content_type in ["library", "server"] and len(url.parts) > 2: @@ -294,7 +294,7 @@ def root_payload(hass, is_internal, platform=None): """Return root payload for Plex.""" children = [] - for server_id in hass.data[DOMAIN][SERVERS]: + for server_id in get_plex_data(hass)[SERVERS]: children.append( browse_media( hass, diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index 13422beec4..6fe6d641a8 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -40,9 +40,9 @@ from .const import ( PLEX_UPDATE_MEDIA_PLAYER_SESSION_SIGNAL, PLEX_UPDATE_MEDIA_PLAYER_SIGNAL, PLEX_UPDATE_SENSOR_SIGNAL, - SERVERS, TRANSIENT_DEVICE_MODELS, ) +from .helpers import get_plex_data, get_plex_server from .media_browser import browse_media from .services import process_plex_payload @@ -85,7 +85,7 @@ async def async_setup_entry( unsub = async_dispatcher_connect( hass, PLEX_NEW_MP_SIGNAL.format(server_id), async_new_media_players ) - hass.data[DOMAIN][DISPATCHERS][server_id].append(unsub) + get_plex_data(hass)[DISPATCHERS][server_id].append(unsub) _LOGGER.debug("New entity listener created") @@ -94,7 +94,7 @@ def _async_add_entities(hass, registry, async_add_entities, server_id, new_entit """Set up Plex media_player entities.""" _LOGGER.debug("New entities: %s", new_entities) entities = [] - plexserver = hass.data[DOMAIN][SERVERS][server_id] + plexserver = get_plex_server(hass, server_id) for entity_params in new_entities: plex_mp = PlexMediaPlayer(plexserver, **entity_params) entities.append(plex_mp) diff --git a/homeassistant/components/plex/sensor.py b/homeassistant/components/plex/sensor.py index f4a2ac6e03..3b66fe0cf6 100644 --- a/homeassistant/components/plex/sensor.py +++ b/homeassistant/components/plex/sensor.py @@ -20,9 +20,8 @@ from .const import ( NAME_FORMAT, PLEX_UPDATE_LIBRARY_SIGNAL, PLEX_UPDATE_SENSOR_SIGNAL, - SERVERS, ) -from .helpers import pretty_title +from .helpers import get_plex_server, pretty_title LIBRARY_ATTRIBUTE_TYPES = { "artist": ["artist", "album"], @@ -57,7 +56,7 @@ async def async_setup_entry( ) -> None: """Set up Plex sensor from a config entry.""" server_id = config_entry.data[CONF_SERVER_IDENTIFIER] - plexserver = hass.data[DOMAIN][SERVERS][server_id] + plexserver = get_plex_server(hass, server_id) sensors = [PlexSensor(hass, plexserver)] def create_library_sensors(): diff --git a/homeassistant/components/plex/server.py b/homeassistant/components/plex/server.py index 827712889e..9684c79792 100644 --- a/homeassistant/components/plex/server.py +++ b/homeassistant/components/plex/server.py @@ -1,4 +1,6 @@ """Shared class to maintain Plex server instances.""" +from __future__ import annotations + import logging import ssl import time @@ -27,7 +29,6 @@ from .const import ( CONF_USE_EPISODE_ART, DEBOUNCE_TIMEOUT, DEFAULT_VERIFY_SSL, - DOMAIN, GDM_DEBOUNCER, GDM_SCANNER, PLAYER_SOURCE, @@ -47,6 +48,7 @@ from .errors import ( ServerNotSpecified, ShouldUpdateConfigEntry, ) +from .helpers import get_plex_data from .media_search import search_media from .models import PlexSession @@ -316,7 +318,7 @@ class PlexServer: """Update the platform entities.""" _LOGGER.debug("Updating devices") - await self.hass.data[DOMAIN][GDM_DEBOUNCER]() + await get_plex_data(self.hass)[GDM_DEBOUNCER]() available_clients = {} ignored_clients = set() @@ -429,7 +431,7 @@ class PlexServer: def connect_new_clients(): """Create connections to newly discovered clients.""" - for gdm_entry in self.hass.data[DOMAIN][GDM_SCANNER].entries: + for gdm_entry in get_plex_data(self.hass)[GDM_SCANNER].entries: machine_identifier = gdm_entry["data"]["Resource-Identifier"] if machine_identifier in self._client_device_cache: client = self._client_device_cache[machine_identifier] diff --git a/homeassistant/components/plex/services.py b/homeassistant/components/plex/services.py index 46c0df8861..6257647144 100644 --- a/homeassistant/components/plex/services.py +++ b/homeassistant/components/plex/services.py @@ -19,6 +19,7 @@ from .const import ( SERVICE_SCAN_CLIENTS, ) from .errors import MediaNotFound +from .helpers import get_plex_data from .models import PlexMediaSearchResult from .server import PlexServer @@ -41,7 +42,7 @@ async def async_setup_services(hass: HomeAssistant) -> None: " Service calls will still work for now but the service will be removed in" " a future release" ) - for server_id in hass.data[DOMAIN][SERVERS]: + for server_id in get_plex_data(hass)[SERVERS]: async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) hass.services.async_register( @@ -84,7 +85,7 @@ def get_plex_server( """Retrieve a configured Plex server by name.""" if DOMAIN not in hass.data: raise HomeAssistantError("Plex integration not configured") - servers: dict[str, PlexServer] = hass.data[DOMAIN][SERVERS] + servers: dict[str, PlexServer] = get_plex_data(hass)[SERVERS] if not servers: raise HomeAssistantError("No Plex servers available") diff --git a/homeassistant/components/plex/view.py b/homeassistant/components/plex/view.py index a2c31f17eb..ba883883dd 100644 --- a/homeassistant/components/plex/view.py +++ b/homeassistant/components/plex/view.py @@ -11,7 +11,8 @@ from aiohttp.typedefs import LooseHeaders from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.components.media_player import async_fetch_image -from .const import DOMAIN, SERVERS +from .const import SERVERS +from .helpers import get_plex_data _LOGGER = logging.getLogger(__name__) @@ -33,7 +34,7 @@ class PlexImageView(HomeAssistantView): return web.Response(status=HTTPStatus.UNAUTHORIZED) hass = request.app["hass"] - if (server := hass.data[DOMAIN][SERVERS].get(server_id)) is None: + if (server := get_plex_data(hass)[SERVERS].get(server_id)) is None: return web.Response(status=HTTPStatus.NOT_FOUND) if (image_url := server.thumbnail_cache.get(media_content_id)) is None: From 5f22796b38b4d249a7fa42e5d8692740cf07fc42 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 17 Mar 2023 22:45:15 +0100 Subject: [PATCH 1050/2810] Refactor imap coordinator (#89759) * Warn if the previous push wait task it taking longer than the update interval * refactor * Call _async_fetch_number_of_messages first * Add cleanup in case fetching fails * mypy * Set sensor to unknown if an error occured. * Handling invalid auth an reraise when needed * Handle invalid folder as setup error * Close IMAP stream before logout at cleanup --------- Co-authored-by: J. Nick Koston --- homeassistant/components/imap/__init__.py | 24 ++- homeassistant/components/imap/coordinator.py | 202 ++++++++++++++----- homeassistant/components/imap/sensor.py | 23 ++- 3 files changed, 185 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/imap/__init__.py b/homeassistant/components/imap/__init__.py index 7e582aa04d..468181be5f 100644 --- a/homeassistant/components/imap/__init__.py +++ b/homeassistant/components/imap/__init__.py @@ -15,7 +15,11 @@ from homeassistant.exceptions import ( ) from .const import DOMAIN -from .coordinator import ImapDataUpdateCoordinator, connect_to_server +from .coordinator import ( + ImapPollingDataUpdateCoordinator, + ImapPushDataUpdateCoordinator, + connect_to_server, +) from .errors import InvalidAuth, InvalidFolder PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -32,7 +36,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except (asyncio.TimeoutError, AioImapException) as err: raise ConfigEntryNotReady from err - coordinator = ImapDataUpdateCoordinator(hass, imap_client) + coordinator_class: type[ + ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator + ] + if imap_client.has_capability("IDLE"): + coordinator_class = ImapPushDataUpdateCoordinator + else: + coordinator_class = ImapPollingDataUpdateCoordinator + + coordinator: ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator = ( + coordinator_class(hass, imap_client) + ) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator @@ -49,6 +63,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - coordinator: ImapDataUpdateCoordinator = hass.data[DOMAIN].pop(entry.entry_id) + coordinator: ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator = hass.data[ + DOMAIN + ].pop( + entry.entry_id + ) await coordinator.shutdown() return unload_ok diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index e170f79e7f..e9bbb62301 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -10,9 +10,10 @@ from typing import Any from aioimaplib import AUTH, IMAP4_SSL, SELECTED, AioImapException import async_timeout -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_CHARSET, CONF_FOLDER, CONF_SEARCH, CONF_SERVER, DOMAIN @@ -20,6 +21,8 @@ from .errors import InvalidAuth, InvalidFolder _LOGGER = logging.getLogger(__name__) +BACKOFF_TIME = 10 + async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: """Connect to imap server and return client.""" @@ -27,82 +30,179 @@ async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: await client.wait_hello_from_server() await client.login(data[CONF_USERNAME], data[CONF_PASSWORD]) if client.protocol.state != AUTH: - raise InvalidAuth + raise InvalidAuth("Invalid username or password") await client.select(data[CONF_FOLDER]) if client.protocol.state != SELECTED: - raise InvalidFolder + raise InvalidFolder(f"Folder {data[CONF_FOLDER]} is invalid") return client -class ImapDataUpdateCoordinator(DataUpdateCoordinator[int]): - """Class for imap client.""" +class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): + """Base class for imap client.""" config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, imap_client: IMAP4_SSL) -> None: + def __init__( + self, + hass: HomeAssistant, + imap_client: IMAP4_SSL, + update_interval: timedelta | None, + ) -> None: """Initiate imap client.""" - self.hass = hass self.imap_client = imap_client - self.support_push = imap_client.has_capability("IDLE") super().__init__( hass, _LOGGER, name=DOMAIN, - update_interval=timedelta(seconds=10) if not self.support_push else None, + update_interval=update_interval, ) - async def _async_update_data(self) -> int: - """Update the number of unread emails.""" - try: - if self.imap_client is None: - self.imap_client = await connect_to_server(self.config_entry.data) - except (AioImapException, asyncio.TimeoutError) as err: - raise UpdateFailed(err) from err + async def async_start(self) -> None: + """Start coordinator.""" - return await self.refresh_email_count() - - async def refresh_email_count(self) -> int: - """Check the number of found emails.""" - try: - await self.imap_client.noop() - result, lines = await self.imap_client.search( - self.config_entry.data[CONF_SEARCH], - charset=self.config_entry.data[CONF_CHARSET], - ) - except (AioImapException, asyncio.TimeoutError) as err: - raise UpdateFailed(err) from err + async def _async_reconnect_if_needed(self) -> None: + """Connect to imap server.""" + if self.imap_client is None: + self.imap_client = await connect_to_server(self.config_entry.data) + async def _async_fetch_number_of_messages(self) -> int | None: + """Fetch number of messages.""" + await self._async_reconnect_if_needed() + await self.imap_client.noop() + result, lines = await self.imap_client.search( + self.config_entry.data[CONF_SEARCH], + charset=self.config_entry.data[CONF_CHARSET], + ) if result != "OK": raise UpdateFailed( f"Invalid response for search '{self.config_entry.data[CONF_SEARCH]}': {result} / {lines[0]}" ) - if self.support_push: - self.hass.async_create_background_task( - self.async_wait_server_push(), "Wait for IMAP data push" - ) return len(lines[0].split()) - async def async_wait_server_push(self) -> None: - """Wait for data push from server.""" - try: - idle: asyncio.Future = await self.imap_client.idle_start() - await self.imap_client.wait_server_push() - self.imap_client.idle_done() - async with async_timeout.timeout(10): - await idle - - except (AioImapException, asyncio.TimeoutError): - _LOGGER.warning( - "Lost %s (will attempt to reconnect)", - self.config_entry.data[CONF_SERVER], - ) + async def _cleanup(self, log_error: bool = False) -> None: + """Close resources.""" + if self.imap_client: + try: + if self.imap_client.has_pending_idle(): + self.imap_client.idle_done() + await self.imap_client.stop_wait_server_push() + await self.imap_client.close() + await self.imap_client.logout() + except (AioImapException, asyncio.TimeoutError) as ex: + if log_error: + self.async_set_update_error(ex) + _LOGGER.warning("Error while cleaning up imap connection") self.imap_client = None - await self.async_request_refresh() async def shutdown(self, *_) -> None: """Close resources.""" - if self.imap_client: - if self.imap_client.has_pending_idle(): + await self._cleanup(log_error=True) + + +class ImapPollingDataUpdateCoordinator(ImapDataUpdateCoordinator): + """Class for imap client.""" + + def __init__(self, hass: HomeAssistant, imap_client: IMAP4_SSL) -> None: + """Initiate imap client.""" + super().__init__(hass, imap_client, timedelta(seconds=10)) + + async def _async_update_data(self) -> int | None: + """Update the number of unread emails.""" + try: + return await self._async_fetch_number_of_messages() + except ( + AioImapException, + UpdateFailed, + asyncio.TimeoutError, + ) as ex: + self.async_set_update_error(ex) + await self._cleanup() + raise UpdateFailed() from ex + except InvalidFolder as ex: + _LOGGER.warning("Selected mailbox folder is invalid") + self.async_set_update_error(ex) + await self._cleanup() + raise ConfigEntryError("Selected mailbox folder is invalid.") from ex + except InvalidAuth as ex: + _LOGGER.warning("Username or password incorrect, starting reauthentication") + self.async_set_update_error(ex) + await self._cleanup() + raise ConfigEntryAuthFailed() from ex + + +class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator): + """Class for imap client.""" + + def __init__(self, hass: HomeAssistant, imap_client: IMAP4_SSL) -> None: + """Initiate imap client.""" + super().__init__(hass, imap_client, None) + self._push_wait_task: asyncio.Task[None] | None = None + + async def _async_update_data(self) -> int | None: + """Update the number of unread emails.""" + await self.async_start() + return None + + async def async_start(self) -> None: + """Start coordinator.""" + self._push_wait_task = self.hass.async_create_background_task( + self._async_wait_push_loop(), "Wait for IMAP data push" + ) + + async def _async_wait_push_loop(self) -> None: + """Wait for data push from server.""" + while True: + try: + number_of_messages = await self._async_fetch_number_of_messages() + except InvalidAuth as ex: + _LOGGER.warning( + "Username or password incorrect, starting reauthentication" + ) + self.config_entry.async_start_reauth(self.hass) + self.async_set_update_error(ex) + await self._cleanup() + await asyncio.sleep(BACKOFF_TIME) + except InvalidFolder as ex: + _LOGGER.warning("Selected mailbox folder is invalid") + self.config_entry.async_set_state( + self.hass, + ConfigEntryState.SETUP_ERROR, + "Selected mailbox folder is invalid.", + ) + self.async_set_update_error(ex) + await self._cleanup() + await asyncio.sleep(BACKOFF_TIME) + except ( + UpdateFailed, + AioImapException, + asyncio.TimeoutError, + ) as ex: + self.async_set_update_error(ex) + await self._cleanup() + await asyncio.sleep(BACKOFF_TIME) + continue + else: + self.async_set_updated_data(number_of_messages) + try: + idle: asyncio.Future = await self.imap_client.idle_start() + await self.imap_client.wait_server_push() self.imap_client.idle_done() - await self.imap_client.stop_wait_server_push() - await self.imap_client.logout() + async with async_timeout.timeout(10): + await idle + + except (AioImapException, asyncio.TimeoutError): + _LOGGER.warning( + "Lost %s (will attempt to reconnect after %s s)", + self.config_entry.data[CONF_SERVER], + BACKOFF_TIME, + ) + self.async_set_update_error(UpdateFailed("Lost connection")) + await self._cleanup() + await asyncio.sleep(BACKOFF_TIME) + continue + + async def shutdown(self, *_) -> None: + """Close resources.""" + if self._push_wait_task: + self._push_wait_task.cancel() + await super().shutdown() diff --git a/homeassistant/components/imap/sensor.py b/homeassistant/components/imap/sensor.py index 20457209e9..0bccce0c98 100644 --- a/homeassistant/components/imap/sensor.py +++ b/homeassistant/components/imap/sensor.py @@ -15,7 +15,7 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import ImapDataUpdateCoordinator +from . import ImapPollingDataUpdateCoordinator, ImapPushDataUpdateCoordinator from .const import ( CONF_CHARSET, CONF_FOLDER, @@ -69,18 +69,26 @@ async def async_setup_entry( ) -> None: """Set up the Imap sensor.""" - coordinator: ImapDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator = ( + hass.data[DOMAIN][entry.entry_id] + ) async_add_entities([ImapSensor(coordinator)]) -class ImapSensor(CoordinatorEntity[ImapDataUpdateCoordinator], SensorEntity): +class ImapSensor( + CoordinatorEntity[ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator], + SensorEntity, +): """Representation of an IMAP sensor.""" _attr_icon = "mdi:email-outline" _attr_has_entity_name = True - def __init__(self, coordinator: ImapDataUpdateCoordinator) -> None: + def __init__( + self, + coordinator: ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator, + ) -> None: """Initialize the sensor.""" super().__init__(coordinator) # To be removed when YAML import is removed @@ -95,11 +103,6 @@ class ImapSensor(CoordinatorEntity[ImapDataUpdateCoordinator], SensorEntity): ) @property - def native_value(self) -> int: + def native_value(self) -> int | None: """Return the number of emails found.""" return self.coordinator.data - - async def async_update(self) -> None: - """Check for idle state before updating.""" - if not await self.coordinator.imap_client.stop_wait_server_push(): - await super().async_update() From dbebf8c78356ecc13b2ba175c45885b6c3de21d3 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 18 Mar 2023 01:24:33 +0100 Subject: [PATCH 1051/2810] Add state attribute translations for media players (#89821) * Add state attribute translations for media players * Process review comments * Process review comments * Fix and extend * Add yes/no as generic state --- .../components/media_player/strings.json | 118 ++++++++++++++++++ homeassistant/strings.json | 2 + 2 files changed, 120 insertions(+) diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index 8627a31307..cee0ee200f 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -30,6 +30,124 @@ "idle": "[%key:common::state::idle%]", "standby": "[%key:common::state::standby%]", "buffering": "Buffering" + }, + "state_attributes": { + "app_id": { + "name": "App ID" + }, + "app_name": { + "name": "App" + }, + "entity_picture_local": { + "name": "Local accessible entity picture" + }, + "groups_members": { + "name": "Group members" + }, + "is_volume_muted": { + "name": "Muted", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "media_album_artist": { + "name": "Album artist" + }, + "media_album_name": { + "name": "Album" + }, + "media_artist": { + "name": "Artist" + }, + "media_channel": { + "name": "Channel" + }, + "media_content_id": { + "name": "Content ID" + }, + "media_content_type": { + "name": "Content type", + "state": { + "album": "Album", + "app": "App", + "artist": "Artist", + "channel": "Channel", + "channels": "Channels", + "composer": "Composer", + "contributing_artist": "Contributing artist", + "episode": "Episode", + "game": "Game", + "genre": "Genre", + "image": "Image", + "movie": "Movie", + "music": "Music", + "playlist": "Playlist", + "podcast": "Podcast", + "season": "Season", + "track": "Track", + "tvshow": "TV show", + "url": "URL", + "video": "Video" + } + }, + "media_duration": { + "name": "Duration" + }, + "media_episode": { + "name": "Episode" + }, + "media_playlist": { + "name": "Playlist" + }, + "media_position": { + "name": "Position" + }, + "media_position_updated_at": { + "name": "Position updated" + }, + "media_title": { + "name": "Title" + }, + "media_track": { + "name": "Track" + }, + "media_season": { + "name": "Season" + }, + "media_series_title": { + "name": "Series" + }, + "repeat": { + "name": "Repeat", + "state": { + "all": "All", + "off": "Off", + "one": "One" + } + }, + "shuffle": { + "name": "Shuffle", + "state": { + "true": "[%key:common::state::on%]", + "false": "[%key:common::state::off%]" + } + }, + "source": { + "name": "Source" + }, + "source_list": { + "name": "Available sources" + }, + "sound_mode": { + "name": "Sound mode" + }, + "sound_mode_list": { + "name": "Available sound modes" + }, + "volume_level": { + "name": "Volume" + } } }, "tv": { diff --git a/homeassistant/strings.json b/homeassistant/strings.json index c00b51ed6c..f3829cd2bd 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -3,6 +3,8 @@ "state": { "off": "Off", "on": "On", + "yes": "Yes", + "no": "No", "open": "Open", "closed": "Closed", "connected": "Connected", From 138bbd9c28d93f458c14af1bf77023e1c12b149a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Mar 2023 14:25:29 -1000 Subject: [PATCH 1052/2810] Use json_loads_object util in backup (#89895) * Use json_loads_object util in backup * adjust test --- homeassistant/components/backup/manager.py | 11 ++++++----- tests/components/backup/test_manager.py | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index e38312dd6e..71098f6191 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -9,7 +9,7 @@ from pathlib import Path import tarfile from tarfile import TarError from tempfile import TemporaryDirectory -from typing import Any, Protocol +from typing import Any, Protocol, cast from securetar import SecureTarFile, atomic_contents_add @@ -19,6 +19,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import integration_platform from homeassistant.helpers.json import save_json from homeassistant.util import dt +from homeassistant.util.json import json_loads_object from .const import DOMAIN, EXCLUDE_FROM_BACKUP, LOGGER @@ -100,11 +101,11 @@ class BackupManager: try: with tarfile.open(backup_path, "r:") as backup_file: if data_file := backup_file.extractfile("./backup.json"): - data = json.loads(data_file.read()) + data = json_loads_object(data_file.read()) backup = Backup( - slug=data["slug"], - name=data["name"], - date=data["date"], + slug=cast(str, data["slug"]), + name=cast(str, data["name"]), + date=cast(str, data["date"]), path=backup_path, size=round(backup_path.stat().st_size / 1_048_576, 2), ) diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index aa7d6bae89..9d3b9889cd 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -85,7 +85,7 @@ async def test_load_backups(hass: HomeAssistant) -> None: with patch("pathlib.Path.glob", return_value=[TEST_BACKUP.path]), patch( "tarfile.open", return_value=MagicMock() ), patch( - "json.loads", + "homeassistant.components.backup.manager.json_loads_object", return_value={ "slug": TEST_BACKUP.slug, "name": TEST_BACKUP.name, From b1f64de6cecb99c7a62f816451433b1115cf661c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Mar 2023 14:27:33 -1000 Subject: [PATCH 1053/2810] Remove the old ix_states_event_id index if its no longer being used (#89901) * Remove the old ix_states_event_id index if its no longer being used * cover it * fixes * fixup * Update homeassistant/components/recorder/tasks.py --- homeassistant/components/recorder/const.py | 2 + homeassistant/components/recorder/core.py | 18 +++++++ .../components/recorder/db_schema.py | 5 +- .../components/recorder/migration.py | 51 ++++++++++++++----- homeassistant/components/recorder/queries.py | 7 +++ homeassistant/components/recorder/tasks.py | 14 +++++ homeassistant/components/recorder/util.py | 21 +++++++- .../components/recorder/test_v32_migration.py | 28 ++++++++-- 8 files changed, 124 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index d72666f76b..6bf46efd36 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -48,6 +48,8 @@ CONTEXT_ID_AS_BINARY_SCHEMA_VERSION = 36 EVENT_TYPE_IDS_SCHEMA_VERSION = 37 STATES_META_SCHEMA_VERSION = 38 +LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28 + class SupportedDialect(StrEnum): """Supported dialects.""" diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 8d1e870865..538d07eb4d 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -46,6 +46,7 @@ from .const import ( DOMAIN, EVENT_TYPE_IDS_SCHEMA_VERSION, KEEPALIVE_TIME, + LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION, MARIADB_PYMYSQL_URL_PREFIX, MARIADB_URL_PREFIX, MAX_QUEUE_BACKLOG, @@ -57,7 +58,9 @@ from .const import ( SupportedDialect, ) from .db_schema import ( + LEGACY_STATES_EVENT_ID_INDEX, SCHEMA_VERSION, + TABLE_STATES, Base, EventData, Events, @@ -93,6 +96,7 @@ from .tasks import ( CompileMissingStatisticsTask, DatabaseLockTask, EntityIDMigrationTask, + EventIdMigrationTask, EventsContextIDMigrationTask, EventTask, EventTypeIDMigrationTask, @@ -113,6 +117,7 @@ from .util import ( dburl_to_path, end_incomplete_runs, execute_stmt_lambda_element, + get_index_by_name, is_second_sunday, move_away_broken_database, session_scope, @@ -730,6 +735,15 @@ class Recorder(threading.Thread): _LOGGER.debug("Activating states_meta manager as all data is migrated") self.states_meta_manager.active = True + if self.schema_version > LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION: + with contextlib.suppress(SQLAlchemyError): + # If the index of event_ids on the states table is still present + # we need to queue a task to remove it. + if get_index_by_name( + session, TABLE_STATES, LEGACY_STATES_EVENT_ID_INDEX + ): + self.queue_task(EventIdMigrationTask()) + # We must only set the db ready after we have set the table managers # to active if there is no data to migrate. # @@ -1138,6 +1152,10 @@ class Recorder(threading.Thread): """Post migrate entity_ids if needed.""" return migration.post_migrate_entity_ids(self) + def _cleanup_legacy_states_event_ids(self) -> bool: + """Cleanup legacy event_ids if needed.""" + return migration.cleanup_legacy_states_event_ids(self) + def _send_keep_alive(self) -> None: """Send a keep alive to keep the db connection open.""" assert self.event_session is not None diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 2fa3746a2c..4826453e4c 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -116,6 +116,7 @@ LAST_UPDATED_INDEX_TS = "ix_states_last_updated_ts" METADATA_ID_LAST_UPDATED_INDEX_TS = "ix_states_metadata_id_last_updated_ts" EVENTS_CONTEXT_ID_BIN_INDEX = "ix_events_context_id_bin" STATES_CONTEXT_ID_BIN_INDEX = "ix_states_context_id_bin" +LEGACY_STATES_EVENT_ID_INDEX = "ix_states_event_id" CONTEXT_ID_BIN_MAX_LENGTH = 16 _DEFAULT_TABLE_ARGS = { @@ -385,9 +386,7 @@ class States(Base): attributes: Mapped[str | None] = mapped_column( Text().with_variant(mysql.LONGTEXT, "mysql", "mariadb") ) # no longer used for new rows - event_id: Mapped[int | None] = mapped_column( # no longer used for new rows - Integer, ForeignKey("events.event_id", ondelete="CASCADE"), index=True - ) + event_id: Mapped[int | None] = mapped_column(Integer) # no longer used for new rows last_changed: Mapped[datetime | None] = mapped_column( DATETIME_TYPE ) # no longer used for new rows diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 08f5f21b89..4619c4531d 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -30,6 +30,7 @@ from homeassistant.util.ulid import ulid_to_bytes from .const import SupportedDialect from .db_schema import ( CONTEXT_ID_BIN_MAX_LENGTH, + LEGACY_STATES_EVENT_ID_INDEX, SCHEMA_VERSION, STATISTICS_TABLES, TABLE_STATES, @@ -51,6 +52,7 @@ from .queries import ( find_event_type_to_migrate, find_events_context_ids_to_migrate, find_states_context_ids_to_migrate, + has_used_states_event_ids, ) from .statistics import ( correct_db_schema as statistics_correct_db_schema, @@ -64,7 +66,12 @@ from .tasks import ( PostSchemaMigrationTask, StatisticsTimestampMigrationCleanupTask, ) -from .util import database_job_retry_wrapper, retryable_database_job, session_scope +from .util import ( + database_job_retry_wrapper, + get_index_by_name, + retryable_database_job, + session_scope, +) if TYPE_CHECKING: from . import Recorder @@ -308,18 +315,7 @@ def _drop_index( with session_scope(session=session_maker()) as session, contextlib.suppress( SQLAlchemyError ): - connection = session.connection() - inspector = sqlalchemy.inspect(connection) - indexes = inspector.get_indexes(table_name) - if index_to_drop := next( - ( - possible_index["name"] - for possible_index in indexes - if possible_index["name"] - and possible_index["name"].endswith(f"_{index_name}") - ), - None, - ): + if index_to_drop := get_index_by_name(session, table_name, index_name): connection.execute(text(f"DROP INDEX {index_to_drop}")) success = True @@ -593,7 +589,7 @@ def _apply_update( # noqa: C901 # but it was removed in version 32 elif new_version == 5: # Create supporting index for States.event_id foreign key - _create_index(session_maker, "states", "ix_states_event_id") + _create_index(session_maker, "states", LEGACY_STATES_EVENT_ID_INDEX) elif new_version == 6: _add_columns( session_maker, @@ -1529,6 +1525,33 @@ def post_migrate_entity_ids(instance: Recorder) -> bool: return is_done +@retryable_database_job("cleanup_legacy_event_ids") +def cleanup_legacy_states_event_ids(instance: Recorder) -> bool: + """Remove old event_id index from states. + + We used to link states to events using the event_id column but we no + longer store state changed events in the events table. + + If all old states have been purged and existing states are in the new + format we can drop the index since it can take up ~10MB per 1M rows. + """ + session_maker = instance.get_session + _LOGGER.debug("Cleanup legacy entity_ids") + with session_scope(session=session_maker()) as session: + result = session.execute(has_used_states_event_ids()).scalar() + # In the future we may migrate existing states to the new format + # but in practice very few of these still exist in production and + # removing the index is the likely all that needs to happen. + all_gone = not result + + if all_gone: + # Only drop the index if there are no more event_ids in the states table + # ex all NULL + _drop_index(session_maker, "states", LEGACY_STATES_EVENT_ID_INDEX) + + return True + + def _initialize_database(session: Session) -> bool: """Initialize a new database. diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py index 5a2c7040f4..f983224e21 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -745,6 +745,13 @@ def batch_cleanup_entity_ids() -> StatementLambdaElement: ) +def has_used_states_event_ids() -> StatementLambdaElement: + """Check if there are used event_ids in the states table.""" + return lambda_stmt( + lambda: select(States.state_id).filter(States.event_id.isnot(None)).limit(1) + ) + + def has_events_context_ids_to_migrate() -> StatementLambdaElement: """Check if there are events context ids to migrate.""" return lambda_stmt( diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index 5762a9ab69..ef8f6a95a7 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -438,3 +438,17 @@ class EntityIDPostMigrationTask(RecorderTask): ): # Schedule a new migration task if this one didn't finish instance.queue_task(EntityIDPostMigrationTask()) + + +@dataclass +class EventIdMigrationTask(RecorderTask): + """An object to insert into the recorder queue to cleanup legacy event_ids in the states table. + + This task should only be queued if the ix_states_event_id index exists + since it is used to scan the states table and it will be removed after this + task is run if its no longer needed. + """ + + def run(self, instance: Recorder) -> None: + """Clean up the legacy event_id index on states.""" + instance._cleanup_legacy_states_event_ids() # pylint: disable=[protected-access] diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index ae09f9fd6a..4ec0a0c450 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -18,7 +18,7 @@ from awesomeversion import ( AwesomeVersionStrategy, ) import ciso8601 -from sqlalchemy import text +from sqlalchemy import inspect, text from sqlalchemy.engine import Result, Row from sqlalchemy.exc import OperationalError, SQLAlchemyError from sqlalchemy.orm.query import Query @@ -832,3 +832,22 @@ def chunked(iterable: Iterable, chunked_num: int) -> Iterable[Any]: From more-itertools """ return iter(partial(take, chunked_num, iter(iterable)), []) + + +def get_index_by_name(session: Session, table_name: str, index_name: str) -> str | None: + """Get an index by name.""" + connection = session.connection() + inspector = inspect(connection) + indexes = inspector.get_indexes(table_name) + return next( + ( + possible_index["name"] + for possible_index in indexes + if possible_index["name"] + and ( + possible_index["name"] == index_name + or possible_index["name"].endswith(f"_{index_name}") + ) + ), + None, + ) diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index 22aa96f8e2..dd49d7b21e 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -91,6 +91,10 @@ async def test_migrate_times( ) number_of_migrations = 5 + def _get_states_index_names(): + with session_scope(hass=hass) as session: + return inspect(session.connection()).get_indexes("states") + with patch.object(recorder, "db_schema", old_db_schema), patch.object( recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION ), patch.object(core, "StatesMeta", old_db_schema.StatesMeta), patch.object( @@ -113,6 +117,8 @@ async def test_migrate_times( "homeassistant.components.recorder.Recorder._migrate_entity_ids", ), patch( "homeassistant.components.recorder.Recorder._post_migrate_entity_ids" + ), patch( + "homeassistant.components.recorder.Recorder._cleanup_legacy_states_event_ids" ): hass = await async_test_home_assistant(asyncio.get_running_loop()) recorder_helper.async_initialize_recorder(hass) @@ -132,11 +138,18 @@ async def test_migrate_times( await hass.async_block_till_done() await recorder.get_instance(hass).async_block_till_done() + states_indexes = await recorder.get_instance(hass).async_add_executor_job( + _get_states_index_names + ) + states_index_names = {index["name"] for index in states_indexes} + await hass.async_stop() await hass.async_block_till_done() dt_util.DEFAULT_TIME_ZONE = ORIG_TZ + assert "ix_states_event_id" in states_index_names + # Test that the duplicates are removed during migration from schema 23 hass = await async_test_home_assistant(asyncio.get_running_loop()) recorder_helper.async_initialize_recorder(hass) @@ -186,13 +199,20 @@ async def test_migrate_times( with session_scope(hass=hass) as session: return inspect(session.connection()).get_indexes("events") - indexes = await recorder.get_instance(hass).async_add_executor_job( + events_indexes = await recorder.get_instance(hass).async_add_executor_job( _get_events_index_names ) - index_names = {index["name"] for index in indexes} + events_index_names = {index["name"] for index in events_indexes} - assert "ix_events_context_id_bin" in index_names - assert "ix_events_context_id" not in index_names + assert "ix_events_context_id_bin" in events_index_names + assert "ix_events_context_id" not in events_index_names + + states_indexes = await recorder.get_instance(hass).async_add_executor_job( + _get_states_index_names + ) + states_index_names = {index["name"] for index in states_indexes} + + assert "ix_states_event_id" not in states_index_names await hass.async_stop() dt_util.DEFAULT_TIME_ZONE = ORIG_TZ From e87359761b96969229d309283346da49871f10e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Mar 2023 14:28:29 -1000 Subject: [PATCH 1054/2810] Fix some I/O in the event loop during backup (#89894) --- homeassistant/components/backup/manager.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 71098f6191..69df310bd5 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -187,13 +187,8 @@ class BackupManager: "compressed": True, } tar_file_path = Path(self.backup_dir, f"{backup_data['slug']}.tar") - - if not self.backup_dir.exists(): - LOGGER.debug("Creating backup directory") - self.hass.async_add_executor_job(self.backup_dir.mkdir) - - await self.hass.async_add_executor_job( - self._generate_backup_contents, + size_in_bytes = await self.hass.async_add_executor_job( + self._mkdir_and_generate_backup_contents, tar_file_path, backup_data, ) @@ -202,7 +197,7 @@ class BackupManager: name=backup_name, date=date_str, path=tar_file_path, - size=round(tar_file_path.stat().st_size / 1_048_576, 2), + size=round(size_in_bytes / 1_048_576, 2), ) if self.loaded_backups: self.backups[slug] = backup @@ -221,12 +216,16 @@ class BackupManager: if isinstance(result, Exception): raise result - def _generate_backup_contents( + def _mkdir_and_generate_backup_contents( self, tar_file_path: Path, backup_data: dict[str, Any], - ) -> None: - """Generate backup contents.""" + ) -> int: + """Generate backup contents and return the size.""" + if not self.backup_dir.exists(): + LOGGER.debug("Creating backup directory") + self.backup_dir.mkdir() + with TemporaryDirectory() as tmp_dir, SecureTarFile( tar_file_path, "w", gzip=False ) as tar_file: @@ -246,6 +245,7 @@ class BackupManager: arcname="data", ) tar_file.add(tmp_dir_path, arcname=".") + return tar_file_path.stat().st_size def _generate_slug(date: str, name: str) -> str: From 1f4164def837954ef82fa760693a3103099c23c4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 18 Mar 2023 01:29:12 +0100 Subject: [PATCH 1055/2810] Add state (attribute) translations for Text (#89898) --- homeassistant/components/text/strings.json | 23 ++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/homeassistant/components/text/strings.json b/homeassistant/components/text/strings.json index 0f5ddf5b33..034f1ab315 100644 --- a/homeassistant/components/text/strings.json +++ b/homeassistant/components/text/strings.json @@ -4,5 +4,28 @@ "action_type": { "set_value": "Set value for {entity_name}" } + }, + "entity_component": { + "_": { + "name": "[%key:component::text::title%]", + "state_attributes": { + "max": { + "name": "Max length" + }, + "min": { + "name": "Min length" + }, + "mode": { + "name": "Mode", + "state": { + "text": "Text", + "password": "Password" + } + }, + "pattern": { + "name": "Pattern" + } + } + } } } From 8ecd73cac7dd4666262a1cb0c77637685c58194d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 18 Mar 2023 01:29:48 +0100 Subject: [PATCH 1056/2810] Add state attribute translations for Weather (#89897) --- homeassistant/components/weather/strings.json | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json index 0f88f1ae7e..a64f846722 100644 --- a/homeassistant/components/weather/strings.json +++ b/homeassistant/components/weather/strings.json @@ -1,7 +1,8 @@ { + "title": "Weather", "entity_component": { "_": { - "name": "Weather", + "name": "[%key:component::weather::title%]", "state": { "clear-night": "Clear, night", "cloudy": "Cloudy", @@ -18,6 +19,47 @@ "sunny": "Sunny", "windy": "Windy", "windy-variant": "Windy" + }, + "state_attributes": { + "forecast": { + "name": "Forecast" + }, + "humidity": { + "name": "Humidity" + }, + "ozone": { + "name": "Ozone" + }, + "precipitation_unit": { + "name": "Precipitation unit" + }, + "pressure": { + "name": "Pressure" + }, + "pressure_unit": { + "name": "Pressure unit" + }, + "temperature": { + "name": "Temperature" + }, + "temperature_unit": { + "name": "Temperature unit" + }, + "visibility": { + "name": "Visibility" + }, + "visibility_unit": { + "name": "Visibility unit" + }, + "wind_bearing": { + "name": "Wind bearing" + }, + "wind_speed": { + "name": "Wind speed" + }, + "wind_speed_unit": { + "name": "Wind speed unit" + } } } } From 30e7ab247d816269b18825e8ae3d9a76f420f787 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Mar 2023 14:32:24 -1000 Subject: [PATCH 1057/2810] Small cleanups to writing entity state (#89890) * Small cleanups to writing entity state * reduce one prop access * small cleanups * small cleanups * name conflict --- homeassistant/helpers/entity.py | 62 +++++++++++++++++---------------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 63b70aa13d..9d9e685d6a 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -579,6 +579,25 @@ class Entity(ABC): return f"{state:.{FLOAT_PRECISION}}" return str(state) + def _friendly_name_internal(self) -> str | None: + """Return the friendly name. + + If has_entity_name is False, this returns self.name + If has_entity_name is True, this returns device.name + self.name + """ + if not self.has_entity_name or not self.registry_entry: + return self.name + + device_registry = dr.async_get(self.hass) + if not (device_id := self.registry_entry.device_id) or not ( + device_entry := device_registry.async_get(device_id) + ): + return self.name + + if not (name := self.name): + return device_entry.name_by_user or device_entry.name + return f"{device_entry.name_by_user or device_entry.name} {name}" + @callback def _async_write_ha_state(self) -> None: """Write the state to the state machine.""" @@ -586,7 +605,11 @@ class Entity(ABC): # Polling returned after the entity has already been removed return - if self.registry_entry and self.registry_entry.disabled_by: + hass = self.hass + entity_id = self.entity_id + entry = self.registry_entry + + if entry and entry.disabled_by: if not self._disabled_reported: self._disabled_reported = True assert self.platform is not None @@ -595,7 +618,7 @@ class Entity(ABC): "Entity %s is incorrectly being triggered for updates while it" " is disabled. This is a bug in the %s integration" ), - self.entity_id, + entity_id, self.platform.platform_name, ) return @@ -614,8 +637,6 @@ class Entity(ABC): if (unit_of_measurement := self.unit_of_measurement) is not None: attr[ATTR_UNIT_OF_MEASUREMENT] = unit_of_measurement - entry = self.registry_entry - if assumed_state := self.assumed_state: attr[ATTR_ASSUMED_STATE] = assumed_state @@ -633,26 +654,9 @@ class Entity(ABC): if (icon := (entry and entry.icon) or self.icon) is not None: attr[ATTR_ICON] = icon - def friendly_name() -> str | None: - """Return the friendly name. - - If has_entity_name is False, this returns self.name - If has_entity_name is True, this returns device.name + self.name - """ - if not self.has_entity_name or not self.registry_entry: - return self.name - - device_registry = dr.async_get(self.hass) - if not (device_id := self.registry_entry.device_id) or not ( - device_entry := device_registry.async_get(device_id) - ): - return self.name - - if not self.name: - return device_entry.name_by_user or device_entry.name - return f"{device_entry.name_by_user or device_entry.name} {self.name}" - - if (name := (entry and entry.name) or friendly_name()) is not None: + if ( + name := (entry and entry.name) or self._friendly_name_internal() + ) is not None: attr[ATTR_FRIENDLY_NAME] = name if (supported_features := self.supported_features) is not None: @@ -665,15 +669,15 @@ class Entity(ABC): report_issue = self._suggest_report_issue() _LOGGER.warning( "Updating state for %s (%s) took %.3f seconds. Please %s", - self.entity_id, + entity_id, type(self), end - start, report_issue, ) # Overwrite properties that have been set in the config file. - if DATA_CUSTOMIZE in self.hass.data: - attr.update(self.hass.data[DATA_CUSTOMIZE].get(self.entity_id)) + if customize := hass.data.get(DATA_CUSTOMIZE): + attr.update(customize.get(entity_id)) if ( self._context_set is not None @@ -682,9 +686,7 @@ class Entity(ABC): self._context = None self._context_set = None - self.hass.states.async_set( - self.entity_id, state, attr, self.force_update, self._context - ) + hass.states.async_set(entity_id, state, attr, self.force_update, self._context) def schedule_update_ha_state(self, force_refresh: bool = False) -> None: """Schedule an update ha state change task. From cd3819abec84ad95c95dabd8ca3c5c7277dbe0eb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 18 Mar 2023 01:32:52 +0100 Subject: [PATCH 1058/2810] Add state attribute translations for Sensor (#89896) --- homeassistant/components/sensor/strings.json | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index a579be6720..5b34c5a28e 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -100,6 +100,22 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" + }, + "state_attributes": { + "last_reset": { + "name": "Last reset" + }, + "options": { + "name": "Possible states" + }, + "state_class": { + "name": "State class", + "state": { + "measurement": "Measurement", + "total": "Total", + "total_increasing": "Total increasing" + } + } } }, "date": { From d106cb48d2cebfc41883e9faf51b2769b8b5dffb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 18 Mar 2023 01:35:25 +0100 Subject: [PATCH 1059/2810] Add state attribute translations for light (#89818) * Add state attribute translations for light * Process review comments --- homeassistant/components/light/strings.json | 59 +++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index ef3fa74bd9..935e38d33d 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -25,6 +25,65 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" + }, + "state_attributes": { + "brightness": { + "name": "Brightness" + }, + "color_mode": { + "name": "Color mode", + "state": { + "brightness": "Brightness only", + "color_temp": "Color temperature", + "hs": "HS", + "onoff": "On/Off", + "rgb": "RGB", + "rgbw": "RGBW", + "rgbww": "RGBWW", + "unknown": "Unknown", + "white": "White", + "xy": "XY" + } + }, + "color_temp": { + "name": "Color temperature (mireds)" + }, + "color_temp_kelvin": { + "name": "Color temperature (Kelvin)" + }, + "effect": { + "name": "Effect" + }, + "effect_list": { + "name": "Available effects" + }, + "max_color_temp_kelvin": { + "name": "Maximum color temperature (Kelvin)" + }, + "min_color_temp_kelvin": { + "name": "Minimum color temperature (Kelvin)" + }, + "max_mireds": { + "name": "Maximum color temperature (mireds)" + }, + "min_mireds": { + "name": "Minimum color temperature (mireds)" + }, + "supported_color_modes": { + "name": "Available color modes", + "state": { + "brightness": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::brightness%]", + "color_temp": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::color_temp%]", + "hs": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::hs%]", + "onoff": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::onoff%]", + "rgb": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::rgb%]", + "rgbw": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::rgbw%]", + "rgbww": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::rgbww%]", + "unknown": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::unknown%]", + "white": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::white%]", + "xy": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::xy%]" + } + } } } } From 6ad9f420ab7e0e85146853fe9fd8ef5c12421ccb Mon Sep 17 00:00:00 2001 From: Vincent Knoop Pathuis <48653141+vpathuis@users.noreply.github.com> Date: Sat, 18 Mar 2023 20:50:50 +0100 Subject: [PATCH 1060/2810] Add Landis+Gyr poll on restart (#89644) --- .../landisgyr_heat_meter/__init__.py | 1 + .../components/landisgyr_heat_meter/sensor.py | 133 +++++++++++------- .../landisgyr_heat_meter/test_sensor.py | 120 +--------------- 3 files changed, 87 insertions(+), 167 deletions(-) diff --git a/homeassistant/components/landisgyr_heat_meter/__init__.py b/homeassistant/components/landisgyr_heat_meter/__init__.py index 541fef017d..3a44267bd4 100644 --- a/homeassistant/components/landisgyr_heat_meter/__init__.py +++ b/homeassistant/components/landisgyr_heat_meter/__init__.py @@ -29,6 +29,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await coordinator.async_config_entry_first_refresh() await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/landisgyr_heat_meter/sensor.py b/homeassistant/components/landisgyr_heat_meter/sensor.py index 508ae43b8e..af96629742 100644 --- a/homeassistant/components/landisgyr_heat_meter/sensor.py +++ b/homeassistant/components/landisgyr_heat_meter/sensor.py @@ -1,14 +1,16 @@ """Platform for sensor integration.""" from __future__ import annotations -from dataclasses import asdict +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime import logging from ultraheat_api.response import HeatMeterResponse from homeassistant.components.sensor import ( - RestoreSensor, SensorDeviceClass, + SensorEntity, SensorEntityDescription, SensorStateClass, ) @@ -25,6 +27,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -36,177 +39,220 @@ from . import DOMAIN _LOGGER = logging.getLogger(__name__) +@dataclass +class HeatMeterSensorEntityDescriptionMixin: + """Mixin for additional Heat Meter sensor description attributes .""" + + value_fn: Callable[[HeatMeterResponse], StateType | datetime] + + +@dataclass +class HeatMeterSensorEntityDescription( + SensorEntityDescription, HeatMeterSensorEntityDescriptionMixin +): + """Heat Meter sensor description.""" + + HEAT_METER_SENSOR_TYPES = ( - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="volume_usage_m3", icon="mdi:fire", name="Volume usage", device_class=SensorDeviceClass.VOLUME, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, state_class=SensorStateClass.TOTAL, + value_fn=lambda res: getattr(res, "volume_usage_m3", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="heat_usage_gj", icon="mdi:fire", name="Heat usage GJ", native_unit_of_measurement=UnitOfEnergy.GIGA_JOULE, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, + value_fn=lambda res: getattr(res, "heat_usage_gj", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="heat_previous_year_gj", icon="mdi:fire", name="Heat previous year GJ", - native_unit_of_measurement="GJ", + native_unit_of_measurement=UnitOfEnergy.GIGA_JOULE, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "heat_previous_year_gj", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="volume_previous_year_m3", icon="mdi:fire", name="Volume usage previous year", device_class=SensorDeviceClass.VOLUME, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "volume_previous_year_m3", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="ownership_number", name="Ownership number", icon="mdi:identifier", entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "ownership_number", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="error_number", name="Error number", icon="mdi:home-alert", entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "error_number", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="device_number", name="Device number", icon="mdi:identifier", entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "device_number", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="measurement_period_minutes", name="Measurement period minutes", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.MINUTES, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "measurement_period_minutes", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="power_max_kw", name="Power max", native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "power_max_kw", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="power_max_previous_year_kw", name="Power max previous year", native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "power_max_previous_year_kw", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="flowrate_max_m3ph", name="Flowrate max", native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, icon="mdi:water-outline", entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "flowrate_max_m3ph", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="flowrate_max_previous_year_m3ph", name="Flowrate max previous year", native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, icon="mdi:water-outline", entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "flowrate_max_previous_year_m3ph", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="return_temperature_max_c", name="Return temperature max", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "return_temperature_max_c", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="return_temperature_max_previous_year_c", name="Return temperature max previous year", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr( + res, "return_temperature_max_previous_year_c", None + ), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="flow_temperature_max_c", name="Flow temperature max", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "flow_temperature_max_c", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="flow_temperature_max_previous_year_c", name="Flow temperature max previous year", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "flow_temperature_max_previous_year_c", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="operating_hours", name="Operating hours", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.HOURS, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "operating_hours", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="flow_hours", name="Flow hours", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.HOURS, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "flow_hours", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="fault_hours", name="Fault hours", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.HOURS, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "fault_hours", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="fault_hours_previous_year", name="Fault hours previous year", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.HOURS, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "fault_hours_previous_year", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="yearly_set_day", name="Yearly set day", icon="mdi:clock-outline", entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "yearly_set_day", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="monthly_set_day", name="Monthly set day", icon="mdi:clock-outline", entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "monthly_set_day", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="meter_date_time", name="Meter date time", icon="mdi:clock-outline", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: dt_util.as_utc(res.meter_date_time) + if res.meter_date_time + else None, ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="measuring_range_m3ph", name="Measuring range", native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, icon="mdi:water-outline", entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "measuring_range_m3ph", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="settings_and_firmware", name="Settings and firmware", entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "settings_and_firmware", None), ), ) @@ -238,14 +284,17 @@ async def async_setup_entry( class HeatMeterSensor( - CoordinatorEntity[DataUpdateCoordinator[HeatMeterResponse]], RestoreSensor + CoordinatorEntity[DataUpdateCoordinator[HeatMeterResponse]], + SensorEntity, ): """Representation of a Sensor.""" + entity_description: HeatMeterSensorEntityDescription + def __init__( self, coordinator: DataUpdateCoordinator[HeatMeterResponse], - description: SensorEntityDescription, + description: HeatMeterSensorEntityDescription, device: DeviceInfo, ) -> None: """Set up the sensor with the initial values.""" @@ -254,25 +303,9 @@ class HeatMeterSensor( self._attr_unique_id = f"{coordinator.config_entry.data['device_number']}_{description.key}" # type: ignore[union-attr] self._attr_name = f"Heat Meter {description.name}" self.entity_description = description - self._attr_device_info = device - self._attr_should_poll = bool(self.key in ("heat_usage", "heat_previous_year")) - async def async_added_to_hass(self) -> None: - """Call when entity about to be added to hass.""" - await super().async_added_to_hass() - state = await self.async_get_last_sensor_data() - if state: - self._attr_native_value = state.native_value - - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - if self.key in asdict(self.coordinator.data): - if self.device_class == SensorDeviceClass.TIMESTAMP: - self._attr_native_value = dt_util.as_utc( - asdict(self.coordinator.data)[self.key] - ) - else: - self._attr_native_value = asdict(self.coordinator.data)[self.key] - - self.async_write_ha_state() + @property + def native_value(self) -> StateType | datetime: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/tests/components/landisgyr_heat_meter/test_sensor.py b/tests/components/landisgyr_heat_meter/test_sensor.py index 6296fadd11..a37fab65a1 100644 --- a/tests/components/landisgyr_heat_meter/test_sensor.py +++ b/tests/components/landisgyr_heat_meter/test_sensor.py @@ -5,20 +5,15 @@ from unittest.mock import patch import serial -from homeassistant.components.homeassistant import ( - DOMAIN as HA_DOMAIN, - SERVICE_UPDATE_ENTITY, -) +from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN from homeassistant.components.landisgyr_heat_meter.const import DOMAIN, POLLING_INTERVAL from homeassistant.components.sensor import ( - ATTR_LAST_RESET, ATTR_STATE_CLASS, SensorDeviceClass, SensorStateClass, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, STATE_UNAVAILABLE, @@ -26,16 +21,12 @@ from homeassistant.const import ( UnitOfEnergy, UnitOfVolume, ) -from homeassistant.core import CoreState, HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.common import ( - MockConfigEntry, - async_fire_time_changed, - mock_restore_cache_with_extra_data, -) +from tests.common import MockConfigEntry, async_fire_time_changed API_HEAT_METER_SERVICE = ( "homeassistant.components.landisgyr_heat_meter.ultraheat_api.HeatMeterService" @@ -80,13 +71,6 @@ async def test_create_sensors( await hass.config_entries.async_setup(mock_entry.entry_id) await async_setup_component(hass, HA_DOMAIN, {}) await hass.async_block_till_done() - await hass.services.async_call( - HA_DOMAIN, - SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: "sensor.heat_meter_heat_usage_gj"}, - blocking=True, - ) - await hass.async_block_till_done() # check if 26 attributes have been created assert len(hass.states.async_all()) == 25 @@ -121,97 +105,6 @@ async def test_create_sensors( assert entity_registry_entry.entity_category == EntityCategory.DIAGNOSTIC -@patch(API_HEAT_METER_SERVICE) -async def test_restore_state(mock_heat_meter, hass: HomeAssistant) -> None: - """Test sensor restore state.""" - # Home assistant is not running yet - hass.state = CoreState.not_running - last_reset = "2022-07-01T00:00:00.000000+00:00" - mock_restore_cache_with_extra_data( - hass, - [ - ( - State( - "sensor.heat_meter_heat_usage_gj", - "34167", - attributes={ - ATTR_LAST_RESET: last_reset, - ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.GIGA_JOULE, - ATTR_STATE_CLASS: SensorStateClass.TOTAL, - }, - ), - { - "native_value": 34167, - "native_unit_of_measurement": UnitOfEnergy.GIGA_JOULE, - "icon": "mdi:fire", - "last_reset": last_reset, - }, - ), - ( - State( - "sensor.heat_meter_volume_usage", - "456", - attributes={ - ATTR_LAST_RESET: last_reset, - ATTR_UNIT_OF_MEASUREMENT: UnitOfVolume.CUBIC_METERS, - ATTR_STATE_CLASS: SensorStateClass.TOTAL, - }, - ), - { - "native_value": 456, - "native_unit_of_measurement": UnitOfVolume.CUBIC_METERS, - "icon": "mdi:fire", - "last_reset": last_reset, - }, - ), - ( - State( - "sensor.heat_meter_device_number", - "devicenr_789", - attributes={ - ATTR_LAST_RESET: last_reset, - }, - ), - { - "native_value": "devicenr_789", - "native_unit_of_measurement": None, - "last_reset": last_reset, - }, - ), - ], - ) - entry_data = { - "device": "/dev/USB0", - "model": "LUGCUH50", - "device_number": "123456789", - } - - # create and add entry - mock_entry = MockConfigEntry(domain=DOMAIN, unique_id=DOMAIN, data=entry_data) - mock_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() - - # restore from cache - state = hass.states.get("sensor.heat_meter_heat_usage_gj") - assert state - assert state.state == "34167" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.GIGA_JOULE - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL - - state = hass.states.get("sensor.heat_meter_volume_usage") - assert state - assert state.state == "456" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL - - state = hass.states.get("sensor.heat_meter_device_number") - assert state - assert state.state == "devicenr_789" - assert state.attributes.get(ATTR_STATE_CLASS) is None - - @patch(API_HEAT_METER_SERVICE) async def test_exception_on_polling(mock_heat_meter, hass: HomeAssistant) -> None: """Test sensor.""" @@ -237,13 +130,6 @@ async def test_exception_on_polling(mock_heat_meter, hass: HomeAssistant) -> Non await hass.config_entries.async_setup(mock_entry.entry_id) await async_setup_component(hass, HA_DOMAIN, {}) await hass.async_block_till_done() - await hass.services.async_call( - HA_DOMAIN, - SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: "sensor.heat_meter_heat_usage_gj"}, - blocking=True, - ) - await hass.async_block_till_done() # check if initial setup succeeded state = hass.states.get("sensor.heat_meter_heat_usage_gj") From e937693d975f1afe1bb30bd9fd348e57bff25518 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 19 Mar 2023 01:57:40 +0100 Subject: [PATCH 1061/2810] Fix blocking MQTT entry unload (#89922) * Remove unneeded async_block_till_done * use await asyncio.sleep(0) instead --- homeassistant/components/mqtt/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index c73eec1244..5a9eb7c3fc 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -704,7 +704,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for component in PLATFORMS ) ) - await hass.async_block_till_done() + await asyncio.sleep(0) # Unsubscribe reload dispatchers while reload_dispatchers := mqtt_data.reload_dispatchers: reload_dispatchers.pop()() From 95240e8aad5536fa6bad1f9de1bd938b7e3fd178 Mon Sep 17 00:00:00 2001 From: Jesse Moody Date: Sun, 19 Mar 2023 02:52:42 -0400 Subject: [PATCH 1062/2810] Change README demo to demo.home-assistant.io subdomain (#89921) change home assistant demo link --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 6f5e0e6989..084949dc44 100644 --- a/README.rst +++ b/README.rst @@ -4,7 +4,7 @@ Home Assistant |Chat Status| Open source home automation that puts local control and privacy first. Powered by a worldwide community of tinkerers and DIY enthusiasts. Perfect to run on a Raspberry Pi or a local server. Check out `home-assistant.io `__ for `a -demo `__, `installation instructions `__, +demo `__, `installation instructions `__, `tutorials `__ and `documentation `__. |screenshot-states| @@ -23,6 +23,6 @@ of a component, check the `Home Assistant help section Date: Sat, 18 Mar 2023 20:59:05 -1000 Subject: [PATCH 1063/2810] Remove async_block_till_done in freebox (#89928) async_block_till_done() is not meant to be called in integrations --- homeassistant/components/freebox/config_flow.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/freebox/config_flow.py b/homeassistant/components/freebox/config_flow.py index fd9252aaa1..dbee01c4e7 100644 --- a/homeassistant/components/freebox/config_flow.py +++ b/homeassistant/components/freebox/config_flow.py @@ -77,7 +77,6 @@ class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # Check permissions await fbx.system.get_config() await fbx.lan.get_hosts_list() - await self.hass.async_block_till_done() # Close connection await fbx.close() From 36ad2c81f10e6d0efb4f7b64e82df87ea57172a3 Mon Sep 17 00:00:00 2001 From: Sven Serlier <85389871+wrt54g@users.noreply.github.com> Date: Sun, 19 Mar 2023 08:06:44 +0100 Subject: [PATCH 1064/2810] Adjust "Lovelace" to "Dashboards" (#89927) "Lovelace" to "Dashboards" --- .github/ISSUE_TEMPLATE/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 2440cb7ff2..8a4c7d4670 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,6 +1,6 @@ blank_issues_enabled: false contact_links: - - name: Report a bug with the UI, Frontend or Lovelace + - name: Report a bug with the UI, Frontend or Dashboards url: https://github.com/home-assistant/frontend/issues about: This is the issue tracker for our backend. Please report issues with the UI in the frontend repository. - name: Report incorrect or missing information on our website From 0e7bd401f28b15c649090ff5244c115318c526f7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 19 Mar 2023 08:56:24 +0100 Subject: [PATCH 1065/2810] Fix lingering timer in config entry flow tests (#89853) --- tests/helpers/test_config_entry_flow.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py index 90d8030be7..23f6571e8b 100644 --- a/tests/helpers/test_config_entry_flow.py +++ b/tests/helpers/test_config_entry_flow.py @@ -355,6 +355,7 @@ async def test_webhook_config_flow_registers_webhook( assert result["data"]["webhook_id"] is not None +@patch("homeassistant.components.cloud.STARTUP_REPAIR_DELAY", 0) async def test_webhook_create_cloudhook( hass: HomeAssistant, webhook_flow_conf: None ) -> None: @@ -410,6 +411,7 @@ async def test_webhook_create_cloudhook( assert result["require_restart"] is False +@patch("homeassistant.components.cloud.STARTUP_REPAIR_DELAY", 0) async def test_webhook_create_cloudhook_aborts_not_connected( hass: HomeAssistant, webhook_flow_conf: None ) -> None: From 87264d219a696093c7cecec0211db365940222fa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Mar 2023 23:13:48 -1000 Subject: [PATCH 1066/2810] Fix ssl context being recreated frequently in httpx (#89932) * Fix ssl context being created every time in httpx * its expensive, only do it once --- homeassistant/helpers/aiohttp_client.py | 2 +- homeassistant/helpers/httpx_client.py | 3 ++- homeassistant/util/ssl.py | 9 +++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index af8fa4d6f4..53c3cc1cf2 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -271,7 +271,7 @@ def _async_get_connector( return cast(aiohttp.BaseConnector, hass.data[key]) if verify_ssl: - ssl_context: bool | SSLContext = ssl_util.client_context() + ssl_context: bool | SSLContext = ssl_util.get_default_context() else: ssl_context = False diff --git a/homeassistant/helpers/httpx_client.py b/homeassistant/helpers/httpx_client.py index 2475469a7d..1e9d2e776c 100644 --- a/homeassistant/helpers/httpx_client.py +++ b/homeassistant/helpers/httpx_client.py @@ -11,6 +11,7 @@ from typing_extensions import Self from homeassistant.const import APPLICATION_NAME, EVENT_HOMEASSISTANT_CLOSE, __version__ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.loader import bind_hass +from homeassistant.util import ssl as ssl_util from .frame import warn_use @@ -65,7 +66,7 @@ def create_async_httpx_client( This method must be run in the event loop. """ client = HassHttpXAsyncClient( - verify=verify_ssl, + verify=ssl_util.get_default_context() if verify_ssl else False, headers={USER_AGENT: SERVER_SOFTWARE}, **kwargs, ) diff --git a/homeassistant/util/ssl.py b/homeassistant/util/ssl.py index 71c88ad844..9c945ef275 100644 --- a/homeassistant/util/ssl.py +++ b/homeassistant/util/ssl.py @@ -16,6 +16,15 @@ def client_context() -> ssl.SSLContext: return ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH, cafile=cafile) +# Create this only once and reuse it +_DEFAULT_SSL_CONTEXT = client_context() + + +def get_default_context() -> ssl.SSLContext: + """Return the default SSL context.""" + return _DEFAULT_SSL_CONTEXT + + def server_context_modern() -> ssl.SSLContext: """Return an SSL context following the Mozilla recommendations. From 0441a64c69acd3c3ff48a1cb2e1c9b95d68a7134 Mon Sep 17 00:00:00 2001 From: Oliver <10700296+ol-iver@users.noreply.github.com> Date: Sun, 19 Mar 2023 11:47:01 +0100 Subject: [PATCH 1067/2810] Update media state via telnet in `denonavr` integration (#89788) --- homeassistant/components/denonavr/manifest.json | 2 +- homeassistant/components/denonavr/media_player.py | 13 ++++++++++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json index 2d6a127ff3..660e4c770b 100644 --- a/homeassistant/components/denonavr/manifest.json +++ b/homeassistant/components/denonavr/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/denonavr", "iot_class": "local_push", "loggers": ["denonavr"], - "requirements": ["denonavr==0.11.1"], + "requirements": ["denonavr==0.11.2"], "ssdp": [ { "manufacturer": "Denon", diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index 5e636c5cfa..eab4c1df3a 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -249,11 +249,19 @@ class DenonDevice(MediaPlayerEntity): self._telnet_was_healthy: bool | None = None - async def _telnet_callback(self, zone, event, parameter): + async def _telnet_callback(self, zone, event, parameter) -> None: """Process a telnet command callback.""" + # There are multiple checks implemented which reduce unnecessary updates of the ha state machine if zone != self._receiver.zone: return - + # Some updates trigger multiple events like one for artist and one for title for one change + # We skip every event except the last one + if event == "NS" and not parameter.startswith("E4"): + return + if event == "TA" and not parameter.startwith("ANNAME"): + return + if event == "HD" and not parameter.startswith("ALBUM"): + return self.async_write_ha_state() async def async_added_to_hass(self) -> None: @@ -276,7 +284,6 @@ class DenonDevice(MediaPlayerEntity): if ( telnet_is_healthy := receiver.telnet_connected and receiver.telnet_healthy ) and self._telnet_was_healthy: - await receiver.input.async_update_media_state() return # if async_update raises an exception, we don't want to skip the next update diff --git a/requirements_all.txt b/requirements_all.txt index 6e834d0547..41b2b14523 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -586,7 +586,7 @@ deluge-client==1.7.1 demetriek==0.4.0 # homeassistant.components.denonavr -denonavr==0.11.1 +denonavr==0.11.2 # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 30bb11b745..ef0c26dc8f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -466,7 +466,7 @@ deluge-client==1.7.1 demetriek==0.4.0 # homeassistant.components.denonavr -denonavr==0.11.1 +denonavr==0.11.2 # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.2 From 557b9c7d5170860198cd9a622d3a98cd6a00a782 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Sun, 19 Mar 2023 02:13:52 -1100 Subject: [PATCH 1068/2810] Add KNX interface device with diagnostic entities (#89213) --- homeassistant/components/knx/__init__.py | 23 +-- homeassistant/components/knx/device.py | 51 ++++++ homeassistant/components/knx/sensor.py | 151 +++++++++++++++++- tests/components/knx/conftest.py | 5 +- tests/components/knx/test_binary_sensor.py | 22 +-- tests/components/knx/test_button.py | 5 +- tests/components/knx/test_climate.py | 20 +-- tests/components/knx/test_cover.py | 5 +- tests/components/knx/test_expose.py | 7 - tests/components/knx/test_fan.py | 3 - tests/components/knx/test_interface_device.py | 112 +++++++++++++ tests/components/knx/test_light.py | 1 - tests/components/knx/test_scene.py | 8 +- tests/components/knx/test_select.py | 2 - tests/components/knx/test_sensor.py | 14 +- tests/components/knx/test_switch.py | 2 - tests/components/knx/test_weather.py | 1 - 17 files changed, 347 insertions(+), 85 deletions(-) create mode 100644 homeassistant/components/knx/device.py create mode 100644 tests/components/knx/test_interface_device.py diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index f58df9dc11..60104545de 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -69,6 +69,7 @@ from .const import ( KNX_ADDRESS, SUPPORTED_PLATFORMS, ) +from .device import KNXInterfaceDevice from .expose import KNXExposeSensor, KNXExposeTime, create_knx_exposure from .schema import ( BinarySensorSchema, @@ -254,13 +255,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: knx_module.exposures.append( create_knx_exposure(hass, knx_module.xknx, expose_config) ) - + # always forward sensor for system entities (telegram counter, etc.) + await hass.config_entries.async_forward_entry_setup(entry, Platform.SENSOR) await hass.config_entries.async_forward_entry_setups( entry, [ platform for platform in SUPPORTED_PLATFORMS - if platform in config and platform is not Platform.NOTIFY + if platform in config and platform not in (Platform.SENSOR, Platform.NOTIFY) ], ) @@ -366,10 +368,17 @@ class KNXModule: self.service_exposures: dict[str, KNXExposeSensor | KNXExposeTime] = {} self.entry = entry - self.init_xknx() + self.xknx = XKNX( + connection_config=self.connection_config(), + rate_limit=self.entry.data[CONF_KNX_RATE_LIMIT], + state_updater=self.entry.data[CONF_KNX_STATE_UPDATER], + ) self.xknx.connection_manager.register_connection_state_changed_cb( self.connection_state_changed_cb ) + self.interface_device = KNXInterfaceDevice( + hass=hass, entry=entry, xknx=self.xknx + ) self._address_filter_transcoder: dict[AddressFilter, type[DPTBase]] = {} self._group_address_transcoder: dict[DeviceGroupAddress, type[DPTBase]] = {} @@ -382,14 +391,6 @@ class KNXModule: ) self.entry.async_on_unload(self.entry.add_update_listener(async_update_entry)) - def init_xknx(self) -> None: - """Initialize XKNX object.""" - self.xknx = XKNX( - connection_config=self.connection_config(), - rate_limit=self.entry.data[CONF_KNX_RATE_LIMIT], - state_updater=self.entry.data[CONF_KNX_STATE_UPDATER], - ) - async def start(self) -> None: """Start XKNX object. Connect to tunneling or Routing device.""" await self.xknx.start() diff --git a/homeassistant/components/knx/device.py b/homeassistant/components/knx/device.py new file mode 100644 index 0000000000..452de577ce --- /dev/null +++ b/homeassistant/components/knx/device.py @@ -0,0 +1,51 @@ +"""Handle KNX Devices.""" +from __future__ import annotations + +from xknx import XKNX +from xknx.core import XknxConnectionState +from xknx.io.gateway_scanner import GatewayDescriptor + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import DeviceInfo + +from .const import DOMAIN + + +class KNXInterfaceDevice: + """Class for KNX Interface Device handling.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry, xknx: XKNX) -> None: + """Initialize interface device class.""" + self.device_registry = dr.async_get(hass) + self.gateway_descriptor: GatewayDescriptor | None = None + self.xknx = xknx + + _device_id = (DOMAIN, f"_{entry.entry_id}_interface") + self.device = self.device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + default_name="KNX Interface", + identifiers={_device_id}, + ) + self.device_info = DeviceInfo(identifiers={_device_id}) + + self.xknx.connection_manager.register_connection_state_changed_cb( + self.connection_state_changed_cb + ) + + async def update(self) -> None: + """Update interface properties on new connection.""" + self.gateway_descriptor = await self.xknx.knxip_interface.gateway_info() + + self.device_registry.async_update_device( + device_id=self.device.id, + model=str(self.gateway_descriptor.name) + if self.gateway_descriptor + else None, + ) + + async def connection_state_changed_cb(self, state: XknxConnectionState) -> None: + """Call invoked after a KNX connection state change was received.""" + if state is XknxConnectionState.CONNECTED: + await self.update() diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index 64cd6151f7..ef15398534 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -1,9 +1,13 @@ """Support for KNX/IP sensors.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta from typing import Any from xknx import XKNX +from xknx.core.connection_state import XknxConnectionState, XknxConnectionType from xknx.devices import Sensor as XknxSensor from homeassistant import config_entries @@ -11,12 +15,15 @@ from homeassistant.components.sensor import ( CONF_STATE_CLASS, SensorDeviceClass, SensorEntity, + SensorEntityDescription, + SensorStateClass, ) from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_ENTITY_CATEGORY, CONF_NAME, CONF_TYPE, + EntityCategory, Platform, ) from homeassistant.core import HomeAssistant @@ -24,10 +31,95 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, StateType from homeassistant.util.enum import try_parse_enum +from . import KNXModule from .const import ATTR_SOURCE, DATA_KNX_CONFIG, DOMAIN from .knx_entity import KnxEntity from .schema import SensorSchema +SCAN_INTERVAL = timedelta(seconds=10) + + +@dataclass +class KNXSystemEntityDescription(SensorEntityDescription): + """Class describing KNX system sensor entities.""" + + always_available: bool = True + entity_category: EntityCategory = EntityCategory.DIAGNOSTIC + has_entity_name: bool = True + should_poll: bool = True + value_fn: Callable[[KNXModule], StateType | datetime] = lambda knx: None + + +SYSTEM_ENTITY_DESCRIPTIONS = ( + KNXSystemEntityDescription( + key="individual_address", + name="Individual Address", + always_available=False, + icon="mdi:router-network", + should_poll=False, + value_fn=lambda knx: str(knx.xknx.current_address), + ), + KNXSystemEntityDescription( + key="connected_since", + name="Connected since", + always_available=False, + device_class=SensorDeviceClass.TIMESTAMP, + should_poll=False, + value_fn=lambda knx: knx.xknx.connection_manager.connected_since, + ), + KNXSystemEntityDescription( + key="connection_type", + name="Connection type", + always_available=False, + device_class=SensorDeviceClass.ENUM, + options=[opt.value for opt in XknxConnectionType], + should_poll=False, + value_fn=lambda knx: knx.xknx.connection_manager.connection_type.value, # type: ignore[no-any-return] + ), + KNXSystemEntityDescription( + key="telegrams_incoming", + name="Telegrams incoming", + icon="mdi:upload-network", + entity_registry_enabled_default=False, + force_update=True, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda knx: knx.xknx.connection_manager.cemi_count_incoming, + ), + KNXSystemEntityDescription( + key="telegrams_incoming_error", + name="Telegrams incoming Error", + icon="mdi:help-network", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda knx: knx.xknx.connection_manager.cemi_count_incoming_error, + ), + KNXSystemEntityDescription( + key="telegrams_outgoing", + name="Telegrams outgoing", + icon="mdi:download-network", + entity_registry_enabled_default=False, + force_update=True, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda knx: knx.xknx.connection_manager.cemi_count_outgoing, + ), + KNXSystemEntityDescription( + key="telegrams_outgoing_error", + name="Telegrams outgoing Error", + icon="mdi:close-network", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda knx: knx.xknx.connection_manager.cemi_count_outgoing_error, + ), + KNXSystemEntityDescription( + key="telegram_count", + name="Telegrams", + icon="mdi:plus-network", + force_update=True, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda knx: knx.xknx.connection_manager.cemi_count_outgoing + + knx.xknx.connection_manager.cemi_count_incoming + + knx.xknx.connection_manager.cemi_count_incoming_error, + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -35,10 +127,18 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensor(s) for KNX platform.""" - xknx: XKNX = hass.data[DOMAIN].xknx - config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.SENSOR] + knx_module: KNXModule = hass.data[DOMAIN] - async_add_entities(KNXSensor(xknx, entity_config) for entity_config in config) + async_add_entities( + KNXSystemSensor(knx_module, description) + for description in SYSTEM_ENTITY_DESCRIPTIONS + ) + + config: list[ConfigType] = hass.data[DATA_KNX_CONFIG].get(Platform.SENSOR) + if config: + async_add_entities( + KNXSensor(knx_module.xknx, entity_config) for entity_config in config + ) def _create_sensor(xknx: XKNX, config: ConfigType) -> XknxSensor: @@ -87,3 +187,48 @@ class KNXSensor(KnxEntity, SensorEntity): if self._device.last_telegram is not None: attr[ATTR_SOURCE] = str(self._device.last_telegram.source_address) return attr + + +class KNXSystemSensor(SensorEntity): + """Representation of a KNX system sensor.""" + + def __init__( + self, + knx: KNXModule, + description: KNXSystemEntityDescription, + ) -> None: + """Initialize of a KNX system sensor.""" + self.entity_description: KNXSystemEntityDescription = description + self.knx = knx + + self._attr_device_info = knx.interface_device.device_info + self._attr_should_poll = description.should_poll + self._attr_unique_id = f"_{knx.entry.entry_id}_{description.key}" + + @property + def native_value(self) -> StateType | datetime: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.knx) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + if self.entity_description.always_available: + return True + return self.knx.xknx.connection_manager.state is XknxConnectionState.CONNECTED + + async def after_update_callback(self, _: XknxConnectionState) -> None: + """Call after device was updated.""" + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Store register state change callback.""" + self.knx.xknx.connection_manager.register_connection_state_changed_cb( + self.after_update_callback + ) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect device object when removed.""" + self.knx.xknx.connection_manager.unregister_connection_state_changed_cb( + self.after_update_callback + ) diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index a67847d26f..9cf325086a 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -6,7 +6,7 @@ from unittest.mock import DEFAULT, AsyncMock, Mock, patch import pytest from xknx import XKNX -from xknx.core import XknxConnectionState +from xknx.core import XknxConnectionState, XknxConnectionType from xknx.dpt import DPTArray, DPTBinary from xknx.io import DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT from xknx.telegram import Telegram, TelegramDirection @@ -67,7 +67,8 @@ class KNXTestKit: # set XknxConnectionState.CONNECTED to avoid `unavailable` entities at startup # and start StateUpdater. This would be awaited on normal startup too. await self.xknx.connection_manager.connection_state_changed( - XknxConnectionState.CONNECTED + state=XknxConnectionState.CONNECTED, + connection_type=XknxConnectionType.TUNNEL_TCP, ) def knx_ip_interface_mock(): diff --git a/tests/components/knx/test_binary_sensor.py b/tests/components/knx/test_binary_sensor.py index daad5b091d..61b7247037 100644 --- a/tests/components/knx/test_binary_sensor.py +++ b/tests/components/knx/test_binary_sensor.py @@ -38,7 +38,6 @@ async def test_binary_sensor_entity_category( ] } ) - assert len(hass.states.async_all()) == 1 await knx.assert_read("1/1/1") await knx.receive_response("1/1/1", True) @@ -65,7 +64,6 @@ async def test_binary_sensor(hass: HomeAssistant, knx: KNXTestKit) -> None: ] } ) - assert len(hass.states.async_all()) == 2 # StateUpdater initialize state await knx.assert_read("1/1/1") @@ -103,8 +101,6 @@ async def test_binary_sensor_ignore_internal_state( hass: HomeAssistant, knx: KNXTestKit ) -> None: """Test KNX binary_sensor with ignore_internal_state.""" - events = async_capture_events(hass, "state_changed") - await knx.setup_integration( { BinarySensorSchema.PLATFORM: [ @@ -122,39 +118,36 @@ async def test_binary_sensor_ignore_internal_state( ] } ) - assert len(hass.states.async_all()) == 2 - # binary_sensor defaults to STATE_OFF - state change form None - assert len(events) == 2 + events = async_capture_events(hass, "state_changed") # receive initial ON telegram await knx.receive_write("1/1/1", True) await knx.receive_write("2/2/2", True) await hass.async_block_till_done() - assert len(events) == 4 + assert len(events) == 2 # receive second ON telegram - ignore_internal_state shall force state_changed event await knx.receive_write("1/1/1", True) await knx.receive_write("2/2/2", True) await hass.async_block_till_done() - assert len(events) == 5 + assert len(events) == 3 # receive first OFF telegram await knx.receive_write("1/1/1", False) await knx.receive_write("2/2/2", False) await hass.async_block_till_done() - assert len(events) == 7 + assert len(events) == 5 # receive second OFF telegram - ignore_internal_state shall force state_changed event await knx.receive_write("1/1/1", False) await knx.receive_write("2/2/2", False) await hass.async_block_till_done() - assert len(events) == 8 + assert len(events) == 6 async def test_binary_sensor_counter(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test KNX binary_sensor with context timeout.""" async_fire_time_changed(hass, dt.utcnow()) - events = async_capture_events(hass, "state_changed") context_timeout = 1 await knx.setup_integration( @@ -169,9 +162,7 @@ async def test_binary_sensor_counter(hass: HomeAssistant, knx: KNXTestKit) -> No ] } ) - assert len(hass.states.async_all()) == 1 - assert len(events) == 1 - events.pop() + events = async_capture_events(hass, "state_changed") # receive initial ON telegram await knx.receive_write("2/2/2", True) @@ -236,7 +227,6 @@ async def test_binary_sensor_reset(hass: HomeAssistant, knx: KNXTestKit) -> None ] } ) - assert len(hass.states.async_all()) == 1 # receive ON telegram await knx.receive_write("2/2/2", True) diff --git a/tests/components/knx/test_button.py b/tests/components/knx/test_button.py index 6f0fecf9d8..4fa8d02716 100644 --- a/tests/components/knx/test_button.py +++ b/tests/components/knx/test_button.py @@ -18,7 +18,6 @@ from tests.common import async_capture_events, async_fire_time_changed async def test_button_simple(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test KNX button with default payload.""" - events = async_capture_events(hass, "state_changed") await knx.setup_integration( { ButtonSchema.PLATFORM: { @@ -27,9 +26,7 @@ async def test_button_simple(hass: HomeAssistant, knx: KNXTestKit) -> None: } } ) - assert len(hass.states.async_all()) == 1 - assert len(events) == 1 - events.pop() + events = async_capture_events(hass, "state_changed") # press button await hass.services.async_call( diff --git a/tests/components/knx/test_climate.py b/tests/components/knx/test_climate.py index 582f082eb9..240fde9ee8 100644 --- a/tests/components/knx/test_climate.py +++ b/tests/components/knx/test_climate.py @@ -20,7 +20,6 @@ async def test_climate_basic_temperature_set( hass: HomeAssistant, knx: KNXTestKit ) -> None: """Test KNX climate basic.""" - events = async_capture_events(hass, "state_changed") await knx.setup_integration( { ClimateSchema.PLATFORM: { @@ -31,9 +30,7 @@ async def test_climate_basic_temperature_set( } } ) - assert len(hass.states.async_all()) == 1 - assert len(events) == 1 - events.pop() + events = async_capture_events(hass, "state_changed") # read temperature await knx.assert_read("1/2/3") @@ -57,7 +54,6 @@ async def test_climate_basic_temperature_set( async def test_climate_hvac_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test KNX climate hvac mode.""" - events = async_capture_events(hass, "state_changed") await knx.setup_integration( { ClimateSchema.PLATFORM: { @@ -72,9 +68,7 @@ async def test_climate_hvac_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: } } ) - assert len(hass.states.async_all()) == 1 - assert len(events) == 1 - events.pop() + async_capture_events(hass, "state_changed") await hass.async_block_till_done() # read states state updater @@ -112,7 +106,6 @@ async def test_climate_preset_mode( hass: HomeAssistant, knx: KNXTestKit, entity_registry: er.EntityRegistry ) -> None: """Test KNX climate preset mode.""" - events = async_capture_events(hass, "state_changed") await knx.setup_integration( { ClimateSchema.PLATFORM: { @@ -125,9 +118,7 @@ async def test_climate_preset_mode( } } ) - assert len(hass.states.async_all()) == 1 - assert len(events) == 1 - events.pop() + events = async_capture_events(hass, "state_changed") await hass.async_block_till_done() # read states state updater @@ -177,7 +168,6 @@ async def test_climate_preset_mode( async def test_update_entity(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test update climate entity for KNX.""" - events = async_capture_events(hass, "state_changed") await knx.setup_integration( { ClimateSchema.PLATFORM: { @@ -192,9 +182,7 @@ async def test_update_entity(hass: HomeAssistant, knx: KNXTestKit) -> None: ) assert await async_setup_component(hass, "homeassistant", {}) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 1 - assert len(events) == 1 - events.pop() + async_capture_events(hass, "state_changed") await hass.async_block_till_done() # read states state updater diff --git a/tests/components/knx/test_cover.py b/tests/components/knx/test_cover.py index 5aef38ea00..4ee9bd04ee 100644 --- a/tests/components/knx/test_cover.py +++ b/tests/components/knx/test_cover.py @@ -11,7 +11,6 @@ from tests.common import async_capture_events async def test_cover_basic(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test KNX cover basic.""" - events = async_capture_events(hass, "state_changed") await knx.setup_integration( { CoverSchema.PLATFORM: { @@ -25,9 +24,7 @@ async def test_cover_basic(hass: HomeAssistant, knx: KNXTestKit) -> None: } } ) - assert len(hass.states.async_all()) == 1 - assert len(events) == 1 - events.pop() + events = async_capture_events(hass, "state_changed") # read position state address and angle state address await knx.assert_read("1/0/2") diff --git a/tests/components/knx/test_expose.py b/tests/components/knx/test_expose.py index 9bb6f22470..bec76f29ee 100644 --- a/tests/components/knx/test_expose.py +++ b/tests/components/knx/test_expose.py @@ -28,7 +28,6 @@ async def test_binary_expose(hass: HomeAssistant, knx: KNXTestKit) -> None: } }, ) - assert not hass.states.async_all() # Change state to on hass.states.async_set(entity_id, "on", {}) @@ -57,7 +56,6 @@ async def test_expose_attribute(hass: HomeAssistant, knx: KNXTestKit) -> None: } }, ) - assert not hass.states.async_all() # Before init no response shall be sent await knx.receive_read("1/1/8") @@ -105,7 +103,6 @@ async def test_expose_attribute_with_default( } }, ) - assert not hass.states.async_all() # Before init default value shall be sent as response await knx.receive_read("1/1/8") @@ -152,7 +149,6 @@ async def test_expose_string(hass: HomeAssistant, knx: KNXTestKit) -> None: } }, ) - assert not hass.states.async_all() # Before init default value shall be sent as response await knx.receive_read("1/1/8") @@ -185,7 +181,6 @@ async def test_expose_cooldown(hass: HomeAssistant, knx: KNXTestKit) -> None: } }, ) - assert not hass.states.async_all() # Change state to 1 hass.states.async_set(entity_id, "1", {}) await knx.assert_write("1/1/8", (1,)) @@ -220,7 +215,6 @@ async def test_expose_conversion_exception( } }, ) - assert not hass.states.async_all() # Before init default value shall be sent as response await knx.receive_read("1/1/8") @@ -253,7 +247,6 @@ async def test_expose_with_date( } } ) - assert not hass.states.async_all() await knx.assert_write("1/1/8", (0x7A, 0x1, 0x7, 0xE9, 0xD, 0xE, 0x20, 0x80)) diff --git a/tests/components/knx/test_fan.py b/tests/components/knx/test_fan.py index 7a0859acc5..3e89aea720 100644 --- a/tests/components/knx/test_fan.py +++ b/tests/components/knx/test_fan.py @@ -17,7 +17,6 @@ async def test_fan_percent(hass: HomeAssistant, knx: KNXTestKit) -> None: } } ) - assert len(hass.states.async_all()) == 1 # turn on fan with default speed (50%) await hass.services.async_call( @@ -63,7 +62,6 @@ async def test_fan_step(hass: HomeAssistant, knx: KNXTestKit) -> None: } } ) - assert len(hass.states.async_all()) == 1 # turn on fan with default speed (50% - step 2) await hass.services.async_call( @@ -116,7 +114,6 @@ async def test_fan_oscillation(hass: HomeAssistant, knx: KNXTestKit) -> None: } } ) - assert len(hass.states.async_all()) == 1 # turn on oscillation await hass.services.async_call( diff --git a/tests/components/knx/test_interface_device.py b/tests/components/knx/test_interface_device.py new file mode 100644 index 0000000000..e45729559c --- /dev/null +++ b/tests/components/knx/test_interface_device.py @@ -0,0 +1,112 @@ +"""Test KNX scene.""" +from unittest.mock import patch + +from xknx.core import XknxConnectionState, XknxConnectionType +from xknx.telegram import IndividualAddress + +from homeassistant.components.knx.sensor import SCAN_INTERVAL +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt + +from .conftest import KNXTestKit + +from tests.common import async_capture_events, async_fire_time_changed + + +async def test_diagnostic_entities( + hass: HomeAssistant, knx: KNXTestKit, entity_registry: er.EntityRegistry +) -> None: + """Test diagnostic entities.""" + await knx.setup_integration({}) + + for entity_id in [ + "sensor.knx_interface_individual_address", + "sensor.knx_interface_connected_since", + "sensor.knx_interface_connection_type", + "sensor.knx_interface_telegrams_incoming", + "sensor.knx_interface_telegrams_incoming_error", + "sensor.knx_interface_telegrams_outgoing", + "sensor.knx_interface_telegrams_outgoing_error", + "sensor.knx_interface_telegrams", + ]: + entity = entity_registry.async_get(entity_id) + assert entity.entity_category is EntityCategory.DIAGNOSTIC + + for entity_id in [ + "sensor.knx_interface_telegrams_incoming", + "sensor.knx_interface_telegrams_outgoing", + ]: + entity = entity_registry.async_get(entity_id) + assert entity.disabled is True + + knx.xknx.connection_manager.cemi_count_incoming = 20 + knx.xknx.connection_manager.cemi_count_incoming_error = 1 + knx.xknx.connection_manager.cemi_count_outgoing = 10 + knx.xknx.connection_manager.cemi_count_outgoing_error = 2 + + events = async_capture_events(hass, "state_changed") + async_fire_time_changed(hass, dt.utcnow() + SCAN_INTERVAL) + await hass.async_block_till_done() + + assert len(events) == 3 # 5 polled sensors - 2 disabled + events.clear() + + for entity_id, test_state in [ + ("sensor.knx_interface_individual_address", "0.0.0"), + ("sensor.knx_interface_connection_type", "Tunnel TCP"), + # skipping connected_since timestamp + ("sensor.knx_interface_telegrams_incoming_error", "1"), + ("sensor.knx_interface_telegrams_outgoing_error", "2"), + ("sensor.knx_interface_telegrams", "31"), + ]: + assert hass.states.get(entity_id).state == test_state + + await knx.xknx.connection_manager.connection_state_changed( + state=XknxConnectionState.DISCONNECTED + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() + assert len(events) == 4 # 3 not always_available + 3 force_update - 2 disabled + events.clear() + + knx.xknx.current_address = IndividualAddress("1.1.1") + await knx.xknx.connection_manager.connection_state_changed( + state=XknxConnectionState.CONNECTED, + connection_type=XknxConnectionType.TUNNEL_UDP, + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() + assert len(events) == 6 # all diagnostic sensors - counters are reset on connect + + for entity_id, test_state in [ + ("sensor.knx_interface_individual_address", "1.1.1"), + ("sensor.knx_interface_connection_type", "Tunnel UDP"), + # skipping connected_since timestamp + ("sensor.knx_interface_telegrams_incoming_error", "0"), + ("sensor.knx_interface_telegrams_outgoing_error", "0"), + ("sensor.knx_interface_telegrams", "0"), + ]: + assert hass.states.get(entity_id).state == test_state + + +async def test_removed_entity( + hass: HomeAssistant, knx: KNXTestKit, entity_registry: er.EntityRegistry +) -> None: + """Test unregister callback when entity is removed.""" + await knx.setup_integration({}) + + with patch.object( + knx.xknx.connection_manager, "unregister_connection_state_changed_cb" + ) as unregister_mock: + entity_registry.async_update_entity( + "sensor.knx_interface_connected_since", + disabled_by=er.RegistryEntryDisabler.USER, + ) + await hass.async_block_till_done() + unregister_mock.assert_called_once() diff --git a/tests/components/knx/test_light.py b/tests/components/knx/test_light.py index 491f5a3c1a..a445d1a6fd 100644 --- a/tests/components/knx/test_light.py +++ b/tests/components/knx/test_light.py @@ -36,7 +36,6 @@ async def test_light_simple(hass: HomeAssistant, knx: KNXTestKit) -> None: } } ) - assert len(hass.states.async_all()) == 1 knx.assert_state("light.test", STATE_OFF) # turn on light diff --git a/tests/components/knx/test_scene.py b/tests/components/knx/test_scene.py index f0381cc9cf..8598ef0a62 100644 --- a/tests/components/knx/test_scene.py +++ b/tests/components/knx/test_scene.py @@ -9,7 +9,9 @@ from homeassistant.helpers import entity_registry as er from .conftest import KNXTestKit -async def test_activate_knx_scene(hass: HomeAssistant, knx: KNXTestKit) -> None: +async def test_activate_knx_scene( + hass: HomeAssistant, knx: KNXTestKit, entity_registry: er.EntityRegistry +) -> None: """Test KNX scene.""" await knx.setup_integration( { @@ -23,10 +25,8 @@ async def test_activate_knx_scene(hass: HomeAssistant, knx: KNXTestKit) -> None: ] } ) - assert len(hass.states.async_all()) == 1 - registry = er.async_get(hass) - entity = registry.async_get("scene.test") + entity = entity_registry.async_get("scene.test") assert entity.entity_category is EntityCategory.DIAGNOSTIC assert entity.unique_id == "1/1/1_24" diff --git a/tests/components/knx/test_select.py b/tests/components/knx/test_select.py index d03fd41b0a..1c89338920 100644 --- a/tests/components/knx/test_select.py +++ b/tests/components/knx/test_select.py @@ -37,7 +37,6 @@ async def test_select_dpt_2_simple(hass: HomeAssistant, knx: KNXTestKit) -> None } } ) - assert len(hass.states.async_all()) == 1 state = hass.states.get("select.test") assert state.state is STATE_UNKNOWN @@ -152,7 +151,6 @@ async def test_select_dpt_20_103_all_options( } } ) - assert len(hass.states.async_all()) == 1 state = hass.states.get("select.test") assert state.state is STATE_UNKNOWN diff --git a/tests/components/knx/test_sensor.py b/tests/components/knx/test_sensor.py index ddccf299e3..10178324c9 100644 --- a/tests/components/knx/test_sensor.py +++ b/tests/components/knx/test_sensor.py @@ -21,7 +21,6 @@ async def test_sensor(hass: HomeAssistant, knx: KNXTestKit) -> None: } } ) - assert len(hass.states.async_all()) == 1 state = hass.states.get("sensor.test") assert state.state is STATE_UNKNOWN @@ -44,7 +43,6 @@ async def test_sensor(hass: HomeAssistant, knx: KNXTestKit) -> None: async def test_always_callback(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test KNX sensor with always_callback.""" - events = async_capture_events(hass, "state_changed") await knx.setup_integration( { SensorSchema.PLATFORM: [ @@ -64,32 +62,30 @@ async def test_always_callback(hass: HomeAssistant, knx: KNXTestKit) -> None: ] } ) - assert len(hass.states.async_all()) == 2 - # state changes form None to "unknown" - assert len(events) == 2 + events = async_capture_events(hass, "state_changed") # receive initial telegram await knx.receive_write("1/1/1", (0x42,)) await knx.receive_write("2/2/2", (0x42,)) await hass.async_block_till_done() - assert len(events) == 4 + assert len(events) == 2 # receive second telegram with identical payload # always_callback shall force state_changed event await knx.receive_write("1/1/1", (0x42,)) await knx.receive_write("2/2/2", (0x42,)) await hass.async_block_till_done() - assert len(events) == 5 + assert len(events) == 3 # receive telegram with different payload await knx.receive_write("1/1/1", (0xFA,)) await knx.receive_write("2/2/2", (0xFA,)) await hass.async_block_till_done() - assert len(events) == 7 + assert len(events) == 5 # receive telegram with second payload again # always_callback shall force state_changed event await knx.receive_write("1/1/1", (0xFA,)) await knx.receive_write("2/2/2", (0xFA,)) await hass.async_block_till_done() - assert len(events) == 8 + assert len(events) == 6 diff --git a/tests/components/knx/test_switch.py b/tests/components/knx/test_switch.py index 7293eee96c..d68970537a 100644 --- a/tests/components/knx/test_switch.py +++ b/tests/components/knx/test_switch.py @@ -23,7 +23,6 @@ async def test_switch_simple(hass: HomeAssistant, knx: KNXTestKit) -> None: } } ) - assert len(hass.states.async_all()) == 1 # turn on switch await hass.services.async_call( @@ -66,7 +65,6 @@ async def test_switch_state(hass: HomeAssistant, knx: KNXTestKit) -> None: }, } ) - assert len(hass.states.async_all()) == 1 # StateUpdater initialize state await knx.assert_read(_STATE_ADDRESS) diff --git a/tests/components/knx/test_weather.py b/tests/components/knx/test_weather.py index d9128a8c07..8aaf4fa433 100644 --- a/tests/components/knx/test_weather.py +++ b/tests/components/knx/test_weather.py @@ -35,7 +35,6 @@ async def test_weather(hass: HomeAssistant, knx: KNXTestKit) -> None: } } ) - assert len(hass.states.async_all()) == 1 state = hass.states.get("weather.test") assert state.state is ATTR_CONDITION_EXCEPTIONAL From d7de23fa6506b9e0f21c914966e2f6d3d639f25b Mon Sep 17 00:00:00 2001 From: Jesse Moody Date: Sun, 19 Mar 2023 16:53:21 -0400 Subject: [PATCH 1069/2810] Adjust eventloop -> event loop spelling (#89931) eventloop -> event loop spelling --- homeassistant/config_entries.py | 2 +- homeassistant/core.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 41cccdf996..3ab16f6967 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -752,7 +752,7 @@ class ConfigEntry: target: Coroutine[Any, Any, _R], name: str | None = None, ) -> asyncio.Task[_R]: - """Create a task from within the eventloop. + """Create a task from within the event loop. This method must be run in the event loop. diff --git a/homeassistant/core.py b/homeassistant/core.py index bfccb721d8..900355d4a5 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -516,7 +516,7 @@ class HomeAssistant: def async_create_task( self, target: Coroutine[Any, Any, _R], name: str | None = None ) -> asyncio.Task[_R]: - """Create a task from within the eventloop. + """Create a task from within the event loop. This method must be run in the event loop. If you are using this in your integration, use the create task methods on the config entry instead. @@ -534,7 +534,7 @@ class HomeAssistant: target: Coroutine[Any, Any, _R], name: str, ) -> asyncio.Task[_R]: - """Create a task from within the eventloop. + """Create a task from within the event loop. This is a background task which will not block startup and will be automatically cancelled on shutdown. If you are using this in your From 5ffb2330043ecfe36a4c8272d239b0883a5d6339 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Mar 2023 16:01:16 -1000 Subject: [PATCH 1070/2810] Avoid database executor job to fetch statistic metadata on cache hit (#89960) * Avoid database executor job to fetch statistic metadata on cache hit Since we will almost always have a cache hit fetching statistic meta data we can avoid an executor job * Avoid database executor job to fetch statistic metadata on cache hit Since we will almost always have a cache hit fetching statistic meta data we can avoid an executor job * Avoid database executor job to fetch statistic metadata on cache hit Since we will almost always have a cache hit fetching statistic meta data we can avoid an executor job * remove exception catch since the threading.excepthook will actually catch this in production * fix a few missed ones * threadsafe * Update homeassistant/components/recorder/table_managers/statistics_meta.py * coverage and optimistic caching --- homeassistant/components/energy/validate.py | 2 +- .../components/energy/websocket_api.py | 4 +- homeassistant/components/recorder/core.py | 1 + .../components/recorder/statistics.py | 167 ++++++++++++------ .../table_managers/statistics_meta.py | 64 ++++--- .../components/recorder/websocket_api.py | 16 +- homeassistant/components/sensor/recorder.py | 6 +- homeassistant/components/tibber/sensor.py | 2 +- tests/components/recorder/common.py | 4 +- tests/components/recorder/db_schema_28.py | 9 + .../table_managers/test_statistics_meta.py | 4 +- tests/components/recorder/test_statistics.py | 57 ++++-- .../components/recorder/test_websocket_api.py | 8 +- tests/components/sensor/test_recorder.py | 4 +- tests/components/tibber/test_statistics.py | 2 +- 15 files changed, 232 insertions(+), 118 deletions(-) diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index a2c3ad094d..0a89c3d927 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -603,7 +603,7 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: functools.partial( recorder.statistics.get_metadata, hass, - statistic_ids=list(wanted_statistics_metadata), + statistic_ids=set(wanted_statistics_metadata), ) ) ) diff --git a/homeassistant/components/energy/websocket_api.py b/homeassistant/components/energy/websocket_api.py index 15ffc6a280..7830d3649f 100644 --- a/homeassistant/components/energy/websocket_api.py +++ b/homeassistant/components/energy/websocket_api.py @@ -262,8 +262,8 @@ async def ws_get_fossil_energy_consumption( connection.send_error(msg["id"], "invalid_end_time", "Invalid end_time") return - statistic_ids = list(msg["energy_statistic_ids"]) - statistic_ids.append(msg["co2_statistic_id"]) + statistic_ids = set(msg["energy_statistic_ids"]) + statistic_ids.add(msg["co2_statistic_id"]) # Fetch energy + CO2 statistics statistics = await recorder.get_instance(hass).async_add_executor_job( diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 538d07eb4d..30dd311c0e 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -501,6 +501,7 @@ class Recorder(threading.Thread): new_size = self.hass.states.async_entity_ids_count() * 2 self.state_attributes_manager.adjust_lru_size(new_size) self.states_meta_manager.adjust_lru_size(new_size) + self.statistics_meta_manager.adjust_lru_size(new_size) @callback def async_periodic_statistics(self) -> None: diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index fcd934270d..2f2deeeaee 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -713,10 +713,10 @@ def compile_missing_statistics(instance: Recorder) -> bool: periods_without_commit += 1 end = start + timedelta(minutes=period_size) _LOGGER.debug("Compiling missing statistics for %s-%s", start, end) - metadata_modified = _compile_statistics( + modified_statistic_ids = _compile_statistics( instance, session, start, end >= last_period ) - if periods_without_commit == commit_interval or metadata_modified: + if periods_without_commit == commit_interval or modified_statistic_ids: session.commit() session.expunge_all() periods_without_commit = 0 @@ -736,29 +736,40 @@ def compile_statistics(instance: Recorder, start: datetime, fire_events: bool) - session=instance.get_session(), exception_filter=_filter_unique_constraint_integrity_error(instance), ) as session: - _compile_statistics(instance, session, start, fire_events) + modified_statistic_ids = _compile_statistics( + instance, session, start, fire_events + ) + + if modified_statistic_ids: + # In the rare case that we have modified statistic_ids, we reload the modified + # statistics meta data into the cache in a fresh session to ensure that the + # cache is up to date and future calls to get statistics meta data will + # not have to hit the database again. + with session_scope(session=instance.get_session(), read_only=True) as session: + instance.statistics_meta_manager.get_many(session, modified_statistic_ids) + return True def _compile_statistics( instance: Recorder, session: Session, start: datetime, fire_events: bool -) -> bool: +) -> set[str]: """Compile 5-minute statistics for all integrations with a recorder platform. This is a helper function for compile_statistics and compile_missing_statistics that does not retry on database errors since both callers already retry. - returns True if metadata was modified, False otherwise + returns a set of modified statistic_ids if any were modified. """ assert start.tzinfo == dt_util.UTC, "start must be in UTC" end = start + timedelta(minutes=5) statistics_meta_manager = instance.statistics_meta_manager - metadata_modified = False + modified_statistic_ids: set[str] = set() # Return if we already have 5-minute statistics for the requested period if session.query(StatisticsRuns).filter_by(start=start).first(): _LOGGER.debug("Statistics already compiled for %s-%s", start, end) - return metadata_modified + return modified_statistic_ids _LOGGER.debug("Compiling statistics for %s-%s", start, end) platform_stats: list[StatisticResult] = [] @@ -782,10 +793,11 @@ def _compile_statistics( # Insert collected statistics in the database for stats in platform_stats: - updated, metadata_id = statistics_meta_manager.update_or_add( + modified_statistic_id, metadata_id = statistics_meta_manager.update_or_add( session, stats["meta"], current_metadata ) - metadata_modified |= updated + if modified_statistic_id is not None: + modified_statistic_ids.add(modified_statistic_id) _insert_statistics( session, StatisticsShortTerm, @@ -804,7 +816,7 @@ def _compile_statistics( if start.minute == 55: instance.hass.bus.fire(EVENT_RECORDER_HOURLY_STATISTICS_GENERATED) - return metadata_modified + return modified_statistic_ids def _adjust_sum_statistics( @@ -882,7 +894,7 @@ def get_metadata_with_session( instance: Recorder, session: Session, *, - statistic_ids: list[str] | None = None, + statistic_ids: set[str] | None = None, statistic_type: Literal["mean"] | Literal["sum"] | None = None, statistic_source: str | None = None, ) -> dict[str, tuple[int, StatisticMetaData]]: @@ -903,7 +915,7 @@ def get_metadata_with_session( def get_metadata( hass: HomeAssistant, *, - statistic_ids: list[str] | None = None, + statistic_ids: set[str] | None = None, statistic_type: Literal["mean"] | Literal["sum"] | None = None, statistic_source: str | None = None, ) -> dict[str, tuple[int, StatisticMetaData]]: @@ -947,9 +959,79 @@ def update_statistics_metadata( ) +async def async_list_statistic_ids( + hass: HomeAssistant, + statistic_ids: set[str] | None = None, + statistic_type: Literal["mean"] | Literal["sum"] | None = None, +) -> list[dict]: + """Return all statistic_ids (or filtered one) and unit of measurement. + + Queries the database for existing statistic_ids, as well as integrations with + a recorder platform for statistic_ids which will be added in the next statistics + period. + """ + instance = get_instance(hass) + + if statistic_ids is not None: + # Try to get the results from the cache since there is nearly + # always a cache hit. + statistics_meta_manager = instance.statistics_meta_manager + metadata = statistics_meta_manager.get_from_cache_threadsafe(statistic_ids) + if not statistic_ids.difference(metadata): + result = _statistic_by_id_from_metadata(hass, metadata) + return _flatten_list_statistic_ids_metadata_result(result) + + return await instance.async_add_executor_job( + list_statistic_ids, + hass, + statistic_ids, + statistic_type, + ) + + +def _statistic_by_id_from_metadata( + hass: HomeAssistant, + metadata: dict[str, tuple[int, StatisticMetaData]], +) -> dict[str, dict[str, Any]]: + """Return a list of results for a given metadata dict.""" + return { + meta["statistic_id"]: { + "display_unit_of_measurement": get_display_unit( + hass, meta["statistic_id"], meta["unit_of_measurement"] + ), + "has_mean": meta["has_mean"], + "has_sum": meta["has_sum"], + "name": meta["name"], + "source": meta["source"], + "unit_class": _get_unit_class(meta["unit_of_measurement"]), + "unit_of_measurement": meta["unit_of_measurement"], + } + for _, meta in metadata.values() + } + + +def _flatten_list_statistic_ids_metadata_result( + result: dict[str, dict[str, Any]] +) -> list[dict]: + """Return a flat dict of metadata.""" + return [ + { + "statistic_id": _id, + "display_unit_of_measurement": info["display_unit_of_measurement"], + "has_mean": info["has_mean"], + "has_sum": info["has_sum"], + "name": info.get("name"), + "source": info["source"], + "statistics_unit_of_measurement": info["unit_of_measurement"], + "unit_class": info["unit_class"], + } + for _id, info in result.items() + ] + + def list_statistic_ids( hass: HomeAssistant, - statistic_ids: list[str] | None = None, + statistic_ids: set[str] | None = None, statistic_type: Literal["mean"] | Literal["sum"] | None = None, ) -> list[dict]: """Return all statistic_ids (or filtered one) and unit of measurement. @@ -959,30 +1041,17 @@ def list_statistic_ids( period. """ result = {} - statistic_ids_set = set(statistic_ids) if statistic_ids else None + instance = get_instance(hass) + statistics_meta_manager = instance.statistics_meta_manager # Query the database with session_scope(hass=hass, read_only=True) as session: - metadata = get_instance(hass).statistics_meta_manager.get_many( + metadata = statistics_meta_manager.get_many( session, statistic_type=statistic_type, statistic_ids=statistic_ids ) + result = _statistic_by_id_from_metadata(hass, metadata) - result = { - meta["statistic_id"]: { - "display_unit_of_measurement": get_display_unit( - hass, meta["statistic_id"], meta["unit_of_measurement"] - ), - "has_mean": meta["has_mean"], - "has_sum": meta["has_sum"], - "name": meta["name"], - "source": meta["source"], - "unit_class": _get_unit_class(meta["unit_of_measurement"]), - "unit_of_measurement": meta["unit_of_measurement"], - } - for _, meta in metadata.values() - } - - if not statistic_ids_set or statistic_ids_set.difference(result): + if not statistic_ids or statistic_ids.difference(result): # If we want all statistic_ids, or some are missing, we need to query # the integrations for the missing ones. # @@ -1009,19 +1078,7 @@ def list_statistic_ids( } # Return a list of statistic_id + metadata - return [ - { - "statistic_id": _id, - "display_unit_of_measurement": info["display_unit_of_measurement"], - "has_mean": info["has_mean"], - "has_sum": info["has_sum"], - "name": info.get("name"), - "source": info["source"], - "statistics_unit_of_measurement": info["unit_of_measurement"], - "unit_class": info["unit_class"], - } - for _id, info in result.items() - ] + return _flatten_list_statistic_ids_metadata_result(result) def _reduce_statistics( @@ -1698,7 +1755,7 @@ def _statistics_during_period_with_session( session: Session, start_time: datetime, end_time: datetime | None, - statistic_ids: list[str] | None, + statistic_ids: set[str] | None, period: Literal["5minute", "day", "hour", "week", "month"], units: dict[str, str] | None, types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], @@ -1708,6 +1765,10 @@ def _statistics_during_period_with_session( If end_time is omitted, returns statistics newer than or equal to start_time. If statistic_ids is omitted, returns statistics for all statistics ids. """ + if statistic_ids is not None and not isinstance(statistic_ids, set): + # This is for backwards compatibility to avoid a breaking change + # for custom integrations that call this method. + statistic_ids = set(statistic_ids) # type: ignore[unreachable] metadata = None # Fetch metadata for the given (or all) statistic_ids metadata = get_instance(hass).statistics_meta_manager.get_many( @@ -1784,7 +1845,7 @@ def statistics_during_period( hass: HomeAssistant, start_time: datetime, end_time: datetime | None, - statistic_ids: list[str] | None, + statistic_ids: set[str] | None, period: Literal["5minute", "day", "hour", "week", "month"], units: dict[str, str] | None, types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], @@ -1845,7 +1906,7 @@ def _get_last_statistics( types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], ) -> dict[str, list[StatisticsRow]]: """Return the last number_of_stats statistics for a given statistic_id.""" - statistic_ids = [statistic_id] + statistic_ids = {statistic_id} with session_scope(hass=hass, read_only=True) as session: # Fetch metadata for the given statistic_id metadata = get_instance(hass).statistics_meta_manager.get_many( @@ -1930,7 +1991,7 @@ def _latest_short_term_statistics_stmt( def get_latest_short_term_statistics( hass: HomeAssistant, - statistic_ids: list[str], + statistic_ids: set[str], types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], metadata: dict[str, tuple[int, StatisticMetaData]] | None = None, ) -> dict[str, list[StatisticsRow]]: @@ -2031,7 +2092,7 @@ def _sorted_statistics_to_dict( hass: HomeAssistant, session: Session, stats: Sequence[Row[Any]], - statistic_ids: list[str] | None, + statistic_ids: set[str] | None, _metadata: dict[str, tuple[int, StatisticMetaData]], convert_units: bool, table: type[StatisticsBase], @@ -2294,7 +2355,7 @@ def _import_statistics_with_session( """Import statistics to the database.""" statistics_meta_manager = instance.statistics_meta_manager old_metadata_dict = statistics_meta_manager.get_many( - session, statistic_ids=[metadata["statistic_id"]] + session, statistic_ids={metadata["statistic_id"]} ) _, metadata_id = statistics_meta_manager.update_or_add( session, metadata, old_metadata_dict @@ -2338,7 +2399,7 @@ def adjust_statistics( with session_scope(session=instance.get_session()) as session: metadata = instance.statistics_meta_manager.get_many( - session, statistic_ids=[statistic_id] + session, statistic_ids={statistic_id} ) if statistic_id not in metadata: return True @@ -2476,7 +2537,7 @@ def _validate_db_schema_utf8( try: with session_scope(session=session_maker()) as session: old_metadata_dict = statistics_meta_manager.get_many( - session, statistic_ids=[statistic_id] + session, statistic_ids={statistic_id} ) try: statistics_meta_manager.update_or_add( @@ -2573,7 +2634,7 @@ def _validate_db_schema( session, start_time, None, - [statistic_id], + {statistic_id}, "hour" if table == Statistics else "5minute", None, {"last_reset", "max", "mean", "min", "state", "sum"}, diff --git a/homeassistant/components/recorder/table_managers/statistics_meta.py b/homeassistant/components/recorder/table_managers/statistics_meta.py index 93417b4325..ba47b3600d 100644 --- a/homeassistant/components/recorder/table_managers/statistics_meta.py +++ b/homeassistant/components/recorder/table_managers/statistics_meta.py @@ -34,7 +34,7 @@ QUERY_STATISTIC_META = ( def _generate_get_metadata_stmt( - statistic_ids: list[str] | None = None, + statistic_ids: set[str] | None = None, statistic_type: Literal["mean"] | Literal["sum"] | None = None, statistic_source: str | None = None, ) -> StatementLambdaElement: @@ -89,7 +89,7 @@ class StatisticsMetaManager: def _get_from_database( self, session: Session, - statistic_ids: list[str] | None = None, + statistic_ids: set[str] | None = None, statistic_type: Literal["mean"] | Literal["sum"] | None = None, statistic_source: str | None = None, ) -> dict[str, tuple[int, StatisticMetaData]]: @@ -112,6 +112,7 @@ class StatisticsMetaManager: ): statistics_meta = cast(StatisticsMeta, row) id_meta = _statistics_meta_to_id_statistics_metadata(statistics_meta) + statistic_id = cast(str, statistics_meta.statistic_id) results[statistic_id] = id_meta if update_cache: @@ -149,7 +150,7 @@ class StatisticsMetaManager: statistic_id: str, new_metadata: StatisticMetaData, old_metadata_dict: dict[str, tuple[int, StatisticMetaData]], - ) -> tuple[bool, int]: + ) -> tuple[str | None, int]: """Update metadata in the database. This call is not thread-safe and must be called from the @@ -163,7 +164,7 @@ class StatisticsMetaManager: or old_metadata["unit_of_measurement"] != new_metadata["unit_of_measurement"] ): - return False, metadata_id + return None, metadata_id self._assert_in_recorder_thread() session.query(StatisticsMeta).filter_by(statistic_id=statistic_id).update( @@ -182,7 +183,7 @@ class StatisticsMetaManager: old_metadata, new_metadata, ) - return True, metadata_id + return statistic_id, metadata_id def load(self, session: Session) -> None: """Load the statistic_id to metadata_id mapping into memory. @@ -196,12 +197,12 @@ class StatisticsMetaManager: self, session: Session, statistic_id: str ) -> tuple[int, StatisticMetaData] | None: """Resolve statistic_id to the metadata_id.""" - return self.get_many(session, [statistic_id]).get(statistic_id) + return self.get_many(session, {statistic_id}).get(statistic_id) def get_many( self, session: Session, - statistic_ids: list[str] | None = None, + statistic_ids: set[str] | None = None, statistic_type: Literal["mean"] | Literal["sum"] | None = None, statistic_source: str | None = None, ) -> dict[str, tuple[int, StatisticMetaData]]: @@ -228,16 +229,8 @@ class StatisticsMetaManager: "Providing statistic_type and statistic_source is mutually exclusive of statistic_ids" ) - results: dict[str, tuple[int, StatisticMetaData]] = {} - missing_statistic_id: list[str] = [] - - for statistic_id in statistic_ids: - if id_meta := self._stat_id_to_id_meta.get(statistic_id): - results[statistic_id] = id_meta - else: - missing_statistic_id.append(statistic_id) - - if not missing_statistic_id: + results = self.get_from_cache_threadsafe(statistic_ids) + if not (missing_statistic_id := statistic_ids.difference(results)): return results # Fetch metadata from the database @@ -245,12 +238,29 @@ class StatisticsMetaManager: session, statistic_ids=missing_statistic_id ) + def get_from_cache_threadsafe( + self, statistic_ids: set[str] + ) -> dict[str, tuple[int, StatisticMetaData]]: + """Get metadata from cache. + + This call is thread safe and can be run in the event loop, + the database executor, or the recorder thread. + """ + return { + statistic_id: id_meta + for statistic_id in statistic_ids + # We must use a get call here and never iterate over the dict + # because the dict can be modified by the recorder thread + # while we are iterating over it. + if (id_meta := self._stat_id_to_id_meta.get(statistic_id)) + } + def update_or_add( self, session: Session, new_metadata: StatisticMetaData, old_metadata_dict: dict[str, tuple[int, StatisticMetaData]], - ) -> tuple[bool, int]: + ) -> tuple[str | None, int]: """Get metadata_id for a statistic_id. If the statistic_id is previously unknown, add it. If it's already known, update @@ -258,16 +268,16 @@ class StatisticsMetaManager: Updating metadata source is not possible. - Returns a tuple of (updated, metadata_id). + Returns a tuple of (statistic_id | None, metadata_id). - updated is True if the metadata was updated, False if it was not updated. + statistic_id is None if the metadata was not updated This call is not thread-safe and must be called from the recorder thread. """ statistic_id = new_metadata["statistic_id"] if statistic_id not in old_metadata_dict: - return True, self._add_metadata(session, statistic_id, new_metadata) + return statistic_id, self._add_metadata(session, statistic_id, new_metadata) return self._update_metadata( session, statistic_id, new_metadata, old_metadata_dict ) @@ -319,4 +329,14 @@ class StatisticsMetaManager: def reset(self) -> None: """Reset the cache.""" - self._stat_id_to_id_meta = {} + self._stat_id_to_id_meta.clear() + + def adjust_lru_size(self, new_size: int) -> None: + """Adjust the LRU cache size. + + This call is not thread-safe and must be called from the + recorder thread. + """ + lru: LRU = self._stat_id_to_id_meta + if new_size > lru.get_size(): + lru.set_size(new_size) diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index 29c0808e6a..df42c519fe 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -37,6 +37,7 @@ from .statistics import ( async_add_external_statistics, async_change_statistics_unit, async_import_statistics, + async_list_statistic_ids, list_statistic_ids, statistic_during_period, statistics_during_period, @@ -151,7 +152,7 @@ def _ws_get_statistics_during_period( msg_id: int, start_time: dt, end_time: dt | None, - statistic_ids: list[str] | None, + statistic_ids: set[str] | None, period: Literal["5minute", "day", "hour", "week", "month"], units: dict[str, str], types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], @@ -208,7 +209,7 @@ async def ws_handle_get_statistics_during_period( msg["id"], start_time, end_time, - msg["statistic_ids"], + set(msg["statistic_ids"]), msg.get("period"), msg.get("units"), types, @@ -329,11 +330,10 @@ async def ws_get_statistics_metadata( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Get metadata for a list of statistic_ids.""" - instance = get_instance(hass) - statistic_ids = await instance.async_add_executor_job( - list_statistic_ids, hass, msg.get("statistic_ids") - ) - connection.send_result(msg["id"], statistic_ids) + statistic_ids = msg.get("statistic_ids") + statistic_ids_set_or_none = set(statistic_ids) if statistic_ids else None + metadata = await async_list_statistic_ids(hass, statistic_ids_set_or_none) + connection.send_result(msg["id"], metadata) @websocket_api.require_admin @@ -413,7 +413,7 @@ async def ws_adjust_sum_statistics( instance = get_instance(hass) metadatas = await instance.async_add_executor_job( - list_statistic_ids, hass, (msg["statistic_id"],) + list_statistic_ids, hass, {msg["statistic_id"]} ) if not metadatas: connection.send_error(msg["id"], "unknown_statistic_id", "Unknown statistic ID") diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 8d5af155fd..c0df642ed3 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -453,10 +453,10 @@ def _compile_statistics( # noqa: C901 # that are not in the metadata table and we are not working # with them anyway. old_metadatas = statistics.get_metadata_with_session( - get_instance(hass), session, statistic_ids=list(entities_with_float_states) + get_instance(hass), session, statistic_ids=set(entities_with_float_states) ) to_process: list[tuple[str, str | None, str, list[tuple[float, State]]]] = [] - to_query: list[str] = [] + to_query: set[str] = set() for _state in sensor_states: entity_id = _state.entity_id if not (maybe_float_states := entities_with_float_states.get(entity_id)): @@ -472,7 +472,7 @@ def _compile_statistics( # noqa: C901 state_class: str = _state.attributes[ATTR_STATE_CLASS] to_process.append((entity_id, statistics_unit, state_class, valid_float_states)) if "sum" in wanted_statistics[entity_id]: - to_query.append(entity_id) + to_query.add(entity_id) last_stats = statistics.get_latest_short_term_statistics( hass, to_query, {"last_reset", "state", "sum"}, metadata=old_metadatas diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 4d847c1920..874ec5be67 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -636,7 +636,7 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]): self.hass, start, None, - [statistic_id], + {statistic_id}, "hour", None, {"sum"}, diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index aec5bf8134..17e8c47f6b 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -144,13 +144,15 @@ def statistics_during_period( hass: HomeAssistant, start_time: datetime, end_time: datetime | None = None, - statistic_ids: list[str] | None = None, + statistic_ids: set[str] | None = None, period: Literal["5minute", "day", "hour", "week", "month"] = "hour", units: dict[str, str] | None = None, types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]] | None = None, ) -> dict[str, list[dict[str, Any]]]: """Call statistics_during_period with defaults for simpler tests.""" + if statistic_ids is not None and not isinstance(statistic_ids, set): + statistic_ids = set(statistic_ids) if types is None: types = {"last_reset", "max", "mean", "min", "state", "sum"} return statistics.statistics_during_period( diff --git a/tests/components/recorder/db_schema_28.py b/tests/components/recorder/db_schema_28.py index d7a9ec0af4..8127cb3f26 100644 --- a/tests/components/recorder/db_schema_28.py +++ b/tests/components/recorder/db_schema_28.py @@ -292,6 +292,15 @@ class States(Base): # type: ignore[misc,valid-type] context_user_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) context_parent_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) origin_idx = Column(SmallInteger) # 0 is local, 1 is remote + context_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v28, only added for recorder to startup ok + context_user_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v28, only added for recorder to startup ok + context_parent_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v28, only added for recorder to startup ok metadata_id = Column( Integer, ForeignKey("states_meta.metadata_id"), index=True ) # *** Not originally in v28, only added for recorder to startup ok diff --git a/tests/components/recorder/table_managers/test_statistics_meta.py b/tests/components/recorder/table_managers/test_statistics_meta.py index 8ec3f9367d..ab6615c6dd 100644 --- a/tests/components/recorder/table_managers/test_statistics_meta.py +++ b/tests/components/recorder/table_managers/test_statistics_meta.py @@ -26,12 +26,12 @@ async def test_passing_mutually_exclusive_options_to_get_many( ) with pytest.raises(ValueError): instance.statistics_meta_manager.get_many( - session, statistic_ids=["light.kitchen"], statistic_source="sensor" + session, statistic_ids={"light.kitchen"}, statistic_source="sensor" ) assert ( instance.statistics_meta_manager.get_many( session, - statistic_ids=["light.kitchen"], + statistic_ids={"light.kitchen"}, ) == {} ) diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 522a3eff2e..de75052d34 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -84,7 +84,7 @@ def test_compile_hourly_statistics(hass_recorder: Callable[..., HomeAssistant]) # Should not fail if there is nothing there yet stats = get_latest_short_term_statistics( - hass, ["sensor.test1"], {"last_reset", "max", "mean", "min", "state", "sum"} + hass, {"sensor.test1"}, {"last_reset", "max", "mean", "min", "state", "sum"} ) assert stats == {} @@ -169,15 +169,15 @@ def test_compile_hourly_statistics(hass_recorder: Callable[..., HomeAssistant]) assert stats == {"sensor.test1": [expected_2]} stats = get_latest_short_term_statistics( - hass, ["sensor.test1"], {"last_reset", "max", "mean", "min", "state", "sum"} + hass, {"sensor.test1"}, {"last_reset", "max", "mean", "min", "state", "sum"} ) assert stats == {"sensor.test1": [expected_2]} - metadata = get_metadata(hass, statistic_ids=['sensor.test1"']) + metadata = get_metadata(hass, statistic_ids={"sensor.test1"}) stats = get_latest_short_term_statistics( hass, - ["sensor.test1"], + {"sensor.test1"}, {"last_reset", "max", "mean", "min", "state", "sum"}, metadata=metadata, ) @@ -213,7 +213,7 @@ def test_compile_hourly_statistics(hass_recorder: Callable[..., HomeAssistant]) instance.get_session().query(StatisticsShortTerm).delete() # Should not fail there is nothing in the table stats = get_latest_short_term_statistics( - hass, ["sensor.test1"], {"last_reset", "max", "mean", "min", "state", "sum"} + hass, {"sensor.test1"}, {"last_reset", "max", "mean", "min", "state", "sum"} ) assert stats == {} @@ -243,7 +243,7 @@ def mock_sensor_statistics(): sensor_stats("sensor.test3", start), ], get_metadata( - _hass, statistic_ids=["sensor.test1", "sensor.test2", "sensor.test3"] + _hass, statistic_ids={"sensor.test1", "sensor.test2", "sensor.test3"} ), ) @@ -385,6 +385,27 @@ def test_rename_entity(hass_recorder: Callable[..., HomeAssistant]) -> None: assert stats == {"sensor.test99": expected_stats99, "sensor.test2": expected_stats2} +def test_statistics_during_period_set_back_compat( + hass_recorder: Callable[..., HomeAssistant] +) -> None: + """Test statistics_during_period can handle a list instead of a set.""" + hass = hass_recorder() + setup_component(hass, "sensor", {}) + # This should not throw an exception when passed a list instead of a set + assert ( + statistics.statistics_during_period( + hass, + dt_util.utcnow(), + None, + statistic_ids=["sensor.test1"], + period="5minute", + units=None, + types=set(), + ) + == {} + ) + + def test_rename_entity_collision( hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture ) -> None: @@ -595,7 +616,7 @@ async def test_import_statistics( "unit_class": "energy", } ] - metadata = get_metadata(hass, statistic_ids=(statistic_id,)) + metadata = get_metadata(hass, statistic_ids={statistic_id}) assert metadata == { statistic_id: ( 1, @@ -692,7 +713,7 @@ async def test_import_statistics( "unit_class": "energy", } ] - metadata = get_metadata(hass, statistic_ids=(statistic_id,)) + metadata = get_metadata(hass, statistic_ids={statistic_id}) assert metadata == { statistic_id: ( 1, @@ -814,7 +835,7 @@ def test_external_statistics_errors( wait_recording_done(hass) assert statistics_during_period(hass, zero, period="hour") == {} assert list_statistic_ids(hass) == [] - assert get_metadata(hass, statistic_ids=("sensor.total_energy_import",)) == {} + assert get_metadata(hass, statistic_ids={"sensor.total_energy_import"}) == {} # Attempt to insert statistics for the wrong domain external_metadata = {**_external_metadata, "source": "other"} @@ -824,7 +845,7 @@ def test_external_statistics_errors( wait_recording_done(hass) assert statistics_during_period(hass, zero, period="hour") == {} assert list_statistic_ids(hass) == [] - assert get_metadata(hass, statistic_ids=("test:total_energy_import",)) == {} + assert get_metadata(hass, statistic_ids={"test:total_energy_import"}) == {} # Attempt to insert statistics for a naive starting time external_metadata = {**_external_metadata} @@ -837,7 +858,7 @@ def test_external_statistics_errors( wait_recording_done(hass) assert statistics_during_period(hass, zero, period="hour") == {} assert list_statistic_ids(hass) == [] - assert get_metadata(hass, statistic_ids=("test:total_energy_import",)) == {} + assert get_metadata(hass, statistic_ids={"test:total_energy_import"}) == {} # Attempt to insert statistics for an invalid starting time external_metadata = {**_external_metadata} @@ -847,7 +868,7 @@ def test_external_statistics_errors( wait_recording_done(hass) assert statistics_during_period(hass, zero, period="hour") == {} assert list_statistic_ids(hass) == [] - assert get_metadata(hass, statistic_ids=("test:total_energy_import",)) == {} + assert get_metadata(hass, statistic_ids={"test:total_energy_import"}) == {} # Attempt to insert statistics with a naive last_reset external_metadata = {**_external_metadata} @@ -860,7 +881,7 @@ def test_external_statistics_errors( wait_recording_done(hass) assert statistics_during_period(hass, zero, period="hour") == {} assert list_statistic_ids(hass) == [] - assert get_metadata(hass, statistic_ids=("test:total_energy_import",)) == {} + assert get_metadata(hass, statistic_ids={"test:total_energy_import"}) == {} def test_import_statistics_errors( @@ -903,7 +924,7 @@ def test_import_statistics_errors( wait_recording_done(hass) assert statistics_during_period(hass, zero, period="hour") == {} assert list_statistic_ids(hass) == [] - assert get_metadata(hass, statistic_ids=("test:total_energy_import",)) == {} + assert get_metadata(hass, statistic_ids={"test:total_energy_import"}) == {} # Attempt to insert statistics for the wrong domain external_metadata = {**_external_metadata, "source": "sensor"} @@ -913,7 +934,7 @@ def test_import_statistics_errors( wait_recording_done(hass) assert statistics_during_period(hass, zero, period="hour") == {} assert list_statistic_ids(hass) == [] - assert get_metadata(hass, statistic_ids=("sensor.total_energy_import",)) == {} + assert get_metadata(hass, statistic_ids={"sensor.total_energy_import"}) == {} # Attempt to insert statistics for a naive starting time external_metadata = {**_external_metadata} @@ -926,7 +947,7 @@ def test_import_statistics_errors( wait_recording_done(hass) assert statistics_during_period(hass, zero, period="hour") == {} assert list_statistic_ids(hass) == [] - assert get_metadata(hass, statistic_ids=("sensor.total_energy_import",)) == {} + assert get_metadata(hass, statistic_ids={"sensor.total_energy_import"}) == {} # Attempt to insert statistics for an invalid starting time external_metadata = {**_external_metadata} @@ -936,7 +957,7 @@ def test_import_statistics_errors( wait_recording_done(hass) assert statistics_during_period(hass, zero, period="hour") == {} assert list_statistic_ids(hass) == [] - assert get_metadata(hass, statistic_ids=("sensor.total_energy_import",)) == {} + assert get_metadata(hass, statistic_ids={"sensor.total_energy_import"}) == {} # Attempt to insert statistics with a naive last_reset external_metadata = {**_external_metadata} @@ -949,7 +970,7 @@ def test_import_statistics_errors( wait_recording_done(hass) assert statistics_during_period(hass, zero, period="hour") == {} assert list_statistic_ids(hass) == [] - assert get_metadata(hass, statistic_ids=("sensor.total_energy_import",)) == {} + assert get_metadata(hass, statistic_ids={"sensor.total_energy_import"}) == {} @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 4ed6747ac0..5244a33f0b 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -2588,7 +2588,7 @@ async def test_import_statistics( "unit_class": "energy", } ] - metadata = get_metadata(hass, statistic_ids=(statistic_id,)) + metadata = get_metadata(hass, statistic_ids={statistic_id}) assert metadata == { statistic_id: ( 1, @@ -2820,7 +2820,7 @@ async def test_adjust_sum_statistics_energy( "unit_class": "energy", } ] - metadata = get_metadata(hass, statistic_ids=(statistic_id,)) + metadata = get_metadata(hass, statistic_ids={statistic_id}) assert metadata == { statistic_id: ( 1, @@ -3016,7 +3016,7 @@ async def test_adjust_sum_statistics_gas( "unit_class": "volume", } ] - metadata = get_metadata(hass, statistic_ids=(statistic_id,)) + metadata = get_metadata(hass, statistic_ids={statistic_id}) assert metadata == { statistic_id: ( 1, @@ -3230,7 +3230,7 @@ async def test_adjust_sum_statistics_errors( "unit_class": unit_class, } ] - metadata = get_metadata(hass, statistic_ids=(statistic_id,)) + metadata = get_metadata(hass, statistic_ids={statistic_id}) assert metadata == { statistic_id: ( 1, diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index ae044c535b..8881bef8ed 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -3067,7 +3067,7 @@ def test_compile_hourly_statistics_changing_state_class( "unit_class": unit_class, }, ] - metadata = get_metadata(hass, statistic_ids=("sensor.test1",)) + metadata = get_metadata(hass, statistic_ids={"sensor.test1"}) assert metadata == { "sensor.test1": ( 1, @@ -3103,7 +3103,7 @@ def test_compile_hourly_statistics_changing_state_class( "unit_class": unit_class, }, ] - metadata = get_metadata(hass, statistic_ids=("sensor.test1",)) + metadata = get_metadata(hass, statistic_ids={"sensor.test1"}) assert metadata == { "sensor.test1": ( 1, diff --git a/tests/components/tibber/test_statistics.py b/tests/components/tibber/test_statistics.py index ca6500e632..6de7549c28 100644 --- a/tests/components/tibber/test_statistics.py +++ b/tests/components/tibber/test_statistics.py @@ -35,7 +35,7 @@ async def test_async_setup_entry(recorder_mock: Recorder, hass: HomeAssistant) - hass, dt_util.parse_datetime(data[0]["from"]), None, - [statistic_id], + {statistic_id}, "hour", None, {"start", "state", "mean", "min", "max", "last_reset", "sum"}, From 7f3e4cb3afcfced7be09b17ffb444fa555860344 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Mar 2023 16:03:12 -1000 Subject: [PATCH 1071/2810] Guard against selecting all invalid entity_ids in history (#89929) If all the entity_ids that were provided do not exist we would end up passing an empty list of ids to the SQL query which would do an unbounded select --- homeassistant/components/logbook/processor.py | 12 +++---- .../components/recorder/history/modern.py | 32 +++++++++---------- .../components/recorder/models/__init__.py | 3 +- .../components/recorder/models/state.py | 11 +++++++ 4 files changed, 33 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index e053dcb081..32301e9835 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -14,6 +14,7 @@ from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.filters import Filters from homeassistant.components.recorder.models import ( bytes_to_uuid_hex_or_none, + extract_metadata_ids, process_datetime_to_timestamp, process_timestamp_to_utc_isoformat, ) @@ -154,14 +155,11 @@ class EventProcessor: metadata_ids: list[int] | None = None if self.entity_ids: instance = get_instance(self.hass) - entity_id_to_metadata_id = instance.states_meta_manager.get_many( - self.entity_ids, session, False + metadata_ids = extract_metadata_ids( + instance.states_meta_manager.get_many( + self.entity_ids, session, False + ) ) - metadata_ids = [ - metadata_id - for metadata_id in entity_id_to_metadata_id.values() - if metadata_id is not None - ] stmt = statement_for_request( start_day, end_day, diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index a6ca9adf7b..d6269d21b2 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -23,7 +23,12 @@ import homeassistant.util.dt as dt_util from ... import recorder from ..db_schema import RecorderRuns, StateAttributes, States, StatesMeta from ..filters import Filters -from ..models import LazyState, process_timestamp, row_to_compressed_state +from ..models import ( + LazyState, + extract_metadata_ids, + process_timestamp, + row_to_compressed_state, +) from ..util import execute_stmt_lambda_element, session_scope from .const import ( IGNORE_DOMAINS_ENTITY_ID_LIKE, @@ -232,14 +237,12 @@ def get_significant_states_with_session( entity_id_to_metadata_id: dict[str, int | None] | None = None if entity_ids: instance = recorder.get_instance(hass) - entity_id_to_metadata_id = instance.states_meta_manager.get_many( - entity_ids, session, False - ) - metadata_ids = [ - metadata_id - for metadata_id in entity_id_to_metadata_id.values() - if metadata_id is not None - ] + if not ( + entity_id_to_metadata_id := instance.states_meta_manager.get_many( + entity_ids, session, False + ) + ) or not (metadata_ids := extract_metadata_ids(entity_id_to_metadata_id)): + return {} stmt = _significant_states_stmt( start_time, end_time, @@ -569,14 +572,9 @@ def _get_rows_with_session( # We have more than one entity to look at so we need to do a query on states # since the last recorder run started. if entity_ids: - if not entity_id_to_metadata_id: - return [] - metadata_ids = [ - metadata_id - for metadata_id in entity_id_to_metadata_id.values() - if metadata_id is not None - ] - if not metadata_ids: + if not entity_id_to_metadata_id or not ( + metadata_ids := extract_metadata_ids(entity_id_to_metadata_id) + ): return [] stmt = _get_states_for_entities_stmt( run.start, utc_point_in_time, metadata_ids, no_attributes diff --git a/homeassistant/components/recorder/models/__init__.py b/homeassistant/components/recorder/models/__init__.py index 3aec02b8d4..91dd80c4aa 100644 --- a/homeassistant/components/recorder/models/__init__.py +++ b/homeassistant/components/recorder/models/__init__.py @@ -8,7 +8,7 @@ from .context import ( uuid_hex_to_bytes_or_none, ) from .database import DatabaseEngine, DatabaseOptimizer, UnsupportedDialect -from .state import LazyState, row_to_compressed_state +from .state import LazyState, extract_metadata_ids, row_to_compressed_state from .statistics import ( CalendarStatisticPeriod, FixedStatisticPeriod, @@ -43,6 +43,7 @@ __all__ = [ "bytes_to_ulid_or_none", "bytes_to_uuid_hex_or_none", "datetime_to_timestamp_or_none", + "extract_metadata_ids", "process_datetime_to_timestamp", "process_timestamp", "process_timestamp_to_utc_isoformat", diff --git a/homeassistant/components/recorder/models/state.py b/homeassistant/components/recorder/models/state.py index c70e434263..5594f5f6d4 100644 --- a/homeassistant/components/recorder/models/state.py +++ b/homeassistant/components/recorder/models/state.py @@ -24,6 +24,17 @@ from .time import process_timestamp _LOGGER = logging.getLogger(__name__) +def extract_metadata_ids( + entity_id_to_metadata_id: dict[str, int | None], +) -> list[int]: + """Extract metadata ids from entity_id_to_metadata_id.""" + return [ + metadata_id + for metadata_id in entity_id_to_metadata_id.values() + if metadata_id is not None + ] + + class LazyState(State): """A lazy version of core State after schema 31.""" From aebe4c66a67ec08620c5fb1c4bd2962ba7ab2780 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Mar 2023 16:04:24 -1000 Subject: [PATCH 1072/2810] Fix cpu thrashing during purge after all legacy events were removed (#89923) * Fix cpu thrashing during purge after all legacy events were removed We now remove the the index of of event ids on the states table when its all NULLs to save space. The purge path needs to avoid checking for legacy rows to purge if the index has been removed since it will result in a full table scan each purge cycle that will always find no legacy rows to purge * one more place * drop the key constraint as well * fixes * more sqlite --- homeassistant/components/recorder/core.py | 2 ++ homeassistant/components/recorder/migration.py | 17 +++++++++++------ homeassistant/components/recorder/purge.py | 17 ++++++++++------- tests/components/recorder/test_purge.py | 14 +++++++++++++- tests/components/recorder/test_v32_migration.py | 8 +++++++- 5 files changed, 43 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 30dd311c0e..da207e24cb 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -221,6 +221,7 @@ class Recorder(threading.Thread): self.async_migration_event = asyncio.Event() self.migration_in_progress = False self.migration_is_live = False + self.use_legacy_events_index = False self._database_lock_task: DatabaseLockTask | None = None self._db_executor: DBInterruptibleThreadPoolExecutor | None = None @@ -744,6 +745,7 @@ class Recorder(threading.Thread): session, TABLE_STATES, LEGACY_STATES_EVENT_ID_INDEX ): self.queue_task(EventIdMigrationTask()) + self.use_legacy_events_index = True # We must only set the db ready after we have set the table managers # to active if there is no data to migrate. diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 4619c4531d..a4dea027d8 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -513,11 +513,7 @@ def _drop_foreign_key_constraints( inspector = sqlalchemy.inspect(engine) drops = [] for foreign_key in inspector.get_foreign_keys(table): - if ( - foreign_key["name"] - and foreign_key.get("options", {}).get("ondelete") - and foreign_key["constrained_columns"] == columns - ): + if foreign_key["name"] and foreign_key["constrained_columns"] == columns: drops.append(ForeignKeyConstraint((), (), name=foreign_key["name"])) # Bind the ForeignKeyConstraints to the table @@ -1547,7 +1543,16 @@ def cleanup_legacy_states_event_ids(instance: Recorder) -> bool: if all_gone: # Only drop the index if there are no more event_ids in the states table # ex all NULL - _drop_index(session_maker, "states", LEGACY_STATES_EVENT_ID_INDEX) + assert instance.engine is not None, "engine should never be None" + if instance.dialect_name != SupportedDialect.SQLITE: + # SQLite does not support dropping foreign key constraints + # so we can't drop the index at this time but we can avoid + # looking for legacy rows during purge + _drop_foreign_key_constraints( + session_maker, instance.engine, TABLE_STATES, ["event_id"] + ) + _drop_index(session_maker, "states", LEGACY_STATES_EVENT_ID_INDEX) + instance.use_legacy_events_index = False return True diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 528cb1247f..fafb7c661a 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -8,7 +8,6 @@ import logging import time from typing import TYPE_CHECKING -from sqlalchemy.engine.row import Row from sqlalchemy.orm.session import Session import homeassistant.util.dt as dt_util @@ -74,7 +73,7 @@ def purge_old_data( with session_scope(session=instance.get_session()) as session: # Purge a max of SQLITE_MAX_BIND_VARS, based on the oldest states or events record has_more_to_purge = False - if _purging_legacy_format(session): + if instance.use_legacy_events_index and _purging_legacy_format(session): _LOGGER.debug( "Purge running in legacy format as there are states with event_id" " remaining" @@ -671,14 +670,18 @@ def _purge_filtered_events( _LOGGER.debug( "Selected %s event_ids to remove that should be filtered", len(event_ids_set) ) - states: list[Row[tuple[int]]] = ( - session.query(States.state_id).filter(States.event_id.in_(event_ids_set)).all() - ) - if states: + if ( + instance.use_legacy_events_index + and ( + states := session.query(States.state_id) + .filter(States.event_id.in_(event_ids_set)) + .all() + ) + and (state_ids := {state.state_id for state in states}) + ): # These are legacy states that are linked to an event that are no longer # created but since we did not remove them when we stopped adding new ones # we will need to purge them here. - state_ids: set[int] = {state.state_id for state in states} _purge_state_ids(instance, session, state_ids) _purge_event_ids(session, event_ids_set) if unused_data_ids_set := _select_unused_event_data_ids( diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index f268325b21..60620f39d6 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -10,7 +10,7 @@ from sqlalchemy.exc import DatabaseError, OperationalError from sqlalchemy.orm.session import Session from homeassistant.components import recorder -from homeassistant.components.recorder import Recorder +from homeassistant.components.recorder import Recorder, migration from homeassistant.components.recorder.const import ( SQLITE_MAX_BIND_VARS, SupportedDialect, @@ -1726,6 +1726,18 @@ async def test_purge_can_mix_legacy_and_new_format( ) -> None: """Test purging with legacy a new events.""" instance = await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + # New databases are no longer created with the legacy events index + assert instance.use_legacy_events_index is False + + def _recreate_legacy_events_index(): + """Recreate the legacy events index since its no longer created on new instances.""" + migration._create_index(instance.get_session, "states", "ix_states_event_id") + instance.use_legacy_events_index = True + + await instance.async_add_executor_job(_recreate_legacy_events_index) + assert instance.use_legacy_events_index is True + utcnow = dt_util.utcnow() eleven_days_ago = utcnow - timedelta(days=11) with session_scope(hass=hass) as session: diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index dd49d7b21e..0b5389ddf7 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -142,6 +142,7 @@ async def test_migrate_times( _get_states_index_names ) states_index_names = {index["name"] for index in states_indexes} + assert recorder.get_instance(hass).use_legacy_events_index is True await hass.async_stop() await hass.async_block_till_done() @@ -212,7 +213,12 @@ async def test_migrate_times( ) states_index_names = {index["name"] for index in states_indexes} - assert "ix_states_event_id" not in states_index_names + # sqlite does not support dropping foreign keys so the + # ix_states_event_id index is not dropped in this case + # but use_legacy_events_index is still False + assert "ix_states_event_id" in states_index_names + + assert recorder.get_instance(hass).use_legacy_events_index is False await hass.async_stop() dt_util.DEFAULT_TIME_ZONE = ORIG_TZ From f27d73fc3461f8d3fb36a829504ed5ec6d0d253a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Mar 2023 16:05:07 -1000 Subject: [PATCH 1073/2810] Remove legacy event lookups from logbook (#89945) Events recorded with Home Assistant 2022.5.x or older will no longer display context information in the logbook --- .../components/logbook/queries/all.py | 16 +---------- .../components/logbook/queries/common.py | 28 ------------------- 2 files changed, 1 insertion(+), 43 deletions(-) diff --git a/homeassistant/components/logbook/queries/all.py b/homeassistant/components/logbook/queries/all.py index 5311d5a9d6..8c37bf22da 100644 --- a/homeassistant/components/logbook/queries/all.py +++ b/homeassistant/components/logbook/queries/all.py @@ -12,12 +12,7 @@ from homeassistant.components.recorder.db_schema import ( States, ) -from .common import ( - apply_states_filters, - legacy_select_events_context_id, - select_events_without_states, - select_states, -) +from .common import apply_states_filters, select_events_without_states, select_states def all_stmt( @@ -33,9 +28,6 @@ def all_stmt( lambda: select_events_without_states(start_day, end_day, event_types) ) if context_id_bin is not None: - # Once all the old `state_changed` events - # are gone from the database remove the - # _legacy_select_events_context_id() stmt += lambda s: s.where(Events.context_id_bin == context_id_bin).union_all( _states_query_for_context_id( start_day, @@ -43,12 +35,6 @@ def all_stmt( # https://github.com/python/mypy/issues/2608 context_id_bin, # type:ignore[arg-type] ), - legacy_select_events_context_id( - start_day, - end_day, - # https://github.com/python/mypy/issues/2608 - context_id_bin, # type:ignore[arg-type] - ), ) else: if events_entity_filter is not None: diff --git a/homeassistant/components/logbook/queries/common.py b/homeassistant/components/logbook/queries/common.py index c63bb30eb6..08bf1b8ab9 100644 --- a/homeassistant/components/logbook/queries/common.py +++ b/homeassistant/components/logbook/queries/common.py @@ -166,34 +166,6 @@ def select_states() -> Select: ) -def legacy_select_events_context_id( - start_day: float, end_day: float, context_id_bin: bytes -) -> Select: - """Generate a legacy events context id select that also joins states.""" - # This can be removed once we no longer have event_ids in the states table - return ( - select( - *EVENT_COLUMNS, - literal(value=None, type_=sqlalchemy.String).label("shared_data"), - *STATE_COLUMNS, - NOT_CONTEXT_ONLY, - ) - .outerjoin(States, (Events.event_id == States.event_id)) - .where( - (States.last_updated_ts == States.last_changed_ts) - | States.last_changed_ts.is_(None) - ) - .where(_not_continuous_entity_matcher()) - .outerjoin( - StateAttributes, (States.attributes_id == StateAttributes.attributes_id) - ) - .outerjoin(StatesMeta, (States.metadata_id == StatesMeta.metadata_id)) - .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) - .where((Events.time_fired_ts > start_day) & (Events.time_fired_ts < end_day)) - .where(Events.context_id_bin == context_id_bin) - ) - - def apply_states_filters(sel: Select, start_day: float, end_day: float) -> Select: """Filter states by time range. From 817ba972276623b11ac33a4294ff18b435367ace Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Mar 2023 16:05:39 -1000 Subject: [PATCH 1074/2810] Remove unneeded lambda_stmt in place add in statistics (#89943) We can generate this entire query in a single lambda_stmt so there is no need to add two which increases the size of the cache key --- .../components/recorder/statistics.py | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 2f2deeeaee..34f9c57f95 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -1969,24 +1969,24 @@ def _latest_short_term_statistics_stmt( metadata_ids: list[int], ) -> StatementLambdaElement: """Create the statement for finding the latest short term stat rows.""" - stmt = lambda_stmt(lambda: select(*QUERY_STATISTICS_SHORT_TERM)) - stmt += lambda s: s.join( - ( - most_recent_statistic_row := ( - select( - StatisticsShortTerm.metadata_id, - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - func.max(StatisticsShortTerm.start_ts).label("start_max"), - ) - .where(StatisticsShortTerm.metadata_id.in_(metadata_ids)) - .group_by(StatisticsShortTerm.metadata_id) - ).subquery() - ), - (StatisticsShortTerm.metadata_id == most_recent_statistic_row.c.metadata_id) - & (StatisticsShortTerm.start_ts == most_recent_statistic_row.c.start_max), + return lambda_stmt( + lambda: select(*QUERY_STATISTICS_SHORT_TERM).join( + ( + most_recent_statistic_row := ( + select( + StatisticsShortTerm.metadata_id, + # https://github.com/sqlalchemy/sqlalchemy/issues/9189 + # pylint: disable-next=not-callable + func.max(StatisticsShortTerm.start_ts).label("start_max"), + ) + .where(StatisticsShortTerm.metadata_id.in_(metadata_ids)) + .group_by(StatisticsShortTerm.metadata_id) + ).subquery() + ), + (StatisticsShortTerm.metadata_id == most_recent_statistic_row.c.metadata_id) + & (StatisticsShortTerm.start_ts == most_recent_statistic_row.c.start_max), + ) ) - return stmt def get_latest_short_term_statistics( From bf63e6cbd40baa287add85cfe9adb06df162a4ec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Mar 2023 16:30:01 -1000 Subject: [PATCH 1075/2810] Set unique on StatesMeta and EventTypes database tables (#89971) Set unique on StatesMeta and EventTypes These should have been marked unique originally to prevent collision bugs from going unnoticed. These have not been to beta yet so this is not a breaking change --- homeassistant/components/recorder/db_schema.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 4826453e4c..0bb0b846a3 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -350,7 +350,7 @@ class EventTypes(Base): __tablename__ = TABLE_EVENT_TYPES event_type_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) event_type: Mapped[str | None] = mapped_column( - String(MAX_LENGTH_EVENT_EVENT_TYPE), index=True + String(MAX_LENGTH_EVENT_EVENT_TYPE), index=True, unique=True ) def __repr__(self) -> str: @@ -600,7 +600,7 @@ class StatesMeta(Base): __tablename__ = TABLE_STATES_META metadata_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) entity_id: Mapped[str | None] = mapped_column( - String(MAX_LENGTH_STATE_ENTITY_ID), index=True + String(MAX_LENGTH_STATE_ENTITY_ID), index=True, unique=True ) def __repr__(self) -> str: From affb48d27170b7ce856b54fec62d69da4c11f9c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Mar 2023 16:44:35 -1000 Subject: [PATCH 1076/2810] Avoid joining states_meta for statistics queries (#89941) --- .../components/recorder/history/modern.py | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index d6269d21b2..d39abd2e9f 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -142,8 +142,8 @@ def _ignore_domains_filter(query: Query) -> Query: def _significant_states_stmt( start_time: datetime, end_time: datetime | None, - entity_ids: list[str] | None, metadata_ids: list[int] | None, + metadata_ids_in_significant_domains: list[int], filters: Filters | None, significant_changes_only: bool, no_attributes: bool, @@ -153,17 +153,23 @@ def _significant_states_stmt( no_attributes, include_last_changed=not significant_changes_only ) join_states_meta = False - if ( - entity_ids - and len(entity_ids) == 1 - and significant_changes_only - and split_entity_id(entity_ids[0])[0] not in SIGNIFICANT_DOMAINS - ): + if metadata_ids and significant_changes_only: + # Since we are filtering on entity_id (metadata_id) we can avoid + # the join of the states_meta table since we already know which + # metadata_ids are in the significant domains. stmt += lambda q: q.filter( - (States.last_changed_ts == States.last_updated_ts) + States.metadata_id.in_(metadata_ids_in_significant_domains) + | (States.last_changed_ts == States.last_updated_ts) | States.last_changed_ts.is_(None) ) elif significant_changes_only: + # This is the case where we are not filtering on entity_id + # so we need to join the states_meta table to filter out + # the domains we do not care about. This query path was + # only used by the old history page to show all entities + # in the UI. The new history page filters on entity_id + # so this query path is not used anymore except for third + # party integrations that use the history API. stmt += lambda q: q.filter( or_( *[ @@ -235,6 +241,7 @@ def get_significant_states_with_session( """ metadata_ids: list[int] | None = None entity_id_to_metadata_id: dict[str, int | None] | None = None + metadata_ids_in_significant_domains: list[int] = [] if entity_ids: instance = recorder.get_instance(hass) if not ( @@ -243,11 +250,18 @@ def get_significant_states_with_session( ) ) or not (metadata_ids := extract_metadata_ids(entity_id_to_metadata_id)): return {} + if significant_changes_only: + metadata_ids_in_significant_domains = [ + metadata_id + for entity_id, metadata_id in entity_id_to_metadata_id.items() + if metadata_id is not None + and split_entity_id(entity_id)[0] in SIGNIFICANT_DOMAINS + ] stmt = _significant_states_stmt( start_time, end_time, - entity_ids, metadata_ids, + metadata_ids_in_significant_domains, filters, significant_changes_only, no_attributes, From c94b054d75f6af7c488cdfd9aacf0b56703e9179 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Mar 2023 17:33:21 -1000 Subject: [PATCH 1077/2810] Retain history when renaming an entity_id (#89963) Co-authored-by: Paulus Schoutsen --- homeassistant/components/recorder/__init__.py | 4 +- homeassistant/components/recorder/core.py | 50 ++-- .../components/recorder/entity_registry.py | 71 +++++ .../components/recorder/statistics.py | 33 +-- .../recorder/table_managers/states_meta.py | 17 ++ homeassistant/components/recorder/tasks.py | 18 +- tests/components/recorder/common.py | 92 ++++++- .../recorder/test_entity_registry.py | 245 ++++++++++++++++++ tests/components/recorder/test_statistics.py | 78 +----- 9 files changed, 478 insertions(+), 130 deletions(-) create mode 100644 homeassistant/components/recorder/entity_registry.py create mode 100644 tests/components/recorder/test_entity_registry.py diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 385c12f37a..2621db9cb7 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -20,7 +20,7 @@ from homeassistant.helpers.integration_platform import ( from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass -from . import statistics, websocket_api +from . import entity_registry, websocket_api from .const import ( # noqa: F401 CONF_DB_INTEGRITY_CHECK, DATA_INSTANCE, @@ -163,8 +163,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: instance.async_register() instance.start() async_register_services(hass, instance) - statistics.async_setup(hass) websocket_api.async_setup(hass) + entity_registry.async_setup(hass) await async_process_integration_platforms(hass, DOMAIN, _process_recorder_platform) return await instance.async_db_ready diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index da207e24cb..893117f6e3 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -109,6 +109,7 @@ from .tasks import ( StatisticsTask, StopTask, SynchronizeTask, + UpdateStatesMetadataTask, UpdateStatisticsMetadataTask, WaitTask, ) @@ -548,6 +549,15 @@ class Recorder(threading.Thread): ) ) + @callback + def async_update_states_metadata( + self, + entity_id: str, + new_entity_id: str, + ) -> None: + """Update states metadata for an entity_id.""" + self.queue_task(UpdateStatesMetadataTask(entity_id, new_entity_id)) + @callback def async_change_statistics_unit( self, @@ -970,8 +980,26 @@ class Recorder(threading.Thread): def _process_state_changed_event_into_session(self, event: Event) -> None: """Process a state_changed event into the session.""" state_attributes_manager = self.state_attributes_manager + states_meta_manager = self.states_meta_manager + entity_removed = not event.data.get("new_state") + entity_id = event.data["entity_id"] + dbstate = States.from_event(event) - if (entity_id := dbstate.entity_id) is None or not ( + + states_manager = self.states_manager + if old_state := states_manager.pop_pending(entity_id): + dbstate.old_state = old_state + elif old_state_id := states_manager.pop_committed(entity_id): + dbstate.old_state_id = old_state_id + if entity_removed: + dbstate.state = None + else: + states_manager.add_pending(entity_id, dbstate) + + if states_meta_manager.active: + dbstate.entity_id = None + + if entity_id is None or not ( shared_attrs_bytes := state_attributes_manager.serialize_from_event(event) ): return @@ -979,11 +1007,16 @@ class Recorder(threading.Thread): assert self.event_session is not None session = self.event_session # Map the entity_id to the StatesMeta table - states_meta_manager = self.states_meta_manager if pending_states_meta := states_meta_manager.get_pending(entity_id): dbstate.states_meta_rel = pending_states_meta elif metadata_id := states_meta_manager.get(entity_id, session, True): dbstate.metadata_id = metadata_id + elif states_meta_manager.active and entity_removed: + # If the entity was removed, we don't need to add it to the + # StatesMeta table or record it in the pending commit + # if it does not have a metadata_id allocated to it as + # it either never existed or was just renamed. + return else: states_meta = StatesMeta(entity_id=entity_id) states_meta_manager.add_pending(states_meta) @@ -1015,19 +1048,6 @@ class Recorder(threading.Thread): session.add(dbstate_attributes) dbstate.state_attributes = dbstate_attributes - states_manager = self.states_manager - if old_state := states_manager.pop_pending(entity_id): - dbstate.old_state = old_state - elif old_state_id := states_manager.pop_committed(entity_id): - dbstate.old_state_id = old_state_id - if event.data.get("new_state"): - states_manager.add_pending(entity_id, dbstate) - else: - dbstate.state = None - - if states_meta_manager.active: - dbstate.entity_id = None - session.add(dbstate) def _handle_database_error(self, err: Exception) -> bool: diff --git a/homeassistant/components/recorder/entity_registry.py b/homeassistant/components/recorder/entity_registry.py new file mode 100644 index 0000000000..fbf6e69177 --- /dev/null +++ b/homeassistant/components/recorder/entity_registry.py @@ -0,0 +1,71 @@ +"""Recorder entity registry helper.""" +import logging + +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.start import async_at_start + +from .core import Recorder +from .util import get_instance, session_scope + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_setup(hass: HomeAssistant) -> None: + """Set up the entity hooks.""" + + @callback + def _async_entity_id_changed(event: Event) -> None: + instance = get_instance(hass) + old_entity_id: str = event.data["old_entity_id"] + new_entity_id: str = event.data["entity_id"] + instance.async_update_statistics_metadata( + old_entity_id, new_statistic_id=new_entity_id + ) + instance.async_update_states_metadata( + old_entity_id, new_entity_id=new_entity_id + ) + + @callback + def entity_registry_changed_filter(event: Event) -> bool: + """Handle entity_id changed filter.""" + return event.data["action"] == "update" and "old_entity_id" in event.data + + @callback + def _setup_entity_registry_event_handler(hass: HomeAssistant) -> None: + """Subscribe to event registry events.""" + hass.bus.async_listen( + er.EVENT_ENTITY_REGISTRY_UPDATED, + _async_entity_id_changed, + event_filter=entity_registry_changed_filter, + run_immediately=True, + ) + + async_at_start(hass, _setup_entity_registry_event_handler) + + +def update_states_metadata( + instance: Recorder, + entity_id: str, + new_entity_id: str, +) -> None: + """Update the states metadata table when an entity is renamed.""" + states_meta_manager = instance.states_meta_manager + if not states_meta_manager.active: + _LOGGER.warning( + "Cannot rename entity_id `%s` to `%s` " + "because the states meta manager is not yet active", + entity_id, + new_entity_id, + ) + return + + with session_scope(session=instance.get_session()) as session: + if not states_meta_manager.update_metadata(session, entity_id, new_entity_id): + _LOGGER.warning( + "Cannot migrate history for entity_id `%s` to `%s` " + "because the new entity_id is already in use", + entity_id, + new_entity_id, + ) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 34f9c57f95..2f93a8a833 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -26,11 +26,9 @@ from sqlalchemy.sql.lambdas import StatementLambdaElement import voluptuous as vol from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT -from homeassistant.core import Event, HomeAssistant, callback, valid_entity_id +from homeassistant.core import HomeAssistant, callback, valid_entity_id from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.json import JSONEncoder -from homeassistant.helpers.start import async_at_start from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util import dt as dt_util @@ -326,35 +324,6 @@ class ValidationIssue: return dataclasses.asdict(self) -def async_setup(hass: HomeAssistant) -> None: - """Set up the history hooks.""" - - @callback - def _async_entity_id_changed(event: Event) -> None: - get_instance(hass).async_update_statistics_metadata( - event.data["old_entity_id"], new_statistic_id=event.data["entity_id"] - ) - - @callback - def entity_registry_changed_filter(event: Event) -> bool: - """Handle entity_id changed filter.""" - if event.data["action"] != "update" or "old_entity_id" not in event.data: - return False - - return True - - @callback - def setup_entity_registry_event_handler(hass: HomeAssistant) -> None: - """Subscribe to event registry events.""" - hass.bus.async_listen( - er.EVENT_ENTITY_REGISTRY_UPDATED, - _async_entity_id_changed, - event_filter=entity_registry_changed_filter, - ) - - async_at_start(hass, setup_entity_registry_event_handler) - - def get_start_time() -> datetime: """Return start time.""" now = dt_util.utcnow() diff --git a/homeassistant/components/recorder/table_managers/states_meta.py b/homeassistant/components/recorder/table_managers/states_meta.py index c9c1ba9028..639e0acaa3 100644 --- a/homeassistant/components/recorder/table_managers/states_meta.py +++ b/homeassistant/components/recorder/table_managers/states_meta.py @@ -144,3 +144,20 @@ class StatesMetaManager(BaseLRUTableManager[StatesMeta]): """ for entity_id in entity_ids: self._id_map.pop(entity_id, None) + + def update_metadata( + self, + session: Session, + entity_id: str, + new_entity_id: str, + ) -> bool: + """Update states metadata for an entity_id.""" + if self.get(new_entity_id, session, True) is not None: + # If the new entity id already exists we have + # a collision and should not update. + return False + session.query(StatesMeta).filter(StatesMeta.entity_id == entity_id).update( + {StatesMeta.entity_id: new_entity_id} + ) + self._id_map.pop(entity_id, None) + return True diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index ef8f6a95a7..d3e3c05382 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -13,7 +13,7 @@ from typing import TYPE_CHECKING, Any from homeassistant.core import Event from homeassistant.helpers.typing import UndefinedType -from . import purge, statistics +from . import entity_registry, purge, statistics from .const import DOMAIN, EXCLUDE_ATTRIBUTES from .db_schema import Statistics, StatisticsShortTerm from .models import StatisticData, StatisticMetaData @@ -83,6 +83,22 @@ class UpdateStatisticsMetadataTask(RecorderTask): ) +@dataclass +class UpdateStatesMetadataTask(RecorderTask): + """Task to update states metadata.""" + + entity_id: str + new_entity_id: str + + def run(self, instance: Recorder) -> None: + """Handle the task.""" + entity_registry.update_states_metadata( + instance, + self.entity_id, + self.new_entity_id, + ) + + @dataclass class PurgeTask(RecorderTask): """Object to store information about purge task.""" diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index 17e8c47f6b..0da58cce6c 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -4,21 +4,22 @@ from __future__ import annotations import asyncio from collections.abc import Iterable from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timedelta import time from typing import Any, Literal, cast +from unittest.mock import patch, sentinel from sqlalchemy import create_engine from sqlalchemy.orm.session import Session from homeassistant import core as ha from homeassistant.components import recorder -from homeassistant.components.recorder import get_instance, statistics -from homeassistant.components.recorder.core import Recorder +from homeassistant.components.recorder import Recorder, get_instance, statistics from homeassistant.components.recorder.db_schema import RecorderRuns from homeassistant.components.recorder.tasks import RecorderTask, StatisticsTask +from homeassistant.const import UnitOfTemperature from homeassistant.core import Event, HomeAssistant, State -from homeassistant.util import dt as dt_util +import homeassistant.util.dt as dt_util from . import db_schema_0 @@ -38,6 +39,15 @@ class BlockRecorderTask(RecorderTask): time.sleep(self.seconds) +@dataclass +class ForceReturnConnectionToPool(RecorderTask): + """Force return connection to pool.""" + + def run(self, instance: Recorder) -> None: + """Handle the task.""" + instance.event_session.commit() + + async def async_block_recorder(hass: HomeAssistant, seconds: float) -> None: """Block the recorders event loop for testing. @@ -223,3 +233,77 @@ def assert_dict_of_states_equal_without_context_and_last_changed( assert_multiple_states_equal_without_context_and_last_changed( state, others[entity_id] ) + + +def record_states(hass): + """Record some test states. + + We inject a bunch of state updates temperature sensors. + """ + mp = "media_player.test" + sns1 = "sensor.test1" + sns2 = "sensor.test2" + sns3 = "sensor.test3" + sns4 = "sensor.test4" + sns1_attr = { + "device_class": "temperature", + "state_class": "measurement", + "unit_of_measurement": UnitOfTemperature.CELSIUS, + } + sns2_attr = { + "device_class": "humidity", + "state_class": "measurement", + "unit_of_measurement": "%", + } + sns3_attr = {"device_class": "temperature"} + sns4_attr = {} + + def set_state(entity_id, state, **kwargs): + """Set the state.""" + hass.states.set(entity_id, state, **kwargs) + wait_recording_done(hass) + return hass.states.get(entity_id) + + zero = dt_util.utcnow() + one = zero + timedelta(seconds=1 * 5) + two = one + timedelta(seconds=15 * 5) + three = two + timedelta(seconds=30 * 5) + four = three + timedelta(seconds=15 * 5) + + states = {mp: [], sns1: [], sns2: [], sns3: [], sns4: []} + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=one + ): + states[mp].append( + set_state(mp, "idle", attributes={"media_title": str(sentinel.mt1)}) + ) + states[sns1].append(set_state(sns1, "10", attributes=sns1_attr)) + states[sns2].append(set_state(sns2, "10", attributes=sns2_attr)) + states[sns3].append(set_state(sns3, "10", attributes=sns3_attr)) + states[sns4].append(set_state(sns4, "10", attributes=sns4_attr)) + + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", + return_value=one + timedelta(microseconds=1), + ): + states[mp].append( + set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt2)}) + ) + + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=two + ): + states[sns1].append(set_state(sns1, "15", attributes=sns1_attr)) + states[sns2].append(set_state(sns2, "15", attributes=sns2_attr)) + states[sns3].append(set_state(sns3, "15", attributes=sns3_attr)) + states[sns4].append(set_state(sns4, "15", attributes=sns4_attr)) + + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=three + ): + states[sns1].append(set_state(sns1, "20", attributes=sns1_attr)) + states[sns2].append(set_state(sns2, "20", attributes=sns2_attr)) + states[sns3].append(set_state(sns3, "20", attributes=sns3_attr)) + states[sns4].append(set_state(sns4, "20", attributes=sns4_attr)) + + return zero, four, states diff --git a/tests/components/recorder/test_entity_registry.py b/tests/components/recorder/test_entity_registry.py new file mode 100644 index 0000000000..922032539c --- /dev/null +++ b/tests/components/recorder/test_entity_registry.py @@ -0,0 +1,245 @@ +"""The tests for sensor recorder platform.""" +from collections.abc import Callable + +import pytest +from sqlalchemy import select +from sqlalchemy.orm import Session + +from homeassistant.components.recorder import history +from homeassistant.components.recorder.db_schema import StatesMeta +from homeassistant.components.recorder.util import session_scope +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import setup_component +from homeassistant.util import dt as dt_util + +from .common import ( + ForceReturnConnectionToPool, + assert_dict_of_states_equal_without_context_and_last_changed, + async_wait_recording_done, + record_states, + wait_recording_done, +) + +from tests.common import MockEntity, MockEntityPlatform, mock_registry +from tests.typing import RecorderInstanceGenerator + + +def _count_entity_id_in_states_meta( + hass: HomeAssistant, session: Session, entity_id: str +) -> int: + return len( + list( + session.execute( + select(StatesMeta).filter(StatesMeta.entity_id == "sensor.test99") + ) + ) + ) + + +def test_rename_entity_without_collision( + hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture +) -> None: + """Test states meta is migrated when entity_id is changed.""" + hass = hass_recorder() + setup_component(hass, "sensor", {}) + + entity_reg = mock_registry(hass) + + @callback + def add_entry(): + reg_entry = entity_reg.async_get_or_create( + "sensor", + "test", + "unique_0000", + suggested_object_id="test1", + ) + assert reg_entry.entity_id == "sensor.test1" + + hass.add_job(add_entry) + hass.block_till_done() + + zero, four, states = record_states(hass) + hist = history.get_significant_states(hass, zero, four) + + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + + @callback + def rename_entry(): + entity_reg.async_update_entity("sensor.test1", new_entity_id="sensor.test99") + + hass.add_job(rename_entry) + wait_recording_done(hass) + + hist = history.get_significant_states(hass, zero, four) + states["sensor.test99"] = states.pop("sensor.test1") + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + + hass.states.set("sensor.test99", "post_migrate") + wait_recording_done(hass) + new_hist = history.get_significant_states(hass, zero, dt_util.utcnow()) + assert not new_hist.get("sensor.test1") + assert new_hist["sensor.test99"][-1].state == "post_migrate" + + with session_scope(hass=hass) as session: + assert _count_entity_id_in_states_meta(hass, session, "sensor.test99") == 1 + assert _count_entity_id_in_states_meta(hass, session, "sensor.test1") == 1 + + assert "the new entity_id is already in use" not in caplog.text + + +async def test_rename_entity_on_mocked_platform( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test states meta is migrated when entity_id is changed when using a mocked platform. + + This test will call async_remove on the entity so we can make + sure that we do not record the entity as removed in the database + when we rename it. + """ + instance = await async_setup_recorder_instance(hass) + entity_reg = er.async_get(hass) + start = dt_util.utcnow() + + reg_entry = entity_reg.async_get_or_create( + "sensor", + "test", + "unique_0000", + suggested_object_id="test1", + ) + assert reg_entry.entity_id == "sensor.test1" + + entity_platform1 = MockEntityPlatform( + hass, domain="mock_integration", platform_name="mock_platform", platform=None + ) + entity1 = MockEntity(entity_id=reg_entry.entity_id) + await entity_platform1.async_add_entities([entity1]) + + await hass.async_block_till_done() + + hass.states.async_set("sensor.test1", "pre_migrate") + await async_wait_recording_done(hass) + + hist = await instance.async_add_executor_job( + history.get_significant_states, + hass, + start, + None, + ["sensor.test1", "sensor.test99"], + ) + + entity_reg.async_update_entity("sensor.test1", new_entity_id="sensor.test99") + await hass.async_block_till_done() + # We have to call the remove method ourselves since we are mocking the platform + hass.states.async_remove("sensor.test1") + + # The remove will trigger a lookup of the non-existing entity_id in the database + # so we need to force the recorder to return the connection to the pool + # since our test setup only allows one connection at a time. + instance.queue_task(ForceReturnConnectionToPool()) + + await async_wait_recording_done(hass) + + hist = await instance.async_add_executor_job( + history.get_significant_states, + hass, + start, + None, + ["sensor.test1", "sensor.test99"], + ) + + assert "sensor.test1" not in hist + # Make sure the states manager has not leaked the old entity_id + assert instance.states_manager.pop_committed("sensor.test1") is None + assert instance.states_manager.pop_pending("sensor.test1") is None + + hass.states.async_set("sensor.test99", "post_migrate") + await async_wait_recording_done(hass) + + new_hist = await instance.async_add_executor_job( + history.get_significant_states, + hass, + start, + None, + ["sensor.test1", "sensor.test99"], + ) + + assert "sensor.test1" not in new_hist + assert new_hist["sensor.test99"][-1].state == "post_migrate" + + def _get_states_meta_counts(): + with session_scope(hass=hass) as session: + return _count_entity_id_in_states_meta( + hass, session, "sensor.test99" + ), _count_entity_id_in_states_meta(hass, session, "sensor.test1") + + test99_count, test1_count = await instance.async_add_executor_job( + _get_states_meta_counts + ) + assert test99_count == 1 + assert test1_count == 1 + + assert "the new entity_id is already in use" not in caplog.text + + +def test_rename_entity_collision( + hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture +) -> None: + """Test states meta is not migrated when there is a collision.""" + hass = hass_recorder() + setup_component(hass, "sensor", {}) + + entity_reg = mock_registry(hass) + + @callback + def add_entry(): + reg_entry = entity_reg.async_get_or_create( + "sensor", + "test", + "unique_0000", + suggested_object_id="test1", + ) + assert reg_entry.entity_id == "sensor.test1" + + hass.add_job(add_entry) + hass.block_till_done() + + zero, four, states = record_states(hass) + hist = history.get_significant_states(hass, zero, four) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + assert len(hist["sensor.test1"]) == 3 + + hass.states.set("sensor.test99", "collision") + hass.states.remove("sensor.test99") + + hass.block_till_done() + + # Rename entity sensor.test1 to sensor.test99 + @callback + def rename_entry(): + entity_reg.async_update_entity("sensor.test1", new_entity_id="sensor.test99") + + hass.add_job(rename_entry) + wait_recording_done(hass) + + # History is not migrated on collision + hist = history.get_significant_states(hass, zero, four) + assert len(hist["sensor.test1"]) == 3 + assert len(hist["sensor.test99"]) == 2 + + with session_scope(hass=hass) as session: + assert _count_entity_id_in_states_meta(hass, session, "sensor.test99") == 1 + + hass.states.set("sensor.test99", "post_migrate") + wait_recording_done(hass) + new_hist = history.get_significant_states(hass, zero, dt_util.utcnow()) + assert new_hist["sensor.test99"][-1].state == "post_migrate" + assert len(hist["sensor.test99"]) == 2 + + with session_scope(hass=hass) as session: + assert _count_entity_id_in_states_meta(hass, session, "sensor.test99") == 1 + assert _count_entity_id_in_states_meta(hass, session, "sensor.test1") == 1 + + assert "the new entity_id is already in use" in caplog.text diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index de75052d34..4863c6c054 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -5,7 +5,7 @@ from collections.abc import Callable from datetime import datetime, timedelta import importlib import sys -from unittest.mock import ANY, DEFAULT, MagicMock, patch, sentinel +from unittest.mock import ANY, DEFAULT, MagicMock, patch import py import pytest @@ -43,7 +43,6 @@ from homeassistant.components.recorder.table_managers.statistics_meta import ( ) from homeassistant.components.recorder.util import session_scope from homeassistant.components.sensor import UNIT_CONVERTERS -from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import recorder as recorder_helper @@ -54,6 +53,7 @@ from .common import ( assert_dict_of_states_equal_without_context_and_last_changed, async_wait_recording_done, do_adhoc_statistics, + record_states, statistics_during_period, wait_recording_done, ) @@ -1758,80 +1758,6 @@ async def test_validate_db_schema_fix_statistics_datetime_issue( modify_columns_mock.assert_called_once_with(ANY, ANY, table, modification) -def record_states(hass): - """Record some test states. - - We inject a bunch of state updates temperature sensors. - """ - mp = "media_player.test" - sns1 = "sensor.test1" - sns2 = "sensor.test2" - sns3 = "sensor.test3" - sns4 = "sensor.test4" - sns1_attr = { - "device_class": "temperature", - "state_class": "measurement", - "unit_of_measurement": UnitOfTemperature.CELSIUS, - } - sns2_attr = { - "device_class": "humidity", - "state_class": "measurement", - "unit_of_measurement": "%", - } - sns3_attr = {"device_class": "temperature"} - sns4_attr = {} - - def set_state(entity_id, state, **kwargs): - """Set the state.""" - hass.states.set(entity_id, state, **kwargs) - wait_recording_done(hass) - return hass.states.get(entity_id) - - zero = dt_util.utcnow() - one = zero + timedelta(seconds=1 * 5) - two = one + timedelta(seconds=15 * 5) - three = two + timedelta(seconds=30 * 5) - four = three + timedelta(seconds=15 * 5) - - states = {mp: [], sns1: [], sns2: [], sns3: [], sns4: []} - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=one - ): - states[mp].append( - set_state(mp, "idle", attributes={"media_title": str(sentinel.mt1)}) - ) - states[sns1].append(set_state(sns1, "10", attributes=sns1_attr)) - states[sns2].append(set_state(sns2, "10", attributes=sns2_attr)) - states[sns3].append(set_state(sns3, "10", attributes=sns3_attr)) - states[sns4].append(set_state(sns4, "10", attributes=sns4_attr)) - - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=one + timedelta(microseconds=1), - ): - states[mp].append( - set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt2)}) - ) - - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=two - ): - states[sns1].append(set_state(sns1, "15", attributes=sns1_attr)) - states[sns2].append(set_state(sns2, "15", attributes=sns2_attr)) - states[sns3].append(set_state(sns3, "15", attributes=sns3_attr)) - states[sns4].append(set_state(sns4, "15", attributes=sns4_attr)) - - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=three - ): - states[sns1].append(set_state(sns1, "20", attributes=sns1_attr)) - states[sns2].append(set_state(sns2, "20", attributes=sns2_attr)) - states[sns3].append(set_state(sns3, "20", attributes=sns3_attr)) - states[sns4].append(set_state(sns4, "20", attributes=sns4_attr)) - - return zero, four, states - - def test_cache_key_for_generate_statistics_during_period_stmt() -> None: """Test cache key for _generate_statistics_during_period_stmt.""" columns = select(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start_ts) From 939fce4607f51b144ff6568096dbd6571272684b Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 20 Mar 2023 04:35:16 +0100 Subject: [PATCH 1078/2810] Shield Reolink webhook callback from cancelation (#89798) * shield Reolink webhook callback from cancelation * Update homeassistant/components/reolink/host.py Co-authored-by: Paulus Schoutsen * fix styling * fix black * Revert to using asyncio.shield --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/reolink/host.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index e8e96ffe9b..9ba4809e90 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -327,6 +327,12 @@ class ReolinkHost: async def handle_webhook( self, hass: HomeAssistant, webhook_id: str, request: Request + ): + """Shield the incoming webhook callback from cancellation.""" + await asyncio.shield(self.handle_webhook_shielded(hass, webhook_id, request)) + + async def handle_webhook_shielded( + self, hass: HomeAssistant, webhook_id: str, request: Request ): """Handle incoming webhook from Reolink for inbound messages and calls.""" From 2039955ef7cd4326767942047047b99144ec2f00 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 20 Mar 2023 04:35:45 +0100 Subject: [PATCH 1079/2810] Fix imap_email_content unknown status and replaying stale states (#89563) --- .../components/imap_email_content/sensor.py | 74 ++++++++++++------- .../imap_email_content/test_sensor.py | 26 +++++-- 2 files changed, 70 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/imap_email_content/sensor.py b/homeassistant/components/imap_email_content/sensor.py index b14de63268..53cb921860 100644 --- a/homeassistant/components/imap_email_content/sensor.py +++ b/homeassistant/components/imap_email_content/sensor.py @@ -95,9 +95,25 @@ class EmailReader: self._folder = folder self._verify_ssl = verify_ssl self._last_id = None + self._last_message = None self._unread_ids = deque([]) self.connection = None + @property + def last_id(self) -> int | None: + """Return last email uid that was processed.""" + return self._last_id + + @property + def last_unread_id(self) -> int | None: + """Return last email uid received.""" + # We assume the last id in the list is the last unread id + # We cannot know if that is the newest one, because it could arrive later + # https://stackoverflow.com/questions/12409862/python-imap-the-order-of-uids + if self._unread_ids: + return int(self._unread_ids[-1]) + return self._last_id + def connect(self): """Login and setup the connection.""" ssl_context = client_context() if self._verify_ssl else None @@ -128,21 +144,21 @@ class EmailReader: try: self.connection.select(self._folder, readonly=True) - if not self._unread_ids: - search = f"SINCE {datetime.date.today():%d-%b-%Y}" - if self._last_id is not None: - search = f"UID {self._last_id}:*" - - _, data = self.connection.uid("search", None, search) - self._unread_ids = deque(data[0].split()) + if self._last_id is None: + # search for today and yesterday + time_from = datetime.datetime.now() - datetime.timedelta(days=1) + search = f"SINCE {time_from:%d-%b-%Y}" + else: + search = f"UID {self._last_id}:*" + _, data = self.connection.uid("search", None, search) + self._unread_ids = deque(data[0].split()) while self._unread_ids: message_uid = self._unread_ids.popleft() if self._last_id is None or int(message_uid) > self._last_id: self._last_id = int(message_uid) - return self._fetch_message(message_uid) - - return self._fetch_message(str(self._last_id)) + self._last_message = self._fetch_message(message_uid) + return self._last_message except imaplib.IMAP4.error: _LOGGER.info("Connection to %s lost, attempting to reconnect", self._server) @@ -254,22 +270,30 @@ class EmailContentSensor(SensorEntity): def update(self) -> None: """Read emails and publish state change.""" email_message = self._email_reader.read_next() + while ( + self._last_id is None or self._last_id != self._email_reader.last_unread_id + ): + if email_message is None: + self._message = None + self._state_attributes = {} + return - if email_message is None: - self._message = None - self._state_attributes = {} - return + self._last_id = self._email_reader.last_id - if self.sender_allowed(email_message): - message = EmailContentSensor.get_msg_subject(email_message) + if self.sender_allowed(email_message): + message = EmailContentSensor.get_msg_subject(email_message) - if self._value_template is not None: - message = self.render_template(email_message) + if self._value_template is not None: + message = self.render_template(email_message) - self._message = message - self._state_attributes = { - ATTR_FROM: EmailContentSensor.get_msg_sender(email_message), - ATTR_SUBJECT: EmailContentSensor.get_msg_subject(email_message), - ATTR_DATE: email_message["Date"], - ATTR_BODY: EmailContentSensor.get_msg_text(email_message), - } + self._message = message + self._state_attributes = { + ATTR_FROM: EmailContentSensor.get_msg_sender(email_message), + ATTR_SUBJECT: EmailContentSensor.get_msg_subject(email_message), + ATTR_DATE: email_message["Date"], + ATTR_BODY: EmailContentSensor.get_msg_text(email_message), + } + + if self._last_id == self._email_reader.last_unread_id: + break + email_message = self._email_reader.read_next() diff --git a/tests/components/imap_email_content/test_sensor.py b/tests/components/imap_email_content/test_sensor.py index afa6116ff4..ba2b362af7 100644 --- a/tests/components/imap_email_content/test_sensor.py +++ b/tests/components/imap_email_content/test_sensor.py @@ -14,9 +14,16 @@ from homeassistant.helpers.template import Template class FakeEMailReader: """A test class for sending test emails.""" - def __init__(self, messages): + def __init__(self, messages) -> None: """Set up the fake email reader.""" self._messages = messages + self.last_id = 0 + self.last_unread_id = len(messages) + + def add_test_message(self, message): + """Add a new message.""" + self.last_unread_id += 1 + self._messages.append(message) def connect(self): """Stay always Connected.""" @@ -26,6 +33,7 @@ class FakeEMailReader: """Get the next email.""" if len(self._messages) == 0: return None + self.last_id += 1 return self._messages.popleft() @@ -146,7 +154,7 @@ async def test_multi_part_only_other_text(hass: HomeAssistant) -> None: async def test_multiple_emails(hass: HomeAssistant) -> None: - """Test multiple emails.""" + """Test multiple emails, discarding stale states.""" states = [] test_message1 = email.message.Message() @@ -158,9 +166,15 @@ async def test_multiple_emails(hass: HomeAssistant) -> None: test_message2 = email.message.Message() test_message2["From"] = "sender@test.com" test_message2["Subject"] = "Test 2" - test_message2["Date"] = datetime.datetime(2016, 1, 1, 12, 44, 57) + test_message2["Date"] = datetime.datetime(2016, 1, 1, 12, 44, 58) test_message2.set_payload("Test Message 2") + test_message3 = email.message.Message() + test_message3["From"] = "sender@test.com" + test_message3["Subject"] = "Test 3" + test_message3["Date"] = datetime.datetime(2016, 1, 1, 12, 50, 1) + test_message3.set_payload("Test Message 2") + def state_changed_listener(entity_id, from_s, to_s): states.append(to_s) @@ -178,11 +192,13 @@ async def test_multiple_emails(hass: HomeAssistant) -> None: sensor.async_schedule_update_ha_state(True) await hass.async_block_till_done() + # Fake a new received message + sensor._email_reader.add_test_message(test_message3) sensor.async_schedule_update_ha_state(True) await hass.async_block_till_done() - assert states[0].state == "Test" - assert states[1].state == "Test 2" + assert states[0].state == "Test 2" + assert states[1].state == "Test 3" assert sensor.extra_state_attributes["body"] == "Test Message 2" From 9721ba59b6ae2f549d75f633b4c50855659cdb47 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 19 Mar 2023 20:42:12 -0700 Subject: [PATCH 1080/2810] Rewrite the calendar trigger to fix potential bugs (#89918) Update the calander event trigger logic to have more exhaustive coverage. The trigger will now use a timespan to create an explicit window for considering upcoming events. The start/end of the time span is now more explicit, rather than getting it from the alarm time. The trigger is now broken into composable pieces: - A timespan object for more explicitly managing the time window - A function to get events during a time span - A function to process upcoming events and determine the trigger times The existing listener is now just responsible for scheduling alarms and glue. This fixes bug with DST handling where the conversion back and forth between UTC and timezone ends up dropping events during the jump forward. In practice, an event was returned from the scanning, but it was never fired by the trigger because (1) it was filtered out of the interval and (2) the event list was previously cleared every iteration so it would get dropped. Future improvements can bake more invariant checking into this structure. --- homeassistant/components/calendar/trigger.py | 197 +++++++++++++------ tests/components/calendar/test_trigger.py | 66 ++++++- 2 files changed, 196 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/calendar/trigger.py b/homeassistant/components/calendar/trigger.py index 1e51c746e1..7807539413 100644 --- a/homeassistant/components/calendar/trigger.py +++ b/homeassistant/components/calendar/trigger.py @@ -1,7 +1,8 @@ """Offer calendar automation rules.""" from __future__ import annotations -from collections.abc import Coroutine +from collections.abc import Awaitable, Callable, Coroutine +from dataclasses import dataclass import datetime import logging from typing import Any @@ -14,7 +15,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import ( - async_track_point_in_utc_time, + async_track_point_in_time, async_track_time_interval, ) from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo @@ -41,34 +42,135 @@ TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( # mypy: disallow-any-generics +@dataclass +class QueuedCalendarEvent: + """An event that is queued to be fired in the future.""" + + trigger_time: datetime.datetime + event: CalendarEvent + + +@dataclass +class Timespan: + """A time range part of start/end dates, used for considering active events.""" + + start: datetime.datetime + """The start datetime of the interval.""" + + end: datetime.datetime + """The end datetime (exclusive) of the interval.""" + + def with_offset(self, offset: datetime.timedelta) -> Timespan: + """Return a new interval shifted by the specified offset.""" + return Timespan(self.start + offset, self.end + offset) + + def contains(self, trigger: datetime.datetime) -> bool: + """Return true if the trigger time is within the time span.""" + return self.start <= trigger < self.end + + def next_upcoming( + self, now: datetime.datetime, interval: datetime.timedelta + ) -> Timespan: + """Return a subsequent time span following the current time span. + + This effectively gives us a cursor like interface for advancing through + time using the interval as a hint. The returned span may have a + different interval than the one specified. For example, time span may + be longer during a daylight saving time transition, or may extend due to + drift if the current interval is old. The returned time span is + adjacent and non-overlapping. + """ + return Timespan(self.end, max(self.end, now) + interval) + + def __str__(self) -> str: + """Return a compact string representation.""" + return f"[{self.start}, {self.end})" + + +EventFetcher = Callable[[Timespan], Awaitable[list[CalendarEvent]]] +QueuedEventFetcher = Callable[[Timespan], Awaitable[list[QueuedCalendarEvent]]] + + +def event_fetcher(hass: HomeAssistant, entity: CalendarEntity) -> EventFetcher: + """Build an async_get_events wrapper to fetch events during a time span.""" + + async def async_get_events(timespan: Timespan) -> list[CalendarEvent]: + """Return events active in the specified time span.""" + # Expand by one second to make the end time exclusive + end_time = timespan.end + datetime.timedelta(seconds=1) + return await entity.async_get_events(hass, timespan.start, end_time) + + return async_get_events + + +def queued_event_fetcher( + fetcher: EventFetcher, event_type: str, offset: datetime.timedelta +) -> QueuedEventFetcher: + """Build a fetcher that produces a schedule of upcoming trigger events.""" + + def get_trigger_time(event: CalendarEvent) -> datetime.datetime: + if event_type == EVENT_START: + return event.start_datetime_local + return event.end_datetime_local + + async def async_get_events(timespan: Timespan) -> list[QueuedCalendarEvent]: + """Get calendar event triggers eligible to fire in the time span.""" + offset_timespan = timespan.with_offset(-1 * offset) + active_events = await fetcher(offset_timespan) + + # Determine the trigger eligibilty of events during this time span. + # Example: For an EVENT_END trigger the event may start during this + # time span, but need to be triggered later when the end happens. + results = [] + for trigger_time, event in zip( + map(get_trigger_time, active_events), active_events + ): + if not offset_timespan.contains(trigger_time): + continue + results.append(QueuedCalendarEvent(trigger_time + offset, event)) + _LOGGER.debug( + "Scan events @ %s%s found %s eligble of %s active", + offset_timespan, + f" (offset={offset})" if offset else "", + len(results), + len(active_events), + ) + results.sort(key=lambda x: x.trigger_time) + return results + + return async_get_events + + class CalendarEventListener: - """Helper class to listen to calendar events.""" + """Helper class to listen to calendar events. + + This listener will poll every UPDATE_INTERVAL to fetch a set of upcoming + calendar events in the upcoming window of time, putting them into a queue. + The queue is drained by scheduling an alarm for the next upcoming event + trigger time, one event at a time. + """ def __init__( self, hass: HomeAssistant, job: HassJob[..., Coroutine[Any, Any, None]], trigger_data: dict[str, Any], - entity: CalendarEntity, - event_type: str, - offset: datetime.timedelta, + fetcher: QueuedEventFetcher, ) -> None: """Initialize CalendarEventListener.""" self._hass = hass self._job = job self._trigger_data = trigger_data - self._entity = entity - self._offset = offset self._unsub_event: CALLBACK_TYPE | None = None self._unsub_refresh: CALLBACK_TYPE | None = None - # Upcoming set of events with their trigger time - self._events: list[tuple[datetime.datetime, CalendarEvent]] = [] - self._event_type = event_type + self._fetcher = fetcher + now = dt_util.now() + self._timespan = Timespan(now, now + UPDATE_INTERVAL) + self._events: list[QueuedCalendarEvent] = [] async def async_attach(self) -> None: """Attach a calendar event listener.""" - now = dt_util.utcnow() - await self._fetch_events(now) + self._events.extend(await self._fetcher(self._timespan)) self._unsub_refresh = async_track_time_interval( self._hass, self._handle_refresh, UPDATE_INTERVAL ) @@ -82,52 +184,19 @@ class CalendarEventListener: self._unsub_refresh() self._unsub_refresh = None - async def _fetch_events(self, last_endtime: datetime.datetime) -> None: - """Update the set of eligible events.""" - # Use a sliding window for selecting in scope events in the next interval. - # The event search range is offset, then the fire time of the returned events - # are offset again below. Event time ranges are exclusive so the end time - # is expanded by 1sec. - start_time = last_endtime - self._offset - end_time = start_time + UPDATE_INTERVAL + datetime.timedelta(seconds=1) - _LOGGER.debug( - "Fetching events between %s, %s (offset=%s)", - start_time, - end_time, - self._offset, - ) - events = await self._entity.async_get_events(self._hass, start_time, end_time) - - # Build list of events and the appropriate time to trigger an alarm. The - # returned events may have already started but matched the start/end time - # filtering above, so exclude any events that have already passed the - # trigger time. - event_list = [] - for event in events: - event_fire_time = ( - event.start_datetime_local - if self._event_type == EVENT_START - else event.end_datetime_local - ) - event_fire_time += self._offset - if event_fire_time > last_endtime: - event_list.append((event_fire_time, event)) - event_list.sort(key=lambda x: x[0]) - self._events = event_list - _LOGGER.debug("Populated event list %s", self._events) - @callback def _listen_next_calendar_event(self) -> None: """Set up the calendar event listener.""" if not self._events: return - (event_fire_time, _event) = self._events[0] - _LOGGER.debug("Scheduled alarm for %s", event_fire_time) - self._unsub_event = async_track_point_in_utc_time( + _LOGGER.debug( + "Scheduled next event trigger for %s", self._events[0].trigger_time + ) + self._unsub_event = async_track_point_in_time( self._hass, self._handle_calendar_event, - event_fire_time, + self._events[0].trigger_time, ) def _clear_event_listener(self) -> None: @@ -138,29 +207,36 @@ class CalendarEventListener: async def _handle_calendar_event(self, now: datetime.datetime) -> None: """Handle calendar event.""" - _LOGGER.debug("Calendar event @ %s", now) + _LOGGER.debug("Calendar event @ %s", dt_util.as_local(now)) self._dispatch_events(now) self._clear_event_listener() self._listen_next_calendar_event() def _dispatch_events(self, now: datetime.datetime) -> None: """Dispatch all events that are eligible to fire.""" - while self._events and self._events[0][0] <= now: - (_fire_time, event) = self._events.pop(0) - _LOGGER.debug("Event: %s", event) + while self._events and self._events[0].trigger_time <= now: + queued_event = self._events.pop(0) + _LOGGER.debug("Dispatching event: %s", queued_event.event) self._hass.async_run_hass_job( self._job, - {"trigger": {**self._trigger_data, "calendar_event": event.as_dict()}}, + { + "trigger": { + **self._trigger_data, + "calendar_event": queued_event.event.as_dict(), + } + }, ) - async def _handle_refresh(self, now: datetime.datetime) -> None: + async def _handle_refresh(self, now_utc: datetime.datetime) -> None: """Handle core config update.""" + now = dt_util.as_local(now_utc) _LOGGER.debug("Refresh events @ %s", now) # Dispatch any eligible events in the boundary case where refresh # fires before the calendar event. self._dispatch_events(now) self._clear_event_listener() - await self._fetch_events(now) + self._timespan = self._timespan.next_upcoming(now, UPDATE_INTERVAL) + self._events.extend(await self._fetcher(self._timespan)) self._listen_next_calendar_event() @@ -190,7 +266,10 @@ async def async_attach_trigger( "offset": offset, } listener = CalendarEventListener( - hass, HassJob(action), trigger_data, entity, event_type, offset + hass, + HassJob(action), + trigger_data, + queued_event_fetcher(event_fetcher(hass, entity), event_type, offset), ) await listener.async_attach() return listener.async_detach diff --git a/tests/components/calendar/test_trigger.py b/tests/components/calendar/test_trigger.py index 7885a4524c..9e15a1996d 100644 --- a/tests/components/calendar/test_trigger.py +++ b/tests/components/calendar/test_trigger.py @@ -14,6 +14,7 @@ import logging import secrets from typing import Any from unittest.mock import patch +import zoneinfo from freezegun.api import FrozenDateTimeFactory import pytest @@ -87,19 +88,17 @@ class FakeSchedule: """Get all events in a specific time frame, used by the demo calendar.""" assert start_date < end_date values = [] - local_start_date = dt_util.as_local(start_date) - local_end_date = dt_util.as_local(end_date) for event in self.events: - if ( - event.start_datetime_local < local_end_date - and local_start_date < event.end_datetime_local - ): - values.append(event) + if event.start_datetime_local >= end_date: + continue + if event.end_datetime_local < start_date: + continue + values.append(event) return values async def fire_time(self, trigger_time: datetime.datetime) -> None: """Fire an alarm and wait.""" - _LOGGER.debug(f"Firing alarm @ {trigger_time}") + _LOGGER.debug(f"Firing alarm @ {dt_util.as_local(trigger_time)}") self.freezer.move_to(trigger_time) async_fire_time_changed(self.hass, trigger_time) await self.hass.async_block_till_done() @@ -666,3 +665,54 @@ async def test_trigger_timestamp_window_edge( "calendar_event": event_data, } ] + + +async def test_event_start_trigger_dst( + hass: HomeAssistant, calls, fake_schedule, freezer +) -> None: + """Test a calendar event trigger happening at the start of daylight savings time.""" + tzinfo = zoneinfo.ZoneInfo("America/Los_Angeles") + hass.config.set_time_zone("America/Los_Angeles") + freezer.move_to("2023-03-12 01:00:00-08:00") + + # Before DST transition starts + event1_data = fake_schedule.create_event( + summary="Event 1", + start=datetime.datetime(2023, 3, 12, 1, 30, tzinfo=tzinfo), + end=datetime.datetime(2023, 3, 12, 1, 45, tzinfo=tzinfo), + ) + # During DST transition (Clocks are turned forward at 2am to 3am) + event2_data = fake_schedule.create_event( + summary="Event 2", + start=datetime.datetime(2023, 3, 12, 2, 30, tzinfo=tzinfo), + end=datetime.datetime(2023, 3, 12, 2, 45, tzinfo=tzinfo), + ) + # After DST transition has ended + event3_data = fake_schedule.create_event( + summary="Event 3", + start=datetime.datetime(2023, 3, 12, 3, 30, tzinfo=tzinfo), + end=datetime.datetime(2023, 3, 12, 3, 45, tzinfo=tzinfo), + ) + await create_automation(hass, EVENT_START) + assert len(calls()) == 0 + + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2023-03-12 05:00:00-08:00"), + ) + assert calls() == [ + { + "platform": "calendar", + "event": EVENT_START, + "calendar_event": event1_data, + }, + { + "platform": "calendar", + "event": EVENT_START, + "calendar_event": event2_data, + }, + { + "platform": "calendar", + "event": EVENT_START, + "calendar_event": event3_data, + }, + ] From e798c30b8b2332361951742dc4df12cc355ae596 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Mar 2023 18:06:23 -1000 Subject: [PATCH 1081/2810] Fix statistics schema auto repair when there is bad data (#89903) - If the user had previously duplicated data we could end up picking the next metadata_id and there could be stale rows in the database that have that metadata_id. This can only happen from bad manual migrations (which is what this is function is validating in the first place). To solve this we now insert data with a future date and look at the latest inserted row instead of the first. Example ``` ['stored_statistics', defaultdict(, {'recorder.db_test_schema': [{'end': 948589200.0, 'last_reset': None, 'max': None, 'mean': 2021.0, 'min': None, 'start': 948585600.0, 'state': None, 'sum': 394.5068}, {'end': 1601946000.000001, 'last_reset': 1601942400.000001, 'max': 1.000000000000001, 'mean': 1.000000000000001, 'min': 1.000000000000001, 'start': 1601942400.000001, 'state': 1.000000000000001, 'sum': 1.000000000000001}]})] ``` --- .../components/recorder/statistics.py | 28 +++++++++++++++---- tests/components/recorder/test_statistics.py | 7 ++++- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 2f93a8a833..b117556b0a 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -2527,6 +2527,11 @@ def _validate_db_schema_utf8( return schema_errors +def _get_future_year() -> int: + """Get a year in the future.""" + return datetime.now().year + 1 + + def _validate_db_schema( hass: HomeAssistant, instance: Recorder, session_maker: Callable[[], Session] ) -> set[str]: @@ -2544,9 +2549,16 @@ def _validate_db_schema( # This number can't be accurately represented as a 32-bit float precise_number = 1.000000000000001 # This time can't be accurately represented unless datetimes have µs precision - precise_time = datetime(2020, 10, 6, microsecond=1, tzinfo=dt_util.UTC) - - start_time = datetime(2020, 10, 6, tzinfo=dt_util.UTC) + # + # We want to insert statistics for a time in the future, in case they + # have conflicting metadata_id's with existing statistics that were + # never cleaned up. By inserting in the future, we can be sure that + # that by selecting the last inserted row, we will get the one we + # just inserted. + # + future_year = _get_future_year() + precise_time = datetime(future_year, 10, 6, microsecond=1, tzinfo=dt_util.UTC) + start_time = datetime(future_year, 10, 6, tzinfo=dt_util.UTC) statistic_id = f"{DOMAIN}.db_test" metadata: StatisticMetaData = { @@ -2614,9 +2626,15 @@ def _validate_db_schema( ) continue + # We want to look at the last inserted row to make sure there + # is not previous garbage data in the table that would cause + # the test to produce an incorrect result. To achieve this, + # we inserted a row in the future, and now we select the last + # inserted row back. + last_stored_statistic = stored_statistic[-1] check_columns( schema_errors, - stored_statistic[0], + last_stored_statistic, statistics, ("max", "mean", "min", "state", "sum"), table.__tablename__, @@ -2625,7 +2643,7 @@ def _validate_db_schema( assert statistics["last_reset"] check_columns( schema_errors, - stored_statistic[0], + last_stored_statistic, { "last_reset": datetime_to_timestamp_or_none( statistics["last_reset"] diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 4863c6c054..ad4d0de410 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -26,6 +26,7 @@ from homeassistant.components.recorder.statistics import ( _generate_max_mean_min_statistic_in_sub_period_stmt, _generate_statistics_at_time_stmt, _generate_statistics_during_period_stmt, + _get_future_year, _statistics_during_period_with_session, async_add_external_statistics, async_import_statistics, @@ -1633,7 +1634,8 @@ async def test_validate_db_schema_fix_float_issue( orig_error = MagicMock() orig_error.args = [1366] precise_number = 1.000000000000001 - precise_time = datetime(2020, 10, 6, microsecond=1, tzinfo=dt_util.UTC) + fixed_future_year = _get_future_year() + precise_time = datetime(fixed_future_year, 10, 6, microsecond=1, tzinfo=dt_util.UTC) statistics = { "recorder.db_test": [ { @@ -1653,6 +1655,9 @@ async def test_validate_db_schema_fix_float_issue( with patch( "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine + ), patch( + "homeassistant.components.recorder.statistics._get_future_year", + return_value=fixed_future_year, ), patch( "homeassistant.components.recorder.statistics._statistics_during_period_with_session", side_effect=fake_statistics, From d33a303a83bc813c8abc35cb755e89773afb5e2d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Mar 2023 18:06:37 -1000 Subject: [PATCH 1082/2810] =?UTF-8?q?Fix=20statistics=20schema=20=C2=B5s?= =?UTF-8?q?=20precision=20auto=20repair=20being=20ineffective=20(#89902)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If a user manually migrated their database to MySQL or PostgresSQL and incorrectly created the timestamp columns as float we would fail to correct them to double because when we migrated to use timestamps for the columns I missed that we needed to change the columns and types for µs precision --- homeassistant/components/recorder/statistics.py | 14 +++++--------- tests/components/recorder/test_statistics.py | 6 +++--- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index b117556b0a..989dc06db5 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -2590,8 +2590,8 @@ def _validate_db_schema( for column in columns: if stored[column] != expected[column]: schema_errors.add(f"{table_name}.{supports}") - _LOGGER.debug( - "Column %s in database table %s does not support %s (%s != %s)", + _LOGGER.error( + "Column %s in database table %s does not support %s (stored=%s != expected=%s)", column, table_name, supports, @@ -2727,18 +2727,14 @@ def correct_db_schema( ], ) if f"{table.__tablename__}.µs precision" in schema_errors: - # Attempt to convert datetime columns to µs precision - if instance.dialect_name == SupportedDialect.MYSQL: - datetime_type = "DATETIME(6)" - else: - datetime_type = "TIMESTAMP(6) WITH TIME ZONE" + # Attempt to convert timestamp columns to µs precision _modify_columns( session_maker, engine, table.__tablename__, [ - f"last_reset {datetime_type}", - f"start {datetime_type}", + "last_reset_ts DOUBLE PRECISION", + "start_ts DOUBLE PRECISION", ], ) diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index ad4d0de410..d783d72be2 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -1687,12 +1687,12 @@ async def test_validate_db_schema_fix_float_issue( @pytest.mark.parametrize( ("db_engine", "modification"), ( - ("mysql", ["last_reset DATETIME(6)", "start DATETIME(6)"]), + ("mysql", ["last_reset_ts DOUBLE PRECISION", "start_ts DOUBLE PRECISION"]), ( "postgresql", [ - "last_reset TIMESTAMP(6) WITH TIME ZONE", - "start TIMESTAMP(6) WITH TIME ZONE", + "last_reset_ts DOUBLE PRECISION", + "start_ts DOUBLE PRECISION", ], ), ), From f62bb0e2eaea4e187b9a81df7265422fd3837e26 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Mar 2023 22:32:21 -1000 Subject: [PATCH 1083/2810] Bump zeroconf to 0.47.4 (#89973) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 02b5982e58..b7a643bb46 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.47.3"] + "requirements": ["zeroconf==0.47.4"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 566dcc61b2..22c2c289c6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -48,7 +48,7 @@ ulid-transform==0.4.2 voluptuous-serialize==2.6.0 voluptuous==0.13.1 yarl==1.8.1 -zeroconf==0.47.3 +zeroconf==0.47.4 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index 41b2b14523..923103ec10 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2700,7 +2700,7 @@ zamg==0.2.2 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.47.3 +zeroconf==0.47.4 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ef0c26dc8f..2a2af951dc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1925,7 +1925,7 @@ youless-api==1.0.1 zamg==0.2.2 # homeassistant.components.zeroconf -zeroconf==0.47.3 +zeroconf==0.47.4 # homeassistant.components.zeversolar zeversolar==0.3.1 From 9a784fddef7c6654969d38bdfab308c0edd87fb2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 20 Mar 2023 10:20:19 +0100 Subject: [PATCH 1084/2810] Fail CI on lingering timers (#89292) --- tests/conftest.py | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 08ca75829c..5a1c44b781 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,6 +9,7 @@ import functools import gc import itertools import logging +import os import sqlite3 import ssl import threading @@ -262,6 +263,22 @@ def expected_lingering_tasks() -> bool: return False +@pytest.fixture(autouse=True) +def expected_lingering_timers() -> bool: + """Temporary ability to bypass test failures. + + Parametrize to True to bypass the pytest failure. + @pytest.mark.parametrize("expected_lingering_timers", [True]) + + This should be removed when all lingering timers have been cleaned up. + """ + current_test = os.getenv("PYTEST_CURRENT_TEST") + if current_test and current_test.startswith("tests/components"): + # As a starting point, we ignore components + return True + return False + + @pytest.fixture def wait_for_stop_scripts_after_shutdown() -> bool: """Add ability to bypass _schedule_stop_scripts_after_shutdown. @@ -291,7 +308,9 @@ def skip_stop_scripts( @pytest.fixture(autouse=True) def verify_cleanup( - event_loop: asyncio.AbstractEventLoop, expected_lingering_tasks: bool + event_loop: asyncio.AbstractEventLoop, + expected_lingering_tasks: bool, + expected_lingering_timers: bool, ) -> Generator[None, None, None]: """Verify that the test has cleaned up resources correctly.""" threads_before = frozenset(threading.enumerate()) @@ -311,16 +330,19 @@ def verify_cleanup( tasks = asyncio.all_tasks(event_loop) - tasks_before for task in tasks: if expected_lingering_tasks: - _LOGGER.warning("Linger task after test %r", task) + _LOGGER.warning("Lingering task after test %r", task) else: - pytest.fail(f"Linger task after test {repr(task)}") + pytest.fail(f"Lingering task after test {repr(task)}") task.cancel() if tasks: event_loop.run_until_complete(asyncio.wait(tasks)) for handle in event_loop._scheduled: # type: ignore[attr-defined] if not handle.cancelled(): - _LOGGER.warning("Lingering timer after test %r", handle) + if expected_lingering_timers: + _LOGGER.warning("Lingering timer after test %r", handle) + else: + pytest.fail(f"Lingering timer after test {repr(handle)}") handle.cancel() # Verify no threads where left behind. From c4ee35570d98a231713fe13bea93a0bd3b77b1c8 Mon Sep 17 00:00:00 2001 From: Jesse Moody Date: Mon, 20 Mar 2023 05:27:55 -0400 Subject: [PATCH 1085/2810] Update django github references to main instead of master branch. (#89951) --- homeassistant/util/dt.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index 09a7923aaa..2dfc9a6f62 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -24,7 +24,7 @@ EPOCHORDINAL = dt.datetime(1970, 1, 1).toordinal() # Copyright (c) Django Software Foundation and individual contributors. # All rights reserved. -# https://github.com/django/django/blob/master/LICENSE +# https://github.com/django/django/blob/main/LICENSE DATETIME_RE = re.compile( r"(?P\d{4})-(?P\d{1,2})-(?P\d{1,2})" r"[T ](?P\d{1,2}):(?P\d{1,2})" @@ -34,7 +34,7 @@ DATETIME_RE = re.compile( # Copyright (c) Django Software Foundation and individual contributors. # All rights reserved. -# https://github.com/django/django/blob/master/LICENSE +# https://github.com/django/django/blob/main/LICENSE STANDARD_DURATION_RE = re.compile( r"^" r"(?:(?P-?\d+) (days?, )?)?" @@ -48,7 +48,7 @@ STANDARD_DURATION_RE = re.compile( # Copyright (c) Django Software Foundation and individual contributors. # All rights reserved. -# https://github.com/django/django/blob/master/LICENSE +# https://github.com/django/django/blob/main/LICENSE ISO8601_DURATION_RE = re.compile( r"^(?P[-+]?)" r"P" @@ -63,7 +63,7 @@ ISO8601_DURATION_RE = re.compile( # Copyright (c) Django Software Foundation and individual contributors. # All rights reserved. -# https://github.com/django/django/blob/master/LICENSE +# https://github.com/django/django/blob/main/LICENSE POSTGRES_INTERVAL_RE = re.compile( r"^" r"(?:(?P-?\d+) (days? ?))?" @@ -178,7 +178,7 @@ def start_of_local_day(dt_or_d: dt.date | dt.datetime | None = None) -> dt.datet # Copyright (c) Django Software Foundation and individual contributors. # All rights reserved. -# https://github.com/django/django/blob/master/LICENSE +# https://github.com/django/django/blob/main/LICENSE def parse_datetime(dt_str: str) -> dt.datetime | None: """Parse a string and return a datetime.datetime. From f3b3818d1fff27f885c278b8b93fa92a35a445c2 Mon Sep 17 00:00:00 2001 From: micha91 Date: Mon, 20 Mar 2023 11:59:27 +0100 Subject: [PATCH 1086/2810] Bump aiomusiccast to 0.14.8 (#89978) --- homeassistant/components/yamaha_musiccast/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yamaha_musiccast/manifest.json b/homeassistant/components/yamaha_musiccast/manifest.json index 9a19f61eb4..48b8de2060 100644 --- a/homeassistant/components/yamaha_musiccast/manifest.json +++ b/homeassistant/components/yamaha_musiccast/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/yamaha_musiccast", "iot_class": "local_push", "loggers": ["aiomusiccast"], - "requirements": ["aiomusiccast==0.14.7"], + "requirements": ["aiomusiccast==0.14.8"], "ssdp": [ { "manufacturer": "Yamaha Corporation" diff --git a/requirements_all.txt b/requirements_all.txt index 923103ec10..26d92aad91 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -214,7 +214,7 @@ aiolyric==1.0.9 aiomodernforms==0.1.8 # homeassistant.components.yamaha_musiccast -aiomusiccast==0.14.7 +aiomusiccast==0.14.8 # homeassistant.components.nanoleaf aionanoleaf==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2a2af951dc..342d006754 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -198,7 +198,7 @@ aiolyric==1.0.9 aiomodernforms==0.1.8 # homeassistant.components.yamaha_musiccast -aiomusiccast==0.14.7 +aiomusiccast==0.14.8 # homeassistant.components.nanoleaf aionanoleaf==0.2.1 From c3043fb0ee1ba292a8d0df67f407e2ad42e4d739 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Mar 2023 01:06:15 -1000 Subject: [PATCH 1087/2810] Bump bluetooth deps for bleak 0.20 (#89925) Co-authored-by: K --- .../components/bluetooth/base_scanner.py | 14 +- .../components/bluetooth/manifest.json | 8 +- .../components/bluetooth/wrappers.py | 24 ++- .../components/esphome/bluetooth/client.py | 6 +- homeassistant/package_constraints.txt | 8 +- requirements_all.txt | 8 +- requirements_test_all.txt | 8 +- tests/components/airthings_ble/__init__.py | 7 +- tests/components/aranet/__init__.py | 5 +- tests/components/bluetooth/__init__.py | 33 +++- .../bluetooth/test_advertisement_tracker.py | 16 +- tests/components/bluetooth/test_api.py | 12 +- .../components/bluetooth/test_base_scanner.py | 19 ++- .../components/bluetooth/test_diagnostics.py | 17 ++- tests/components/bluetooth/test_init.py | 143 ++++++++++-------- tests/components/bluetooth/test_manager.py | 69 +++++---- tests/components/bluetooth/test_models.py | 31 ++-- tests/components/bluetooth/test_scanner.py | 11 +- tests/components/bluetooth/test_usage.py | 11 +- tests/components/bluetooth/test_wrappers.py | 4 +- .../test_device_tracker.py | 13 +- tests/components/bthome/__init__.py | 19 ++- tests/components/dormakaba_dkey/__init__.py | 7 +- tests/components/fjaraskupan/__init__.py | 6 +- tests/components/ibeacon/__init__.py | 10 +- tests/components/ibeacon/test_coordinator.py | 4 +- tests/components/keymitt_ble/__init__.py | 6 +- tests/components/ld2410_ble/__init__.py | 7 +- tests/components/led_ble/__init__.py | 13 +- tests/components/melnor/conftest.py | 7 +- tests/components/oralb/__init__.py | 5 +- tests/components/snooz/__init__.py | 4 +- tests/components/switchbot/__init__.py | 20 ++- tests/components/xiaomi_ble/__init__.py | 19 ++- tests/components/yalexs_ble/__init__.py | 11 +- 35 files changed, 347 insertions(+), 258 deletions(-) diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index 903f14a922..1c16639d61 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -165,13 +165,13 @@ class BaseHaScanner(ABC): "monotonic_time": MONOTONIC_TIME(), "discovered_devices_and_advertisement_data": [ { - "name": device_adv[0].name, - "address": device_adv[0].address, - "rssi": device_adv[0].rssi, - "advertisement_data": device_adv[1], - "details": device_adv[0].details, + "name": device.name, + "address": device.address, + "rssi": advertisement_data.rssi, + "advertisement_data": advertisement_data, + "details": device.details, } - for device_adv in device_adv_datas + for device, advertisement_data in device_adv_datas ], } @@ -339,7 +339,7 @@ class BaseHaRemoteScanner(BaseHaScanner): tx_power=NO_RSSI_VALUE if tx_power is None else tx_power, platform_data=(), ) - device = BLEDevice( # type: ignore[no-untyped-call] + device = BLEDevice( address=address, name=local_name, details=self._details | details, diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 8331117c9c..f6cbe5b3e5 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -15,11 +15,11 @@ ], "quality_scale": "internal", "requirements": [ - "bleak==0.19.5", - "bleak-retry-connector==2.13.0", - "bluetooth-adapters==0.15.2", + "bleak==0.20.0", + "bleak-retry-connector==3.0.1", + "bluetooth-adapters==0.15.3", "bluetooth-auto-recovery==1.0.3", "bluetooth-data-tools==0.3.1", - "dbus-fast==1.84.1" + "dbus-fast==1.84.2" ] } diff --git a/homeassistant/components/bluetooth/wrappers.py b/homeassistant/components/bluetooth/wrappers.py index 6b463423c7..cf17796105 100644 --- a/homeassistant/components/bluetooth/wrappers.py +++ b/homeassistant/components/bluetooth/wrappers.py @@ -224,10 +224,28 @@ class HaBleakClientWrapper(BleakClient): self.__disconnected_callback = callback if self._backend: self._backend.set_disconnected_callback( - callback, # type: ignore[arg-type] + self._make_disconnected_callback(callback), **kwargs, ) + def _make_disconnected_callback( + self, callback: Callable[[BleakClient], None] | None + ) -> Callable[[], None] | None: + """Make the disconnected callback. + + https://github.com/hbldh/bleak/pull/1256 + The disconnected callback needs to get the top level + BleakClientWrapper instance, not the backend instance. + + The signature of the callback for the backend is: + Callable[[], None] + + To make this work we need to wrap the callback in a partial + that passes the BleakClientWrapper instance as the first + argument. + """ + return None if callback is None else partial(callback, self) + async def connect(self, **kwargs: Any) -> bool: """Connect to the specified GATT server.""" assert models.MANAGER is not None @@ -235,7 +253,9 @@ class HaBleakClientWrapper(BleakClient): wrapped_backend = self._async_get_best_available_backend_and_device(manager) self._backend = wrapped_backend.client( wrapped_backend.device, - disconnected_callback=self.__disconnected_callback, + disconnected_callback=self._make_disconnected_callback( + self.__disconnected_callback + ), timeout=self.__timeout, hass=manager.hass, ) diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index 343847f55f..71d081ff6a 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -223,7 +223,7 @@ class ESPHomeClient(BaseBleakClient): def _async_call_bleak_disconnected_callback(self) -> None: """Call the disconnected callback to inform the bleak consumer.""" if self._disconnected_callback: - self._disconnected_callback(self) + self._disconnected_callback() self._disconnected_callback = None @api_error_as_bleak_error @@ -499,8 +499,10 @@ class ESPHomeClient(BaseBleakClient): self, char_specifier: BleakGATTCharacteristic | int | str | uuid.UUID ) -> BleakGATTCharacteristic: """Resolve a characteristic specifier to a BleakGATTCharacteristic object.""" + if (services := self.services) is None: + raise BleakError("Services have not been resolved") if not isinstance(char_specifier, BleakGATTCharacteristic): - characteristic = self.services.get_characteristic(char_specifier) + characteristic = services.get_characteristic(char_specifier) else: characteristic = char_specifier if not characteristic: diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 22c2c289c6..29665559e4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -10,15 +10,15 @@ atomicwrites-homeassistant==1.4.1 attrs==22.2.0 awesomeversion==22.9.0 bcrypt==4.0.1 -bleak-retry-connector==2.13.0 -bleak==0.19.5 -bluetooth-adapters==0.15.2 +bleak-retry-connector==3.0.1 +bleak==0.20.0 +bluetooth-adapters==0.15.3 bluetooth-auto-recovery==1.0.3 bluetooth-data-tools==0.3.1 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==39.0.1 -dbus-fast==1.84.1 +dbus-fast==1.84.2 fnvhash==0.1.0 hass-nabucasa==0.61.1 hassil==1.0.6 diff --git a/requirements_all.txt b/requirements_all.txt index 26d92aad91..be03dac8c0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -431,10 +431,10 @@ bimmer_connected==0.12.1 bizkaibus==0.1.1 # homeassistant.components.bluetooth -bleak-retry-connector==2.13.0 +bleak-retry-connector==3.0.1 # homeassistant.components.bluetooth -bleak==0.19.5 +bleak==0.20.0 # homeassistant.components.blebox blebox_uniapi==2.1.4 @@ -456,7 +456,7 @@ bluemaestro-ble==0.2.3 # bluepy==1.3.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.15.2 +bluetooth-adapters==0.15.3 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.0.3 @@ -563,7 +563,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.84.1 +dbus-fast==1.84.2 # homeassistant.components.debugpy debugpy==1.6.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 342d006754..38d1075771 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -361,10 +361,10 @@ bellows==0.34.10 bimmer_connected==0.12.1 # homeassistant.components.bluetooth -bleak-retry-connector==2.13.0 +bleak-retry-connector==3.0.1 # homeassistant.components.bluetooth -bleak==0.19.5 +bleak==0.20.0 # homeassistant.components.blebox blebox_uniapi==2.1.4 @@ -376,7 +376,7 @@ blinkpy==0.19.2 bluemaestro-ble==0.2.3 # homeassistant.components.bluetooth -bluetooth-adapters==0.15.2 +bluetooth-adapters==0.15.3 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.0.3 @@ -449,7 +449,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.84.1 +dbus-fast==1.84.2 # homeassistant.components.debugpy debugpy==1.6.6 diff --git a/tests/components/airthings_ble/__init__.py b/tests/components/airthings_ble/__init__.py index 7f8df35f26..71875b9c4b 100644 --- a/tests/components/airthings_ble/__init__.py +++ b/tests/components/airthings_ble/__init__.py @@ -4,11 +4,10 @@ from __future__ import annotations from unittest.mock import patch from airthings_ble import AirthingsBluetoothDeviceData, AirthingsDevice -from bleak.backends.device import BLEDevice from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak -from tests.components.bluetooth import generate_advertisement_data +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device def patch_async_setup_entry(return_value=True): @@ -45,7 +44,7 @@ WAVE_SERVICE_INFO = BluetoothServiceInfoBleak( service_data={}, service_uuids=["b42e1c08-ade7-11e4-89d3-123b93f75cba"], source="local", - device=BLEDevice( + device=generate_ble_device( "cc:cc:cc:cc:cc:cc", "cc-cc-cc-cc-cc-cc", ), @@ -65,7 +64,7 @@ UNKNOWN_SERVICE_INFO = BluetoothServiceInfoBleak( service_data={}, service_uuids=[], source="local", - device=BLEDevice( + device=generate_ble_device( "cc:cc:cc:cc:cc:cc", "unknown", ), diff --git a/tests/components/aranet/__init__.py b/tests/components/aranet/__init__.py index 2fe27329bd..c85748abea 100644 --- a/tests/components/aranet/__init__.py +++ b/tests/components/aranet/__init__.py @@ -2,11 +2,12 @@ from time import time -from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from homeassistant.components.bluetooth import BluetoothServiceInfoBleak +from tests.components.bluetooth import generate_ble_device + def fake_service_info(name, service_uuid, manufacturer_data): """Return a BluetoothServiceInfoBleak for use in testing.""" @@ -20,7 +21,7 @@ def fake_service_info(name, service_uuid, manufacturer_data): source="local", connectable=False, time=time(), - device=BLEDevice("aa:bb:cc:dd:ee:ff", name=name), + device=generate_ble_device("aa:bb:cc:dd:ee:ff", name=name), advertisement=AdvertisementData( local_name=name, manufacturer_data=manufacturer_data, diff --git a/tests/components/bluetooth/__init__.py b/tests/components/bluetooth/__init__.py index 91016206e8..3aedd6f2de 100644 --- a/tests/components/bluetooth/__init__.py +++ b/tests/components/bluetooth/__init__.py @@ -33,6 +33,7 @@ __all__ = ( "patch_all_discovered_devices", "patch_discovered_devices", "generate_advertisement_data", + "generate_ble_device", "MockBleakClient", ) @@ -46,6 +47,12 @@ ADVERTISEMENT_DATA_DEFAULTS = { "tx_power": -127, } +BLE_DEVICE_DEFAULTS = { + "name": None, + "rssi": -127, + "details": None, +} + def generate_advertisement_data(**kwargs: Any) -> AdvertisementData: """Generate advertisement data with defaults.""" @@ -55,6 +62,28 @@ def generate_advertisement_data(**kwargs: Any) -> AdvertisementData: return AdvertisementData(**new) +def generate_ble_device( + address: str | None = None, + name: str | None = None, + details: Any | None = None, + rssi: int | None = None, + **kwargs: Any, +) -> BLEDevice: + """Generate a BLEDevice with defaults.""" + new = kwargs.copy() + if address is not None: + new["address"] = address + if name is not None: + new["name"] = name + if details is not None: + new["details"] = details + if rssi is not None: + new["rssi"] = rssi + for key, value in BLE_DEVICE_DEFAULTS.items(): + new.setdefault(key, value) + return BLEDevice(**new) + + def _get_manager() -> BluetoothManager: """Return the bluetooth manager.""" return models.MANAGER @@ -126,7 +155,7 @@ def inject_bluetooth_service_info_bleak( service_uuids=info.service_uuids, rssi=info.rssi, ) - device = BLEDevice( # type: ignore[no-untyped-call] + device = generate_ble_device( # type: ignore[no-untyped-call] address=info.address, name=info.name, details={}, @@ -152,7 +181,7 @@ def inject_bluetooth_service_info( service_uuids=info.service_uuids, rssi=info.rssi, ) - device = BLEDevice( # type: ignore[no-untyped-call] + device = generate_ble_device( # type: ignore[no-untyped-call] address=info.address, name=info.name, details={}, diff --git a/tests/components/bluetooth/test_advertisement_tracker.py b/tests/components/bluetooth/test_advertisement_tracker.py index 88106a029d..5a2c55259b 100644 --- a/tests/components/bluetooth/test_advertisement_tracker.py +++ b/tests/components/bluetooth/test_advertisement_tracker.py @@ -3,7 +3,6 @@ from datetime import timedelta import time from unittest.mock import patch -from bleak.backends.scanner import BLEDevice import pytest from homeassistant.components.bluetooth import ( @@ -24,6 +23,7 @@ from homeassistant.util import dt as dt_util from . import ( FakeScanner, generate_advertisement_data, + generate_ble_device, inject_advertisement_with_time_and_source, inject_advertisement_with_time_and_source_connectable, ) @@ -41,7 +41,7 @@ async def test_advertisment_interval_shorter_than_adapter_stack_timeout( ) -> None: """Test we can determine the advertisement interval.""" start_monotonic_time = time.monotonic() - switchbot_device = BLEDevice("44:44:33:11:23:12", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:12", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) @@ -88,7 +88,7 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_connectab ) -> None: """Test device with a long advertisement interval.""" start_monotonic_time = time.monotonic() - switchbot_device = BLEDevice("44:44:33:11:23:18", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:18", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) @@ -137,7 +137,7 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c ) -> None: """Test device with a long advertisement interval with an adapter change.""" start_monotonic_time = time.monotonic() - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) @@ -195,7 +195,7 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_not_conne ) -> None: """Test device with a long advertisement interval that is not connectable not reaching the advertising interval.""" start_monotonic_time = time.monotonic() - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) @@ -247,7 +247,7 @@ async def test_advertisment_interval_shorter_than_adapter_stack_timeout_adapter_ ) -> None: """Test device with a short advertisement interval with an adapter change that is not connectable.""" start_monotonic_time = time.monotonic() - switchbot_device = BLEDevice("44:44:33:11:23:5C", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:5C", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -315,7 +315,7 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c ) -> None: """Test device with a long advertisement interval with an adapter change that is not connectable.""" start_monotonic_time = time.monotonic() - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -416,7 +416,7 @@ async def test_advertisment_interval_longer_increasing_than_adapter_stack_timeou ) -> None: """Test device with a increasing advertisement interval with an adapter change that is not connectable.""" start_monotonic_time = time.monotonic() - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) diff --git a/tests/components/bluetooth/test_api.py b/tests/components/bluetooth/test_api.py index 3aaa2ce7ef..77d802264e 100644 --- a/tests/components/bluetooth/test_api.py +++ b/tests/components/bluetooth/test_api.py @@ -11,7 +11,13 @@ from homeassistant.components.bluetooth import ( ) from homeassistant.core import HomeAssistant -from . import FakeScanner, MockBleakClient, _get_manager, generate_advertisement_data +from . import ( + FakeScanner, + MockBleakClient, + _get_manager, + generate_advertisement_data, + generate_ble_device, +) async def test_scanner_by_source(hass: HomeAssistant, enable_bluetooth: None) -> None: @@ -56,7 +62,7 @@ async def test_async_scanner_devices_by_address_connectable( ) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner, True) - switchbot_device = BLEDevice( + switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, @@ -89,7 +95,7 @@ async def test_async_scanner_devices_by_address_non_connectable( ) -> None: """Test getting scanner devices by address with non-connectable devices.""" manager = _get_manager() - switchbot_device = BLEDevice( + switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index 14722d81ae..79a36630df 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -29,7 +29,12 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from homeassistant.util.json import json_loads -from . import MockBleakClient, _get_manager, generate_advertisement_data +from . import ( + MockBleakClient, + _get_manager, + generate_advertisement_data, + generate_ble_device, +) from tests.common import async_fire_time_changed, load_fixture @@ -38,7 +43,7 @@ async def test_remote_scanner(hass: HomeAssistant, enable_bluetooth: None) -> No """Test the remote scanner base class merges advertisement_data.""" manager = _get_manager() - switchbot_device = BLEDevice( + switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, @@ -51,7 +56,7 @@ async def test_remote_scanner(hass: HomeAssistant, enable_bluetooth: None) -> No manufacturer_data={1: b"\x01"}, rssi=-100, ) - switchbot_device_2 = BLEDevice( + switchbot_device_2 = generate_ble_device( "44:44:33:11:23:45", "w", {}, @@ -126,7 +131,7 @@ async def test_remote_scanner_expires_connectable( """Test the remote scanner expires stale connectable data.""" manager = _get_manager() - switchbot_device = BLEDevice( + switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, @@ -200,7 +205,7 @@ async def test_remote_scanner_expires_non_connectable( """Test the remote scanner expires stale non connectable data.""" manager = _get_manager() - switchbot_device = BLEDevice( + switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, @@ -297,7 +302,7 @@ async def test_base_scanner_connecting_behavior( """Test that the default behavior is to mark the scanner as not scanning when connecting.""" manager = _get_manager() - switchbot_device = BLEDevice( + switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, @@ -420,7 +425,7 @@ async def test_device_with_ten_minute_advertising_interval( """Test a device with a 10 minute advertising interval.""" manager = _get_manager() - bparasite_device = BLEDevice( + bparasite_device = generate_ble_device( "44:44:33:11:23:45", "bparasite", {}, diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index ced401417e..7ffd3f0013 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -12,6 +12,7 @@ from . import ( MockBleakClient, _get_manager, generate_advertisement_data, + generate_ble_device, inject_advertisement, ) @@ -37,7 +38,7 @@ async def test_diagnostics( "homeassistant.components.bluetooth.scanner.HaScanner.discovered_devices_and_advertisement_data", { "44:44:33:11:23:45": ( - BLEDevice(name="x", rssi=-60, address="44:44:33:11:23:45"), + generate_ble_device(name="x", rssi=-127, address="44:44:33:11:23:45"), generate_advertisement_data(local_name="x"), ) }, @@ -174,7 +175,7 @@ async def test_diagnostics( ], "details": None, "name": "x", - "rssi": -60, + "rssi": -127, } ], "last_detection": ANY, @@ -201,7 +202,7 @@ async def test_diagnostics( ], "details": None, "name": "x", - "rssi": -60, + "rssi": -127, } ], "last_detection": ANY, @@ -228,7 +229,7 @@ async def test_diagnostics( ], "details": None, "name": "x", - "rssi": -60, + "rssi": -127, } ], "last_detection": ANY, @@ -257,7 +258,7 @@ async def test_diagnostics_macos( # because we cannot import the scanner class directly without it throwing an # error if the test is not running on linux since we won't have the correct # deps installed when testing on MacOS. - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) @@ -266,7 +267,7 @@ async def test_diagnostics_macos( "homeassistant.components.bluetooth.scanner.HaScanner.discovered_devices_and_advertisement_data", { "44:44:33:11:23:45": ( - BLEDevice(name="x", rssi=-60, address="44:44:33:11:23:45"), + generate_ble_device(name="x", rssi=-127, address="44:44:33:11:23:45"), switchbot_adv, ) }, @@ -404,7 +405,7 @@ async def test_diagnostics_macos( ], "details": None, "name": "x", - "rssi": -60, + "rssi": -127, } ], "last_detection": ANY, @@ -430,7 +431,7 @@ async def test_diagnostics_remote_adapter( ) -> None: """Test diagnostics for remote adapter.""" manager = _get_manager() - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index 5bb1eeb977..66ef0c4a14 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -49,6 +49,7 @@ from . import ( _get_manager, async_setup_with_default_adapter, generate_advertisement_data, + generate_ble_device, inject_advertisement, inject_advertisement_with_time_and_source_connectable, patch_discovered_devices, @@ -354,7 +355,7 @@ async def test_discovery_match_by_service_uuid( assert len(mock_bleak_scanner_start.mock_calls) == 1 - wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name") + wrong_device = generate_ble_device("44:44:33:11:23:45", "wrong_name") wrong_adv = generate_advertisement_data( local_name="wrong_name", service_uuids=[] ) @@ -364,7 +365,7 @@ async def test_discovery_match_by_service_uuid( assert len(mock_config_flow.mock_calls) == 0 - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) @@ -401,7 +402,7 @@ async def test_discovery_match_by_service_uuid_connectable( assert len(mock_bleak_scanner_start.mock_calls) == 1 - wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name") + wrong_device = generate_ble_device("44:44:33:11:23:45", "wrong_name") wrong_adv = generate_advertisement_data( local_name="wrong_name", service_uuids=[] ) @@ -413,7 +414,7 @@ async def test_discovery_match_by_service_uuid_connectable( assert len(_domains_from_mock_config_flow(mock_config_flow)) == 0 - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) @@ -448,7 +449,7 @@ async def test_discovery_match_by_service_uuid_not_connectable( assert len(mock_bleak_scanner_start.mock_calls) == 1 - wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name") + wrong_device = generate_ble_device("44:44:33:11:23:45", "wrong_name") wrong_adv = generate_advertisement_data( local_name="wrong_name", service_uuids=[] ) @@ -460,7 +461,7 @@ async def test_discovery_match_by_service_uuid_not_connectable( assert len(_domains_from_mock_config_flow(mock_config_flow)) == 0 - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) @@ -493,7 +494,7 @@ async def test_discovery_match_by_name_connectable_false( assert len(mock_bleak_scanner_start.mock_calls) == 1 - wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name") + wrong_device = generate_ble_device("44:44:33:11:23:45", "wrong_name") wrong_adv = generate_advertisement_data( local_name="wrong_name", service_uuids=[] ) @@ -505,7 +506,9 @@ async def test_discovery_match_by_name_connectable_false( assert len(_domains_from_mock_config_flow(mock_config_flow)) == 0 - qingping_device = BLEDevice("44:44:33:11:23:45", "Qingping Motion & Light") + qingping_device = generate_ble_device( + "44:44:33:11:23:45", "Qingping Motion & Light" + ) qingping_adv = generate_advertisement_data( local_name="Qingping Motion & Light", service_data={ @@ -561,7 +564,7 @@ async def test_discovery_match_by_local_name( assert len(mock_bleak_scanner_start.mock_calls) == 1 - wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name") + wrong_device = generate_ble_device("44:44:33:11:23:45", "wrong_name") wrong_adv = generate_advertisement_data( local_name="wrong_name", service_uuids=[] ) @@ -571,7 +574,7 @@ async def test_discovery_match_by_local_name( assert len(mock_config_flow.mock_calls) == 0 - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) @@ -605,7 +608,7 @@ async def test_discovery_match_by_manufacturer_id_and_manufacturer_data_start( assert len(mock_bleak_scanner_start.mock_calls) == 1 - hkc_device = BLEDevice("44:44:33:11:23:45", "lock") + hkc_device = generate_ble_device("44:44:33:11:23:45", "lock") hkc_adv_no_mfr_data = generate_advertisement_data( local_name="lock", service_uuids=[], @@ -639,7 +642,7 @@ async def test_discovery_match_by_manufacturer_id_and_manufacturer_data_start( assert len(mock_config_flow.mock_calls) == 0 mock_config_flow.reset_mock() - not_hkc_device = BLEDevice("44:44:33:11:23:21", "lock") + not_hkc_device = generate_ble_device("44:44:33:11:23:21", "lock") not_hkc_adv = generate_advertisement_data( local_name="lock", service_uuids=[], manufacturer_data={76: b"\x02"} ) @@ -648,7 +651,7 @@ async def test_discovery_match_by_manufacturer_id_and_manufacturer_data_start( await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 0 - not_apple_device = BLEDevice("44:44:33:11:23:23", "lock") + not_apple_device = generate_ble_device("44:44:33:11:23:23", "lock") not_apple_adv = generate_advertisement_data( local_name="lock", service_uuids=[], manufacturer_data={21: b"\x02"} ) @@ -688,7 +691,7 @@ async def test_discovery_match_by_service_data_uuid_then_others( assert len(mock_bleak_scanner_start.mock_calls) == 1 - device = BLEDevice("44:44:33:11:23:45", "lock") + device = generate_ble_device("44:44:33:11:23:45", "lock") adv_without_service_data_uuid = generate_advertisement_data( local_name="lock", service_uuids=[], @@ -838,7 +841,7 @@ async def test_discovery_match_by_service_data_uuid_when_format_changes( assert len(mock_bleak_scanner_start.mock_calls) == 1 - device = BLEDevice("44:44:33:11:23:45", "lock") + device = generate_ble_device("44:44:33:11:23:45", "lock") adv_without_service_data_uuid = generate_advertisement_data( local_name="Qingping Temp RH M", service_uuids=[], @@ -921,7 +924,7 @@ async def test_discovery_match_first_by_service_uuid_and_then_manufacturer_id( assert len(mock_bleak_scanner_start.mock_calls) == 1 - device = BLEDevice("44:44:33:11:23:45", "lock") + device = generate_ble_device("44:44:33:11:23:45", "lock") adv_service_uuids = generate_advertisement_data( local_name="lock", service_uuids=["0000fd3d-0000-1000-8000-00805f9b34fc"], @@ -976,7 +979,7 @@ async def test_rediscovery( assert len(mock_bleak_scanner_start.mock_calls) == 1 - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) @@ -1026,12 +1029,12 @@ async def test_async_discovered_device_api( assert not bluetooth.async_discovered_service_info(hass) - wrong_device = BLEDevice("44:44:33:11:23:42", "wrong_name") + wrong_device = generate_ble_device("44:44:33:11:23:42", "wrong_name") wrong_adv = generate_advertisement_data( local_name="wrong_name", service_uuids=[] ) inject_advertisement(hass, wrong_device, wrong_adv) - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=[] ) @@ -1119,7 +1122,7 @@ async def test_register_callbacks( hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - seen_switchbot_device = BLEDevice("44:44:33:11:23:46", "wohand") + seen_switchbot_device = generate_ble_device("44:44:33:11:23:46", "wohand") seen_switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -1138,7 +1141,7 @@ async def test_register_callbacks( assert len(mock_bleak_scanner_start.mock_calls) == 1 - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -1148,13 +1151,13 @@ async def test_register_callbacks( inject_advertisement(hass, switchbot_device, switchbot_adv) - empty_device = BLEDevice("11:22:33:44:55:66", "empty") + empty_device = generate_ble_device("11:22:33:44:55:66", "empty") empty_adv = generate_advertisement_data(local_name="empty") inject_advertisement(hass, empty_device, empty_adv) await hass.async_block_till_done() - empty_device = BLEDevice("11:22:33:44:55:66", "empty") + empty_device = generate_ble_device("11:22:33:44:55:66", "empty") empty_adv = generate_advertisement_data(local_name="empty") inject_advertisement(hass, empty_device, empty_adv) @@ -1209,7 +1212,7 @@ async def test_register_callbacks_raises_exception( assert len(mock_bleak_scanner_start.mock_calls) == 1 - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -1268,7 +1271,7 @@ async def test_register_callback_by_address( assert len(mock_bleak_scanner_start.mock_calls) == 1 - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -1278,13 +1281,13 @@ async def test_register_callback_by_address( inject_advertisement(hass, switchbot_device, switchbot_adv) - empty_device = BLEDevice("11:22:33:44:55:66", "empty") + empty_device = generate_ble_device("11:22:33:44:55:66", "empty") empty_adv = generate_advertisement_data(local_name="empty") inject_advertisement(hass, empty_device, empty_adv) await hass.async_block_till_done() - empty_device = BLEDevice("11:22:33:44:55:66", "empty") + empty_device = generate_ble_device("11:22:33:44:55:66", "empty") empty_adv = generate_advertisement_data(local_name="empty") # 3rd callback raises ValueError but is still tracked @@ -1370,7 +1373,7 @@ async def test_register_callback_by_address_connectable_only( assert len(mock_bleak_scanner_start.mock_calls) == 1 - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -1436,7 +1439,7 @@ async def test_register_callback_by_manufacturer_id( assert len(mock_bleak_scanner_start.mock_calls) == 1 - apple_device = BLEDevice("44:44:33:11:23:45", "rtx") + apple_device = generate_ble_device("44:44:33:11:23:45", "rtx") apple_adv = generate_advertisement_data( local_name="rtx", manufacturer_data={21: b"\xd8.\xad\xcd\r\x85"}, @@ -1444,7 +1447,7 @@ async def test_register_callback_by_manufacturer_id( inject_advertisement(hass, apple_device, apple_adv) - empty_device = BLEDevice("11:22:33:44:55:66", "empty") + empty_device = generate_ble_device("11:22:33:44:55:66", "empty") empty_adv = generate_advertisement_data(local_name="empty") inject_advertisement(hass, empty_device, empty_adv) @@ -1491,7 +1494,7 @@ async def test_register_callback_by_connectable( assert len(mock_bleak_scanner_start.mock_calls) == 1 - apple_device = BLEDevice("44:44:33:11:23:45", "rtx") + apple_device = generate_ble_device("44:44:33:11:23:45", "rtx") apple_adv = generate_advertisement_data( local_name="rtx", manufacturer_data={7676: b"\xd8.\xad\xcd\r\x85"}, @@ -1499,7 +1502,7 @@ async def test_register_callback_by_connectable( inject_advertisement(hass, apple_device, apple_adv) - empty_device = BLEDevice("11:22:33:44:55:66", "empty") + empty_device = generate_ble_device("11:22:33:44:55:66", "empty") empty_adv = generate_advertisement_data(local_name="empty") inject_advertisement(hass, empty_device, empty_adv) @@ -1546,7 +1549,7 @@ async def test_not_filtering_wanted_apple_devices( assert len(mock_bleak_scanner_start.mock_calls) == 1 - ibeacon_device = BLEDevice("44:44:33:11:23:45", "rtx") + ibeacon_device = generate_ble_device("44:44:33:11:23:45", "rtx") ibeacon_adv = generate_advertisement_data( local_name="ibeacon", manufacturer_data={76: b"\x02\x00\x00\x00"}, @@ -1554,7 +1557,7 @@ async def test_not_filtering_wanted_apple_devices( inject_advertisement(hass, ibeacon_device, ibeacon_adv) - homekit_device = BLEDevice("44:44:33:11:23:46", "rtx") + homekit_device = generate_ble_device("44:44:33:11:23:46", "rtx") homekit_adv = generate_advertisement_data( local_name="homekit", manufacturer_data={76: b"\x06\x00\x00\x00"}, @@ -1562,7 +1565,7 @@ async def test_not_filtering_wanted_apple_devices( inject_advertisement(hass, homekit_device, homekit_adv) - apple_device = BLEDevice("44:44:33:11:23:47", "rtx") + apple_device = generate_ble_device("44:44:33:11:23:47", "rtx") apple_adv = generate_advertisement_data( local_name="apple", manufacturer_data={76: b"\x10\x00\x00\x00"}, @@ -1606,7 +1609,7 @@ async def test_filtering_noisy_apple_devices( assert len(mock_bleak_scanner_start.mock_calls) == 1 - apple_device = BLEDevice("44:44:33:11:23:45", "rtx") + apple_device = generate_ble_device("44:44:33:11:23:45", "rtx") apple_adv = generate_advertisement_data( local_name="noisy", manufacturer_data={76: b"\xd8.\xad\xcd\r\x85"}, @@ -1614,7 +1617,7 @@ async def test_filtering_noisy_apple_devices( inject_advertisement(hass, apple_device, apple_adv) - empty_device = BLEDevice("11:22:33:44:55:66", "empty") + empty_device = generate_ble_device("11:22:33:44:55:66", "empty") empty_adv = generate_advertisement_data(local_name="empty") inject_advertisement(hass, empty_device, empty_adv) @@ -1656,7 +1659,7 @@ async def test_register_callback_by_address_connectable_manufacturer_id( assert len(mock_bleak_scanner_start.mock_calls) == 1 - apple_device = BLEDevice("44:44:33:11:23:45", "rtx") + apple_device = generate_ble_device("44:44:33:11:23:45", "rtx") apple_adv = generate_advertisement_data( local_name="rtx", manufacturer_data={21: b"\xd8.\xad\xcd\r\x85"}, @@ -1664,7 +1667,7 @@ async def test_register_callback_by_address_connectable_manufacturer_id( inject_advertisement(hass, apple_device, apple_adv) - apple_device_wrong_address = BLEDevice("44:44:33:11:23:46", "rtx") + apple_device_wrong_address = generate_ble_device("44:44:33:11:23:46", "rtx") inject_advertisement(hass, apple_device_wrong_address, apple_adv) await hass.async_block_till_done() @@ -1710,7 +1713,7 @@ async def test_register_callback_by_manufacturer_id_and_address( assert len(mock_bleak_scanner_start.mock_calls) == 1 - rtx_device = BLEDevice("44:44:33:11:23:45", "rtx") + rtx_device = generate_ble_device("44:44:33:11:23:45", "rtx") rtx_adv = generate_advertisement_data( local_name="rtx", manufacturer_data={21: b"\xd8.\xad\xcd\r\x85"}, @@ -1718,7 +1721,7 @@ async def test_register_callback_by_manufacturer_id_and_address( inject_advertisement(hass, rtx_device, rtx_adv) - yale_device = BLEDevice("44:44:33:11:23:45", "apple") + yale_device = generate_ble_device("44:44:33:11:23:45", "apple") yale_adv = generate_advertisement_data( local_name="yale", manufacturer_data={465: b"\xd8.\xad\xcd\r\x85"}, @@ -1727,7 +1730,7 @@ async def test_register_callback_by_manufacturer_id_and_address( inject_advertisement(hass, yale_device, yale_adv) await hass.async_block_till_done() - other_apple_device = BLEDevice("44:44:33:11:23:22", "apple") + other_apple_device = generate_ble_device("44:44:33:11:23:22", "apple") other_apple_adv = generate_advertisement_data( local_name="apple", manufacturer_data={21: b"\xd8.\xad\xcd\r\x85"}, @@ -1778,7 +1781,7 @@ async def test_register_callback_by_service_uuid_and_address( assert len(mock_bleak_scanner_start.mock_calls) == 1 - switchbot_dev = BLEDevice("44:44:33:11:23:45", "switchbot") + switchbot_dev = generate_ble_device("44:44:33:11:23:45", "switchbot") switchbot_adv = generate_advertisement_data( local_name="switchbot", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -1786,7 +1789,9 @@ async def test_register_callback_by_service_uuid_and_address( inject_advertisement(hass, switchbot_dev, switchbot_adv) - switchbot_missing_service_uuid_dev = BLEDevice("44:44:33:11:23:45", "switchbot") + switchbot_missing_service_uuid_dev = generate_ble_device( + "44:44:33:11:23:45", "switchbot" + ) switchbot_missing_service_uuid_adv = generate_advertisement_data( local_name="switchbot", ) @@ -1796,7 +1801,9 @@ async def test_register_callback_by_service_uuid_and_address( ) await hass.async_block_till_done() - service_uuid_wrong_address_dev = BLEDevice("44:44:33:11:23:22", "switchbot2") + service_uuid_wrong_address_dev = generate_ble_device( + "44:44:33:11:23:22", "switchbot2" + ) service_uuid_wrong_address_adv = generate_advertisement_data( local_name="switchbot2", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -1847,7 +1854,7 @@ async def test_register_callback_by_service_data_uuid_and_address( assert len(mock_bleak_scanner_start.mock_calls) == 1 - switchbot_dev = BLEDevice("44:44:33:11:23:45", "switchbot") + switchbot_dev = generate_ble_device("44:44:33:11:23:45", "switchbot") switchbot_adv = generate_advertisement_data( local_name="switchbot", service_data={"cba20d00-224d-11e6-9fb8-0002a5d5c51b": b"x"}, @@ -1855,7 +1862,9 @@ async def test_register_callback_by_service_data_uuid_and_address( inject_advertisement(hass, switchbot_dev, switchbot_adv) - switchbot_missing_service_uuid_dev = BLEDevice("44:44:33:11:23:45", "switchbot") + switchbot_missing_service_uuid_dev = generate_ble_device( + "44:44:33:11:23:45", "switchbot" + ) switchbot_missing_service_uuid_adv = generate_advertisement_data( local_name="switchbot", ) @@ -1865,7 +1874,9 @@ async def test_register_callback_by_service_data_uuid_and_address( ) await hass.async_block_till_done() - service_uuid_wrong_address_dev = BLEDevice("44:44:33:11:23:22", "switchbot2") + service_uuid_wrong_address_dev = generate_ble_device( + "44:44:33:11:23:22", "switchbot2" + ) service_uuid_wrong_address_adv = generate_advertisement_data( local_name="switchbot2", service_data={"cba20d00-224d-11e6-9fb8-0002a5d5c51b": b"x"}, @@ -1913,7 +1924,7 @@ async def test_register_callback_by_local_name( assert len(mock_bleak_scanner_start.mock_calls) == 1 - rtx_device = BLEDevice("44:44:33:11:23:45", "rtx") + rtx_device = generate_ble_device("44:44:33:11:23:45", "rtx") rtx_adv = generate_advertisement_data( local_name="rtx", manufacturer_data={21: b"\xd8.\xad\xcd\r\x85"}, @@ -1921,12 +1932,12 @@ async def test_register_callback_by_local_name( inject_advertisement(hass, rtx_device, rtx_adv) - empty_device = BLEDevice("11:22:33:44:55:66", "empty") + empty_device = generate_ble_device("11:22:33:44:55:66", "empty") empty_adv = generate_advertisement_data(local_name="empty") inject_advertisement(hass, empty_device, empty_adv) - rtx_device_2 = BLEDevice("44:44:33:11:23:45", "rtx") + rtx_device_2 = generate_ble_device("44:44:33:11:23:45", "rtx") rtx_adv_2 = generate_advertisement_data( local_name="rtx2", manufacturer_data={21: b"\xd8.\xad\xcd\r\x85"}, @@ -2012,7 +2023,7 @@ async def test_register_callback_by_service_data_uuid( assert len(mock_bleak_scanner_start.mock_calls) == 1 - apple_device = BLEDevice("44:44:33:11:23:45", "xiaomi") + apple_device = generate_ble_device("44:44:33:11:23:45", "xiaomi") apple_adv = generate_advertisement_data( local_name="xiaomi", service_data={ @@ -2022,7 +2033,7 @@ async def test_register_callback_by_service_data_uuid( inject_advertisement(hass, apple_device, apple_adv) - empty_device = BLEDevice("11:22:33:44:55:66", "empty") + empty_device = generate_ble_device("11:22:33:44:55:66", "empty") empty_adv = generate_advertisement_data(local_name="empty") inject_advertisement(hass, empty_device, empty_adv) @@ -2066,7 +2077,7 @@ async def test_register_callback_survives_reload( assert len(mock_bleak_scanner_start.mock_calls) == 1 - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["zba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -2120,7 +2131,7 @@ async def test_process_advertisements_bail_on_good_advertisement( ) while not done.done(): - device = BLEDevice("aa:44:33:11:23:45", "wohand") + device = generate_ble_device("aa:44:33:11:23:45", "wohand") adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51a"], @@ -2145,7 +2156,7 @@ async def test_process_advertisements_ignore_bad_advertisement( done = asyncio.Event() return_value = asyncio.Event() - device = BLEDevice("aa:44:33:11:23:45", "wohand") + device = generate_ble_device("aa:44:33:11:23:45", "wohand") adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51a"], @@ -2227,7 +2238,7 @@ async def test_wrapped_instance_with_filter( """Handle a detected device.""" detected.append((device, advertisement_data)) - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -2240,7 +2251,7 @@ async def test_wrapped_instance_with_filter( manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) - empty_device = BLEDevice("11:22:33:44:55:66", "empty") + empty_device = generate_ble_device("11:22:33:44:55:66", "empty") empty_adv = generate_advertisement_data(local_name="empty") assert _get_manager() is not None @@ -2299,7 +2310,7 @@ async def test_wrapped_instance_with_service_uuids( """Handle a detected device.""" detected.append((device, advertisement_data)) - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -2312,7 +2323,7 @@ async def test_wrapped_instance_with_service_uuids( manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) - empty_device = BLEDevice("11:22:33:44:55:66", "empty") + empty_device = generate_ble_device("11:22:33:44:55:66", "empty") empty_adv = generate_advertisement_data(local_name="empty") assert _get_manager() is not None @@ -2357,7 +2368,7 @@ async def test_wrapped_instance_with_broken_callbacks( raise ValueError detected.append((device, advertisement_data)) - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -2398,7 +2409,7 @@ async def test_wrapped_instance_changes_uuids( """Handle a detected device.""" detected.append((device, advertisement_data)) - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -2411,7 +2422,7 @@ async def test_wrapped_instance_changes_uuids( manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) - empty_device = BLEDevice("11:22:33:44:55:66", "empty") + empty_device = generate_ble_device("11:22:33:44:55:66", "empty") empty_adv = generate_advertisement_data(local_name="empty") assert _get_manager() is not None @@ -2453,7 +2464,7 @@ async def test_wrapped_instance_changes_filters( """Handle a detected device.""" detected.append((device, advertisement_data)) - switchbot_device = BLEDevice("44:44:33:11:23:42", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:42", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -2466,7 +2477,7 @@ async def test_wrapped_instance_changes_filters( manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) - empty_device = BLEDevice("11:22:33:44:55:62", "empty") + empty_device = generate_ble_device("11:22:33:44:55:62", "empty") empty_adv = generate_advertisement_data(local_name="empty") assert _get_manager() is not None @@ -2541,7 +2552,7 @@ async def test_async_ble_device_from_address( assert not bluetooth.async_discovered_service_info(hass) - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=[] ) diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 7e605ece4c..9d8d20bec5 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -37,6 +37,7 @@ from . import ( MockBleakClient, _get_manager, generate_advertisement_data, + generate_ble_device, inject_advertisement_with_source, inject_advertisement_with_time_and_source, inject_advertisement_with_time_and_source_connectable, @@ -73,7 +74,9 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason( address = "44:44:33:11:23:12" - switchbot_device_signal_100 = BLEDevice(address, "wohand_signal_100", rssi=-100) + switchbot_device_signal_100 = generate_ble_device( + address, "wohand_signal_100", rssi=-100 + ) switchbot_adv_signal_100 = generate_advertisement_data( local_name="wohand_signal_100", service_uuids=[] ) @@ -86,7 +89,9 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason( is switchbot_device_signal_100 ) - switchbot_device_signal_99 = BLEDevice(address, "wohand_signal_99", rssi=-99) + switchbot_device_signal_99 = generate_ble_device( + address, "wohand_signal_99", rssi=-99 + ) switchbot_adv_signal_99 = generate_advertisement_data( local_name="wohand_signal_99", service_uuids=[] ) @@ -99,7 +104,9 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason( is switchbot_device_signal_99 ) - switchbot_device_signal_98 = BLEDevice(address, "wohand_good_signal", rssi=-98) + switchbot_device_signal_98 = generate_ble_device( + address, "wohand_good_signal", rssi=-98 + ) switchbot_adv_signal_98 = generate_advertisement_data( local_name="wohand_good_signal", service_uuids=[] ) @@ -124,7 +131,7 @@ async def test_switching_adapters_based_on_rssi( address = "44:44:33:11:23:45" - switchbot_device_poor_signal = BLEDevice(address, "wohand_poor_signal") + switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal") switchbot_adv_poor_signal = generate_advertisement_data( local_name="wohand_poor_signal", service_uuids=[], rssi=-100 ) @@ -137,7 +144,7 @@ async def test_switching_adapters_based_on_rssi( is switchbot_device_poor_signal ) - switchbot_device_good_signal = BLEDevice(address, "wohand_good_signal") + switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal") switchbot_adv_good_signal = generate_advertisement_data( local_name="wohand_good_signal", service_uuids=[], rssi=-60 ) @@ -159,7 +166,9 @@ async def test_switching_adapters_based_on_rssi( ) # We should not switch adapters unless the signal hits the threshold - switchbot_device_similar_signal = BLEDevice(address, "wohand_similar_signal") + switchbot_device_similar_signal = generate_ble_device( + address, "wohand_similar_signal" + ) switchbot_adv_similar_signal = generate_advertisement_data( local_name="wohand_similar_signal", service_uuids=[], rssi=-62 ) @@ -183,7 +192,7 @@ async def test_switching_adapters_based_on_zero_rssi( address = "44:44:33:11:23:45" - switchbot_device_no_rssi = BLEDevice(address, "wohand_poor_signal") + switchbot_device_no_rssi = generate_ble_device(address, "wohand_poor_signal") switchbot_adv_no_rssi = generate_advertisement_data( local_name="wohand_no_rssi", service_uuids=[], rssi=0 ) @@ -196,7 +205,7 @@ async def test_switching_adapters_based_on_zero_rssi( is switchbot_device_no_rssi ) - switchbot_device_good_signal = BLEDevice(address, "wohand_good_signal") + switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal") switchbot_adv_good_signal = generate_advertisement_data( local_name="wohand_good_signal", service_uuids=[], rssi=-60 ) @@ -218,7 +227,9 @@ async def test_switching_adapters_based_on_zero_rssi( ) # We should not switch adapters unless the signal hits the threshold - switchbot_device_similar_signal = BLEDevice(address, "wohand_similar_signal") + switchbot_device_similar_signal = generate_ble_device( + address, "wohand_similar_signal" + ) switchbot_adv_similar_signal = generate_advertisement_data( local_name="wohand_similar_signal", service_uuids=[], rssi=-62 ) @@ -243,7 +254,9 @@ async def test_switching_adapters_based_on_stale( address = "44:44:33:11:23:41" start_time_monotonic = 50.0 - switchbot_device_poor_signal_hci0 = BLEDevice(address, "wohand_poor_signal_hci0") + switchbot_device_poor_signal_hci0 = generate_ble_device( + address, "wohand_poor_signal_hci0" + ) switchbot_adv_poor_signal_hci0 = generate_advertisement_data( local_name="wohand_poor_signal_hci0", service_uuids=[], rssi=-100 ) @@ -260,7 +273,9 @@ async def test_switching_adapters_based_on_stale( is switchbot_device_poor_signal_hci0 ) - switchbot_device_poor_signal_hci1 = BLEDevice(address, "wohand_poor_signal_hci1") + switchbot_device_poor_signal_hci1 = generate_ble_device( + address, "wohand_poor_signal_hci1" + ) switchbot_adv_poor_signal_hci1 = generate_advertisement_data( local_name="wohand_poor_signal_hci1", service_uuids=[], rssi=-99 ) @@ -301,7 +316,7 @@ async def test_restore_history_from_dbus( """Test we can restore history from dbus.""" address = "AA:BB:CC:CC:CC:FF" - ble_device = BLEDevice(address, "name") + ble_device = generate_ble_device(address, "name") history = { address: AdvertisementHistory( ble_device, generate_advertisement_data(local_name="name"), "hci0" @@ -337,7 +352,7 @@ async def test_restore_history_from_dbus_and_remote_adapters( for address in timestamps: timestamps[address] = now - ble_device = BLEDevice(address, "name") + ble_device = generate_ble_device(address, "name") history = { address: AdvertisementHistory( ble_device, generate_advertisement_data(local_name="name"), "hci0" @@ -377,7 +392,7 @@ async def test_restore_history_from_dbus_and_corrupted_remote_adapters( for address in timestamps: timestamps[address] = now - ble_device = BLEDevice(address, "name") + ble_device = generate_ble_device(address, "name") history = { address: AdvertisementHistory( ble_device, generate_advertisement_data(local_name="name"), "hci0" @@ -406,7 +421,7 @@ async def test_switching_adapters_based_on_rssi_connectable_to_non_connectable( address = "44:44:33:11:23:45" now = time.monotonic() - switchbot_device_poor_signal = BLEDevice(address, "wohand_poor_signal") + switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal") switchbot_adv_poor_signal = generate_advertisement_data( local_name="wohand_poor_signal", service_uuids=[], rssi=-100 ) @@ -422,7 +437,7 @@ async def test_switching_adapters_based_on_rssi_connectable_to_non_connectable( bluetooth.async_ble_device_from_address(hass, address, True) is switchbot_device_poor_signal ) - switchbot_device_good_signal = BLEDevice(address, "wohand_good_signal") + switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal") switchbot_adv_good_signal = generate_advertisement_data( local_name="wohand_good_signal", service_uuids=[], rssi=-60 ) @@ -459,7 +474,9 @@ async def test_switching_adapters_based_on_rssi_connectable_to_non_connectable( bluetooth.async_ble_device_from_address(hass, address, True) is switchbot_device_poor_signal ) - switchbot_device_excellent_signal = BLEDevice(address, "wohand_excellent_signal") + switchbot_device_excellent_signal = generate_ble_device( + address, "wohand_excellent_signal" + ) switchbot_adv_excellent_signal = generate_advertisement_data( local_name="wohand_excellent_signal", service_uuids=[], rssi=-25 ) @@ -496,7 +513,7 @@ async def test_connectable_advertisement_can_be_retrieved_with_best_path_is_non_ address = "44:44:33:11:23:45" now = time.monotonic() - switchbot_device_good_signal = BLEDevice(address, "wohand_good_signal") + switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal") switchbot_adv_good_signal = generate_advertisement_data( local_name="wohand_good_signal", service_uuids=[], rssi=-60 ) @@ -515,7 +532,7 @@ async def test_connectable_advertisement_can_be_retrieved_with_best_path_is_non_ ) assert bluetooth.async_ble_device_from_address(hass, address, True) is None - switchbot_device_poor_signal = BLEDevice(address, "wohand_poor_signal") + switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal") switchbot_adv_poor_signal = generate_advertisement_data( local_name="wohand_poor_signal", service_uuids=[], rssi=-100 ) @@ -543,7 +560,7 @@ async def test_switching_adapters_when_one_goes_away( address = "44:44:33:11:23:45" - switchbot_device_good_signal = BLEDevice(address, "wohand_good_signal") + switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal") switchbot_adv_good_signal = generate_advertisement_data( local_name="wohand_good_signal", service_uuids=[], rssi=-60 ) @@ -556,7 +573,7 @@ async def test_switching_adapters_when_one_goes_away( is switchbot_device_good_signal ) - switchbot_device_poor_signal = BLEDevice(address, "wohand_poor_signal") + switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal") switchbot_adv_poor_signal = generate_advertisement_data( local_name="wohand_poor_signal", service_uuids=[], rssi=-100 ) @@ -593,7 +610,7 @@ async def test_switching_adapters_when_one_stop_scanning( address = "44:44:33:11:23:45" - switchbot_device_good_signal = BLEDevice(address, "wohand_good_signal") + switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal") switchbot_adv_good_signal = generate_advertisement_data( local_name="wohand_good_signal", service_uuids=[], rssi=-60 ) @@ -606,7 +623,7 @@ async def test_switching_adapters_when_one_stop_scanning( is switchbot_device_good_signal ) - switchbot_device_poor_signal = BLEDevice(address, "wohand_poor_signal") + switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal") switchbot_adv_poor_signal = generate_advertisement_data( local_name="wohand_poor_signal", service_uuids=[], rssi=-100 ) @@ -645,13 +662,13 @@ async def test_goes_unavailable_connectable_only_and_recovers( assert async_scanner_count(hass, connectable=True) == 0 assert async_scanner_count(hass, connectable=False) == 0 - switchbot_device_connectable = BLEDevice( + switchbot_device_connectable = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, rssi=-100, ) - switchbot_device_non_connectable = BLEDevice( + switchbot_device_non_connectable = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, @@ -813,7 +830,7 @@ async def test_goes_unavailable_dismisses_discovery( await hass.async_block_till_done() assert async_scanner_count(hass, connectable=False) == 0 - switchbot_device_non_connectable = BLEDevice( + switchbot_device_non_connectable = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, diff --git a/tests/components/bluetooth/test_models.py b/tests/components/bluetooth/test_models.py index d17583bcce..8331a8b6b7 100644 --- a/tests/components/bluetooth/test_models.py +++ b/tests/components/bluetooth/test_models.py @@ -24,6 +24,7 @@ from . import ( MockBleakClient, _get_manager, generate_advertisement_data, + generate_ble_device, inject_advertisement, inject_advertisement_with_source, ) @@ -34,7 +35,7 @@ async def test_wrapped_bleak_scanner( ) -> None: """Test wrapped bleak scanner dispatches calls as expected.""" scanner = HaBleakScannerWrapper() - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) @@ -47,7 +48,7 @@ async def test_wrapped_bleak_client_raises_device_missing( hass: HomeAssistant, enable_bluetooth: None ) -> None: """Test wrapped bleak client dispatches calls as expected.""" - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") client = HaBleakClientWrapper(switchbot_device) assert client.is_connected is False with pytest.raises(bleak.BleakError): @@ -61,7 +62,7 @@ async def test_wrapped_bleak_client_set_disconnected_callback_before_connected( hass: HomeAssistant, enable_bluetooth: None ) -> None: """Test wrapped bleak client can set a disconnected callback before connected.""" - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") client = HaBleakClientWrapper(switchbot_device) client.set_disconnected_callback(lambda client: None) @@ -72,7 +73,7 @@ async def test_wrapped_bleak_client_local_adapter_only( """Test wrapped bleak client with only a local adapter.""" manager = _get_manager() - switchbot_device = BLEDevice( + switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {"path": "/org/bluez/hci0/dev_44_44_33_11_23_45"}, @@ -133,7 +134,7 @@ async def test_wrapped_bleak_client_set_disconnected_callback_after_connected( """Test wrapped bleak client can set a disconnected callback after connected.""" manager = _get_manager() - switchbot_proxy_device_has_connection_slot = BLEDevice( + switchbot_proxy_device_has_connection_slot = generate_ble_device( "44:44:33:11:23:45", "wohand", { @@ -148,7 +149,7 @@ async def test_wrapped_bleak_client_set_disconnected_callback_after_connected( manufacturer_data={1: b"\x01"}, rssi=-40, ) - switchbot_device = BLEDevice( + switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {"path": "/org/bluez/hci0/dev_44_44_33_11_23_45"}, @@ -220,7 +221,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_no_scanners( """Test we switch to the next available proxy when one runs out of connections with no scanners.""" manager = _get_manager() - switchbot_proxy_device_no_connection_slot = BLEDevice( + switchbot_proxy_device_no_connection_slot = generate_ble_device( "44:44:33:11:23:45", "wohand", { @@ -257,7 +258,7 @@ async def test_ble_device_with_proxy_client_out_of_connections( """Test handling all scanners are out of connection slots.""" manager = _get_manager() - switchbot_proxy_device_no_connection_slot = BLEDevice( + switchbot_proxy_device_no_connection_slot = generate_ble_device( "44:44:33:11:23:45", "wohand", { @@ -322,7 +323,7 @@ async def test_ble_device_with_proxy_clear_cache( """Test we can clear cache on the proxy.""" manager = _get_manager() - switchbot_proxy_device_with_connection_slot = BLEDevice( + switchbot_proxy_device_with_connection_slot = generate_ble_device( "44:44:33:11:23:45", "wohand", { @@ -384,7 +385,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab """Test we switch to the next available proxy when one runs out of connections.""" manager = _get_manager() - switchbot_proxy_device_no_connection_slot = BLEDevice( + switchbot_proxy_device_no_connection_slot = generate_ble_device( "44:44:33:11:23:45", "wohand", { @@ -398,7 +399,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab manufacturer_data={1: b"\x01"}, rssi=-30, ) - switchbot_proxy_device_has_connection_slot = BLEDevice( + switchbot_proxy_device_has_connection_slot = generate_ble_device( "44:44:33:11:23:45", "wohand", { @@ -413,7 +414,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab manufacturer_data={1: b"\x01"}, rssi=-40, ) - switchbot_device = BLEDevice( + switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {"path": "/org/bluez/hci0/dev_44_44_33_11_23_45"}, @@ -493,7 +494,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab """Test we switch to the next available proxy when one runs out of connections on MacOS.""" manager = _get_manager() - switchbot_proxy_device_no_connection_slot = BLEDevice( + switchbot_proxy_device_no_connection_slot = generate_ble_device( "44:44:33:11:23:45", "wohand_no_connection_slot", { @@ -509,7 +510,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab manufacturer_data={1: b"\x01"}, rssi=-30, ) - switchbot_proxy_device_has_connection_slot = BLEDevice( + switchbot_proxy_device_has_connection_slot = generate_ble_device( "44:44:33:11:23:45", "wohand_has_connection_slot", { @@ -525,7 +526,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab rssi=-40, ) - switchbot_device = BLEDevice( + switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, diff --git a/tests/components/bluetooth/test_scanner.py b/tests/components/bluetooth/test_scanner.py index 3417d1fd21..81f9765de0 100644 --- a/tests/components/bluetooth/test_scanner.py +++ b/tests/components/bluetooth/test_scanner.py @@ -5,7 +5,7 @@ import time from unittest.mock import MagicMock, patch from bleak import BleakError -from bleak.backends.scanner import AdvertisementDataCallback, BLEDevice +from bleak.backends.scanner import AdvertisementDataCallback from dbus_fast import InvalidMessageError import pytest @@ -20,7 +20,12 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from . import _get_manager, async_setup_with_one_adapter, generate_advertisement_data +from . import ( + _get_manager, + async_setup_with_one_adapter, + generate_advertisement_data, + generate_ble_device, +) from tests.common import async_fire_time_changed @@ -236,7 +241,7 @@ async def test_recovery_from_dbus_restart( return_value=start_time_monotonic, ): _callback( - BLEDevice("44:44:33:11:23:42", "any_name"), + generate_ble_device("44:44:33:11:23:42", "any_name"), generate_advertisement_data(local_name="any_name"), ) diff --git a/tests/components/bluetooth/test_usage.py b/tests/components/bluetooth/test_usage.py index cb7bdd1038..0edab3ce77 100644 --- a/tests/components/bluetooth/test_usage.py +++ b/tests/components/bluetooth/test_usage.py @@ -2,7 +2,6 @@ from unittest.mock import patch import bleak -from bleak.backends.device import BLEDevice import bleak_retry_connector import pytest @@ -16,10 +15,14 @@ from homeassistant.components.bluetooth.wrappers import ( ) from homeassistant.core import HomeAssistant -from . import _get_manager +from . import _get_manager, generate_ble_device -MOCK_BLE_DEVICE = BLEDevice( - "00:00:00:00:00:00", "any", delegate="", details={"path": "/dev/hci0/device"} +MOCK_BLE_DEVICE = generate_ble_device( + "00:00:00:00:00:00", + "any", + delegate="", + details={"path": "/dev/hci0/device"}, + rssi=-127, ) diff --git a/tests/components/bluetooth/test_wrappers.py b/tests/components/bluetooth/test_wrappers.py index 00cf70d5a0..e1656b39c1 100644 --- a/tests/components/bluetooth/test_wrappers.py +++ b/tests/components/bluetooth/test_wrappers.py @@ -21,7 +21,7 @@ from homeassistant.components.bluetooth.usage import ( ) from homeassistant.core import HomeAssistant -from . import _get_manager, generate_advertisement_data +from . import _get_manager, generate_advertisement_data, generate_ble_device class FakeScanner(BaseHaRemoteScanner): @@ -108,7 +108,7 @@ def _generate_ble_device_and_adv_data( ) -> tuple[BLEDevice, AdvertisementData]: """Generate a BLE device with adv data.""" return ( - BLEDevice( + generate_ble_device( mac, "any", delegate="", diff --git a/tests/components/bluetooth_le_tracker/test_device_tracker.py b/tests/components/bluetooth_le_tracker/test_device_tracker.py index c2f61888b8..8dc31e2622 100644 --- a/tests/components/bluetooth_le_tracker/test_device_tracker.py +++ b/tests/components/bluetooth_le_tracker/test_device_tracker.py @@ -4,7 +4,6 @@ from datetime import timedelta from unittest.mock import patch from bleak import BleakError -from bleak.backends.scanner import BLEDevice from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from homeassistant.components.bluetooth_le_tracker import device_tracker @@ -24,7 +23,7 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util, slugify from tests.common import async_fire_time_changed -from tests.components.bluetooth import generate_advertisement_data +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device class MockBleakClient: @@ -89,7 +88,7 @@ async def test_preserve_new_tracked_device_name( service_data={}, service_uuids=[], source="local", - device=BLEDevice(address, None), + device=generate_ble_device(address, None), advertisement=generate_advertisement_data(local_name="empty"), time=0, connectable=False, @@ -114,7 +113,7 @@ async def test_preserve_new_tracked_device_name( service_data={}, service_uuids=[], source="local", - device=BLEDevice(address, None), + device=generate_ble_device(address, None), advertisement=generate_advertisement_data(local_name="empty"), time=0, connectable=False, @@ -159,7 +158,7 @@ async def test_tracking_battery_times_out( service_data={}, service_uuids=[], source="local", - device=BLEDevice(address, None), + device=generate_ble_device(address, None), advertisement=generate_advertisement_data(local_name="empty"), time=0, connectable=False, @@ -228,7 +227,7 @@ async def test_tracking_battery_fails( service_data={}, service_uuids=[], source="local", - device=BLEDevice(address, None), + device=generate_ble_device(address, None), advertisement=generate_advertisement_data(local_name="empty"), time=0, connectable=False, @@ -297,7 +296,7 @@ async def test_tracking_battery_successful( service_data={}, service_uuids=[], source="local", - device=BLEDevice(address, None), + device=generate_ble_device(address, None), advertisement=generate_advertisement_data(local_name="empty"), time=0, connectable=True, diff --git a/tests/components/bthome/__init__.py b/tests/components/bthome/__init__.py index d05c92b690..de46cd8231 100644 --- a/tests/components/bthome/__init__.py +++ b/tests/components/bthome/__init__.py @@ -1,15 +1,14 @@ """Tests for the BTHome integration.""" -from bleak.backends.device import BLEDevice from homeassistant.components.bluetooth import BluetoothServiceInfoBleak -from tests.components.bluetooth import generate_advertisement_data +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device TEMP_HUMI_SERVICE_INFO = BluetoothServiceInfoBleak( name="ATC 8D18B2", address="A4:C1:38:8D:18:B2", - device=BLEDevice("A4:C1:38:8D:18:B2", None), + device=generate_ble_device("A4:C1:38:8D:18:B2", None), rssi=-63, manufacturer_data={}, service_data={ @@ -25,7 +24,7 @@ TEMP_HUMI_SERVICE_INFO = BluetoothServiceInfoBleak( TEMP_HUMI_ENCRYPTED_SERVICE_INFO = BluetoothServiceInfoBleak( name="TEST DEVICE 8F80A5", address="54:48:E6:8F:80:A5", - device=BLEDevice("54:48:E6:8F:80:A5", None), + device=generate_ble_device("54:48:E6:8F:80:A5", None), rssi=-63, manufacturer_data={}, service_data={ @@ -43,7 +42,7 @@ TEMP_HUMI_ENCRYPTED_SERVICE_INFO = BluetoothServiceInfoBleak( PRST_SERVICE_INFO = BluetoothServiceInfoBleak( name="prst 8F80A5", address="54:48:E6:8F:80:A5", - device=BLEDevice("54:48:E6:8F:80:A5", None), + device=generate_ble_device("54:48:E6:8F:80:A5", None), rssi=-63, manufacturer_data={}, service_data={ @@ -61,7 +60,7 @@ PRST_SERVICE_INFO = BluetoothServiceInfoBleak( INVALID_PAYLOAD = BluetoothServiceInfoBleak( name="ATC 565384", address="A4:C1:38:56:53:84", - device=BLEDevice("A4:C1:38:56:53:84", None), + device=generate_ble_device("A4:C1:38:56:53:84", None), rssi=-56, manufacturer_data={}, service_data={ @@ -77,7 +76,7 @@ INVALID_PAYLOAD = BluetoothServiceInfoBleak( NOT_BTHOME_SERVICE_INFO = BluetoothServiceInfoBleak( name="Not it", address="00:00:00:00:00:00", - device=BLEDevice("00:00:00:00:00:00", None), + device=generate_ble_device("00:00:00:00:00:00", None), rssi=-63, manufacturer_data={3234: b"\x00\x01"}, service_data={}, @@ -94,7 +93,7 @@ def make_bthome_v1_adv(address: str, payload: bytes) -> BluetoothServiceInfoBlea return BluetoothServiceInfoBleak( name="Test Device", address=address, - device=BLEDevice(address, None), + device=generate_ble_device(address, None), rssi=-56, manufacturer_data={}, service_data={ @@ -115,7 +114,7 @@ def make_encrypted_bthome_v1_adv( return BluetoothServiceInfoBleak( name="ATC 8F80A5", address=address, - device=BLEDevice(address, None), + device=generate_ble_device(address, None), rssi=-56, manufacturer_data={}, service_data={ @@ -134,7 +133,7 @@ def make_bthome_v2_adv(address: str, payload: bytes) -> BluetoothServiceInfoBlea return BluetoothServiceInfoBleak( name="Test Device", address=address, - device=BLEDevice(address, None), + device=generate_ble_device(address, None), rssi=-56, manufacturer_data={}, service_data={ diff --git a/tests/components/dormakaba_dkey/__init__.py b/tests/components/dormakaba_dkey/__init__.py index 12396a8c82..be51109b2a 100644 --- a/tests/components/dormakaba_dkey/__init__.py +++ b/tests/components/dormakaba_dkey/__init__.py @@ -1,9 +1,8 @@ """Tests for the Dormakaba dKey integration.""" -from bleak.backends.device import BLEDevice from homeassistant.components.bluetooth import BluetoothServiceInfoBleak -from tests.components.bluetooth import generate_advertisement_data +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device DKEY_DISCOVERY_INFO = BluetoothServiceInfoBleak( name="00123456", @@ -13,7 +12,7 @@ DKEY_DISCOVERY_INFO = BluetoothServiceInfoBleak( service_uuids=["e7a60000-6639-429f-94fd-86de8ea26897"], service_data={}, source="local", - device=BLEDevice(address="AA:BB:CC:DD:EE:F0", name="00123456"), + device=generate_ble_device(address="AA:BB:CC:DD:EE:F0", name="00123456"), advertisement=generate_advertisement_data( service_uuids=["e7a60000-6639-429f-94fd-86de8ea26897"] ), @@ -33,7 +32,7 @@ NOT_DKEY_DISCOVERY_INFO = BluetoothServiceInfoBleak( service_uuids=[], service_data={}, source="local", - device=BLEDevice(address="AA:BB:CC:DD:EE:F2", name="Aug"), + device=generate_ble_device(address="AA:BB:CC:DD:EE:F2", name="Aug"), advertisement=generate_advertisement_data(), time=0, connectable=True, diff --git a/tests/components/fjaraskupan/__init__.py b/tests/components/fjaraskupan/__init__.py index d4014ea865..5025fbeaf0 100644 --- a/tests/components/fjaraskupan/__init__.py +++ b/tests/components/fjaraskupan/__init__.py @@ -1,11 +1,9 @@ """Tests for the Fjäråskupan integration.""" -from bleak.backends.device import BLEDevice - from homeassistant.components.bluetooth import BluetoothServiceInfoBleak -from tests.components.bluetooth import generate_advertisement_data +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device COOKER_SERVICE_INFO = BluetoothServiceInfoBleak( name="COOKERHOOD_FJAR", @@ -15,7 +13,7 @@ COOKER_SERVICE_INFO = BluetoothServiceInfoBleak( service_uuids=[], service_data={}, source="local", - device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="COOKERHOOD_FJAR"), + device=generate_ble_device(address="AA:BB:CC:DD:EE:FF", name="COOKERHOOD_FJAR"), advertisement=generate_advertisement_data(), time=0, connectable=True, diff --git a/tests/components/ibeacon/__init__.py b/tests/components/ibeacon/__init__.py index 50636ee9d4..a18a90f6c3 100644 --- a/tests/components/ibeacon/__init__.py +++ b/tests/components/ibeacon/__init__.py @@ -1,11 +1,11 @@ """Tests for the ibeacon integration.""" from typing import Any -from bleak.backends.device import BLEDevice - from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo -BLUECHARM_BLE_DEVICE = BLEDevice( +from tests.components.bluetooth import generate_ble_device + +BLUECHARM_BLE_DEVICE = generate_ble_device( address="61DE521B-F0BF-9F44-64D4-75BBE1738105", name="BlueCharm_177999", ) @@ -71,12 +71,12 @@ TESLA_TRANSIENT = BluetoothServiceInfo( service_uuids=[], source="hci0", ) -TESLA_TRANSIENT_BLE_DEVICE = BLEDevice( +TESLA_TRANSIENT_BLE_DEVICE = generate_ble_device( address="CC:CC:CC:CC:CC:CC", name="S6da7c9389bd5452cC", ) -FEASY_BEACON_BLE_DEVICE = BLEDevice( +FEASY_BEACON_BLE_DEVICE = generate_ble_device( address="AA:BB:CC:DD:EE:FF", name="FSC-BP108", ) diff --git a/tests/components/ibeacon/test_coordinator.py b/tests/components/ibeacon/test_coordinator.py index a13a3d2e7e..3c9beaf396 100644 --- a/tests/components/ibeacon/test_coordinator.py +++ b/tests/components/ibeacon/test_coordinator.py @@ -2,7 +2,6 @@ from datetime import timedelta import time -from bleak.backends.scanner import BLEDevice import pytest from homeassistant.components.ibeacon.const import ATTR_SOURCE, DOMAIN, UPDATE_INTERVAL @@ -23,6 +22,7 @@ from . import ( from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.bluetooth import ( generate_advertisement_data, + generate_ble_device, inject_advertisement_with_time_and_source_connectable, inject_bluetooth_service_info, patch_all_discovered_devices, @@ -276,7 +276,7 @@ async def test_changing_source_attribute(hass: HomeAssistant) -> None: now = time.monotonic() info = BLUECHARM_BEACON_SERVICE_INFO_2 - device = BLEDevice( + device = generate_ble_device( address=info.address, name=info.name, details={}, diff --git a/tests/components/keymitt_ble/__init__.py b/tests/components/keymitt_ble/__init__.py index 136ca99c56..2938e22c92 100644 --- a/tests/components/keymitt_ble/__init__.py +++ b/tests/components/keymitt_ble/__init__.py @@ -1,12 +1,10 @@ """Tests for the MicroBot integration.""" from unittest.mock import patch -from bleak.backends.device import BLEDevice - from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from homeassistant.const import CONF_ADDRESS -from tests.components.bluetooth import generate_advertisement_data +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device DOMAIN = "keymitt_ble" @@ -44,7 +42,7 @@ SERVICE_INFO = BluetoothServiceInfoBleak( manufacturer_data={}, service_uuids=["0000abcd-0000-1000-8000-00805f9b34fb"], ), - device=BLEDevice("aa:bb:cc:dd:ee:ff", "mibp"), + device=generate_ble_device("aa:bb:cc:dd:ee:ff", "mibp"), time=0, connectable=True, ) diff --git a/tests/components/ld2410_ble/__init__.py b/tests/components/ld2410_ble/__init__.py index 2abb955793..b38115aab4 100644 --- a/tests/components/ld2410_ble/__init__.py +++ b/tests/components/ld2410_ble/__init__.py @@ -1,9 +1,8 @@ """Tests for the LD2410 BLE Bluetooth integration.""" -from bleak.backends.device import BLEDevice from homeassistant.components.bluetooth import BluetoothServiceInfoBleak -from tests.components.bluetooth import generate_advertisement_data +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device LD2410_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( name="HLK-LD2410B_EEFF", @@ -13,7 +12,7 @@ LD2410_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( service_uuids=[], service_data={}, source="local", - device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="HLK-LD2410B_EEFF"), + device=generate_ble_device(address="AA:BB:CC:DD:EE:FF", name="HLK-LD2410B_EEFF"), advertisement=generate_advertisement_data(), time=0, connectable=True, @@ -30,7 +29,7 @@ NOT_LD2410_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( service_uuids=[], service_data={}, source="local", - device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="Aug"), + device=generate_ble_device(address="AA:BB:CC:DD:EE:FF", name="Aug"), advertisement=generate_advertisement_data(), time=0, connectable=True, diff --git a/tests/components/led_ble/__init__.py b/tests/components/led_ble/__init__.py index 7f48ff7a08..10eaf75875 100644 --- a/tests/components/led_ble/__init__.py +++ b/tests/components/led_ble/__init__.py @@ -1,9 +1,8 @@ """Tests for the LED BLE Bluetooth integration.""" -from bleak.backends.device import BLEDevice from homeassistant.components.bluetooth import BluetoothServiceInfoBleak -from tests.components.bluetooth import generate_advertisement_data +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device LED_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( name="Triones:F30200000152C", @@ -13,7 +12,9 @@ LED_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( service_uuids=[], service_data={}, source="local", - device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="Triones:F30200000152C"), + device=generate_ble_device( + address="AA:BB:CC:DD:EE:FF", name="Triones:F30200000152C" + ), advertisement=generate_advertisement_data(), time=0, connectable=True, @@ -27,7 +28,9 @@ UNSUPPORTED_LED_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( service_uuids=[], service_data={}, source="local", - device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="LEDnetWFF30200000152C"), + device=generate_ble_device( + address="AA:BB:CC:DD:EE:FF", name="LEDnetWFF30200000152C" + ), advertisement=generate_advertisement_data(), time=0, connectable=True, @@ -45,7 +48,7 @@ NOT_LED_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( service_uuids=[], service_data={}, source="local", - device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="Aug"), + device=generate_ble_device(address="AA:BB:CC:DD:EE:FF", name="Aug"), advertisement=generate_advertisement_data(), time=0, connectable=True, diff --git a/tests/components/melnor/conftest.py b/tests/components/melnor/conftest.py index 943018fae8..790301171c 100644 --- a/tests/components/melnor/conftest.py +++ b/tests/components/melnor/conftest.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Generator from unittest.mock import AsyncMock, patch -from bleak.backends.device import BLEDevice from melnor_bluetooth.device import Device import pytest @@ -14,7 +13,7 @@ from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -from tests.components.bluetooth import generate_advertisement_data +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device FAKE_ADDRESS_1 = "FAKE-ADDRESS-1" FAKE_ADDRESS_2 = "FAKE-ADDRESS-2" @@ -30,7 +29,7 @@ FAKE_SERVICE_INFO_1 = BluetoothServiceInfoBleak( service_uuids=[], service_data={}, source="local", - device=BLEDevice(FAKE_ADDRESS_1, None), + device=generate_ble_device(FAKE_ADDRESS_1, None), advertisement=generate_advertisement_data(local_name=""), time=0, connectable=True, @@ -46,7 +45,7 @@ FAKE_SERVICE_INFO_2 = BluetoothServiceInfoBleak( service_uuids=[], service_data={}, source="local", - device=BLEDevice(FAKE_ADDRESS_2, None), + device=generate_ble_device(FAKE_ADDRESS_2, None), advertisement=generate_advertisement_data(local_name=""), time=0, connectable=True, diff --git a/tests/components/oralb/__init__.py b/tests/components/oralb/__init__.py index d3f1b526fb..668f8804a5 100644 --- a/tests/components/oralb/__init__.py +++ b/tests/components/oralb/__init__.py @@ -1,11 +1,10 @@ """Tests for the OralB integration.""" -from bleak.backends.device import BLEDevice from home_assistant_bluetooth import BluetoothServiceInfoBleak from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo -from tests.components.bluetooth import generate_advertisement_data +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device NOT_ORALB_SERVICE_INFO = BluetoothServiceInfo( name="Not it", @@ -41,7 +40,7 @@ ORALB_IO_SERIES_4_SERVICE_INFO = BluetoothServiceInfo( ORALB_IO_SERIES_6_SERVICE_INFO = BluetoothServiceInfoBleak( name="Oral-B Toothbrush", address="B0:D2:78:20:1D:CF", - device=BLEDevice("B0:D2:78:20:1D:CF", "Oral-B Toothbrush"), + device=generate_ble_device("B0:D2:78:20:1D:CF", "Oral-B Toothbrush"), rssi=-56, manufacturer_data={220: b"\x062k\x02r\x00\x00\x02\x01\x00\x04"}, service_data={"a0f0ff00-5047-4d53-8208-4f72616c2d42": bytearray(b"1\x00\x00\x00")}, diff --git a/tests/components/snooz/__init__.py b/tests/components/snooz/__init__.py index d5802642c3..1e38978f44 100644 --- a/tests/components/snooz/__init__.py +++ b/tests/components/snooz/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations from dataclasses import dataclass from unittest.mock import patch -from bleak import BLEDevice from pysnooz.commands import SnoozCommandData from pysnooz.testing import MockSnoozDevice @@ -14,6 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo from tests.common import MockConfigEntry +from tests.components.bluetooth import generate_ble_device TEST_ADDRESS = "00:00:00:00:AB:CD" TEST_SNOOZ_LOCAL_NAME = "Snooz-ABCD" @@ -90,7 +90,7 @@ async def create_mock_snooz_config_entry( "homeassistant.components.snooz.SnoozDevice", return_value=device ), patch( "homeassistant.components.snooz.async_ble_device_from_address", - return_value=BLEDevice(device.address, device.name), + return_value=generate_ble_device(device.address, device.name), ): entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index ce39579915..257501ea19 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -1,14 +1,12 @@ """Tests for the switchbot integration.""" from unittest.mock import patch -from bleak.backends.device import BLEDevice - from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -from tests.components.bluetooth import generate_advertisement_data +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device DOMAIN = "switchbot" @@ -68,7 +66,7 @@ WOHAND_SERVICE_INFO = BluetoothServiceInfoBleak( service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x90\xd9"}, service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], ), - device=BLEDevice("AA:BB:CC:DD:EE:FF", "WoHand"), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "WoHand"), time=0, connectable=True, ) @@ -88,7 +86,7 @@ WOHAND_SERVICE_INFO_NOT_CONNECTABLE = BluetoothServiceInfoBleak( service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x90\xd9"}, service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], ), - device=BLEDevice("aa:bb:cc:dd:ee:ff", "WoHand"), + device=generate_ble_device("aa:bb:cc:dd:ee:ff", "WoHand"), time=0, connectable=False, ) @@ -108,7 +106,7 @@ WOHAND_ENCRYPTED_SERVICE_INFO = BluetoothServiceInfoBleak( service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"\xc8\x10\xcf"}, service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], ), - device=BLEDevice("798A8547-2A3D-C609-55FF-73FA824B923B", "WoHand"), + device=generate_ble_device("798A8547-2A3D-C609-55FF-73FA824B923B", "WoHand"), time=0, connectable=True, ) @@ -128,7 +126,7 @@ WOHAND_SERVICE_ALT_ADDRESS_INFO = BluetoothServiceInfoBleak( service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x90\xd9"}, service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], ), - device=BLEDevice("aa:bb:cc:dd:ee:ff", "WoHand"), + device=generate_ble_device("aa:bb:cc:dd:ee:ff", "WoHand"), time=0, connectable=True, ) @@ -146,7 +144,7 @@ WOCURTAIN_SERVICE_INFO = BluetoothServiceInfoBleak( service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"c\xd0Y\x00\x11\x04"}, service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], ), - device=BLEDevice("aa:bb:cc:dd:ee:ff", "WoCurtain"), + device=generate_ble_device("aa:bb:cc:dd:ee:ff", "WoCurtain"), time=0, connectable=True, ) @@ -163,7 +161,7 @@ WOSENSORTH_SERVICE_INFO = BluetoothServiceInfoBleak( manufacturer_data={2409: b"\xda,\x1e\xb1\x86Au\x03\x00\x96\xac"}, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"T\x00d\x00\x96\xac"}, ), - device=BLEDevice("aa:bb:cc:dd:ee:ff", "WoSensorTH"), + device=generate_ble_device("aa:bb:cc:dd:ee:ff", "WoSensorTH"), time=0, connectable=False, ) @@ -183,7 +181,7 @@ WOLOCK_SERVICE_INFO = BluetoothServiceInfoBleak( service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"o\x80d"}, service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], ), - device=BLEDevice("aa:bb:cc:dd:ee:ff", "WoLock"), + device=generate_ble_device("aa:bb:cc:dd:ee:ff", "WoLock"), time=0, connectable=True, ) @@ -200,7 +198,7 @@ NOT_SWITCHBOT_INFO = BluetoothServiceInfoBleak( manufacturer_data={}, service_data={}, ), - device=BLEDevice("aa:bb:cc:dd:ee:ff", "unknown"), + device=generate_ble_device("aa:bb:cc:dd:ee:ff", "unknown"), time=0, connectable=True, ) diff --git a/tests/components/xiaomi_ble/__init__.py b/tests/components/xiaomi_ble/__init__.py index 04fb1c03a4..ea11feab9c 100644 --- a/tests/components/xiaomi_ble/__init__.py +++ b/tests/components/xiaomi_ble/__init__.py @@ -1,15 +1,14 @@ """Tests for the SensorPush integration.""" -from bleak.backends.device import BLEDevice from homeassistant.components.bluetooth import BluetoothServiceInfoBleak -from tests.components.bluetooth import generate_advertisement_data +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device NOT_SENSOR_PUSH_SERVICE_INFO = BluetoothServiceInfoBleak( name="Not it", address="00:00:00:00:00:00", - device=BLEDevice("00:00:00:00:00:00", None), + device=generate_ble_device("00:00:00:00:00:00", None), rssi=-63, manufacturer_data={3234: b"\x00\x01"}, service_data={}, @@ -23,7 +22,7 @@ NOT_SENSOR_PUSH_SERVICE_INFO = BluetoothServiceInfoBleak( LYWSDCGQ_SERVICE_INFO = BluetoothServiceInfoBleak( name="LYWSDCGQ", address="58:2D:34:35:93:21", - device=BLEDevice("00:00:00:00:00:00", None), + device=generate_ble_device("00:00:00:00:00:00", None), rssi=-63, manufacturer_data={}, service_data={ @@ -41,7 +40,7 @@ LYWSDCGQ_SERVICE_INFO = BluetoothServiceInfoBleak( MMC_T201_1_SERVICE_INFO = BluetoothServiceInfoBleak( name="MMC_T201_1", address="00:81:F9:DD:6F:C1", - device=BLEDevice("00:00:00:00:00:00", None), + device=generate_ble_device("00:00:00:00:00:00", None), rssi=-56, manufacturer_data={}, service_data={ @@ -59,7 +58,7 @@ MMC_T201_1_SERVICE_INFO = BluetoothServiceInfoBleak( JTYJGD03MI_SERVICE_INFO = BluetoothServiceInfoBleak( name="JTYJGD03MI", address="54:EF:44:E3:9C:BC", - device=BLEDevice("00:00:00:00:00:00", None), + device=generate_ble_device("00:00:00:00:00:00", None), rssi=-56, manufacturer_data={}, service_data={ @@ -77,7 +76,7 @@ JTYJGD03MI_SERVICE_INFO = BluetoothServiceInfoBleak( YLKG07YL_SERVICE_INFO = BluetoothServiceInfoBleak( name="YLKG07YL", address="F8:24:41:C5:98:8B", - device=BLEDevice("00:00:00:00:00:00", None), + device=generate_ble_device("00:00:00:00:00:00", None), rssi=-56, manufacturer_data={}, service_data={ @@ -95,7 +94,7 @@ YLKG07YL_SERVICE_INFO = BluetoothServiceInfoBleak( HHCCJCY10_SERVICE_INFO = BluetoothServiceInfoBleak( name="HHCCJCY10", address="DC:23:4D:E5:5B:FC", - device=BLEDevice("00:00:00:00:00:00", None), + device=generate_ble_device("00:00:00:00:00:00", None), rssi=-56, manufacturer_data={}, service_data={"0000fd50-0000-1000-8000-00805f9b34fb": b"\x0e\x00n\x014\xa4(\x00["}, @@ -109,7 +108,7 @@ HHCCJCY10_SERVICE_INFO = BluetoothServiceInfoBleak( MISSING_PAYLOAD_ENCRYPTED = BluetoothServiceInfoBleak( name="LYWSD02MMC", address="A4:C1:38:56:53:84", - device=BLEDevice("00:00:00:00:00:00", None), + device=generate_ble_device("00:00:00:00:00:00", None), rssi=-56, manufacturer_data={}, service_data={ @@ -130,7 +129,7 @@ def make_advertisement( return BluetoothServiceInfoBleak( name="Test Device", address=address, - device=BLEDevice(address, None), + device=generate_ble_device(address, None), rssi=-56, manufacturer_data={}, service_data={ diff --git a/tests/components/yalexs_ble/__init__.py b/tests/components/yalexs_ble/__init__.py index 200200c0a0..62a702f2f4 100644 --- a/tests/components/yalexs_ble/__init__.py +++ b/tests/components/yalexs_ble/__init__.py @@ -1,9 +1,8 @@ """Tests for the Yale Access Bluetooth integration.""" -from bleak.backends.device import BLEDevice from homeassistant.components.bluetooth import BluetoothServiceInfoBleak -from tests.components.bluetooth import generate_advertisement_data +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device YALE_ACCESS_LOCK_DISCOVERY_INFO = BluetoothServiceInfoBleak( name="M1012LU", @@ -16,7 +15,7 @@ YALE_ACCESS_LOCK_DISCOVERY_INFO = BluetoothServiceInfoBleak( service_uuids=[], service_data={}, source="local", - device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="M1012LU"), + device=generate_ble_device(address="AA:BB:CC:DD:EE:FF", name="M1012LU"), advertisement=generate_advertisement_data(), time=0, connectable=True, @@ -34,7 +33,7 @@ LOCK_DISCOVERY_INFO_UUID_ADDRESS = BluetoothServiceInfoBleak( service_uuids=[], service_data={}, source="local", - device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="M1012LU"), + device=generate_ble_device(address="AA:BB:CC:DD:EE:FF", name="M1012LU"), advertisement=generate_advertisement_data(), time=0, connectable=True, @@ -51,7 +50,7 @@ OLD_FIRMWARE_LOCK_DISCOVERY_INFO = BluetoothServiceInfoBleak( service_uuids=[], service_data={}, source="local", - device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="Aug"), + device=generate_ble_device(address="AA:BB:CC:DD:EE:FF", name="Aug"), advertisement=generate_advertisement_data(), time=0, connectable=True, @@ -69,7 +68,7 @@ NOT_YALE_DISCOVERY_INFO = BluetoothServiceInfoBleak( service_uuids=[], service_data={}, source="local", - device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="Aug"), + device=generate_ble_device(address="AA:BB:CC:DD:EE:FF", name="Aug"), advertisement=generate_advertisement_data(), time=0, connectable=True, From e258f36ded1570b8115a0a4c56cce0fa0e820d15 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 20 Mar 2023 12:06:40 +0100 Subject: [PATCH 1088/2810] Remove deprecated binary update sensor from AVM FRITZ!Box Tools (#89940) --- homeassistant/components/fritz/binary_sensor.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/homeassistant/components/fritz/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py index d355906ec6..918a114fdf 100644 --- a/homeassistant/components/fritz/binary_sensor.py +++ b/homeassistant/components/fritz/binary_sensor.py @@ -41,15 +41,6 @@ SENSOR_TYPES: tuple[FritzBinarySensorEntityDescription, ...] = ( device_class=BinarySensorDeviceClass.PLUG, entity_category=EntityCategory.DIAGNOSTIC, ), - FritzBinarySensorEntityDescription( - # Deprecated, scheduled to be removed in 2022.7 (#70096) - entity_registry_enabled_default=False, - key="firmware_update", - name="Firmware Update", - device_class=BinarySensorDeviceClass.UPDATE, - entity_category=EntityCategory.DIAGNOSTIC, - is_suitable=lambda info: True, - ), ) @@ -89,13 +80,6 @@ class FritzBoxBinarySensor(FritzBoxBaseEntity, BinarySensorEntity): def update(self) -> None: """Update data.""" _LOGGER.debug("Updating FRITZ!Box binary sensors") - - if self.entity_description.key == "firmware_update": - self._attr_is_on = self._avm_wrapper.update_available - self._attr_extra_state_attributes = { - "installed_version": self._avm_wrapper.current_firmware, - "latest_available_version": self._avm_wrapper.latest_firmware, - } if self.entity_description.key == "is_connected": self._attr_is_on = bool(self._avm_wrapper.fritz_status.is_connected) elif self.entity_description.key == "is_linked": From 146a31163cd6ff866bad3ab6f534e95ade58885d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Mar 2023 01:07:41 -1000 Subject: [PATCH 1089/2810] Use bluetooth address instead of uuid on MacOS (#89926) --- homeassistant/components/bluetooth/scanner.py | 6 ++- tests/components/bluetooth/test_scanner.py | 52 ++++++++++++++++++- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bluetooth/scanner.py b/homeassistant/components/bluetooth/scanner.py index a80386c25e..911862a422 100644 --- a/homeassistant/components/bluetooth/scanner.py +++ b/homeassistant/components/bluetooth/scanner.py @@ -91,12 +91,16 @@ def create_bleak_scanner( "detection_callback": detection_callback, "scanning_mode": SCANNING_MODE_TO_BLEAK[scanning_mode], } - if platform.system() == "Linux": + system = platform.system() + if system == "Linux": # Only Linux supports multiple adapters if adapter: scanner_kwargs["adapter"] = adapter if scanning_mode == BluetoothScanningMode.PASSIVE: scanner_kwargs["bluez"] = PASSIVE_SCANNER_ARGS + elif system == "Darwin": + # We want mac address on macOS + scanner_kwargs["cb"] = {"use_bdaddr": True} _LOGGER.debug("Initializing bluetooth scanner with %s", scanner_kwargs) try: diff --git a/tests/components/bluetooth/test_scanner.py b/tests/components/bluetooth/test_scanner.py index 81f9765de0..fcff8c15d5 100644 --- a/tests/components/bluetooth/test_scanner.py +++ b/tests/components/bluetooth/test_scanner.py @@ -2,7 +2,7 @@ import asyncio from datetime import timedelta import time -from unittest.mock import MagicMock, patch +from unittest.mock import ANY, MagicMock, patch from bleak import BleakError from bleak.backends.scanner import AdvertisementDataCallback @@ -18,6 +18,7 @@ from homeassistant.components.bluetooth.scanner import NEED_RESET_ERRORS from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from . import ( @@ -27,7 +28,7 @@ from . import ( generate_ble_device, ) -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed async def test_config_entry_can_be_reloaded_when_stop_raises( @@ -580,3 +581,50 @@ async def test_restart_takes_longer_than_watchdog_time( await hass.async_block_till_done() assert "already restarting" in caplog.text + + +async def test_setup_and_stop_macos( + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, macos_adapter: None +) -> None: + """Test we enable use_bdaddr on MacOS.""" + entry = MockConfigEntry( + domain=bluetooth.DOMAIN, + data={}, + unique_id="00:00:00:00:00:00", + ) + entry.add_to_hass(hass) + init_kwargs = None + + class MockBleakScanner: + def __init__(self, *args, **kwargs): + """Init the scanner.""" + nonlocal init_kwargs + init_kwargs = kwargs + + async def start(self, *args, **kwargs): + """Start the scanner.""" + + async def stop(self, *args, **kwargs): + """Stop the scanner.""" + + def register_detection_callback(self, *args, **kwargs): + """Register a callback.""" + + with patch( + "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + MockBleakScanner, + ): + assert await async_setup_component( + hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} + ) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + assert init_kwargs == { + "detection_callback": ANY, + "scanning_mode": "active", + "cb": {"use_bdaddr": True}, + } From 9f1e170851140fdf7946752ac9b972b4fb64cf7d Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Mon, 20 Mar 2023 12:08:27 +0100 Subject: [PATCH 1090/2810] Correct missing wordswap for S series nibe (#89866) Correct missing wordswap for nibe --- homeassistant/components/nibe_heatpump/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nibe_heatpump/__init__.py b/homeassistant/components/nibe_heatpump/__init__.py index fd77b5e234..89aac6bed6 100644 --- a/homeassistant/components/nibe_heatpump/__init__.py +++ b/homeassistant/components/nibe_heatpump/__init__.py @@ -62,13 +62,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Nibe Heat Pump from a config entry.""" heatpump = HeatPump(Model[entry.data[CONF_MODEL]]) + heatpump.word_swap = entry.data.get(CONF_WORD_SWAP, True) await heatpump.initialize() connection: Connection connection_type = entry.data[CONF_CONNECTION_TYPE] if connection_type == CONF_CONNECTION_TYPE_NIBEGW: - heatpump.word_swap = entry.data[CONF_WORD_SWAP] connection = NibeGW( heatpump, entry.data[CONF_IP_ADDRESS], From 0bf652ca960358ba151118469686a1bf0221b331 Mon Sep 17 00:00:00 2001 From: Malte Franken Date: Mon, 20 Mar 2023 23:26:38 +1100 Subject: [PATCH 1091/2810] Refactor constants in geo_json_events integration (#89912) move constants to separate file --- .../components/geo_json_events/const.py | 15 +++++++++++++ .../geo_json_events/geo_location.py | 22 +++++++++---------- .../components/geo_json_events/manager.py | 6 ++--- .../geo_json_events/test_geo_location.py | 18 +++++++-------- 4 files changed, 38 insertions(+), 23 deletions(-) create mode 100644 homeassistant/components/geo_json_events/const.py diff --git a/homeassistant/components/geo_json_events/const.py b/homeassistant/components/geo_json_events/const.py new file mode 100644 index 0000000000..4c73be3995 --- /dev/null +++ b/homeassistant/components/geo_json_events/const.py @@ -0,0 +1,15 @@ +"""Define constants for the GeoJSON events integration.""" +from __future__ import annotations + +from datetime import timedelta +from typing import Final + +DOMAIN: Final = "geo_json_events" + +ATTR_EXTERNAL_ID: Final = "external_id" +DEFAULT_RADIUS_IN_KM: Final = 20.0 +DEFAULT_SCAN_INTERVAL: Final = timedelta(minutes=5) +SOURCE: Final = "geo_json_events" + +SIGNAL_DELETE_ENTITY: Final = "geo_json_events_delete_{}" +SIGNAL_UPDATE_ENTITY: Final = "geo_json_events_update_{}" diff --git a/homeassistant/components/geo_json_events/geo_location.py b/homeassistant/components/geo_json_events/geo_location.py index 2df049dd9c..df2978b654 100644 --- a/homeassistant/components/geo_json_events/geo_location.py +++ b/homeassistant/components/geo_json_events/geo_location.py @@ -26,18 +26,18 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .const import ( + ATTR_EXTERNAL_ID, + DEFAULT_RADIUS_IN_KM, + DEFAULT_SCAN_INTERVAL, + SIGNAL_DELETE_ENTITY, + SIGNAL_UPDATE_ENTITY, + SOURCE, +) from .manager import GeoJsonFeedEntityManager _LOGGER = logging.getLogger(__name__) -ATTR_EXTERNAL_ID = "external_id" - -DEFAULT_RADIUS_IN_KM = 20.0 - -SCAN_INTERVAL = timedelta(minutes=5) - -SOURCE = "geo_json_events" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_URL): cv.string, @@ -56,7 +56,7 @@ async def async_setup_platform( ) -> None: """Set up the GeoJSON Events platform.""" url: str = config[CONF_URL] - scan_interval: timedelta = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) + scan_interval: timedelta = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) coordinates: tuple[float, float] = ( config.get(CONF_LATITUDE, hass.config.latitude), config.get(CONF_LONGITUDE, hass.config.longitude), @@ -106,12 +106,12 @@ class GeoJsonLocationEvent(GeolocationEvent): """Call when entity is added to hass.""" self._remove_signal_delete = async_dispatcher_connect( self.hass, - f"geo_json_events_delete_{self._external_id}", + SIGNAL_DELETE_ENTITY.format(self._external_id), self._delete_callback, ) self._remove_signal_update = async_dispatcher_connect( self.hass, - f"geo_json_events_update_{self._external_id}", + SIGNAL_UPDATE_ENTITY.format(self._external_id), self._update_callback, ) diff --git a/homeassistant/components/geo_json_events/manager.py b/homeassistant/components/geo_json_events/manager.py index 6c51e6dd72..a999d224ac 100644 --- a/homeassistant/components/geo_json_events/manager.py +++ b/homeassistant/components/geo_json_events/manager.py @@ -12,7 +12,7 @@ from homeassistant.helpers import aiohttp_client from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval -DOMAIN = "geo_json_events" +from .const import DOMAIN, SIGNAL_DELETE_ENTITY, SIGNAL_UPDATE_ENTITY _LOGGER = logging.getLogger(__name__) @@ -77,8 +77,8 @@ class GeoJsonFeedEntityManager: async def _update_entity(self, external_id: str) -> None: """Update entity.""" - async_dispatcher_send(self._hass, f"geo_json_events_update_{external_id}") + async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) async def _remove_entity(self, external_id: str) -> None: """Remove entity.""" - async_dispatcher_send(self._hass, f"geo_json_events_delete_{external_id}") + async_dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) diff --git a/tests/components/geo_json_events/test_geo_location.py b/tests/components/geo_json_events/test_geo_location.py index b79c5f1504..529d78fd83 100644 --- a/tests/components/geo_json_events/test_geo_location.py +++ b/tests/components/geo_json_events/test_geo_location.py @@ -5,9 +5,9 @@ from aio_geojson_generic_client import GenericFeed from freezegun import freeze_time from homeassistant.components import geo_location -from homeassistant.components.geo_json_events.geo_location import ( +from homeassistant.components.geo_json_events.const import ( ATTR_EXTERNAL_ID, - SCAN_INTERVAL, + DEFAULT_SCAN_INTERVAL, ) from homeassistant.components.geo_location import ATTR_SOURCE from homeassistant.const import ( @@ -132,7 +132,7 @@ async def test_setup(hass: HomeAssistant) -> None: "OK", [mock_entry_1, mock_entry_4, mock_entry_3], ) - async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) + async_fire_time_changed(hass, utcnow + DEFAULT_SCAN_INTERVAL) await hass.async_block_till_done() all_states = hass.states.async_all() @@ -141,7 +141,7 @@ async def test_setup(hass: HomeAssistant) -> None: # Simulate an update - empty data, but successful update, # so no changes to entities. mock_feed_update.return_value = "OK_NO_DATA", None - async_fire_time_changed(hass, utcnow + 2 * SCAN_INTERVAL) + async_fire_time_changed(hass, utcnow + 2 * DEFAULT_SCAN_INTERVAL) await hass.async_block_till_done() all_states = hass.states.async_all() @@ -149,7 +149,7 @@ async def test_setup(hass: HomeAssistant) -> None: # Simulate an update - empty data, removes all entities mock_feed_update.return_value = "ERROR", None - async_fire_time_changed(hass, utcnow + 3 * SCAN_INTERVAL) + async_fire_time_changed(hass, utcnow + 3 * DEFAULT_SCAN_INTERVAL) await hass.async_block_till_done() all_states = hass.states.async_all() @@ -227,7 +227,7 @@ async def test_setup_race_condition(hass: HomeAssistant) -> None: # Simulate an update - empty data, removes all entities mock_feed_update.return_value = "ERROR", None - async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) + async_fire_time_changed(hass, utcnow + DEFAULT_SCAN_INTERVAL) await hass.async_block_till_done() all_states = hass.states.async_all() @@ -237,7 +237,7 @@ async def test_setup_race_condition(hass: HomeAssistant) -> None: # Simulate an update - 1 entry mock_feed_update.return_value = "OK", [mock_entry_1] - async_fire_time_changed(hass, utcnow + 2 * SCAN_INTERVAL) + async_fire_time_changed(hass, utcnow + 2 * DEFAULT_SCAN_INTERVAL) await hass.async_block_till_done() all_states = hass.states.async_all() @@ -247,7 +247,7 @@ async def test_setup_race_condition(hass: HomeAssistant) -> None: # Simulate an update - 1 entry mock_feed_update.return_value = "OK", [mock_entry_1] - async_fire_time_changed(hass, utcnow + 3 * SCAN_INTERVAL) + async_fire_time_changed(hass, utcnow + 3 * DEFAULT_SCAN_INTERVAL) await hass.async_block_till_done() all_states = hass.states.async_all() @@ -257,7 +257,7 @@ async def test_setup_race_condition(hass: HomeAssistant) -> None: # Simulate an update - empty data, removes all entities mock_feed_update.return_value = "ERROR", None - async_fire_time_changed(hass, utcnow + 4 * SCAN_INTERVAL) + async_fire_time_changed(hass, utcnow + 4 * DEFAULT_SCAN_INTERVAL) await hass.async_block_till_done() all_states = hass.states.async_all() From b9ff69d3ac8e9fd8353c10d553cba588dc222266 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 20 Mar 2023 13:39:20 +0100 Subject: [PATCH 1092/2810] Extend attribute state translations for Camera (#89876) * Extend attribute state translations for Camera * Add common generic translations --- homeassistant/components/camera/strings.json | 25 ++++++++++++++++++++ homeassistant/strings.json | 3 +++ 2 files changed, 28 insertions(+) diff --git a/homeassistant/components/camera/strings.json b/homeassistant/components/camera/strings.json index 06ddaeeb09..0722ec1c5e 100644 --- a/homeassistant/components/camera/strings.json +++ b/homeassistant/components/camera/strings.json @@ -7,6 +7,31 @@ "recording": "Recording", "streaming": "Streaming", "idle": "[%key:common::state::idle%]" + }, + "state_attributes": { + "access_token": { + "name": "Access token" + }, + "brand": { + "name": "Brand" + }, + "frontend_stream_type": { + "name": "Stream type", + "state": { + "hls": "HLS", + "webrtc": "WebRTC" + } + }, + "motion_detection": { + "name": "Motion detection", + "state": { + "true": "Enabled", + "false": "Disabled" + } + }, + "model_name": { + "name": "[%key:common::generic::model%]" + } } } } diff --git a/homeassistant/strings.json b/homeassistant/strings.json index f3829cd2bd..ad18b675e0 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -1,5 +1,8 @@ { "common": { + "generic": { + "model": "Model" + }, "state": { "off": "Off", "on": "On", From 9949ca13aa7d04860774e41d8ec3353b5830504b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 20 Mar 2023 13:42:59 +0100 Subject: [PATCH 1093/2810] Adjust state class of Toon monetary sensors (#89985) --- homeassistant/components/toon/sensor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/toon/sensor.py b/homeassistant/components/toon/sensor.py index 3b06f5d38b..90dd466045 100644 --- a/homeassistant/components/toon/sensor.py +++ b/homeassistant/components/toon/sensor.py @@ -183,7 +183,7 @@ SENSOR_ENTITIES: tuple[ToonSensorEntityDescription, ...] = ( section="gas_usage", measurement="day_cost", device_class=SensorDeviceClass.MONETARY, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, native_unit_of_measurement=CURRENCY_EUR, icon="mdi:gas-cylinder", cls=ToonGasMeterDeviceSensor, @@ -233,7 +233,7 @@ SENSOR_ENTITIES: tuple[ToonSensorEntityDescription, ...] = ( section="power_usage", measurement="day_cost", device_class=SensorDeviceClass.MONETARY, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, native_unit_of_measurement=CURRENCY_EUR, icon="mdi:power-plug", cls=ToonElectricityMeterDeviceSensor, @@ -358,7 +358,7 @@ SENSOR_ENTITIES: tuple[ToonSensorEntityDescription, ...] = ( section="water_usage", measurement="day_cost", device_class=SensorDeviceClass.MONETARY, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, native_unit_of_measurement=CURRENCY_EUR, icon="mdi:water-pump", entity_registry_enabled_default=False, From 9a5ceb9ef832f1ca4e95fc00708ae24db3331707 Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Mon, 20 Mar 2023 15:44:05 +0100 Subject: [PATCH 1094/2810] Use default rounding/presentation mechanism for upnp (#89954) --- homeassistant/components/upnp/coordinator.py | 11 +++++---- homeassistant/components/upnp/device.py | 7 ++++-- homeassistant/components/upnp/entity.py | 1 - homeassistant/components/upnp/sensor.py | 25 ++++++++++---------- 4 files changed, 24 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/upnp/coordinator.py b/homeassistant/components/upnp/coordinator.py index 2820a58463..72e14ecc4f 100644 --- a/homeassistant/components/upnp/coordinator.py +++ b/homeassistant/components/upnp/coordinator.py @@ -1,7 +1,6 @@ """UPnP/IGD coordinator.""" -from datetime import timedelta -from typing import Any +from datetime import datetime, timedelta from async_upnp_client.exceptions import UpnpCommunicationError @@ -13,7 +12,9 @@ from .const import LOGGER from .device import Device -class UpnpDataUpdateCoordinator(DataUpdateCoordinator): +class UpnpDataUpdateCoordinator( + DataUpdateCoordinator[dict[str, str | datetime | int | float | None]] +): """Define an object to update data from UPNP device.""" def __init__( @@ -34,7 +35,9 @@ class UpnpDataUpdateCoordinator(DataUpdateCoordinator): update_interval=update_interval, ) - async def _async_update_data(self) -> dict[str, Any]: + async def _async_update_data( + self, + ) -> dict[str, str | datetime | int | float | None]: """Update data.""" try: return await self.device.async_get_data() diff --git a/homeassistant/components/upnp/device.py b/homeassistant/components/upnp/device.py index ed06a9eb36..b62edbf9bc 100644 --- a/homeassistant/components/upnp/device.py +++ b/homeassistant/components/upnp/device.py @@ -1,6 +1,7 @@ """Home Assistant representation of an UPnP/IGD.""" from __future__ import annotations +from datetime import datetime from functools import partial from ipaddress import ip_address from typing import Any @@ -68,7 +69,9 @@ class Device: """Initialize UPnP/IGD device.""" self.hass = hass self._igd_device = igd_device - self.coordinator: DataUpdateCoordinator | None = None + self.coordinator: DataUpdateCoordinator[ + dict[str, str | datetime | int | float | None] + ] | None = None self.original_udn: str | None = None async def async_get_mac_address(self) -> str | None: @@ -134,7 +137,7 @@ class Device: """Get string representation.""" return f"IGD Device: {self.name}/{self.udn}::{self.device_type}" - async def async_get_data(self) -> dict[str, Any]: + async def async_get_data(self) -> dict[str, str | datetime | int | float | None]: """Get all data from device.""" _LOGGER.debug("Getting data for device: %s", self) igd_state = await self._igd_device.async_get_traffic_and_status_data() diff --git a/homeassistant/components/upnp/entity.py b/homeassistant/components/upnp/entity.py index b787018adc..cd39609d9d 100644 --- a/homeassistant/components/upnp/entity.py +++ b/homeassistant/components/upnp/entity.py @@ -13,7 +13,6 @@ from .coordinator import UpnpDataUpdateCoordinator class UpnpEntityDescription(EntityDescription): """UPnP entity description.""" - format: str = "s" unique_id: str | None = None value_key: str | None = None diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index 1a374714be..6f0fe340f3 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import datetime from homeassistant.components.sensor import ( SensorDeviceClass, @@ -52,9 +53,9 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( icon="mdi:server-network", device_class=SensorDeviceClass.DATA_SIZE, native_unit_of_measurement=UnitOfInformation.BYTES, - format="d", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, ), UpnpSensorEntityDescription( key=BYTES_SENT, @@ -62,27 +63,27 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( icon="mdi:server-network", device_class=SensorDeviceClass.DATA_SIZE, native_unit_of_measurement=UnitOfInformation.BYTES, - format="d", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, ), UpnpSensorEntityDescription( key=PACKETS_RECEIVED, name=f"{DATA_PACKETS} received", icon="mdi:server-network", native_unit_of_measurement=DATA_PACKETS, - format="d", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, ), UpnpSensorEntityDescription( key=PACKETS_SENT, name=f"{DATA_PACKETS} sent", icon="mdi:server-network", native_unit_of_measurement=DATA_PACKETS, - format="d", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, ), UpnpSensorEntityDescription( key=ROUTER_IP, @@ -96,8 +97,8 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( icon="mdi:server-network", native_unit_of_measurement=UnitOfTime.SECONDS, entity_registry_enabled_default=False, - format="d", entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=0, ), UpnpSensorEntityDescription( key=WAN_STATUS, @@ -114,8 +115,8 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( icon="mdi:server-network", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, - format=".1f", state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), UpnpSensorEntityDescription( key=BYTES_SENT, @@ -125,8 +126,8 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( icon="mdi:server-network", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, - format=".1f", state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), UpnpSensorEntityDescription( key=PACKETS_RECEIVED, @@ -135,9 +136,9 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( name=f"{DATA_RATE_PACKETS_PER_SECOND} received", icon="mdi:server-network", native_unit_of_measurement=DATA_RATE_PACKETS_PER_SECOND, - format=".1f", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), UpnpSensorEntityDescription( key=PACKETS_SENT, @@ -146,9 +147,9 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( name=f"{DATA_RATE_PACKETS_PER_SECOND} sent", icon="mdi:server-network", native_unit_of_measurement=DATA_RATE_PACKETS_PER_SECOND, - format=".1f", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), ) @@ -180,10 +181,8 @@ class UpnpSensor(UpnpEntity, SensorEntity): entity_description: UpnpSensorEntityDescription @property - def native_value(self) -> str | None: + def native_value(self) -> str | datetime | int | float | None: """Return the state of the device.""" if (key := self.entity_description.value_key) is None: return None - if (value := self.coordinator.data[key]) is None: - return None - return format(value, self.entity_description.format) + return self.coordinator.data[key] From e4275a053c9c681c49363d0adf6a07a085f98a5a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 20 Mar 2023 15:52:07 +0100 Subject: [PATCH 1095/2810] Remove yaml import from imap integration (#89981) * Remove yaml import from imap integration * Cleanup sensor code and strings.json --- homeassistant/components/imap/config_flow.py | 9 +-- homeassistant/components/imap/sensor.py | 58 ++------------------ homeassistant/components/imap/strings.json | 6 -- tests/components/imap/test_config_flow.py | 43 --------------- 4 files changed, 6 insertions(+), 110 deletions(-) diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index 7306d07d06..36528ae6ed 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -9,7 +9,7 @@ from aioimaplib import AioImapException import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv @@ -87,8 +87,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) if not (errors := await validate_input(user_input)): - # To be removed when YAML import is removed - title = user_input.get(CONF_NAME, user_input[CONF_USERNAME]) + title = user_input[CONF_USERNAME] return self.async_create_entry(title=title, data=user_input) @@ -96,10 +95,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) - async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: - """Import a config entry from configuration.yaml.""" - return await self.async_step_user(import_config) - async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" self._reauth_entry = self.hass.config_entries.async_get_entry( diff --git a/homeassistant/components/imap/sensor.py b/homeassistant/components/imap/sensor.py index 0bccce0c98..4dc0c0fffb 100644 --- a/homeassistant/components/imap/sensor.py +++ b/homeassistant/components/imap/sensor.py @@ -1,67 +1,17 @@ """IMAP sensor support.""" from __future__ import annotations -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import ImapPollingDataUpdateCoordinator, ImapPushDataUpdateCoordinator -from .const import ( - CONF_CHARSET, - CONF_FOLDER, - CONF_SEARCH, - CONF_SERVER, - DEFAULT_PORT, - DOMAIN, -) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAME): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_SERVER): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_CHARSET, default="utf-8"): cv.string, - vol.Optional(CONF_FOLDER, default="INBOX"): cv.string, - vol.Optional(CONF_SEARCH, default="UnSeen UnDeleted"): cv.string, - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the IMAP platform.""" - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2023.4.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) +from .const import DOMAIN async def async_setup_entry( diff --git a/homeassistant/components/imap/strings.json b/homeassistant/components/imap/strings.json index 25bcf840c3..2fedef55f6 100644 --- a/homeassistant/components/imap/strings.json +++ b/homeassistant/components/imap/strings.json @@ -30,11 +30,5 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } - }, - "issues": { - "deprecated_yaml": { - "title": "The IMAP YAML configuration is being removed", - "description": "Configuring IMAP using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the IMAP YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/tests/components/imap/test_config_flow.py b/tests/components/imap/test_config_flow.py index 7fc5f99884..ad8e63fa61 100644 --- a/tests/components/imap/test_config_flow.py +++ b/tests/components/imap/test_config_flow.py @@ -59,49 +59,6 @@ async def test_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_import_flow_success(hass: HomeAssistant) -> None: - """Test a successful import of yaml.""" - with patch( - "homeassistant.components.imap.config_flow.connect_to_server" - ) as mock_client, patch( - "homeassistant.components.imap.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - mock_client.return_value.search.return_value = ( - "OK", - [b""], - ) - result2 = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - "name": "IMAP", - "username": "email@email.com", - "password": "password", - "server": "imap.server.com", - "port": 993, - "charset": "utf-8", - "folder": "INBOX", - "search": "UnSeen UnDeleted", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "IMAP" - assert result2["data"] == { - "name": "IMAP", - "username": "email@email.com", - "password": "password", - "server": "imap.server.com", - "port": 993, - "charset": "utf-8", - "folder": "INBOX", - "search": "UnSeen UnDeleted", - } - assert len(mock_setup_entry.mock_calls) == 1 - - async def test_entry_already_configured(hass: HomeAssistant) -> None: """Test aborting if the entry is already configured.""" entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) From 51b12cbf960bffc4ee9eced9a58bdd45da67e617 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 20 Mar 2023 15:52:54 +0100 Subject: [PATCH 1096/2810] Add user_input as suggested value in imap config flow (#89982) Add user_input as suggested value to config_schema --- homeassistant/components/imap/config_flow.py | 5 ++--- tests/components/imap/test_config_flow.py | 6 ++++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index 36528ae6ed..de1ac1e5d6 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -91,9 +91,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=title, data=user_input) - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors - ) + schema = self.add_suggested_values_to_schema(STEP_USER_DATA_SCHEMA, user_input) + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" diff --git a/tests/components/imap/test_config_flow.py b/tests/components/imap/test_config_flow.py index ad8e63fa61..663637ff0b 100644 --- a/tests/components/imap/test_config_flow.py +++ b/tests/components/imap/test_config_flow.py @@ -129,6 +129,12 @@ async def test_form_cannot_connect(hass: HomeAssistant, exc: Exception) -> None: assert result2["type"] == FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} + # make sure we do not lose the user input if somethings gets wrong + assert { + key: key.description.get("suggested_value") + for key in result2["data_schema"].schema + } == MOCK_CONFIG + async def test_form_invalid_charset(hass: HomeAssistant) -> None: """Test we handle invalid charset.""" From 6bb80adbb9079e1304062bbc565a3be84a076275 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Mar 2023 06:15:11 -1000 Subject: [PATCH 1097/2810] Rollback the session after performing stats schema validation (#89904) --- homeassistant/components/recorder/statistics.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 989dc06db5..34adcbddcc 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -2504,7 +2504,9 @@ def _validate_db_schema_utf8( # Try inserting some metadata which needs utfmb4 support try: - with session_scope(session=session_maker()) as session: + # Mark the session as read_only to ensure that the test data is not committed + # to the database and we always rollback when the scope is exited + with session_scope(session=session_maker(), read_only=True) as session: old_metadata_dict = statistics_meta_manager.get_many( session, statistic_ids={statistic_id} ) @@ -2605,7 +2607,9 @@ def _validate_db_schema( StatisticsShortTerm, ) try: - with session_scope(session=session_maker()) as session: + # Mark the session as read_only to ensure that the test data is not committed + # to the database and we always rollback when the scope is exited + with session_scope(session=session_maker(), read_only=True) as session: for table in tables: _import_statistics_with_session( instance, session, metadata, (statistics,), table From 20c9ed6d892ad703148d902fe423dd4a623bd5f2 Mon Sep 17 00:00:00 2001 From: Jon Caruana Date: Mon, 20 Mar 2023 12:01:14 -0700 Subject: [PATCH 1098/2810] Mark LiteJet as Platinum integration (#88623) --- homeassistant/components/litejet/manifest.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/litejet/manifest.json b/homeassistant/components/litejet/manifest.json index b2b213d06f..136880257c 100644 --- a/homeassistant/components/litejet/manifest.json +++ b/homeassistant/components/litejet/manifest.json @@ -4,7 +4,9 @@ "codeowners": ["@joncar"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/litejet", + "integration_type": "hub", "iot_class": "local_push", "loggers": ["pylitejet"], + "quality_scale": "platinum", "requirements": ["pylitejet==0.5.0"] } From 49f08ad71d2db803675def882118d773462cf81e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Mar 2023 09:04:46 -1000 Subject: [PATCH 1099/2810] Filter out duplicate updates in esphome state dispatch (#89779) --- homeassistant/components/esphome/__init__.py | 6 +++++- .../components/esphome/entry_data.py | 19 ++++++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 59db885d45..192a19e480 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -345,6 +345,10 @@ async def async_setup_entry( # noqa: C901 disconnect_cb() entry_data.disconnect_callbacks = [] entry_data.available = False + # Clear out the states so that we will always dispatch + # the next state update of that type when the device reconnects + for state_keys in entry_data.state.values(): + state_keys.clear() entry_data.async_update_device_state(hass) async def on_connect_error(err: Exception) -> None: @@ -760,7 +764,7 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): self.async_on_remove( async_dispatcher_connect( self.hass, - f"esphome_{self._entry_id}_on_device_update", + self._entry_data.signal_device_updated, self._on_device_update, ) ) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 0aed6ce43a..4d03542708 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -39,6 +39,7 @@ from homeassistant.helpers.storage import Store from .dashboard import async_get_dashboard +_SENTINEL = object() SAVE_DELAY = 120 _LOGGER = logging.getLogger(__name__) @@ -198,14 +199,26 @@ class RuntimeEntryData: @callback def async_update_state(self, state: EntityState) -> None: """Distribute an update of state information to the target.""" - subscription_key = (type(state), state.key) - self.state[type(state)][state.key] = state + key = state.key + state_type = type(state) + current_state_by_type = self.state[state_type] + current_state = current_state_by_type.get(key, _SENTINEL) + if current_state == state: + _LOGGER.debug( + "%s: ignoring duplicate update with and key %s: %s", + self.name, + key, + state, + ) + return _LOGGER.debug( "%s: dispatching update with key %s: %s", self.name, - subscription_key, + key, state, ) + current_state_by_type[key] = state + subscription_key = (state_type, key) if subscription_key in self.state_subscriptions: self.state_subscriptions[subscription_key]() From 18df3a22cae94347d5608f0370161ba53e8a2f57 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 20 Mar 2023 20:06:44 +0100 Subject: [PATCH 1100/2810] Add FTTH information to SFR Box (#89781) --- homeassistant/components/sfr_box/__init__.py | 5 +- .../components/sfr_box/binary_sensor.py | 18 ++- homeassistant/components/sfr_box/models.py | 3 +- .../sfr_box/snapshots/test_binary_sensor.ambr | 123 +++++++++++++++++- .../components/sfr_box/test_binary_sensor.py | 9 +- 5 files changed, 149 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/sfr_box/__init__.py b/homeassistant/components/sfr_box/__init__.py index b4014c159a..564f1970b6 100644 --- a/homeassistant/components/sfr_box/__init__.py +++ b/homeassistant/components/sfr_box/__init__.py @@ -36,6 +36,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data = DomainData( box=box, dsl=SFRDataUpdateCoordinator(hass, box, "dsl", lambda b: b.dsl_get_info()), + ftth=SFRDataUpdateCoordinator(hass, box, "ftth", lambda b: b.ftth_get_info()), system=SFRDataUpdateCoordinator( hass, box, "system", lambda b: b.system_get_info() ), @@ -47,8 +48,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Preload other coordinators (based on net infrastructure) tasks = [data.wan.async_config_entry_first_refresh()] - if system_info.net_infra == "adsl": + if (net_infra := system_info.net_infra) == "adsl": tasks.append(data.dsl.async_config_entry_first_refresh()) + elif net_infra == "ftth": + tasks.append(data.ftth.async_config_entry_first_refresh()) await asyncio.gather(*tasks) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = data diff --git a/homeassistant/components/sfr_box/binary_sensor.py b/homeassistant/components/sfr_box/binary_sensor.py index 83c7cd8d10..8758764a14 100644 --- a/homeassistant/components/sfr_box/binary_sensor.py +++ b/homeassistant/components/sfr_box/binary_sensor.py @@ -5,7 +5,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Generic, TypeVar -from sfrbox_api.models import DslInfo, SystemInfo, WanInfo +from sfrbox_api.models import DslInfo, FtthInfo, SystemInfo, WanInfo from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -48,6 +48,15 @@ DSL_SENSOR_TYPES: tuple[SFRBoxBinarySensorEntityDescription[DslInfo], ...] = ( value_fn=lambda x: x.status == "up", ), ) +FTTH_SENSOR_TYPES: tuple[SFRBoxBinarySensorEntityDescription[FtthInfo], ...] = ( + SFRBoxBinarySensorEntityDescription[FtthInfo]( + key="status", + name="FTTH status", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda x: x.status == "up", + ), +) WAN_SENSOR_TYPES: tuple[SFRBoxBinarySensorEntityDescription[WanInfo], ...] = ( SFRBoxBinarySensorEntityDescription[WanInfo]( key="status", @@ -69,11 +78,16 @@ async def async_setup_entry( SFRBoxBinarySensor(data.wan, description, data.system.data) for description in WAN_SENSOR_TYPES ] - if data.system.data.net_infra == "adsl": + if (net_infra := data.system.data.net_infra) == "adsl": entities.extend( SFRBoxBinarySensor(data.dsl, description, data.system.data) for description in DSL_SENSOR_TYPES ) + elif net_infra == "ftth": + entities.extend( + SFRBoxBinarySensor(data.ftth, description, data.system.data) + for description in FTTH_SENSOR_TYPES + ) async_add_entities(entities) diff --git a/homeassistant/components/sfr_box/models.py b/homeassistant/components/sfr_box/models.py index 9302de83e7..ff723c2c6e 100644 --- a/homeassistant/components/sfr_box/models.py +++ b/homeassistant/components/sfr_box/models.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from sfrbox_api.bridge import SFRBox -from sfrbox_api.models import DslInfo, SystemInfo, WanInfo +from sfrbox_api.models import DslInfo, FtthInfo, SystemInfo, WanInfo from .coordinator import SFRDataUpdateCoordinator @@ -13,5 +13,6 @@ class DomainData: box: SFRBox dsl: SFRDataUpdateCoordinator[DslInfo] + ftth: SFRDataUpdateCoordinator[FtthInfo] system: SFRDataUpdateCoordinator[SystemInfo] wan: SFRDataUpdateCoordinator[WanInfo] diff --git a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr index a932a3beae..dcb3508ace 100644 --- a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_binary_sensors +# name: test_binary_sensors[adsl] list([ DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -28,7 +28,7 @@ }), ]) # --- -# name: test_binary_sensors.1 +# name: test_binary_sensors[adsl].1 list([ EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -88,7 +88,7 @@ }), ]) # --- -# name: test_binary_sensors[binary_sensor.sfr_box_dsl_status] +# name: test_binary_sensors[adsl][binary_sensor.sfr_box_dsl_status] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', @@ -101,7 +101,122 @@ 'state': 'on', }) # --- -# name: test_binary_sensors[binary_sensor.sfr_box_wan_status] +# name: test_binary_sensors[adsl][binary_sensor.sfr_box_wan_status] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'SFR Box WAN status', + }), + 'context': , + 'entity_id': 'binary_sensor.sfr_box_wan_status', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[ftth] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://192.168.0.1', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'sfr_box', + 'e4:5d:51:00:11:22', + ), + }), + 'is_new': False, + 'manufacturer': None, + 'model': 'NB6VAC-FXC-r0', + 'name': 'SFR Box', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'NB6VAC-MAIN-R4.0.44k', + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensors[ftth].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.sfr_box_wan_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'WAN status', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e4:5d:51:00:11:22_wan_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.sfr_box_ftth_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'FTTH status', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e4:5d:51:00:11:22_ftth_status', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_binary_sensors[ftth][binary_sensor.sfr_box_ftth_status] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'SFR Box FTTH status', + }), + 'context': , + 'entity_id': 'binary_sensor.sfr_box_ftth_status', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[ftth][binary_sensor.sfr_box_wan_status] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', diff --git a/tests/components/sfr_box/test_binary_sensor.py b/tests/components/sfr_box/test_binary_sensor.py index c7a643a916..db6124bec3 100644 --- a/tests/components/sfr_box/test_binary_sensor.py +++ b/tests/components/sfr_box/test_binary_sensor.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import patch import pytest +from sfrbox_api.models import SystemInfo from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry @@ -10,7 +11,9 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -pytestmark = pytest.mark.usefixtures("system_get_info", "dsl_get_info", "wan_get_info") +pytestmark = pytest.mark.usefixtures( + "system_get_info", "dsl_get_info", "ftth_get_info", "wan_get_info" +) @pytest.fixture(autouse=True) @@ -20,14 +23,18 @@ def override_platforms() -> Generator[None, None, None]: yield +@pytest.mark.parametrize("net_infra", ["adsl", "ftth"]) async def test_binary_sensors( hass: HomeAssistant, config_entry: ConfigEntry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + system_get_info: SystemInfo, + net_infra: str, ) -> None: """Test for SFR Box binary sensors.""" + system_get_info.net_infra = net_infra await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() From cbe85126cb8e01d99037eb69e852f6bb965551f4 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 20 Mar 2023 18:30:56 -0400 Subject: [PATCH 1101/2810] Introduce a delay between update entity calls (#89737) * Introduce a delay between update entity calls * Update homeassistant/components/zwave_js/update.py Co-authored-by: Martin Hjelmare * move delay to constant and patch * rename constant * Switch to async_call_later * Remove failing test * Reimplement to solve task problem * comment * pass count directly so that value doesn't mutate before we store it * lines * Fix logic and tests * Comments * Readd missed coverage * Add test for delays * cleanup * Fix async_added_to_hass logic * flip conditional * Store firmware info in extra data so we can restore it along with latest version * Comment * comment * Add test for is_running check and fix bugs * comment * Add tests for various restore state scenarios * move comment so it's less confusing * improve typing * consolidate into constant and remove unused one * Update update.py * update test to unknown state during partial restore * fix elif check * Fix type * clean up test docstrings and function names --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/zwave_js/update.py | 101 +++++-- tests/components/zwave_js/conftest.py | 6 + tests/components/zwave_js/test_update.py | 296 +++++++++++++++++--- 3 files changed, 350 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index 5485870dc5..33cb0a1c5a 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -2,9 +2,11 @@ from __future__ import annotations import asyncio +from collections import Counter from collections.abc import Callable +from dataclasses import asdict, dataclass from datetime import datetime, timedelta -from typing import Any +from typing import Any, Final from awesomeversion import AwesomeVersion from zwave_js_server.client import Client as ZwaveClient @@ -19,41 +21,72 @@ from zwave_js_server.model.node.firmware import ( ) from homeassistant.components.update import ( + ATTR_LATEST_VERSION, UpdateDeviceClass, UpdateEntity, UpdateEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CoreState, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.start import async_at_start +from homeassistant.helpers.restore_state import ExtraStoredData from .const import API_KEY_FIRMWARE_UPDATE_SERVICE, DATA_CLIENT, DOMAIN, LOGGER from .helpers import get_device_info, get_valueless_base_unique_id PARALLEL_UPDATES = 1 +UPDATE_DELAY_STRING = "delay" +UPDATE_DELAY_INTERVAL = 5 # In minutes + + +@dataclass +class ZWaveNodeFirmwareUpdateExtraStoredData(ExtraStoredData): + """Extra stored data for Z-Wave node firmware update entity.""" + + latest_version_firmware: NodeFirmwareUpdateInfo | None + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the extra data.""" + return { + "latest_version_firmware": asdict(self.latest_version_firmware) + if self.latest_version_firmware + else None + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> ZWaveNodeFirmwareUpdateExtraStoredData: + """Initialize the extra data from a dict.""" + if not (firmware_dict := data["latest_version_firmware"]): + return cls(None) + + return cls(NodeFirmwareUpdateInfo.from_dict(firmware_dict)) + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up Z-Wave button from config entry.""" + """Set up Z-Wave update entity from config entry.""" client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] - - semaphore = asyncio.Semaphore(3) + cnt: Counter = Counter() @callback def async_add_firmware_update_entity(node: ZwaveNode) -> None: """Add firmware update entity.""" + # We need to delay the first update of each entity to avoid flooding the network + # so we maintain a counter to schedule first update in UPDATE_DELAY_INTERVAL + # minute increments. + cnt[UPDATE_DELAY_STRING] += 1 + delay = timedelta(minutes=(cnt[UPDATE_DELAY_STRING] * UPDATE_DELAY_INTERVAL)) driver = client.driver assert driver is not None # Driver is ready before platforms are loaded. - async_add_entities([ZWaveNodeFirmwareUpdate(driver, node, semaphore)]) + async_add_entities([ZWaveNodeFirmwareUpdate(driver, node, delay)]) config_entry.async_on_unload( async_dispatcher_connect( @@ -77,13 +110,10 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): _attr_has_entity_name = True _attr_should_poll = False - def __init__( - self, driver: Driver, node: ZwaveNode, semaphore: asyncio.Semaphore - ) -> None: + def __init__(self, driver: Driver, node: ZwaveNode, delay: timedelta) -> None: """Initialize a Z-Wave device firmware update entity.""" self.driver = driver self.node = node - self.semaphore = semaphore self._latest_version_firmware: NodeFirmwareUpdateInfo | None = None self._status_unsub: Callable[[], None] | None = None self._poll_unsub: Callable[[], None] | None = None @@ -91,6 +121,7 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): self._finished_unsub: Callable[[], None] | None = None self._finished_event = asyncio.Event() self._result: NodeFirmwareUpdateResult | None = None + self._delay: Final[timedelta] = delay # Entity class attributes self._attr_name = "Firmware" @@ -100,6 +131,11 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): # device may not be precreated in main handler yet self._attr_device_info = get_device_info(driver, node) + @property + def extra_restore_state_data(self) -> ExtraStoredData: + """Return ZWave Node Firmware Update specific state data to be restored.""" + return ZWaveNodeFirmwareUpdateExtraStoredData(self._latest_version_firmware) + @callback def _update_on_status_change(self, _: dict[str, Any]) -> None: """Update the entity when node is awake.""" @@ -143,7 +179,17 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): async def _async_update(self, _: HomeAssistant | datetime | None = None) -> None: """Update the entity.""" - self._poll_unsub = None + if self._poll_unsub: + self._poll_unsub() + self._poll_unsub = None + + # If hass hasn't started yet, push the next update to the next day so that we + # can preserve the offsets we've created between each node + if self.hass.state != CoreState.running: + self._poll_unsub = async_call_later( + self.hass, timedelta(days=1), self._async_update + ) + return # If device is asleep/dead, wait for it to wake up/become alive before # attempting an update @@ -159,12 +205,11 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): return try: - async with self.semaphore: - available_firmware_updates = ( - await self.driver.controller.async_get_available_firmware_updates( - self.node, API_KEY_FIRMWARE_UPDATE_SERVICE - ) + available_firmware_updates = ( + await self.driver.controller.async_get_available_firmware_updates( + self.node, API_KEY_FIRMWARE_UPDATE_SERVICE ) + ) except FailedZWaveCommand as err: LOGGER.debug( "Failed to get firmware updates for node %s: %s", @@ -277,7 +322,27 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): ) ) - self.async_on_remove(async_at_start(self.hass, self._async_update)) + # If we have a complete previous state, use that to set the latest version + if (state := await self.async_get_last_state()) and ( + extra_data := await self.async_get_last_extra_data() + ): + self._attr_latest_version = state.attributes[ATTR_LATEST_VERSION] + self._latest_version_firmware = ( + ZWaveNodeFirmwareUpdateExtraStoredData.from_dict( + extra_data.as_dict() + ).latest_version_firmware + ) + # If we have no state to restore, we can set the latest version to installed + # so that the entity starts as off. If we have partial restore data due to an + # upgrade to an HA version where this feature is released from one that is not + # the entity will start in an unknown state until we can correct on next update + elif not state: + self._attr_latest_version = self._attr_installed_version + + # Spread updates out in 5 minute increments to avoid flooding the network + self.async_on_remove( + async_call_later(self.hass, self._delay, self._async_update) + ) async def async_will_remove_from_hass(self) -> None: """Call when entity will be removed.""" diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index f20c814bdc..32082a0bb8 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -235,6 +235,9 @@ def create_backup_fixture(): yield create_backup +# State fixtures + + @pytest.fixture(name="controller_state", scope="session") def controller_state_fixture(): """Load the controller state fixture data.""" @@ -601,6 +604,9 @@ def lock_home_connect_620_state_fixture(): return json.loads(load_fixture("zwave_js/lock_home_connect_620_state.json")) +# model fixtures + + @pytest.fixture(name="client") def mock_client_fixture( controller_state, controller_node_state, version_state, log_config_state diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index 30371904a4..36f16d0b50 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -20,16 +20,26 @@ from homeassistant.components.update import ( ) from homeassistant.components.zwave_js.const import DOMAIN, SERVICE_REFRESH_VALUE from homeassistant.components.zwave_js.helpers import get_valueless_base_unique_id -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_registry import async_get from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + mock_restore_cache, + mock_restore_cache_with_extra_data, +) from tests.typing import WebSocketGenerator UPDATE_ENTITY = "update.z_wave_thermostat_firmware" +LATEST_VERSION_FIRMWARE = { + "version": "11.2.4", + "changelog": "blah 2", + "files": [{"target": 0, "url": "https://example2.com", "integrity": "sha2"}], +} FIRMWARE_UPDATES = { "updates": [ { @@ -39,13 +49,7 @@ FIRMWARE_UPDATES = { {"target": 0, "url": "https://example1.com", "integrity": "sha1"} ], }, - { - "version": "11.2.4", - "changelog": "blah 2", - "files": [ - {"target": 0, "url": "https://example2.com", "integrity": "sha2"} - ], - }, + LATEST_VERSION_FIRMWARE, { "version": "11.1.5", "changelog": "blah 3", @@ -56,19 +60,6 @@ FIRMWARE_UPDATES = { ] } -FIRMWARE_UPDATE_MULTIPLE_FILES = { - "updates": [ - { - "version": "11.2.4", - "changelog": "blah 2", - "files": [ - {"target": 0, "url": "https://example2.com", "integrity": "sha2"}, - {"target": 1, "url": "https://example4.com", "integrity": "sha4"}, - ], - }, - ] -} - async def test_update_entity_states( hass: HomeAssistant, @@ -85,7 +76,7 @@ async def test_update_entity_states( client.async_send_command.return_value = {"updates": []} - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() state = hass.states.get(UPDATE_ENTITY) @@ -104,7 +95,7 @@ async def test_update_entity_states( client.async_send_command.return_value = FIRMWARE_UPDATES - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=2)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=2)) await hass.async_block_till_done() state = hass.states.get(UPDATE_ENTITY) @@ -139,6 +130,15 @@ async def test_update_entity_states( assert "There is no value to refresh for this entity" in caplog.text + client.async_send_command.return_value = {"updates": []} + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=3)) + await hass.async_block_till_done() + + state = hass.states.get(UPDATE_ENTITY) + assert state + assert state.state == STATE_OFF + # Assert a node firmware update entity is not created for the controller driver = client.driver node = driver.controller.nodes[1] @@ -164,7 +164,7 @@ async def test_update_entity_install_raises( """Test update entity install raises exception.""" client.async_send_command.return_value = FIRMWARE_UPDATES - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() # Test failed installation by driver @@ -197,7 +197,7 @@ async def test_update_entity_sleep( client.async_send_command.return_value = FIRMWARE_UPDATES - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() # Because node is asleep we shouldn't attempt to check for firmware updates @@ -234,7 +234,7 @@ async def test_update_entity_dead( client.async_send_command.return_value = FIRMWARE_UPDATES - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() # Because node is asleep we shouldn't attempt to check for firmware updates @@ -261,7 +261,7 @@ async def test_update_entity_ha_not_running( zen_31, hass_ws_client: WebSocketGenerator, ) -> None: - """Test update occurs after HA starts.""" + """Test update occurs only after HA is running.""" await hass.async_stop() entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) @@ -272,6 +272,22 @@ async def test_update_entity_ha_not_running( assert len(client.async_send_command.call_args_list) == 0 await hass.async_start() + await hass.async_block_till_done() + + assert len(client.async_send_command.call_args_list) == 0 + + # Update should be delayed by a day because HA is not running + hass.state = CoreState.starting + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) + await hass.async_block_till_done() + + assert len(client.async_send_command.call_args_list) == 0 + + hass.state = CoreState.running + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) + await hass.async_block_till_done() assert len(client.async_send_command.call_args_list) == 1 args = client.async_send_command.call_args_list[0][0][0] @@ -289,7 +305,7 @@ async def test_update_entity_update_failure( assert len(client.async_send_command.call_args_list) == 0 client.async_send_command.side_effect = FailedZWaveCommand("test", 260, "test") - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() state = hass.states.get(UPDATE_ENTITY) @@ -314,7 +330,7 @@ async def test_update_entity_progress( node = climate_radio_thermostat_ct100_plus_different_endpoints client.async_send_command.return_value = FIRMWARE_UPDATES - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() state = hass.states.get(UPDATE_ENTITY) @@ -410,7 +426,7 @@ async def test_update_entity_install_failed( node = climate_radio_thermostat_ct100_plus_different_endpoints client.async_send_command.return_value = FIRMWARE_UPDATES - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() state = hass.states.get(UPDATE_ENTITY) @@ -503,7 +519,7 @@ async def test_update_entity_reload( client.async_send_command.return_value = {"updates": []} - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() state = hass.states.get(UPDATE_ENTITY) @@ -512,7 +528,7 @@ async def test_update_entity_reload( client.async_send_command.return_value = FIRMWARE_UPDATES - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=2)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=2)) await hass.async_block_till_done() state = hass.states.get(UPDATE_ENTITY) @@ -543,10 +559,220 @@ async def test_update_entity_reload( await hass.async_block_till_done() # Trigger another update and make sure the skipped version is still skipped - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=4)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=4)) await hass.async_block_till_done() state = hass.states.get(UPDATE_ENTITY) assert state assert state.state == STATE_OFF assert state.attributes[ATTR_SKIPPED_VERSION] == "11.2.4" + + +async def test_update_entity_delay( + hass: HomeAssistant, + client, + ge_in_wall_dimmer_switch, + zen_31, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test update occurs on a delay after HA starts.""" + client.async_send_command.reset_mock() + await hass.async_stop() + + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(client.async_send_command.call_args_list) == 0 + + await hass.async_start() + await hass.async_block_till_done() + + assert len(client.async_send_command.call_args_list) == 0 + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) + await hass.async_block_till_done() + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "controller.get_available_firmware_updates" + assert args["nodeId"] == ge_in_wall_dimmer_switch.node_id + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) + await hass.async_block_till_done() + + assert len(client.async_send_command.call_args_list) == 2 + args = client.async_send_command.call_args_list[1][0][0] + assert args["command"] == "controller.get_available_firmware_updates" + assert args["nodeId"] == zen_31.node_id + + +async def test_update_entity_partial_restore_data( + hass: HomeAssistant, + client, + climate_radio_thermostat_ct100_plus_different_endpoints, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test update entity with partial restore data resets state.""" + mock_restore_cache( + hass, + [ + State( + UPDATE_ENTITY, + STATE_OFF, + { + ATTR_INSTALLED_VERSION: "10.7", + ATTR_LATEST_VERSION: "11.2.4", + ATTR_SKIPPED_VERSION: "11.2.4", + }, + ) + ], + ) + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(UPDATE_ENTITY) + assert state + assert state.state == STATE_UNKNOWN + + +async def test_update_entity_full_restore_data_skipped_version( + hass: HomeAssistant, + client, + climate_radio_thermostat_ct100_plus_different_endpoints, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test update entity with full restore data (skipped version) restores state.""" + mock_restore_cache_with_extra_data( + hass, + [ + ( + State( + UPDATE_ENTITY, + STATE_OFF, + { + ATTR_INSTALLED_VERSION: "10.7", + ATTR_LATEST_VERSION: "11.2.4", + ATTR_SKIPPED_VERSION: "11.2.4", + }, + ), + {"latest_version_firmware": LATEST_VERSION_FIRMWARE}, + ) + ], + ) + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(UPDATE_ENTITY) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_SKIPPED_VERSION] == "11.2.4" + assert state.attributes[ATTR_LATEST_VERSION] == "11.2.4" + + +async def test_update_entity_full_restore_data_update_available( + hass: HomeAssistant, + client, + climate_radio_thermostat_ct100_plus_different_endpoints, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test update entity with full restore data (update available) restores state.""" + mock_restore_cache_with_extra_data( + hass, + [ + ( + State( + UPDATE_ENTITY, + STATE_OFF, + { + ATTR_INSTALLED_VERSION: "10.7", + ATTR_LATEST_VERSION: "11.2.4", + ATTR_SKIPPED_VERSION: None, + }, + ), + {"latest_version_firmware": LATEST_VERSION_FIRMWARE}, + ) + ], + ) + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(UPDATE_ENTITY) + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_SKIPPED_VERSION] is None + assert state.attributes[ATTR_LATEST_VERSION] == "11.2.4" + + client.async_send_command.return_value = {"success": True} + + # Test successful install call without a version + install_task = hass.async_create_task( + hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: UPDATE_ENTITY, + }, + blocking=True, + ) + ) + + # Sleep so that task starts + await asyncio.sleep(0.1) + + state = hass.states.get(UPDATE_ENTITY) + assert state + attrs = state.attributes + assert attrs[ATTR_IN_PROGRESS] is True + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args_list[0][0][0] == { + "command": "controller.firmware_update_ota", + "nodeId": climate_radio_thermostat_ct100_plus_different_endpoints.node_id, + "updates": [{"target": 0, "url": "https://example2.com", "integrity": "sha2"}], + } + + install_task.cancel() + + +async def test_update_entity_full_restore_data_no_update_available( + hass: HomeAssistant, + client, + climate_radio_thermostat_ct100_plus_different_endpoints, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test entity with full restore data (no update available) restores state.""" + mock_restore_cache_with_extra_data( + hass, + [ + ( + State( + UPDATE_ENTITY, + STATE_OFF, + { + ATTR_INSTALLED_VERSION: "10.7", + ATTR_LATEST_VERSION: "10.7", + ATTR_SKIPPED_VERSION: None, + }, + ), + {"latest_version_firmware": None}, + ) + ], + ) + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(UPDATE_ENTITY) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_SKIPPED_VERSION] is None + assert state.attributes[ATTR_LATEST_VERSION] == "10.7" From 5e5ace9c4e3ad996e4db45e99b14c4df8402b416 Mon Sep 17 00:00:00 2001 From: Arturo Date: Mon, 20 Mar 2023 18:29:33 -0600 Subject: [PATCH 1102/2810] Add door lock device type to matter integration (#89277) * Adds base code for matter lock * Adds basic matter door lock support * Adds matter lock fixture * Adds tests for matter lock * Addresses feedback * Added logic to handle inter states of matter lock * Addesses feedback --- homeassistant/components/matter/discovery.py | 2 + homeassistant/components/matter/light.py | 7 +- homeassistant/components/matter/lock.py | 141 +++++ homeassistant/components/matter/switch.py | 9 +- .../matter/fixtures/nodes/door-lock.json | 509 ++++++++++++++++++ tests/components/matter/test_door_lock.py | 106 ++++ 6 files changed, 769 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/matter/lock.py create mode 100644 tests/components/matter/fixtures/nodes/door-lock.json create mode 100644 tests/components/matter/test_door_lock.py diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index 3fb8481dc9..36f415dacc 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -11,6 +11,7 @@ from homeassistant.core import callback from .binary_sensor import DISCOVERY_SCHEMAS as BINARY_SENSOR_SCHEMAS from .light import DISCOVERY_SCHEMAS as LIGHT_SCHEMAS +from .lock import DISCOVERY_SCHEMAS as LOCK_SCHEMAS from .models import MatterDiscoverySchema, MatterEntityInfo from .sensor import DISCOVERY_SCHEMAS as SENSOR_SCHEMAS from .switch import DISCOVERY_SCHEMAS as SWITCH_SCHEMAS @@ -18,6 +19,7 @@ from .switch import DISCOVERY_SCHEMAS as SWITCH_SCHEMAS DISCOVERY_SCHEMAS: dict[Platform, list[MatterDiscoverySchema]] = { Platform.BINARY_SENSOR: BINARY_SENSOR_SCHEMAS, Platform.LIGHT: LIGHT_SCHEMAS, + Platform.LOCK: LOCK_SCHEMAS, Platform.SENSOR: SENSOR_SCHEMAS, Platform.SWITCH: SWITCH_SCHEMAS, } diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index 080cc472f2..10a52eb880 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -372,7 +372,10 @@ DISCOVERY_SCHEMAS = [ clusters.ColorControl.Attributes.CurrentY, clusters.ColorControl.Attributes.ColorTemperatureMireds, ), - # restrict device type to prevent discovery in switch platform - not_device_type=(device_types.OnOffPlugInUnit,), + # restrict device type to prevent discovery by the wrong platform + not_device_type=( + device_types.OnOffPlugInUnit, + device_types.DoorLock, + ), ), ] diff --git a/homeassistant/components/matter/lock.py b/homeassistant/components/matter/lock.py new file mode 100644 index 0000000000..f90d8eb485 --- /dev/null +++ b/homeassistant/components/matter/lock.py @@ -0,0 +1,141 @@ +"""Matter lock.""" +from __future__ import annotations + +from enum import IntFlag +from typing import Any + +from chip.clusters import Objects as clusters + +from homeassistant.components.lock import LockEntity, LockEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import LOGGER +from .entity import MatterEntity +from .helpers import get_matter +from .models import MatterDiscoverySchema + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Matter lock from Config Entry.""" + matter = get_matter(hass) + matter.register_platform_handler(Platform.LOCK, async_add_entities) + + +class MatterLock(MatterEntity, LockEntity): + """Representation of a Matter lock.""" + + features: int | None = None + + @property + def supports_door_position_sensor(self) -> bool: + """Return True if the lock supports door position sensor.""" + if self.features is None: + return False + + return bool(self.features & DoorLockFeature.kDoorPositionSensor) + + async def send_device_command( + self, + command: clusters.ClusterCommand, + timed_request_timeout_ms: int = 1000, + ) -> None: + """Send a command to the device.""" + await self.matter_client.send_device_command( + node_id=self._endpoint.node.node_id, + endpoint_id=self._endpoint.endpoint_id, + command=command, + timed_request_timeout_ms=timed_request_timeout_ms, + ) + + async def async_lock(self, **kwargs: Any) -> None: + """Lock the lock with pin if needed.""" + await self.send_device_command(command=clusters.DoorLock.Commands.LockDoor()) + + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock the lock with pin if needed.""" + await self.send_device_command(command=clusters.DoorLock.Commands.UnlockDoor()) + + @callback + def _update_from_device(self) -> None: + """Update the entity from the device.""" + + if self.features is None: + self.features = int( + self.get_matter_attribute_value(clusters.DoorLock.Attributes.FeatureMap) + ) + + lock_state = self.get_matter_attribute_value( + clusters.DoorLock.Attributes.LockState + ) + + LOGGER.debug("Lock state: %s for %s", lock_state, self.entity_id) + + if lock_state is clusters.DoorLock.Enums.DlLockState.kLocked: + self._attr_is_locked = True + self._attr_is_locking = False + self._attr_is_unlocking = False + elif lock_state is clusters.DoorLock.Enums.DlLockState.kUnlocked: + self._attr_is_locked = False + self._attr_is_locking = False + self._attr_is_unlocking = False + elif lock_state is clusters.DoorLock.Enums.DlLockState.kNotFullyLocked: + if self.is_locked is True: + self._attr_is_unlocking = True + elif self.is_locked is False: + self._attr_is_locking = True + else: + # According to the matter docs a null state can happen during device startup. + self._attr_is_locked = None + self._attr_is_locking = None + self._attr_is_unlocking = None + + if self.supports_door_position_sensor: + door_state = self.get_matter_attribute_value( + clusters.DoorLock.Attributes.DoorState + ) + + assert door_state is not None + + LOGGER.debug("Door state: %s for %s", door_state, self.entity_id) + + self._attr_is_jammed = ( + door_state is clusters.DoorLock.Enums.DlDoorState.kDoorJammed + ) + + +class DoorLockFeature(IntFlag): + """Temp enum that represents the features of a door lock. + + Should be replaced by the library provided one once that is released. + """ + + kPinCredential = 0x1 + kRfidCredential = 0x2 + kFingerCredentials = 0x4 + kLogging = 0x8 + kWeekDayAccessSchedules = 0x10 + kDoorPositionSensor = 0x20 + kFaceCredentials = 0x40 + kCredentialsOverTheAirAccess = 0x80 + kUser = 0x100 + kNotification = 0x200 + kYearDayAccessSchedules = 0x400 + kHolidaySchedules = 0x800 + + +DISCOVERY_SCHEMAS = [ + MatterDiscoverySchema( + platform=Platform.LOCK, + entity_description=LockEntityDescription(key="MatterLock"), + entity_class=MatterLock, + required_attributes=(clusters.DoorLock.Attributes.LockState,), + optional_attributes=(clusters.DoorLock.Attributes.DoorState,), + ), +] diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index e5c9861043..809d0ad738 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -67,8 +67,11 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterSwitch, required_attributes=(clusters.OnOff.Attributes.OnOff,), - # restrict device type to prevent discovery by light - # platform which also uses OnOff cluster - not_device_type=(device_types.OnOffLight, device_types.DimmableLight), + # restrict device type to prevent discovery by the wrong platform + not_device_type=( + device_types.OnOffLight, + device_types.DimmableLight, + device_types.DoorLock, + ), ), ] diff --git a/tests/components/matter/fixtures/nodes/door-lock.json b/tests/components/matter/fixtures/nodes/door-lock.json new file mode 100644 index 0000000000..f7a9749325 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/door-lock.json @@ -0,0 +1,509 @@ +{ + "node_id": 1, + "date_commissioned": "2023-03-07T09:06:06.059454", + "last_interview": "2023-03-07T09:06:06.059456", + "interview_version": 2, + "available": true, + "attributes": { + "0/29/0": [ + { + "type": 22, + "revision": 1 + } + ], + "0/29/1": [ + 29, 31, 40, 42, 43, 44, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 60, 62, + 63, 64, 65 + ], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "0/31/0": [ + { + "privilege": 5, + "authMode": 2, + "subjects": [112233], + "targets": null, + "fabricIndex": 1 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65530, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "TEST_VENDOR", + "0/40/2": 65521, + "0/40/3": "Mock Door Lock", + "0/40/4": 32769, + "0/40/5": "Mock Door Lock", + "0/40/6": "**REDACTED**", + "0/40/7": 0, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "TEST_SN", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "mock-door-lock", + "0/40/19": { + "caseSessionsPerFabric": 3, + "subscriptionsPerFabric": 65535 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 65528, 65529, 65530, 65531, 65532, 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 0, + "0/42/3": 0, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "0/43/0": "en-US", + "0/43/1": [ + "en-US", + "de-DE", + "fr-FR", + "en-GB", + "es-ES", + "zh-CN", + "it-IT", + "ja-JP" + ], + "0/43/65532": 0, + "0/43/65533": 1, + "0/43/65528": [], + "0/43/65529": [], + "0/43/65531": [0, 1, 65528, 65529, 65530, 65531, 65532, 65533], + "0/44/0": 0, + "0/44/1": 0, + "0/44/2": [0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 7], + "0/44/65532": 0, + "0/44/65533": 1, + "0/44/65528": [], + "0/44/65529": [], + "0/44/65531": [0, 1, 2, 65528, 65529, 65530, 65531, 65532, 65533], + "0/46/0": [0, 1], + "0/46/65532": 0, + "0/46/65533": 1, + "0/46/65528": [], + "0/46/65529": [], + "0/46/65531": [0, 65528, 65529, 65530, 65531, 65532, 65533], + "0/47/0": 1, + "0/47/1": 0, + "0/47/2": "USB", + "0/47/6": 0, + "0/47/65532": 1, + "0/47/65533": 1, + "0/47/65528": [], + "0/47/65529": [], + "0/47/65531": [0, 1, 2, 6, 65528, 65529, 65530, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "failSafeExpiryLengthSeconds": 60, + "maxCumulativeFailsafeSeconds": 900 + }, + "0/48/2": 0, + "0/48/3": 2, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65530, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [], + "0/49/2": 10, + "0/49/3": 20, + "0/49/4": true, + "0/49/5": null, + "0/49/6": null, + "0/49/7": null, + "0/49/65532": 2, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "0/50/65532": 0, + "0/50/65533": 1, + "0/50/65528": [1], + "0/50/65529": [0], + "0/50/65531": [65528, 65529, 65530, 65531, 65532, 65533], + "0/51/0": [ + { + "name": "eth0", + "isOperational": true, + "offPremiseServicesReachableIPv4": null, + "offPremiseServicesReachableIPv6": null, + "hardwareAddress": "/mQDt/2Q", + "IPv4Addresses": ["CjwBaQ=="], + "IPv6Addresses": [ + "/VqgxiAxQib8ZAP//rf9kA==", + "IAEEcLs7AAb8ZAP//rf9kA==", + "/oAAAAAAAAD8ZAP//rf9kA==" + ], + "type": 2 + }, + { + "name": "lo", + "isOperational": true, + "offPremiseServicesReachableIPv4": null, + "offPremiseServicesReachableIPv6": null, + "hardwareAddress": "AAAAAAAA", + "IPv4Addresses": ["fwAAAQ=="], + "IPv6Addresses": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "type": 0 + } + ], + "0/51/1": 1, + "0/51/2": 25, + "0/51/3": 0, + "0/51/4": 0, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "0/52/0": [ + { + "id": 26957, + "name": "26957", + "stackFreeCurrent": null, + "stackFreeMinimum": null, + "stackSize": null + }, + { + "id": 26956, + "name": "26956", + "stackFreeCurrent": null, + "stackFreeMinimum": null, + "stackSize": null + }, + { + "id": 26955, + "name": "26955", + "stackFreeCurrent": null, + "stackFreeMinimum": null, + "stackSize": null + }, + { + "id": 26953, + "name": "26953", + "stackFreeCurrent": null, + "stackFreeMinimum": null, + "stackSize": null + }, + { + "id": 26952, + "name": "26952", + "stackFreeCurrent": null, + "stackFreeMinimum": null, + "stackSize": null + } + ], + "0/52/1": 351120, + "0/52/2": 529520, + "0/52/3": 529520, + "0/52/65532": 1, + "0/52/65533": 1, + "0/52/65528": [], + "0/52/65529": [0], + "0/52/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "0/53/0": null, + "0/53/1": null, + "0/53/2": null, + "0/53/3": null, + "0/53/4": null, + "0/53/5": null, + "0/53/6": 0, + "0/53/7": [], + "0/53/8": [], + "0/53/9": null, + "0/53/10": null, + "0/53/11": null, + "0/53/12": null, + "0/53/13": null, + "0/53/14": 0, + "0/53/15": 0, + "0/53/16": 0, + "0/53/17": 0, + "0/53/18": 0, + "0/53/19": 0, + "0/53/20": 0, + "0/53/21": 0, + "0/53/22": 0, + "0/53/23": 0, + "0/53/24": 0, + "0/53/25": 0, + "0/53/26": 0, + "0/53/27": 0, + "0/53/28": 0, + "0/53/29": 0, + "0/53/30": 0, + "0/53/31": 0, + "0/53/32": 0, + "0/53/33": 0, + "0/53/34": 0, + "0/53/35": 0, + "0/53/36": 0, + "0/53/37": 0, + "0/53/38": 0, + "0/53/39": 0, + "0/53/40": 0, + "0/53/41": 0, + "0/53/42": 0, + "0/53/43": 0, + "0/53/44": 0, + "0/53/45": 0, + "0/53/46": 0, + "0/53/47": 0, + "0/53/48": 0, + "0/53/49": 0, + "0/53/50": 0, + "0/53/51": 0, + "0/53/52": 0, + "0/53/53": 0, + "0/53/54": 0, + "0/53/55": 0, + "0/53/56": null, + "0/53/57": null, + "0/53/58": null, + "0/53/59": null, + "0/53/60": null, + "0/53/61": null, + "0/53/62": [], + "0/53/65532": 15, + "0/53/65533": 1, + "0/53/65528": [], + "0/53/65529": [0], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, + 57, 58, 59, 60, 61, 62, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "0/54/0": null, + "0/54/1": null, + "0/54/2": 3, + "0/54/3": null, + "0/54/4": null, + "0/54/5": null, + "0/54/6": null, + "0/54/7": null, + "0/54/8": null, + "0/54/9": null, + "0/54/10": null, + "0/54/11": null, + "0/54/12": null, + "0/54/65532": 3, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [0], + "0/54/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 65528, 65529, 65530, 65531, + 65532, 65533 + ], + "0/55/0": null, + "0/55/1": false, + "0/55/2": 823, + "0/55/3": 969, + "0/55/4": 0, + "0/55/5": 0, + "0/55/6": 0, + "0/55/7": null, + "0/55/8": 25, + "0/55/65532": 3, + "0/55/65533": 1, + "0/55/65528": [], + "0/55/65529": [0], + "0/55/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65530, 65531, 65532, 65533], + "0/62/0": [ + { + "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE55h6CbNLPZH/uM3/rDdA+jeuuD2QSPN8gBeEB0bmGJqWz/gCT4/ySB77rK3XiwVWVAmJhJ/eMcTIA0XXWMqKPDcKNQEoARgkAgE2AwQCBAEYMAQUqnKiC76YFhcTHt4AQ/kAbtrZ2MowBRSL6EWyWm8+uC0Puc2/BncMqYbpmhgwC0AA05Z+y1mcyHUeOFJ5kyDJJMN/oNCwN5h8UpYN/868iuQArr180/fbaN1+db9lab4D2lf0HK7wgHIR3HsOa2w9GA==", + "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE5R1DrUQE/L8tx95WR1g1dZJf4d+6LEB7JAYZN/nw9ZBUg5VOHDrB1xIw5KguYJzt10K+0KqQBBEbuwW+wLLobTcKNQEpARgkAmAwBBSL6EWyWm8+uC0Puc2/BncMqYbpmjAFFM0I6fPFzfOv2IWbX1huxb3eW0fqGDALQHXLE0TgIDW6XOnvtsOJCyKoENts8d4TQWBgTKviv1LF/+MS9eFYi+kO+1Idq5mVgwN+lH7eyecShQR0iqq6WLUY", + "fabricIndex": 1 + } + ], + "0/62/1": [ + { + "rootPublicKey": "BJ/jL2MdDrdq9TahKSa5c/dBc166NRCU0W9l7hK2kcuVtN915DLqiS+RAJ2iPEvWK5FawZHF/QdKLZmTkZHudxY=", + "vendorId": 65521, + "fabricId": 1, + "nodeId": 1, + "label": "", + "fabricIndex": 1 + } + ], + "0/62/2": 16, + "0/62/3": 1, + "0/62/4": [ + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEn+MvYx0Ot2r1NqEpJrlz90FzXro1EJTRb2XuEraRy5W033XkMuqJL5EAnaI8S9YrkVrBkcX9B0otmZORke53FjcKNQEpARgkAmAwBBTNCOnzxc3zr9iFm19YbsW93ltH6jAFFM0I6fPFzfOv2IWbX1huxb3eW0fqGDALQILjpR3BTSHHl6DQtvwzWkjmA+i5jjXdc3qjemFGFjFVAnV6dPLQo7tctC8Y0uL4ZNERga2/NZAt1gRD72S0YR4Y" + ], + "0/62/5": 1, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65530, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "0/64/0": [ + { + "label": "room", + "value": "bedroom 2" + }, + { + "label": "orientation", + "value": "North" + }, + { + "label": "floor", + "value": "2" + }, + { + "label": "direction", + "value": "up" + } + ], + "0/64/65532": 0, + "0/64/65533": 1, + "0/64/65528": [], + "0/64/65529": [], + "0/64/65531": [0, 65528, 65529, 65530, 65531, 65532, 65533], + "0/65/0": [], + "0/65/65532": 0, + "0/65/65533": 1, + "0/65/65528": [], + "0/65/65529": [], + "0/65/65531": [0, 65528, 65529, 65530, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [0, 1, 65528, 65529, 65530, 65531, 65532, 65533], + "1/6/0": false, + "1/6/16384": true, + "1/6/16385": 0, + "1/6/16386": 0, + "1/6/16387": 0, + "1/6/65532": 0, + "1/6/65533": 4, + "1/6/65528": [], + "1/6/65529": [0, 1, 2], + "1/6/65531": [ + 0, 16384, 16385, 16386, 16387, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "1/29/0": [ + { + "type": 10, + "revision": 1 + } + ], + "1/29/1": [3, 6, 29, 47, 257], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "1/47/0": 1, + "1/47/1": 1, + "1/47/2": "Battery", + "1/47/14": 0, + "1/47/15": false, + "1/47/16": 0, + "1/47/19": "", + "1/47/65532": 10, + "1/47/65533": 1, + "1/47/65528": [], + "1/47/65529": [], + "1/47/65531": [ + 0, 1, 2, 14, 15, 16, 19, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "1/257/0": 1, + "1/257/1": 0, + "1/257/2": true, + "1/257/3": 1, + "1/257/17": 10, + "1/257/18": 10, + "1/257/19": 10, + "1/257/20": 10, + "1/257/21": 10, + "1/257/22": 10, + "1/257/23": 8, + "1/257/24": 6, + "1/257/25": 20, + "1/257/26": 10, + "1/257/27": 1, + "1/257/28": 5, + "1/257/33": "en", + "1/257/35": 60, + "1/257/36": 0, + "1/257/37": 0, + "1/257/38": 65526, + "1/257/41": false, + "1/257/43": false, + "1/257/48": 3, + "1/257/49": 10, + "1/257/51": false, + "1/257/65532": 3507, + "1/257/65533": 6, + "1/257/65528": [12, 15, 18, 28, 35, 37], + "1/257/65529": [ + 0, 1, 3, 11, 12, 13, 14, 15, 16, 17, 18, 19, 26, 27, 29, 34, 36, 38 + ], + "1/257/65531": [ + 0, 1, 2, 3, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 33, 35, 36, + 37, 38, 41, 43, 48, 49, 51, 65528, 65529, 65530, 65531, 65532, 65533 + ] + } +} diff --git a/tests/components/matter/test_door_lock.py b/tests/components/matter/test_door_lock.py new file mode 100644 index 0000000000..072658044d --- /dev/null +++ b/tests/components/matter/test_door_lock.py @@ -0,0 +1,106 @@ +"""Test Matter locks.""" +from unittest.mock import MagicMock, call + +from chip.clusters import Objects as clusters +from matter_server.client.models.node import MatterNode +import pytest + +from homeassistant.components.lock import ( + STATE_LOCKED, + STATE_LOCKING, + STATE_UNLOCKED, + STATE_UNLOCKING, +) +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from .common import ( + set_node_attribute, + setup_integration_with_node_fixture, + trigger_subscription_callback, +) + + +@pytest.fixture(name="door_lock") +async def door_lock_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a door lock node.""" + return await setup_integration_with_node_fixture(hass, "door-lock", matter_client) + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_lock( + hass: HomeAssistant, + matter_client: MagicMock, + door_lock: MatterNode, +) -> None: + """Test door lock.""" + await hass.services.async_call( + "lock", + "unlock", + { + "entity_id": "lock.mock_door_lock", + }, + blocking=True, + ) + + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=door_lock.node_id, + endpoint_id=1, + command=clusters.DoorLock.Commands.UnlockDoor(), + timed_request_timeout_ms=1000, + ) + matter_client.send_device_command.reset_mock() + + await hass.services.async_call( + "lock", + "lock", + { + "entity_id": "lock.mock_door_lock", + }, + blocking=True, + ) + + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=door_lock.node_id, + endpoint_id=1, + command=clusters.DoorLock.Commands.LockDoor(), + timed_request_timeout_ms=1000, + ) + matter_client.send_device_command.reset_mock() + + state = hass.states.get("lock.mock_door_lock") + assert state + assert state.state == STATE_LOCKED + + set_node_attribute(door_lock, 1, 257, 0, 0) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("lock.mock_door_lock") + assert state + assert state.state == STATE_UNLOCKING + + set_node_attribute(door_lock, 1, 257, 0, 2) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("lock.mock_door_lock") + assert state + assert state.state == STATE_UNLOCKED + + set_node_attribute(door_lock, 1, 257, 0, 0) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("lock.mock_door_lock") + assert state + assert state.state == STATE_LOCKING + + set_node_attribute(door_lock, 1, 257, 0, None) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("lock.mock_door_lock") + assert state + assert state.state == STATE_UNKNOWN From 7158dbc142ade94e8083bdc6de18db84fc57ab25 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Mar 2023 17:49:30 -1000 Subject: [PATCH 1103/2810] Bump yalexs-ble to 2.1.1 (#90015) * Bump yalexs-ble to 2.1.1 There was another task that could be prematurely GCed changelog: https://github.com/bdraco/yalexs-ble/compare/v2.1.0...v2.1.1 * fixes --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 6 ++---- requirements_test_all.txt | 6 ++---- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index eba6e2c1b3..213f0237e1 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.2.7", "yalexs_ble==2.1.0"] + "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.1"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index e793fe2728..6bb58752a0 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.1.0"] + "requirements": ["yalexs-ble==2.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index be03dac8c0..7e74fa1e8a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2669,15 +2669,13 @@ xs1-api-client==3.0.0 # homeassistant.components.yale_smart_alarm yalesmartalarmclient==0.3.9 +# homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.0 +yalexs-ble==2.1.1 # homeassistant.components.august yalexs==1.2.7 -# homeassistant.components.august -yalexs_ble==2.1.0 - # homeassistant.components.yeelight yeelight==0.7.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 38d1075771..443f1b79e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1903,15 +1903,13 @@ xmltodict==0.13.0 # homeassistant.components.yale_smart_alarm yalesmartalarmclient==0.3.9 +# homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.0 +yalexs-ble==2.1.1 # homeassistant.components.august yalexs==1.2.7 -# homeassistant.components.august -yalexs_ble==2.1.0 - # homeassistant.components.yeelight yeelight==0.7.10 From 030361870521659b7b2ce8446fff33fb9d3a0717 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Mar 2023 17:49:59 -1000 Subject: [PATCH 1104/2810] Handle cancelation of wait_for_ble_connections_free in esphome bluetooth (#90014) Handle cancelation in wait_for_ble_connections_free If `wait_for_ble_connections_free` was canceled due to timeout or the esp disconnecting from Home Assistant the future would get canceled. When we reconnect and get the next callback we need to handle it being done. fixes ``` 2023-03-21 02:34:36.876 ERROR (MainThread) [homeassistant] Error doing job: Fatal error: protocol.data_received() call failed. Traceback (most recent call last): File "/usr/local/lib/python3.10/asyncio/selector_events.py", line 868, in _read_ready__data_received self._protocol.data_received(data) File "/usr/local/lib/python3.10/site-packages/aioesphomeapi/_frame_helper.py", line 195, in data_received self._callback_packet(msg_type_int, bytes(packet_data)) File "/usr/local/lib/python3.10/site-packages/aioesphomeapi/_frame_helper.py", line 110, in _callback_packet self._on_pkt(Packet(type_, data)) File "/usr/local/lib/python3.10/site-packages/aioesphomeapi/connection.py", line 688, in _process_packet handler(msg) File "/usr/local/lib/python3.10/site-packages/aioesphomeapi/client.py", line 482, in on_msg on_bluetooth_connections_free_update(resp.free, resp.limit) File "/usr/src/homeassistant/homeassistant/components/esphome/entry_data.py", line 136, in async_update_ble_connection_limits fut.set_result(free) asyncio.exceptions.InvalidStateError: invalid state ``` --- homeassistant/components/esphome/entry_data.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 4d03542708..d7f25f319a 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -131,10 +131,15 @@ class RuntimeEntryData: ) self.ble_connections_free = free self.ble_connections_limit = limit - if free: - for fut in self._ble_connection_free_futures: + if not free: + return + for fut in self._ble_connection_free_futures: + # If wait_for_ble_connections_free gets cancelled, it will + # leave a future in the list. We need to check if it's done + # before setting the result. + if not fut.done(): fut.set_result(free) - self._ble_connection_free_futures.clear() + self._ble_connection_free_futures.clear() async def wait_for_ble_connections_free(self) -> int: """Wait until there are free BLE connections.""" From 91dbda1ce7eda8d6e685770b9b80e26e4338c576 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 21 Mar 2023 08:20:37 +0100 Subject: [PATCH 1105/2810] Add mqtt common tests for availability (part2) (#89805) * update test_availability_when_connection_lost * Adjust test_availability_without_topic * Update test_default_availability_payload + helper * Update test_default_availability_list_payload * Use helper for async_setup_component * Update test_default_availability_list_* * Update test_custom_availability_payload --- .../mqtt/test_alarm_control_panel.py | 21 ++- tests/components/mqtt/test_binary_sensor.py | 30 ++-- tests/components/mqtt/test_button.py | 19 +-- tests/components/mqtt/test_camera.py | 18 ++- tests/components/mqtt/test_climate.py | 18 ++- tests/components/mqtt/test_common.py | 151 ++++++++---------- tests/components/mqtt/test_cover.py | 18 ++- tests/components/mqtt/test_fan.py | 18 ++- tests/components/mqtt/test_humidifier.py | 18 ++- tests/components/mqtt/test_legacy_vacuum.py | 18 ++- tests/components/mqtt/test_light.py | 18 ++- tests/components/mqtt/test_light_json.py | 18 ++- tests/components/mqtt/test_light_template.py | 18 ++- tests/components/mqtt/test_lock.py | 18 ++- tests/components/mqtt/test_number.py | 18 ++- tests/components/mqtt/test_scene.py | 19 +-- tests/components/mqtt/test_select.py | 18 ++- tests/components/mqtt/test_sensor.py | 30 ++-- tests/components/mqtt/test_siren.py | 19 +-- tests/components/mqtt/test_state_vacuum.py | 18 ++- tests/components/mqtt/test_switch.py | 19 +-- tests/components/mqtt/test_text.py | 19 +-- tests/components/mqtt/test_update.py | 18 ++- 23 files changed, 290 insertions(+), 289 deletions(-) diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index 8056e98a3c..0e760c69a8 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -593,49 +593,48 @@ async def test_attributes_code_text( ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_CODE]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, - mqtt_mock_entry_with_yaml_config, - alarm_control_panel.DOMAIN, - DEFAULT_CONFIG_CODE, + hass, mqtt_mock_entry_no_yaml_config, alarm_control_panel.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_CODE]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_CODE, ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_CODE, ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, alarm_control_panel.DOMAIN, DEFAULT_CONFIG, ) diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index 73176de9ed..344a33ad1a 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -547,51 +547,41 @@ async def test_invalid_device_class( assert "Invalid config for [mqtt]: expected BinarySensorDeviceClass" in caplog.text +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, - mqtt_mock_entry_with_yaml_config, - binary_sensor.DOMAIN, - DEFAULT_CONFIG, + hass, mqtt_mock_entry_no_yaml_config, binary_sensor.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, - mqtt_mock_entry_with_yaml_config, - binary_sensor.DOMAIN, - DEFAULT_CONFIG, + hass, mqtt_mock_entry_no_yaml_config, binary_sensor.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, - mqtt_mock_entry_with_yaml_config, - binary_sensor.DOMAIN, - DEFAULT_CONFIG, + hass, mqtt_mock_entry_no_yaml_config, binary_sensor.DOMAIN, DEFAULT_CONFIG ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, - mqtt_mock_entry_with_yaml_config, - binary_sensor.DOMAIN, - DEFAULT_CONFIG, + hass, mqtt_mock_entry_no_yaml_config, binary_sensor.DOMAIN, DEFAULT_CONFIG ) diff --git a/tests/components/mqtt/test_button.py b/tests/components/mqtt/test_button.py index c4aa5a8606..4902deaa49 100644 --- a/tests/components/mqtt/test_button.py +++ b/tests/components/mqtt/test_button.py @@ -136,26 +136,28 @@ async def test_command_template( mqtt_mock.async_publish.reset_mock() +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, button.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, button.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, button.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, button.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" config = { @@ -167,10 +169,9 @@ async def test_default_availability_payload( } } } - await help_test_default_availability_payload( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, button.DOMAIN, config, True, @@ -180,7 +181,7 @@ async def test_default_availability_payload( async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" config = { @@ -195,7 +196,7 @@ async def test_custom_availability_payload( await help_test_custom_availability_payload( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, button.DOMAIN, config, True, diff --git a/tests/components/mqtt/test_camera.py b/tests/components/mqtt/test_camera.py index dc96d3e9cd..5ba74b0999 100644 --- a/tests/components/mqtt/test_camera.py +++ b/tests/components/mqtt/test_camera.py @@ -157,39 +157,41 @@ async def test_camera_b64_encoded_with_availability( assert body == "grass" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, camera.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, camera.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, camera.DOMAIN, DEFAULT_CONFIG ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, camera.DOMAIN, DEFAULT_CONFIG ) diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index f9f13034ea..a9c1ca6598 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -1042,39 +1042,41 @@ async def test_set_aux( assert state.attributes.get("aux_heat") == "off" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, climate.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, climate.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, climate.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, climate.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, climate.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, climate.DOMAIN, DEFAULT_CONFIG ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, climate.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, climate.DOMAIN, DEFAULT_CONFIG ) diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index 0f54e83949..88d993ef5d 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -27,13 +27,17 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.generated.mqtt import MQTT -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, async_fire_mqtt_message -from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient +from tests.typing import MqttMockHAClient, MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG_DEVICE_INFO_ID = { "identifiers": ["helloworld"], @@ -82,16 +86,46 @@ def help_test_validate_platform_config( return False -async def help_test_availability_when_connection_lost( +async def help_setup_component( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator | None, domain: str, config: ConfigType, + use_discovery: bool = False, +) -> MqttMockHAClient | None: + """Help to set up the MQTT component.""" + # `async_setup_component` will call `async_setup` and + # after that it will also start the entry `async_start_entry` + # when `async_setup` removed mqtt_mock_entry_with_no_config should be awaited. + + if use_discovery: + comp_config = cv.ensure_list(config[mqtt.DOMAIN][domain]) + item = 0 + assert mqtt_mock_entry is not None + mqtt_mock = await mqtt_mock_entry() + for comp in comp_config: + item += 1 + topic = f"homeassistant/{domain}/item_{item}/config" + async_fire_mqtt_message(hass, topic, json.dumps(comp)) + else: + await async_setup_component( + hass, + mqtt.DOMAIN, + config, + ) + mqtt_mock = None + await hass.async_block_till_done() + return mqtt_mock + + +async def help_test_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + domain: str, ) -> None: """Test availability after MQTT disconnection.""" - assert await async_setup_component(hass, mqtt.DOMAIN, config) + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get(f"{domain}.test") assert state and state.state != STATE_UNAVAILABLE @@ -106,15 +140,14 @@ async def help_test_availability_when_connection_lost( async def help_test_availability_without_topic( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, domain: str, config: ConfigType, ) -> None: """Test availability without defined availability topic.""" assert "availability_topic" not in config[mqtt.DOMAIN][domain] - assert await async_setup_component(hass, mqtt.DOMAIN, config) + await mqtt_mock_entry_no_yaml_config() await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() state = hass.states.get(f"{domain}.test") assert state and state.state != STATE_UNAVAILABLE @@ -122,7 +155,7 @@ async def help_test_availability_without_topic( async def help_test_default_availability_payload( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_with_no_config: MqttMockHAClientGenerator, domain: str, config: ConfigType, no_assumed_state: bool = False, @@ -136,13 +169,8 @@ async def help_test_default_availability_payload( # Add availability settings to config config = copy.deepcopy(config) config[mqtt.DOMAIN][domain]["availability_topic"] = "availability-topic" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - config, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + + await help_setup_component(hass, mqtt_mock_entry_with_no_config, domain, config) state = hass.states.get(f"{domain}.test") assert state and state.state == STATE_UNAVAILABLE @@ -173,7 +201,7 @@ async def help_test_default_availability_payload( async def help_test_default_availability_list_payload( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_with_no_config: MqttMockHAClientGenerator, domain: str, config: ConfigType, no_assumed_state: bool = False, @@ -190,13 +218,7 @@ async def help_test_default_availability_list_payload( {"topic": "availability-topic1"}, {"topic": "availability-topic2"}, ] - assert await async_setup_component( - hass, - mqtt.DOMAIN, - config, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await help_setup_component(hass, mqtt_mock_entry_with_no_config, domain, config) state = hass.states.get(f"{domain}.test") assert state and state.state == STATE_UNAVAILABLE @@ -239,7 +261,7 @@ async def help_test_default_availability_list_payload( async def help_test_default_availability_list_payload_all( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, domain: str, config: ConfigType, no_assumed_state: bool = False, @@ -257,13 +279,7 @@ async def help_test_default_availability_list_payload_all( {"topic": "availability-topic1"}, {"topic": "availability-topic2"}, ] - assert await async_setup_component( - hass, - mqtt.DOMAIN, - config, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await help_setup_component(hass, mqtt_mock_entry_no_yaml_config, domain, config) state = hass.states.get(f"{domain}.test") assert state and state.state == STATE_UNAVAILABLE @@ -307,7 +323,7 @@ async def help_test_default_availability_list_payload_all( async def help_test_default_availability_list_payload_any( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, domain: str, config: ConfigType, no_assumed_state: bool = False, @@ -325,13 +341,7 @@ async def help_test_default_availability_list_payload_any( {"topic": "availability-topic1"}, {"topic": "availability-topic2"}, ] - assert await async_setup_component( - hass, - mqtt.DOMAIN, - config, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await help_setup_component(hass, mqtt_mock_entry_no_yaml_config, domain, config) state = hass.states.get(f"{domain}.test") assert state and state.state == STATE_UNAVAILABLE @@ -384,20 +394,17 @@ async def help_test_default_availability_list_single( {"topic": "availability-topic1"}, ] config[mqtt.DOMAIN][domain]["availability_topic"] = "availability-topic" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, - config, - ) + help_test_validate_platform_config(hass, domain, config) + assert ( - "Invalid config for [mqtt]: two or more values in the same group of exclusion 'availability'" + f"Invalid config for [{domain}]: two or more values in the same group of exclusion 'availability'" in caplog.text ) async def help_test_custom_availability_payload( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, domain: str, config: ConfigType, no_assumed_state: bool = False, @@ -413,13 +420,7 @@ async def help_test_custom_availability_payload( config[mqtt.DOMAIN][domain]["availability_topic"] = "availability-topic" config[mqtt.DOMAIN][domain]["payload_available"] = "good" config[mqtt.DOMAIN][domain]["payload_not_available"] = "nogood" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - config, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await help_setup_component(hass, mqtt_mock_entry_no_yaml_config, domain, config) state = hass.states.get(f"{domain}.test") assert state and state.state == STATE_UNAVAILABLE @@ -528,7 +529,7 @@ async def help_test_discovery_update_availability( async def help_test_setting_attribute_via_mqtt_json_message( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, domain: str, config: ConfigType, ) -> None: @@ -539,13 +540,7 @@ async def help_test_setting_attribute_via_mqtt_json_message( # Add JSON attributes settings to config config = copy.deepcopy(config) config[mqtt.DOMAIN][domain]["json_attributes_topic"] = "attr-topic" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - config, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await help_setup_component(hass, mqtt_mock_entry_no_yaml_config, domain, config) async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }') state = hass.states.get(f"{domain}.test") @@ -588,7 +583,7 @@ async def help_test_setting_blocked_attribute_via_mqtt_json_message( async def help_test_setting_attribute_with_template( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, domain: str, config: ConfigType, ) -> None: @@ -602,13 +597,7 @@ async def help_test_setting_attribute_with_template( config[mqtt.DOMAIN][domain][ "json_attributes_template" ] = "{{ value_json['Timer1'] | tojson }}" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - config, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await help_setup_component(hass, mqtt_mock_entry_no_yaml_config, domain, config) async_fire_mqtt_message( hass, "attr-topic", json.dumps({"Timer1": {"Arm": 0, "Time": "22:18"}}) @@ -622,7 +611,7 @@ async def help_test_setting_attribute_with_template( async def help_test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, domain: str, config: ConfigType, @@ -634,13 +623,7 @@ async def help_test_update_with_json_attrs_not_dict( # Add JSON attributes settings to config config = copy.deepcopy(config) config[mqtt.DOMAIN][domain]["json_attributes_topic"] = "attr-topic" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - config, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await help_setup_component(hass, mqtt_mock_entry_no_yaml_config, domain, config) async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]') state = hass.states.get(f"{domain}.test") @@ -651,7 +634,7 @@ async def help_test_update_with_json_attrs_not_dict( async def help_test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, domain: str, config: ConfigType, @@ -663,13 +646,7 @@ async def help_test_update_with_json_attrs_bad_json( # Add JSON attributes settings to config config = copy.deepcopy(config) config[mqtt.DOMAIN][domain]["json_attributes_topic"] = "attr-topic" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - config, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await help_setup_component(hass, mqtt_mock_entry_no_yaml_config, domain, config) async_fire_mqtt_message(hass, "attr-topic", "This is not JSON") diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index 27eac0842c..9618ff7b04 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -2508,39 +2508,41 @@ async def test_find_in_range_altered_inverted(hass: HomeAssistant) -> None: assert mqtt_cover.find_in_range_from_percent(60, "cover") == 120 +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN, DEFAULT_CONFIG ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN, DEFAULT_CONFIG ) diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 1a0bc4faf5..0b6f32a2fd 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -1657,31 +1657,33 @@ async def test_supported_features( assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == features +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, fan.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, fan.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, fan.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, fan.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, fan.DOMAIN, DEFAULT_CONFIG, True, @@ -1691,12 +1693,12 @@ async def test_default_availability_payload( async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, fan.DOMAIN, DEFAULT_CONFIG, True, diff --git a/tests/components/mqtt/test_humidifier.py b/tests/components/mqtt/test_humidifier.py index 653f5ea781..f74e883c4a 100644 --- a/tests/components/mqtt/test_humidifier.py +++ b/tests/components/mqtt/test_humidifier.py @@ -1010,31 +1010,33 @@ async def test_supported_features( assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == features +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG, True, @@ -1044,12 +1046,12 @@ async def test_default_availability_payload( async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG, True, diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index 6d45cd4898..17ae575dca 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -631,39 +631,41 @@ async def test_missing_fan_speed_template(hass: HomeAssistant) -> None: ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_2]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_2]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index a947bfc797..515c41b8ba 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -2133,39 +2133,41 @@ async def test_effect( mqtt_mock.async_publish.assert_called_once_with("test_light/set", "OFF", 0, False) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index de8ba889e2..f9c1a63793 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -1882,39 +1882,41 @@ async def test_invalid_values( assert state.attributes.get("color_temp") == 100 +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index a5d0f009ca..944f6ad016 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -850,39 +850,41 @@ async def test_invalid_values( assert state.attributes.get("effect") == "rainbow" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index 1d48640011..88b86ef153 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -652,39 +652,41 @@ async def test_sending_mqtt_commands_pessimistic( assert state.state is STATE_LOCKED +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, lock.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, lock.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, lock.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, lock.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, lock.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, lock.DOMAIN, DEFAULT_CONFIG ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, lock.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, lock.DOMAIN, DEFAULT_CONFIG ) diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index da7e278e03..b4c032468e 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -445,39 +445,41 @@ async def test_run_number_service_with_command_template( assert state.state == "32" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN, DEFAULT_CONFIG ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN, DEFAULT_CONFIG ) diff --git a/tests/components/mqtt/test_scene.py b/tests/components/mqtt/test_scene.py index b6899062d2..3662cd6a1c 100644 --- a/tests/components/mqtt/test_scene.py +++ b/tests/components/mqtt/test_scene.py @@ -79,26 +79,28 @@ async def test_sending_mqtt_commands( ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, scene.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, scene.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, scene.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, scene.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" config = { @@ -110,10 +112,9 @@ async def test_default_availability_payload( } } } - await help_test_default_availability_payload( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, scene.DOMAIN, config, True, @@ -123,7 +124,7 @@ async def test_default_availability_payload( async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" config = { @@ -138,7 +139,7 @@ async def test_custom_availability_payload( await help_test_custom_availability_payload( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, scene.DOMAIN, config, True, diff --git a/tests/components/mqtt/test_select.py b/tests/components/mqtt/test_select.py index 31eb7daff8..f7ebd8fb7f 100644 --- a/tests/components/mqtt/test_select.py +++ b/tests/components/mqtt/test_select.py @@ -322,39 +322,41 @@ async def test_run_select_service_with_command_template( ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN, DEFAULT_CONFIG ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN, DEFAULT_CONFIG ) diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index c1bc07f77a..a76f7ec289 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -661,57 +661,59 @@ async def test_force_update_enabled( assert len(events) == 2 +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_list_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_list_payload( - hass, mqtt_mock_entry_with_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_list_payload_all( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_list_payload_all( - hass, mqtt_mock_entry_with_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_list_payload_any( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_list_payload_any( - hass, mqtt_mock_entry_with_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) @@ -728,11 +730,11 @@ async def test_default_availability_list_single( async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) diff --git a/tests/components/mqtt/test_siren.py b/tests/components/mqtt/test_siren.py index 329a9150f7..252a9d13f6 100644 --- a/tests/components/mqtt/test_siren.py +++ b/tests/components/mqtt/test_siren.py @@ -457,26 +457,28 @@ async def test_filtering_not_supported_attributes_via_state( assert state3.attributes.get(siren.ATTR_VOLUME_LEVEL) == 0.88 +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, siren.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, siren.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, siren.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, siren.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" config = { @@ -490,10 +492,9 @@ async def test_default_availability_payload( } } } - await help_test_default_availability_payload( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, siren.DOMAIN, config, True, @@ -503,7 +504,7 @@ async def test_default_availability_payload( async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" config = { @@ -520,7 +521,7 @@ async def test_custom_availability_payload( await help_test_custom_availability_payload( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, siren.DOMAIN, config, True, diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_state_vacuum.py index 55a2a773f6..e8622ceb9b 100644 --- a/tests/components/mqtt/test_state_vacuum.py +++ b/tests/components/mqtt/test_state_vacuum.py @@ -368,39 +368,41 @@ async def test_status_invalid_json( assert state.state == STATE_UNKNOWN +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_2]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_2]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py index 1eb2294618..a11155f392 100644 --- a/tests/components/mqtt/test_switch.py +++ b/tests/components/mqtt/test_switch.py @@ -219,26 +219,28 @@ async def test_controlling_state_via_topic_and_json_message( assert state.state == STATE_UNKNOWN +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, switch.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, switch.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" config = { @@ -252,10 +254,9 @@ async def test_default_availability_payload( } } } - await help_test_default_availability_payload( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, switch.DOMAIN, config, True, @@ -265,7 +266,7 @@ async def test_default_availability_payload( async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" config = { @@ -282,7 +283,7 @@ async def test_custom_availability_payload( await help_test_custom_availability_payload( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, switch.DOMAIN, config, True, diff --git a/tests/components/mqtt/test_text.py b/tests/components/mqtt/test_text.py index 83b2a0d55e..ed2b2a2e2b 100644 --- a/tests/components/mqtt/test_text.py +++ b/tests/components/mqtt/test_text.py @@ -313,26 +313,28 @@ async def test_set_text_validation( assert state.state == "no" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, text.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, text.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, text.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, text.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" config = { @@ -344,10 +346,9 @@ async def test_default_availability_payload( } } } - await help_test_default_availability_payload( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, text.DOMAIN, config, True, @@ -357,7 +358,7 @@ async def test_default_availability_payload( async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" config = { @@ -372,7 +373,7 @@ async def test_custom_availability_payload( await help_test_custom_availability_payload( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, text.DOMAIN, config, True, diff --git a/tests/components/mqtt/test_update.py b/tests/components/mqtt/test_update.py index 4821aeca8e..c324d7d7bf 100644 --- a/tests/components/mqtt/test_update.py +++ b/tests/components/mqtt/test_update.py @@ -428,39 +428,41 @@ async def test_run_install_service( mqtt_mock.async_publish.assert_called_once_with(command_topic, "install", 0, False) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG ) From a8e95684fa3c447e6dc5eb5936448c61e84d8f0c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Mar 2023 21:51:39 -1000 Subject: [PATCH 1106/2810] Fix websocket back pressure bottleneck (#89905) --- .../components/websocket_api/http.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index d92e52dbf8..de0b23e495 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -268,6 +268,43 @@ class WebSocketHandler: ) async_dispatcher_send(self.hass, SIGNAL_WEBSOCKET_CONNECTED) + # + # + # Our websocket implementation is backed by an asyncio.Queue + # + # As back-pressure builds, the queue will back up and use more memory + # until we disconnect the client when the queue size reaches + # MAX_PENDING_MSG. When we are generating a high volume of websocket messages, + # we hit a bottleneck in aiohttp where it will wait for + # the buffer to drain before sending the next message and messages + # start backing up in the queue. + # + # https://github.com/aio-libs/aiohttp/issues/1367 added drains + # to the websocket writer to handle malicious clients and network issues. + # The drain causes multiple problems for us since the buffer cannot be + # drained fast enough when we deliver a high volume or large messages: + # + # - We end up disconnecting the client. The client will then reconnect, + # and the cycle repeats itself, which results in a significant amount of + # CPU usage. + # + # - Messages latency increases because messages cannot be moved into + # the TCP buffer because it is blocked waiting for the drain to happen because + # of the low default limit of 16KiB. By increasing the limit, we instead + # rely on the underlying TCP buffer and stack to deliver the messages which + # can typically happen much faster. + # + # After the auth phase is completed, and we are not concerned about + # the user being a malicious client, we set the limit to force a drain + # to 1MiB. 1MiB is the maximum expected size of the serialized entity + # registry, which is the largest message we usually send. + # + # https://github.com/aio-libs/aiohttp/commit/b3c80ee3f7d5d8f0b8bc27afe52e4d46621eaf99 + # added a way to set the limit, but there is no way to actually + # reach the code to set the limit, so we have to set it directly. + # + wsock._writer._limit = 2**20 # type: ignore[union-attr] # pylint: disable=protected-access + # Command phase while not wsock.closed: msg = await wsock.receive() From 0c0c86bf7b596988f51b0327eba7533690a4efc3 Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Tue, 21 Mar 2023 03:56:44 -0400 Subject: [PATCH 1107/2810] Add support for new Insteon i3 devcies (#89892) --- homeassistant/components/insteon/ipdb.py | 28 +++++++++++++------ .../components/insteon/manifest.json | 4 +-- requirements_all.txt | 4 +-- requirements_test_all.txt | 4 +-- tests/components/insteon/mock_devices.py | 4 +-- 5 files changed, 28 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/insteon/ipdb.py b/homeassistant/components/insteon/ipdb.py index 46302d3e6a..fea1262bff 100644 --- a/homeassistant/components/insteon/ipdb.py +++ b/homeassistant/components/insteon/ipdb.py @@ -4,14 +4,17 @@ from pyinsteon.device_types.ipdb import ( ClimateControl_Thermostat, ClimateControl_WirelessThermostat, DimmableLightingControl, + DimmableLightingControl_Dial, DimmableLightingControl_DinRail, DimmableLightingControl_FanLinc, - DimmableLightingControl_InLineLinc, + DimmableLightingControl_InLineLinc01, + DimmableLightingControl_InLineLinc02, DimmableLightingControl_KeypadLinc_6, DimmableLightingControl_KeypadLinc_8, DimmableLightingControl_LampLinc, DimmableLightingControl_OutletLinc, - DimmableLightingControl_SwitchLinc, + DimmableLightingControl_SwitchLinc01, + DimmableLightingControl_SwitchLinc02, DimmableLightingControl_ToggleLinc, EnergyManagement_LoadController, GeneralController_ControlLinc, @@ -28,12 +31,15 @@ from pyinsteon.device_types.ipdb import ( SwitchedLightingControl, SwitchedLightingControl_ApplianceLinc, SwitchedLightingControl_DinRail, - SwitchedLightingControl_InLineLinc, + SwitchedLightingControl_I3Outlet, + SwitchedLightingControl_InLineLinc01, + SwitchedLightingControl_InLineLinc02, SwitchedLightingControl_KeypadLinc_6, SwitchedLightingControl_KeypadLinc_8, SwitchedLightingControl_OnOffOutlet, SwitchedLightingControl_OutletLinc, - SwitchedLightingControl_SwitchLinc, + SwitchedLightingControl_SwitchLinc01, + SwitchedLightingControl_SwitchLinc02, SwitchedLightingControl_ToggleLinc, WindowCovering, X10Dimmable, @@ -54,9 +60,11 @@ from .const import ON_OFF_EVENTS DEVICE_PLATFORM = { AccessControl_Morningstar: {LOCK: [1]}, DimmableLightingControl: {LIGHT: [1], ON_OFF_EVENTS: [1]}, + DimmableLightingControl_Dial: {LIGHT: [1], ON_OFF_EVENTS: [1]}, DimmableLightingControl_DinRail: {LIGHT: [1], ON_OFF_EVENTS: [1]}, DimmableLightingControl_FanLinc: {LIGHT: [1], FAN: [2], ON_OFF_EVENTS: [1, 2]}, - DimmableLightingControl_InLineLinc: {LIGHT: [1], ON_OFF_EVENTS: [1]}, + DimmableLightingControl_InLineLinc01: {LIGHT: [1], ON_OFF_EVENTS: [1]}, + DimmableLightingControl_InLineLinc02: {LIGHT: [1], ON_OFF_EVENTS: [1]}, DimmableLightingControl_KeypadLinc_6: { LIGHT: [1], SWITCH: [3, 4, 5, 6], @@ -69,7 +77,8 @@ DEVICE_PLATFORM = { }, DimmableLightingControl_LampLinc: {LIGHT: [1], ON_OFF_EVENTS: [1]}, DimmableLightingControl_OutletLinc: {LIGHT: [1], ON_OFF_EVENTS: [1]}, - DimmableLightingControl_SwitchLinc: {LIGHT: [1], ON_OFF_EVENTS: [1]}, + DimmableLightingControl_SwitchLinc01: {LIGHT: [1], ON_OFF_EVENTS: [1]}, + DimmableLightingControl_SwitchLinc02: {LIGHT: [1], ON_OFF_EVENTS: [1]}, DimmableLightingControl_ToggleLinc: {LIGHT: [1], ON_OFF_EVENTS: [1]}, EnergyManagement_LoadController: {SWITCH: [1], BINARY_SENSOR: [2]}, GeneralController_ControlLinc: {ON_OFF_EVENTS: [1]}, @@ -86,7 +95,9 @@ DEVICE_PLATFORM = { SwitchedLightingControl: {SWITCH: [1], ON_OFF_EVENTS: [1]}, SwitchedLightingControl_ApplianceLinc: {SWITCH: [1], ON_OFF_EVENTS: [1]}, SwitchedLightingControl_DinRail: {SWITCH: [1], ON_OFF_EVENTS: [1]}, - SwitchedLightingControl_InLineLinc: {SWITCH: [1], ON_OFF_EVENTS: [1]}, + SwitchedLightingControl_I3Outlet: {SWITCH: [1, 2], ON_OFF_EVENTS: [1, 2]}, + SwitchedLightingControl_InLineLinc01: {SWITCH: [1], ON_OFF_EVENTS: [1]}, + SwitchedLightingControl_InLineLinc02: {SWITCH: [1], ON_OFF_EVENTS: [1]}, SwitchedLightingControl_KeypadLinc_6: { SWITCH: [1, 3, 4, 5, 6], ON_OFF_EVENTS: [1, 3, 4, 5, 6], @@ -97,7 +108,8 @@ DEVICE_PLATFORM = { }, SwitchedLightingControl_OnOffOutlet: {SWITCH: [1, 2], ON_OFF_EVENTS: [1, 2]}, SwitchedLightingControl_OutletLinc: {SWITCH: [1], ON_OFF_EVENTS: [1]}, - SwitchedLightingControl_SwitchLinc: {SWITCH: [1], ON_OFF_EVENTS: [1]}, + SwitchedLightingControl_SwitchLinc01: {SWITCH: [1], ON_OFF_EVENTS: [1]}, + SwitchedLightingControl_SwitchLinc02: {SWITCH: [1], ON_OFF_EVENTS: [1]}, SwitchedLightingControl_ToggleLinc: {SWITCH: [1], ON_OFF_EVENTS: [1]}, ClimateControl_Thermostat: {CLIMATE: [1]}, ClimateControl_WirelessThermostat: {CLIMATE: [1]}, diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index 743e7e4fa1..af9396399a 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -17,8 +17,8 @@ "iot_class": "local_push", "loggers": ["pyinsteon", "pypubsub"], "requirements": [ - "pyinsteon==1.3.4", - "insteon-frontend-home-assistant==0.3.3" + "pyinsteon==1.4.0", + "insteon-frontend-home-assistant==0.3.4" ], "usb": [ { diff --git a/requirements_all.txt b/requirements_all.txt index 7e74fa1e8a..d7d30cc987 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -979,7 +979,7 @@ influxdb==5.3.1 inkbird-ble==0.5.6 # homeassistant.components.insteon -insteon-frontend-home-assistant==0.3.3 +insteon-frontend-home-assistant==0.3.4 # homeassistant.components.intellifire intellifire4py==2.2.2 @@ -1687,7 +1687,7 @@ pyialarm==2.2.0 pyicloud==1.0.0 # homeassistant.components.insteon -pyinsteon==1.3.4 +pyinsteon==1.4.0 # homeassistant.components.intesishome pyintesishome==1.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 443f1b79e9..19e29a7c2f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -741,7 +741,7 @@ influxdb==5.3.1 inkbird-ble==0.5.6 # homeassistant.components.insteon -insteon-frontend-home-assistant==0.3.3 +insteon-frontend-home-assistant==0.3.4 # homeassistant.components.intellifire intellifire4py==2.2.2 @@ -1215,7 +1215,7 @@ pyialarm==2.2.0 pyicloud==1.0.0 # homeassistant.components.insteon -pyinsteon==1.3.4 +pyinsteon==1.4.0 # homeassistant.components.ipma pyipma==3.0.6 diff --git a/tests/components/insteon/mock_devices.py b/tests/components/insteon/mock_devices.py index e1f36ab641..dd0ab0b56a 100644 --- a/tests/components/insteon/mock_devices.py +++ b/tests/components/insteon/mock_devices.py @@ -11,14 +11,14 @@ from pyinsteon.device_types.ipdb import ( GeneralController_RemoteLinc, Hub, SensorsActuators_IOLink, - SwitchedLightingControl_SwitchLinc, + SwitchedLightingControl_SwitchLinc02, ) from pyinsteon.managers.saved_devices_manager import dict_to_aldb_record from pyinsteon.topics import DEVICE_LIST_CHANGED from pyinsteon.utils import subscribe_topic -class MockSwitchLinc(SwitchedLightingControl_SwitchLinc): +class MockSwitchLinc(SwitchedLightingControl_SwitchLinc02): """Mock SwitchLinc device.""" @property From dd1700954b92309cb33d57f8462da5579adf45da Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 21 Mar 2023 09:00:17 +0100 Subject: [PATCH 1108/2810] Deprecate YAML in SamsungTV (#89743) Co-authored-by: Franck Nijhof --- .../components/samsungtv/__init__.py | 21 ++++++++++- .../components/samsungtv/media_player.py | 14 ++++--- .../components/samsungtv/strings.json | 6 +++ .../samsungtv/snapshots/test_init.ambr | 10 +++++ .../samsungtv/test_device_trigger.py | 14 ++----- tests/components/samsungtv/test_init.py | 10 ++++- tests/components/samsungtv/test_trigger.py | 37 ++++++++----------- 7 files changed, 71 insertions(+), 41 deletions(-) create mode 100644 tests/components/samsungtv/snapshots/test_init.ambr diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index 993100262e..0d90157f76 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -26,8 +26,12 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.typing import ConfigType @@ -92,6 +96,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if DOMAIN not in config: return True + ir.async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.6.0", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "on_action_url": "https://www.home-assistant.io/integrations/samsungtv/#turn-on-action" + }, + learn_more_url="https://www.home-assistant.io/integrations/samsungtv/#turn-on-action", + ) for entry_config in config[DOMAIN]: ip_address = await hass.async_add_executor_job( socket.gethostbyname, entry_config[CONF_HOST] diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 3fac14a82c..4e66a9c1d2 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -33,10 +33,12 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry from homeassistant.const import CONF_HOST, CONF_MAC, CONF_MODEL, CONF_NAME from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_component +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_component, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script @@ -144,7 +146,7 @@ class SamsungTVDevice(MediaPlayerEntity): self._attr_device_info["identifiers"] = {(DOMAIN, self.unique_id)} if self._mac: self._attr_device_info["connections"] = { - (CONNECTION_NETWORK_MAC, self._mac) + (dr.CONNECTION_NETWORK_MAC, self._mac) } # Mark the end of a shutdown command (need to wait 15 seconds before @@ -475,8 +477,8 @@ class SamsungTVDevice(MediaPlayerEntity): """Turn the media player on.""" if self._turn_on: await self._turn_on.async_run(self.hass, self._context) - # on_script is deprecated - replaced by turn_on trigger - if self._on_script: + elif self._on_script: + # YAML on_script is deprecated - replaced by turn_on trigger await self._on_script.async_run(context=self._context) elif self._mac: await self.hass.async_add_executor_job(self._wake_on_lan) diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json index f1f237fa4f..cfa04244e8 100644 --- a/homeassistant/components/samsungtv/strings.json +++ b/homeassistant/components/samsungtv/strings.json @@ -44,5 +44,11 @@ "trigger_type": { "samsungtv.turn_on": "Device is requested to turn on" } + }, + "issues": { + "deprecated_yaml": { + "title": "The SamsungTV YAML configuration is being removed", + "description": "Configuring SamsungTV using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the SamsungTV YAML configuration from your `configuration.yaml` file and restart Home Assistant to fix this issue.\n\nPlease note that previously configured `turn_on_action` needs to be manually converted to use the `turn_on` trigger ([documentation]({on_action_url}))." + } } } diff --git a/tests/components/samsungtv/snapshots/test_init.ambr b/tests/components/samsungtv/snapshots/test_init.ambr new file mode 100644 index 0000000000..877bfe0420 --- /dev/null +++ b/tests/components/samsungtv/snapshots/test_init.ambr @@ -0,0 +1,10 @@ +# serializer version: 1 +# name: test_setup + IssueRegistryItemSnapshot({ + 'created': , + 'dismissed_version': None, + 'domain': 'samsungtv', + 'is_persistent': False, + 'issue_id': 'deprecated_yaml', + }) +# --- diff --git a/tests/components/samsungtv/test_device_trigger.py b/tests/components/samsungtv/test_device_trigger.py index 1420440ad4..92df6356f5 100644 --- a/tests/components/samsungtv/test_device_trigger.py +++ b/tests/components/samsungtv/test_device_trigger.py @@ -1,6 +1,4 @@ """The tests for Samsung TV device triggers.""" -from unittest.mock import patch - import pytest from homeassistant.components import automation @@ -90,15 +88,11 @@ async def test_if_fires_on_turn_on_request( }, ) - with patch("homeassistant.components.samsungtv.media_player.send_magic_packet"): - await hass.services.async_call( - "media_player", - "turn_on", - {"entity_id": ENTITY_ID}, - blocking=True, - ) + await hass.services.async_call( + "media_player", "turn_on", {"entity_id": ENTITY_ID}, blocking=True + ) + await hass.async_block_till_done() - await hass.async_block_till_done() assert len(calls) == 2 assert calls[0].data["some"] == device.id assert calls[0].data["id"] == 0 diff --git a/tests/components/samsungtv/test_init.py b/tests/components/samsungtv/test_init.py index 82bfbcce67..24b3e7d4c7 100644 --- a/tests/components/samsungtv/test_init.py +++ b/tests/components/samsungtv/test_init.py @@ -2,6 +2,7 @@ from unittest.mock import Mock, patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.media_player import DOMAIN, MediaPlayerEntityFeature from homeassistant.components.samsungtv.const import ( @@ -31,6 +32,7 @@ from homeassistant.const import ( SERVICE_VOLUME_UP, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from . import setup_samsungtv_entry @@ -77,7 +79,9 @@ REMOTE_CALL = { @pytest.mark.usefixtures("remotews", "remoteencws_failing", "rest_api") -async def test_setup(hass: HomeAssistant) -> None: +async def test_setup( + hass: HomeAssistant, issue_registry: ir.IssueRegistry, snapshot: SnapshotAssertion +) -> None: """Test Samsung TV integration is setup.""" await async_setup_component(hass, SAMSUNGTV_DOMAIN, MOCK_CONFIG) await hass.async_block_till_done() @@ -96,6 +100,10 @@ async def test_setup(hass: HomeAssistant) -> None: DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) + # ensure deprecated_yaml issue is raised + issue = issue_registry.async_get_issue(SAMSUNGTV_DOMAIN, "deprecated_yaml") + assert issue == snapshot + async def test_setup_from_yaml_without_port_device_offline(hass: HomeAssistant) -> None: """Test import from yaml when the device is offline.""" diff --git a/tests/components/samsungtv/test_trigger.py b/tests/components/samsungtv/test_trigger.py index 407d98186b..27f6d7a8e5 100644 --- a/tests/components/samsungtv/test_trigger.py +++ b/tests/components/samsungtv/test_trigger.py @@ -48,15 +48,11 @@ async def test_turn_on_trigger_device_id( }, ) - with patch("homeassistant.components.samsungtv.media_player.send_magic_packet"): - await hass.services.async_call( - "media_player", - "turn_on", - {"entity_id": ENTITY_ID}, - blocking=True, - ) + await hass.services.async_call( + "media_player", "turn_on", {"entity_id": ENTITY_ID}, blocking=True + ) + await hass.async_block_till_done() - await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["some"] == device.id assert calls[0].data["id"] == 0 @@ -66,16 +62,17 @@ async def test_turn_on_trigger_device_id( calls.clear() - with patch("homeassistant.components.samsungtv.media_player.send_magic_packet"): + # Ensure WOL backup is called when trigger not present + with patch( + "homeassistant.components.samsungtv.media_player.send_magic_packet" + ) as mock_send_magic_packet: await hass.services.async_call( - "media_player", - "turn_on", - {"entity_id": ENTITY_ID}, - blocking=True, + "media_player", "turn_on", {"entity_id": ENTITY_ID}, blocking=True ) - await hass.async_block_till_done() + assert len(calls) == 0 + mock_send_magic_packet.assert_called() @pytest.mark.usefixtures("remoteencws", "rest_api") @@ -107,15 +104,11 @@ async def test_turn_on_trigger_entity_id( }, ) - with patch("homeassistant.components.samsungtv.media_player.send_magic_packet"): - await hass.services.async_call( - "media_player", - "turn_on", - {"entity_id": ENTITY_ID}, - blocking=True, - ) + await hass.services.async_call( + "media_player", "turn_on", {"entity_id": ENTITY_ID}, blocking=True + ) + await hass.async_block_till_done() - await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["some"] == ENTITY_ID assert calls[0].data["id"] == 0 From fe49861e26acc6143af3ffcb8d6f3024d36430a1 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 21 Mar 2023 09:07:46 +0100 Subject: [PATCH 1109/2810] Prepare MQTT common tests part4 (#90023) * Upd test_setting_attribute_via_mqtt_json_message * Update test_setting_attribute_with_template * Update test_update_with_json_attrs_not_dict * Update test_update_with_json_attrs_bad_json --- .../mqtt/test_alarm_control_panel.py | 16 ++++++++-------- tests/components/mqtt/test_binary_sensor.py | 19 ++++++++----------- tests/components/mqtt/test_button.py | 16 ++++++++-------- tests/components/mqtt/test_camera.py | 16 ++++++++-------- tests/components/mqtt/test_climate.py | 16 ++++++++-------- tests/components/mqtt/test_cover.py | 16 ++++++++-------- tests/components/mqtt/test_fan.py | 16 ++++++++-------- tests/components/mqtt/test_humidifier.py | 16 ++++++++-------- tests/components/mqtt/test_legacy_vacuum.py | 16 ++++++++-------- tests/components/mqtt/test_light.py | 16 ++++++++-------- tests/components/mqtt/test_light_json.py | 16 ++++++++-------- tests/components/mqtt/test_light_template.py | 16 ++++++++-------- tests/components/mqtt/test_lock.py | 16 ++++++++-------- tests/components/mqtt/test_number.py | 16 ++++++++-------- tests/components/mqtt/test_select.py | 16 ++++++++-------- tests/components/mqtt/test_sensor.py | 16 ++++++++-------- tests/components/mqtt/test_siren.py | 16 ++++++++-------- tests/components/mqtt/test_state_vacuum.py | 16 ++++++++-------- tests/components/mqtt/test_switch.py | 16 ++++++++-------- tests/components/mqtt/test_text.py | 16 ++++++++-------- tests/components/mqtt/test_update.py | 16 ++++++++-------- 21 files changed, 168 insertions(+), 171 deletions(-) diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index 0e760c69a8..020d361a05 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -641,12 +641,12 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, alarm_control_panel.DOMAIN, DEFAULT_CONFIG, ) @@ -666,12 +666,12 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, alarm_control_panel.DOMAIN, DEFAULT_CONFIG, ) @@ -679,13 +679,13 @@ async def test_setting_attribute_with_template( async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, alarm_control_panel.DOMAIN, DEFAULT_CONFIG, @@ -694,13 +694,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, alarm_control_panel.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index 344a33ad1a..49417c3142 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -716,24 +716,21 @@ async def test_off_delay( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, - mqtt_mock_entry_with_yaml_config, - binary_sensor.DOMAIN, - DEFAULT_CONFIG, + hass, mqtt_mock_entry_no_yaml_config, binary_sensor.DOMAIN, DEFAULT_CONFIG ) async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, binary_sensor.DOMAIN, DEFAULT_CONFIG, ) @@ -741,13 +738,13 @@ async def test_setting_attribute_with_template( async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, binary_sensor.DOMAIN, DEFAULT_CONFIG, @@ -756,13 +753,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, binary_sensor.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_button.py b/tests/components/mqtt/test_button.py index 4902deaa49..bfbee72bd1 100644 --- a/tests/components/mqtt/test_button.py +++ b/tests/components/mqtt/test_button.py @@ -206,11 +206,11 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_with_yaml_config, button.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, button.DOMAIN, DEFAULT_CONFIG ) @@ -224,23 +224,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, button.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, button.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, button.DOMAIN, DEFAULT_CONFIG, @@ -249,13 +249,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, button.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_camera.py b/tests/components/mqtt/test_camera.py index 5ba74b0999..4ff15f8d32 100644 --- a/tests/components/mqtt/test_camera.py +++ b/tests/components/mqtt/test_camera.py @@ -196,11 +196,11 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_with_yaml_config, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, camera.DOMAIN, DEFAULT_CONFIG ) @@ -218,23 +218,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, camera.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, camera.DOMAIN, DEFAULT_CONFIG, @@ -243,13 +243,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, camera.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index a9c1ca6598..0ea9588992 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -1487,11 +1487,11 @@ async def test_temperature_unit( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_with_yaml_config, climate.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, climate.DOMAIN, DEFAULT_CONFIG ) @@ -1509,23 +1509,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, climate.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, climate.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, climate.DOMAIN, DEFAULT_CONFIG, @@ -1534,13 +1534,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, climate.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index 9618ff7b04..a20781cad1 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -2591,11 +2591,11 @@ async def test_invalid_device_class( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_with_yaml_config, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN, DEFAULT_CONFIG ) @@ -2613,23 +2613,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, cover.DOMAIN, DEFAULT_CONFIG, @@ -2638,13 +2638,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, cover.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 0b6f32a2fd..4d13eb5c8b 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -1708,11 +1708,11 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_with_yaml_config, fan.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, fan.DOMAIN, DEFAULT_CONFIG ) @@ -1730,23 +1730,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, fan.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, fan.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, fan.DOMAIN, DEFAULT_CONFIG, @@ -1755,13 +1755,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, fan.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_humidifier.py b/tests/components/mqtt/test_humidifier.py index f74e883c4a..bba468a4ab 100644 --- a/tests/components/mqtt/test_humidifier.py +++ b/tests/components/mqtt/test_humidifier.py @@ -1061,11 +1061,11 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_with_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG ) @@ -1083,23 +1083,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, humidifier.DOMAIN, DEFAULT_CONFIG, @@ -1108,13 +1108,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, humidifier.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index 17ae575dca..7bd03bd392 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -670,11 +670,11 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) @@ -692,23 +692,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_2, @@ -717,13 +717,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_2, diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index 515c41b8ba..ed57482f7f 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -2172,11 +2172,11 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) @@ -2194,23 +2194,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, light.DOMAIN, DEFAULT_CONFIG, @@ -2219,13 +2219,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, light.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index f9c1a63793..9bf0ef7a7f 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -1921,11 +1921,11 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) @@ -1943,23 +1943,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, light.DOMAIN, DEFAULT_CONFIG, @@ -1968,13 +1968,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, light.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index 944f6ad016..e2e1b127c1 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -889,11 +889,11 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) @@ -911,23 +911,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, light.DOMAIN, DEFAULT_CONFIG, @@ -936,13 +936,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, light.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index 88b86ef153..a99ad74557 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -691,11 +691,11 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_with_yaml_config, lock.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, lock.DOMAIN, DEFAULT_CONFIG ) @@ -713,23 +713,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, lock.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, lock.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, lock.DOMAIN, DEFAULT_CONFIG, @@ -738,13 +738,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, lock.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index b4c032468e..b005b75a8a 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -484,11 +484,11 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_with_yaml_config, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN, DEFAULT_CONFIG ) @@ -506,23 +506,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, number.DOMAIN, DEFAULT_CONFIG, @@ -531,13 +531,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, number.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_select.py b/tests/components/mqtt/test_select.py index f7ebd8fb7f..6e885f0bff 100644 --- a/tests/components/mqtt/test_select.py +++ b/tests/components/mqtt/test_select.py @@ -361,11 +361,11 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_with_yaml_config, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN, DEFAULT_CONFIG ) @@ -383,23 +383,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, select.DOMAIN, DEFAULT_CONFIG, @@ -408,13 +408,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, select.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index a76f7ec289..eb1e8fbdeb 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -868,11 +868,11 @@ async def test_valid_state_class( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_with_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) @@ -890,23 +890,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, sensor.DOMAIN, DEFAULT_CONFIG, @@ -915,13 +915,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, sensor.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_siren.py b/tests/components/mqtt/test_siren.py index 252a9d13f6..b288acdd15 100644 --- a/tests/components/mqtt/test_siren.py +++ b/tests/components/mqtt/test_siren.py @@ -570,11 +570,11 @@ async def test_custom_state_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_with_yaml_config, siren.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, siren.DOMAIN, DEFAULT_CONFIG ) @@ -588,23 +588,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, siren.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, siren.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, siren.DOMAIN, DEFAULT_CONFIG, @@ -613,13 +613,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, siren.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_state_vacuum.py index e8622ceb9b..5164a747c8 100644 --- a/tests/components/mqtt/test_state_vacuum.py +++ b/tests/components/mqtt/test_state_vacuum.py @@ -407,11 +407,11 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) @@ -429,23 +429,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_2, @@ -454,13 +454,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_2, diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py index a11155f392..4d60424722 100644 --- a/tests/components/mqtt/test_switch.py +++ b/tests/components/mqtt/test_switch.py @@ -332,11 +332,11 @@ async def test_custom_state_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_with_yaml_config, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, switch.DOMAIN, DEFAULT_CONFIG ) @@ -350,23 +350,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, switch.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, switch.DOMAIN, DEFAULT_CONFIG, @@ -375,13 +375,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, switch.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_text.py b/tests/components/mqtt/test_text.py index ed2b2a2e2b..1477240740 100644 --- a/tests/components/mqtt/test_text.py +++ b/tests/components/mqtt/test_text.py @@ -383,11 +383,11 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_with_yaml_config, text.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, text.DOMAIN, DEFAULT_CONFIG ) @@ -401,23 +401,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, text.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, text.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, text.DOMAIN, DEFAULT_CONFIG, @@ -426,13 +426,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, text.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_update.py b/tests/components/mqtt/test_update.py index c324d7d7bf..e300550e13 100644 --- a/tests/components/mqtt/test_update.py +++ b/tests/components/mqtt/test_update.py @@ -467,32 +467,32 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG ) async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, update.DOMAIN, DEFAULT_CONFIG, @@ -501,13 +501,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_json( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, update.DOMAIN, DEFAULT_CONFIG, From 23f136e9d67a38d8ab43082488f481a4fde1c57b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 21 Mar 2023 09:16:32 +0100 Subject: [PATCH 1110/2810] Add state translations for Siren entities (#89994) --- homeassistant/components/siren/strings.json | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/siren/strings.json b/homeassistant/components/siren/strings.json index c8e60e91ce..60d8843c15 100644 --- a/homeassistant/components/siren/strings.json +++ b/homeassistant/components/siren/strings.json @@ -1,3 +1,17 @@ { - "title": "Siren" + "title": "Siren", + "entity_component": { + "_": { + "name": "[%key:component::siren::title%]", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + }, + "state_attributes": { + "available_tones": { + "name": "Available tones" + } + } + } + } } From d8654400126d76e3a7721c3986dffef8d4aa3b97 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 21 Mar 2023 09:19:20 +0100 Subject: [PATCH 1111/2810] Prepare MQTT common tests part3 (#90022) --- .../mqtt/test_alarm_control_panel.py | 95 +++++++-------- tests/components/mqtt/test_binary_sensor.py | 71 +++++------ tests/components/mqtt/test_button.py | 66 ++++++----- tests/components/mqtt/test_camera.py | 55 +++++---- tests/components/mqtt/test_climate.py | 88 +++++++------- tests/components/mqtt/test_common.py | 92 ++++++--------- tests/components/mqtt/test_cover.py | 84 ++++++------- tests/components/mqtt/test_device_tracker.py | 27 +++-- tests/components/mqtt/test_fan.py | 88 +++++++------- tests/components/mqtt/test_humidifier.py | 92 ++++++++------- tests/components/mqtt/test_init.py | 42 +++---- tests/components/mqtt/test_legacy_vacuum.py | 84 ++++++------- tests/components/mqtt/test_light.py | 110 +++++++++--------- tests/components/mqtt/test_light_json.py | 99 ++++++++-------- tests/components/mqtt/test_light_template.py | 105 +++++++++-------- tests/components/mqtt/test_lock.py | 87 +++++++------- tests/components/mqtt/test_number.py | 88 +++++++------- tests/components/mqtt/test_scene.py | 51 ++++---- tests/components/mqtt/test_select.py | 92 ++++++++------- tests/components/mqtt/test_sensor.py | 70 +++++------ tests/components/mqtt/test_siren.py | 87 +++++++------- tests/components/mqtt/test_state_vacuum.py | 88 +++++++------- tests/components/mqtt/test_switch.py | 88 +++++++------- tests/components/mqtt/test_text.py | 88 +++++++------- tests/components/mqtt/test_update.py | 55 +++++---- 25 files changed, 1023 insertions(+), 969 deletions(-) diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index 020d361a05..a7e5678a3b 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -1,6 +1,7 @@ """The tests the MQTT alarm control panel component.""" import copy import json +from typing import Any from unittest.mock import patch import pytest @@ -9,6 +10,7 @@ from homeassistant.components import alarm_control_panel, mqtt from homeassistant.components.mqtt.alarm_control_panel import ( MQTT_ALARM_ATTRIBUTES_BLOCKED, ) +from homeassistant.components.mqtt.models import PublishPayloadType from homeassistant.const import ( ATTR_CODE, ATTR_ENTITY_ID, @@ -58,7 +60,6 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, - help_test_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -172,10 +173,7 @@ async def test_fail_setup_without_state_or_command_topic( hass: HomeAssistant, config, valid ) -> None: """Test for failing setup with no state or command topic.""" - assert ( - help_test_validate_platform_config(hass, alarm_control_panel.DOMAIN, config) - == valid - ) + assert help_test_validate_platform_config(hass, config) == valid @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) @@ -722,30 +720,35 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + alarm_control_panel.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one alarm per unique_id.""" - config = { - mqtt.DOMAIN: { - alarm_control_panel.DOMAIN: [ - { - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "state_topic": "test-topic", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, alarm_control_panel.DOMAIN, config + hass, mqtt_mock_entry_no_yaml_config, alarm_control_panel.DOMAIN ) @@ -888,16 +891,14 @@ async def test_discovery_broken( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, ) -> None: """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, alarm_control_panel.DOMAIN, DEFAULT_CONFIG[mqtt.DOMAIN][alarm_control_panel.DOMAIN], topic, @@ -954,14 +955,11 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, - mqtt_mock_entry_with_yaml_config, - alarm_control_panel.DOMAIN, - DEFAULT_CONFIG, + hass, mqtt_mock_entry_no_yaml_config, alarm_control_panel.DOMAIN, DEFAULT_CONFIG ) @@ -1016,15 +1014,15 @@ async def test_entity_debug_info_message( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, - tpl_par, - tpl_output, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, + tpl_par: str, + tpl_output: PublishPayloadType, ) -> None: """Test publishing MQTT payload with different encoding.""" domain = alarm_control_panel.DOMAIN @@ -1032,7 +1030,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -1056,10 +1054,13 @@ async def test_reloadable( await help_test_reloadable(hass, mqtt_client_mock, domain, config) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = alarm_control_panel.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index 49417c3142..3e224a4136 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -3,6 +3,7 @@ import copy from datetime import datetime, timedelta import json from pathlib import Path +from typing import Any from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory @@ -43,7 +44,6 @@ from .test_common import ( help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, - help_test_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -781,28 +781,33 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + binary_sensor.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one sensor per unique_id.""" - config = { - mqtt.DOMAIN: { - binary_sensor.DOMAIN: [ - { - "name": "Test 1", - "state_topic": "test-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "state_topic": "test-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, binary_sensor.DOMAIN, config + hass, mqtt_mock_entry_no_yaml_config, binary_sensor.DOMAIN ) @@ -909,18 +914,16 @@ async def test_discovery_update_binary_sensor_template( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, ) -> None: """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, binary_sensor.DOMAIN, DEFAULT_CONFIG[mqtt.DOMAIN][binary_sensor.DOMAIN], topic, @@ -1021,14 +1024,11 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, - mqtt_mock_entry_with_yaml_config, - binary_sensor.DOMAIN, - DEFAULT_CONFIG, + hass, mqtt_mock_entry_no_yaml_config, binary_sensor.DOMAIN, DEFAULT_CONFIG ) @@ -1162,10 +1162,13 @@ async def test_skip_restoring_state_with_over_due_expire_trigger( assert state.state == STATE_UNAVAILABLE -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = binary_sensor.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_button.py b/tests/components/mqtt/test_button.py index bfbee72bd1..cdb3d0fbf3 100644 --- a/tests/components/mqtt/test_button.py +++ b/tests/components/mqtt/test_button.py @@ -1,5 +1,6 @@ """The tests for the MQTT button platform.""" import copy +from typing import Any from unittest.mock import patch import pytest @@ -35,7 +36,6 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, - help_test_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -277,29 +277,32 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + button.DOMAIN: [ + { + "name": "Test 1", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one button per unique_id.""" - config = { - mqtt.DOMAIN: { - button.DOMAIN: [ - { - "name": "Test 1", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, button.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, button.DOMAIN) async def test_discovery_removal_button( @@ -497,13 +500,13 @@ async def test_valid_device_class( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, ) -> None: """Test publishing MQTT payload with different encoding.""" domain = button.DOMAIN @@ -511,7 +514,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -533,10 +536,13 @@ async def test_reloadable( await help_test_reloadable(hass, mqtt_client_mock, domain, config) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = button.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_camera.py b/tests/components/mqtt/test_camera.py index 4ff15f8d32..90020bce48 100644 --- a/tests/components/mqtt/test_camera.py +++ b/tests/components/mqtt/test_camera.py @@ -33,7 +33,6 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, - help_test_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -271,29 +270,32 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + camera.DOMAIN: [ + { + "name": "Test 1", + "topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one camera per unique_id.""" - config = { - mqtt.DOMAIN: { - camera.DOMAIN: [ - { - "name": "Test 1", - "topic": "test-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "topic": "test-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, camera.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, camera.DOMAIN) async def test_discovery_removal_camera( @@ -394,12 +396,12 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, camera.DOMAIN, DEFAULT_CONFIG, ["test_topic"], @@ -440,10 +442,13 @@ async def test_reloadable( await help_test_reloadable(hass, mqtt_client_mock, domain, config) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = camera.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 0ea9588992..9a9a9d81e8 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -1,6 +1,7 @@ """The tests for the mqtt climate component.""" import copy import json +from typing import Any from unittest.mock import call, patch import pytest @@ -54,7 +55,6 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, - help_test_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -1562,31 +1562,34 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + climate.DOMAIN: [ + { + "name": "Test 1", + "mode_state_topic": "test_topic1/state", + "mode_command_topic": "test_topic1/command", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "mode_state_topic": "test_topic2/state", + "mode_command_topic": "test_topic2/command", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one climate per unique_id.""" - config = { - mqtt.DOMAIN: { - climate.DOMAIN: [ - { - "name": "Test 1", - "mode_state_topic": "test_topic1/state", - "mode_command_topic": "test_topic1/command", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "mode_state_topic": "test_topic2/state", - "mode_command_topic": "test_topic2/command", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, climate.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, climate.DOMAIN) @pytest.mark.parametrize( @@ -1609,19 +1612,17 @@ async def test_unique_id( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, ) -> None: """Test handling of incoming encoded payload.""" config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][climate.DOMAIN]) await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, climate.DOMAIN, config, topic, @@ -1727,7 +1728,7 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" config = { @@ -1741,7 +1742,7 @@ async def test_entity_id_update_subscriptions( } await help_test_entity_id_update_subscriptions( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, climate.DOMAIN, config, ["test-topic", "avty-topic"], @@ -1919,13 +1920,13 @@ async def test_precision_whole( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, ) -> None: """Test publishing MQTT payload with different encoding.""" domain = climate.DOMAIN @@ -1936,7 +1937,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -2016,10 +2017,13 @@ async def test_reloadable( await help_test_reloadable(hass, mqtt_client_mock, domain, config) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = climate.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index 88d993ef5d..f5a4648e34 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -17,6 +17,7 @@ from homeassistant.components.mqtt import debug_info from homeassistant.components.mqtt.config_integration import PLATFORM_CONFIG_SCHEMA_BASE from homeassistant.components.mqtt.const import MQTT_DISCONNECTED from homeassistant.components.mqtt.mixins import MQTT_ATTRIBUTES_BLOCKED +from homeassistant.components.mqtt.models import PublishPayloadType from homeassistant.config import async_log_exception from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( @@ -73,7 +74,7 @@ MQTT_YAML_SCHEMA = vol.Schema({mqtt.DOMAIN: PLATFORM_CONFIG_SCHEMA_BASE}) def help_test_validate_platform_config( - hass: HomeAssistant, domain: str, config: ConfigType + hass: HomeAssistant, config: ConfigType ) -> ConfigType | None: """Test the schema validation.""" try: @@ -82,7 +83,7 @@ def help_test_validate_platform_config( return True except vol.Error as exc: # log schema exceptions - async_log_exception(exc, domain, config, hass) + async_log_exception(exc, mqtt.DOMAIN, config, hass) return False @@ -394,10 +395,10 @@ async def help_test_default_availability_list_single( {"topic": "availability-topic1"}, ] config[mqtt.DOMAIN][domain]["availability_topic"] = "availability-topic" - help_test_validate_platform_config(hass, domain, config) + help_test_validate_platform_config(hass, config) assert ( - f"Invalid config for [{domain}]: two or more values in the same group of exclusion 'availability'" + "Invalid config for [mqtt]: two or more values in the same group of exclusion 'availability'" in caplog.text ) @@ -698,14 +699,12 @@ async def help_test_discovery_update_attr( async def help_test_unique_id( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, domain: str, - config: ConfigType, ) -> None: """Test unique id option only creates one entity per unique_id.""" - assert await async_setup_component(hass, mqtt.DOMAIN, config) + await mqtt_mock_entry_no_yaml_config() await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() assert len(hass.states.async_entity_ids(domain)) == 1 @@ -854,15 +853,14 @@ async def help_test_discovery_broken( async def help_test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, domain: str, config: ConfigType, topic: str, value: Any, attribute: str | None = None, attribute_value: Any = None, - init_payload: str | None = None, + init_payload: tuple[str, str] | None = None, skip_raw_test: bool = False, ) -> None: """Test handling of incoming encoded payload.""" @@ -929,13 +927,17 @@ async def help_test_encoding_subscribable_topics( init_payload_value_utf8 = init_payload[1].encode("utf-8") init_payload_value_utf16 = init_payload[1].encode("utf-16") - await hass.async_block_till_done() - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {domain: [config1, config2, config3]}} + await mqtt_mock_entry_no_yaml_config() + async_fire_mqtt_message( + hass, f"homeassistant/{domain}/item1/config", json.dumps(config1) + ) + async_fire_mqtt_message( + hass, f"homeassistant/{domain}/item2/config", json.dumps(config2) + ) + async_fire_mqtt_message( + hass, f"homeassistant/{domain}/item3/config", json.dumps(config3) ) await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() expected_result = attribute_value or value @@ -1124,7 +1126,7 @@ async def help_test_entity_device_info_update( async def help_test_entity_id_update_subscriptions( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, domain: str, config: ConfigType, topics: list[str] | None = None, @@ -1142,13 +1144,10 @@ async def help_test_entity_id_update_subscriptions( assert len(topics) > 0 entity_registry = er.async_get(hass) - assert await async_setup_component( - hass, - mqtt.DOMAIN, - config, + mqtt_mock = await help_setup_component( + hass, mqtt_mock_entry_no_yaml_config, domain, config, True ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + assert mqtt_mock is not None state = hass.states.get(f"{domain}.test") assert state is not None @@ -1632,7 +1631,7 @@ async def help_test_entity_category( async def help_test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, domain: str, config: ConfigType, @@ -1642,7 +1641,7 @@ async def help_test_publishing_with_custom_encoding( payload: str, template: str | None, tpl_par: str = "value", - tpl_output: str | None = None, + tpl_output: PublishPayloadType = None, ) -> None: """Test a service with publishing MQTT payload with different encoding.""" # prepare config for tests @@ -1676,14 +1675,16 @@ async def help_test_publishing_with_custom_encoding( if parameters: service_data[test_id].update(parameters) - # setup test entities - assert await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {domain: setup_config}}, - ) + # setup test entities using discovery + mqtt_mock = await mqtt_mock_entry_no_yaml_config() + item: int = 0 + for component_config in setup_config: + conf = json.dumps(component_config) + item += 1 + async_fire_mqtt_message( + hass, f"homeassistant/{domain}/component_{item}/config", conf + ) await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() # 1) test with default encoding await hass.services.async_call( @@ -1692,6 +1693,7 @@ async def help_test_publishing_with_custom_encoding( service_data["test1"], blocking=True, ) + await hass.async_block_till_done() mqtt_mock.async_publish.assert_any_call("cmd/test1", str(payload), 0, False) mqtt_mock.async_publish.reset_mock() @@ -1816,8 +1818,7 @@ async def help_test_reloadable( # We should call await mqtt.async_setup_entry(hass, entry) when async_setup # is removed (this is planned with #87987). Until then we set up the mqtt component # to test reload after the async_setup setup has set the initial config - await async_setup_component(hass, mqtt.DOMAIN, old_config) - await hass.async_block_till_done() + await help_setup_component(hass, None, domain, old_config, use_discovery=False) assert hass.states.get(f"{domain}.test_old_1") assert hass.states.get(f"{domain}.test_old_2") @@ -1852,23 +1853,6 @@ async def help_test_reloadable( assert hass.states.get(f"{domain}.test_new_3") -async def help_test_setup_manual_entity_from_yaml( - hass: HomeAssistant, config: ConfigType -) -> None: - """Help to test setup from yaml through configuration entry.""" - # until `async_setup` does the initial config setup, we need to use - # async_setup_component to test with other yaml config - assert await async_setup_component(hass, mqtt.DOMAIN, config) - # Mock config entry - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) - entry.add_to_hass(hass) - - with patch("paho.mqtt.client.Client") as mock_client: - mock_client().connect = lambda *args: 0 - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - async def help_test_unload_config_entry(hass: HomeAssistant) -> None: """Test unloading the MQTT config entry.""" mqtt_config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] @@ -1892,9 +1876,9 @@ async def help_test_unload_config_entry_with_platform( config_setup: dict[str, dict[str, Any]] = copy.deepcopy(config) config_setup[mqtt.DOMAIN][domain]["name"] = "config_setup" config_name = config_setup - # To be replaced with entry setup when `async_setup` is removed. - assert await async_setup_component(hass, mqtt.DOMAIN, config_setup) - await hass.async_block_till_done() + await help_setup_component( + hass, mqtt_mock_entry_no_yaml_config, domain, config_setup + ) # prepare setup through discovery discovery_setup = copy.deepcopy(config[mqtt.DOMAIN][domain]) diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index a20781cad1..a09edcd25e 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -1,4 +1,5 @@ """The tests for the MQTT cover platform.""" +from typing import Any from unittest.mock import patch import pytest @@ -69,7 +70,6 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, - help_test_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -2666,29 +2666,32 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + cover.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique_id option only creates one cover per id.""" - config = { - mqtt.DOMAIN: { - cover.DOMAIN: [ - { - "name": "Test 1", - "state_topic": "test-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "state_topic": "test-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, cover.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN) async def test_discovery_removal_cover( @@ -2787,11 +2790,11 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_with_yaml_config, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN, DEFAULT_CONFIG ) @@ -3469,13 +3472,13 @@ async def test_tilt_status_template_without_tilt_status_topic_topic( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, ) -> None: """Test publishing MQTT payload with different encoding.""" domain = cover.DOMAIN @@ -3484,7 +3487,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -3517,18 +3520,16 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, ) -> None: """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, cover.DOMAIN, DEFAULT_CONFIG[mqtt.DOMAIN][cover.DOMAIN], topic, @@ -3539,10 +3540,13 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = cover.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py index bc3371f6e5..a8c45f8cd7 100644 --- a/tests/components/mqtt/test_device_tracker.py +++ b/tests/components/mqtt/test_device_tracker.py @@ -4,17 +4,13 @@ from unittest.mock import patch import pytest from homeassistant.components import device_tracker, mqtt -from homeassistant.components.device_tracker import legacy from homeassistant.components.mqtt.const import DOMAIN as MQTT_DOMAIN from homeassistant.const import STATE_HOME, STATE_NOT_HOME, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from .test_common import ( - help_test_setting_blocked_attribute_via_mqtt_json_message, - help_test_setup_manual_entity_from_yaml, -) +from .test_common import help_test_setting_blocked_attribute_via_mqtt_json_message from tests.common import async_fire_mqtt_message from tests.typing import MqttMockHAClientGenerator, WebSocketGenerator @@ -589,18 +585,21 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + device_tracker.DOMAIN: {"name": "jan", "state_topic": "/location/jan"} + } + } + ], +) async def test_setup_with_modern_schema( - hass: HomeAssistant, mock_device_tracker_conf: list[legacy.Device] + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setup using the modern schema.""" + await mqtt_mock_entry_no_yaml_config() dev_id = "jan" entity_id = f"{device_tracker.DOMAIN}.{dev_id}" - topic = "/location/jan" - - config = { - mqtt.DOMAIN: {device_tracker.DOMAIN: {"name": dev_id, "state_topic": topic}} - } - - await help_test_setup_manual_entity_from_yaml(hass, config) - assert hass.states.get(entity_id) is not None diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 4d13eb5c8b..41ff43aba8 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -1,5 +1,6 @@ """Test MQTT fans.""" import copy +from typing import Any from unittest.mock import patch import pytest @@ -56,7 +57,6 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, - help_test_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -1371,12 +1371,11 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, ) -> None: """Test handling of incoming encoded payload.""" config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][fan.DOMAIN]) @@ -1386,8 +1385,7 @@ async def test_encoding_subscribable_topics( config[CONF_OSCILLATION_COMMAND_TOPIC] = "fan/some_oscillation_command_topic" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, fan.DOMAIN, config, topic, @@ -1779,31 +1777,34 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + fan.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "test_topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "command_topic": "test_topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique_id option only creates one fan per id.""" - config = { - mqtt.DOMAIN: { - fan.DOMAIN: [ - { - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "test_topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "state_topic": "test-topic", - "command_topic": "test_topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, fan.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, fan.DOMAIN) async def test_discovery_removal_fan( @@ -1903,11 +1904,11 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_with_yaml_config, fan.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, fan.DOMAIN, DEFAULT_CONFIG ) @@ -1975,13 +1976,13 @@ async def test_entity_debug_info_message( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, ) -> None: """Test publishing MQTT payload with different encoding.""" domain = fan.DOMAIN @@ -1991,7 +1992,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -2013,10 +2014,13 @@ async def test_reloadable( await help_test_reloadable(hass, mqtt_client_mock, domain, config) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = fan.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_humidifier.py b/tests/components/mqtt/test_humidifier.py index bba468a4ab..89afe0a397 100644 --- a/tests/components/mqtt/test_humidifier.py +++ b/tests/components/mqtt/test_humidifier.py @@ -1,5 +1,6 @@ """Test MQTT humidifiers.""" import copy +from typing import Any from unittest.mock import patch import pytest @@ -58,7 +59,6 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, - help_test_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -752,12 +752,11 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, ) -> None: """Test handling of incoming encoded payload.""" config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][humidifier.DOMAIN]) @@ -765,8 +764,7 @@ async def test_encoding_subscribable_topics( config[CONF_MODE_COMMAND_TOPIC] = "humidifier/some_mode_command_topic" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN, config, topic, @@ -1136,33 +1134,36 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + humidifier.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "test_topic", + "target_humidity_command_topic": "humidity-command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "command_topic": "test_topic", + "target_humidity_command_topic": "humidity-command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique_id option only creates one fan per id.""" - config = { - mqtt.DOMAIN: { - humidifier.DOMAIN: [ - { - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "test_topic", - "target_humidity_command_topic": "humidity-command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "state_topic": "test-topic", - "command_topic": "test_topic", - "target_humidity_command_topic": "humidity-command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, humidifier.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN) async def test_discovery_removal_humidifier( @@ -1274,11 +1275,11 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_with_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG ) @@ -1339,13 +1340,13 @@ async def test_entity_debug_info_message( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, ) -> None: """Test publishing MQTT payload with different encoding.""" domain = humidifier.DOMAIN @@ -1355,7 +1356,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -1377,10 +1378,13 @@ async def test_reloadable( await help_test_reloadable(hass, mqtt_client_mock, domain, config) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = humidifier.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 2209006428..9fbca57e3a 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -42,7 +42,7 @@ from homeassistant.util.dt import utcnow from .test_common import ( help_test_entry_reload_with_new_config, - help_test_setup_manual_entity_from_yaml, + help_test_validate_platform_config, ) from tests.common import ( @@ -1867,8 +1867,7 @@ async def test_setup_manual_mqtt_with_platform_key( } } } - with pytest.raises(AssertionError): - await help_test_setup_manual_entity_from_yaml(hass, config) + help_test_validate_platform_config(hass, config) assert ( "Invalid config for [mqtt]: [platform] is an invalid option for [mqtt]" in caplog.text @@ -1881,8 +1880,7 @@ async def test_setup_manual_mqtt_with_invalid_config( ) -> None: """Test set up a manual MQTT item with an invalid config.""" config = {mqtt.DOMAIN: {"light": {"name": "test"}}} - with pytest.raises(AssertionError): - await help_test_setup_manual_entity_from_yaml(hass, config) + help_test_validate_platform_config(hass, config) assert ( "Invalid config for [mqtt]: required key not provided @ data['mqtt']['light'][0]['command_topic']." " Got None. (See ?, line ?)" in caplog.text @@ -1895,7 +1893,7 @@ async def test_setup_manual_mqtt_empty_platform( ) -> None: """Test set up a manual MQTT platform without items.""" config: ConfigType = {mqtt.DOMAIN: {"light": []}} - await help_test_setup_manual_entity_from_yaml(hass, config) + help_test_validate_platform_config(hass, config) assert "voluptuous.error.MultipleInvalid" not in caplog.text @@ -3407,12 +3405,12 @@ async def test_disabling_and_enabling_entry( ) async def test_setup_manual_items_with_unique_ids( hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - hass_config: ConfigType, unique: bool, ) -> None: """Test setup manual items is generating unique id's.""" - await help_test_setup_manual_entity_from_yaml(hass, hass_config) + await mqtt_mock_entry_no_yaml_config() assert hass.states.get("light.test1") is not None assert (hass.states.get("light.test2") is not None) == unique @@ -3480,19 +3478,20 @@ async def test_remove_unknown_conf_entry_options( ], ) async def test_link_config_entry( - hass: HomeAssistant, hass_config: ConfigType, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test manual and dynamically setup entities are linked to the config entry.""" + # set up manual item + await mqtt_mock_entry_no_yaml_config() + + # set up item through discovery config_discovery = { "name": "test_discovery", "unique_id": "test_discovery_unique456", "command_topic": "test-topic_discovery", } - - # set up manual item - await help_test_setup_manual_entity_from_yaml(hass, hass_config) - - # set up item through discovery async_fire_mqtt_message( hass, "homeassistant/light/bla/config", json.dumps(config_discovery) ) @@ -3541,18 +3540,3 @@ async def test_link_config_entry( ) await hass.async_block_till_done() assert _check_entities() == 2 - - -@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SENSOR]) -@pytest.mark.parametrize( - "config_manual", - [ - {"mqtt": {"sensor": []}}, - {"mqtt": {"broker": "test"}}, - ], -) -async def test_setup_manual_entity_from_yaml( - hass: HomeAssistant, config_manual: ConfigType -) -> None: - """Test setup with empty platform keys.""" - await help_test_setup_manual_entity_from_yaml(hass, config_manual) diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index 7bd03bd392..42077fee0a 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -1,6 +1,7 @@ """The tests for the Legacy Mqtt vacuum platform.""" from copy import deepcopy import json +from typing import Any from unittest.mock import patch import pytest @@ -56,7 +57,6 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, - help_test_setup_manual_entity_from_yaml, help_test_unique_id, help_test_update_with_json_attrs_bad_json, help_test_update_with_json_attrs_not_dict, @@ -745,29 +745,32 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + vacuum.DOMAIN: [ + { + "name": "Test 1", + "command_topic": "test_topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "command_topic": "test_topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one vacuum per unique_id.""" - config = { - mqtt.DOMAIN: { - vacuum.DOMAIN: [ - { - "name": "Test 1", - "command_topic": "test_topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "command_topic": "test_topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN) async def test_discovery_removal_vacuum( @@ -866,7 +869,7 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" config = { @@ -882,7 +885,7 @@ async def test_entity_id_update_subscriptions( } await help_test_entity_id_update_subscriptions( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, config, ["test-topic", "avty-topic"], @@ -964,13 +967,13 @@ async def test_entity_debug_info_message( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, ) -> None: """Test publishing MQTT payload with different encoding.""" domain = vacuum.DOMAIN @@ -985,7 +988,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -1030,12 +1033,11 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, ) -> None: """Test handling of incoming encoded payload.""" domain = vacuum.DOMAIN @@ -1056,8 +1058,7 @@ async def test_encoding_subscribable_topics( await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, config, topic, @@ -1068,8 +1069,11 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = vacuum.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.mqtttest") diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index ed57482f7f..7d0c0333b7 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -169,6 +169,7 @@ mqtt: """ import copy +from typing import Any from unittest.mock import call, patch import pytest @@ -186,6 +187,7 @@ from homeassistant.components.mqtt.light.schema_basic import ( CONF_XY_COMMAND_TOPIC, MQTT_LIGHT_ATTRIBUTES_BLOCKED, ) +from homeassistant.components.mqtt.models import PublishPayloadType from homeassistant.const import ( ATTR_ASSUMED_STATE, STATE_OFF, @@ -219,7 +221,6 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, - help_test_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -2247,31 +2248,34 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "test_topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "command_topic": "test_topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one light per unique_id.""" - config = { - mqtt.DOMAIN: { - light.DOMAIN: [ - { - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "test_topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "state_topic": "test-topic", - "command_topic": "test_topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN) async def test_discovery_removal_light( @@ -2859,11 +2863,11 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) @@ -2999,15 +3003,15 @@ async def test_max_mireds( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, - tpl_par, - tpl_output, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, + tpl_par: str, + tpl_output: PublishPayloadType, ) -> None: """Test publishing MQTT payload with different encoding.""" domain = light.DOMAIN @@ -3019,7 +3023,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -3075,13 +3079,12 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, - init_payload, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, + init_payload: tuple[str, str] | None, ) -> None: """Test handling of incoming encoded payload.""" config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][light.DOMAIN]) @@ -3098,8 +3101,7 @@ async def test_encoding_subscribable_topics( await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, light.DOMAIN, config, topic, @@ -3118,13 +3120,13 @@ async def test_encoding_subscribable_topics( ) async def test_encoding_subscribable_topics_brightness( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, - init_payload, + topic: str, + value: str, + attribute: str, + attribute_value: int, + init_payload: tuple[str, str] | None, ) -> None: """Test handling of incoming encoded payload for a brightness only light.""" config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][light.DOMAIN]) @@ -3132,8 +3134,7 @@ async def test_encoding_subscribable_topics_brightness( await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, light.DOMAIN, config, topic, @@ -3298,10 +3299,13 @@ async def test_sending_mqtt_xy_command_with_template( assert state.attributes["xy_color"] == (0.151, 0.343) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = light.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 9bf0ef7a7f..db39cea784 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -79,6 +79,7 @@ light: brightness_scale: 99 """ import copy +from typing import Any from unittest.mock import call, patch import pytest @@ -87,6 +88,7 @@ from homeassistant.components import light, mqtt from homeassistant.components.mqtt.light.schema_basic import ( MQTT_LIGHT_ATTRIBUTES_BLOCKED, ) +from homeassistant.components.mqtt.models import PublishPayloadType from homeassistant.const import ( ATTR_ASSUMED_STATE, ATTR_SUPPORTED_FEATURES, @@ -122,7 +124,6 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, - help_test_setup_manual_entity_from_yaml, help_test_unique_id, help_test_update_with_json_attrs_bad_json, help_test_update_with_json_attrs_not_dict, @@ -1996,33 +1997,36 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: [ + { + "name": "Test 1", + "schema": "json", + "state_topic": "test-topic", + "command_topic": "test_topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "schema": "json", + "state_topic": "test-topic", + "command_topic": "test_topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one light per unique_id.""" - config = { - mqtt.DOMAIN: { - light.DOMAIN: [ - { - "name": "Test 1", - "schema": "json", - "state_topic": "test-topic", - "command_topic": "test_topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "schema": "json", - "state_topic": "test-topic", - "command_topic": "test_topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN) async def test_discovery_removal( @@ -2167,11 +2171,11 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) @@ -2247,15 +2251,15 @@ async def test_max_mireds( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, - tpl_par, - tpl_output, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, + tpl_par: str, + tpl_output: PublishPayloadType, ) -> None: """Test publishing MQTT payload with different encoding.""" domain = light.DOMAIN @@ -2265,7 +2269,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -2303,13 +2307,12 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, - init_payload, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, + init_payload: tuple[str, str] | None, ) -> None: """Test handling of incoming encoded payload.""" config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][light.DOMAIN]) @@ -2324,8 +2327,7 @@ async def test_encoding_subscribable_topics( ] await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, light.DOMAIN, config, topic, @@ -2337,8 +2339,11 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = light.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index e2e1b127c1..c8018d4847 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -25,6 +25,7 @@ If your light doesn't support color temp feature, omit `color_temp_template`. If your light doesn't support RGB feature, omit `(red|green|blue)_template`. """ import copy +from typing import Any from unittest.mock import patch import pytest @@ -33,6 +34,7 @@ from homeassistant.components import light, mqtt from homeassistant.components.mqtt.light.schema_basic import ( MQTT_LIGHT_ATTRIBUTES_BLOCKED, ) +from homeassistant.components.mqtt.models import PublishPayloadType from homeassistant.const import ( ATTR_ASSUMED_STATE, ATTR_SUPPORTED_FEATURES, @@ -67,7 +69,6 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, - help_test_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -964,37 +965,40 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: [ + { + "name": "Test 1", + "schema": "template", + "state_topic": "test-topic", + "command_topic": "test_topic", + "command_on_template": "on,{{ transition }}", + "command_off_template": "off,{{ transition|d }}", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "schema": "template", + "state_topic": "test-topic2", + "command_topic": "test_topic2", + "command_on_template": "on,{{ transition }}", + "command_off_template": "off,{{ transition|d }}", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one light per unique_id.""" - config = { - mqtt.DOMAIN: { - light.DOMAIN: [ - { - "name": "Test 1", - "schema": "template", - "state_topic": "test-topic", - "command_topic": "test_topic", - "command_on_template": "on,{{ transition }}", - "command_off_template": "off,{{ transition|d }}", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "schema": "template", - "state_topic": "test-topic2", - "command_topic": "test_topic2", - "command_on_template": "on,{{ transition }}", - "command_off_template": "off,{{ transition|d }}", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN) async def test_discovery_removal( @@ -1127,11 +1131,11 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) @@ -1219,15 +1223,15 @@ async def test_max_mireds( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, - tpl_par, - tpl_output, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, + tpl_par: str, + tpl_output: PublishPayloadType, ) -> None: """Test publishing MQTT payload with different encoding.""" domain = light.DOMAIN @@ -1237,7 +1241,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -1269,12 +1273,11 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, init_payload, ) -> None: """Test handling of incoming encoded payload.""" @@ -1282,8 +1285,7 @@ async def test_encoding_subscribable_topics( config["state_template"] = "{{ value }}" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, light.DOMAIN, config, topic, @@ -1294,10 +1296,13 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = light.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index a99ad74557..0c8b6680c5 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -1,5 +1,5 @@ """The tests for the MQTT lock platform.""" -from pathlib import Path +from typing import Any from unittest.mock import patch import pytest @@ -50,7 +50,6 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, - help_test_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -762,31 +761,34 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + lock.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "test_topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "command_topic": "test_topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one lock per unique_id.""" - config = { - mqtt.DOMAIN: { - lock.DOMAIN: [ - { - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "test_topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "state_topic": "test-topic", - "command_topic": "test_topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, lock.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, lock.DOMAIN) async def test_discovery_removal_lock( @@ -899,11 +901,11 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_with_yaml_config, lock.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, lock.DOMAIN, DEFAULT_CONFIG ) @@ -944,13 +946,13 @@ async def test_entity_debug_info_message( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, ) -> None: """Test publishing MQTT payload with different encoding.""" domain = lock.DOMAIN @@ -958,7 +960,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -988,18 +990,16 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, ) -> None: """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, lock.DOMAIN, DEFAULT_CONFIG[mqtt.DOMAIN][lock.DOMAIN], topic, @@ -1009,12 +1009,13 @@ async def test_encoding_subscribable_topics( ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_setup_manual_entity_from_yaml( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = lock.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index b005b75a8a..eb2a640228 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -1,5 +1,6 @@ """The tests for mqtt number component.""" import json +from typing import Any from unittest.mock import patch import pytest @@ -54,7 +55,6 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, - help_test_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -559,31 +559,34 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + number.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "command_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one number per unique_id.""" - config = { - mqtt.DOMAIN: { - number.DOMAIN: [ - { - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "test-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "state_topic": "test-topic", - "command_topic": "test-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, number.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN) async def test_discovery_removal_number( @@ -696,11 +699,11 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_with_yaml_config, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN, DEFAULT_CONFIG ) @@ -938,13 +941,13 @@ async def test_mqtt_payload_out_of_range_error( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, ) -> None: """Test publishing MQTT payload with different encoding.""" domain = NUMBER_DOMAIN @@ -952,7 +955,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -983,18 +986,16 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, ) -> None: """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, number.DOMAIN, DEFAULT_CONFIG[mqtt.DOMAIN][number.DOMAIN], topic, @@ -1004,10 +1005,13 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = number.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_scene.py b/tests/components/mqtt/test_scene.py index 3662cd6a1c..3da5fd4f36 100644 --- a/tests/components/mqtt/test_scene.py +++ b/tests/components/mqtt/test_scene.py @@ -19,7 +19,6 @@ from .test_common import ( help_test_discovery_update, help_test_discovery_update_unchanged, help_test_reloadable, - help_test_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, ) @@ -148,29 +147,32 @@ async def test_custom_availability_payload( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + scene.DOMAIN: [ + { + "name": "Test 1", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one scene per unique_id.""" - config = { - mqtt.DOMAIN: { - scene.DOMAIN: [ - { - "name": "Test 1", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, scene.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, scene.DOMAIN) async def test_discovery_removal_scene( @@ -252,10 +254,13 @@ async def test_reloadable( await help_test_reloadable(hass, mqtt_client_mock, domain, config) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = scene.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_select.py b/tests/components/mqtt/test_select.py index 6e885f0bff..3a639ecf08 100644 --- a/tests/components/mqtt/test_select.py +++ b/tests/components/mqtt/test_select.py @@ -1,6 +1,7 @@ """The tests for mqtt select component.""" import copy import json +from typing import Any from unittest.mock import patch import pytest @@ -46,7 +47,6 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, - help_test_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -436,33 +436,36 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + select.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + "options": ["milk", "beer"], + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "command_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + "options": ["milk", "beer"], + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one select per unique_id.""" - config = { - mqtt.DOMAIN: { - select.DOMAIN: [ - { - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "test-topic", - "unique_id": "TOTALLY_UNIQUE", - "options": ["milk", "beer"], - }, - { - "name": "Test 2", - "state_topic": "test-topic", - "command_topic": "test-topic", - "unique_id": "TOTALLY_UNIQUE", - "options": ["milk", "beer"], - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, select.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN) async def test_discovery_removal_select( @@ -573,11 +576,11 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_with_yaml_config, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN, DEFAULT_CONFIG ) @@ -683,13 +686,13 @@ async def test_mqtt_payload_not_an_option_warning( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, ) -> None: """Test publishing MQTT payload with different encoding.""" domain = select.DOMAIN @@ -698,7 +701,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -729,20 +732,18 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, ) -> None: """Test handling of incoming encoded payload.""" config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][select.DOMAIN]) config["options"] = ["milk", "beer"] await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, select.DOMAIN, config, topic, @@ -752,10 +753,13 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = select.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index eb1e8fbdeb..6889069c8c 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -3,6 +3,7 @@ import copy from datetime import datetime, timedelta import json from pathlib import Path +from typing import Any from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory @@ -56,7 +57,6 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, - help_test_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -943,29 +943,32 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + sensor.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one sensor per unique_id.""" - config = { - mqtt.DOMAIN: { - sensor.DOMAIN: [ - { - "name": "Test 1", - "state_topic": "test-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "state_topic": "test-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, sensor.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN) async def test_discovery_removal_sensor( @@ -1123,11 +1126,11 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_with_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) @@ -1378,18 +1381,16 @@ async def test_skip_restoring_state_with_over_due_expire_trigger( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, ) -> None: """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG[mqtt.DOMAIN][sensor.DOMAIN], topic, @@ -1400,10 +1401,13 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = sensor.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_siren.py b/tests/components/mqtt/test_siren.py index b288acdd15..9837a3cc8a 100644 --- a/tests/components/mqtt/test_siren.py +++ b/tests/components/mqtt/test_siren.py @@ -44,7 +44,6 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, - help_test_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -641,31 +640,34 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + siren.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one siren per unique_id.""" - config = { - mqtt.DOMAIN: { - siren.DOMAIN: [ - { - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "state_topic": "test-topic", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, siren.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, siren.DOMAIN) async def test_discovery_removal_siren( @@ -924,11 +926,11 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_with_yaml_config, siren.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, siren.DOMAIN, DEFAULT_CONFIG ) @@ -976,13 +978,13 @@ async def test_entity_debug_info_message( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, ) -> None: """Test publishing MQTT payload with command templates and different encoding.""" domain = siren.DOMAIN @@ -991,7 +993,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -1021,18 +1023,16 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, ) -> None: """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, siren.DOMAIN, DEFAULT_CONFIG[mqtt.DOMAIN][siren.DOMAIN], topic, @@ -1042,10 +1042,13 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = siren.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_state_vacuum.py index 5164a747c8..a5c8381310 100644 --- a/tests/components/mqtt/test_state_vacuum.py +++ b/tests/components/mqtt/test_state_vacuum.py @@ -1,6 +1,7 @@ """The tests for the State vacuum Mqtt platform.""" from copy import deepcopy import json +from typing import Any from unittest.mock import patch import pytest @@ -53,7 +54,6 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, - help_test_setup_manual_entity_from_yaml, help_test_unique_id, help_test_update_with_json_attrs_bad_json, help_test_update_with_json_attrs_not_dict, @@ -482,31 +482,34 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + vacuum.DOMAIN: [ + { + "schema": "state", + "name": "Test 1", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "schema": "state", + "name": "Test 2", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one vacuum per unique_id.""" - config = { - mqtt.DOMAIN: { - vacuum.DOMAIN: [ - { - "schema": "state", - "name": "Test 1", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "schema": "state", - "name": "Test 2", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN) async def test_discovery_removal_vacuum( @@ -605,11 +608,11 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) @@ -679,13 +682,13 @@ async def test_entity_debug_info_message( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, ) -> None: """Test publishing MQTT payload with different encoding.""" domain = vacuum.DOMAIN @@ -705,7 +708,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -746,18 +749,16 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, ) -> None: """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN], topic, @@ -768,8 +769,11 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = vacuum.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.mqtttest") diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py index 4d60424722..83580edf00 100644 --- a/tests/components/mqtt/test_switch.py +++ b/tests/components/mqtt/test_switch.py @@ -1,5 +1,6 @@ """The tests for the MQTT switch platform.""" import copy +from typing import Any from unittest.mock import patch import pytest @@ -39,7 +40,6 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, - help_test_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -403,31 +403,34 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + switch.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one switch per unique_id.""" - config = { - mqtt.DOMAIN: { - switch.DOMAIN: [ - { - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "state_topic": "test-topic", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, switch.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, switch.DOMAIN) async def test_discovery_removal_switch( @@ -602,11 +605,11 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_with_yaml_config, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, switch.DOMAIN, DEFAULT_CONFIG ) @@ -653,13 +656,13 @@ async def test_entity_debug_info_message( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, ) -> None: """Test publishing MQTT payload with different encoding.""" domain = switch.DOMAIN @@ -667,7 +670,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -697,18 +700,16 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, ) -> None: """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, switch.DOMAIN, DEFAULT_CONFIG[mqtt.DOMAIN][switch.DOMAIN], topic, @@ -718,10 +719,13 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = switch.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_text.py b/tests/components/mqtt/test_text.py index 1477240740..d12a03a9fa 100644 --- a/tests/components/mqtt/test_text.py +++ b/tests/components/mqtt/test_text.py @@ -1,6 +1,7 @@ """The tests for the MQTT text platform.""" from __future__ import annotations +from typing import Any from unittest.mock import patch import pytest @@ -38,7 +39,6 @@ from .test_common import ( help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, - help_test_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -454,31 +454,34 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + text.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one text per unique_id.""" - config = { - mqtt.DOMAIN: { - text.DOMAIN: [ - { - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "state_topic": "test-topic", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, text.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, text.DOMAIN) async def test_discovery_removal_text( @@ -627,11 +630,11 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_with_yaml_config, text.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, text.DOMAIN, DEFAULT_CONFIG ) @@ -667,13 +670,13 @@ async def test_entity_debug_info_message( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, ) -> None: """Test publishing MQTT payload with different encoding.""" domain = text.DOMAIN @@ -681,7 +684,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -711,18 +714,16 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, ) -> None: """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, text.DOMAIN, DEFAULT_CONFIG[mqtt.DOMAIN][text.DOMAIN], topic, @@ -732,10 +733,13 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = text.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_update.py b/tests/components/mqtt/test_update.py index e300550e13..200a3ca6dd 100644 --- a/tests/components/mqtt/test_update.py +++ b/tests/components/mqtt/test_update.py @@ -34,7 +34,6 @@ from .test_common import ( help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, - help_test_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -529,31 +528,34 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + update.DOMAIN: [ + { + "name": "Bear", + "state_topic": "installed-topic", + "latest_version_topic": "latest-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Milk", + "state_topic": "installed-topic", + "latest_version_topic": "latest-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one update per unique_id.""" - config = { - mqtt.DOMAIN: { - update.DOMAIN: [ - { - "name": "Bear", - "state_topic": "installed-topic", - "latest_version_topic": "latest-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Milk", - "state_topic": "installed-topic", - "latest_version_topic": "latest-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN) async def test_discovery_removal_update( @@ -670,10 +672,13 @@ async def test_entity_id_update_discovery_update( ) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = update.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") From 43ce6f843c0d30a00346837e5b4058863cd0f095 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 21 Mar 2023 01:21:14 -0700 Subject: [PATCH 1112/2810] Update the calendar trigger based on PR feedback (#90017) --- homeassistant/components/calendar/trigger.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/calendar/trigger.py b/homeassistant/components/calendar/trigger.py index 7807539413..f8a6014e26 100644 --- a/homeassistant/components/calendar/trigger.py +++ b/homeassistant/components/calendar/trigger.py @@ -64,7 +64,7 @@ class Timespan: """Return a new interval shifted by the specified offset.""" return Timespan(self.start + offset, self.end + offset) - def contains(self, trigger: datetime.datetime) -> bool: + def __contains__(self, trigger: datetime.datetime) -> bool: """Return true if the trigger time is within the time span.""" return self.start <= trigger < self.end @@ -83,7 +83,7 @@ class Timespan: return Timespan(self.end, max(self.end, now) + interval) def __str__(self) -> str: - """Return a compact string representation.""" + """Return a string representing the half open interval timespan.""" return f"[{self.start}, {self.end})" @@ -125,9 +125,10 @@ def queued_event_fetcher( for trigger_time, event in zip( map(get_trigger_time, active_events), active_events ): - if not offset_timespan.contains(trigger_time): + if trigger_time not in offset_timespan: continue results.append(QueuedCalendarEvent(trigger_time + offset, event)) + _LOGGER.debug( "Scan events @ %s%s found %s eligble of %s active", offset_timespan, From 292feb4e246ccf8de88475ada7b95260d52fda31 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 21 Mar 2023 09:51:05 +0100 Subject: [PATCH 1113/2810] Enable inheritance checks on ExtraStoredData (#90021) --- homeassistant/components/zwave_js/update.py | 2 +- pylint/plugins/hass_enforce_type_hints.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index 33cb0a1c5a..70d12b22de 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -132,7 +132,7 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): self._attr_device_info = get_device_info(driver, node) @property - def extra_restore_state_data(self) -> ExtraStoredData: + def extra_restore_state_data(self) -> ZWaveNodeFirmwareUpdateExtraStoredData: """Return ZWave Node Firmware Update specific state data to be restored.""" return ZWaveNodeFirmwareUpdateExtraStoredData(self._latest_version_firmware) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index c63fde19c8..9430158fae 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -680,6 +680,7 @@ _RESTORE_ENTITY_MATCH: list[TypeHintMatch] = [ TypeHintMatch( function_name="extra_restore_state_data", return_type=["ExtraStoredData", None], + check_return_type_inheritance=True, ), ] _TOGGLE_ENTITY_MATCH: list[TypeHintMatch] = [ From 04872f72eac497b4d00c52dc02d217023d6322f4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 21 Mar 2023 10:32:13 +0100 Subject: [PATCH 1114/2810] Improve humidifier type hints in integrations (#90030) Fix humidifier type hints in integrations --- homeassistant/components/generic_hygrostat/humidifier.py | 2 +- homeassistant/components/xiaomi_miio/humidifier.py | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index 8ed2711d7c..dfd6be14e6 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -436,7 +436,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): data = {ATTR_ENTITY_ID: self._switch_entity_id} await self.hass.services.async_call(HA_DOMAIN, SERVICE_TURN_OFF, data) - async def async_set_mode(self, mode: str): + async def async_set_mode(self, mode: str) -> None: """Set new mode. This method must be run in the event loop and returns a coroutine. diff --git a/homeassistant/components/xiaomi_miio/humidifier.py b/homeassistant/components/xiaomi_miio/humidifier.py index 50e0cd8c72..6fde33309e 100644 --- a/homeassistant/components/xiaomi_miio/humidifier.py +++ b/homeassistant/components/xiaomi_miio/humidifier.py @@ -1,6 +1,7 @@ """Support for Xiaomi Mi Air Purifier and Xiaomi Mi Air Humidifier with humidifier entity.""" import logging import math +from typing import Any from miio.integrations.humidifier.deerma.airhumidifier_mjjsq import ( OperationMode as AirhumidifierMjjsqOperationMode, @@ -136,10 +137,7 @@ class XiaomiGenericHumidifier(XiaomiCoordinatedMiioEntity, HumidifierEntity): """Get the current mode.""" return self._mode - async def async_turn_on( - self, - **kwargs, - ) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" result = await self._try_command( "Turning the miio device on failed.", self._device.on @@ -148,7 +146,7 @@ class XiaomiGenericHumidifier(XiaomiCoordinatedMiioEntity, HumidifierEntity): self._state = True self.async_write_ha_state() - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" result = await self._try_command( "Turning the miio device off failed.", self._device.off From 4836404288e8e8a733dfe434d4964e6c6427fcad Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 21 Mar 2023 11:10:12 +0100 Subject: [PATCH 1115/2810] Improve media_player type hints in integrations (#90029) Fix some media_player type hints --- homeassistant/components/fully_kiosk/media_player.py | 3 ++- homeassistant/components/group/media_player.py | 6 ++++-- homeassistant/components/gstreamer/media_player.py | 2 +- homeassistant/components/hdmi_cec/media_player.py | 5 ++++- homeassistant/components/heos/media_player.py | 2 +- homeassistant/components/horizon/media_player.py | 4 +++- homeassistant/components/itunes/media_player.py | 4 +++- homeassistant/components/jellyfin/media_player.py | 2 +- homeassistant/components/lg_netcast/media_player.py | 4 +++- homeassistant/components/mpd/media_player.py | 2 +- homeassistant/components/onkyo/media_player.py | 5 ++++- homeassistant/components/openhome/media_player.py | 2 +- homeassistant/components/panasonic_viera/media_player.py | 2 +- homeassistant/components/plex/media_player.py | 4 +++- homeassistant/components/roku/media_player.py | 4 ++-- homeassistant/components/roon/media_player.py | 5 ++++- homeassistant/components/samsungtv/media_player.py | 2 +- homeassistant/components/slimproto/media_player.py | 3 ++- homeassistant/components/sonos/media_player.py | 4 ++-- homeassistant/components/soundtouch/media_player.py | 7 +++++-- homeassistant/components/spotify/media_player.py | 6 ++++-- homeassistant/components/squeezebox/media_player.py | 2 +- homeassistant/components/universal/media_player.py | 6 ++++-- homeassistant/components/xbox/media_player.py | 2 +- homeassistant/components/yamaha/media_player.py | 4 +++- homeassistant/components/yamaha_musiccast/media_player.py | 2 +- 26 files changed, 62 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/fully_kiosk/media_player.py b/homeassistant/components/fully_kiosk/media_player.py index ae6cf083ed..0fcd8c3543 100644 --- a/homeassistant/components/fully_kiosk/media_player.py +++ b/homeassistant/components/fully_kiosk/media_player.py @@ -8,6 +8,7 @@ from homeassistant.components.media_player import ( BrowseMedia, MediaPlayerEntity, MediaPlayerState, + MediaType, async_process_play_media_url, ) from homeassistant.config_entries import ConfigEntry @@ -42,7 +43,7 @@ class FullyMediaPlayer(FullyKioskEntity, MediaPlayerEntity): self._attr_state = MediaPlayerState.IDLE async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play a piece of media.""" if media_source.is_media_source_id(media_id): diff --git a/homeassistant/components/group/media_player.py b/homeassistant/components/group/media_player.py index 3766c64cae..15be22ddfb 100644 --- a/homeassistant/components/group/media_player.py +++ b/homeassistant/components/group/media_player.py @@ -1,6 +1,7 @@ """Platform allowing several media players to be grouped into one media player.""" from __future__ import annotations +from collections.abc import Mapping from contextlib import suppress from typing import Any @@ -20,6 +21,7 @@ from homeassistant.components.media_player import ( MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, + MediaType, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -207,7 +209,7 @@ class MediaPlayerGroup(MediaPlayerEntity): return self._name @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> Mapping[str, Any]: """Return the state attributes for the media group.""" return {ATTR_ENTITY_ID: self._entities} @@ -298,7 +300,7 @@ class MediaPlayerGroup(MediaPlayerEntity): ) async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play a piece of media.""" data = { diff --git a/homeassistant/components/gstreamer/media_player.py b/homeassistant/components/gstreamer/media_player.py index cb6e6cee72..04e91e4317 100644 --- a/homeassistant/components/gstreamer/media_player.py +++ b/homeassistant/components/gstreamer/media_player.py @@ -100,7 +100,7 @@ class GstreamerDevice(MediaPlayerEntity): self._player.volume = volume async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play media.""" # Handle media_source diff --git a/homeassistant/components/hdmi_cec/media_player.py b/homeassistant/components/hdmi_cec/media_player.py index 25019ec693..df7df830fd 100644 --- a/homeassistant/components/hdmi_cec/media_player.py +++ b/homeassistant/components/hdmi_cec/media_player.py @@ -30,6 +30,7 @@ from homeassistant.components.media_player import ( MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, + MediaType, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -105,7 +106,9 @@ class CecPlayerEntity(CecEntity, MediaPlayerEntity): self.send_keypress(KEY_STOP) self._attr_state = MediaPlayerState.IDLE - def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: + def play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: """Not supported.""" raise NotImplementedError() diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 4184e9f82b..3147c1e166 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -195,7 +195,7 @@ class HeosMediaPlayer(MediaPlayerEntity): @log_command_error("play media") async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play a piece of media.""" if media_source.is_media_source_id(media_id): diff --git a/homeassistant/components/horizon/media_player.py b/homeassistant/components/horizon/media_player.py index 3a05f09501..d91fe7019d 100644 --- a/homeassistant/components/horizon/media_player.py +++ b/homeassistant/components/horizon/media_player.py @@ -142,7 +142,9 @@ class HorizonDevice(MediaPlayerEntity): else: self._attr_state = MediaPlayerState.PAUSED - def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: + def play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: """Play media / switch to channel.""" if media_type == MediaType.CHANNEL: try: diff --git a/homeassistant/components/itunes/media_player.py b/homeassistant/components/itunes/media_player.py index c9b0e4a07a..78fd8b2a5b 100644 --- a/homeassistant/components/itunes/media_player.py +++ b/homeassistant/components/itunes/media_player.py @@ -380,7 +380,9 @@ class ItunesDevice(MediaPlayerEntity): response = self.client.previous() self.update_state(response) - def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: + def play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: """Send the play_media command to the media player.""" if media_type == MediaType.PLAYLIST: response = self.client.play_playlist(media_id) diff --git a/homeassistant/components/jellyfin/media_player.py b/homeassistant/components/jellyfin/media_player.py index 3b3c8fbdf5..32ca1d59d7 100644 --- a/homeassistant/components/jellyfin/media_player.py +++ b/homeassistant/components/jellyfin/media_player.py @@ -262,7 +262,7 @@ class JellyfinMediaPlayer(JellyfinEntity, MediaPlayerEntity): self._attr_state = MediaPlayerState.IDLE def play_media( - self, media_type: str, media_id: str, **kwargs: dict[str, Any] + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play a piece of media.""" self.coordinator.api_client.jellyfin.remote_play_media( diff --git a/homeassistant/components/lg_netcast/media_player.py b/homeassistant/components/lg_netcast/media_player.py index 1af16a904d..2074966e1e 100644 --- a/homeassistant/components/lg_netcast/media_player.py +++ b/homeassistant/components/lg_netcast/media_player.py @@ -260,7 +260,9 @@ class LgTVDevice(MediaPlayerEntity): """Send the previous track command.""" self.send_command(LG_COMMAND.REWIND) - def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: + def play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: """Tune to channel.""" if media_type != MediaType.CHANNEL: raise ValueError(f"Invalid media type: {media_type}") diff --git a/homeassistant/components/mpd/media_player.py b/homeassistant/components/mpd/media_player.py index fd783e0975..7395777320 100644 --- a/homeassistant/components/mpd/media_player.py +++ b/homeassistant/components/mpd/media_player.py @@ -435,7 +435,7 @@ class MpdDevice(MediaPlayerEntity): self._muted = mute async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Send the media player the command for playing a playlist.""" if media_source.is_media_source_id(media_id): diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index a12f2bc798..4d6d0f6965 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -14,6 +14,7 @@ from homeassistant.components.media_player import ( MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, + MediaType, ) from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant, ServiceCall @@ -394,7 +395,9 @@ class OnkyoDevice(MediaPlayerEntity): source = self._reverse_mapping[source] self.command(f"input-selector {source}") - def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: + def play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: """Play radio station by preset number.""" source = self._reverse_mapping[self._attr_source] if media_type.lower() == "radio" and source in DEFAULT_PLAYABLE_SOURCES: diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index ef30d37bdc..68357c862c 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -211,7 +211,7 @@ class OpenhomeDevice(MediaPlayerEntity): @catch_request_errors() async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Send the play_media command to the media player.""" if media_source.is_media_source_id(media_id): diff --git a/homeassistant/components/panasonic_viera/media_player.py b/homeassistant/components/panasonic_viera/media_player.py index 14c440f0ec..8b676f37c2 100644 --- a/homeassistant/components/panasonic_viera/media_player.py +++ b/homeassistant/components/panasonic_viera/media_player.py @@ -185,7 +185,7 @@ class PanasonicVieraTVEntity(MediaPlayerEntity): await self._remote.async_send_key(Keys.rewind) async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play media.""" if media_source.is_media_source_id(media_id): diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index 6fe6d641a8..c1a3ac5bd3 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -479,7 +479,9 @@ class PlexMediaPlayer(MediaPlayerEntity): if self.device and "playback" in self._device_protocol_capabilities: self.device.skipPrevious(self._active_media_plexapi_type) - def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: + def play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: """Play a piece of media.""" if not (self.device and "playback" in self._device_protocol_capabilities): raise HomeAssistantError( diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index b09ddb7ef7..b0191f605d 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -252,7 +252,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): return None @property - def source_list(self) -> list: + def source_list(self) -> list[str]: """List of available input sources.""" return ["Home"] + sorted( app.name for app in self.coordinator.data.apps if app.name is not None @@ -353,7 +353,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): @roku_exception_handler() async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play media from a URL or file, launch an application, or tune to a channel.""" extra: dict[str, Any] = kwargs.get(ATTR_MEDIA_EXTRA) or {} diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index d87c6f3137..307765da5c 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -12,6 +12,7 @@ from homeassistant.components.media_player import ( MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, + MediaType, RepeatMode, ) from homeassistant.config_entries import ConfigEntry @@ -394,7 +395,9 @@ class RoonDevice(MediaPlayerEntity): raise ValueError(f"Unsupported repeat mode: {repeat}") self._server.roonapi.repeat(self.output_id, REPEAT_MODE_MAPPING_TO_ROON[repeat]) - def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: + def play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: """Send the play_media command to the media player.""" _LOGGER.debug("Playback request for %s / %s", media_type, media_id) diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 4e66a9c1d2..302d9c4915 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -444,7 +444,7 @@ class SamsungTVDevice(MediaPlayerEntity): await self._async_send_keys(["KEY_CHDOWN"]) async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Support changing a channel.""" if media_type == MediaType.APP: diff --git a/homeassistant/components/slimproto/media_player.py b/homeassistant/components/slimproto/media_player.py index 993ed9571a..597ed50f42 100644 --- a/homeassistant/components/slimproto/media_player.py +++ b/homeassistant/components/slimproto/media_player.py @@ -15,6 +15,7 @@ from homeassistant.components.media_player import ( MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, + MediaType, async_process_play_media_url, ) from homeassistant.config_entries import ConfigEntry @@ -175,7 +176,7 @@ class SlimProtoPlayer(MediaPlayerEntity): await self.player.power(False) async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Send the play_media command to the media player.""" to_send_media_type: str | None = media_type diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 22517b9399..fbd74e5774 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -303,7 +303,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): return PLAY_MODES[self.media.play_mode][0] @property - def repeat(self) -> str | None: + def repeat(self) -> RepeatMode | None: """Return current repeat mode.""" sonos_repeat = PLAY_MODES[self.media.play_mode][1] return SONOS_TO_REPEAT[sonos_repeat] @@ -493,7 +493,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): @soco_error() def play_media( # noqa: C901 - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Send the play_media command to the media player. diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py index 17c197d692..111a13c2c9 100644 --- a/homeassistant/components/soundtouch/media_player.py +++ b/homeassistant/components/soundtouch/media_player.py @@ -18,6 +18,7 @@ from homeassistant.components.media_player import ( MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, + MediaType, async_process_play_media_url, ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry @@ -282,7 +283,7 @@ class SoundTouchMediaPlayer(MediaPlayerEntity): ) async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play a piece of media.""" if media_source.is_media_source_id(media_id): @@ -295,7 +296,9 @@ class SoundTouchMediaPlayer(MediaPlayerEntity): partial(self.play_media, media_type, media_id, **kwargs) ) - def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: + def play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: """Play a piece of media.""" _LOGGER.debug("Starting media with media_id: %s", media_id) if re.match(r"http?://", str(media_id)): diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index 51dd645ec0..7c583eb533 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -281,7 +281,7 @@ class SpotifyMediaPlayer(MediaPlayerEntity): return self._currently_playing.get("shuffle_state") @property - def repeat(self) -> str | None: + def repeat(self) -> RepeatMode | None: """Return current repeat mode.""" if ( not self._currently_playing @@ -321,7 +321,9 @@ class SpotifyMediaPlayer(MediaPlayerEntity): self.data.client.seek_track(int(position * 1000)) @spotify_exception_handler - def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: + def play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: """Play media.""" media_type = media_type.removeprefix(MEDIA_PLAYER_PREFIX) diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 22812f06ed..5c6f45c6ae 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -469,7 +469,7 @@ class SqueezeBoxEntity(MediaPlayerEntity): await self._player.async_set_power(True) async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Send the play_media command to the media player.""" index = None diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py index 9f6c9db416..21d741d345 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -44,6 +44,8 @@ from homeassistant.components.media_player import ( MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, + MediaType, + RepeatMode, ) from homeassistant.components.media_player.browse_media import BrowseMedia from homeassistant.const import ( @@ -574,7 +576,7 @@ class UniversalMediaPlayer(MediaPlayerEntity): await self._async_call_service(SERVICE_MEDIA_SEEK, data) async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play a piece of media.""" data = {ATTR_MEDIA_CONTENT_TYPE: media_type, ATTR_MEDIA_CONTENT_ID: media_id} @@ -613,7 +615,7 @@ class UniversalMediaPlayer(MediaPlayerEntity): data = {ATTR_MEDIA_SHUFFLE: shuffle} await self._async_call_service(SERVICE_SHUFFLE_SET, data, allow_override=True) - async def async_set_repeat(self, repeat: str) -> None: + async def async_set_repeat(self, repeat: RepeatMode) -> None: """Set repeat mode.""" data = {ATTR_MEDIA_REPEAT: repeat} await self._async_call_service(SERVICE_REPEAT_SET, data, allow_override=True) diff --git a/homeassistant/components/xbox/media_player.py b/homeassistant/components/xbox/media_player.py index 1d56cfc71c..ab16afa928 100644 --- a/homeassistant/components/xbox/media_player.py +++ b/homeassistant/components/xbox/media_player.py @@ -205,7 +205,7 @@ class XboxMediaPlayer(CoordinatorEntity[XboxUpdateCoordinator], MediaPlayerEntit ) async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Launch an app on the Xbox.""" if media_id == "Home": diff --git a/homeassistant/components/yamaha/media_player.py b/homeassistant/components/yamaha/media_player.py index aeb38c0faa..e2658c21f3 100644 --- a/homeassistant/components/yamaha/media_player.py +++ b/homeassistant/components/yamaha/media_player.py @@ -347,7 +347,9 @@ class YamahaDevice(MediaPlayerEntity): """Select input source.""" self.receiver.input = self._reverse_mapping.get(source, source) - def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: + def play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: """Play media from an ID. This exposes a pass through for various input sources in the diff --git a/homeassistant/components/yamaha_musiccast/media_player.py b/homeassistant/components/yamaha_musiccast/media_player.py index 01e5e1b898..05518a6c3c 100644 --- a/homeassistant/components/yamaha_musiccast/media_player.py +++ b/homeassistant/components/yamaha_musiccast/media_player.py @@ -263,7 +263,7 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): ) async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play media.""" if media_source.is_media_source_id(media_id): From 0e1c76f81f8a1d8f10815ba8ca7f25a04b9fe02c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 21 Mar 2023 11:39:42 +0100 Subject: [PATCH 1116/2810] Improve sensor type hints in integrations (#90031) * Improve sensor type hints in integrations * Improve --- homeassistant/components/awair/sensor.py | 6 +++--- homeassistant/components/emonitor/sensor.py | 2 +- homeassistant/components/entur_public_transport/sensor.py | 2 +- homeassistant/components/fints/sensor.py | 2 +- homeassistant/components/gtfs/sensor.py | 2 +- homeassistant/components/nsw_fuel_station/sensor.py | 2 +- homeassistant/components/statistics/sensor.py | 4 ++-- homeassistant/components/waze_travel_time/sensor.py | 3 ++- 8 files changed, 12 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index dc48e0f92c..f42a46999f 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -1,7 +1,7 @@ """Support for Awair sensors.""" from __future__ import annotations -from typing import cast +from typing import Any, cast from python_awair.air_data import AirData from python_awair.devices import AwairBaseDevice, AwairLocalDevice @@ -156,7 +156,7 @@ class AwairSensor(CoordinatorEntity[AwairDataUpdateCoordinator], SensorEntity): return round(state, 2) @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, Any]: """Return the Awair Index alongside state attributes. The Awair Index is a subjective score ranging from 0-4 (inclusive) that @@ -178,7 +178,7 @@ class AwairSensor(CoordinatorEntity[AwairDataUpdateCoordinator], SensorEntity): https://docs.developer.getawair.com/?version=latest#awair-score-and-index """ sensor_type = self.entity_description.key - attrs: dict = {} + attrs: dict[str, Any] = {} if not self._air_data: return attrs if sensor_type in self._air_data.indices: diff --git a/homeassistant/components/emonitor/sensor.py b/homeassistant/components/emonitor/sensor.py index d5e677abcc..dc7159001d 100644 --- a/homeassistant/components/emonitor/sensor.py +++ b/homeassistant/components/emonitor/sensor.py @@ -123,6 +123,6 @@ class EmonitorPowerSensor(CoordinatorEntity, SensorEntity): return self._paired_attr(self.entity_description.key) @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, int]: """Return the device specific state attributes.""" return {"channel": self.channel_number} diff --git a/homeassistant/components/entur_public_transport/sensor.py b/homeassistant/components/entur_public_transport/sensor.py index f5a954b16d..e109c25d34 100644 --- a/homeassistant/components/entur_public_transport/sensor.py +++ b/homeassistant/components/entur_public_transport/sensor.py @@ -183,7 +183,7 @@ class EnturPublicTransportSensor(SensorEntity): return self._state @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes.""" self._attributes[ATTR_STOP_ID] = self._stop return self._attributes diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py index 6ef0467f7b..479e59d9cd 100644 --- a/homeassistant/components/fints/sensor.py +++ b/homeassistant/components/fints/sensor.py @@ -272,7 +272,7 @@ class FinTsHoldingsAccount(SensorEntity): self._attr_native_value = sum(h.total_value for h in self._holdings) @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, Any]: """Additional attributes of the sensor. Lists each holding of the account with the current value. diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index 6cf1a6d460..77e1d0f7d3 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -568,7 +568,7 @@ class GTFSDepartureSensor(SensorEntity): return self._available @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return self._attributes diff --git a/homeassistant/components/nsw_fuel_station/sensor.py b/homeassistant/components/nsw_fuel_station/sensor.py index 6ebbccc446..7106b48778 100644 --- a/homeassistant/components/nsw_fuel_station/sensor.py +++ b/homeassistant/components/nsw_fuel_station/sensor.py @@ -117,7 +117,7 @@ class StationPriceSensor( return prices.get((self._station_id, self._fuel_type)) @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, int | str]: """Return the state attributes of the device.""" return { ATTR_STATION_ID: self._station_id, diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index 9a87129e5d..078eb59fe7 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -7,7 +7,7 @@ import contextlib from datetime import datetime, timedelta import logging import statistics -from typing import Any, Literal, cast +from typing import Any, cast import voluptuous as vol @@ -410,7 +410,7 @@ class StatisticsSensor(SensorEntity): return None @property - def state_class(self) -> Literal[SensorStateClass.MEASUREMENT] | None: + def state_class(self) -> SensorStateClass | None: """Return the state class of this entity.""" if self._state_characteristic in STATS_NOT_A_NUMBER: return None diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index f69f9a019f..ecbf3e9e12 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import timedelta import logging +from typing import Any from WazeRouteCalculator import WazeRouteCalculator, WRCError @@ -112,7 +113,7 @@ class WazeTravelTime(SensorEntity): return None @property - def extra_state_attributes(self) -> dict | None: + def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes of the last update.""" if self._waze_data.duration is None: return None From 86b43544779b71ea4a03e5df1047377e65a7973f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 21 Mar 2023 11:40:06 +0100 Subject: [PATCH 1117/2810] Improve native_value type hints in integrations (#90033) --- homeassistant/components/fivem/sensor.py | 4 +-- .../components/fully_kiosk/sensor.py | 8 +++--- .../components/kostal_plenticore/sensor.py | 3 ++- homeassistant/components/metoffice/sensor.py | 3 ++- .../components/nissan_leaf/sensor.py | 8 +++--- homeassistant/components/pi_hole/sensor.py | 9 +++---- .../components/synology_dsm/sensor.py | 26 +++++++++++-------- homeassistant/components/tibber/sensor.py | 5 ++-- 8 files changed, 35 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/fivem/sensor.py b/homeassistant/components/fivem/sensor.py index 31e23565a6..9afe589016 100644 --- a/homeassistant/components/fivem/sensor.py +++ b/homeassistant/components/fivem/sensor.py @@ -1,11 +1,11 @@ """The FiveM sensor platform.""" from dataclasses import dataclass -from typing import Any from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from . import FiveMEntity, FiveMEntityDescription from .const import ( @@ -73,6 +73,6 @@ class FiveMSensorEntity(FiveMEntity, SensorEntity): entity_description: FiveMSensorEntityDescription @property - def native_value(self) -> Any: + def native_value(self) -> StateType: """Return the state of the sensor.""" return self.coordinator.data[self.entity_description.key] diff --git a/homeassistant/components/fully_kiosk/sensor.py b/homeassistant/components/fully_kiosk/sensor.py index cf7dd62dec..60009eb6ae 100644 --- a/homeassistant/components/fully_kiosk/sensor.py +++ b/homeassistant/components/fully_kiosk/sensor.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Any from homeassistant.components.sensor import ( SensorDeviceClass, @@ -15,6 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from .const import DOMAIN from .coordinator import FullyKioskDataUpdateCoordinator @@ -30,7 +30,7 @@ def round_storage(value: int) -> float: class FullySensorEntityDescription(SensorEntityDescription): """Fully Kiosk Browser sensor description.""" - state_fn: Callable | None = None + state_fn: Callable[[int], float] | None = None SENSORS: tuple[FullySensorEntityDescription, ...] = ( @@ -130,7 +130,7 @@ class FullySensor(FullyKioskEntity, SensorEntity): super().__init__(coordinator) @property - def native_value(self) -> Any: + def native_value(self) -> StateType: """Return the state of the sensor.""" if (value := self.coordinator.data.get(self.entity_description.key)) is None: return None @@ -138,4 +138,4 @@ class FullySensor(FullyKioskEntity, SensorEntity): if self.entity_description.state_fn is not None: return self.entity_description.state_fn(value) - return value + return value # type: ignore[no-any-return] diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index f919d15d6b..a9b9433c1b 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -24,6 +24,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -791,7 +792,7 @@ class PlenticoreDataSensor( return f"{self.platform_name} {self._sensor_name}" @property - def native_value(self) -> Any | None: + def native_value(self) -> StateType: """Return the state of the sensor.""" if self.coordinator.data is None: # None is translated to STATE_UNKNOWN diff --git a/homeassistant/components/metoffice/sensor.py b/homeassistant/components/metoffice/sensor.py index 544dabd018..3bf50525ca 100644 --- a/homeassistant/components/metoffice/sensor.py +++ b/homeassistant/components/metoffice/sensor.py @@ -20,6 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -207,7 +208,7 @@ class MetOfficeCurrentSensor( ) @property - def native_value(self) -> Any | None: + def native_value(self) -> StateType: """Return the state of the sensor.""" value = None diff --git a/homeassistant/components/nissan_leaf/sensor.py b/homeassistant/components/nissan_leaf/sensor.py index 5b2f99b997..cd3524eaf8 100644 --- a/homeassistant/components/nissan_leaf/sensor.py +++ b/homeassistant/components/nissan_leaf/sensor.py @@ -3,14 +3,12 @@ from __future__ import annotations import logging -from voluptuous.validators import Number - from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import PERCENTAGE, UnitOfLength from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from homeassistant.util.unit_conversion import DistanceConverter from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -63,11 +61,11 @@ class LeafBatterySensor(LeafEntity, SensorEntity): return f"{self.car.leaf.nickname} Charge" @property - def native_value(self) -> Number | None: + def native_value(self) -> StateType: """Battery state percentage.""" if self.car.data[DATA_BATTERY] is None: return None - return round(self.car.data[DATA_BATTERY]) + return round(self.car.data[DATA_BATTERY]) # type: ignore[no-any-return] @property def icon(self) -> str: diff --git a/homeassistant/components/pi_hole/sensor.py b/homeassistant/components/pi_hole/sensor.py index dbca866137..5d36ba67e8 100644 --- a/homeassistant/components/pi_hole/sensor.py +++ b/homeassistant/components/pi_hole/sensor.py @@ -1,8 +1,6 @@ """Support for getting statistical data from a Pi-hole system.""" from __future__ import annotations -from typing import Any - from hole import Hole from homeassistant.components.sensor import SensorEntity, SensorEntityDescription @@ -10,6 +8,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import PiHoleEntity @@ -113,9 +112,9 @@ class PiHoleSensor(PiHoleEntity, SensorEntity): self._attr_unique_id = f"{self._server_unique_id}/{description.name}" @property - def native_value(self) -> Any: + def native_value(self) -> StateType: """Return the state of the device.""" try: - return round(self.api.data[self.entity_description.key], 2) + return round(self.api.data[self.entity_description.key], 2) # type: ignore[no-any-return] except TypeError: - return self.api.data[self.entity_description.key] + return self.api.data[self.entity_description.key] # type: ignore[no-any-return] diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index 06bfd166bb..4031a921a7 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -3,7 +3,6 @@ from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timedelta -from typing import Any from synology_dsm.api.core.utilization import SynoCoreUtilization from synology_dsm.api.dsm.information import SynoDSMInformation @@ -26,6 +25,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow from . import SynoApi @@ -349,7 +349,7 @@ class SynoDSMUtilSensor(SynoDSMSensor): """Representation a Synology Utilisation sensor.""" @property - def native_value(self) -> Any | None: + def native_value(self) -> StateType: """Return the state.""" attr = getattr(self._api.utilisation, self.entity_description.key) if callable(attr): @@ -357,19 +357,23 @@ class SynoDSMUtilSensor(SynoDSMSensor): if attr is None: return None + result: StateType = attr # Data (RAM) if self.native_unit_of_measurement == UnitOfInformation.MEGABYTES: - return round(attr / 1024.0**2, 1) + result = round(attr / 1024.0**2, 1) + return result # Network if self.native_unit_of_measurement == UnitOfDataRate.KILOBYTES_PER_SECOND: - return round(attr / 1024.0, 1) + result = round(attr / 1024.0, 1) + return result # CPU load average if self.native_unit_of_measurement == ENTITY_UNIT_LOAD: - return round(attr / 100, 2) + result = round(attr / 100, 2) + return result - return attr + return result @property def available(self) -> bool: @@ -393,7 +397,7 @@ class SynoDSMStorageSensor(SynologyDSMDeviceEntity, SynoDSMSensor): super().__init__(api, coordinator, description, device_id) @property - def native_value(self) -> Any | None: + def native_value(self) -> StateType: """Return the state.""" attr = getattr(self._api.storage, self.entity_description.key)(self._device_id) if attr is None: @@ -401,9 +405,9 @@ class SynoDSMStorageSensor(SynologyDSMDeviceEntity, SynoDSMSensor): # Data (disk space) if self.native_unit_of_measurement == UnitOfInformation.TERABYTES: - return round(attr / 1024.0**4, 2) + return round(attr / 1024.0**4, 2) # type: ignore[no-any-return] - return attr + return attr # type: ignore[no-any-return] class SynoDSMInfoSensor(SynoDSMSensor): @@ -421,7 +425,7 @@ class SynoDSMInfoSensor(SynoDSMSensor): self._last_boot: datetime | None = None @property - def native_value(self) -> Any | None: + def native_value(self) -> StateType | datetime: """Return the state.""" attr = getattr(self._api.information, self.entity_description.key) if attr is None: @@ -434,4 +438,4 @@ class SynoDSMInfoSensor(SynoDSMSensor): self._previous_uptime = attr return self._last_boot - return attr + return attr # type: ignore[no-any-return] diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 874ec5be67..a2f1db7536 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -41,6 +41,7 @@ from homeassistant.helpers.device_registry import async_get as async_get_dev_reg from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import async_get as async_get_entity_reg +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -426,9 +427,9 @@ class TibberDataSensor(TibberSensor, CoordinatorEntity["TibberDataCoordinator"]) self._device_name = self._home_name @property - def native_value(self) -> Any: + def native_value(self) -> StateType: """Return the value of the sensor.""" - return getattr(self._tibber_home, self.entity_description.key) + return getattr(self._tibber_home, self.entity_description.key) # type: ignore[no-any-return] class TibberSensorRT(TibberSensor, CoordinatorEntity["TibberRtDataCoordinator"]): From 33e698d67f4ac3fe61635af1db77604ad3690d2b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 21 Mar 2023 11:40:19 +0100 Subject: [PATCH 1118/2810] Improve notify type hints in integrations (#90034) --- homeassistant/components/command_line/notify.py | 3 ++- homeassistant/components/file/notify.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/command_line/notify.py b/homeassistant/components/command_line/notify.py index c41e26c21b..412456ff6e 100644 --- a/homeassistant/components/command_line/notify.py +++ b/homeassistant/components/command_line/notify.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging import subprocess +from typing import Any import voluptuous as vol @@ -46,7 +47,7 @@ class CommandLineNotificationService(BaseNotificationService): self.command = command self._timeout = timeout - def send_message(self, message="", **kwargs) -> None: + def send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a command line.""" with subprocess.Popen( self.command, diff --git a/homeassistant/components/file/notify.py b/homeassistant/components/file/notify.py index 4a0b4c11ca..3238fe9110 100644 --- a/homeassistant/components/file/notify.py +++ b/homeassistant/components/file/notify.py @@ -2,7 +2,7 @@ from __future__ import annotations import os -from typing import TextIO +from typing import Any, TextIO import voluptuous as vol @@ -48,7 +48,7 @@ class FileNotificationService(BaseNotificationService): self.filename = filename self.add_timestamp = add_timestamp - def send_message(self, message="", **kwargs) -> None: + def send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a file.""" file: TextIO if not self.hass.config.config_dir: From 485a78e0cfece8bb489bbc2c0a37a5b94c613a90 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 21 Mar 2023 11:40:33 +0100 Subject: [PATCH 1119/2810] Improve light type hints in integrations (#90035) * Improve light type hints in integrations * Improve --- homeassistant/components/homematic/light.py | 4 ++-- homeassistant/components/iaqualink/light.py | 2 +- homeassistant/components/knx/light.py | 4 ++-- homeassistant/components/tplink/light.py | 4 ++-- homeassistant/components/velbus/light.py | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/homematic/light.py b/homeassistant/components/homematic/light.py index 87f3dfb314..39e6df9d0e 100644 --- a/homeassistant/components/homematic/light.py +++ b/homeassistant/components/homematic/light.py @@ -68,9 +68,9 @@ class HMLight(HMDevice, LightEntity): return ColorMode.BRIGHTNESS @property - def supported_color_modes(self) -> set[ColorMode | str]: + def supported_color_modes(self) -> set[ColorMode]: """Flag supported color modes.""" - color_modes: set[ColorMode | str] = set() + color_modes: set[ColorMode] = set() if "COLOR" in self._hmdevice.WRITENODE: color_modes.add(ColorMode.HS) diff --git a/homeassistant/components/iaqualink/light.py b/homeassistant/components/iaqualink/light.py index 00c9445a3b..8b83f70191 100644 --- a/homeassistant/components/iaqualink/light.py +++ b/homeassistant/components/iaqualink/light.py @@ -83,7 +83,7 @@ class HassAqualinkLight(AqualinkEntity, LightEntity): return self.dev.effect @property - def effect_list(self) -> list: + def effect_list(self) -> list[str]: """Return supported light effects.""" return list(self.dev.supported_effects) diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index e4260f5e86..f5ef8f61b8 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -256,7 +256,7 @@ class KNXLight(KnxEntity, LightEntity): return None @property - def color_mode(self) -> ColorMode | None: + def color_mode(self) -> ColorMode: """Return the color mode of the light.""" if self._device.supports_xyy_color: return ColorMode.XY @@ -276,7 +276,7 @@ class KNXLight(KnxEntity, LightEntity): return ColorMode.ONOFF @property - def supported_color_modes(self) -> set | None: + def supported_color_modes(self) -> set[ColorMode]: """Flag supported color modes.""" return {self.color_mode} diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index 7bbde327e1..e4f91f282f 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -267,9 +267,9 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): return hue, saturation @property - def supported_color_modes(self) -> set[ColorMode | str] | None: + def supported_color_modes(self) -> set[ColorMode]: """Return list of available color modes.""" - modes: set[ColorMode | str] = set() + modes: set[ColorMode] = set() if self.device.is_variable_color_temp: modes.add(ColorMode.COLOR_TEMP) if self.device.is_color: diff --git a/homeassistant/components/velbus/light.py b/homeassistant/components/velbus/light.py index e89c81bc11..ca00a3134c 100644 --- a/homeassistant/components/velbus/light.py +++ b/homeassistant/components/velbus/light.py @@ -109,7 +109,7 @@ class VelbusButtonLight(VelbusEntity, LightEntity): self._attr_name = f"LED {self._channel.get_name()}" @property - def is_on(self) -> Any: + def is_on(self) -> bool: """Return true if the light is on.""" return self._channel.is_on() From 0467c8ff63698f248df82c7c15def81e6b170138 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 21 Mar 2023 12:39:07 +0100 Subject: [PATCH 1120/2810] Add attribute state translations for oscillating fans (#89990) --- homeassistant/components/fan/strings.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/fan/strings.json b/homeassistant/components/fan/strings.json index e9a808e0ae..cccf413465 100644 --- a/homeassistant/components/fan/strings.json +++ b/homeassistant/components/fan/strings.json @@ -32,7 +32,11 @@ } }, "oscillating": { - "name": "Oscillating" + "name": "Oscillating", + "state": { + "true": "[%key:common::state::yes%}", + "false": "[%key:common::state::no%]" + } }, "percentage": { "name": "Speed" From 6d3c3ff4fb9eb3ed198fabf0e687c9cfac2d4f80 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 21 Mar 2023 12:45:06 +0100 Subject: [PATCH 1121/2810] Add state translations for Select entities (#89995) --- homeassistant/components/select/strings.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/homeassistant/components/select/strings.json b/homeassistant/components/select/strings.json index 11a4ba9517..9080b940b2 100644 --- a/homeassistant/components/select/strings.json +++ b/homeassistant/components/select/strings.json @@ -14,5 +14,15 @@ "condition_type": { "selected_option": "Current {entity_name} selected option" } + }, + "entity_component": { + "_": { + "name": "[%key:component::select::title%]", + "state_attributes": { + "options": { + "name": "Options" + } + } + } } } From 2a0401366b1dd776a3a91c975853e5c3e8899a14 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 21 Mar 2023 12:53:05 +0100 Subject: [PATCH 1122/2810] Add state translations for all day calendar attribute (#89988) --- homeassistant/components/calendar/strings.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index 2663408cfd..898953c18a 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -9,7 +9,11 @@ }, "state_attributes": { "all_day": { - "name": "All day" + "name": "All day", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } }, "description": { "name": "Description" From f01f5e1d2a89656efa5ea855f09f3dc5e759c4b5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 21 Mar 2023 13:25:19 +0100 Subject: [PATCH 1123/2810] Improve type hints in tuya vacuum (#90041) --- homeassistant/components/tuya/vacuum.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index 27fe764b1e..7827fb061e 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -190,9 +190,14 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): self._send_command([{"code": DPCode.SUCTION, "value": fan_speed}]) def send_command( - self, command: str, params: dict | list | None = None, **kwargs: Any + self, + command: str, + params: dict[str, Any] | list[Any] | None = None, + **kwargs: Any, ) -> None: """Send raw command.""" if not params: raise ValueError("Params cannot be omitted for Tuya vacuum commands") + if not isinstance(params, list): + raise TypeError("Params must be a list for Tuya vacuum commands") self._send_command([{"code": command, "value": params[0]}]) From 9092a76dbf44103fd416d2f72bd55cc463950ef9 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 21 Mar 2023 13:26:03 +0100 Subject: [PATCH 1124/2810] Correct typing of pymodbus in modbus (#90039) --- homeassistant/components/modbus/modbus.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index b53cfda104..cb3501f337 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -390,12 +390,12 @@ class ModbusHub: def _pymodbus_call( self, unit: int | None, address: int, value: int | list[int], use_call: str - ) -> ModbusResponse: + ) -> ModbusResponse | None: """Call sync. pymodbus.""" kwargs = {"slave": unit} if unit else {} entry = self._pb_call[use_call] try: - result = entry.func(address, value, **kwargs) + result: ModbusResponse = entry.func(address, value, **kwargs) except ModbusException as exception_error: self._log_error(str(exception_error)) return None From 93efdc499131c6d9b0bb34bb31fd82dfbd541829 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 21 Mar 2023 13:26:41 +0100 Subject: [PATCH 1125/2810] Improve switch and climate type hints in integrations (#90040) --- homeassistant/components/bsblan/climate.py | 2 +- homeassistant/components/rachio/switch.py | 2 +- homeassistant/components/switchbot/switch.py | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index fcff6a925e..cbc6dd0047 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -129,7 +129,7 @@ class BSBLANClimate( return PRESET_ECO return PRESET_NONE - async def async_set_hvac_mode(self, hvac_mode: str) -> None: + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set hvac mode.""" await self.async_set_data(hvac_mode=hvac_mode) diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py index bc27c0b220..85abb989b6 100644 --- a/homeassistant/components/rachio/switch.py +++ b/homeassistant/components/rachio/switch.py @@ -399,7 +399,7 @@ class RachioZone(RachioSwitch): return self._entity_picture @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, Any]: """Return the optional state attributes.""" props = {ATTR_ZONE_NUMBER: self._zone_number, ATTR_ZONE_SUMMARY: self._summary} if self._shade_type: diff --git a/homeassistant/components/switchbot/switch.py b/homeassistant/components/switchbot/switch.py index 76214a4412..befbf00f8b 100644 --- a/homeassistant/components/switchbot/switch.py +++ b/homeassistant/components/switchbot/switch.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any import switchbot @@ -61,7 +62,7 @@ class SwitchBotSwitch(SwitchbotSwitchedEntity, SwitchEntity, RestoreEntity): return self._device.is_on() @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return { **super().extra_state_attributes, From 41ea8fa9b4e15c7332cf2f9e6fded10745228582 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 21 Mar 2023 15:01:35 +0100 Subject: [PATCH 1126/2810] Guess media type when cast is playing media without media type (#90048) --- homeassistant/components/cast/media_player.py | 10 +++++++++- tests/components/cast/test_media_player.py | 15 +++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index 0540380bc9..b701890d85 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -819,7 +819,15 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): return MediaType.MOVIE if media_status.media_is_musictrack: return MediaType.MUSIC - return None + + chromecast = self._get_chromecast() + if chromecast.cast_type in ( + pychromecast.const.CAST_TYPE_AUDIO, + pychromecast.const.CAST_TYPE_GROUP, + ): + return MediaType.MUSIC + + return MediaType.VIDEO @property def media_duration(self): diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index eea8c05088..8001411ac7 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -1336,7 +1336,17 @@ async def test_entity_play_media_playlist( ) -async def test_entity_media_content_type(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("cast_type", "default_content_type"), + [ + (pychromecast.const.CAST_TYPE_AUDIO, "music"), + (pychromecast.const.CAST_TYPE_GROUP, "music"), + (pychromecast.const.CAST_TYPE_CHROMECAST, "video"), + ], +) +async def test_entity_media_content_type( + hass: HomeAssistant, cast_type, default_content_type +) -> None: """Test various content types.""" entity_id = "media_player.speaker" reg = er.async_get(hass) @@ -1344,6 +1354,7 @@ async def test_entity_media_content_type(hass: HomeAssistant) -> None: info = get_fake_chromecast_info() chromecast, _ = await async_setup_media_player_cast(hass, info) + chromecast.cast_type = cast_type _, conn_status_cb, media_status_cb = get_status_callbacks(chromecast) connection_status = MagicMock() @@ -1364,7 +1375,7 @@ async def test_entity_media_content_type(hass: HomeAssistant) -> None: media_status_cb(media_status) await hass.async_block_till_done() state = hass.states.get(entity_id) - assert state.attributes.get("media_content_type") is None + assert state.attributes.get("media_content_type") == default_content_type media_status.media_is_tvshow = True media_status_cb(media_status) From 6f88fe93ef4bcbbe2911d57d00169fd38c255d1b Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 21 Mar 2023 15:21:45 +0100 Subject: [PATCH 1127/2810] Only publish mqtt_statestream when MQTT is started (#89833) * Only publish mqtt_statestream when ha is started * also catch startup states and use event filter * Add check for MQTT to be available first * Make sure MQTT is available and started * Fix test * Improve test * Reset mock before assertung not called --- .../components/mqtt_statestream/__init__.py | 60 +++++++++---- .../components/mqtt_statestream/test_init.py | 84 ++++++++++++++++++- 2 files changed, 126 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/mqtt_statestream/__init__.py b/homeassistant/components/mqtt_statestream/__init__.py index 5213f67546..0142573754 100644 --- a/homeassistant/components/mqtt_statestream/__init__.py +++ b/homeassistant/components/mqtt_statestream/__init__.py @@ -1,19 +1,20 @@ """Publish simple item state changes via MQTT.""" import json +import logging import voluptuous as vol from homeassistant.components import mqtt from homeassistant.components.mqtt import valid_publish_topic -from homeassistant.const import MATCH_ALL -from homeassistant.core import HomeAssistant +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED +from homeassistant.core import Event, HomeAssistant, State, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import ( INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, convert_include_exclude_filter, ) -from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.start import async_at_start from homeassistant.helpers.typing import ConfigType CONF_BASE_TOPIC = "base_topic" @@ -35,23 +36,31 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +_LOGGER = logging.getLogger(__name__) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the MQTT state feed.""" - conf = config[DOMAIN] + # Make sure MQTT is available and the entry is loaded + if not hass.config_entries.async_entries( + mqtt.DOMAIN + ) or not await hass.config_entries.async_wait_component( + hass.config_entries.async_entries(mqtt.DOMAIN)[0] + ): + _LOGGER.error("MQTT integration is not available") + return False + + conf: ConfigType = config[DOMAIN] publish_filter = convert_include_exclude_filter(conf) - base_topic = conf.get(CONF_BASE_TOPIC) - publish_attributes = conf.get(CONF_PUBLISH_ATTRIBUTES) - publish_timestamps = conf.get(CONF_PUBLISH_TIMESTAMPS) + base_topic: str = conf[CONF_BASE_TOPIC] + publish_attributes: bool = conf[CONF_PUBLISH_ATTRIBUTES] + publish_timestamps: bool = conf[CONF_PUBLISH_TIMESTAMPS] if not base_topic.endswith("/"): base_topic = f"{base_topic}/" - async def _state_publisher(entity_id, old_state, new_state): - if new_state is None: - return - - if not publish_filter(entity_id): - return + async def _state_publisher(evt: Event) -> None: + entity_id: str = evt.data["entity_id"] + new_state: State = evt.data["new_state"] payload = new_state.state @@ -81,5 +90,28 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: encoded_val = json.dumps(val, cls=JSONEncoder) await mqtt.async_publish(hass, mybase + key, encoded_val, 1, True) - async_track_state_change(hass, MATCH_ALL, _state_publisher) + @callback + def _ha_started(hass: HomeAssistant) -> None: + @callback + def _event_filter(evt: Event) -> bool: + entity_id: str = evt.data["entity_id"] + new_state: State | None = evt.data["new_state"] + if new_state is None: + return False + if not publish_filter(entity_id): + return False + return True + + callback_handler = hass.bus.async_listen( + EVENT_STATE_CHANGED, _state_publisher, _event_filter + ) + + @callback + def _ha_stopping(_: Event) -> None: + callback_handler() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _ha_stopping) + + async_at_start(hass, _ha_started) + return True diff --git a/tests/components/mqtt_statestream/test_init.py b/tests/components/mqtt_statestream/test_init.py index fd3430e2c7..130d874cc5 100644 --- a/tests/components/mqtt_statestream/test_init.py +++ b/tests/components/mqtt_statestream/test_init.py @@ -1,22 +1,25 @@ """The tests for the MQTT statestream component.""" from unittest.mock import ANY, call +import pytest + import homeassistant.components.mqtt_statestream as statestream -from homeassistant.core import HomeAssistant, State +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.setup import async_setup_component -from tests.common import mock_state_change_event +from tests.common import MockEntity, MockEntityPlatform, mock_state_change_event from tests.typing import MqttMockHAClient async def add_statestream( - hass, + hass: HomeAssistant, base_topic=None, publish_attributes=None, publish_timestamps=None, publish_include=None, publish_exclude=None, -): +) -> bool: """Add a mqtt_statestream component.""" config = {} if base_topic: @@ -48,6 +51,59 @@ async def test_setup_succeeds_without_attributes( assert await add_statestream(hass, base_topic="pub") +async def test_setup_and_stop_waits_for_ha( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test the success of the setup with a valid base_topic.""" + e_id = "fake.entity" + + # HA is not running + hass.state = CoreState.not_running + + assert await add_statestream(hass, base_topic="pub") + await hass.async_block_till_done() + # Set a state of an entity + mock_state_change_event(hass, State(e_id, "on")) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Make sure 'on' was not published to pub/fake/entity/state + mqtt_mock.async_publish.assert_not_called() + + # HA is starting up + await hass.async_start() + await hass.async_block_till_done() + + # Change a state of an entity + mock_state_change_event(hass, State(e_id, "off")) + await hass.async_block_till_done() + await hass.async_block_till_done() + + mqtt_mock.async_publish.assert_called_with("pub/fake/entity/state", "off", 1, True) + assert mqtt_mock.async_publish.called + mqtt_mock.reset_mock() + + # HA is shutting down + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + # Change a state of an entity + mock_state_change_event(hass, State(e_id, "on")) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Make sure 'on' was not published to pub/fake/entity/state + mqtt_mock.async_publish.assert_not_called() + + +async def test_startup_no_mqtt( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test startup without MQTT support.""" + assert not await add_statestream(hass, base_topic="pub") + assert "MQTT integration is not available" in caplog.text + + async def test_setup_succeeds_with_attributes( hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: @@ -78,6 +134,26 @@ async def test_state_changed_event_sends_message( # Make sure 'on' was published to pub/fake/entity/state mqtt_mock.async_publish.assert_called_with("pub/fake/entity/state", "on", 1, True) assert mqtt_mock.async_publish.called + mqtt_mock.async_publish.reset_mock() + + # Create a test entity and add it to hass + platform = MockEntityPlatform(hass) + entity = MockEntity(unique_id="1234") + await platform.async_add_entities([entity]) + + mqtt_mock.async_publish.assert_called_with( + "pub/test_domain/test_platform_1234/state", "unknown", 1, True + ) + mqtt_mock.async_publish.reset_mock() + + state = hass.states.get("test_domain.test_platform_1234") + assert state is not None + + # Now remove it, nothing should be published + hass.states.async_remove("test_domain.test_platform_1234") + await hass.async_block_till_done() + await hass.async_block_till_done() + mqtt_mock.async_publish.assert_not_called() async def test_state_changed_event_sends_message_and_timestamp( From 1303dd12e77ecb175b8b5acc19c2d7d92970ef65 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 21 Mar 2023 15:21:56 +0100 Subject: [PATCH 1128/2810] Improve type hints in zha fan (#90042) --- homeassistant/components/zha/fan.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 13d63808b6..5153d3c456 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -278,10 +278,8 @@ class IkeaFan(BaseFan, ZhaEntity): """Return the number of speeds the fan supports.""" return int_states_in_range(IKEA_SPEED_RANGE) - async def async_set_percentage(self, percentage: int | None) -> None: - """Set the speed percenage of the fan.""" - if percentage is None: - percentage = 0 + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the fan.""" fan_mode = math.ceil(percentage_to_ranged_value(IKEA_SPEED_RANGE, percentage)) await self._async_set_fan_mode(fan_mode) @@ -311,12 +309,17 @@ class IkeaFan(BaseFan, ZhaEntity): """Return the current preset mode.""" return IKEA_PRESET_MODES_TO_NAME.get(self._fan_channel.fan_mode) - async def async_turn_on(self, percentage=None, preset_mode=None, **kwargs) -> None: + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: """Turn the entity on.""" if percentage is None: - percentage = (100 / self.speed_count) * IKEA_NAME_TO_PRESET_MODE[ - PRESET_MODE_AUTO - ] + percentage = int( + (100 / self.speed_count) * IKEA_NAME_TO_PRESET_MODE[PRESET_MODE_AUTO] + ) await self.async_set_percentage(percentage) async def async_turn_off(self, **kwargs: Any) -> None: From 1895c82ffcde7eb6e481eff845e48f9c2bfd5462 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 21 Mar 2023 15:52:30 +0100 Subject: [PATCH 1129/2810] Fix fritzbox TypedDict definition (#90043) --- homeassistant/components/fritzbox/model.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/fritzbox/model.py b/homeassistant/components/fritzbox/model.py index ea63ab983c..3c3275e0ff 100644 --- a/homeassistant/components/fritzbox/model.py +++ b/homeassistant/components/fritzbox/model.py @@ -8,7 +8,6 @@ from typing import TypedDict from pyfritzhome import FritzhomeDevice -@dataclass class ClimateExtraAttributes(TypedDict, total=False): """TypedDict for climates extra attributes.""" From c507ca1e665abffd9b8986f0974f74d35cde1653 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 21 Mar 2023 15:53:51 +0100 Subject: [PATCH 1130/2810] Improve type hints in rachio switch (#90050) --- homeassistant/components/rachio/switch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py index 85abb989b6..c04a1a09f8 100644 --- a/homeassistant/components/rachio/switch.py +++ b/homeassistant/components/rachio/switch.py @@ -506,7 +506,7 @@ class RachioSchedule(RachioSwitch): return "mdi:water" if self.schedule_is_enabled else "mdi:water-off" @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, Any]: """Return the optional state attributes.""" return { ATTR_SCHEDULE_SUMMARY: self._summary, From 616e6e6ae8b914c6992ae802d35767872d135b60 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Mar 2023 07:14:27 -1000 Subject: [PATCH 1131/2810] Fix missing length on context id and incorrect precision with MariaDB (dev only fix) (#90058) Fix missing length on context id column with MariaDB spotted by @dcoder42 The migration still worked as intented but the blob should have been a bit smaller. This only affects dev so there is no need for a backport --- homeassistant/components/recorder/migration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index a4dea027d8..419ef7aa11 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -549,7 +549,7 @@ def _apply_update( # noqa: C901 if dialect == SupportedDialect.MYSQL: timestamp_type = "DOUBLE PRECISION" context_bin_type = f"BLOB({CONTEXT_ID_BIN_MAX_LENGTH})" - if dialect == SupportedDialect.POSTGRESQL: + elif dialect == SupportedDialect.POSTGRESQL: timestamp_type = "DOUBLE PRECISION" context_bin_type = "BYTEA" else: From ed4e49a4c25dcb4a7bcdc34b56d501f85ed88605 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 21 Mar 2023 18:41:27 +0100 Subject: [PATCH 1132/2810] Add translations for Counter (#89989) --- homeassistant/components/counter/strings.json | 29 +++++++++++++++++++ homeassistant/generated/integrations.json | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/counter/strings.json diff --git a/homeassistant/components/counter/strings.json b/homeassistant/components/counter/strings.json new file mode 100644 index 0000000000..fb7d34edf4 --- /dev/null +++ b/homeassistant/components/counter/strings.json @@ -0,0 +1,29 @@ +{ + "title": "Counter", + "entity_component": { + "_": { + "name": "[%key:component::counter::title%]", + "state_attributes": { + "editable": { + "name": "UI-managed", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "initial": { + "name": "Initial value" + }, + "maximum": { + "name": "Maximum" + }, + "minimum": { + "name": "Minimum" + }, + "step": { + "name": "Step" + } + } + } + } +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 8dbdd5a785..efd1899c5b 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6490,7 +6490,6 @@ }, "helper": { "counter": { - "name": "Counter", "integration_type": "helper", "config_flow": false }, @@ -6573,6 +6572,7 @@ "alert", "aurora", "cert_expiry", + "counter", "cpuspeed", "demo", "derivative", From d20b07f3ac852c41cc337b643d6380862b0f04cf Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 21 Mar 2023 19:16:50 +0100 Subject: [PATCH 1133/2810] Bump reolink-aio to 0.5.6 (#90059) --- homeassistant/components/reolink/manifest.json | 2 +- homeassistant/components/reolink/number.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 1f776d1372..7050ed61d5 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.5.5"] + "requirements": ["reolink-aio==0.5.6"] } diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index cab925d41f..4a221e2ca9 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -63,7 +63,7 @@ NUMBER_ENTITIES = ( native_step=1, get_min_value=lambda api, ch: api.zoom_range(ch)["focus"]["pos"]["min"], get_max_value=lambda api, ch: api.zoom_range(ch)["focus"]["pos"]["max"], - supported=lambda api, ch: api.supported(ch, "zoom"), + supported=lambda api, ch: api.supported(ch, "focus"), value=lambda api, ch: api.get_focus(ch), method=lambda api, ch, value: api.set_focus(ch, int(value)), ), diff --git a/requirements_all.txt b/requirements_all.txt index d7d30cc987..4a06243b41 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2237,7 +2237,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.5 +reolink-aio==0.5.6 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 19e29a7c2f..f0fef72dca 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1594,7 +1594,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.5 +reolink-aio==0.5.6 # homeassistant.components.python_script restrictedpython==6.0 From 0f5c49c7be334709b4f0926731b4ed98ef460ef7 Mon Sep 17 00:00:00 2001 From: dougiteixeira <31328123+dougiteixeira@users.noreply.github.com> Date: Tue, 21 Mar 2023 16:42:44 -0300 Subject: [PATCH 1134/2810] Fix translation string for fan oscillation (#90045) Fix string --- homeassistant/components/fan/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/fan/strings.json b/homeassistant/components/fan/strings.json index cccf413465..b16d6da6df 100644 --- a/homeassistant/components/fan/strings.json +++ b/homeassistant/components/fan/strings.json @@ -34,7 +34,7 @@ "oscillating": { "name": "Oscillating", "state": { - "true": "[%key:common::state::yes%}", + "true": "[%key:common::state::yes%]", "false": "[%key:common::state::no%]" } }, From d4cc4a343dd9585686e53aefb4708901ae3d5550 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 21 Mar 2023 21:33:33 +0100 Subject: [PATCH 1135/2810] Use has_template property from lib in Fritz!SmartHome (#89152) --- homeassistant/components/fritzbox/__init__.py | 11 ++--------- tests/components/fritzbox/test_init.py | 17 +---------------- 2 files changed, 3 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py index fc65ed9645..38f0e375e8 100644 --- a/homeassistant/components/fritzbox/__init__.py +++ b/homeassistant/components/fritzbox/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations from abc import ABC, abstractmethod -from xml.etree.ElementTree import ParseError from pyfritzhome import Fritzhome, FritzhomeDevice, LoginError from pyfritzhome.devicetypes.fritzhomeentitybase import FritzhomeEntityBase @@ -44,14 +43,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: CONF_CONNECTIONS: fritz, } - try: - await hass.async_add_executor_job(fritz.update_templates) - except ParseError: - LOGGER.debug("Disable smarthome templates") - has_templates = False - else: - LOGGER.debug("Enable smarthome templates") - has_templates = True + has_templates = await hass.async_add_executor_job(fritz.has_templates) + LOGGER.debug("enable smarthome templates: %s", has_templates) coordinator = FritzboxDataUpdateCoordinator(hass, entry, has_templates) diff --git a/tests/components/fritzbox/test_init.py b/tests/components/fritzbox/test_init.py index 3aa155863d..28476d8827 100644 --- a/tests/components/fritzbox/test_init.py +++ b/tests/components/fritzbox/test_init.py @@ -2,7 +2,6 @@ from __future__ import annotations from unittest.mock import Mock, call, patch -from xml.etree.ElementTree import ParseError from pyfritzhome import LoginError import pytest @@ -170,7 +169,7 @@ async def test_coordinator_update_after_reboot( assert await hass.config_entries.async_setup(entry.entry_id) assert fritz().update_devices.call_count == 2 - assert fritz().update_templates.call_count == 2 + assert fritz().update_templates.call_count == 1 assert fritz().get_devices.call_count == 1 assert fritz().get_templates.call_count == 1 assert fritz().login.call_count == 2 @@ -270,17 +269,3 @@ async def test_raise_config_entry_not_ready_when_offline(hass: HomeAssistant) -> entries = hass.config_entries.async_entries() config_entry = entries[0] assert config_entry.state is ConfigEntryState.SETUP_ERROR - - -async def test_disable_smarthome_templates(hass: HomeAssistant, fritz: Mock) -> None: - """Test smarthome templates are disabled.""" - entry = MockConfigEntry( - domain=FB_DOMAIN, - data=MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], - unique_id="any", - ) - entry.add_to_hass(hass) - fritz().update_templates.side_effect = [ParseError(), ""] - - assert await hass.config_entries.async_setup(entry.entry_id) - assert fritz().update_templates.call_count == 1 From 980425508a14e6f61859c269f0fb060361f0c2d6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 21 Mar 2023 22:31:55 +0100 Subject: [PATCH 1136/2810] Update twentemilieu to 1.0.0 (#90071) --- homeassistant/components/twentemilieu/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/twentemilieu/manifest.json b/homeassistant/components/twentemilieu/manifest.json index f5745734f1..cfacc9072f 100644 --- a/homeassistant/components/twentemilieu/manifest.json +++ b/homeassistant/components/twentemilieu/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["twentemilieu"], "quality_scale": "platinum", - "requirements": ["twentemilieu==0.6.1"] + "requirements": ["twentemilieu==1.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4a06243b41..5395f1104e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2536,7 +2536,7 @@ ttls==1.5.1 tuya-iot-py-sdk==0.6.6 # homeassistant.components.twentemilieu -twentemilieu==0.6.1 +twentemilieu==1.0.0 # homeassistant.components.twilio twilio==6.32.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f0fef72dca..265ec1eeab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1797,7 +1797,7 @@ ttls==1.5.1 tuya-iot-py-sdk==0.6.6 # homeassistant.components.twentemilieu -twentemilieu==0.6.1 +twentemilieu==1.0.0 # homeassistant.components.twilio twilio==6.32.0 From f98d6851541cc4c57e48847a48644cd3fb7410fb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 21 Mar 2023 22:32:41 +0100 Subject: [PATCH 1137/2810] Refactor WLED select tests (#89219) --- .../wled/snapshots/test_select.ambr | 431 ++++++++++++++++ tests/components/wled/test_select.py | 461 ++++-------------- 2 files changed, 522 insertions(+), 370 deletions(-) create mode 100644 tests/components/wled/snapshots/test_select.ambr diff --git a/tests/components/wled/snapshots/test_select.ambr b/tests/components/wled/snapshots/test_select.ambr new file mode 100644 index 0000000000..05d61fc18c --- /dev/null +++ b/tests/components/wled/snapshots/test_select.ambr @@ -0,0 +1,431 @@ +# serializer version: 1 +# name: test_color_palette_state[rgb-select.wled_rgb_light_live_override-2-live-called_with1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WLED RGB Light Live override', + 'icon': 'mdi:theater', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.wled_rgb_light_live_override', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_color_palette_state[rgb-select.wled_rgb_light_live_override-2-live-called_with1].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.wled_rgb_light_live_override', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:theater', + 'original_name': 'Live override', + 'platform': 'wled', + 'supported_features': 0, + 'translation_key': 'live_override', + 'unique_id': 'aabbccddeeff_live_override', + 'unit_of_measurement': None, + }) +# --- +# name: test_color_palette_state[rgb-select.wled_rgb_light_live_override-2-live-called_with1].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://127.0.0.1', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'esp8266', + 'id': , + 'identifiers': set({ + tuple( + 'wled', + 'aabbccddeeff', + ), + }), + 'is_new': False, + 'manufacturer': 'WLED', + 'model': 'DIY light', + 'name': 'WLED RGB Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.8.5', + 'via_device_id': None, + }) +# --- +# name: test_color_palette_state[rgb-select.wled_rgb_light_segment_1_color_palette-Icefire-segment-called_with0] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WLED RGB Light Segment 1 color palette', + 'icon': 'mdi:palette-outline', + 'options': list([ + 'Analogous', + 'April Night', + 'Autumn', + 'Based on Primary', + 'Based on Set', + 'Beach', + 'Beech', + 'Breeze', + 'C9', + 'Cloud', + 'Cyane', + 'Default', + 'Departure', + 'Drywet', + 'Fire', + 'Forest', + 'Grintage', + 'Hult', + 'Hult 64', + 'Icefire', + 'Jul', + 'Landscape', + 'Lava', + 'Light Pink', + 'Magenta', + 'Magred', + 'Ocean', + 'Orange & Teal', + 'Orangery', + 'Party', + 'Pastel', + 'Primary Color', + 'Rainbow', + 'Rainbow Bands', + 'Random Cycle', + 'Red & Blue', + 'Rewhi', + 'Rivendell', + 'Sakura', + 'Set Colors', + 'Sherbet', + 'Splash', + 'Sunset', + 'Sunset 2', + 'Tertiary', + 'Tiamat', + 'Vintage', + 'Yelblu', + 'Yellowout', + 'Yelmag', + ]), + }), + 'context': , + 'entity_id': 'select.wled_rgb_light_segment_1_color_palette', + 'last_changed': , + 'last_updated': , + 'state': 'Random Cycle', + }) +# --- +# name: test_color_palette_state[rgb-select.wled_rgb_light_segment_1_color_palette-Icefire-segment-called_with0].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Analogous', + 'April Night', + 'Autumn', + 'Based on Primary', + 'Based on Set', + 'Beach', + 'Beech', + 'Breeze', + 'C9', + 'Cloud', + 'Cyane', + 'Default', + 'Departure', + 'Drywet', + 'Fire', + 'Forest', + 'Grintage', + 'Hult', + 'Hult 64', + 'Icefire', + 'Jul', + 'Landscape', + 'Lava', + 'Light Pink', + 'Magenta', + 'Magred', + 'Ocean', + 'Orange & Teal', + 'Orangery', + 'Party', + 'Pastel', + 'Primary Color', + 'Rainbow', + 'Rainbow Bands', + 'Random Cycle', + 'Red & Blue', + 'Rewhi', + 'Rivendell', + 'Sakura', + 'Set Colors', + 'Sherbet', + 'Splash', + 'Sunset', + 'Sunset 2', + 'Tertiary', + 'Tiamat', + 'Vintage', + 'Yelblu', + 'Yellowout', + 'Yelmag', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.wled_rgb_light_segment_1_color_palette', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:palette-outline', + 'original_name': 'Segment 1 color palette', + 'platform': 'wled', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_palette_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_color_palette_state[rgb-select.wled_rgb_light_segment_1_color_palette-Icefire-segment-called_with0].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://127.0.0.1', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'esp8266', + 'id': , + 'identifiers': set({ + tuple( + 'wled', + 'aabbccddeeff', + ), + }), + 'is_new': False, + 'manufacturer': 'WLED', + 'model': 'DIY light', + 'name': 'WLED RGB Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.8.5', + 'via_device_id': None, + }) +# --- +# name: test_color_palette_state[rgbw-select.wled_rgbw_light_playlist-Playlist 2-playlist-called_with2] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WLED RGBW Light Playlist', + 'icon': 'mdi:play-speed', + 'options': list([ + 'Playlist 1', + 'Playlist 2', + ]), + }), + 'context': , + 'entity_id': 'select.wled_rgbw_light_playlist', + 'last_changed': , + 'last_updated': , + 'state': 'Playlist 1', + }) +# --- +# name: test_color_palette_state[rgbw-select.wled_rgbw_light_playlist-Playlist 2-playlist-called_with2].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Playlist 1', + 'Playlist 2', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.wled_rgbw_light_playlist', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:play-speed', + 'original_name': 'Playlist', + 'platform': 'wled', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddee11_playlist', + 'unit_of_measurement': None, + }) +# --- +# name: test_color_palette_state[rgbw-select.wled_rgbw_light_playlist-Playlist 2-playlist-called_with2].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://127.0.0.1', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:11', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'esp8266', + 'id': , + 'identifiers': set({ + tuple( + 'wled', + 'aabbccddee11', + ), + }), + 'is_new': False, + 'manufacturer': 'WLED', + 'model': 'DIY light', + 'name': 'WLED RGBW Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.8.6b4', + 'via_device_id': None, + }) +# --- +# name: test_color_palette_state[rgbw-select.wled_rgbw_light_preset-Preset 2-preset-called_with3] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WLED RGBW Light Preset', + 'icon': 'mdi:playlist-play', + 'options': list([ + 'Preset 1', + 'Preset 2', + ]), + }), + 'context': , + 'entity_id': 'select.wled_rgbw_light_preset', + 'last_changed': , + 'last_updated': , + 'state': 'Preset 1', + }) +# --- +# name: test_color_palette_state[rgbw-select.wled_rgbw_light_preset-Preset 2-preset-called_with3].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Preset 1', + 'Preset 2', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.wled_rgbw_light_preset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:playlist-play', + 'original_name': 'Preset', + 'platform': 'wled', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddee11_preset', + 'unit_of_measurement': None, + }) +# --- +# name: test_color_palette_state[rgbw-select.wled_rgbw_light_preset-Preset 2-preset-called_with3].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://127.0.0.1', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:11', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'esp8266', + 'id': , + 'identifiers': set({ + tuple( + 'wled', + 'aabbccddee11', + ), + }), + 'is_new': False, + 'manufacturer': 'WLED', + 'model': 'DIY light', + 'name': 'WLED RGBW Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.8.6b4', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/wled/test_select.py b/tests/components/wled/test_select.py index 4ef8fa5941..caf1fa2486 100644 --- a/tests/components/wled/test_select.py +++ b/tests/components/wled/test_select.py @@ -3,25 +3,20 @@ import json from unittest.mock import MagicMock import pytest +from syrupy.assertion import SnapshotAssertion from wled import Device as WLEDDevice, WLEDConnectionError, WLEDError -from homeassistant.components.select import ( - ATTR_OPTION, - ATTR_OPTIONS, - DOMAIN as SELECT_DOMAIN, -) +from homeassistant.components.select import ATTR_OPTION, DOMAIN as SELECT_DOMAIN from homeassistant.components.wled.const import SCAN_INTERVAL from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_ICON, SERVICE_SELECT_OPTION, STATE_UNAVAILABLE, STATE_UNKNOWN, - EntityCategory, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed, load_fixture @@ -29,95 +24,102 @@ from tests.common import async_fire_time_changed, load_fixture pytestmark = pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + ("device_fixture", "entity_id", "option", "method", "called_with"), + [ + ( + "rgb", + "select.wled_rgb_light_segment_1_color_palette", + "Icefire", + "segment", + {"segment_id": 1, "palette": "Icefire"}, + ), + ( + "rgb", + "select.wled_rgb_light_live_override", + "2", + "live", + {"live": 2}, + ), + ( + "rgbw", + "select.wled_rgbw_light_playlist", + "Playlist 2", + "playlist", + {"playlist": "Playlist 2"}, + ), + ( + "rgbw", + "select.wled_rgbw_light_preset", + "Preset 2", + "preset", + {"preset": "Preset 2"}, + ), + ], +) async def test_color_palette_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_wled: MagicMock, + entity_id: str, + option: str, + method: str, + called_with: dict[str, int | str], ) -> None: """Test the creation and values of the WLED selects.""" # First segment of the strip - assert (state := hass.states.get("select.wled_rgb_light_segment_1_color_palette")) - assert state.attributes.get(ATTR_ICON) == "mdi:palette-outline" - assert state.attributes.get(ATTR_OPTIONS) == [ - "Analogous", - "April Night", - "Autumn", - "Based on Primary", - "Based on Set", - "Beach", - "Beech", - "Breeze", - "C9", - "Cloud", - "Cyane", - "Default", - "Departure", - "Drywet", - "Fire", - "Forest", - "Grintage", - "Hult", - "Hult 64", - "Icefire", - "Jul", - "Landscape", - "Lava", - "Light Pink", - "Magenta", - "Magred", - "Ocean", - "Orange & Teal", - "Orangery", - "Party", - "Pastel", - "Primary Color", - "Rainbow", - "Rainbow Bands", - "Random Cycle", - "Red & Blue", - "Rewhi", - "Rivendell", - "Sakura", - "Set Colors", - "Sherbet", - "Splash", - "Sunset", - "Sunset 2", - "Tertiary", - "Tiamat", - "Vintage", - "Yelblu", - "Yellowout", - "Yelmag", - ] - assert state.state == "Random Cycle" + assert (state := hass.states.get(entity_id)) + assert state == snapshot - assert ( - entry := entity_registry.async_get( - "select.wled_rgb_light_segment_1_color_palette" - ) - ) - assert entry.unique_id == "aabbccddeeff_palette_1" - assert entry.entity_category is EntityCategory.CONFIG + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert entity_entry == snapshot + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry == snapshot + + method_mock = getattr(mock_wled, method) -async def test_color_palette_segment_change_state( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test the option change of state of the WLED segments.""" await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: "select.wled_rgb_light_segment_1_color_palette", - ATTR_OPTION: "Icefire", - }, + {ATTR_ENTITY_ID: state.entity_id, ATTR_OPTION: option}, blocking=True, ) - assert mock_wled.segment.call_count == 1 - mock_wled.segment.assert_called_with( - segment_id=1, - palette="Icefire", - ) + assert method_mock.call_count == 1 + method_mock.assert_called_with(**called_with) + + # Test invalid response, not becoming unavailable + method_mock.side_effect = WLEDError + with pytest.raises(HomeAssistantError, match="Invalid response from WLED API"): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: state.entity_id, ATTR_OPTION: option}, + blocking=True, + ) + + assert (state := hass.states.get(state.entity_id)) + assert state.state != STATE_UNAVAILABLE + assert method_mock.call_count == 2 + method_mock.assert_called_with(**called_with) + + # Test connection error, leading to becoming unavailable + method_mock.side_effect = WLEDConnectionError + with pytest.raises(HomeAssistantError, match="Error communicating with WLED API"): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: state.entity_id, ATTR_OPTION: option}, + blocking=True, + ) + + assert (state := hass.states.get(state.entity_id)) + assert state.state == STATE_UNAVAILABLE + assert method_mock.call_count == 3 + method_mock.assert_called_with(**called_with) @pytest.mark.parametrize("device_fixture", ["rgb_single_segment"]) @@ -158,86 +160,16 @@ async def test_color_palette_dynamically_handle_segments( assert segment1.state == STATE_UNAVAILABLE -async def test_color_palette_select_error( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test error handling of the WLED selects.""" - mock_wled.segment.side_effect = WLEDError - - with pytest.raises(HomeAssistantError, match="Invalid response from WLED API"): - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: "select.wled_rgb_light_segment_1_color_palette", - ATTR_OPTION: "Icefire", - }, - blocking=True, - ) - - assert (state := hass.states.get("select.wled_rgb_light_segment_1_color_palette")) - assert state.state == "Random Cycle" - assert mock_wled.segment.call_count == 1 - mock_wled.segment.assert_called_with(segment_id=1, palette="Icefire") - - -async def test_color_palette_select_connection_error( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test error handling of the WLED selects.""" - mock_wled.segment.side_effect = WLEDConnectionError - - with pytest.raises(HomeAssistantError, match="Error communicating with WLED API"): - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: "select.wled_rgb_light_segment_1_color_palette", - ATTR_OPTION: "Icefire", - }, - blocking=True, - ) - - assert (state := hass.states.get("select.wled_rgb_light_segment_1_color_palette")) - assert state.state == STATE_UNAVAILABLE - assert mock_wled.segment.call_count == 1 - mock_wled.segment.assert_called_with(segment_id=1, palette="Icefire") - - async def test_preset_unavailable_without_presets(hass: HomeAssistant) -> None: """Test WLED preset entity is unavailable when presets are not available.""" assert (state := hass.states.get("select.wled_rgb_light_preset")) assert state.state == STATE_UNAVAILABLE -@pytest.mark.parametrize("device_fixture", ["rgbw"]) -async def test_preset_state( - hass: HomeAssistant, - mock_wled: MagicMock, - entity_registry: er.EntityRegistry, -) -> None: - """Test the creation and values of the WLED selects.""" - assert (state := hass.states.get("select.wled_rgbw_light_preset")) - assert state.attributes.get(ATTR_ICON) == "mdi:playlist-play" - assert state.attributes.get(ATTR_OPTIONS) == ["Preset 1", "Preset 2"] - assert state.state == "Preset 1" - - assert (entry := entity_registry.async_get("select.wled_rgbw_light_preset")) - assert entry.unique_id == "aabbccddee11_preset" - - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: "select.wled_rgbw_light_preset", - ATTR_OPTION: "Preset 2", - }, - blocking=True, - ) - assert mock_wled.preset.call_count == 1 - mock_wled.preset.assert_called_with(preset="Preset 2") +async def test_playlist_unavailable_without_playlists(hass: HomeAssistant) -> None: + """Test WLED playlist entity is unavailable when playlists are not available.""" + assert (state := hass.states.get("select.wled_rgb_light_playlist")) + assert state.state == STATE_UNAVAILABLE @pytest.mark.parametrize("device_fixture", ["rgbw"]) @@ -256,92 +188,6 @@ async def test_old_style_preset_active( assert state.state == STATE_UNKNOWN -@pytest.mark.parametrize("device_fixture", ["rgbw"]) -async def test_preset_select_error( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test error handling of the WLED selects.""" - mock_wled.preset.side_effect = WLEDError - - with pytest.raises(HomeAssistantError, match="Invalid response from WLED API"): - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: "select.wled_rgbw_light_preset", - ATTR_OPTION: "Preset 2", - }, - blocking=True, - ) - await hass.async_block_till_done() - - assert (state := hass.states.get("select.wled_rgbw_light_preset")) - assert state.state == "Preset 1" - assert mock_wled.preset.call_count == 1 - mock_wled.preset.assert_called_with(preset="Preset 2") - - -@pytest.mark.parametrize("device_fixture", ["rgbw"]) -async def test_preset_select_connection_error( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test error handling of the WLED selects.""" - mock_wled.preset.side_effect = WLEDConnectionError - - with pytest.raises(HomeAssistantError, match="Error communicating with WLED API"): - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: "select.wled_rgbw_light_preset", - ATTR_OPTION: "Preset 2", - }, - blocking=True, - ) - - assert (state := hass.states.get("select.wled_rgbw_light_preset")) - assert state.state == STATE_UNAVAILABLE - assert mock_wled.preset.call_count == 1 - mock_wled.preset.assert_called_with(preset="Preset 2") - - -async def test_playlist_unavailable_without_playlists(hass: HomeAssistant) -> None: - """Test WLED playlist entity is unavailable when playlists are not available.""" - assert (state := hass.states.get("select.wled_rgb_light_playlist")) - assert state.state == STATE_UNAVAILABLE - - -@pytest.mark.parametrize("device_fixture", ["rgbw"]) -async def test_playlist_state( - hass: HomeAssistant, - mock_wled: MagicMock, - entity_registry: er.EntityRegistry, -) -> None: - """Test the creation and values of the WLED selects.""" - - assert (state := hass.states.get("select.wled_rgbw_light_playlist")) - assert state.attributes.get(ATTR_ICON) == "mdi:play-speed" - assert state.attributes.get(ATTR_OPTIONS) == ["Playlist 1", "Playlist 2"] - assert state.state == "Playlist 1" - - assert (entry := entity_registry.async_get("select.wled_rgbw_light_playlist")) - assert entry.unique_id == "aabbccddee11_playlist" - - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: "select.wled_rgbw_light_playlist", - ATTR_OPTION: "Playlist 2", - }, - blocking=True, - ) - assert mock_wled.playlist.call_count == 1 - mock_wled.playlist.assert_called_with(playlist="Playlist 2") - - @pytest.mark.parametrize("device_fixture", ["rgbw"]) async def test_old_style_playlist_active( hass: HomeAssistant, @@ -356,128 +202,3 @@ async def test_old_style_playlist_active( assert (state := hass.states.get("select.wled_rgbw_light_playlist")) assert state.state == STATE_UNKNOWN - - -@pytest.mark.parametrize("device_fixture", ["rgbw"]) -async def test_playlist_select_error( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test error handling of the WLED selects.""" - mock_wled.playlist.side_effect = WLEDError - - with pytest.raises(HomeAssistantError, match="Invalid response from WLED API"): - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: "select.wled_rgbw_light_playlist", - ATTR_OPTION: "Playlist 2", - }, - blocking=True, - ) - - assert (state := hass.states.get("select.wled_rgbw_light_playlist")) - assert state.state == "Playlist 1" - assert mock_wled.playlist.call_count == 1 - mock_wled.playlist.assert_called_with(playlist="Playlist 2") - - -@pytest.mark.parametrize("device_fixture", ["rgbw"]) -async def test_playlist_select_connection_error( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test error handling of the WLED selects.""" - mock_wled.playlist.side_effect = WLEDConnectionError - - with pytest.raises(HomeAssistantError, match="Error communicating with WLED API"): - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: "select.wled_rgbw_light_playlist", - ATTR_OPTION: "Playlist 2", - }, - blocking=True, - ) - - assert (state := hass.states.get("select.wled_rgbw_light_playlist")) - assert state.state == STATE_UNAVAILABLE - assert mock_wled.playlist.call_count == 1 - mock_wled.playlist.assert_called_with(playlist="Playlist 2") - - -async def test_live_override( - hass: HomeAssistant, - mock_wled: MagicMock, - entity_registry: er.EntityRegistry, -) -> None: - """Test the creation and values of the WLED selects.""" - assert (state := hass.states.get("select.wled_rgb_light_live_override")) - assert state.attributes.get(ATTR_ICON) == "mdi:theater" - assert state.attributes.get(ATTR_OPTIONS) == ["0", "1", "2"] - assert state.state == "0" - - assert (entry := entity_registry.async_get("select.wled_rgb_light_live_override")) - assert entry.unique_id == "aabbccddeeff_live_override" - - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: "select.wled_rgb_light_live_override", - ATTR_OPTION: "2", - }, - blocking=True, - ) - assert mock_wled.live.call_count == 1 - mock_wled.live.assert_called_with(live=2) - - -async def test_live_select_error( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test error handling of the WLED selects.""" - mock_wled.live.side_effect = WLEDError - - with pytest.raises(HomeAssistantError, match="Invalid response from WLED API"): - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: "select.wled_rgb_light_live_override", - ATTR_OPTION: "1", - }, - blocking=True, - ) - - assert (state := hass.states.get("select.wled_rgb_light_live_override")) - assert state.state == "0" - assert mock_wled.live.call_count == 1 - mock_wled.live.assert_called_with(live=1) - - -async def test_live_select_connection_error( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test error handling of the WLED selects.""" - mock_wled.live.side_effect = WLEDConnectionError - - with pytest.raises(HomeAssistantError, match="Error communicating with WLED API"): - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: "select.wled_rgb_light_live_override", - ATTR_OPTION: "2", - }, - blocking=True, - ) - - assert (state := hass.states.get("select.wled_rgb_light_live_override")) - assert state.state == STATE_UNAVAILABLE - assert mock_wled.live.call_count == 1 - mock_wled.live.assert_called_with(live=2) From 086bcfb2fcc0b95d6f0e111724557c695bec4030 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Mar 2023 15:06:10 -1000 Subject: [PATCH 1138/2810] Make recorder migration column types for each dialect constants (#90072) Make column types for each dialect constants --- .../components/recorder/migration.py | 89 +++++++++++++------ 1 file changed, 62 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 419ef7aa11..38eed25bee 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -25,6 +25,7 @@ from sqlalchemy.schema import AddConstraint, DropConstraint from sqlalchemy.sql.expression import true from homeassistant.core import HomeAssistant +from homeassistant.util.enum import try_parse_enum from homeassistant.util.ulid import ulid_to_bytes from .const import SupportedDialect @@ -84,6 +85,38 @@ _EMPTY_EVENT_TYPE = "missing_event_type" _LOGGER = logging.getLogger(__name__) +@dataclass +class _ColumnTypesForDialect: + big_int_type: str + timestamp_type: str + context_bin_type: str + + +_MYSQL_COLUMN_TYPES = _ColumnTypesForDialect( + big_int_type="INTEGER(20)", + timestamp_type="DOUBLE PRECISION", + context_bin_type=f"BLOB({CONTEXT_ID_BIN_MAX_LENGTH})", +) + +_POSTGRESQL_COLUMN_TYPES = _ColumnTypesForDialect( + big_int_type="INTEGER", + timestamp_type="DOUBLE PRECISION", + context_bin_type="BYTEA", +) + +_SQLITE_COLUMN_TYPES = _ColumnTypesForDialect( + big_int_type="INTEGER", + timestamp_type="FLOAT", + context_bin_type="BLOB", +) + +_COLUMN_TYPES_FOR_DIALECT: dict[SupportedDialect | None, _ColumnTypesForDialect] = { + SupportedDialect.MYSQL: _MYSQL_COLUMN_TYPES, + SupportedDialect.POSTGRESQL: _POSTGRESQL_COLUMN_TYPES, + SupportedDialect.SQLITE: _SQLITE_COLUMN_TYPES, +} + + def raise_if_exception_missing_str(ex: Exception, match_substrs: Iterable[str]) -> None: """Raise if the exception and cause do not contain the match substrs.""" lower_ex_strs = [str(ex).lower(), str(ex.__cause__).lower()] @@ -544,18 +577,9 @@ def _apply_update( # noqa: C901 old_version: int, ) -> None: """Perform operations to bring schema up to date.""" - dialect = engine.dialect.name - big_int = "INTEGER(20)" if dialect == SupportedDialect.MYSQL else "INTEGER" - if dialect == SupportedDialect.MYSQL: - timestamp_type = "DOUBLE PRECISION" - context_bin_type = f"BLOB({CONTEXT_ID_BIN_MAX_LENGTH})" - elif dialect == SupportedDialect.POSTGRESQL: - timestamp_type = "DOUBLE PRECISION" - context_bin_type = "BYTEA" - else: - timestamp_type = "FLOAT" - context_bin_type = "BLOB" - + assert engine.dialect.name is not None, "Dialect name must be set" + dialect = try_parse_enum(SupportedDialect, engine.dialect.name) + _column_types = _COLUMN_TYPES_FOR_DIALECT.get(dialect, _SQLITE_COLUMN_TYPES) if new_version == 1: # This used to create ix_events_time_fired, but it was removed in version 32 pass @@ -817,12 +841,14 @@ def _apply_update( # noqa: C901 # of removing any duplicate if they still exist. pass elif new_version == 25: - _add_columns(session_maker, "states", [f"attributes_id {big_int}"]) + _add_columns( + session_maker, "states", [f"attributes_id {_column_types.big_int_type}"] + ) _create_index(session_maker, "states", "ix_states_attributes_id") elif new_version == 26: _create_index(session_maker, "statistics_runs", "ix_statistics_runs_start") elif new_version == 27: - _add_columns(session_maker, "events", [f"data_id {big_int}"]) + _add_columns(session_maker, "events", [f"data_id {_column_types.big_int_type}"]) _create_index(session_maker, "events", "ix_events_data_id") elif new_version == 28: _add_columns(session_maker, "events", ["origin_idx INTEGER"]) @@ -881,11 +907,16 @@ def _apply_update( # noqa: C901 # ALTER TABLE events DROP COLUMN time_fired # ALTER TABLE states DROP COLUMN last_updated # ALTER TABLE states DROP COLUMN last_changed - _add_columns(session_maker, "events", [f"time_fired_ts {timestamp_type}"]) + _add_columns( + session_maker, "events", [f"time_fired_ts {_column_types.timestamp_type}"] + ) _add_columns( session_maker, "states", - [f"last_updated_ts {timestamp_type}", f"last_changed_ts {timestamp_type}"], + [ + f"last_updated_ts {_column_types.timestamp_type}", + f"last_changed_ts {_column_types.timestamp_type}", + ], ) _create_index(session_maker, "events", "ix_events_time_fired_ts") _create_index(session_maker, "events", "ix_events_event_type_time_fired_ts") @@ -917,18 +948,18 @@ def _apply_update( # noqa: C901 session_maker, "statistics", [ - f"created_ts {timestamp_type}", - f"start_ts {timestamp_type}", - f"last_reset_ts {timestamp_type}", + f"created_ts {_column_types.timestamp_type}", + f"start_ts {_column_types.timestamp_type}", + f"last_reset_ts {_column_types.timestamp_type}", ], ) _add_columns( session_maker, "statistics_short_term", [ - f"created_ts {timestamp_type}", - f"start_ts {timestamp_type}", - f"last_reset_ts {timestamp_type}", + f"created_ts {_column_types.timestamp_type}", + f"start_ts {_column_types.timestamp_type}", + f"last_reset_ts {_column_types.timestamp_type}", ], ) _create_index(session_maker, "statistics", "ix_statistics_start_ts") @@ -983,20 +1014,24 @@ def _apply_update( # noqa: C901 session_maker, table, [ - f"context_id_bin {context_bin_type}", - f"context_user_id_bin {context_bin_type}", - f"context_parent_id_bin {context_bin_type}", + f"context_id_bin {_column_types.context_bin_type}", + f"context_user_id_bin {_column_types.context_bin_type}", + f"context_parent_id_bin {_column_types.context_bin_type}", ], ) _create_index(session_maker, "events", "ix_events_context_id_bin") _create_index(session_maker, "states", "ix_states_context_id_bin") elif new_version == 37: - _add_columns(session_maker, "events", [f"event_type_id {big_int}"]) + _add_columns( + session_maker, "events", [f"event_type_id {_column_types.big_int_type}"] + ) _create_index(session_maker, "events", "ix_events_event_type_id") _drop_index(session_maker, "events", "ix_events_event_type_time_fired_ts") _create_index(session_maker, "events", "ix_events_event_type_id_time_fired_ts") elif new_version == 38: - _add_columns(session_maker, "states", [f"metadata_id {big_int}"]) + _add_columns( + session_maker, "states", [f"metadata_id {_column_types.big_int_type}"] + ) _create_index(session_maker, "states", "ix_states_metadata_id") _create_index(session_maker, "states", "ix_states_metadata_id_last_updated_ts") elif new_version == 39: From ddcaa9d3721f2e373c6c6a4f07504598fbc9d339 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Mar 2023 15:08:06 -1000 Subject: [PATCH 1139/2810] Break out statistics repairs into a `auto_repairs` modules (#90068) * Break out statistics schema repairs into a repairs module A future PR will add repairs for events, states, etc * reorg * reorg * reorg * reorg * fixes * fix patch targets * name space rename --- .../recorder/auto_repairs/__init__.py | 1 + .../auto_repairs/statistics/__init__.py | 1 + .../auto_repairs/statistics/duplicates.py | 261 +++++++++ .../auto_repairs/statistics/schema.py | 295 ++++++++++ .../components/recorder/migration.py | 16 +- .../components/recorder/statistics.py | 512 +---------------- .../recorder/auto_repairs/__init__.py | 5 + .../auto_repairs/statistics/__init__.py | 5 + .../statistics/test_duplicates.py | 330 +++++++++++ .../auto_repairs/statistics/test_schema.py | 235 ++++++++ tests/components/recorder/test_statistics.py | 531 +----------------- tests/conftest.py | 10 +- 12 files changed, 1157 insertions(+), 1045 deletions(-) create mode 100644 homeassistant/components/recorder/auto_repairs/__init__.py create mode 100644 homeassistant/components/recorder/auto_repairs/statistics/__init__.py create mode 100644 homeassistant/components/recorder/auto_repairs/statistics/duplicates.py create mode 100644 homeassistant/components/recorder/auto_repairs/statistics/schema.py create mode 100644 tests/components/recorder/auto_repairs/__init__.py create mode 100644 tests/components/recorder/auto_repairs/statistics/__init__.py create mode 100644 tests/components/recorder/auto_repairs/statistics/test_duplicates.py create mode 100644 tests/components/recorder/auto_repairs/statistics/test_schema.py diff --git a/homeassistant/components/recorder/auto_repairs/__init__.py b/homeassistant/components/recorder/auto_repairs/__init__.py new file mode 100644 index 0000000000..aa3880bf1d --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/__init__.py @@ -0,0 +1 @@ +"""Repairs for Recorder.""" diff --git a/homeassistant/components/recorder/auto_repairs/statistics/__init__.py b/homeassistant/components/recorder/auto_repairs/statistics/__init__.py new file mode 100644 index 0000000000..64bfd4fbb2 --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/statistics/__init__.py @@ -0,0 +1 @@ +"""Statistics repairs for Recorder.""" diff --git a/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py b/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py new file mode 100644 index 0000000000..8a24dcbf92 --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py @@ -0,0 +1,261 @@ +"""Statistics duplication repairs.""" +from __future__ import annotations + +import json +import logging +import os +from typing import TYPE_CHECKING + +from sqlalchemy import func +from sqlalchemy.engine.row import Row +from sqlalchemy.orm.session import Session +from sqlalchemy.sql.expression import literal_column + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.storage import STORAGE_DIR +from homeassistant.util import dt as dt_util + +from ...const import SQLITE_MAX_BIND_VARS +from ...db_schema import Statistics, StatisticsBase, StatisticsMeta, StatisticsShortTerm +from ...util import database_job_retry_wrapper, execute + +if TYPE_CHECKING: + from ... import Recorder + +_LOGGER = logging.getLogger(__name__) + + +def _find_duplicates( + session: Session, table: type[StatisticsBase] +) -> tuple[list[int], list[dict]]: + """Find duplicated statistics.""" + subquery = ( + session.query( + table.start, + table.metadata_id, + literal_column("1").label("is_duplicate"), + ) + .group_by(table.metadata_id, table.start) + # https://github.com/sqlalchemy/sqlalchemy/issues/9189 + # pylint: disable-next=not-callable + .having(func.count() > 1) + .subquery() + ) + query = ( + session.query( + table.id, + table.metadata_id, + table.created, + table.start, + table.mean, + table.min, + table.max, + table.last_reset, + table.state, + table.sum, + ) + .outerjoin( + subquery, + (subquery.c.metadata_id == table.metadata_id) + & (subquery.c.start == table.start), + ) + .filter(subquery.c.is_duplicate == 1) + .order_by(table.metadata_id, table.start, table.id.desc()) + .limit(1000 * SQLITE_MAX_BIND_VARS) + ) + duplicates = execute(query) + original_as_dict = {} + start = None + metadata_id = None + duplicate_ids: list[int] = [] + non_identical_duplicates_as_dict: list[dict] = [] + + if not duplicates: + return (duplicate_ids, non_identical_duplicates_as_dict) + + def columns_to_dict(duplicate: Row) -> dict: + """Convert a SQLAlchemy row to dict.""" + dict_ = {} + for key in ( + "id", + "metadata_id", + "start", + "created", + "mean", + "min", + "max", + "last_reset", + "state", + "sum", + ): + dict_[key] = getattr(duplicate, key) + return dict_ + + def compare_statistic_rows(row1: dict, row2: dict) -> bool: + """Compare two statistics rows, ignoring id and created.""" + ignore_keys = {"id", "created"} + keys1 = set(row1).difference(ignore_keys) + keys2 = set(row2).difference(ignore_keys) + return keys1 == keys2 and all(row1[k] == row2[k] for k in keys1) + + for duplicate in duplicates: + if start != duplicate.start or metadata_id != duplicate.metadata_id: + original_as_dict = columns_to_dict(duplicate) + start = duplicate.start + metadata_id = duplicate.metadata_id + continue + duplicate_as_dict = columns_to_dict(duplicate) + duplicate_ids.append(duplicate.id) + if not compare_statistic_rows(original_as_dict, duplicate_as_dict): + non_identical_duplicates_as_dict.append( + {"duplicate": duplicate_as_dict, "original": original_as_dict} + ) + + return (duplicate_ids, non_identical_duplicates_as_dict) + + +def _delete_duplicates_from_table( + session: Session, table: type[StatisticsBase] +) -> tuple[int, list[dict]]: + """Identify and delete duplicated statistics from a specified table.""" + all_non_identical_duplicates: list[dict] = [] + total_deleted_rows = 0 + while True: + duplicate_ids, non_identical_duplicates = _find_duplicates(session, table) + if not duplicate_ids: + break + all_non_identical_duplicates.extend(non_identical_duplicates) + for i in range(0, len(duplicate_ids), SQLITE_MAX_BIND_VARS): + deleted_rows = ( + session.query(table) + .filter(table.id.in_(duplicate_ids[i : i + SQLITE_MAX_BIND_VARS])) + .delete(synchronize_session=False) + ) + total_deleted_rows += deleted_rows + return (total_deleted_rows, all_non_identical_duplicates) + + +@database_job_retry_wrapper("delete statistics duplicates", 3) +def delete_statistics_duplicates( + instance: Recorder, hass: HomeAssistant, session: Session +) -> None: + """Identify and delete duplicated statistics. + + A backup will be made of duplicated statistics before it is deleted. + """ + deleted_statistics_rows, non_identical_duplicates = _delete_duplicates_from_table( + session, Statistics + ) + if deleted_statistics_rows: + _LOGGER.info("Deleted %s duplicated statistics rows", deleted_statistics_rows) + + if non_identical_duplicates: + isotime = dt_util.utcnow().isoformat() + backup_file_name = f"deleted_statistics.{isotime}.json" + backup_path = hass.config.path(STORAGE_DIR, backup_file_name) + + os.makedirs(os.path.dirname(backup_path), exist_ok=True) + with open(backup_path, "w", encoding="utf8") as backup_file: + json.dump( + non_identical_duplicates, + backup_file, + indent=4, + sort_keys=True, + cls=JSONEncoder, + ) + _LOGGER.warning( + ( + "Deleted %s non identical duplicated %s rows, a backup of the deleted" + " rows has been saved to %s" + ), + len(non_identical_duplicates), + Statistics.__tablename__, + backup_path, + ) + + deleted_short_term_statistics_rows, _ = _delete_duplicates_from_table( + session, StatisticsShortTerm + ) + if deleted_short_term_statistics_rows: + _LOGGER.warning( + "Deleted duplicated short term statistic rows, please report at %s", + "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+recorder%22", + ) + + +def _find_statistics_meta_duplicates(session: Session) -> list[int]: + """Find duplicated statistics_meta.""" + # When querying the database, be careful to only explicitly query for columns + # which were present in schema version 29. If querying the table, SQLAlchemy + # will refer to future columns. + subquery = ( + session.query( + StatisticsMeta.statistic_id, + literal_column("1").label("is_duplicate"), + ) + .group_by(StatisticsMeta.statistic_id) + # https://github.com/sqlalchemy/sqlalchemy/issues/9189 + # pylint: disable-next=not-callable + .having(func.count() > 1) + .subquery() + ) + query = ( + session.query(StatisticsMeta.statistic_id, StatisticsMeta.id) + .outerjoin( + subquery, + (subquery.c.statistic_id == StatisticsMeta.statistic_id), + ) + .filter(subquery.c.is_duplicate == 1) + .order_by(StatisticsMeta.statistic_id, StatisticsMeta.id.desc()) + .limit(1000 * SQLITE_MAX_BIND_VARS) + ) + duplicates = execute(query) + statistic_id = None + duplicate_ids: list[int] = [] + + if not duplicates: + return duplicate_ids + + for duplicate in duplicates: + if statistic_id != duplicate.statistic_id: + statistic_id = duplicate.statistic_id + continue + duplicate_ids.append(duplicate.id) + + return duplicate_ids + + +def _delete_statistics_meta_duplicates(session: Session) -> int: + """Identify and delete duplicated statistics from a specified table.""" + total_deleted_rows = 0 + while True: + duplicate_ids = _find_statistics_meta_duplicates(session) + if not duplicate_ids: + break + for i in range(0, len(duplicate_ids), SQLITE_MAX_BIND_VARS): + deleted_rows = ( + session.query(StatisticsMeta) + .filter( + StatisticsMeta.id.in_(duplicate_ids[i : i + SQLITE_MAX_BIND_VARS]) + ) + .delete(synchronize_session=False) + ) + total_deleted_rows += deleted_rows + return total_deleted_rows + + +@database_job_retry_wrapper("delete statistics meta duplicates", 3) +def delete_statistics_meta_duplicates(instance: Recorder, session: Session) -> None: + """Identify and delete duplicated statistics_meta. + + This is used when migrating from schema version 28 to schema version 29. + """ + deleted_statistics_rows = _delete_statistics_meta_duplicates(session) + if deleted_statistics_rows: + statistics_meta_manager = instance.statistics_meta_manager + statistics_meta_manager.reset() + statistics_meta_manager.load(session) + _LOGGER.info( + "Deleted %s duplicated statistics_meta rows", deleted_statistics_rows + ) diff --git a/homeassistant/components/recorder/auto_repairs/statistics/schema.py b/homeassistant/components/recorder/auto_repairs/statistics/schema.py new file mode 100644 index 0000000000..bbf59080ac --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/statistics/schema.py @@ -0,0 +1,295 @@ +"""Statistics schema repairs.""" +from __future__ import annotations + +from collections.abc import Callable, Mapping +import contextlib +from datetime import datetime +import logging +from typing import TYPE_CHECKING + +from sqlalchemy import text +from sqlalchemy.engine import Engine +from sqlalchemy.exc import OperationalError, SQLAlchemyError +from sqlalchemy.orm.session import Session + +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from ...const import DOMAIN, SupportedDialect +from ...db_schema import Statistics, StatisticsShortTerm +from ...models import StatisticData, StatisticMetaData, datetime_to_timestamp_or_none +from ...statistics import ( + _import_statistics_with_session, + _statistics_during_period_with_session, +) +from ...util import session_scope + +if TYPE_CHECKING: + from ... import Recorder + +_LOGGER = logging.getLogger(__name__) + + +def _validate_db_schema_utf8( + instance: Recorder, session_maker: Callable[[], Session] +) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + schema_errors: set[str] = set() + + # Lack of full utf8 support is only an issue for MySQL / MariaDB + if instance.dialect_name != SupportedDialect.MYSQL: + return schema_errors + + # This name can't be represented unless 4-byte UTF-8 unicode is supported + utf8_name = "𓆚𓃗" + statistic_id = f"{DOMAIN}.db_test" + + metadata: StatisticMetaData = { + "has_mean": True, + "has_sum": True, + "name": utf8_name, + "source": DOMAIN, + "statistic_id": statistic_id, + "unit_of_measurement": None, + } + statistics_meta_manager = instance.statistics_meta_manager + + # Try inserting some metadata which needs utf8mb4 support + try: + # Mark the session as read_only to ensure that the test data is not committed + # to the database and we always rollback when the scope is exited + with session_scope(session=session_maker(), read_only=True) as session: + old_metadata_dict = statistics_meta_manager.get_many( + session, statistic_ids={statistic_id} + ) + try: + statistics_meta_manager.update_or_add( + session, metadata, old_metadata_dict + ) + statistics_meta_manager.delete(session, statistic_ids=[statistic_id]) + except OperationalError as err: + if err.orig and err.orig.args[0] == 1366: + _LOGGER.debug( + "Database table statistics_meta does not support 4-byte UTF-8" + ) + schema_errors.add("statistics_meta.4-byte UTF-8") + session.rollback() + else: + raise + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception("Error when validating DB schema: %s", exc) + return schema_errors + + +def _get_future_year() -> int: + """Get a year in the future.""" + return datetime.now().year + 1 + + +def _validate_db_schema( + hass: HomeAssistant, instance: Recorder, session_maker: Callable[[], Session] +) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + schema_errors: set[str] = set() + statistics_meta_manager = instance.statistics_meta_manager + + # Wrong precision is only an issue for MySQL / MariaDB / PostgreSQL + if instance.dialect_name not in ( + SupportedDialect.MYSQL, + SupportedDialect.POSTGRESQL, + ): + return schema_errors + + # This number can't be accurately represented as a 32-bit float + precise_number = 1.000000000000001 + # This time can't be accurately represented unless datetimes have µs precision + # + # We want to insert statistics for a time in the future, in case they + # have conflicting metadata_id's with existing statistics that were + # never cleaned up. By inserting in the future, we can be sure that + # that by selecting the last inserted row, we will get the one we + # just inserted. + # + future_year = _get_future_year() + precise_time = datetime(future_year, 10, 6, microsecond=1, tzinfo=dt_util.UTC) + start_time = datetime(future_year, 10, 6, tzinfo=dt_util.UTC) + statistic_id = f"{DOMAIN}.db_test" + + metadata: StatisticMetaData = { + "has_mean": True, + "has_sum": True, + "name": None, + "source": DOMAIN, + "statistic_id": statistic_id, + "unit_of_measurement": None, + } + statistics: StatisticData = { + "last_reset": precise_time, + "max": precise_number, + "mean": precise_number, + "min": precise_number, + "start": precise_time, + "state": precise_number, + "sum": precise_number, + } + + def check_columns( + schema_errors: set[str], + stored: Mapping, + expected: Mapping, + columns: tuple[str, ...], + table_name: str, + supports: str, + ) -> None: + for column in columns: + if stored[column] != expected[column]: + schema_errors.add(f"{table_name}.{supports}") + _LOGGER.error( + "Column %s in database table %s does not support %s (stored=%s != expected=%s)", + column, + table_name, + supports, + stored[column], + expected[column], + ) + + # Insert / adjust a test statistics row in each of the tables + tables: tuple[type[Statistics | StatisticsShortTerm], ...] = ( + Statistics, + StatisticsShortTerm, + ) + try: + # Mark the session as read_only to ensure that the test data is not committed + # to the database and we always rollback when the scope is exited + with session_scope(session=session_maker(), read_only=True) as session: + for table in tables: + _import_statistics_with_session( + instance, session, metadata, (statistics,), table + ) + stored_statistics = _statistics_during_period_with_session( + hass, + session, + start_time, + None, + {statistic_id}, + "hour" if table == Statistics else "5minute", + None, + {"last_reset", "max", "mean", "min", "state", "sum"}, + ) + if not (stored_statistic := stored_statistics.get(statistic_id)): + _LOGGER.warning( + "Schema validation failed for table: %s", table.__tablename__ + ) + continue + + # We want to look at the last inserted row to make sure there + # is not previous garbage data in the table that would cause + # the test to produce an incorrect result. To achieve this, + # we inserted a row in the future, and now we select the last + # inserted row back. + last_stored_statistic = stored_statistic[-1] + check_columns( + schema_errors, + last_stored_statistic, + statistics, + ("max", "mean", "min", "state", "sum"), + table.__tablename__, + "double precision", + ) + assert statistics["last_reset"] + check_columns( + schema_errors, + last_stored_statistic, + { + "last_reset": datetime_to_timestamp_or_none( + statistics["last_reset"] + ), + "start": datetime_to_timestamp_or_none(statistics["start"]), + }, + ("start", "last_reset"), + table.__tablename__, + "µs precision", + ) + statistics_meta_manager.delete(session, statistic_ids=[statistic_id]) + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception("Error when validating DB schema: %s", exc) + + return schema_errors + + +def validate_db_schema( + hass: HomeAssistant, instance: Recorder, session_maker: Callable[[], Session] +) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + schema_errors: set[str] = set() + schema_errors |= _validate_db_schema_utf8(instance, session_maker) + schema_errors |= _validate_db_schema(hass, instance, session_maker) + if schema_errors: + _LOGGER.debug( + "Detected statistics schema errors: %s", ", ".join(sorted(schema_errors)) + ) + return schema_errors + + +def correct_db_schema( + instance: Recorder, + engine: Engine, + session_maker: Callable[[], Session], + schema_errors: set[str], +) -> None: + """Correct issues detected by validate_db_schema.""" + from ...migration import _modify_columns # pylint: disable=import-outside-toplevel + + if "statistics_meta.4-byte UTF-8" in schema_errors: + # Attempt to convert the table to utf8mb4 + _LOGGER.warning( + ( + "Updating character set and collation of table %s to utf8mb4. " + "Note: this can take several minutes on large databases and slow " + "computers. Please be patient!" + ), + "statistics_meta", + ) + with contextlib.suppress(SQLAlchemyError), session_scope( + session=session_maker() + ) as session: + connection = session.connection() + connection.execute( + # Using LOCK=EXCLUSIVE to prevent the database from corrupting + # https://github.com/home-assistant/core/issues/56104 + text( + "ALTER TABLE statistics_meta CONVERT TO CHARACTER SET utf8mb4" + " COLLATE utf8mb4_unicode_ci, LOCK=EXCLUSIVE" + ) + ) + + tables: tuple[type[Statistics | StatisticsShortTerm], ...] = ( + Statistics, + StatisticsShortTerm, + ) + for table in tables: + if f"{table.__tablename__}.double precision" in schema_errors: + # Attempt to convert float columns to double precision + _modify_columns( + session_maker, + engine, + table.__tablename__, + [ + "mean DOUBLE PRECISION", + "min DOUBLE PRECISION", + "max DOUBLE PRECISION", + "state DOUBLE PRECISION", + "sum DOUBLE PRECISION", + ], + ) + if f"{table.__tablename__}.µs precision" in schema_errors: + # Attempt to convert timestamp columns to µs precision + _modify_columns( + session_maker, + engine, + table.__tablename__, + [ + "last_reset_ts DOUBLE PRECISION", + "start_ts DOUBLE PRECISION", + ], + ) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 38eed25bee..6fc2138d91 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -28,6 +28,14 @@ from homeassistant.core import HomeAssistant from homeassistant.util.enum import try_parse_enum from homeassistant.util.ulid import ulid_to_bytes +from .auto_repairs.statistics.duplicates import ( + delete_statistics_duplicates, + delete_statistics_meta_duplicates, +) +from .auto_repairs.statistics.schema import ( + correct_db_schema as statistics_correct_db_schema, + validate_db_schema as statistics_validate_db_schema, +) from .const import SupportedDialect from .db_schema import ( CONTEXT_ID_BIN_MAX_LENGTH, @@ -55,13 +63,7 @@ from .queries import ( find_states_context_ids_to_migrate, has_used_states_event_ids, ) -from .statistics import ( - correct_db_schema as statistics_correct_db_schema, - delete_statistics_duplicates, - delete_statistics_meta_duplicates, - get_start_time, - validate_db_schema as statistics_validate_db_schema, -) +from .statistics import get_start_time from .tasks import ( CommitTask, PostSchemaMigrationTask, diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 34adcbddcc..82fbf7798f 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -2,34 +2,28 @@ from __future__ import annotations from collections import defaultdict -from collections.abc import Callable, Iterable, Mapping, Sequence +from collections.abc import Callable, Iterable, Sequence import contextlib import dataclasses from datetime import datetime, timedelta from functools import lru_cache, partial from itertools import chain, groupby -import json import logging from operator import itemgetter -import os import re from statistics import mean from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast from sqlalchemy import Select, and_, bindparam, func, lambda_stmt, select, text -from sqlalchemy.engine import Engine from sqlalchemy.engine.row import Row -from sqlalchemy.exc import OperationalError, SQLAlchemyError, StatementError +from sqlalchemy.exc import SQLAlchemyError, StatementError from sqlalchemy.orm.session import Session -from sqlalchemy.sql.expression import literal_column from sqlalchemy.sql.lambdas import StatementLambdaElement import voluptuous as vol from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant, callback, valid_entity_id from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.json import JSONEncoder -from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( @@ -53,14 +47,12 @@ from .const import ( DOMAIN, EVENT_RECORDER_5MIN_STATISTICS_GENERATED, EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, - SQLITE_MAX_BIND_VARS, SupportedDialect, ) from .db_schema import ( STATISTICS_TABLES, Statistics, StatisticsBase, - StatisticsMeta, StatisticsRuns, StatisticsShortTerm, ) @@ -73,7 +65,6 @@ from .models import ( process_timestamp, ) from .util import ( - database_job_retry_wrapper, execute, execute_stmt_lambda_element, get_instance, @@ -333,240 +324,6 @@ def get_start_time() -> datetime: return last_period -def _find_duplicates( - session: Session, table: type[StatisticsBase] -) -> tuple[list[int], list[dict]]: - """Find duplicated statistics.""" - subquery = ( - session.query( - table.start, - table.metadata_id, - literal_column("1").label("is_duplicate"), - ) - .group_by(table.metadata_id, table.start) - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - .having(func.count() > 1) - .subquery() - ) - query = ( - session.query( - table.id, - table.metadata_id, - table.created, - table.start, - table.mean, - table.min, - table.max, - table.last_reset, - table.state, - table.sum, - ) - .outerjoin( - subquery, - (subquery.c.metadata_id == table.metadata_id) - & (subquery.c.start == table.start), - ) - .filter(subquery.c.is_duplicate == 1) - .order_by(table.metadata_id, table.start, table.id.desc()) - .limit(1000 * SQLITE_MAX_BIND_VARS) - ) - duplicates = execute(query) - original_as_dict = {} - start = None - metadata_id = None - duplicate_ids: list[int] = [] - non_identical_duplicates_as_dict: list[dict] = [] - - if not duplicates: - return (duplicate_ids, non_identical_duplicates_as_dict) - - def columns_to_dict(duplicate: Row) -> dict: - """Convert a SQLAlchemy row to dict.""" - dict_ = {} - for key in ( - "id", - "metadata_id", - "start", - "created", - "mean", - "min", - "max", - "last_reset", - "state", - "sum", - ): - dict_[key] = getattr(duplicate, key) - return dict_ - - def compare_statistic_rows(row1: dict, row2: dict) -> bool: - """Compare two statistics rows, ignoring id and created.""" - ignore_keys = {"id", "created"} - keys1 = set(row1).difference(ignore_keys) - keys2 = set(row2).difference(ignore_keys) - return keys1 == keys2 and all(row1[k] == row2[k] for k in keys1) - - for duplicate in duplicates: - if start != duplicate.start or metadata_id != duplicate.metadata_id: - original_as_dict = columns_to_dict(duplicate) - start = duplicate.start - metadata_id = duplicate.metadata_id - continue - duplicate_as_dict = columns_to_dict(duplicate) - duplicate_ids.append(duplicate.id) - if not compare_statistic_rows(original_as_dict, duplicate_as_dict): - non_identical_duplicates_as_dict.append( - {"duplicate": duplicate_as_dict, "original": original_as_dict} - ) - - return (duplicate_ids, non_identical_duplicates_as_dict) - - -def _delete_duplicates_from_table( - session: Session, table: type[StatisticsBase] -) -> tuple[int, list[dict]]: - """Identify and delete duplicated statistics from a specified table.""" - all_non_identical_duplicates: list[dict] = [] - total_deleted_rows = 0 - while True: - duplicate_ids, non_identical_duplicates = _find_duplicates(session, table) - if not duplicate_ids: - break - all_non_identical_duplicates.extend(non_identical_duplicates) - for i in range(0, len(duplicate_ids), SQLITE_MAX_BIND_VARS): - deleted_rows = ( - session.query(table) - .filter(table.id.in_(duplicate_ids[i : i + SQLITE_MAX_BIND_VARS])) - .delete(synchronize_session=False) - ) - total_deleted_rows += deleted_rows - return (total_deleted_rows, all_non_identical_duplicates) - - -@database_job_retry_wrapper("delete statistics duplicates", 3) -def delete_statistics_duplicates( - instance: Recorder, hass: HomeAssistant, session: Session -) -> None: - """Identify and delete duplicated statistics. - - A backup will be made of duplicated statistics before it is deleted. - """ - deleted_statistics_rows, non_identical_duplicates = _delete_duplicates_from_table( - session, Statistics - ) - if deleted_statistics_rows: - _LOGGER.info("Deleted %s duplicated statistics rows", deleted_statistics_rows) - - if non_identical_duplicates: - isotime = dt_util.utcnow().isoformat() - backup_file_name = f"deleted_statistics.{isotime}.json" - backup_path = hass.config.path(STORAGE_DIR, backup_file_name) - - os.makedirs(os.path.dirname(backup_path), exist_ok=True) - with open(backup_path, "w", encoding="utf8") as backup_file: - json.dump( - non_identical_duplicates, - backup_file, - indent=4, - sort_keys=True, - cls=JSONEncoder, - ) - _LOGGER.warning( - ( - "Deleted %s non identical duplicated %s rows, a backup of the deleted" - " rows has been saved to %s" - ), - len(non_identical_duplicates), - Statistics.__tablename__, - backup_path, - ) - - deleted_short_term_statistics_rows, _ = _delete_duplicates_from_table( - session, StatisticsShortTerm - ) - if deleted_short_term_statistics_rows: - _LOGGER.warning( - "Deleted duplicated short term statistic rows, please report at %s", - "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+recorder%22", - ) - - -def _find_statistics_meta_duplicates(session: Session) -> list[int]: - """Find duplicated statistics_meta.""" - # When querying the database, be careful to only explicitly query for columns - # which were present in schema version 29. If querying the table, SQLAlchemy - # will refer to future columns. - subquery = ( - session.query( - StatisticsMeta.statistic_id, - literal_column("1").label("is_duplicate"), - ) - .group_by(StatisticsMeta.statistic_id) - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - .having(func.count() > 1) - .subquery() - ) - query = ( - session.query(StatisticsMeta.statistic_id, StatisticsMeta.id) - .outerjoin( - subquery, - (subquery.c.statistic_id == StatisticsMeta.statistic_id), - ) - .filter(subquery.c.is_duplicate == 1) - .order_by(StatisticsMeta.statistic_id, StatisticsMeta.id.desc()) - .limit(1000 * SQLITE_MAX_BIND_VARS) - ) - duplicates = execute(query) - statistic_id = None - duplicate_ids: list[int] = [] - - if not duplicates: - return duplicate_ids - - for duplicate in duplicates: - if statistic_id != duplicate.statistic_id: - statistic_id = duplicate.statistic_id - continue - duplicate_ids.append(duplicate.id) - - return duplicate_ids - - -def _delete_statistics_meta_duplicates(session: Session) -> int: - """Identify and delete duplicated statistics from a specified table.""" - total_deleted_rows = 0 - while True: - duplicate_ids = _find_statistics_meta_duplicates(session) - if not duplicate_ids: - break - for i in range(0, len(duplicate_ids), SQLITE_MAX_BIND_VARS): - deleted_rows = ( - session.query(StatisticsMeta) - .filter( - StatisticsMeta.id.in_(duplicate_ids[i : i + SQLITE_MAX_BIND_VARS]) - ) - .delete(synchronize_session=False) - ) - total_deleted_rows += deleted_rows - return total_deleted_rows - - -def delete_statistics_meta_duplicates(instance: Recorder, session: Session) -> None: - """Identify and delete duplicated statistics_meta. - - This is used when migrating from schema version 28 to schema version 29. - """ - deleted_statistics_rows = _delete_statistics_meta_duplicates(session) - if deleted_statistics_rows: - statistics_meta_manager = instance.statistics_meta_manager - statistics_meta_manager.reset() - statistics_meta_manager.load(session) - _LOGGER.info( - "Deleted %s duplicated statistics_meta rows", deleted_statistics_rows - ) - - def _compile_hourly_statistics_summary_mean_stmt( start_time_ts: float, end_time_ts: float ) -> StatementLambdaElement: @@ -2478,271 +2235,6 @@ def async_change_statistics_unit( ) -def _validate_db_schema_utf8( - instance: Recorder, session_maker: Callable[[], Session] -) -> set[str]: - """Do some basic checks for common schema errors caused by manual migration.""" - schema_errors: set[str] = set() - - # Lack of full utf8 support is only an issue for MySQL / MariaDB - if instance.dialect_name != SupportedDialect.MYSQL: - return schema_errors - - # This name can't be represented unless 4-byte UTF-8 unicode is supported - utf8_name = "𓆚𓃗" - statistic_id = f"{DOMAIN}.db_test" - - metadata: StatisticMetaData = { - "has_mean": True, - "has_sum": True, - "name": utf8_name, - "source": DOMAIN, - "statistic_id": statistic_id, - "unit_of_measurement": None, - } - statistics_meta_manager = instance.statistics_meta_manager - - # Try inserting some metadata which needs utfmb4 support - try: - # Mark the session as read_only to ensure that the test data is not committed - # to the database and we always rollback when the scope is exited - with session_scope(session=session_maker(), read_only=True) as session: - old_metadata_dict = statistics_meta_manager.get_many( - session, statistic_ids={statistic_id} - ) - try: - statistics_meta_manager.update_or_add( - session, metadata, old_metadata_dict - ) - statistics_meta_manager.delete(session, statistic_ids=[statistic_id]) - except OperationalError as err: - if err.orig and err.orig.args[0] == 1366: - _LOGGER.debug( - "Database table statistics_meta does not support 4-byte UTF-8" - ) - schema_errors.add("statistics_meta.4-byte UTF-8") - session.rollback() - else: - raise - except Exception as exc: # pylint: disable=broad-except - _LOGGER.exception("Error when validating DB schema: %s", exc) - return schema_errors - - -def _get_future_year() -> int: - """Get a year in the future.""" - return datetime.now().year + 1 - - -def _validate_db_schema( - hass: HomeAssistant, instance: Recorder, session_maker: Callable[[], Session] -) -> set[str]: - """Do some basic checks for common schema errors caused by manual migration.""" - schema_errors: set[str] = set() - statistics_meta_manager = instance.statistics_meta_manager - - # Wrong precision is only an issue for MySQL / MariaDB / PostgreSQL - if instance.dialect_name not in ( - SupportedDialect.MYSQL, - SupportedDialect.POSTGRESQL, - ): - return schema_errors - - # This number can't be accurately represented as a 32-bit float - precise_number = 1.000000000000001 - # This time can't be accurately represented unless datetimes have µs precision - # - # We want to insert statistics for a time in the future, in case they - # have conflicting metadata_id's with existing statistics that were - # never cleaned up. By inserting in the future, we can be sure that - # that by selecting the last inserted row, we will get the one we - # just inserted. - # - future_year = _get_future_year() - precise_time = datetime(future_year, 10, 6, microsecond=1, tzinfo=dt_util.UTC) - start_time = datetime(future_year, 10, 6, tzinfo=dt_util.UTC) - statistic_id = f"{DOMAIN}.db_test" - - metadata: StatisticMetaData = { - "has_mean": True, - "has_sum": True, - "name": None, - "source": DOMAIN, - "statistic_id": statistic_id, - "unit_of_measurement": None, - } - statistics: StatisticData = { - "last_reset": precise_time, - "max": precise_number, - "mean": precise_number, - "min": precise_number, - "start": precise_time, - "state": precise_number, - "sum": precise_number, - } - - def check_columns( - schema_errors: set[str], - stored: Mapping, - expected: Mapping, - columns: tuple[str, ...], - table_name: str, - supports: str, - ) -> None: - for column in columns: - if stored[column] != expected[column]: - schema_errors.add(f"{table_name}.{supports}") - _LOGGER.error( - "Column %s in database table %s does not support %s (stored=%s != expected=%s)", - column, - table_name, - supports, - stored[column], - expected[column], - ) - - # Insert / adjust a test statistics row in each of the tables - tables: tuple[type[Statistics | StatisticsShortTerm], ...] = ( - Statistics, - StatisticsShortTerm, - ) - try: - # Mark the session as read_only to ensure that the test data is not committed - # to the database and we always rollback when the scope is exited - with session_scope(session=session_maker(), read_only=True) as session: - for table in tables: - _import_statistics_with_session( - instance, session, metadata, (statistics,), table - ) - stored_statistics = _statistics_during_period_with_session( - hass, - session, - start_time, - None, - {statistic_id}, - "hour" if table == Statistics else "5minute", - None, - {"last_reset", "max", "mean", "min", "state", "sum"}, - ) - if not (stored_statistic := stored_statistics.get(statistic_id)): - _LOGGER.warning( - "Schema validation failed for table: %s", table.__tablename__ - ) - continue - - # We want to look at the last inserted row to make sure there - # is not previous garbage data in the table that would cause - # the test to produce an incorrect result. To achieve this, - # we inserted a row in the future, and now we select the last - # inserted row back. - last_stored_statistic = stored_statistic[-1] - check_columns( - schema_errors, - last_stored_statistic, - statistics, - ("max", "mean", "min", "state", "sum"), - table.__tablename__, - "double precision", - ) - assert statistics["last_reset"] - check_columns( - schema_errors, - last_stored_statistic, - { - "last_reset": datetime_to_timestamp_or_none( - statistics["last_reset"] - ), - "start": datetime_to_timestamp_or_none(statistics["start"]), - }, - ("start", "last_reset"), - table.__tablename__, - "µs precision", - ) - statistics_meta_manager.delete(session, statistic_ids=[statistic_id]) - except Exception as exc: # pylint: disable=broad-except - _LOGGER.exception("Error when validating DB schema: %s", exc) - - return schema_errors - - -def validate_db_schema( - hass: HomeAssistant, instance: Recorder, session_maker: Callable[[], Session] -) -> set[str]: - """Do some basic checks for common schema errors caused by manual migration.""" - schema_errors: set[str] = set() - schema_errors |= _validate_db_schema_utf8(instance, session_maker) - schema_errors |= _validate_db_schema(hass, instance, session_maker) - if schema_errors: - _LOGGER.debug( - "Detected statistics schema errors: %s", ", ".join(sorted(schema_errors)) - ) - return schema_errors - - -def correct_db_schema( - instance: Recorder, - engine: Engine, - session_maker: Callable[[], Session], - schema_errors: set[str], -) -> None: - """Correct issues detected by validate_db_schema.""" - from .migration import _modify_columns # pylint: disable=import-outside-toplevel - - if "statistics_meta.4-byte UTF-8" in schema_errors: - # Attempt to convert the table to utf8mb4 - _LOGGER.warning( - ( - "Updating character set and collation of table %s to utf8mb4. " - "Note: this can take several minutes on large databases and slow " - "computers. Please be patient!" - ), - "statistics_meta", - ) - with contextlib.suppress(SQLAlchemyError), session_scope( - session=session_maker() - ) as session: - connection = session.connection() - connection.execute( - # Using LOCK=EXCLUSIVE to prevent the database from corrupting - # https://github.com/home-assistant/core/issues/56104 - text( - "ALTER TABLE statistics_meta CONVERT TO CHARACTER SET utf8mb4" - " COLLATE utf8mb4_unicode_ci, LOCK=EXCLUSIVE" - ) - ) - - tables: tuple[type[Statistics | StatisticsShortTerm], ...] = ( - Statistics, - StatisticsShortTerm, - ) - for table in tables: - if f"{table.__tablename__}.double precision" in schema_errors: - # Attempt to convert float columns to double precision - _modify_columns( - session_maker, - engine, - table.__tablename__, - [ - "mean DOUBLE PRECISION", - "min DOUBLE PRECISION", - "max DOUBLE PRECISION", - "state DOUBLE PRECISION", - "sum DOUBLE PRECISION", - ], - ) - if f"{table.__tablename__}.µs precision" in schema_errors: - # Attempt to convert timestamp columns to µs precision - _modify_columns( - session_maker, - engine, - table.__tablename__, - [ - "last_reset_ts DOUBLE PRECISION", - "start_ts DOUBLE PRECISION", - ], - ) - - def cleanup_statistics_timestamp_migration(instance: Recorder) -> bool: """Clean up the statistics migration from timestamp to datetime. diff --git a/tests/components/recorder/auto_repairs/__init__.py b/tests/components/recorder/auto_repairs/__init__.py new file mode 100644 index 0000000000..6e98d881ea --- /dev/null +++ b/tests/components/recorder/auto_repairs/__init__.py @@ -0,0 +1,5 @@ +"""Tests for Recorder component.""" + +import pytest + +pytest.register_assert_rewrite("tests.components.recorder.common") diff --git a/tests/components/recorder/auto_repairs/statistics/__init__.py b/tests/components/recorder/auto_repairs/statistics/__init__.py new file mode 100644 index 0000000000..6e98d881ea --- /dev/null +++ b/tests/components/recorder/auto_repairs/statistics/__init__.py @@ -0,0 +1,5 @@ +"""Tests for Recorder component.""" + +import pytest + +pytest.register_assert_rewrite("tests.components.recorder.common") diff --git a/tests/components/recorder/auto_repairs/statistics/test_duplicates.py b/tests/components/recorder/auto_repairs/statistics/test_duplicates.py new file mode 100644 index 0000000000..53dac0b6ab --- /dev/null +++ b/tests/components/recorder/auto_repairs/statistics/test_duplicates.py @@ -0,0 +1,330 @@ +"""Test removing statistics duplicates.""" +from collections.abc import Callable + +# pylint: disable=invalid-name +import importlib +import sys +from unittest.mock import patch + +import py +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from homeassistant.components import recorder +from homeassistant.components.recorder import statistics +from homeassistant.components.recorder.auto_repairs.statistics.duplicates import ( + delete_statistics_duplicates, + delete_statistics_meta_duplicates, +) +from homeassistant.components.recorder.const import SQLITE_URL_PREFIX +from homeassistant.components.recorder.statistics import async_add_external_statistics +from homeassistant.components.recorder.util import session_scope +from homeassistant.core import HomeAssistant +from homeassistant.helpers import recorder as recorder_helper +from homeassistant.setup import setup_component +import homeassistant.util.dt as dt_util + +from ...common import wait_recording_done + +from tests.common import get_test_home_assistant + +ORIG_TZ = dt_util.DEFAULT_TIME_ZONE + + +def test_delete_duplicates_no_duplicates( + hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture +) -> None: + """Test removal of duplicated statistics.""" + hass = hass_recorder() + wait_recording_done(hass) + instance = recorder.get_instance(hass) + with session_scope(hass=hass) as session: + delete_statistics_duplicates(instance, hass, session) + assert "duplicated statistics rows" not in caplog.text + assert "Found non identical" not in caplog.text + assert "Found duplicated" not in caplog.text + + +def test_duplicate_statistics_handle_integrity_error( + hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture +) -> None: + """Test the recorder does not blow up if statistics is duplicated.""" + hass = hass_recorder() + wait_recording_done(hass) + + period1 = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 23:00:00")) + + external_energy_metadata_1 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import_tariff_1", + "unit_of_measurement": "kWh", + } + external_energy_statistics_1 = [ + { + "start": period1, + "last_reset": None, + "state": 3, + "sum": 5, + }, + ] + external_energy_statistics_2 = [ + { + "start": period2, + "last_reset": None, + "state": 3, + "sum": 6, + } + ] + + with patch.object( + statistics, "_statistics_exists", return_value=False + ), patch.object( + statistics, "_insert_statistics", wraps=statistics._insert_statistics + ) as insert_statistics_mock: + async_add_external_statistics( + hass, external_energy_metadata_1, external_energy_statistics_1 + ) + async_add_external_statistics( + hass, external_energy_metadata_1, external_energy_statistics_1 + ) + async_add_external_statistics( + hass, external_energy_metadata_1, external_energy_statistics_2 + ) + wait_recording_done(hass) + assert insert_statistics_mock.call_count == 3 + + with session_scope(hass=hass) as session: + tmp = session.query(recorder.db_schema.Statistics).all() + assert len(tmp) == 2 + + assert "Blocked attempt to insert duplicated statistic rows" in caplog.text + + +def _create_engine_28(*args, **kwargs): + """Test version of create_engine that initializes with old schema. + + This simulates an existing db with the old schema. + """ + module = "tests.components.recorder.db_schema_28" + importlib.import_module(module) + old_db_schema = sys.modules[module] + engine = create_engine(*args, **kwargs) + old_db_schema.Base.metadata.create_all(engine) + with Session(engine) as session: + session.add( + recorder.db_schema.StatisticsRuns(start=statistics.get_start_time()) + ) + session.add( + recorder.db_schema.SchemaChanges( + schema_version=old_db_schema.SCHEMA_VERSION + ) + ) + session.commit() + return engine + + +def test_delete_metadata_duplicates( + caplog: pytest.LogCaptureFixture, tmpdir: py.path.local +) -> None: + """Test removal of duplicated statistics.""" + test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") + dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" + + module = "tests.components.recorder.db_schema_28" + importlib.import_module(module) + old_db_schema = sys.modules[module] + + external_energy_metadata_1 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import_tariff_1", + "unit_of_measurement": "kWh", + } + external_energy_metadata_2 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import_tariff_1", + "unit_of_measurement": "kWh", + } + external_co2_metadata = { + "has_mean": True, + "has_sum": False, + "name": "Fossil percentage", + "source": "test", + "statistic_id": "test:fossil_percentage", + "unit_of_measurement": "%", + } + + # Create some duplicated statistics_meta with schema version 28 + with patch.object(recorder, "db_schema", old_db_schema), patch.object( + recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION + ), patch( + "homeassistant.components.recorder.core.create_engine", new=_create_engine_28 + ): + hass = get_test_home_assistant() + recorder_helper.async_initialize_recorder(hass) + setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) + wait_recording_done(hass) + wait_recording_done(hass) + + with session_scope(hass=hass) as session: + session.add( + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_1) + ) + session.add( + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_2) + ) + session.add( + recorder.db_schema.StatisticsMeta.from_meta(external_co2_metadata) + ) + + with session_scope(hass=hass) as session: + tmp = session.query(recorder.db_schema.StatisticsMeta).all() + assert len(tmp) == 3 + assert tmp[0].id == 1 + assert tmp[0].statistic_id == "test:total_energy_import_tariff_1" + assert tmp[1].id == 2 + assert tmp[1].statistic_id == "test:total_energy_import_tariff_1" + assert tmp[2].id == 3 + assert tmp[2].statistic_id == "test:fossil_percentage" + + hass.stop() + dt_util.DEFAULT_TIME_ZONE = ORIG_TZ + + # Test that the duplicates are removed during migration from schema 28 + hass = get_test_home_assistant() + recorder_helper.async_initialize_recorder(hass) + setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) + hass.start() + wait_recording_done(hass) + wait_recording_done(hass) + + assert "Deleted 1 duplicated statistics_meta rows" in caplog.text + with session_scope(hass=hass) as session: + tmp = session.query(recorder.db_schema.StatisticsMeta).all() + assert len(tmp) == 2 + assert tmp[0].id == 2 + assert tmp[0].statistic_id == "test:total_energy_import_tariff_1" + assert tmp[1].id == 3 + assert tmp[1].statistic_id == "test:fossil_percentage" + + hass.stop() + dt_util.DEFAULT_TIME_ZONE = ORIG_TZ + + +def test_delete_metadata_duplicates_many( + caplog: pytest.LogCaptureFixture, tmpdir: py.path.local +) -> None: + """Test removal of duplicated statistics.""" + test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") + dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" + + module = "tests.components.recorder.db_schema_28" + importlib.import_module(module) + old_db_schema = sys.modules[module] + + external_energy_metadata_1 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import_tariff_1", + "unit_of_measurement": "kWh", + } + external_energy_metadata_2 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import_tariff_2", + "unit_of_measurement": "kWh", + } + external_co2_metadata = { + "has_mean": True, + "has_sum": False, + "name": "Fossil percentage", + "source": "test", + "statistic_id": "test:fossil_percentage", + "unit_of_measurement": "%", + } + + # Create some duplicated statistics with schema version 28 + with patch.object(recorder, "db_schema", old_db_schema), patch.object( + recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION + ), patch( + "homeassistant.components.recorder.core.create_engine", new=_create_engine_28 + ): + hass = get_test_home_assistant() + recorder_helper.async_initialize_recorder(hass) + setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) + wait_recording_done(hass) + wait_recording_done(hass) + + with session_scope(hass=hass) as session: + session.add( + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_1) + ) + for _ in range(1100): + session.add( + recorder.db_schema.StatisticsMeta.from_meta( + external_energy_metadata_1 + ) + ) + session.add( + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_2) + ) + session.add( + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_2) + ) + session.add( + recorder.db_schema.StatisticsMeta.from_meta(external_co2_metadata) + ) + session.add( + recorder.db_schema.StatisticsMeta.from_meta(external_co2_metadata) + ) + + hass.stop() + dt_util.DEFAULT_TIME_ZONE = ORIG_TZ + + # Test that the duplicates are removed during migration from schema 28 + hass = get_test_home_assistant() + recorder_helper.async_initialize_recorder(hass) + setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) + hass.start() + wait_recording_done(hass) + wait_recording_done(hass) + + assert "Deleted 1102 duplicated statistics_meta rows" in caplog.text + with session_scope(hass=hass) as session: + tmp = session.query(recorder.db_schema.StatisticsMeta).all() + assert len(tmp) == 3 + assert tmp[0].id == 1101 + assert tmp[0].statistic_id == "test:total_energy_import_tariff_1" + assert tmp[1].id == 1103 + assert tmp[1].statistic_id == "test:total_energy_import_tariff_2" + assert tmp[2].id == 1105 + assert tmp[2].statistic_id == "test:fossil_percentage" + + hass.stop() + dt_util.DEFAULT_TIME_ZONE = ORIG_TZ + + +def test_delete_metadata_duplicates_no_duplicates( + hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture +) -> None: + """Test removal of duplicated statistics.""" + hass = hass_recorder() + wait_recording_done(hass) + with session_scope(hass=hass) as session: + instance = recorder.get_instance(hass) + delete_statistics_meta_duplicates(instance, session) + assert "duplicated statistics_meta rows" not in caplog.text diff --git a/tests/components/recorder/auto_repairs/statistics/test_schema.py b/tests/components/recorder/auto_repairs/statistics/test_schema.py new file mode 100644 index 0000000000..2c4e06580f --- /dev/null +++ b/tests/components/recorder/auto_repairs/statistics/test_schema.py @@ -0,0 +1,235 @@ +"""The test repairing statistics schema.""" + +# pylint: disable=invalid-name +from datetime import datetime +from unittest.mock import ANY, DEFAULT, MagicMock, patch + +import pytest +from sqlalchemy.exc import OperationalError + +from homeassistant.components.recorder.auto_repairs.statistics.schema import ( + _get_future_year, +) +from homeassistant.components.recorder.statistics import ( + _statistics_during_period_with_session, +) +from homeassistant.components.recorder.table_managers.statistics_meta import ( + StatisticsMetaManager, +) +from homeassistant.core import HomeAssistant +import homeassistant.util.dt as dt_util + +from ...common import async_wait_recording_done + +from tests.typing import RecorderInstanceGenerator + + +@pytest.mark.parametrize("enable_statistics_table_validation", [True]) +@pytest.mark.parametrize("db_engine", ("mysql", "postgresql")) +async def test_validate_db_schema( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + db_engine, +) -> None: + """Test validating DB schema with MySQL and PostgreSQL. + + Note: The test uses SQLite, the purpose is only to exercise the code. + """ + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine + ): + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + assert "Schema validation failed" not in caplog.text + assert "Detected statistics schema errors" not in caplog.text + assert "Database is about to correct DB schema errors" not in caplog.text + + +@pytest.mark.parametrize("enable_statistics_table_validation", [True]) +async def test_validate_db_schema_fix_utf8_issue( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema with MySQL. + + Note: The test uses SQLite, the purpose is only to exercise the code. + """ + orig_error = MagicMock() + orig_error.args = [1366] + utf8_error = OperationalError("", "", orig=orig_error) + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", "mysql" + ), patch( + "homeassistant.components.recorder.table_managers.statistics_meta.StatisticsMetaManager.update_or_add", + wraps=StatisticsMetaManager.update_or_add, + side_effect=[utf8_error, DEFAULT, DEFAULT], + ): + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + assert "Schema validation failed" not in caplog.text + assert ( + "Database is about to correct DB schema errors: statistics_meta.4-byte UTF-8" + in caplog.text + ) + assert ( + "Updating character set and collation of table statistics_meta to utf8mb4" + in caplog.text + ) + + +@pytest.mark.parametrize("enable_statistics_table_validation", [True]) +@pytest.mark.parametrize("db_engine", ("mysql", "postgresql")) +@pytest.mark.parametrize( + ("table", "replace_index"), (("statistics", 0), ("statistics_short_term", 1)) +) +@pytest.mark.parametrize( + ("column", "value"), + (("max", 1.0), ("mean", 1.0), ("min", 1.0), ("state", 1.0), ("sum", 1.0)), +) +async def test_validate_db_schema_fix_float_issue( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + db_engine, + table, + replace_index, + column, + value, +) -> None: + """Test validating DB schema with MySQL. + + Note: The test uses SQLite, the purpose is only to exercise the code. + """ + orig_error = MagicMock() + orig_error.args = [1366] + precise_number = 1.000000000000001 + fixed_future_year = _get_future_year() + precise_time = datetime(fixed_future_year, 10, 6, microsecond=1, tzinfo=dt_util.UTC) + statistics = { + "recorder.db_test": [ + { + "last_reset": precise_time.timestamp(), + "max": precise_number, + "mean": precise_number, + "min": precise_number, + "start": precise_time.timestamp(), + "state": precise_number, + "sum": precise_number, + } + ] + } + statistics["recorder.db_test"][0][column] = value + fake_statistics = [DEFAULT, DEFAULT] + fake_statistics[replace_index] = statistics + + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine + ), patch( + "homeassistant.components.recorder.auto_repairs.statistics.schema._get_future_year", + return_value=fixed_future_year, + ), patch( + "homeassistant.components.recorder.auto_repairs.statistics.schema._statistics_during_period_with_session", + side_effect=fake_statistics, + wraps=_statistics_during_period_with_session, + ), patch( + "homeassistant.components.recorder.migration._modify_columns" + ) as modify_columns_mock: + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + assert "Schema validation failed" not in caplog.text + assert ( + f"Database is about to correct DB schema errors: {table}.double precision" + in caplog.text + ) + modification = [ + "mean DOUBLE PRECISION", + "min DOUBLE PRECISION", + "max DOUBLE PRECISION", + "state DOUBLE PRECISION", + "sum DOUBLE PRECISION", + ] + modify_columns_mock.assert_called_once_with(ANY, ANY, table, modification) + + +@pytest.mark.parametrize("enable_statistics_table_validation", [True]) +@pytest.mark.parametrize( + ("db_engine", "modification"), + ( + ("mysql", ["last_reset_ts DOUBLE PRECISION", "start_ts DOUBLE PRECISION"]), + ( + "postgresql", + [ + "last_reset_ts DOUBLE PRECISION", + "start_ts DOUBLE PRECISION", + ], + ), + ), +) +@pytest.mark.parametrize( + ("table", "replace_index"), (("statistics", 0), ("statistics_short_term", 1)) +) +@pytest.mark.parametrize( + ("column", "value"), + ( + ("last_reset", "2020-10-06T00:00:00+00:00"), + ("start", "2020-10-06T00:00:00+00:00"), + ), +) +async def test_validate_db_schema_fix_statistics_datetime_issue( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + db_engine, + modification, + table, + replace_index, + column, + value, +) -> None: + """Test validating DB schema with MySQL. + + Note: The test uses SQLite, the purpose is only to exercise the code. + """ + orig_error = MagicMock() + orig_error.args = [1366] + precise_number = 1.000000000000001 + precise_time = datetime(2020, 10, 6, microsecond=1, tzinfo=dt_util.UTC) + statistics = { + "recorder.db_test": [ + { + "last_reset": precise_time, + "max": precise_number, + "mean": precise_number, + "min": precise_number, + "start": precise_time, + "state": precise_number, + "sum": precise_number, + } + ] + } + statistics["recorder.db_test"][0][column] = value + fake_statistics = [DEFAULT, DEFAULT] + fake_statistics[replace_index] = statistics + + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine + ), patch( + "homeassistant.components.recorder.auto_repairs.statistics.schema._statistics_during_period_with_session", + side_effect=fake_statistics, + wraps=_statistics_during_period_with_session, + ), patch( + "homeassistant.components.recorder.migration._modify_columns" + ) as modify_columns_mock: + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + assert "Schema validation failed" not in caplog.text + assert ( + f"Database is about to correct DB schema errors: {table}.µs precision" + in caplog.text + ) + modify_columns_mock.assert_called_once_with(ANY, ANY, table, modification) diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index d783d72be2..ebad039ca4 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -2,20 +2,14 @@ from collections.abc import Callable # pylint: disable=invalid-name -from datetime import datetime, timedelta -import importlib -import sys -from unittest.mock import ANY, DEFAULT, MagicMock, patch +from datetime import timedelta +from unittest.mock import patch -import py import pytest -from sqlalchemy import create_engine, select -from sqlalchemy.exc import OperationalError -from sqlalchemy.orm import Session +from sqlalchemy import select from homeassistant.components import recorder from homeassistant.components.recorder import Recorder, history, statistics -from homeassistant.components.recorder.const import SQLITE_URL_PREFIX from homeassistant.components.recorder.db_schema import StatisticsShortTerm from homeassistant.components.recorder.models import ( datetime_to_timestamp_or_none, @@ -26,12 +20,8 @@ from homeassistant.components.recorder.statistics import ( _generate_max_mean_min_statistic_in_sub_period_stmt, _generate_statistics_at_time_stmt, _generate_statistics_during_period_stmt, - _get_future_year, - _statistics_during_period_with_session, async_add_external_statistics, async_import_statistics, - delete_statistics_duplicates, - delete_statistics_meta_duplicates, get_last_short_term_statistics, get_last_statistics, get_latest_short_term_statistics, @@ -39,14 +29,12 @@ from homeassistant.components.recorder.statistics import ( list_statistic_ids, ) from homeassistant.components.recorder.table_managers.statistics_meta import ( - StatisticsMetaManager, _generate_get_metadata_stmt, ) from homeassistant.components.recorder.util import session_scope from homeassistant.components.sensor import UNIT_CONVERTERS from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import recorder as recorder_helper from homeassistant.setup import setup_component import homeassistant.util.dt as dt_util @@ -59,8 +47,8 @@ from .common import ( wait_recording_done, ) -from tests.common import get_test_home_assistant, mock_registry -from tests.typing import RecorderInstanceGenerator, WebSocketGenerator +from tests.common import mock_registry +from tests.typing import WebSocketGenerator ORIG_TZ = dt_util.DEFAULT_TIME_ZONE @@ -1254,515 +1242,6 @@ def test_monthly_statistics( dt_util.set_default_time_zone(dt_util.get_time_zone("UTC")) -def test_delete_duplicates_no_duplicates( - hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture -) -> None: - """Test removal of duplicated statistics.""" - hass = hass_recorder() - wait_recording_done(hass) - instance = recorder.get_instance(hass) - with session_scope(hass=hass) as session: - delete_statistics_duplicates(instance, hass, session) - assert "duplicated statistics rows" not in caplog.text - assert "Found non identical" not in caplog.text - assert "Found duplicated" not in caplog.text - - -def test_duplicate_statistics_handle_integrity_error( - hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture -) -> None: - """Test the recorder does not blow up if statistics is duplicated.""" - hass = hass_recorder() - wait_recording_done(hass) - - period1 = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) - period2 = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 23:00:00")) - - external_energy_metadata_1 = { - "has_mean": False, - "has_sum": True, - "name": "Total imported energy", - "source": "test", - "statistic_id": "test:total_energy_import_tariff_1", - "unit_of_measurement": "kWh", - } - external_energy_statistics_1 = [ - { - "start": period1, - "last_reset": None, - "state": 3, - "sum": 5, - }, - ] - external_energy_statistics_2 = [ - { - "start": period2, - "last_reset": None, - "state": 3, - "sum": 6, - } - ] - - with patch.object( - statistics, "_statistics_exists", return_value=False - ), patch.object( - statistics, "_insert_statistics", wraps=statistics._insert_statistics - ) as insert_statistics_mock: - async_add_external_statistics( - hass, external_energy_metadata_1, external_energy_statistics_1 - ) - async_add_external_statistics( - hass, external_energy_metadata_1, external_energy_statistics_1 - ) - async_add_external_statistics( - hass, external_energy_metadata_1, external_energy_statistics_2 - ) - wait_recording_done(hass) - assert insert_statistics_mock.call_count == 3 - - with session_scope(hass=hass) as session: - tmp = session.query(recorder.db_schema.Statistics).all() - assert len(tmp) == 2 - - assert "Blocked attempt to insert duplicated statistic rows" in caplog.text - - -def _create_engine_28(*args, **kwargs): - """Test version of create_engine that initializes with old schema. - - This simulates an existing db with the old schema. - """ - module = "tests.components.recorder.db_schema_28" - importlib.import_module(module) - old_db_schema = sys.modules[module] - engine = create_engine(*args, **kwargs) - old_db_schema.Base.metadata.create_all(engine) - with Session(engine) as session: - session.add( - recorder.db_schema.StatisticsRuns(start=statistics.get_start_time()) - ) - session.add( - recorder.db_schema.SchemaChanges( - schema_version=old_db_schema.SCHEMA_VERSION - ) - ) - session.commit() - return engine - - -def test_delete_metadata_duplicates( - caplog: pytest.LogCaptureFixture, tmpdir: py.path.local -) -> None: - """Test removal of duplicated statistics.""" - test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") - dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" - - module = "tests.components.recorder.db_schema_28" - importlib.import_module(module) - old_db_schema = sys.modules[module] - - external_energy_metadata_1 = { - "has_mean": False, - "has_sum": True, - "name": "Total imported energy", - "source": "test", - "statistic_id": "test:total_energy_import_tariff_1", - "unit_of_measurement": "kWh", - } - external_energy_metadata_2 = { - "has_mean": False, - "has_sum": True, - "name": "Total imported energy", - "source": "test", - "statistic_id": "test:total_energy_import_tariff_1", - "unit_of_measurement": "kWh", - } - external_co2_metadata = { - "has_mean": True, - "has_sum": False, - "name": "Fossil percentage", - "source": "test", - "statistic_id": "test:fossil_percentage", - "unit_of_measurement": "%", - } - - # Create some duplicated statistics_meta with schema version 28 - with patch.object(recorder, "db_schema", old_db_schema), patch.object( - recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION - ), patch( - "homeassistant.components.recorder.core.create_engine", new=_create_engine_28 - ): - hass = get_test_home_assistant() - recorder_helper.async_initialize_recorder(hass) - setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) - wait_recording_done(hass) - wait_recording_done(hass) - - with session_scope(hass=hass) as session: - session.add( - recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_1) - ) - session.add( - recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_2) - ) - session.add( - recorder.db_schema.StatisticsMeta.from_meta(external_co2_metadata) - ) - - with session_scope(hass=hass) as session: - tmp = session.query(recorder.db_schema.StatisticsMeta).all() - assert len(tmp) == 3 - assert tmp[0].id == 1 - assert tmp[0].statistic_id == "test:total_energy_import_tariff_1" - assert tmp[1].id == 2 - assert tmp[1].statistic_id == "test:total_energy_import_tariff_1" - assert tmp[2].id == 3 - assert tmp[2].statistic_id == "test:fossil_percentage" - - hass.stop() - dt_util.DEFAULT_TIME_ZONE = ORIG_TZ - - # Test that the duplicates are removed during migration from schema 28 - hass = get_test_home_assistant() - recorder_helper.async_initialize_recorder(hass) - setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) - hass.start() - wait_recording_done(hass) - wait_recording_done(hass) - - assert "Deleted 1 duplicated statistics_meta rows" in caplog.text - with session_scope(hass=hass) as session: - tmp = session.query(recorder.db_schema.StatisticsMeta).all() - assert len(tmp) == 2 - assert tmp[0].id == 2 - assert tmp[0].statistic_id == "test:total_energy_import_tariff_1" - assert tmp[1].id == 3 - assert tmp[1].statistic_id == "test:fossil_percentage" - - hass.stop() - dt_util.DEFAULT_TIME_ZONE = ORIG_TZ - - -def test_delete_metadata_duplicates_many( - caplog: pytest.LogCaptureFixture, tmpdir: py.path.local -) -> None: - """Test removal of duplicated statistics.""" - test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") - dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" - - module = "tests.components.recorder.db_schema_28" - importlib.import_module(module) - old_db_schema = sys.modules[module] - - external_energy_metadata_1 = { - "has_mean": False, - "has_sum": True, - "name": "Total imported energy", - "source": "test", - "statistic_id": "test:total_energy_import_tariff_1", - "unit_of_measurement": "kWh", - } - external_energy_metadata_2 = { - "has_mean": False, - "has_sum": True, - "name": "Total imported energy", - "source": "test", - "statistic_id": "test:total_energy_import_tariff_2", - "unit_of_measurement": "kWh", - } - external_co2_metadata = { - "has_mean": True, - "has_sum": False, - "name": "Fossil percentage", - "source": "test", - "statistic_id": "test:fossil_percentage", - "unit_of_measurement": "%", - } - - # Create some duplicated statistics with schema version 28 - with patch.object(recorder, "db_schema", old_db_schema), patch.object( - recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION - ), patch( - "homeassistant.components.recorder.core.create_engine", new=_create_engine_28 - ): - hass = get_test_home_assistant() - recorder_helper.async_initialize_recorder(hass) - setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) - wait_recording_done(hass) - wait_recording_done(hass) - - with session_scope(hass=hass) as session: - session.add( - recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_1) - ) - for _ in range(1100): - session.add( - recorder.db_schema.StatisticsMeta.from_meta( - external_energy_metadata_1 - ) - ) - session.add( - recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_2) - ) - session.add( - recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_2) - ) - session.add( - recorder.db_schema.StatisticsMeta.from_meta(external_co2_metadata) - ) - session.add( - recorder.db_schema.StatisticsMeta.from_meta(external_co2_metadata) - ) - - hass.stop() - dt_util.DEFAULT_TIME_ZONE = ORIG_TZ - - # Test that the duplicates are removed during migration from schema 28 - hass = get_test_home_assistant() - recorder_helper.async_initialize_recorder(hass) - setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) - hass.start() - wait_recording_done(hass) - wait_recording_done(hass) - - assert "Deleted 1102 duplicated statistics_meta rows" in caplog.text - with session_scope(hass=hass) as session: - tmp = session.query(recorder.db_schema.StatisticsMeta).all() - assert len(tmp) == 3 - assert tmp[0].id == 1101 - assert tmp[0].statistic_id == "test:total_energy_import_tariff_1" - assert tmp[1].id == 1103 - assert tmp[1].statistic_id == "test:total_energy_import_tariff_2" - assert tmp[2].id == 1105 - assert tmp[2].statistic_id == "test:fossil_percentage" - - hass.stop() - dt_util.DEFAULT_TIME_ZONE = ORIG_TZ - - -def test_delete_metadata_duplicates_no_duplicates( - hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture -) -> None: - """Test removal of duplicated statistics.""" - hass = hass_recorder() - wait_recording_done(hass) - with session_scope(hass=hass) as session: - instance = recorder.get_instance(hass) - delete_statistics_meta_duplicates(instance, session) - assert "duplicated statistics_meta rows" not in caplog.text - - -@pytest.mark.parametrize("enable_statistics_table_validation", [True]) -@pytest.mark.parametrize("db_engine", ("mysql", "postgresql")) -async def test_validate_db_schema( - async_setup_recorder_instance: RecorderInstanceGenerator, - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - db_engine, -) -> None: - """Test validating DB schema with MySQL and PostgreSQL. - - Note: The test uses SQLite, the purpose is only to exercise the code. - """ - with patch( - "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine - ): - await async_setup_recorder_instance(hass) - await async_wait_recording_done(hass) - assert "Schema validation failed" not in caplog.text - assert "Detected statistics schema errors" not in caplog.text - assert "Database is about to correct DB schema errors" not in caplog.text - - -@pytest.mark.parametrize("enable_statistics_table_validation", [True]) -async def test_validate_db_schema_fix_utf8_issue( - async_setup_recorder_instance: RecorderInstanceGenerator, - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test validating DB schema with MySQL. - - Note: The test uses SQLite, the purpose is only to exercise the code. - """ - orig_error = MagicMock() - orig_error.args = [1366] - utf8_error = OperationalError("", "", orig=orig_error) - with patch( - "homeassistant.components.recorder.core.Recorder.dialect_name", "mysql" - ), patch( - "homeassistant.components.recorder.table_managers.statistics_meta.StatisticsMetaManager.update_or_add", - wraps=StatisticsMetaManager.update_or_add, - side_effect=[utf8_error, DEFAULT, DEFAULT], - ): - await async_setup_recorder_instance(hass) - await async_wait_recording_done(hass) - - assert "Schema validation failed" not in caplog.text - assert ( - "Database is about to correct DB schema errors: statistics_meta.4-byte UTF-8" - in caplog.text - ) - assert ( - "Updating character set and collation of table statistics_meta to utf8mb4" - in caplog.text - ) - - -@pytest.mark.parametrize("enable_statistics_table_validation", [True]) -@pytest.mark.parametrize("db_engine", ("mysql", "postgresql")) -@pytest.mark.parametrize( - ("table", "replace_index"), (("statistics", 0), ("statistics_short_term", 1)) -) -@pytest.mark.parametrize( - ("column", "value"), - (("max", 1.0), ("mean", 1.0), ("min", 1.0), ("state", 1.0), ("sum", 1.0)), -) -async def test_validate_db_schema_fix_float_issue( - async_setup_recorder_instance: RecorderInstanceGenerator, - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - db_engine, - table, - replace_index, - column, - value, -) -> None: - """Test validating DB schema with MySQL. - - Note: The test uses SQLite, the purpose is only to exercise the code. - """ - orig_error = MagicMock() - orig_error.args = [1366] - precise_number = 1.000000000000001 - fixed_future_year = _get_future_year() - precise_time = datetime(fixed_future_year, 10, 6, microsecond=1, tzinfo=dt_util.UTC) - statistics = { - "recorder.db_test": [ - { - "last_reset": precise_time.timestamp(), - "max": precise_number, - "mean": precise_number, - "min": precise_number, - "start": precise_time.timestamp(), - "state": precise_number, - "sum": precise_number, - } - ] - } - statistics["recorder.db_test"][0][column] = value - fake_statistics = [DEFAULT, DEFAULT] - fake_statistics[replace_index] = statistics - - with patch( - "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine - ), patch( - "homeassistant.components.recorder.statistics._get_future_year", - return_value=fixed_future_year, - ), patch( - "homeassistant.components.recorder.statistics._statistics_during_period_with_session", - side_effect=fake_statistics, - wraps=_statistics_during_period_with_session, - ), patch( - "homeassistant.components.recorder.migration._modify_columns" - ) as modify_columns_mock: - await async_setup_recorder_instance(hass) - await async_wait_recording_done(hass) - - assert "Schema validation failed" not in caplog.text - assert ( - f"Database is about to correct DB schema errors: {table}.double precision" - in caplog.text - ) - modification = [ - "mean DOUBLE PRECISION", - "min DOUBLE PRECISION", - "max DOUBLE PRECISION", - "state DOUBLE PRECISION", - "sum DOUBLE PRECISION", - ] - modify_columns_mock.assert_called_once_with(ANY, ANY, table, modification) - - -@pytest.mark.parametrize("enable_statistics_table_validation", [True]) -@pytest.mark.parametrize( - ("db_engine", "modification"), - ( - ("mysql", ["last_reset_ts DOUBLE PRECISION", "start_ts DOUBLE PRECISION"]), - ( - "postgresql", - [ - "last_reset_ts DOUBLE PRECISION", - "start_ts DOUBLE PRECISION", - ], - ), - ), -) -@pytest.mark.parametrize( - ("table", "replace_index"), (("statistics", 0), ("statistics_short_term", 1)) -) -@pytest.mark.parametrize( - ("column", "value"), - ( - ("last_reset", "2020-10-06T00:00:00+00:00"), - ("start", "2020-10-06T00:00:00+00:00"), - ), -) -async def test_validate_db_schema_fix_statistics_datetime_issue( - async_setup_recorder_instance: RecorderInstanceGenerator, - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - db_engine, - modification, - table, - replace_index, - column, - value, -) -> None: - """Test validating DB schema with MySQL. - - Note: The test uses SQLite, the purpose is only to exercise the code. - """ - orig_error = MagicMock() - orig_error.args = [1366] - precise_number = 1.000000000000001 - precise_time = datetime(2020, 10, 6, microsecond=1, tzinfo=dt_util.UTC) - statistics = { - "recorder.db_test": [ - { - "last_reset": precise_time, - "max": precise_number, - "mean": precise_number, - "min": precise_number, - "start": precise_time, - "state": precise_number, - "sum": precise_number, - } - ] - } - statistics["recorder.db_test"][0][column] = value - fake_statistics = [DEFAULT, DEFAULT] - fake_statistics[replace_index] = statistics - - with patch( - "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine - ), patch( - "homeassistant.components.recorder.statistics._statistics_during_period_with_session", - side_effect=fake_statistics, - wraps=_statistics_during_period_with_session, - ), patch( - "homeassistant.components.recorder.migration._modify_columns" - ) as modify_columns_mock: - await async_setup_recorder_instance(hass) - await async_wait_recording_done(hass) - - assert "Schema validation failed" not in caplog.text - assert ( - f"Database is about to correct DB schema errors: {table}.µs precision" - in caplog.text - ) - modify_columns_mock.assert_called_once_with(ANY, ANY, table, modification) - - def test_cache_key_for_generate_statistics_during_period_stmt() -> None: """Test cache key for _generate_statistics_during_period_stmt.""" columns = select(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start_ts) diff --git a/tests/conftest.py b/tests/conftest.py index 5a1c44b781..c5197dd2bd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1282,13 +1282,16 @@ def hass_recorder( # pylint: disable-next=import-outside-toplevel from homeassistant.components import recorder + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.recorder.auto_repairs.statistics import schema + original_tz = dt_util.DEFAULT_TIME_ZONE hass = get_test_home_assistant() nightly = recorder.Recorder.async_nightly_tasks if enable_nightly_purge else None stats = recorder.Recorder.async_periodic_statistics if enable_statistics else None stats_validate = ( - recorder.statistics.validate_db_schema + schema.validate_db_schema if enable_statistics_table_validation else itertools.repeat(set()) ) @@ -1397,13 +1400,16 @@ async def async_setup_recorder_instance( # pylint: disable-next=import-outside-toplevel from homeassistant.components import recorder + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.recorder.auto_repairs.statistics import schema + # pylint: disable-next=import-outside-toplevel from .components.recorder.common import async_recorder_block_till_done nightly = recorder.Recorder.async_nightly_tasks if enable_nightly_purge else None stats = recorder.Recorder.async_periodic_statistics if enable_statistics else None stats_validate = ( - recorder.statistics.validate_db_schema + schema.validate_db_schema if enable_statistics_table_validation else itertools.repeat(set()) ) From 0e7ffff869cf9711d85c895ec3527977ce411c2e Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 21 Mar 2023 20:10:31 -0500 Subject: [PATCH 1140/2810] Add TTS to pipelines (#90004) * Add text to speech and stages to pipeline * Default to "cloud" TTS when engine is None * Refactor pipeline request to split text/audio * Refactor with PipelineRun * Generate pipeline from language * Clean up * Restore TTS code * Add audio pipeline test * Clean TTS cache in test * Clean up tests and pipeline base class * Stop pylint and pytest magics from fighting * Include mock_get_cache_files --- .../components/voice_assistant/__init__.py | 11 +- .../components/voice_assistant/pipeline.py | 177 +++++++++++++----- .../voice_assistant/websocket_api.py | 50 +++-- .../voice_assistant/test_pipeline.py | 110 +++++++++++ .../voice_assistant/test_websocket.py | 15 +- 5 files changed, 289 insertions(+), 74 deletions(-) create mode 100644 tests/components/voice_assistant/test_pipeline.py diff --git a/homeassistant/components/voice_assistant/__init__.py b/homeassistant/components/voice_assistant/__init__.py index d06176847e..2ae169a28e 100644 --- a/homeassistant/components/voice_assistant/__init__.py +++ b/homeassistant/components/voice_assistant/__init__.py @@ -4,20 +4,13 @@ from __future__ import annotations from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType -from .const import DEFAULT_PIPELINE, DOMAIN -from .pipeline import Pipeline +from .const import DOMAIN from .websocket_api import async_register_websocket_api async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Voice Assistant integration.""" - hass.data[DOMAIN] = { - DEFAULT_PIPELINE: Pipeline( - name=DEFAULT_PIPELINE, - language=None, - conversation_engine=None, - ) - } + hass.data[DOMAIN] = {} async_register_websocket_api(hass) return True diff --git a/homeassistant/components/voice_assistant/pipeline.py b/homeassistant/components/voice_assistant/pipeline.py index 8c7d22981a..0b55d72455 100644 --- a/homeassistant/components/voice_assistant/pipeline.py +++ b/homeassistant/components/voice_assistant/pipeline.py @@ -1,6 +1,7 @@ """Classes for voice assistant pipelines.""" from __future__ import annotations +from abc import ABC, abstractmethod import asyncio from collections.abc import Callable from dataclasses import dataclass, field @@ -8,20 +9,16 @@ from typing import Any from homeassistant.backports.enum import StrEnum from homeassistant.components import conversation +from homeassistant.components.media_source import async_resolve_media +from homeassistant.components.tts.media_source import ( + generate_media_source_id as tts_generate_media_source_id, +) from homeassistant.core import Context, HomeAssistant from homeassistant.util.dt import utcnow DEFAULT_TIMEOUT = 30 # seconds -@dataclass -class PipelineRequest: - """Request to start a pipeline run.""" - - intent_input: str - conversation_id: str | None = None - - class PipelineEventType(StrEnum): """Event types emitted during a pipeline run.""" @@ -29,6 +26,8 @@ class PipelineEventType(StrEnum): RUN_FINISH = "run-finish" INTENT_START = "intent-start" INTENT_FINISH = "intent-finish" + TTS_START = "tts-start" + TTS_FINISH = "tts-finish" ERROR = "error" @@ -56,69 +55,161 @@ class Pipeline: name: str language: str | None conversation_engine: str | None + tts_engine: str | None - async def run( - self, - hass: HomeAssistant, - context: Context, - request: PipelineRequest, - event_callback: Callable[[PipelineEvent], None], - timeout: int | float | None = DEFAULT_TIMEOUT, - ) -> None: - """Run a pipeline with an optional timeout.""" - await asyncio.wait_for( - self._run(hass, context, request, event_callback), timeout=timeout - ) - async def _run( - self, - hass: HomeAssistant, - context: Context, - request: PipelineRequest, - event_callback: Callable[[PipelineEvent], None], - ) -> None: - """Run a pipeline.""" - language = self.language or hass.config.language - event_callback( +@dataclass +class PipelineRun: + """Running context for a pipeline.""" + + hass: HomeAssistant + context: Context + pipeline: Pipeline + event_callback: Callable[[PipelineEvent], None] + language: str = None # type: ignore[assignment] + + def __post_init__(self): + """Set language for pipeline.""" + self.language = self.pipeline.language or self.hass.config.language + + def start(self): + """Emit run start event.""" + self.event_callback( PipelineEvent( PipelineEventType.RUN_START, { - "pipeline": self.name, - "language": language, + "pipeline": self.pipeline.name, + "language": self.language, }, ) ) - intent_input = request.intent_input + def finish(self): + """Emit run finish event.""" + self.event_callback( + PipelineEvent( + PipelineEventType.RUN_FINISH, + ) + ) - event_callback( + async def recognize_intent( + self, intent_input: str, conversation_id: str | None + ) -> conversation.ConversationResult: + """Run intent recognition portion of pipeline.""" + self.event_callback( PipelineEvent( PipelineEventType.INTENT_START, { - "engine": self.conversation_engine or "default", + "engine": self.pipeline.conversation_engine or "default", "intent_input": intent_input, }, ) ) conversation_result = await conversation.async_converse( - hass=hass, + hass=self.hass, text=intent_input, - conversation_id=request.conversation_id, - context=context, - language=language, - agent_id=self.conversation_engine, + conversation_id=conversation_id, + context=self.context, + language=self.language, + agent_id=self.pipeline.conversation_engine, ) - event_callback( + self.event_callback( PipelineEvent( PipelineEventType.INTENT_FINISH, {"intent_output": conversation_result.as_dict()}, ) ) - event_callback( + return conversation_result + + async def text_to_speech(self, tts_input: str) -> str: + """Run text to speech portion of pipeline. Returns URL of TTS audio.""" + self.event_callback( PipelineEvent( - PipelineEventType.RUN_FINISH, + PipelineEventType.TTS_START, + { + "engine": self.pipeline.tts_engine or "default", + "tts_input": tts_input, + }, ) ) + + tts_media = await async_resolve_media( + self.hass, + tts_generate_media_source_id( + self.hass, + tts_input, + engine=self.pipeline.tts_engine, + ), + ) + tts_url = tts_media.url + + self.event_callback( + PipelineEvent( + PipelineEventType.TTS_FINISH, + {"tts_output": tts_url}, + ) + ) + + return tts_url + + +@dataclass +class PipelineRequest(ABC): + """Request to for a pipeline run.""" + + async def execute( + self, run: PipelineRun, timeout: int | float | None = DEFAULT_TIMEOUT + ): + """Run pipeline with optional timeout.""" + await asyncio.wait_for( + self._execute(run), + timeout=timeout, + ) + + @abstractmethod + async def _execute(self, run: PipelineRun): + """Run pipeline with request info and context.""" + + +@dataclass +class TextPipelineRequest(PipelineRequest): + """Request to run the text portion only of a pipeline.""" + + intent_input: str + conversation_id: str | None = None + + async def _execute( + self, + run: PipelineRun, + ): + run.start() + await run.recognize_intent(self.intent_input, self.conversation_id) + run.finish() + + +@dataclass +class AudioPipelineRequest(PipelineRequest): + """Request to full pipeline from audio input (stt) to audio output (tts).""" + + intent_input: str # this will be changed to stt audio + conversation_id: str | None = None + + async def _execute(self, run: PipelineRun): + run.start() + + # stt will go here + + conversation_result = await run.recognize_intent( + self.intent_input, self.conversation_id + ) + + tts_input = conversation_result.response.speech.get("plain", {}).get( + "speech", "" + ) + + await run.text_to_speech(tts_input) + + run.finish() diff --git a/homeassistant/components/voice_assistant/websocket_api.py b/homeassistant/components/voice_assistant/websocket_api.py index 4ea88c3da0..54e87e292a 100644 --- a/homeassistant/components/voice_assistant/websocket_api.py +++ b/homeassistant/components/voice_assistant/websocket_api.py @@ -7,7 +7,7 @@ from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback from .const import DOMAIN -from .pipeline import DEFAULT_TIMEOUT, PipelineRequest +from .pipeline import DEFAULT_TIMEOUT, Pipeline, PipelineRun, TextPipelineRequest @callback @@ -19,7 +19,8 @@ def async_register_websocket_api(hass: HomeAssistant) -> None: @websocket_api.websocket_command( { vol.Required("type"): "voice_assistant/run", - vol.Optional("pipeline", default="default"): str, + vol.Optional("language"): str, + vol.Optional("pipeline"): str, vol.Required("intent_input"): str, vol.Optional("conversation_id"): vol.Any(str, None), vol.Optional("timeout"): vol.Any(float, int), @@ -32,27 +33,42 @@ async def websocket_run( msg: dict[str, Any], ) -> None: """Run a pipeline.""" - pipeline_id = msg["pipeline"] - pipeline = hass.data[DOMAIN].get(pipeline_id) - if pipeline is None: - connection.send_error( - msg["id"], "pipeline_not_found", f"Pipeline not found: {pipeline_id}" + pipeline_id = msg.get("pipeline") + if pipeline_id is not None: + pipeline = hass.data[DOMAIN].get(pipeline_id) + if pipeline is None: + connection.send_error( + msg["id"], + "pipeline_not_found", + f"Pipeline not found: {pipeline_id}", + ) + return + + else: + # Construct a pipeline for the required/configured language + language = msg.get("language", hass.config.language) + pipeline = Pipeline( + name=language, + language=language, + conversation_engine=None, + tts_engine=None, ) - return # Run pipeline with a timeout. # Events are sent over the websocket connection. timeout = msg.get("timeout", DEFAULT_TIMEOUT) run_task = hass.async_create_task( - pipeline.run( - hass, - connection.context(msg), - request=PipelineRequest( - intent_input=msg["intent_input"], - conversation_id=msg.get("conversation_id"), - ), - event_callback=lambda event: connection.send_event( - msg["id"], event.as_dict() + TextPipelineRequest( + intent_input=msg["intent_input"], + conversation_id=msg.get("conversation_id"), + ).execute( + PipelineRun( + hass, + connection.context(msg), + pipeline, + event_callback=lambda event: connection.send_event( + msg["id"], event.as_dict() + ), ), timeout=timeout, ) diff --git a/tests/components/voice_assistant/test_pipeline.py b/tests/components/voice_assistant/test_pipeline.py new file mode 100644 index 0000000000..343719a49f --- /dev/null +++ b/tests/components/voice_assistant/test_pipeline.py @@ -0,0 +1,110 @@ +"""Pipeline tests for Voice Assistant integration.""" +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.voice_assistant.pipeline import ( + AudioPipelineRequest, + Pipeline, + PipelineEventType, + PipelineRun, +) +from homeassistant.core import Context +from homeassistant.setup import async_setup_component + +from tests.components.tts.conftest import ( # noqa: F401, pylint: disable=unused-import + mock_get_cache_files, + mock_init_cache_dir, +) + + +@pytest.fixture(autouse=True) +async def init_components(hass): + """Initialize relevant components with empty configs.""" + assert await async_setup_component(hass, "voice_assistant", {}) + + +@pytest.fixture +async def mock_get_tts_audio(hass): + """Set up media source.""" + assert await async_setup_component(hass, "media_source", {}) + assert await async_setup_component( + hass, + "tts", + { + "tts": { + "platform": "demo", + } + }, + ) + + with patch( + "homeassistant.components.demo.tts.DemoProvider.get_tts_audio", + return_value=("mp3", b""), + ) as mock_get_tts: + yield mock_get_tts + + +async def test_audio_pipeline(hass, mock_get_tts_audio): + """Run audio pipeline with mock TTS.""" + pipeline = Pipeline( + name="test", + language=hass.config.language, + conversation_engine=None, + tts_engine=None, + ) + + event_callback = MagicMock() + await AudioPipelineRequest(intent_input="Are the lights on?").execute( + PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + event_callback=event_callback, + language=hass.config.language, + ) + ) + + calls = event_callback.mock_calls + assert calls[0].args[0].type == PipelineEventType.RUN_START + assert calls[0].args[0].data == { + "pipeline": "test", + "language": hass.config.language, + } + + assert calls[1].args[0].type == PipelineEventType.INTENT_START + assert calls[1].args[0].data == { + "engine": "default", + "intent_input": "Are the lights on?", + } + assert calls[2].args[0].type == PipelineEventType.INTENT_FINISH + assert calls[2].args[0].data == { + "intent_output": { + "conversation_id": None, + "response": { + "card": {}, + "data": {"code": "no_intent_match"}, + "language": hass.config.language, + "response_type": "error", + "speech": { + "plain": { + "extra_data": None, + "speech": "Sorry, I couldn't understand that", + } + }, + }, + } + } + + assert calls[3].args[0].type == PipelineEventType.TTS_START + assert calls[3].args[0].data == { + "engine": "default", + "tts_input": "Sorry, I couldn't understand that", + } + assert calls[4].args[0].type == PipelineEventType.TTS_FINISH + assert ( + calls[4].args[0].data["tts_output"] + == f"/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_{hass.config.language}_-_demo.mp3" + ) + + assert calls[5].args[0].type == PipelineEventType.RUN_FINISH diff --git a/tests/components/voice_assistant/test_websocket.py b/tests/components/voice_assistant/test_websocket.py index e862da6f54..2fec6cdfb0 100644 --- a/tests/components/voice_assistant/test_websocket.py +++ b/tests/components/voice_assistant/test_websocket.py @@ -24,7 +24,11 @@ async def test_text_only_pipeline( client = await hass_ws_client(hass) await client.send_json( - {"id": 5, "type": "voice_assistant/run", "intent_input": "Are the lights on?"} + { + "id": 5, + "type": "voice_assistant/run", + "intent_input": "Are the lights on?", + } ) # result @@ -35,7 +39,7 @@ async def test_text_only_pipeline( msg = await client.receive_json() assert msg["event"]["type"] == "run-start" assert msg["event"]["data"] == { - "pipeline": "default", + "pipeline": hass.config.language, "language": hass.config.language, } @@ -83,7 +87,8 @@ async def test_conversation_timeout( await asyncio.sleep(3600) with patch( - "homeassistant.components.conversation.async_converse", new=sleepy_converse + "homeassistant.components.conversation.async_converse", + new=sleepy_converse, ): await client.send_json( { @@ -102,7 +107,7 @@ async def test_conversation_timeout( msg = await client.receive_json() assert msg["event"]["type"] == "run-start" assert msg["event"]["data"] == { - "pipeline": "default", + "pipeline": hass.config.language, "language": hass.config.language, } @@ -130,7 +135,7 @@ async def test_pipeline_timeout( await asyncio.sleep(3600) with patch( - "homeassistant.components.voice_assistant.pipeline.Pipeline._run", + "homeassistant.components.voice_assistant.pipeline.TextPipelineRequest._execute", new=sleepy_run, ): await client.send_json( From 88ad97f112bd1088a767d3d8181bad52541cc561 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Mar 2023 15:12:45 -1000 Subject: [PATCH 1141/2810] Fix generating statistics for time periods smaller than we can measure (#90069) If the time period for the mean/time weighted average was smaller than we can measure (less than one microsecond), generating statistics would fail with a divide by zero error. This is likely only happens if the database schema precision is incorrect. --- homeassistant/components/sensor/recorder.py | 11 +- tests/components/sensor/test_recorder.py | 334 ++++++++++++++++++++ 2 files changed, 344 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index c0df642ed3..21fbf453ac 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -119,7 +119,16 @@ def _time_weighted_average( duration = end - old_start_time accumulated += old_fstate * duration.total_seconds() - return accumulated / (end - start).total_seconds() + period_seconds = (end - start).total_seconds() + if period_seconds == 0: + # If the only state changed that happened was at the exact moment + # at the end of the period, we can't calculate a meaningful average + # so we return 0.0 since it represents a time duration smaller than + # we can measure. This probably means the precision of statistics + # column schema in the database is incorrect but it is actually possible + # to happen if the state change event fired at the exact microsecond + return 0.0 + return accumulated / period_seconds def _get_units(fstates: list[tuple[float, State]]) -> set[str | None]: diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 8881bef8ed..f3e373f5a6 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -193,6 +193,340 @@ def test_compile_hourly_statistics( assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.parametrize( + ( + "device_class", + "state_unit", + "display_unit", + "statistics_unit", + "unit_class", + "mean", + "min", + "max", + ), + [ + ("temperature", "°C", "°C", "°C", "temperature", 27.796610169491526, -10, 60), + ("temperature", "°F", "°F", "°F", "temperature", 27.796610169491526, -10, 60), + ], +) +def test_compile_hourly_statistics_with_some_same_last_updated( + hass_recorder: Callable[..., HomeAssistant], + caplog: pytest.LogCaptureFixture, + device_class, + state_unit, + display_unit, + statistics_unit, + unit_class, + mean, + min, + max, +) -> None: + """Test compiling hourly statistics with the some of the same last updated value. + + If the last updated value is the same we will have a zero duration. + """ + zero = dt_util.utcnow() + hass = hass_recorder() + setup_component(hass, "sensor", {}) + wait_recording_done(hass) # Wait for the sensor recorder platform to be added + entity_id = "sensor.test1" + attributes = { + "device_class": device_class, + "state_class": "measurement", + "unit_of_measurement": state_unit, + } + attributes = dict(attributes) + seq = [-10, 15, 30, 60] + + def set_state(entity_id, state, **kwargs): + """Set the state.""" + hass.states.set(entity_id, state, **kwargs) + wait_recording_done(hass) + return hass.states.get(entity_id) + + one = zero + timedelta(seconds=1 * 5) + two = one + timedelta(seconds=10 * 5) + three = two + timedelta(seconds=40 * 5) + four = three + timedelta(seconds=10 * 5) + + states = {entity_id: []} + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=one + ): + states[entity_id].append( + set_state(entity_id, str(seq[0]), attributes=attributes) + ) + + # Record two states at the exact same time + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=two + ): + states[entity_id].append( + set_state(entity_id, str(seq[1]), attributes=attributes) + ) + states[entity_id].append( + set_state(entity_id, str(seq[2]), attributes=attributes) + ) + + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=three + ): + states[entity_id].append( + set_state(entity_id, str(seq[3]), attributes=attributes) + ) + + hist = history.get_significant_states(hass, zero, four) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + + do_adhoc_statistics(hass, start=zero) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.test1", + "display_unit_of_measurement": display_unit, + "has_mean": True, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": statistics_unit, + "unit_class": unit_class, + } + ] + stats = statistics_during_period(hass, zero, period="5minute") + assert stats == { + "sensor.test1": [ + { + "start": process_timestamp(zero).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(mean), + "min": pytest.approx(min), + "max": pytest.approx(max), + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + +@pytest.mark.parametrize( + ( + "device_class", + "state_unit", + "display_unit", + "statistics_unit", + "unit_class", + "mean", + "min", + "max", + ), + [ + ("temperature", "°C", "°C", "°C", "temperature", 60, -10, 60), + ("temperature", "°F", "°F", "°F", "temperature", 60, -10, 60), + ], +) +def test_compile_hourly_statistics_with_all_same_last_updated( + hass_recorder: Callable[..., HomeAssistant], + caplog: pytest.LogCaptureFixture, + device_class, + state_unit, + display_unit, + statistics_unit, + unit_class, + mean, + min, + max, +) -> None: + """Test compiling hourly statistics with the all of the same last updated value. + + If the last updated value is the same we will have a zero duration. + """ + zero = dt_util.utcnow() + hass = hass_recorder() + setup_component(hass, "sensor", {}) + wait_recording_done(hass) # Wait for the sensor recorder platform to be added + entity_id = "sensor.test1" + attributes = { + "device_class": device_class, + "state_class": "measurement", + "unit_of_measurement": state_unit, + } + attributes = dict(attributes) + seq = [-10, 15, 30, 60] + + def set_state(entity_id, state, **kwargs): + """Set the state.""" + hass.states.set(entity_id, state, **kwargs) + wait_recording_done(hass) + return hass.states.get(entity_id) + + one = zero + timedelta(seconds=1 * 5) + two = one + timedelta(seconds=10 * 5) + three = two + timedelta(seconds=40 * 5) + four = three + timedelta(seconds=10 * 5) + + states = {entity_id: []} + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=two + ): + states[entity_id].append( + set_state(entity_id, str(seq[0]), attributes=attributes) + ) + states[entity_id].append( + set_state(entity_id, str(seq[1]), attributes=attributes) + ) + states[entity_id].append( + set_state(entity_id, str(seq[2]), attributes=attributes) + ) + states[entity_id].append( + set_state(entity_id, str(seq[3]), attributes=attributes) + ) + + hist = history.get_significant_states(hass, zero, four) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + + do_adhoc_statistics(hass, start=zero) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.test1", + "display_unit_of_measurement": display_unit, + "has_mean": True, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": statistics_unit, + "unit_class": unit_class, + } + ] + stats = statistics_during_period(hass, zero, period="5minute") + assert stats == { + "sensor.test1": [ + { + "start": process_timestamp(zero).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(mean), + "min": pytest.approx(min), + "max": pytest.approx(max), + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + +@pytest.mark.parametrize( + ( + "device_class", + "state_unit", + "display_unit", + "statistics_unit", + "unit_class", + "mean", + "min", + "max", + ), + [ + ("temperature", "°C", "°C", "°C", "temperature", 0, 60, 60), + ("temperature", "°F", "°F", "°F", "temperature", 0, 60, 60), + ], +) +def test_compile_hourly_statistics_only_state_is_and_end_of_period( + hass_recorder: Callable[..., HomeAssistant], + caplog: pytest.LogCaptureFixture, + device_class, + state_unit, + display_unit, + statistics_unit, + unit_class, + mean, + min, + max, +) -> None: + """Test compiling hourly statistics when the only state at end of period.""" + zero = dt_util.utcnow() + hass = hass_recorder() + setup_component(hass, "sensor", {}) + wait_recording_done(hass) # Wait for the sensor recorder platform to be added + entity_id = "sensor.test1" + attributes = { + "device_class": device_class, + "state_class": "measurement", + "unit_of_measurement": state_unit, + } + attributes = dict(attributes) + seq = [-10, 15, 30, 60] + + def set_state(entity_id, state, **kwargs): + """Set the state.""" + hass.states.set(entity_id, state, **kwargs) + wait_recording_done(hass) + return hass.states.get(entity_id) + + one = zero + timedelta(seconds=1 * 5) + two = one + timedelta(seconds=10 * 5) + three = two + timedelta(seconds=40 * 5) + four = three + timedelta(seconds=10 * 5) + end = zero + timedelta(minutes=5) + + states = {entity_id: []} + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=end + ): + states[entity_id].append( + set_state(entity_id, str(seq[0]), attributes=attributes) + ) + states[entity_id].append( + set_state(entity_id, str(seq[1]), attributes=attributes) + ) + states[entity_id].append( + set_state(entity_id, str(seq[2]), attributes=attributes) + ) + states[entity_id].append( + set_state(entity_id, str(seq[3]), attributes=attributes) + ) + + hist = history.get_significant_states(hass, zero, four) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + + do_adhoc_statistics(hass, start=zero) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.test1", + "display_unit_of_measurement": display_unit, + "has_mean": True, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": statistics_unit, + "unit_class": unit_class, + } + ] + stats = statistics_during_period(hass, zero, period="5minute") + assert stats == { + "sensor.test1": [ + { + "start": process_timestamp(zero).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(mean), + "min": pytest.approx(min), + "max": pytest.approx(max), + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + @pytest.mark.parametrize( ("device_class", "state_unit", "display_unit", "statistics_unit", "unit_class"), [ From 1439a3d572f4ef3fb9f8a7b674d7dc8c3797f8f1 Mon Sep 17 00:00:00 2001 From: Luke Date: Tue, 21 Mar 2023 23:09:38 -0400 Subject: [PATCH 1142/2810] Bump to oralb-ble 0.17.6 (#90081) --- homeassistant/components/oralb/manifest.json | 3 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/oralb/manifest.json b/homeassistant/components/oralb/manifest.json index 37b043e543..a1071cc0a1 100644 --- a/homeassistant/components/oralb/manifest.json +++ b/homeassistant/components/oralb/manifest.json @@ -11,5 +11,6 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/oralb", "iot_class": "local_push", - "requirements": ["oralb-ble==0.17.5"] + "loggers": ["oralb-ble"], + "requirements": ["oralb-ble==0.17.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5395f1104e..a19f989c58 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1299,7 +1299,7 @@ openwrt-luci-rpc==1.1.11 openwrt-ubus-rpc==0.0.2 # homeassistant.components.oralb -oralb-ble==0.17.5 +oralb-ble==0.17.6 # homeassistant.components.oru oru==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 265ec1eeab..639b7e2d4d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -950,7 +950,7 @@ openai==0.26.2 openerz-api==0.2.0 # homeassistant.components.oralb -oralb-ble==0.17.5 +oralb-ble==0.17.6 # homeassistant.components.ovo_energy ovoenergy==1.2.0 From 96225bb287cabd52791300c21ce06a70886f8f3b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Mar 2023 18:38:33 -1000 Subject: [PATCH 1143/2810] Rename recorder run_history to table_managers.recorder_runs_manager (#90070) --- homeassistant/components/recorder/core.py | 18 +++++++------- .../components/recorder/history/legacy.py | 2 +- .../components/recorder/history/modern.py | 2 +- homeassistant/components/recorder/purge.py | 4 +++- .../recorder/system_health/__init__.py | 6 ++--- .../recorder_runs.py} | 6 ++--- homeassistant/components/recorder/tasks.py | 2 +- .../test_recorder_runs.py} | 24 ++++++++++++------- tests/components/recorder/test_init.py | 4 ++-- tests/components/recorder/test_migrate.py | 2 +- .../components/recorder/test_system_health.py | 20 +++++++++------- 11 files changed, 51 insertions(+), 39 deletions(-) rename homeassistant/components/recorder/{run_history.py => table_managers/recorder_runs.py} (97%) rename tests/components/recorder/{test_run_history.py => table_managers/test_recorder_runs.py} (76%) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 893117f6e3..bbdab2690d 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -80,9 +80,9 @@ from .queries import ( has_events_context_ids_to_migrate, has_states_context_ids_to_migrate, ) -from .run_history import RunHistory from .table_managers.event_data import EventDataManager from .table_managers.event_types import EventTypeManager +from .table_managers.recorder_runs import RecorderRunsManager from .table_managers.state_attributes import StateAttributesManager from .table_managers.states import StatesManager from .table_managers.states_meta import StatesMetaManager @@ -198,7 +198,6 @@ class Recorder(threading.Thread): self.async_recorder_ready = asyncio.Event() self._queue_watch = threading.Event() self.engine: Engine | None = None - self.run_history = RunHistory() # The entity_filter is exposed on the recorder instance so that # it can be used to see if an entity is being recorded and is called @@ -208,6 +207,8 @@ class Recorder(threading.Thread): self.schema_version = 0 self._commits_without_expire = 0 + + self.recorder_runs_manager = RecorderRunsManager() self.states_manager = StatesManager() self.event_data_manager = EventDataManager(self) self.event_type_manager = EventTypeManager(self) @@ -216,6 +217,7 @@ class Recorder(threading.Thread): self, exclude_attributes_by_domain ) self.statistics_meta_manager = StatisticsMetaManager(self) + self.event_session: Session | None = None self._get_session: Callable[[], Session] | None = None self._completed_first_database_setup: bool | None = None @@ -1117,7 +1119,7 @@ class Recorder(threading.Thread): finally: self._close_connection() move_away_broken_database(dburl_to_path(self.db_url)) - self.run_history.reset() + self.recorder_runs_manager.reset() self._setup_recorder() self._setup_run() @@ -1333,8 +1335,8 @@ class Recorder(threading.Thread): def _setup_run(self) -> None: """Log the start of the current run and schedule any needed jobs.""" with session_scope(session=self.get_session()) as session: - end_incomplete_runs(session, self.run_history.recording_start) - self.run_history.start(session) + end_incomplete_runs(session, self.recorder_runs_manager.recording_start) + self.recorder_runs_manager.start(session) self._open_event_session() @@ -1346,15 +1348,15 @@ class Recorder(threading.Thread): """End the recorder session.""" if self.event_session is None: return - if self.run_history.active: - self.run_history.end(self.event_session) + if self.recorder_runs_manager.active: + self.recorder_runs_manager.end(self.event_session) try: self._commit_event_session_or_retry() except Exception as err: # pylint: disable=broad-except _LOGGER.exception("Error saving the event session during shutdown: %s", err) self.event_session.close() - self.run_history.clear() + self.recorder_runs_manager.clear() def _shutdown(self) -> None: """Save end time for current run.""" diff --git a/homeassistant/components/recorder/history/legacy.py b/homeassistant/components/recorder/history/legacy.py index b8f27211c7..e51b1a2568 100644 --- a/homeassistant/components/recorder/history/legacy.py +++ b/homeassistant/components/recorder/history/legacy.py @@ -742,7 +742,7 @@ def _get_rows_with_session( ) if run is None: - run = recorder.get_instance(hass).run_history.get(utc_point_in_time) + run = recorder.get_instance(hass).recorder_runs_manager.get(utc_point_in_time) if run is None or process_timestamp(run.start) > utc_point_in_time: # History did not run before utc_point_in_time diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index d39abd2e9f..50e6102703 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -577,7 +577,7 @@ def _get_rows_with_session( ) if run is None: - run = recorder.get_instance(hass).run_history.get(utc_point_in_time) + run = recorder.get_instance(hass).recorder_runs_manager.get(utc_point_in_time) if run is None or process_timestamp(run.start) > utc_point_in_time: # History did not run before utc_point_in_time diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index fafb7c661a..662be41b1c 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -517,7 +517,9 @@ def _purge_old_recorder_runs( """Purge all old recorder runs.""" # Recorder runs is small, no need to batch run it deleted_rows = session.execute( - delete_recorder_runs_rows(purge_before, instance.run_history.current.run_id) + delete_recorder_runs_rows( + purge_before, instance.recorder_runs_manager.current.run_id + ) ) _LOGGER.debug("Deleted %s recorder_runs", deleted_rows) diff --git a/homeassistant/components/recorder/system_health/__init__.py b/homeassistant/components/recorder/system_health/__init__.py index 76542a0c17..a3545ec2c8 100644 --- a/homeassistant/components/recorder/system_health/__init__.py +++ b/homeassistant/components/recorder/system_health/__init__.py @@ -58,7 +58,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: """Get info for the info page.""" instance = get_instance(hass) - run_history = instance.run_history + recorder_runs_manager = instance.recorder_runs_manager database_name = urlparse(instance.db_url).path.lstrip("/") db_engine_info = _async_get_db_engine_info(instance) db_stats: dict[str, Any] = {} @@ -68,7 +68,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: _get_db_stats, instance, database_name ) db_runs = { - "oldest_recorder_run": run_history.first.start, - "current_recorder_run": run_history.current.start, + "oldest_recorder_run": recorder_runs_manager.first.start, + "current_recorder_run": recorder_runs_manager.current.start, } return db_runs | db_stats | db_engine_info diff --git a/homeassistant/components/recorder/run_history.py b/homeassistant/components/recorder/table_managers/recorder_runs.py similarity index 97% rename from homeassistant/components/recorder/run_history.py rename to homeassistant/components/recorder/table_managers/recorder_runs.py index b424c99999..455c8375b1 100644 --- a/homeassistant/components/recorder/run_history.py +++ b/homeassistant/components/recorder/table_managers/recorder_runs.py @@ -9,8 +9,8 @@ from sqlalchemy.orm.session import Session import homeassistant.util.dt as dt_util -from .db_schema import RecorderRuns -from .models import process_timestamp +from ..db_schema import RecorderRuns +from ..models import process_timestamp def _find_recorder_run_for_start_time( @@ -40,7 +40,7 @@ class _RecorderRunsHistory: runs_by_timestamp: dict[int, RecorderRuns] -class RunHistory: +class RecorderRunsManager: """Track recorder run history.""" def __init__(self) -> None: diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index d3e3c05382..7b8fa4867b 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -113,7 +113,7 @@ class PurgeTask(RecorderTask): instance, self.purge_before, self.repack, self.apply_filter ): with instance.get_session() as session: - instance.run_history.load_from_db(session) + instance.recorder_runs_manager.load_from_db(session) # We always need to do the db cleanups after a purge # is finished to ensure the WAL checkpoint and other # tasks happen after a vacuum. diff --git a/tests/components/recorder/test_run_history.py b/tests/components/recorder/table_managers/test_recorder_runs.py similarity index 76% rename from tests/components/recorder/test_run_history.py rename to tests/components/recorder/table_managers/test_recorder_runs.py index 9c7db9ca1b..2946850ec1 100644 --- a/tests/components/recorder/test_run_history.py +++ b/tests/components/recorder/table_managers/test_recorder_runs.py @@ -1,4 +1,4 @@ -"""Test run history.""" +"""Test recorder runs table manager.""" from datetime import timedelta from unittest.mock import patch @@ -25,29 +25,35 @@ async def test_run_history(recorder_mock: Recorder, hass: HomeAssistant) -> None session.add(RecorderRuns(start=two_days_ago, created=two_days_ago)) session.add(RecorderRuns(start=one_day_ago, created=one_day_ago)) session.commit() - instance.run_history.load_from_db(session) + instance.recorder_runs_manager.load_from_db(session) assert ( process_timestamp( - instance.run_history.get(three_days_ago + timedelta(microseconds=1)).start + instance.recorder_runs_manager.get( + three_days_ago + timedelta(microseconds=1) + ).start ) == three_days_ago ) assert ( process_timestamp( - instance.run_history.get(two_days_ago + timedelta(microseconds=1)).start + instance.recorder_runs_manager.get( + two_days_ago + timedelta(microseconds=1) + ).start ) == two_days_ago ) assert ( process_timestamp( - instance.run_history.get(one_day_ago + timedelta(microseconds=1)).start + instance.recorder_runs_manager.get( + one_day_ago + timedelta(microseconds=1) + ).start ) == one_day_ago ) assert ( - process_timestamp(instance.run_history.get(now).start) - == instance.run_history.recording_start + process_timestamp(instance.recorder_runs_manager.get(now).start) + == instance.recorder_runs_manager.recording_start ) @@ -64,10 +70,10 @@ async def test_run_history_while_recorder_is_not_yet_started( # Prevent the run history from starting to ensure # we can test run_history.current.start returns the expected value with patch( - "homeassistant.components.recorder.run_history.RunHistory.start", + "homeassistant.components.recorder.table_managers.recorder_runs.RecorderRunsManager.start", ): instance = await async_setup_recorder_instance(hass) - run_history = instance.run_history + run_history = instance.recorder_runs_manager assert run_history.current.start == run_history.recording_start def _start_run_history(): diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 48429d7a11..3232b10fdc 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -1551,7 +1551,7 @@ async def test_database_corruption_while_running( await hass.async_block_till_done() caplog.clear() - original_start_time = get_instance(hass).run_history.recording_start + original_start_time = get_instance(hass).recorder_runs_manager.recording_start hass.states.async_set("test.lost", "on", {}) @@ -1599,7 +1599,7 @@ async def test_database_corruption_while_running( assert state.entity_id == "test.two" assert state.state == "on" - new_start_time = get_instance(hass).run_history.recording_start + new_start_time = get_instance(hass).recorder_runs_manager.recording_start assert original_start_time < new_start_time hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index e030ef1629..b23b7a2dfc 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -348,7 +348,7 @@ async def test_schema_migrate( def _mock_setup_run(self): self.run_info = RecorderRuns( - start=self.run_history.recording_start, created=dt_util.utcnow() + start=self.recorder_runs_manager.recording_start, created=dt_util.utcnow() ) def _instrument_migrate_schema(*args): diff --git a/tests/components/recorder/test_system_health.py b/tests/components/recorder/test_system_health.py index 6a8815d499..5adacaf0ab 100644 --- a/tests/components/recorder/test_system_health.py +++ b/tests/components/recorder/test_system_health.py @@ -27,8 +27,8 @@ async def test_recorder_system_health( info = await get_system_health_info(hass, "recorder") instance = get_instance(hass) assert info == { - "current_recorder_run": instance.run_history.current.start, - "oldest_recorder_run": instance.run_history.first.start, + "current_recorder_run": instance.recorder_runs_manager.current.start, + "oldest_recorder_run": instance.recorder_runs_manager.first.start, "estimated_db_size": ANY, "database_engine": SupportedDialect.SQLITE.value, "database_version": ANY, @@ -53,8 +53,8 @@ async def test_recorder_system_health_alternate_dbms( info = await get_system_health_info(hass, "recorder") instance = get_instance(hass) assert info == { - "current_recorder_run": instance.run_history.current.start, - "oldest_recorder_run": instance.run_history.first.start, + "current_recorder_run": instance.recorder_runs_manager.current.start, + "oldest_recorder_run": instance.recorder_runs_manager.first.start, "estimated_db_size": "1.00 MiB", "database_engine": dialect_name.value, "database_version": ANY, @@ -84,8 +84,8 @@ async def test_recorder_system_health_db_url_missing_host( ): info = await get_system_health_info(hass, "recorder") assert info == { - "current_recorder_run": instance.run_history.current.start, - "oldest_recorder_run": instance.run_history.first.start, + "current_recorder_run": instance.recorder_runs_manager.current.start, + "oldest_recorder_run": instance.recorder_runs_manager.first.start, "estimated_db_size": "1.00 MiB", "database_engine": dialect_name.value, "database_version": ANY, @@ -102,14 +102,16 @@ async def test_recorder_system_health_crashed_recorder_runs_table( # This test is specific for SQLite return - with patch("homeassistant.components.recorder.run_history.RunHistory.load_from_db"): + with patch( + "homeassistant.components.recorder.table_managers.recorder_runs.RecorderRunsManager.load_from_db" + ): assert await async_setup_component(hass, "system_health", {}) instance = await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) info = await get_system_health_info(hass, "recorder") assert info == { - "current_recorder_run": instance.run_history.current.start, - "oldest_recorder_run": instance.run_history.current.start, + "current_recorder_run": instance.recorder_runs_manager.current.start, + "oldest_recorder_run": instance.recorder_runs_manager.current.start, "estimated_db_size": ANY, "database_engine": SupportedDialect.SQLITE.value, "database_version": ANY, From d25e3943105b4b1aea985633e1175168c4a04ac9 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 22 Mar 2023 09:18:09 +0100 Subject: [PATCH 1144/2810] Implement data update coordinator for nextcloud (#89652) * implement data update coordinator * apply suggestions * apply suggestions --- .../components/nextcloud/__init__.py | 57 +++------------ .../components/nextcloud/binary_sensor.py | 18 +++-- .../components/nextcloud/coordinator.py | 73 +++++++++++++++++++ homeassistant/components/nextcloud/entity.py | 27 +++---- homeassistant/components/nextcloud/sensor.py | 18 +++-- 5 files changed, 121 insertions(+), 72 deletions(-) create mode 100644 homeassistant/components/nextcloud/coordinator.py diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py index b4080dd2a1..5dffcbf9fb 100644 --- a/homeassistant/components/nextcloud/__init__.py +++ b/homeassistant/components/nextcloud/__init__.py @@ -13,10 +13,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, discovery -from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.typing import ConfigType from .const import DEFAULT_SCAN_INTERVAL, DOMAIN +from .coordinator import NextcloudDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -40,61 +40,28 @@ CONFIG_SCHEMA = vol.Schema( ) -def setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Nextcloud integration.""" - # Fetch Nextcloud Monitor api data conf = config[DOMAIN] try: - ncm = NextcloudMonitor(conf[CONF_URL], conf[CONF_USERNAME], conf[CONF_PASSWORD]) + ncm = await hass.async_add_executor_job( + NextcloudMonitor, conf[CONF_URL], conf[CONF_USERNAME], conf[CONF_PASSWORD] + ) except NextcloudMonitorError: _LOGGER.error("Nextcloud setup failed - Check configuration") return False - hass.data[DOMAIN] = get_data_points(ncm.data) - hass.data[DOMAIN]["instance"] = conf[CONF_URL] + coordinator = NextcloudDataUpdateCoordinator( + hass, + ncm, + conf, + ) + hass.data[DOMAIN] = coordinator - def nextcloud_update(event_time): - """Update data from nextcloud api.""" - try: - ncm.update() - except NextcloudMonitorError: - _LOGGER.error("Nextcloud update failed") - return False - - hass.data[DOMAIN] = get_data_points(ncm.data) - hass.data[DOMAIN]["instance"] = conf[CONF_URL] - - # Update sensors on time interval - track_time_interval(hass, nextcloud_update, conf[CONF_SCAN_INTERVAL]) + await coordinator.async_config_entry_first_refresh() for platform in PLATFORMS: discovery.load_platform(hass, platform, DOMAIN, {}, config) return True - - -# Use recursion to create list of sensors & values based on nextcloud api data -def get_data_points(api_data, key_path="", leaf=False): - """Use Recursion to discover data-points and values. - - Get dictionary of data-points by recursing through dict returned by api until - the dictionary value does not contain another dictionary and use the - resulting path of dictionary keys and resulting value as the name/value - for the data-point. - - returns: dictionary of data-point/values - """ - result = {} - for key, value in api_data.items(): - if isinstance(value, dict): - if leaf: - key_path = f"{key}_" - if not leaf: - key_path += f"{key}_" - leaf = True - result.update(get_data_points(value, key_path, leaf)) - else: - result[f"{DOMAIN}_{key_path}{key}"] = value - leaf = False - return result diff --git a/homeassistant/components/nextcloud/binary_sensor.py b/homeassistant/components/nextcloud/binary_sensor.py index 6e0df919f9..52ddb66007 100644 --- a/homeassistant/components/nextcloud/binary_sensor.py +++ b/homeassistant/components/nextcloud/binary_sensor.py @@ -7,6 +7,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN +from .coordinator import NextcloudDataUpdateCoordinator from .entity import NextcloudEntity BINARY_SENSORS = ( @@ -26,11 +27,16 @@ def setup_platform( """Set up the Nextcloud sensors.""" if discovery_info is None: return - binary_sensors = [] - for name in hass.data[DOMAIN]: - if name in BINARY_SENSORS: - binary_sensors.append(NextcloudBinarySensor(name)) - add_entities(binary_sensors, True) + coordinator: NextcloudDataUpdateCoordinator = hass.data[DOMAIN] + + add_entities( + [ + NextcloudBinarySensor(coordinator, name) + for name in coordinator.data + if name in BINARY_SENSORS + ], + True, + ) class NextcloudBinarySensor(NextcloudEntity, BinarySensorEntity): @@ -39,4 +45,4 @@ class NextcloudBinarySensor(NextcloudEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" - return self._state == "yes" + return self.coordinator.data.get(self.item) == "yes" diff --git a/homeassistant/components/nextcloud/coordinator.py b/homeassistant/components/nextcloud/coordinator.py new file mode 100644 index 0000000000..07dc76d41d --- /dev/null +++ b/homeassistant/components/nextcloud/coordinator.py @@ -0,0 +1,73 @@ +"""Data update coordinator for the Nextcloud integration.""" + +import logging +from typing import Any + +from nextcloudmonitor import NextcloudMonitor, NextcloudMonitorError + +from homeassistant.const import CONF_SCAN_INTERVAL, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class NextcloudDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Nextcloud data update coordinator.""" + + def __init__( + self, hass: HomeAssistant, ncm: NextcloudMonitor, config: ConfigType + ) -> None: + """Initialize the Nextcloud coordinator.""" + self.config = config + self.ncm = ncm + self.url = config[CONF_URL] + + super().__init__( + hass, + _LOGGER, + name=self.url, + update_interval=config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL), + ) + + # Use recursion to create list of sensors & values based on nextcloud api data + def _get_data_points( + self, api_data: dict, key_path: str = "", leaf: bool = False + ) -> dict[str, Any]: + """Use Recursion to discover data-points and values. + + Get dictionary of data-points by recursing through dict returned by api until + the dictionary value does not contain another dictionary and use the + resulting path of dictionary keys and resulting value as the name/value + for the data-point. + + returns: dictionary of data-point/values + """ + result = {} + for key, value in api_data.items(): + if isinstance(value, dict): + if leaf: + key_path = f"{key}_" + if not leaf: + key_path += f"{key}_" + leaf = True + result.update(self._get_data_points(value, key_path, leaf)) + else: + result[f"{DOMAIN}_{key_path}{key}"] = value + leaf = False + return result + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch all Nextcloud data.""" + + def _update_data() -> None: + try: + self.ncm.update() + except NextcloudMonitorError as ex: + raise UpdateFailed from ex + + await self.hass.async_add_executor_job(_update_data) + return self._get_data_points(self.ncm.data) diff --git a/homeassistant/components/nextcloud/entity.py b/homeassistant/components/nextcloud/entity.py index cb066e0fcf..54976351dd 100644 --- a/homeassistant/components/nextcloud/entity.py +++ b/homeassistant/components/nextcloud/entity.py @@ -1,26 +1,23 @@ """Base entity for the Nextcloud integration.""" -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.typing import StateType - -from .const import DOMAIN -class NextcloudEntity(Entity): +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import NextcloudDataUpdateCoordinator + + +class NextcloudEntity(CoordinatorEntity[NextcloudDataUpdateCoordinator]): """Base Nextcloud entity.""" _attr_icon = "mdi:cloud" - def __init__(self, item: str) -> None: - """Initialize the Nextcloud entity.""" - self._attr_name = item + def __init__(self, coordinator: NextcloudDataUpdateCoordinator, item: str) -> None: + """Initialize the Nextcloud sensor.""" + super().__init__(coordinator) self.item = item - self._state: StateType = None + self._attr_name = item @property - def unique_id(self): + def unique_id(self) -> str: """Return the unique ID for this sensor.""" - return f"{self.hass.data[DOMAIN]['instance']}#{self.item}" - - def update(self) -> None: - """Update the sensor.""" - self._state = self.hass.data[DOMAIN][self.item] + return f"{self.coordinator.url}#{self.item}" diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py index 91d4411b0c..459f22d30e 100644 --- a/homeassistant/components/nextcloud/sensor.py +++ b/homeassistant/components/nextcloud/sensor.py @@ -7,6 +7,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from .const import DOMAIN +from .coordinator import NextcloudDataUpdateCoordinator from .entity import NextcloudEntity SENSORS = ( @@ -65,11 +66,16 @@ def setup_platform( """Set up the Nextcloud sensors.""" if discovery_info is None: return - sensors = [] - for name in hass.data[DOMAIN]: - if name in SENSORS: - sensors.append(NextcloudSensor(name)) - add_entities(sensors, True) + coordinator: NextcloudDataUpdateCoordinator = hass.data[DOMAIN] + + add_entities( + [ + NextcloudSensor(coordinator, name) + for name in coordinator.data + if name in SENSORS + ], + True, + ) class NextcloudSensor(NextcloudEntity, SensorEntity): @@ -78,4 +84,4 @@ class NextcloudSensor(NextcloudEntity, SensorEntity): @property def native_value(self) -> StateType: """Return the state for this sensor.""" - return self._state + return self.coordinator.data.get(self.item) From 214286acb93a07e95ae4094ba0499d2aa5c946cf Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 22 Mar 2023 10:23:08 +0100 Subject: [PATCH 1145/2810] Prepare MQTT platorm tests part1 (#90051) * Add help_custom_config * Tests alarm_control_panel * Tests binary_sensor * Only use help_custom_config with iterable options --- .../mqtt/test_alarm_control_panel.py | 323 ++++++++------- tests/components/mqtt/test_binary_sensor.py | 382 ++++++++++-------- tests/components/mqtt/test_common.py | 24 ++ 3 files changed, 410 insertions(+), 319 deletions(-) diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index a7e5678a3b..79c06d7a5f 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -35,9 +35,9 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -204,17 +204,12 @@ async def test_update_state_via_state_topic( assert hass.states.get(entity_id).state == state +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_ignore_update_state_if_unknown_via_state_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test ignoring updates via state topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - DEFAULT_CONFIG, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() entity_id = "alarm_control_panel.test" @@ -225,31 +220,25 @@ async def test_ignore_update_state_if_unknown_via_state_topic( @pytest.mark.parametrize( - ("service", "payload"), + ("hass_config", "service", "payload"), [ - (SERVICE_ALARM_ARM_HOME, "ARM_HOME"), - (SERVICE_ALARM_ARM_AWAY, "ARM_AWAY"), - (SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT"), - (SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), - (SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS"), - (SERVICE_ALARM_DISARM, "DISARM"), - (SERVICE_ALARM_TRIGGER, "TRIGGER"), + (DEFAULT_CONFIG, SERVICE_ALARM_ARM_HOME, "ARM_HOME"), + (DEFAULT_CONFIG, SERVICE_ALARM_ARM_AWAY, "ARM_AWAY"), + (DEFAULT_CONFIG, SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT"), + (DEFAULT_CONFIG, SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), + (DEFAULT_CONFIG, SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS"), + (DEFAULT_CONFIG, SERVICE_ALARM_DISARM, "DISARM"), + (DEFAULT_CONFIG, SERVICE_ALARM_TRIGGER, "TRIGGER"), ], ) async def test_publish_mqtt_no_code( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, service, payload, ) -> None: """Test publishing of MQTT messages when no code is configured.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - DEFAULT_CONFIG, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( alarm_control_panel.DOMAIN, @@ -262,31 +251,25 @@ async def test_publish_mqtt_no_code( @pytest.mark.parametrize( - ("service", "payload"), + ("hass_config", "service", "payload"), [ - (SERVICE_ALARM_ARM_HOME, "ARM_HOME"), - (SERVICE_ALARM_ARM_AWAY, "ARM_AWAY"), - (SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT"), - (SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), - (SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS"), - (SERVICE_ALARM_DISARM, "DISARM"), - (SERVICE_ALARM_TRIGGER, "TRIGGER"), + (DEFAULT_CONFIG_CODE, SERVICE_ALARM_ARM_HOME, "ARM_HOME"), + (DEFAULT_CONFIG_CODE, SERVICE_ALARM_ARM_AWAY, "ARM_AWAY"), + (DEFAULT_CONFIG_CODE, SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT"), + (DEFAULT_CONFIG_CODE, SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), + (DEFAULT_CONFIG_CODE, SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS"), + (DEFAULT_CONFIG_CODE, SERVICE_ALARM_DISARM, "DISARM"), + (DEFAULT_CONFIG_CODE, SERVICE_ALARM_TRIGGER, "TRIGGER"), ], ) async def test_publish_mqtt_with_code( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, service, payload, ) -> None: """Test publishing of MQTT messages when code is configured.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - DEFAULT_CONFIG_CODE, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() call_count = mqtt_mock.async_publish.call_count # No code provided, should not publish @@ -318,31 +301,29 @@ async def test_publish_mqtt_with_code( @pytest.mark.parametrize( - ("service", "payload"), + ("hass_config", "service", "payload"), [ - (SERVICE_ALARM_ARM_HOME, "ARM_HOME"), - (SERVICE_ALARM_ARM_AWAY, "ARM_AWAY"), - (SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT"), - (SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), - (SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS"), - (SERVICE_ALARM_DISARM, "DISARM"), - (SERVICE_ALARM_TRIGGER, "TRIGGER"), + (DEFAULT_CONFIG_REMOTE_CODE, SERVICE_ALARM_ARM_HOME, "ARM_HOME"), + (DEFAULT_CONFIG_REMOTE_CODE, SERVICE_ALARM_ARM_AWAY, "ARM_AWAY"), + (DEFAULT_CONFIG_REMOTE_CODE, SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT"), + (DEFAULT_CONFIG_REMOTE_CODE, SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), + ( + DEFAULT_CONFIG_REMOTE_CODE, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + "ARM_CUSTOM_BYPASS", + ), + (DEFAULT_CONFIG_REMOTE_CODE, SERVICE_ALARM_DISARM, "DISARM"), + (DEFAULT_CONFIG_REMOTE_CODE, SERVICE_ALARM_TRIGGER, "TRIGGER"), ], ) async def test_publish_mqtt_with_remote_code( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, service, payload, ) -> None: """Test publishing of MQTT messages when remode code is configured.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - DEFAULT_CONFIG_REMOTE_CODE, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() call_count = mqtt_mock.async_publish.call_count # No code provided, should not publish @@ -365,31 +346,29 @@ async def test_publish_mqtt_with_remote_code( @pytest.mark.parametrize( - ("service", "payload"), + ("hass_config", "service", "payload"), [ - (SERVICE_ALARM_ARM_HOME, "ARM_HOME"), - (SERVICE_ALARM_ARM_AWAY, "ARM_AWAY"), - (SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT"), - (SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), - (SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS"), - (SERVICE_ALARM_DISARM, "DISARM"), - (SERVICE_ALARM_TRIGGER, "TRIGGER"), + (DEFAULT_CONFIG_REMOTE_CODE_TEXT, SERVICE_ALARM_ARM_HOME, "ARM_HOME"), + (DEFAULT_CONFIG_REMOTE_CODE_TEXT, SERVICE_ALARM_ARM_AWAY, "ARM_AWAY"), + (DEFAULT_CONFIG_REMOTE_CODE_TEXT, SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT"), + (DEFAULT_CONFIG_REMOTE_CODE_TEXT, SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), + ( + DEFAULT_CONFIG_REMOTE_CODE_TEXT, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + "ARM_CUSTOM_BYPASS", + ), + (DEFAULT_CONFIG_REMOTE_CODE_TEXT, SERVICE_ALARM_DISARM, "DISARM"), + (DEFAULT_CONFIG_REMOTE_CODE_TEXT, SERVICE_ALARM_TRIGGER, "TRIGGER"), ], ) async def test_publish_mqtt_with_remote_code_text( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - service, - payload, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + service: str, + payload: str, ) -> None: """Test publishing of MQTT messages when remote text code is configured.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - DEFAULT_CONFIG_REMOTE_CODE_TEXT, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() call_count = mqtt_mock.async_publish.call_count # No code provided, should not publish @@ -412,38 +391,85 @@ async def test_publish_mqtt_with_remote_code_text( @pytest.mark.parametrize( - ("service", "payload", "disable_code"), + ("hass_config", "service", "payload"), [ - (SERVICE_ALARM_ARM_HOME, "ARM_HOME", "code_arm_required"), - (SERVICE_ALARM_ARM_AWAY, "ARM_AWAY", "code_arm_required"), - (SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT", "code_arm_required"), - (SERVICE_ALARM_ARM_VACATION, "ARM_VACATION", "code_arm_required"), - (SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS", "code_arm_required"), - (SERVICE_ALARM_DISARM, "DISARM", "code_disarm_required"), - (SERVICE_ALARM_TRIGGER, "TRIGGER", "code_trigger_required"), + ( + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG_CODE, + ({"code_arm_required": False},), + ), + SERVICE_ALARM_ARM_HOME, + "ARM_HOME", + ), + ( + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG_CODE, + ({"code_arm_required": False},), + ), + SERVICE_ALARM_ARM_AWAY, + "ARM_AWAY", + ), + ( + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG_CODE, + ({"code_arm_required": False},), + ), + SERVICE_ALARM_ARM_NIGHT, + "ARM_NIGHT", + ), + ( + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG_CODE, + ({"code_arm_required": False},), + ), + SERVICE_ALARM_ARM_VACATION, + "ARM_VACATION", + ), + ( + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG_CODE, + ({"code_arm_required": False},), + ), + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + "ARM_CUSTOM_BYPASS", + ), + ( + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG_CODE, + ({"code_disarm_required": False},), + ), + SERVICE_ALARM_DISARM, + "DISARM", + ), + ( + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG_CODE, + ({"code_trigger_required": False},), + ), + SERVICE_ALARM_TRIGGER, + "TRIGGER", + ), ], ) async def test_publish_mqtt_with_code_required_false( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - service, - payload, - disable_code, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + service: str, + payload: str, ) -> None: """Test publishing of MQTT messages when code is configured. code_arm_required = False / code_disarm_required = False / code_trigger_required = False """ - config = copy.deepcopy(DEFAULT_CONFIG_CODE) - config[mqtt.DOMAIN][alarm_control_panel.DOMAIN][disable_code] = False - assert await async_setup_component( - hass, - mqtt.DOMAIN, - config, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() # No code provided, should publish await hass.services.async_call( @@ -476,25 +502,29 @@ async def test_publish_mqtt_with_code_required_false( mqtt_mock.reset_mock() +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG_CODE, + ( + { + "code": "0123", + "command_template": '{"action":"{{ action }}","code":"{{ code }}"}', + }, + ), + ) + ], +) async def test_disarm_publishes_mqtt_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test publishing of MQTT messages while disarmed. When command_template set to output json """ - config = copy.deepcopy(DEFAULT_CONFIG_CODE) - config[mqtt.DOMAIN][alarm_control_panel.DOMAIN]["code"] = "0123" - config[mqtt.DOMAIN][alarm_control_panel.DOMAIN][ - "command_template" - ] = '{"action":"{{ action }}","code":"{{ code }}"}' - assert await async_setup_component( - hass, - mqtt.DOMAIN, - config, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await common.async_alarm_disarm(hass, "0123") mqtt_mock.async_publish.assert_called_once_with( @@ -502,13 +532,9 @@ async def test_disarm_publishes_mqtt_with_template( ) -async def test_update_state_via_state_topic_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test updating with template_value via state topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { alarm_control_panel.DOMAIN: { @@ -523,10 +549,14 @@ async def test_update_state_via_state_topic_template( {% endif %}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_update_state_via_state_topic_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test updating with template_value via state topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("alarm_control_panel.test") assert state.state == STATE_UNKNOWN @@ -537,16 +567,19 @@ async def test_update_state_via_state_topic_template( assert state.state == STATE_ALARM_ARMED_AWAY +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + alarm_control_panel.DOMAIN, DEFAULT_CONFIG, ({"code": CODE_NUMBER},) + ) + ], +) async def test_attributes_code_number( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test attributes which are not supported by the vacuum.""" - config = copy.deepcopy(DEFAULT_CONFIG) - config[mqtt.DOMAIN][alarm_control_panel.DOMAIN]["code"] = CODE_NUMBER - - assert await async_setup_component(hass, mqtt.DOMAIN, config) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("alarm_control_panel.test") assert ( @@ -555,16 +588,21 @@ async def test_attributes_code_number( ) +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG_REMOTE_CODE, + ({"code": "REMOTE_CODE"},), + ) + ], +) async def test_attributes_remote_code_number( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test attributes which are not supported by the vacuum.""" - config = copy.deepcopy(DEFAULT_CONFIG_REMOTE_CODE) - config[mqtt.DOMAIN][alarm_control_panel.DOMAIN]["code"] = "REMOTE_CODE" - - assert await async_setup_component(hass, mqtt.DOMAIN, config) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("alarm_control_panel.test") assert ( @@ -573,16 +611,19 @@ async def test_attributes_remote_code_number( ) +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + alarm_control_panel.DOMAIN, DEFAULT_CONFIG, ({"code": CODE_TEXT},) + ) + ], +) async def test_attributes_code_text( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test attributes which are not supported by the vacuum.""" - config = copy.deepcopy(DEFAULT_CONFIG) - config[mqtt.DOMAIN][alarm_control_panel.DOMAIN]["code"] = CODE_TEXT - - assert await async_setup_component(hass, mqtt.DOMAIN, config) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("alarm_control_panel.test") assert ( diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index 3e224a4136..0d3cd69549 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -19,10 +19,11 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, State, callback -from homeassistant.setup import async_setup_component +from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -74,15 +75,9 @@ def binary_sensor_platform_only(): yield -async def test_setting_sensor_value_expires_availability_topic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the expiration of the value.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -93,10 +88,16 @@ async def test_setting_sensor_value_expires_availability_topic( "availability_topic": "availability-topic", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_value_expires_availability_topic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the expiration of the value.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNAVAILABLE @@ -110,15 +111,9 @@ async def test_setting_sensor_value_expires_availability_topic( await expires_helper(hass) -async def test_setting_sensor_value_expires( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the expiration of the value.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -128,10 +123,16 @@ async def test_setting_sensor_value_expires( "force_update": True, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_value_expires( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the expiration of the value.""" + await mqtt_mock_entry_no_yaml_config() # State should be unavailable since expire_after is defined and > 0 state = hass.states.get("binary_sensor.test") @@ -274,13 +275,9 @@ async def test_expiration_on_discovery_and_discovery_update_of_binary_sensor( assert state.state == STATE_UNAVAILABLE -async def test_setting_sensor_value_via_mqtt_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the setting of the value via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -290,10 +287,14 @@ async def test_setting_sensor_value_via_mqtt_message( "payload_off": "OFF", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_value_via_mqtt_message( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the setting of the value via MQTT.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("binary_sensor.test") @@ -312,15 +313,9 @@ async def test_setting_sensor_value_via_mqtt_message( assert state.state == STATE_UNKNOWN -async def test_invalid_sensor_value_via_mqtt_message( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the setting of the value via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -330,10 +325,16 @@ async def test_invalid_sensor_value_via_mqtt_message( "payload_off": "OFF", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_invalid_sensor_value_via_mqtt_message( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the setting of the value via MQTT.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("binary_sensor.test") @@ -356,13 +357,9 @@ async def test_invalid_sensor_value_via_mqtt_message( assert "No matching payload found for entity" in caplog.text -async def test_setting_sensor_value_via_mqtt_message_and_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the setting of the value via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -374,10 +371,14 @@ async def test_setting_sensor_value_via_mqtt_message_and_template( "{%-else-%}ON{%-endif%}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_value_via_mqtt_message_and_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the setting of the value via MQTT.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNKNOWN @@ -391,15 +392,9 @@ async def test_setting_sensor_value_via_mqtt_message_and_template( assert state.state == STATE_OFF -async def test_setting_sensor_value_via_mqtt_message_and_template2( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the setting of the value via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -411,9 +406,15 @@ async def test_setting_sensor_value_via_mqtt_message_and_template2( } } }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + ], +) +async def test_setting_sensor_value_via_mqtt_message_and_template2( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the setting of the value via MQTT.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNKNOWN @@ -432,15 +433,9 @@ async def test_setting_sensor_value_via_mqtt_message_and_template2( assert "template output: 'ILLEGAL'" in caplog.text -async def test_setting_sensor_value_via_mqtt_message_and_template_and_raw_state_encoding( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test processing a raw value via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -452,10 +447,16 @@ async def test_setting_sensor_value_via_mqtt_message_and_template_and_raw_state_ "value_template": "{%if value|unpack('b')-%}ON{%else%}OFF{%-endif-%}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_value_via_mqtt_message_and_template_and_raw_state_encoding( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test processing a raw value via MQTT.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNKNOWN @@ -469,13 +470,9 @@ async def test_setting_sensor_value_via_mqtt_message_and_template_and_raw_state_ assert state.state == STATE_OFF -async def test_setting_sensor_value_via_mqtt_message_empty_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the setting of the value via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -486,10 +483,14 @@ async def test_setting_sensor_value_via_mqtt_message_empty_template( "value_template": '{%if value == "ABC"%}ON{%endif%}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_value_via_mqtt_message_empty_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the setting of the value via MQTT.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNKNOWN @@ -503,13 +504,9 @@ async def test_setting_sensor_value_via_mqtt_message_empty_template( assert state.state == STATE_ON -async def test_valid_device_class( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the setting of a valid sensor class.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -518,22 +515,22 @@ async def test_valid_device_class( "state_topic": "test-topic", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_valid_device_class( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the setting of a valid sensor class.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("binary_sensor.test") assert state.attributes.get("device_class") == "motion" -async def test_invalid_device_class( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test the setting of an invalid sensor class.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -542,8 +539,17 @@ async def test_invalid_device_class( "state_topic": "test-topic", } } - }, - ) + } + ], +) +async def test_invalid_device_class( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test the setting of an invalid sensor class.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert "Invalid config for [mqtt]: expected BinarySensorDeviceClass" in caplog.text @@ -585,13 +591,9 @@ async def test_custom_availability_payload( ) -async def test_force_update_disabled( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test force update option.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -601,10 +603,14 @@ async def test_force_update_disabled( "payload_off": "OFF", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_force_update_disabled( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test force update option.""" + await mqtt_mock_entry_no_yaml_config() events = [] @@ -624,13 +630,9 @@ async def test_force_update_disabled( assert len(events) == 1 -async def test_force_update_enabled( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test force update option.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -641,10 +643,14 @@ async def test_force_update_enabled( "force_update": True, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_force_update_enabled( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test force update option.""" + await mqtt_mock_entry_no_yaml_config() events = [] @@ -664,13 +670,9 @@ async def test_force_update_enabled( assert len(events) == 2 -async def test_off_delay( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test off_delay option.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -682,10 +684,14 @@ async def test_off_delay( "force_update": True, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_off_delay( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test off_delay option.""" + await mqtt_mock_entry_no_yaml_config() events = [] @@ -1068,40 +1074,54 @@ async def test_reloadable( @pytest.mark.parametrize( - ("payload1", "state1", "payload2", "state2"), - [("ON", "on", "OFF", "off"), ("OFF", "off", "ON", "on")], + ("hass_config", "payload1", "state1", "payload2", "state2"), + [ + ( + help_custom_config( + binary_sensor.DOMAIN, + DEFAULT_CONFIG, + ( + {"name": "test1", "expire_after": 30, "state_topic": "test-topic1"}, + {"name": "test2", "expire_after": 5, "state_topic": "test-topic2"}, + ), + ), + "ON", + "on", + "OFF", + "off", + ), + ( + help_custom_config( + binary_sensor.DOMAIN, + DEFAULT_CONFIG, + ( + {"name": "test1", "expire_after": 30, "state_topic": "test-topic1"}, + {"name": "test2", "expire_after": 5, "state_topic": "test-topic2"}, + ), + ), + "OFF", + "off", + "ON", + "on", + ), + ], ) async def test_cleanup_triggers_and_restoring_state( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, tmp_path: Path, freezer: FrozenDateTimeFactory, + hass_config: ConfigType, payload1, state1, payload2, state2, ) -> None: """Test cleanup old triggers at reloading and restoring the state.""" - domain = binary_sensor.DOMAIN - config1 = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][domain]) - config1["name"] = "test1" - config1["expire_after"] = 30 - config1["state_topic"] = "test-topic1" - config2 = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][domain]) - config2["name"] = "test2" - config2["expire_after"] = 5 - config2["state_topic"] = "test-topic2" - freezer.move_to("2022-02-02 12:01:00+01:00") - assert await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {binary_sensor.DOMAIN: [config1, config2]}}, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "test-topic1", payload1) state = hass.states.get("binary_sensor.test1") @@ -1114,7 +1134,7 @@ async def test_cleanup_triggers_and_restoring_state( freezer.move_to("2022-02-02 12:01:10+01:00") await help_test_reload_with_config( - hass, caplog, tmp_path, {mqtt.DOMAIN: {domain: [config1, config2]}} + hass, caplog, tmp_path, {mqtt.DOMAIN: hass_config} ) state = hass.states.get("binary_sensor.test1") @@ -1132,9 +1152,19 @@ async def test_cleanup_triggers_and_restoring_state( assert state.state == state2 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + binary_sensor.DOMAIN, + DEFAULT_CONFIG, + ({"name": "test3", "expire_after": 10, "state_topic": "test-topic3"},), + ) + ], +) async def test_skip_restoring_state_with_over_due_expire_trigger( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, freezer: FrozenDateTimeFactory, ) -> None: """Test restoring a state with over due expire timer.""" @@ -1153,11 +1183,7 @@ async def test_skip_restoring_state_with_over_due_expire_trigger( ) mock_restore_cache(hass, (fake_state,)) - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {domain: config3}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("binary_sensor.test3") assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index f5a4648e34..6d238a63f4 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -1,4 +1,5 @@ """Common test objects.""" +from collections.abc import Iterable from contextlib import suppress import copy from datetime import datetime @@ -119,6 +120,29 @@ async def help_setup_component( return mqtt_mock +def help_custom_config( + mqtt_entity_domain: str, + mqtt_base_config: ConfigType, + mqtt_entity_configs: Iterable[ConfigType,], +) -> ConfigType: + """Tweak a default config for parametrization. + + Returns a custom config to be used as parametrization for with hass_config, + based on the supplied mqtt_base_config and updated with mqtt_entity_configs. + For each item in mqtt_entity_configs an entity instance is added to the config. + """ + config: ConfigType = copy.deepcopy(mqtt_base_config) + entity_instances: list[ConfigType] = [] + for instance in mqtt_entity_configs: + base: ConfigType = copy.deepcopy( + mqtt_base_config[mqtt.DOMAIN][mqtt_entity_domain] + ) + base.update(instance) + entity_instances.append(base) + config[mqtt.DOMAIN][mqtt_entity_domain]: list[ConfigType] = entity_instances + return config + + async def help_test_availability_when_connection_lost( hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, From 7efe058aa6d6d607f87982b9c152c02eda05728f Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Wed, 22 Mar 2023 10:46:17 +0100 Subject: [PATCH 1146/2810] Bump easyEnergy to v0.2.2 (#90080) --- homeassistant/components/easyenergy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json index fc0a4fd773..0954269628 100644 --- a/homeassistant/components/easyenergy/manifest.json +++ b/homeassistant/components/easyenergy/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/easyenergy", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["easyenergy==0.2.1"] + "requirements": ["easyenergy==0.2.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index a19f989c58..0bfd27361c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -625,7 +625,7 @@ dynalite_devices==0.1.47 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==0.2.1 +easyenergy==0.2.2 # homeassistant.components.ebusd ebusdpy==0.0.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 639b7e2d4d..7a02110f54 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -493,7 +493,7 @@ dynalite_devices==0.1.47 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==0.2.1 +easyenergy==0.2.2 # homeassistant.components.elgato elgato==4.0.1 From 87e6dd3949872a76173e2d3e3f6ecdea27c6d263 Mon Sep 17 00:00:00 2001 From: Matrix Date: Wed, 22 Mar 2023 19:01:04 +0800 Subject: [PATCH 1147/2810] YoLink flexfob support (#90027) --- homeassistant/components/yolink/__init__.py | 39 +++- homeassistant/components/yolink/const.py | 1 + .../components/yolink/device_trigger.py | 88 +++++++++ homeassistant/components/yolink/sensor.py | 5 +- homeassistant/components/yolink/strings.json | 12 ++ .../components/yolink/test_device_trigger.py | 169 ++++++++++++++++++ 6 files changed, 308 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/yolink/device_trigger.py create mode 100644 tests/components/yolink/test_device_trigger.py diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index 7362a09609..c10cc8158e 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -7,6 +7,7 @@ from datetime import timedelta from typing import Any import async_timeout +from yolink.const import ATTR_DEVICE_SMART_REMOTER from yolink.device import YoLinkDevice from yolink.exception import YoLinkAuthFailError, YoLinkClientError from yolink.home_manager import YoLinkHome @@ -16,11 +17,16 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow +from homeassistant.helpers import ( + aiohttp_client, + config_entry_oauth2_flow, + device_registry as dr, +) from . import api -from .const import DOMAIN +from .const import DOMAIN, YOLINK_EVENT from .coordinator import YoLinkCoordinator +from .device_trigger import CONF_LONG_PRESS, CONF_SHORT_PRESS SCAN_INTERVAL = timedelta(minutes=5) @@ -53,9 +59,32 @@ class YoLinkHomeMessageListener(MessageListener): device_coordinators = entry_data.device_coordinators if not device_coordinators: return - device_coordiantor = device_coordinators.get(device.device_id) - if device_coordiantor is not None: - device_coordiantor.async_set_updated_data(msg_data) + device_coordinator = device_coordinators.get(device.device_id) + if device_coordinator is None: + return + device_coordinator.async_set_updated_data(msg_data) + # handling events + if ( + device_coordinator.device.device_type == ATTR_DEVICE_SMART_REMOTER + and msg_data.get("event") is not None + ): + device_registry = dr.async_get(self._hass) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, device_coordinator.device.device_id)} + ) + if device_entry is None: + return + key_press_type = None + if msg_data["event"]["type"] == "Press": + key_press_type = CONF_SHORT_PRESS + else: + key_press_type = CONF_LONG_PRESS + button_idx = msg_data["event"]["keyMask"] + event_data = { + "type": f"button_{button_idx}_{key_press_type}", + "device_id": device_entry.id, + } + self._hass.bus.async_fire(YOLINK_EVENT, event_data) @dataclass diff --git a/homeassistant/components/yolink/const.py b/homeassistant/components/yolink/const.py index 61cbc8b302..935889a036 100644 --- a/homeassistant/components/yolink/const.py +++ b/homeassistant/components/yolink/const.py @@ -7,3 +7,4 @@ ATTR_DEVICE_TYPE = "type" ATTR_DEVICE_NAME = "name" ATTR_DEVICE_STATE = "state" ATTR_DEVICE_ID = "deviceId" +YOLINK_EVENT = f"{DOMAIN}_event" diff --git a/homeassistant/components/yolink/device_trigger.py b/homeassistant/components/yolink/device_trigger.py new file mode 100644 index 0000000000..aac860c6a2 --- /dev/null +++ b/homeassistant/components/yolink/device_trigger.py @@ -0,0 +1,88 @@ +"""Provides device triggers for YoLink.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol +from yolink.const import ATTR_DEVICE_SMART_REMOTER + +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.homeassistant.triggers import event as event_trigger +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.typing import ConfigType + +from . import DOMAIN, YOLINK_EVENT + +CONF_BUTTON_1 = "button_1" +CONF_BUTTON_2 = "button_2" +CONF_BUTTON_3 = "button_3" +CONF_BUTTON_4 = "button_4" +CONF_SHORT_PRESS = "short_press" +CONF_LONG_PRESS = "long_press" + +REMOTE_TRIGGER_TYPES = { + f"{CONF_BUTTON_1}_{CONF_SHORT_PRESS}", + f"{CONF_BUTTON_1}_{CONF_LONG_PRESS}", + f"{CONF_BUTTON_2}_{CONF_SHORT_PRESS}", + f"{CONF_BUTTON_2}_{CONF_LONG_PRESS}", + f"{CONF_BUTTON_3}_{CONF_SHORT_PRESS}", + f"{CONF_BUTTON_3}_{CONF_LONG_PRESS}", + f"{CONF_BUTTON_4}_{CONF_SHORT_PRESS}", + f"{CONF_BUTTON_4}_{CONF_LONG_PRESS}", +} + +TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + {vol.Required(CONF_TYPE): vol.In(REMOTE_TRIGGER_TYPES)} +) + + +# YoLink Remotes YS3604/YS3605/YS3606/YS3607 +DEVICE_TRIGGER_TYPES: dict[str, set[str]] = { + ATTR_DEVICE_SMART_REMOTER: REMOTE_TRIGGER_TYPES, +} + + +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: + """List device triggers for YoLink devices.""" + device_registry = dr.async_get(hass) + registry_device = device_registry.async_get(device_id) + if not registry_device or registry_device.model != ATTR_DEVICE_SMART_REMOTER: + return [] + + triggers = [] + for trigger in DEVICE_TRIGGER_TYPES[ATTR_DEVICE_SMART_REMOTER]: + triggers.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_PLATFORM: "device", + CONF_TYPE: trigger, + } + ) + return triggers + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, +) -> CALLBACK_TYPE: + """Listen for state changes based on configuration.""" + event_config = { + event_trigger.CONF_PLATFORM: "event", + event_trigger.CONF_EVENT_TYPE: YOLINK_EVENT, + event_trigger.CONF_EVENT_DATA: { + CONF_DEVICE_ID: config[CONF_DEVICE_ID], + CONF_TYPE: config[CONF_TYPE], + }, + } + event_config = event_trigger.TRIGGER_SCHEMA(event_config) + return await event_trigger.async_attach_trigger( + hass, event_config, action, trigger_info, platform_type="device" + ) diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index 4850df4a26..5f89f54ccb 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -1,4 +1,4 @@ -"""YoLink Binary Sensor.""" +"""YoLink Sensor.""" from __future__ import annotations from collections.abc import Callable @@ -15,6 +15,7 @@ from yolink.const import ( ATTR_DEVICE_MULTI_OUTLET, ATTR_DEVICE_OUTLET, ATTR_DEVICE_SIREN, + ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_SWITCH, ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_THERMOSTAT, @@ -68,6 +69,7 @@ SENSOR_DEVICE_TYPE = [ ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_MOTION_SENSOR, ATTR_DEVICE_MULTI_OUTLET, + ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_OUTLET, ATTR_DEVICE_SIREN, ATTR_DEVICE_SWITCH, @@ -84,6 +86,7 @@ BATTERY_POWER_SENSOR = [ ATTR_DEVICE_DOOR_SENSOR, ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_MOTION_SENSOR, + ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_VIBRATION_SENSOR, ATTR_DEVICE_LOCK, diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index 94fe5dc09a..de16e1a6e3 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -21,5 +21,17 @@ "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" } + }, + "device_automation": { + "trigger_type": { + "button_1_short_press": "Button_1 (short press)", + "button_1_long_press": "Button_1 (long press)", + "button_2_short_press": "Button_2 (short press)", + "button_2_long_press": "Button_2 (long press)", + "button_3_short_press": "Button_3 (short press)", + "button_3_long_press": "Button_3 (long press)", + "button_4_short_press": "Button_4 (short press)", + "button_4_long_press": "Button_4 (long press)" + } } } diff --git a/tests/components/yolink/test_device_trigger.py b/tests/components/yolink/test_device_trigger.py new file mode 100644 index 0000000000..f5679ca19c --- /dev/null +++ b/tests/components/yolink/test_device_trigger.py @@ -0,0 +1,169 @@ +"""The tests for YoLink device triggers.""" +import pytest +from yolink.const import ATTR_DEVICE_DIMMER, ATTR_DEVICE_SMART_REMOTER + +from homeassistant.components import automation +from homeassistant.components.device_automation import DeviceAutomationType +from homeassistant.components.yolink import DOMAIN, YOLINK_EVENT +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + assert_lists_same, + async_get_device_automations, + async_mock_service, +) + + +@pytest.fixture +def calls(hass: HomeAssistant): + """Track calls to a mock service.""" + return async_mock_service(hass, "yolink", "automation") + + +async def test_get_triggers( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test we get the expected triggers from a yolink flexfob.""" + config_entry = MockConfigEntry(domain="yolink", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + model=ATTR_DEVICE_SMART_REMOTER, + ) + + expected_triggers = [ + { + "platform": "device", + "domain": DOMAIN, + "type": "button_1_short_press", + "device_id": device_entry.id, + "metadata": {}, + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "button_1_long_press", + "device_id": device_entry.id, + "metadata": {}, + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "button_2_short_press", + "device_id": device_entry.id, + "metadata": {}, + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "button_2_long_press", + "device_id": device_entry.id, + "metadata": {}, + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "button_3_short_press", + "device_id": device_entry.id, + "metadata": {}, + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "button_3_long_press", + "device_id": device_entry.id, + "metadata": {}, + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "button_4_short_press", + "device_id": device_entry.id, + "metadata": {}, + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "button_4_long_press", + "device_id": device_entry.id, + "metadata": {}, + }, + ] + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) + assert_lists_same(triggers, expected_triggers) + + +async def test_get_triggers_exception( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test get triggers when device type not flexfob.""" + config_entry = MockConfigEntry(domain="yolink", data={}) + config_entry.add_to_hass(hass) + device_entity = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + model=ATTR_DEVICE_DIMMER, + ) + + expected_triggers = [] + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entity.id + ) + assert_lists_same(triggers, expected_triggers) + + +async def test_if_fires_on_event( + hass: HomeAssistant, calls, device_registry: dr.DeviceRegistry +) -> None: + """Test for event triggers firing.""" + mac_address = "12:34:56:AB:CD:EF" + connection = (dr.CONNECTION_NETWORK_MAC, mac_address) + config_entry = MockConfigEntry(domain=DOMAIN, data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={connection}, + identifiers={(DOMAIN, mac_address)}, + model=ATTR_DEVICE_SMART_REMOTER, + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "type": "button_1_long_press", + }, + "action": { + "service": "yolink.automation", + "data": {"message": "service called"}, + }, + }, + ] + }, + ) + + device = device_registry.async_get_device(set(), {connection}) + assert device is not None + # Fake remote button long press. + hass.bus.async_fire( + event_type=YOLINK_EVENT, + event_data={ + "type": "button_1_long_press", + "device_id": device.id, + }, + ) + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["message"] == "service called" From 9b9ed21dc4b0de9bc8dccf5ec1ab047bcb084e8b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 22 Mar 2023 08:24:28 -0400 Subject: [PATCH 1148/2810] Update hass-nabucasa to 0.62.0 (#90085) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/components/cloud/tts.py | 7 +++++-- homeassistant/components/tts/__init__.py | 3 ++- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 11 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 7f8dfca144..7bd4a822fb 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.61.1"] + "requirements": ["hass-nabucasa==0.62.0"] } diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index 00eacf7ca5..bbf4ef287d 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -1,7 +1,7 @@ """Support for the cloud for text to speech service.""" from hass_nabucasa import Cloud -from hass_nabucasa.voice import MAP_VOICE, VoiceError +from hass_nabucasa.voice import MAP_VOICE, AudioOutput, VoiceError import voluptuous as vol from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider @@ -99,7 +99,10 @@ class CloudProvider(Provider): # Process TTS try: data = await self.cloud.voice.process_tts( - message, language, gender=options[CONF_GENDER] + message, + language, + gender=options[CONF_GENDER], + output=AudioOutput.MP3, ) except VoiceError: return (None, None) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 0d253d7d94..39aedfe8cb 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -502,7 +502,8 @@ class SpeechManager: ) # Save to memory - data = self.write_tags(filename, data, provider, message, language, options) + if extension == "mp3": + data = self.write_tags(filename, data, provider, message, language, options) self._async_store_to_memcache(cache_key, filename, data) if cache: diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 29665559e4..edc88caef8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -20,7 +20,7 @@ ciso8601==2.3.0 cryptography==39.0.1 dbus-fast==1.84.2 fnvhash==0.1.0 -hass-nabucasa==0.61.1 +hass-nabucasa==0.62.0 hassil==1.0.6 home-assistant-bluetooth==1.9.3 home-assistant-frontend==20230309.1 diff --git a/requirements_all.txt b/requirements_all.txt index 0bfd27361c..91b480e973 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -868,7 +868,7 @@ ha-philipsjs==3.0.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.61.1 +hass-nabucasa==0.62.0 # homeassistant.components.splunk hass_splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7a02110f54..6b88020aff 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -666,7 +666,7 @@ ha-philipsjs==3.0.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.61.1 +hass-nabucasa==0.62.0 # homeassistant.components.conversation hassil==1.0.6 From 19d56a7102ce1d2f58b64cba5c6f8b60b9c0c71a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 22 Mar 2023 13:32:02 +0100 Subject: [PATCH 1149/2810] Change error handling in async_process_play_media_url (#90052) --- homeassistant/components/media_player/browse_media.py | 2 +- tests/components/media_player/test_browse_media.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_player/browse_media.py b/homeassistant/components/media_player/browse_media.py index d1328a851d..2b046868f1 100644 --- a/homeassistant/components/media_player/browse_media.py +++ b/homeassistant/components/media_player/browse_media.py @@ -44,7 +44,7 @@ def async_process_play_media_url( return media_content_id else: if media_content_id[0] != "/": - raise ValueError("URL is relative, but does not start with a /") + return media_content_id if parsed.query: logging.getLogger(__name__).debug( diff --git a/tests/components/media_player/test_browse_media.py b/tests/components/media_player/test_browse_media.py index 014eeb7dc4..c7ce52eb12 100644 --- a/tests/components/media_player/test_browse_media.py +++ b/tests/components/media_player/test_browse_media.py @@ -86,8 +86,8 @@ async def test_process_play_media_url(hass: HomeAssistant, mock_sign_path) -> No == "http://example.local:8123/api/tts_proxy/bla" ) - with pytest.raises(ValueError): - async_process_play_media_url(hass, "hello") + # Not changing a URL which is not absolute and does not start with / + async_process_play_media_url(hass, "hello") == "hello" async def test_process_play_media_url_for_addon( From 0ca67233788038d0d00dfa4c3cd4de8e707f5b85 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 22 Mar 2023 08:36:36 -0400 Subject: [PATCH 1150/2810] Allow passing binary to the WS connection (#89882) * Allow passing binary to the WS connection * Expand test coverage * Test non-existing handler * Allow signaling end of stream using empty payloads * Store handlers in a list * Handle binary handlers raising exceptions --- .../components/websocket_api/connection.py | 60 ++++++++++++- .../components/websocket_api/http.py | 9 ++ .../websocket_api/test_connection.py | 24 ++++++ tests/components/websocket_api/test_http.py | 85 ++++++++++++++++++- 4 files changed, 175 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 08d0531452..f91cc3a827 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -25,6 +25,9 @@ current_connection = ContextVar["ActiveConnection | None"]( "current_connection", default=None ) +MessageHandler = Callable[[HomeAssistant, "ActiveConnection", dict[str, Any]], None] +BinaryHandler = Callable[[HomeAssistant, "ActiveConnection", bytes], None] + class ActiveConnection: """Handle an active websocket client connection.""" @@ -46,7 +49,10 @@ class ActiveConnection: self.subscriptions: dict[Hashable, Callable[[], Any]] = {} self.last_id = 0 self.supported_features: dict[str, float] = {} - self.handlers = self.hass.data[const.DOMAIN] + self.handlers: dict[str, tuple[MessageHandler, vol.Schema]] = self.hass.data[ + const.DOMAIN + ] + self.binary_handlers: list[BinaryHandler | None] = [] current_connection.set(self) def get_description(self, request: web.Request | None) -> str: @@ -60,6 +66,38 @@ class ActiveConnection: """Return a context.""" return Context(user_id=self.user.id) + @callback + def async_register_binary_handler( + self, handler: BinaryHandler + ) -> tuple[int, Callable[[], None]]: + """Register a temporary binary handler for this connection. + + Returns a binary handler_id (1 byte) and a callback to unregister the handler. + """ + if len(self.binary_handlers) < 255: + index = len(self.binary_handlers) + self.binary_handlers.append(None) + else: + # Once the list is full, we search for a None entry to reuse. + index = None + for idx, existing in enumerate(self.binary_handlers): + if existing is None: + index = idx + break + + if index is None: + raise RuntimeError("Too many binary handlers registered") + + self.binary_handlers[index] = handler + + @callback + def unsub() -> None: + """Unregister the handler.""" + assert index is not None + self.binary_handlers[index] = None + + return index + 1, unsub + @callback def send_result(self, msg_id: int, result: Any | None = None) -> None: """Send a result message.""" @@ -75,6 +113,26 @@ class ActiveConnection: """Send a error message.""" self.send_message(messages.error_message(msg_id, code, message)) + @callback + def async_handle_binary(self, handler_id: int, payload: bytes) -> None: + """Handle a single incoming binary message.""" + index = handler_id - 1 + if ( + index < 0 + or index >= len(self.binary_handlers) + or (handler := self.binary_handlers[index]) is None + ): + self.logger.error( + "Received binary message for non-existing handler %s", handler_id + ) + return + + try: + handler(self.hass, self, payload) + except Exception: # pylint: disable=broad-except + self.logger.exception("Error handling binary message") + self.binary_handlers[index] = None + @callback def async_handle(self, msg: dict[str, Any]) -> None: """Handle a single incoming message.""" diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index de0b23e495..75eccc7aba 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -312,6 +312,15 @@ class WebSocketHandler: if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.CLOSING): break + if msg.type == WSMsgType.BINARY: + if len(msg.data) < 1: + disconnect_warn = "Received invalid binary message." + break + handler = msg.data[0] + payload = msg.data[1:] + connection.async_handle_binary(handler, payload) + continue + if msg.type != WSMsgType.TEXT: disconnect_warn = "Received non-Text message." break diff --git a/tests/components/websocket_api/test_connection.py b/tests/components/websocket_api/test_connection.py index 53baab98b4..da435d64d5 100644 --- a/tests/components/websocket_api/test_connection.py +++ b/tests/components/websocket_api/test_connection.py @@ -101,3 +101,27 @@ async def test_exception_handling( assert send_messages[0]["error"]["code"] == code assert send_messages[0]["error"]["message"] == err assert log in caplog.text + + +async def test_binary_handler_registration() -> None: + """Test binary handler registration.""" + connection = websocket_api.ActiveConnection( + None, Mock(data={websocket_api.DOMAIN: None}), None, None, Mock() + ) + + # One filler to align indexes with prefix numbers + unsubs = [None] + fake_handler = object() + for i in range(255): + prefix, unsub = connection.async_register_binary_handler(fake_handler) + assert prefix == i + 1 + unsubs.append(unsub) + + with pytest.raises(RuntimeError): + connection.async_register_binary_handler(None) + + unsubs[15]() + + # Verify we reuse an unsubscribed prefix + prefix, unsub = connection.async_register_binary_handler(None) + assert prefix == 15 diff --git a/tests/components/websocket_api/test_http.py b/tests/components/websocket_api/test_http.py index fce6eb428a..475fbeee76 100644 --- a/tests/components/websocket_api/test_http.py +++ b/tests/components/websocket_api/test_http.py @@ -1,13 +1,20 @@ """Test Websocket API http module.""" import asyncio from datetime import timedelta +from typing import Any from unittest.mock import patch from aiohttp import ServerDisconnectedError, WSMsgType, web import pytest -from homeassistant.components.websocket_api import const, http -from homeassistant.core import HomeAssistant +from homeassistant.components.websocket_api import ( + async_register_command, + const, + http, + websocket_command, +) +from homeassistant.components.websocket_api.connection import ActiveConnection +from homeassistant.core import HomeAssistant, callback from homeassistant.util.dt import utcnow from tests.common import async_fire_time_changed @@ -155,3 +162,77 @@ async def test_prepare_fail( await hass_ws_client(hass) assert "Timeout preparing request" in caplog.text + + +async def test_binary_message( + hass: HomeAssistant, websocket_client, caplog: pytest.LogCaptureFixture +) -> None: + """Test binary messages.""" + binary_payloads = { + 104: ([], asyncio.Future()), + 105: ([], asyncio.Future()), + } + + # Register a handler + @callback + @websocket_command( + { + "type": "get_binary_message_handler", + } + ) + def get_binary_message_handler( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] + ): + unsub = None + + @callback + def binary_message_handler( + hass: HomeAssistant, connection: ActiveConnection, payload: bytes + ): + nonlocal unsub + if msg["id"] == 103: + raise ValueError("Boom") + + if payload: + binary_payloads[msg["id"]][0].append(payload) + else: + binary_payloads[msg["id"]][1].set_result( + b"".join(binary_payloads[msg["id"]][0]) + ) + unsub() + + prefix, unsub = connection.async_register_binary_handler(binary_message_handler) + + connection.send_result(msg["id"], {"prefix": prefix}) + + async_register_command(hass, get_binary_message_handler) + + # Register multiple binary handlers + for i in range(101, 106): + await websocket_client.send_json( + {"id": i, "type": "get_binary_message_handler"} + ) + result = await websocket_client.receive_json() + assert result["id"] == i + assert result["type"] == const.TYPE_RESULT + assert result["success"] + assert result["result"]["prefix"] == i - 100 + + # Send message to binary + await websocket_client.send_bytes((0).to_bytes(1, "big") + b"test0") + await websocket_client.send_bytes((3).to_bytes(1, "big") + b"test3") + await websocket_client.send_bytes((3).to_bytes(1, "big") + b"test3") + await websocket_client.send_bytes((10).to_bytes(1, "big") + b"test10") + await websocket_client.send_bytes((4).to_bytes(1, "big") + b"test4") + await websocket_client.send_bytes((4).to_bytes(1, "big") + b"") + await websocket_client.send_bytes((5).to_bytes(1, "big") + b"test5") + await websocket_client.send_bytes((5).to_bytes(1, "big") + b"test5-2") + await websocket_client.send_bytes((5).to_bytes(1, "big") + b"") + + # Verify received + assert await binary_payloads[104][1] == b"test4" + assert await binary_payloads[105][1] == b"test5test5-2" + assert "Error handling binary message" in caplog.text + assert "Received binary message for non-existing handler 0" in caplog.text + assert "Received binary message for non-existing handler 3" in caplog.text + assert "Received binary message for non-existing handler 10" in caplog.text From 0ecd043cb2f0e84d8234fce6a82529988630caa5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 22 Mar 2023 13:59:35 +0100 Subject: [PATCH 1151/2810] Add test helper mock_config_flow (#90103) --- tests/common.py | 11 +++++++++++ .../application_credentials/test_init.py | 8 ++++---- tests/components/hassio/test_discovery.py | 18 +++++++++++------- .../test_silabs_multiprotocol_addon.py | 15 ++++++++++----- 4 files changed, 36 insertions(+), 16 deletions(-) diff --git a/tests/common.py b/tests/common.py index 569813d221..f7a2c04a5f 100644 --- a/tests/common.py +++ b/tests/common.py @@ -34,6 +34,7 @@ from homeassistant.components.device_automation import ( # noqa: F401 _async_get_device_automation_capabilities as async_get_device_automation_capabilities, ) from homeassistant.config import async_process_component_config +from homeassistant.config_entries import ConfigFlow from homeassistant.const import ( DEVICE_DEFAULT_NAME, EVENT_HOMEASSISTANT_CLOSE, @@ -1242,6 +1243,16 @@ async def get_system_health_info(hass: HomeAssistant, domain: str) -> dict[str, return await hass.data["system_health"][domain].info_callback(hass) +@contextmanager +def mock_config_flow(domain: str, config_flow: type[ConfigFlow]) -> None: + """Mock a config flow handler.""" + assert domain not in config_entries.HANDLERS + config_entries.HANDLERS[domain] = config_flow + _LOGGER.info("Adding mock config flow: %s", domain) + yield + config_entries.HANDLERS.pop(domain) + + def mock_integration( hass: HomeAssistant, module: MockModule, built_in: bool = True ) -> loader.Integration: diff --git a/tests/components/application_credentials/test_init.py b/tests/components/application_credentials/test_init.py index 2f17340b07..cc56894cf0 100644 --- a/tests/components/application_credentials/test_init.py +++ b/tests/components/application_credentials/test_init.py @@ -28,7 +28,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, mock_platform +from tests.common import MockConfigEntry, mock_config_flow, mock_platform from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -98,7 +98,7 @@ async def mock_application_credentials_integration( yield -class FakeConfigFlow(config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN): +class FakeConfigFlow(config_entry_oauth2_flow.AbstractOAuth2FlowHandler): """Config flow used during tests.""" DOMAIN = TEST_DOMAIN @@ -115,8 +115,8 @@ def config_flow_handler( ) -> Generator[FakeConfigFlow, None, None]: """Fixture for a test config flow.""" mock_platform(hass, f"{TEST_DOMAIN}.config_flow") - with patch.dict(config_entries.HANDLERS, {TEST_DOMAIN: FakeConfigFlow}): - yield FakeConfigFlow + with mock_config_flow(TEST_DOMAIN, FakeConfigFlow): + yield class OAuthFixture: diff --git a/tests/components/hassio/test_discovery.py b/tests/components/hassio/test_discovery.py index 2cb4aa206d..51659927df 100644 --- a/tests/components/hassio/test_discovery.py +++ b/tests/components/hassio/test_discovery.py @@ -12,7 +12,12 @@ from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_S from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockModule, mock_entity_platform, mock_integration +from tests.common import ( + MockModule, + mock_config_flow, + mock_entity_platform, + mock_integration, +) from tests.test_util.aiohttp import AiohttpClientMocker @@ -22,15 +27,14 @@ async def mock_mqtt_fixture(hass): mock_integration(hass, MockModule(MQTT_DOMAIN)) mock_entity_platform(hass, f"config_flow.{MQTT_DOMAIN}", None) - with patch.dict(config_entries.HANDLERS): + class MqttFlow(config_entries.ConfigFlow): + """Test flow.""" - class MqttFlow(config_entries.ConfigFlow, domain=MQTT_DOMAIN): - """Test flow.""" + VERSION = 1 - VERSION = 1 - - async_step_hassio = AsyncMock(return_value={"type": "abort"}) + async_step_hassio = AsyncMock(return_value={"type": "abort"}) + with mock_config_flow(MQTT_DOMAIN, MqttFlow): yield MqttFlow diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index abe66d35a9..57e4a23ab5 100644 --- a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py +++ b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py @@ -7,7 +7,6 @@ from unittest.mock import Mock, patch import pytest -from homeassistant import config_entries from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.components.homeassistant_hardware import silabs_multiprotocol_addon from homeassistant.components.zha.core.const import DOMAIN as ZHA_DOMAIN @@ -15,12 +14,18 @@ from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult, FlowResultType -from tests.common import MockConfigEntry, MockModule, mock_integration, mock_platform +from tests.common import ( + MockConfigEntry, + MockModule, + mock_config_flow, + mock_integration, + mock_platform, +) TEST_DOMAIN = "test" -class TestConfigFlow(ConfigFlow, domain=TEST_DOMAIN): +class TestConfigFlow(ConfigFlow): """Handle a config flow for the silabs multiprotocol add-on.""" VERSION = 1 @@ -87,8 +92,8 @@ def config_flow_handler( ) -> Generator[TestConfigFlow, None, None]: """Fixture for a test config flow.""" mock_platform(hass, f"{TEST_DOMAIN}.config_flow") - with patch.dict(config_entries.HANDLERS, {TEST_DOMAIN: TestConfigFlow}): - yield TestConfigFlow + with mock_config_flow(TEST_DOMAIN, TestConfigFlow): + yield async def test_option_flow_install_multi_pan_addon( From 130c8ea5f54f73ed84e34da20683ebbd2b9d4ce0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 22 Mar 2023 14:03:39 +0100 Subject: [PATCH 1152/2810] Update OTRB config entry if REST API port has changed (#90101) * Update OTRB config entry if REST API port has changed * Improve test coverage --- homeassistant/components/otbr/__init__.py | 7 ++ homeassistant/components/otbr/config_flow.py | 25 +++++-- tests/components/otbr/test_config_flow.py | 68 +++++++++++++++++++- tests/components/otbr/test_init.py | 27 +++++++- 4 files changed, 118 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index 602c76f77e..a25ff8b46b 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -155,6 +155,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _warn_on_default_network_settings(hass, entry, dataset_tlvs) await async_add_dataset(hass, DOMAIN, dataset_tlvs.hex()) + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) + hass.data[DOMAIN] = otbrdata return True @@ -166,6 +168,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle an options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_get_active_dataset_tlvs(hass: HomeAssistant) -> bytes | None: """Get current active operational dataset in TLVS format, or None. diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py index 0e9c8e9606..4247d5dbd6 100644 --- a/homeassistant/components/otbr/config_flow.py +++ b/homeassistant/components/otbr/config_flow.py @@ -8,10 +8,11 @@ import aiohttp import python_otbr_api from python_otbr_api import tlv_parser import voluptuous as vol +import yarl from homeassistant.components.hassio import HassioServiceInfo from homeassistant.components.thread import async_get_preferred_dataset -from homeassistant.config_entries import ConfigFlow +from homeassistant.config_entries import SOURCE_HASSIO, ConfigFlow from homeassistant.const import CONF_URL from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -86,11 +87,25 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResult: """Handle hassio discovery.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - config = discovery_info.config url = f"http://{config['host']}:{config['port']}" + config_entry_data = {"url": url} + + if current_entries := self._async_current_entries(): + for current_entry in current_entries: + if current_entry.source != SOURCE_HASSIO: + continue + current_url = yarl.URL(current_entry.data["url"]) + if ( + current_url.host != config["host"] + or current_url.port == config["port"] + ): + continue + # Update URL with the new port + self.hass.config_entries.async_update_entry( + current_entry, data=config_entry_data + ) + return self.async_abort(reason="single_instance_allowed") try: await self._connect_and_create_dataset(url) @@ -101,5 +116,5 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(DOMAIN) return self.async_create_entry( title="Open Thread Border Router", - data={"url": url}, + data=config_entry_data, ) diff --git a/tests/components/otbr/test_config_flow.py b/tests/components/otbr/test_config_flow.py index 2ec79dcaee..ae49c63002 100644 --- a/tests/components/otbr/test_config_flow.py +++ b/tests/components/otbr/test_config_flow.py @@ -1,6 +1,7 @@ """Test the Open Thread Border Router config flow.""" import asyncio from http import HTTPStatus +from typing import Any from unittest.mock import patch import aiohttp @@ -373,8 +374,69 @@ async def test_hassio_discovery_flow_404( assert result["reason"] == "unknown" -@pytest.mark.parametrize("source", ("hassio", "user")) -async def test_config_flow_single_entry(hass: HomeAssistant, source: str) -> None: +async def test_hassio_discovery_flow_new_port(hass: HomeAssistant) -> None: + """Test the port can be updated.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={ + "url": f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port']+1}" + }, + domain=otbr.DOMAIN, + options={}, + source="hassio", + title="Open Thread Border Router", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + expected_data = { + "url": f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port']}", + } + config_entry = hass.config_entries.async_entries(otbr.DOMAIN)[0] + assert config_entry.data == expected_data + + +async def test_hassio_discovery_flow_new_port_other_addon(hass: HomeAssistant) -> None: + """Test the port is not updated if we get data for another addon hosting OTBR.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={"url": f"http://openthread_border_router:{HASSIO_DATA.config['port']+1}"}, + domain=otbr.DOMAIN, + options={}, + source="hassio", + title="Open Thread Border Router", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + # Make sure the data was not updated + expected_data = { + "url": f"http://openthread_border_router:{HASSIO_DATA.config['port']+1}", + } + config_entry = hass.config_entries.async_entries(otbr.DOMAIN)[0] + assert config_entry.data == expected_data + + +@pytest.mark.parametrize(("source", "data"), [("hassio", HASSIO_DATA), ("user", None)]) +async def test_config_flow_single_entry( + hass: HomeAssistant, source: str, data: Any +) -> None: """Test only a single entry is allowed.""" mock_integration(hass, MockModule("hassio")) @@ -392,7 +454,7 @@ async def test_config_flow_single_entry(hass: HomeAssistant, source: str) -> Non return_value=True, ) as mock_setup_entry: result = await hass.config_entries.flow.async_init( - otbr.DOMAIN, context={"source": source} + otbr.DOMAIN, context={"source": source}, data=data ) assert result["type"] == FlowResultType.ABORT diff --git a/tests/components/otbr/test_init.py b/tests/components/otbr/test_init.py index 2b329ae8d9..3ed3ec8c30 100644 --- a/tests/components/otbr/test_init.py +++ b/tests/components/otbr/test_init.py @@ -1,7 +1,7 @@ """Test the Open Thread Border Router integration.""" import asyncio from http import HTTPStatus -from unittest.mock import patch +from unittest.mock import ANY, AsyncMock, MagicMock, patch import aiohttp import pytest @@ -100,6 +100,31 @@ async def test_config_entry_not_ready(hass: HomeAssistant, error) -> None: assert not await hass.config_entries.async_setup(config_entry.entry_id) +async def test_config_entry_update(hass: HomeAssistant) -> None: + """Test update config entry settings.""" + config_entry = MockConfigEntry( + data=CONFIG_ENTRY_DATA, + domain=otbr.DOMAIN, + options={}, + title="My OTBR", + ) + config_entry.add_to_hass(hass) + mock_api = MagicMock() + mock_api.get_active_dataset_tlvs = AsyncMock(return_value=None) + with patch("python_otbr_api.OTBR", return_value=mock_api) as mock_otrb_api: + assert await hass.config_entries.async_setup(config_entry.entry_id) + + mock_otrb_api.assert_called_once_with(CONFIG_ENTRY_DATA["url"], ANY, ANY) + + new_config_entry_data = {"url": "http://core-silabs-multiprotocol:8082"} + assert CONFIG_ENTRY_DATA["url"] != new_config_entry_data["url"] + with patch("python_otbr_api.OTBR", return_value=mock_api) as mock_otrb_api: + hass.config_entries.async_update_entry(config_entry, data=new_config_entry_data) + await hass.async_block_till_done() + + mock_otrb_api.assert_called_once_with(new_config_entry_data["url"], ANY, ANY) + + async def test_remove_entry( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_config_entry ) -> None: From c581116c824b008c36ef0e05c6d098450f63bb04 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 22 Mar 2023 11:15:46 -0400 Subject: [PATCH 1153/2810] ZHA network settings API (#88564) * Rename `zha.api` to `zha.websocket_api` * Implement a ZHA network settings API * Use the enum name as the radio type * Don't filter out ignored config entries * [WIP] Start unit tests * Add unit tests * Rename ZHA websocket API module in `.coveragerc` * Rename `api` to `websocket_api` * Increase test coverage to 100% --- .coveragerc | 2 +- homeassistant/components/zha/__init__.py | 8 +- homeassistant/components/zha/api.py | 1615 +---------------- homeassistant/components/zha/core/device.py | 2 +- homeassistant/components/zha/core/gateway.py | 22 +- homeassistant/components/zha/device_action.py | 2 +- homeassistant/components/zha/websocket_api.py | 1541 ++++++++++++++++ tests/components/zha/test_api.py | 869 +-------- tests/components/zha/test_init.py | 4 +- tests/components/zha/test_websocket_api.py | 842 +++++++++ 10 files changed, 2558 insertions(+), 2349 deletions(-) create mode 100644 homeassistant/components/zha/websocket_api.py create mode 100644 tests/components/zha/test_websocket_api.py diff --git a/.coveragerc b/.coveragerc index 20ee077ffa..e59c60ddcc 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1508,7 +1508,7 @@ omit = homeassistant/components/zeversolar/coordinator.py homeassistant/components/zeversolar/entity.py homeassistant/components/zeversolar/sensor.py - homeassistant/components/zha/api.py + homeassistant/components/zha/websocket_api.py homeassistant/components/zha/core/channels/* homeassistant/components/zha/core/device.py homeassistant/components/zha/core/gateway.py diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index dd07d4da42..5607cabffe 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -17,7 +17,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import ConfigType -from . import api +from . import websocket_api from .core import ZHAGateway from .core.const import ( BAUD_RATES, @@ -131,7 +131,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b model=zha_gateway.radio_description, ) - api.async_load_api(hass) + websocket_api.async_load_api(hass) async def async_zha_shutdown(event): """Handle shutdown tasks.""" @@ -150,11 +150,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload ZHA config entry.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway: ZHAGateway = hass.data[DATA_ZHA].pop(DATA_ZHA_GATEWAY) await zha_gateway.shutdown() GROUP_PROBE.cleanup() - api.async_unload_api(hass) + websocket_api.async_unload_api(hass) # our components don't have unload methods so no need to look at return values await asyncio.gather( diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index d0e04e0c16..d34dd2338e 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -1,1549 +1,120 @@ -"""Web socket API for Zigbee Home Automation devices.""" +"""API for Zigbee Home Automation.""" + from __future__ import annotations -import asyncio -import logging -from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar, cast +from typing import TYPE_CHECKING -import voluptuous as vol -import zigpy.backups from zigpy.backups import NetworkBackup -from zigpy.config.validators import cv_boolean -from zigpy.types.named import EUI64 -from zigpy.zcl.clusters.security import IasAce -import zigpy.zdo.types as zdo_types - -from homeassistant.components import websocket_api -from homeassistant.const import ATTR_COMMAND, ATTR_ID, ATTR_NAME -from homeassistant.core import HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.service import async_register_admin_service +from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH from .core.const import ( - ATTR_ARGS, - ATTR_ATTRIBUTE, - ATTR_CLUSTER_ID, - ATTR_CLUSTER_TYPE, - ATTR_COMMAND_TYPE, - ATTR_ENDPOINT_ID, - ATTR_IEEE, - ATTR_LEVEL, - ATTR_MANUFACTURER, - ATTR_MEMBERS, - ATTR_PARAMS, - ATTR_TYPE, - ATTR_VALUE, - ATTR_WARNING_DEVICE_DURATION, - ATTR_WARNING_DEVICE_MODE, - ATTR_WARNING_DEVICE_STROBE, - ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE, - ATTR_WARNING_DEVICE_STROBE_INTENSITY, - BINDINGS, - CHANNEL_IAS_WD, - CLUSTER_COMMAND_SERVER, - CLUSTER_COMMANDS_CLIENT, - CLUSTER_COMMANDS_SERVER, - CLUSTER_TYPE_IN, - CLUSTER_TYPE_OUT, CONF_RADIO_TYPE, - CUSTOM_CONFIGURATION, DATA_ZHA, + DATA_ZHA_CONFIG, DATA_ZHA_GATEWAY, DOMAIN, - EZSP_OVERWRITE_EUI64, - GROUP_ID, - GROUP_IDS, - GROUP_NAME, - MFG_CLUSTER_ID_START, - WARNING_DEVICE_MODE_EMERGENCY, - WARNING_DEVICE_SOUND_HIGH, - WARNING_DEVICE_SQUAWK_MODE_ARMED, - WARNING_DEVICE_STROBE_HIGH, - WARNING_DEVICE_STROBE_YES, - ZHA_ALARM_OPTIONS, - ZHA_CHANNEL_MSG, - ZHA_CONFIG_SCHEMAS, -) -from .core.gateway import EntityReference -from .core.group import GroupMember -from .core.helpers import ( - async_cluster_exists, - async_is_bindable_target, - cluster_command_schema_to_vol_schema, - convert_install_code, - get_matched_clusters, - qr_to_install_code, + RadioType, ) +from .core.gateway import ZHAGateway if TYPE_CHECKING: - from homeassistant.components.websocket_api.connection import ActiveConnection + from zigpy.application import ControllerApplication - from .core.device import ZHADevice - from .core.gateway import ZHAGateway - -_LOGGER = logging.getLogger(__name__) - -TYPE = "type" -CLIENT = "client" -ID = "id" -RESPONSE = "response" -DEVICE_INFO = "device_info" - -ATTR_DURATION = "duration" -ATTR_GROUP = "group" -ATTR_IEEE_ADDRESS = "ieee_address" -ATTR_INSTALL_CODE = "install_code" -ATTR_SOURCE_IEEE = "source_ieee" -ATTR_TARGET_IEEE = "target_ieee" -ATTR_QR_CODE = "qr_code" - -SERVICE_PERMIT = "permit" -SERVICE_REMOVE = "remove" -SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE = "set_zigbee_cluster_attribute" -SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND = "issue_zigbee_cluster_command" -SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND = "issue_zigbee_group_command" -SERVICE_DIRECT_ZIGBEE_BIND = "issue_direct_zigbee_bind" -SERVICE_DIRECT_ZIGBEE_UNBIND = "issue_direct_zigbee_unbind" -SERVICE_WARNING_DEVICE_SQUAWK = "warning_device_squawk" -SERVICE_WARNING_DEVICE_WARN = "warning_device_warn" -SERVICE_ZIGBEE_BIND = "service_zigbee_bind" -IEEE_SERVICE = "ieee_based_service" - -IEEE_SCHEMA = vol.All(cv.string, EUI64.convert) - -# typing typevar -_T = TypeVar("_T") + from homeassistant.config_entries import ConfigEntry + from homeassistant.core import HomeAssistant -def _ensure_list_if_present(value: _T | None) -> list[_T] | list[Any] | None: - """Wrap value in list if it is provided and not one.""" - if value is None: +def _get_gateway(hass: HomeAssistant) -> ZHAGateway: + """Get a reference to the ZHA gateway device.""" + return hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + + +def _get_config_entry(hass: HomeAssistant) -> ConfigEntry: + """Find the singleton ZHA config entry, if one exists.""" + + # If ZHA is already running, use its config entry + try: + zha_gateway = _get_gateway(hass) + except KeyError: + pass + else: + return zha_gateway.config_entry + + # Otherwise, find one + entries = hass.config_entries.async_entries(DOMAIN) + + if len(entries) != 1: + raise ValueError(f"Invalid number of ZHA config entries: {entries!r}") + + return entries[0] + + +def _wrap_network_settings(app: ControllerApplication) -> NetworkBackup: + """Wrap the ZHA network settings into a `NetworkBackup`.""" + return NetworkBackup( + node_info=app.state.node_info, + network_info=app.state.network_info, + ) + + +def async_get_active_network_settings(hass: HomeAssistant) -> NetworkBackup: + """Get the network settings for the currently active ZHA network.""" + zha_gateway: ZHAGateway = _get_gateway(hass) + + return _wrap_network_settings(zha_gateway.application_controller) + + +async def async_get_last_network_settings( + hass: HomeAssistant, config_entry: ConfigEntry | None = None +) -> NetworkBackup | None: + """Get the network settings for the last-active ZHA network.""" + if config_entry is None: + config_entry = _get_config_entry(hass) + + config = hass.data.get(DATA_ZHA, {}).get(DATA_ZHA_CONFIG, {}) + zha_gateway = ZHAGateway(hass, config, config_entry) + + app_controller_cls, app_config = zha_gateway.get_application_controller_data() + app = app_controller_cls(app_config) + + try: + await app._load_db() # pylint: disable=protected-access + settings = _wrap_network_settings(app) + finally: + await app.shutdown() + + if settings.network_info.channel == 0: return None - return cast("list[_T]", value) if isinstance(value, list) else [value] + + return settings -SERVICE_PERMIT_PARAMS = { - vol.Optional(ATTR_IEEE): IEEE_SCHEMA, - vol.Optional(ATTR_DURATION, default=60): vol.All( - vol.Coerce(int), vol.Range(0, 254) - ), - vol.Inclusive(ATTR_SOURCE_IEEE, "install_code"): IEEE_SCHEMA, - vol.Inclusive(ATTR_INSTALL_CODE, "install_code"): vol.All( - cv.string, convert_install_code - ), - vol.Exclusive(ATTR_QR_CODE, "install_code"): vol.All(cv.string, qr_to_install_code), -} - -SERVICE_SCHEMAS = { - SERVICE_PERMIT: vol.Schema( - vol.All( - cv.deprecated(ATTR_IEEE_ADDRESS, replacement_key=ATTR_IEEE), - SERVICE_PERMIT_PARAMS, - ) - ), - IEEE_SERVICE: vol.Schema( - vol.All( - cv.deprecated(ATTR_IEEE_ADDRESS, replacement_key=ATTR_IEEE), - {vol.Required(ATTR_IEEE): IEEE_SCHEMA}, - ) - ), - SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE: vol.Schema( - { - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_ENDPOINT_ID): cv.positive_int, - vol.Required(ATTR_CLUSTER_ID): cv.positive_int, - vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, - vol.Required(ATTR_ATTRIBUTE): vol.Any(cv.positive_int, str), - vol.Required(ATTR_VALUE): vol.Any(int, cv.boolean, cv.string), - vol.Optional(ATTR_MANUFACTURER): cv.positive_int, - } - ), - SERVICE_WARNING_DEVICE_SQUAWK: vol.Schema( - { - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Optional( - ATTR_WARNING_DEVICE_MODE, default=WARNING_DEVICE_SQUAWK_MODE_ARMED - ): cv.positive_int, - vol.Optional( - ATTR_WARNING_DEVICE_STROBE, default=WARNING_DEVICE_STROBE_YES - ): cv.positive_int, - vol.Optional( - ATTR_LEVEL, default=WARNING_DEVICE_SOUND_HIGH - ): cv.positive_int, - } - ), - SERVICE_WARNING_DEVICE_WARN: vol.Schema( - { - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Optional( - ATTR_WARNING_DEVICE_MODE, default=WARNING_DEVICE_MODE_EMERGENCY - ): cv.positive_int, - vol.Optional( - ATTR_WARNING_DEVICE_STROBE, default=WARNING_DEVICE_STROBE_YES - ): cv.positive_int, - vol.Optional( - ATTR_LEVEL, default=WARNING_DEVICE_SOUND_HIGH - ): cv.positive_int, - vol.Optional(ATTR_WARNING_DEVICE_DURATION, default=5): cv.positive_int, - vol.Optional( - ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE, default=0x00 - ): cv.positive_int, - vol.Optional( - ATTR_WARNING_DEVICE_STROBE_INTENSITY, default=WARNING_DEVICE_STROBE_HIGH - ): cv.positive_int, - } - ), - SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND: vol.All( - vol.Schema( - { - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_ENDPOINT_ID): cv.positive_int, - vol.Required(ATTR_CLUSTER_ID): cv.positive_int, - vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, - vol.Required(ATTR_COMMAND): cv.positive_int, - vol.Required(ATTR_COMMAND_TYPE): cv.string, - vol.Exclusive(ATTR_ARGS, "attrs_params"): _ensure_list_if_present, - vol.Exclusive(ATTR_PARAMS, "attrs_params"): dict, - vol.Optional(ATTR_MANUFACTURER): cv.positive_int, - } - ), - cv.deprecated(ATTR_ARGS), - cv.has_at_least_one_key(ATTR_ARGS, ATTR_PARAMS), - ), - SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND: vol.Schema( - { - vol.Required(ATTR_GROUP): cv.positive_int, - vol.Required(ATTR_CLUSTER_ID): cv.positive_int, - vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, - vol.Required(ATTR_COMMAND): cv.positive_int, - vol.Optional(ATTR_ARGS, default=[]): cv.ensure_list, - vol.Optional(ATTR_MANUFACTURER): cv.positive_int, - } - ), -} - - -class ClusterBinding(NamedTuple): - """Describes a cluster binding.""" - - name: str - type: str - id: int - endpoint_id: int - - -def _cv_group_member(value: dict[str, Any]) -> GroupMember: - """Transform a group member.""" - return GroupMember( - ieee=value[ATTR_IEEE], - endpoint_id=value[ATTR_ENDPOINT_ID], - ) - - -def _cv_cluster_binding(value: dict[str, Any]) -> ClusterBinding: - """Transform a cluster binding.""" - return ClusterBinding( - name=value[ATTR_NAME], - type=value[ATTR_TYPE], - id=value[ATTR_ID], - endpoint_id=value[ATTR_ENDPOINT_ID], - ) - - -def _cv_zigpy_network_backup(value: dict[str, Any]) -> zigpy.backups.NetworkBackup: - """Transform a zigpy network backup.""" +async def async_get_network_settings( + hass: HomeAssistant, config_entry: ConfigEntry | None = None +) -> NetworkBackup | None: + """Get ZHA network settings, preferring the active settings if ZHA is running.""" try: - return zigpy.backups.NetworkBackup.from_dict(value) - except ValueError as err: - raise vol.Invalid(str(err)) from err + return async_get_active_network_settings(hass) + except KeyError: + return await async_get_last_network_settings(hass, config_entry) -GROUP_MEMBER_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_ENDPOINT_ID): vol.Coerce(int), - } - ), - _cv_group_member, -) +def async_get_radio_type( + hass: HomeAssistant, config_entry: ConfigEntry | None = None +) -> RadioType: + """Get ZHA radio type.""" + if config_entry is None: + config_entry = _get_config_entry(hass) + return RadioType[config_entry.data[CONF_RADIO_TYPE]] -CLUSTER_BINDING_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(ATTR_NAME): cv.string, - vol.Required(ATTR_TYPE): cv.string, - vol.Required(ATTR_ID): vol.Coerce(int), - vol.Required(ATTR_ENDPOINT_ID): vol.Coerce(int), - } - ), - _cv_cluster_binding, -) +def async_get_radio_path( + hass: HomeAssistant, config_entry: ConfigEntry | None = None +) -> str: + """Get ZHA radio path.""" + if config_entry is None: + config_entry = _get_config_entry(hass) -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required("type"): "zha/devices/permit", - **SERVICE_PERMIT_PARAMS, - } -) -@websocket_api.async_response -async def websocket_permit_devices( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Permit ZHA zigbee devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - duration: int = msg[ATTR_DURATION] - ieee: EUI64 | None = msg.get(ATTR_IEEE) - - async def forward_messages(data): - """Forward events to websocket.""" - connection.send_message(websocket_api.event_message(msg["id"], data)) - - remove_dispatcher_function = async_dispatcher_connect( - hass, "zha_gateway_message", forward_messages - ) - - @callback - def async_cleanup() -> None: - """Remove signal listener and turn off debug mode.""" - zha_gateway.async_disable_debug_mode() - remove_dispatcher_function() - - connection.subscriptions[msg["id"]] = async_cleanup - zha_gateway.async_enable_debug_mode() - src_ieee: EUI64 - code: bytes - if ATTR_SOURCE_IEEE in msg: - src_ieee = msg[ATTR_SOURCE_IEEE] - code = msg[ATTR_INSTALL_CODE] - _LOGGER.debug("Allowing join for %s device with install code", src_ieee) - await zha_gateway.application_controller.permit_with_key( - time_s=duration, node=src_ieee, code=code - ) - elif ATTR_QR_CODE in msg: - src_ieee, code = msg[ATTR_QR_CODE] - _LOGGER.debug("Allowing join for %s device with install code", src_ieee) - await zha_gateway.application_controller.permit_with_key( - time_s=duration, node=src_ieee, code=code - ) - else: - await zha_gateway.application_controller.permit(time_s=duration, node=ieee) - connection.send_result(msg[ID]) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/devices"}) -@websocket_api.async_response -async def websocket_get_devices( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - devices = [device.zha_device_info for device in zha_gateway.devices.values()] - connection.send_result(msg[ID], devices) - - -@callback -def _get_entity_name( - zha_gateway: ZHAGateway, entity_ref: EntityReference -) -> str | None: - entry = zha_gateway.ha_entity_registry.async_get(entity_ref.reference_id) - return entry.name if entry else None - - -@callback -def _get_entity_original_name( - zha_gateway: ZHAGateway, entity_ref: EntityReference -) -> str | None: - entry = zha_gateway.ha_entity_registry.async_get(entity_ref.reference_id) - return entry.original_name if entry else None - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/devices/groupable"}) -@websocket_api.async_response -async def websocket_get_groupable_devices( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA devices that can be grouped.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - - devices = [device for device in zha_gateway.devices.values() if device.is_groupable] - groupable_devices = [] - - for device in devices: - entity_refs = zha_gateway.device_registry[device.ieee] - for ep_id in device.async_get_groupable_endpoints(): - groupable_devices.append( - { - "endpoint_id": ep_id, - "entities": [ - { - "name": _get_entity_name(zha_gateway, entity_ref), - "original_name": _get_entity_original_name( - zha_gateway, entity_ref - ), - } - for entity_ref in entity_refs - if list(entity_ref.cluster_channels.values())[ - 0 - ].cluster.endpoint.endpoint_id - == ep_id - ], - "device": device.zha_device_info, - } - ) - - connection.send_result(msg[ID], groupable_devices) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/groups"}) -@websocket_api.async_response -async def websocket_get_groups( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA groups.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - groups = [group.group_info for group in zha_gateway.groups.values()] - connection.send_result(msg[ID], groups) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/device", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - } -) -@websocket_api.async_response -async def websocket_get_device( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = msg[ATTR_IEEE] - - if not (zha_device := zha_gateway.devices.get(ieee)): - connection.send_message( - websocket_api.error_message( - msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Device not found" - ) - ) - return - - device_info = zha_device.zha_device_info - connection.send_result(msg[ID], device_info) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/group", - vol.Required(GROUP_ID): cv.positive_int, - } -) -@websocket_api.async_response -async def websocket_get_group( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - group_id: int = msg[GROUP_ID] - - if not (zha_group := zha_gateway.groups.get(group_id)): - connection.send_message( - websocket_api.error_message( - msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" - ) - ) - return - - group_info = zha_group.group_info - connection.send_result(msg[ID], group_info) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/group/add", - vol.Required(GROUP_NAME): cv.string, - vol.Optional(GROUP_ID): cv.positive_int, - vol.Optional(ATTR_MEMBERS): vol.All(cv.ensure_list, [GROUP_MEMBER_SCHEMA]), - } -) -@websocket_api.async_response -async def websocket_add_group( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Add a new ZHA group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - group_name: str = msg[GROUP_NAME] - group_id: int | None = msg.get(GROUP_ID) - members: list[GroupMember] | None = msg.get(ATTR_MEMBERS) - group = await zha_gateway.async_create_zigpy_group(group_name, members, group_id) - assert group - connection.send_result(msg[ID], group.group_info) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/group/remove", - vol.Required(GROUP_IDS): vol.All(cv.ensure_list, [cv.positive_int]), - } -) -@websocket_api.async_response -async def websocket_remove_groups( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Remove the specified ZHA groups.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - group_ids: list[int] = msg[GROUP_IDS] - - if len(group_ids) > 1: - tasks = [] - for group_id in group_ids: - tasks.append(zha_gateway.async_remove_zigpy_group(group_id)) - await asyncio.gather(*tasks) - else: - await zha_gateway.async_remove_zigpy_group(group_ids[0]) - ret_groups = [group.group_info for group in zha_gateway.groups.values()] - connection.send_result(msg[ID], ret_groups) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/group/members/add", - vol.Required(GROUP_ID): cv.positive_int, - vol.Required(ATTR_MEMBERS): vol.All(cv.ensure_list, [GROUP_MEMBER_SCHEMA]), - } -) -@websocket_api.async_response -async def websocket_add_group_members( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Add members to a ZHA group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - group_id: int = msg[GROUP_ID] - members: list[GroupMember] = msg[ATTR_MEMBERS] - - if not (zha_group := zha_gateway.groups.get(group_id)): - connection.send_message( - websocket_api.error_message( - msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" - ) - ) - return - - await zha_group.async_add_members(members) - ret_group = zha_group.group_info - connection.send_result(msg[ID], ret_group) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/group/members/remove", - vol.Required(GROUP_ID): cv.positive_int, - vol.Required(ATTR_MEMBERS): vol.All(cv.ensure_list, [GROUP_MEMBER_SCHEMA]), - } -) -@websocket_api.async_response -async def websocket_remove_group_members( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Remove members from a ZHA group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - group_id: int = msg[GROUP_ID] - members: list[GroupMember] = msg[ATTR_MEMBERS] - - if not (zha_group := zha_gateway.groups.get(group_id)): - connection.send_message( - websocket_api.error_message( - msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" - ) - ) - return - - await zha_group.async_remove_members(members) - ret_group = zha_group.group_info - connection.send_result(msg[ID], ret_group) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/reconfigure", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - } -) -@websocket_api.async_response -async def websocket_reconfigure_node( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Reconfigure a ZHA nodes entities by its ieee address.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = msg[ATTR_IEEE] - device: ZHADevice | None = zha_gateway.get_device(ieee) - - async def forward_messages(data): - """Forward events to websocket.""" - connection.send_message(websocket_api.event_message(msg["id"], data)) - - remove_dispatcher_function = async_dispatcher_connect( - hass, ZHA_CHANNEL_MSG, forward_messages - ) - - @callback - def async_cleanup() -> None: - """Remove signal listener.""" - remove_dispatcher_function() - - connection.subscriptions[msg["id"]] = async_cleanup - - _LOGGER.debug("Reconfiguring node with ieee_address: %s", ieee) - assert device - hass.async_create_task(device.async_configure()) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/topology/update", - } -) -@websocket_api.async_response -async def websocket_update_topology( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Update the ZHA network topology.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - hass.async_create_task(zha_gateway.application_controller.topology.scan()) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/clusters", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - } -) -@websocket_api.async_response -async def websocket_device_clusters( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Return a list of device clusters.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = msg[ATTR_IEEE] - zha_device = zha_gateway.get_device(ieee) - response_clusters = [] - if zha_device is not None: - clusters_by_endpoint = zha_device.async_get_clusters() - for ep_id, clusters in clusters_by_endpoint.items(): - for c_id, cluster in clusters[CLUSTER_TYPE_IN].items(): - response_clusters.append( - { - TYPE: CLUSTER_TYPE_IN, - ID: c_id, - ATTR_NAME: cluster.__class__.__name__, - "endpoint_id": ep_id, - } - ) - for c_id, cluster in clusters[CLUSTER_TYPE_OUT].items(): - response_clusters.append( - { - TYPE: CLUSTER_TYPE_OUT, - ID: c_id, - ATTR_NAME: cluster.__class__.__name__, - "endpoint_id": ep_id, - } - ) - - connection.send_result(msg[ID], response_clusters) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/clusters/attributes", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_ENDPOINT_ID): int, - vol.Required(ATTR_CLUSTER_ID): int, - vol.Required(ATTR_CLUSTER_TYPE): str, - } -) -@websocket_api.async_response -async def websocket_device_cluster_attributes( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Return a list of cluster attributes.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = msg[ATTR_IEEE] - endpoint_id: int = msg[ATTR_ENDPOINT_ID] - cluster_id: int = msg[ATTR_CLUSTER_ID] - cluster_type: str = msg[ATTR_CLUSTER_TYPE] - cluster_attributes: list[dict[str, Any]] = [] - zha_device = zha_gateway.get_device(ieee) - attributes = None - if zha_device is not None: - attributes = zha_device.async_get_cluster_attributes( - endpoint_id, cluster_id, cluster_type - ) - if attributes is not None: - for attr_id, attr in attributes.items(): - cluster_attributes.append({ID: attr_id, ATTR_NAME: attr.name}) - _LOGGER.debug( - "Requested attributes for: %s: %s, %s: '%s', %s: %s, %s: %s", - ATTR_CLUSTER_ID, - cluster_id, - ATTR_CLUSTER_TYPE, - cluster_type, - ATTR_ENDPOINT_ID, - endpoint_id, - RESPONSE, - cluster_attributes, - ) - - connection.send_result(msg[ID], cluster_attributes) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/clusters/commands", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_ENDPOINT_ID): int, - vol.Required(ATTR_CLUSTER_ID): int, - vol.Required(ATTR_CLUSTER_TYPE): str, - } -) -@websocket_api.async_response -async def websocket_device_cluster_commands( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Return a list of cluster commands.""" - import voluptuous_serialize # pylint: disable=import-outside-toplevel - - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = msg[ATTR_IEEE] - endpoint_id: int = msg[ATTR_ENDPOINT_ID] - cluster_id: int = msg[ATTR_CLUSTER_ID] - cluster_type: str = msg[ATTR_CLUSTER_TYPE] - zha_device = zha_gateway.get_device(ieee) - cluster_commands: list[dict[str, Any]] = [] - commands = None - if zha_device is not None: - commands = zha_device.async_get_cluster_commands( - endpoint_id, cluster_id, cluster_type - ) - - if commands is not None: - for cmd_id, cmd in commands[CLUSTER_COMMANDS_CLIENT].items(): - cluster_commands.append( - { - TYPE: CLIENT, - ID: cmd_id, - ATTR_NAME: cmd.name, - "schema": voluptuous_serialize.convert( - cluster_command_schema_to_vol_schema(cmd.schema), - custom_serializer=cv.custom_serializer, - ), - } - ) - for cmd_id, cmd in commands[CLUSTER_COMMANDS_SERVER].items(): - cluster_commands.append( - { - TYPE: CLUSTER_COMMAND_SERVER, - ID: cmd_id, - ATTR_NAME: cmd.name, - "schema": voluptuous_serialize.convert( - cluster_command_schema_to_vol_schema(cmd.schema), - custom_serializer=cv.custom_serializer, - ), - } - ) - _LOGGER.debug( - "Requested commands for: %s: %s, %s: '%s', %s: %s, %s: %s", - ATTR_CLUSTER_ID, - cluster_id, - ATTR_CLUSTER_TYPE, - cluster_type, - ATTR_ENDPOINT_ID, - endpoint_id, - RESPONSE, - cluster_commands, - ) - - connection.send_result(msg[ID], cluster_commands) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/clusters/attributes/value", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_ENDPOINT_ID): int, - vol.Required(ATTR_CLUSTER_ID): int, - vol.Required(ATTR_CLUSTER_TYPE): str, - vol.Required(ATTR_ATTRIBUTE): int, - vol.Optional(ATTR_MANUFACTURER): cv.positive_int, - } -) -@websocket_api.async_response -async def websocket_read_zigbee_cluster_attributes( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Read zigbee attribute for cluster on ZHA entity.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = msg[ATTR_IEEE] - endpoint_id: int = msg[ATTR_ENDPOINT_ID] - cluster_id: int = msg[ATTR_CLUSTER_ID] - cluster_type: str = msg[ATTR_CLUSTER_TYPE] - attribute: int = msg[ATTR_ATTRIBUTE] - manufacturer: int | None = msg.get(ATTR_MANUFACTURER) - zha_device = zha_gateway.get_device(ieee) - success = {} - failure = {} - if zha_device is not None: - if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: - manufacturer = zha_device.manufacturer_code - cluster = zha_device.async_get_cluster( - endpoint_id, cluster_id, cluster_type=cluster_type - ) - success, failure = await cluster.read_attributes( - [attribute], allow_cache=False, only_cache=False, manufacturer=manufacturer - ) - _LOGGER.debug( - ( - "Read attribute for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s]" - " %s: [%s]," - ), - ATTR_CLUSTER_ID, - cluster_id, - ATTR_CLUSTER_TYPE, - cluster_type, - ATTR_ENDPOINT_ID, - endpoint_id, - ATTR_ATTRIBUTE, - attribute, - ATTR_MANUFACTURER, - manufacturer, - RESPONSE, - str(success.get(attribute)), - "failure", - failure, - ) - connection.send_result(msg[ID], str(success.get(attribute))) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/bindable", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - } -) -@websocket_api.async_response -async def websocket_get_bindable_devices( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Directly bind devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - source_ieee: EUI64 = msg[ATTR_IEEE] - source_device = zha_gateway.get_device(source_ieee) - - devices = [ - device.zha_device_info - for device in zha_gateway.devices.values() - if async_is_bindable_target(source_device, device) - ] - - _LOGGER.debug( - "Get bindable devices: %s: [%s], %s: [%s]", - ATTR_SOURCE_IEEE, - source_ieee, - "bindable devices", - devices, - ) - - connection.send_message(websocket_api.result_message(msg[ID], devices)) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/bind", - vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_TARGET_IEEE): IEEE_SCHEMA, - } -) -@websocket_api.async_response -async def websocket_bind_devices( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Directly bind devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] - target_ieee: EUI64 = msg[ATTR_TARGET_IEEE] - await async_binding_operation( - zha_gateway, source_ieee, target_ieee, zdo_types.ZDOCmd.Bind_req - ) - _LOGGER.info( - "Devices bound: %s: [%s] %s: [%s]", - ATTR_SOURCE_IEEE, - source_ieee, - ATTR_TARGET_IEEE, - target_ieee, - ) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/unbind", - vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_TARGET_IEEE): IEEE_SCHEMA, - } -) -@websocket_api.async_response -async def websocket_unbind_devices( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Remove a direct binding between devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] - target_ieee: EUI64 = msg[ATTR_TARGET_IEEE] - await async_binding_operation( - zha_gateway, source_ieee, target_ieee, zdo_types.ZDOCmd.Unbind_req - ) - _LOGGER.info( - "Devices un-bound: %s: [%s] %s: [%s]", - ATTR_SOURCE_IEEE, - source_ieee, - ATTR_TARGET_IEEE, - target_ieee, - ) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/groups/bind", - vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, - vol.Required(GROUP_ID): cv.positive_int, - vol.Required(BINDINGS): vol.All(cv.ensure_list, [CLUSTER_BINDING_SCHEMA]), - } -) -@websocket_api.async_response -async def websocket_bind_group( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Directly bind a device to a group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] - group_id: int = msg[GROUP_ID] - bindings: list[ClusterBinding] = msg[BINDINGS] - source_device = zha_gateway.get_device(source_ieee) - assert source_device - await source_device.async_bind_to_group(group_id, bindings) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/groups/unbind", - vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, - vol.Required(GROUP_ID): cv.positive_int, - vol.Required(BINDINGS): vol.All(cv.ensure_list, [CLUSTER_BINDING_SCHEMA]), - } -) -@websocket_api.async_response -async def websocket_unbind_group( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Unbind a device from a group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] - group_id: int = msg[GROUP_ID] - bindings: list[ClusterBinding] = msg[BINDINGS] - source_device = zha_gateway.get_device(source_ieee) - assert source_device - await source_device.async_unbind_from_group(group_id, bindings) - - -async def async_binding_operation( - zha_gateway: ZHAGateway, - source_ieee: EUI64, - target_ieee: EUI64, - operation: zdo_types.ZDOCmd, -) -> None: - """Create or remove a direct zigbee binding between 2 devices.""" - - source_device = zha_gateway.get_device(source_ieee) - target_device = zha_gateway.get_device(target_ieee) - - assert source_device - assert target_device - clusters_to_bind = await get_matched_clusters(source_device, target_device) - - zdo = source_device.device.zdo - bind_tasks = [] - for binding_pair in clusters_to_bind: - op_msg = "cluster: %s %s --> [%s]" - op_params = ( - binding_pair.source_cluster.cluster_id, - operation.name, - target_ieee, - ) - zdo.debug(f"processing {op_msg}", *op_params) - - bind_tasks.append( - ( - zdo.request( - operation, - source_device.ieee, - binding_pair.source_cluster.endpoint.endpoint_id, - binding_pair.source_cluster.cluster_id, - binding_pair.destination_address, - ), - op_msg, - op_params, - ) - ) - res = await asyncio.gather(*(t[0] for t in bind_tasks), return_exceptions=True) - for outcome, log_msg in zip(res, bind_tasks): - if isinstance(outcome, Exception): - fmt = f"{log_msg[1]} failed: %s" - else: - fmt = f"{log_msg[1]} completed: %s" - zdo.debug(fmt, *(log_msg[2] + (outcome,))) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/configuration"}) -@websocket_api.async_response -async def websocket_get_configuration( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA configuration.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - import voluptuous_serialize # pylint: disable=import-outside-toplevel - - def custom_serializer(schema: Any) -> Any: - """Serialize additional types for voluptuous_serialize.""" - if schema is cv_boolean: - return {"type": "bool"} - if schema is vol.Schema: - return voluptuous_serialize.convert( - schema, custom_serializer=custom_serializer - ) - - return cv.custom_serializer(schema) - - data: dict[str, dict[str, Any]] = {"schemas": {}, "data": {}} - for section, schema in ZHA_CONFIG_SCHEMAS.items(): - if section == ZHA_ALARM_OPTIONS and not async_cluster_exists( - hass, IasAce.cluster_id - ): - continue - data["schemas"][section] = voluptuous_serialize.convert( - schema, custom_serializer=custom_serializer - ) - data["data"][section] = zha_gateway.config_entry.options.get( - CUSTOM_CONFIGURATION, {} - ).get(section, {}) - - # send default values for unconfigured options - for entry in data["schemas"][section]: - if data["data"][section].get(entry["name"]) is None: - data["data"][section][entry["name"]] = entry["default"] - - connection.send_result(msg[ID], data) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/configuration/update", - vol.Required("data"): ZHA_CONFIG_SCHEMAS, - } -) -@websocket_api.async_response -async def websocket_update_zha_configuration( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Update the ZHA configuration.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - options = zha_gateway.config_entry.options - data_to_save = {**options, **{CUSTOM_CONFIGURATION: msg["data"]}} - - for section, schema in ZHA_CONFIG_SCHEMAS.items(): - for entry in schema.schema: - # remove options that match defaults - if ( - data_to_save[CUSTOM_CONFIGURATION].get(section, {}).get(entry) - == entry.default() - ): - data_to_save[CUSTOM_CONFIGURATION][section].pop(entry) - # remove entire section block if empty - if ( - not data_to_save[CUSTOM_CONFIGURATION].get(section) - and section in data_to_save[CUSTOM_CONFIGURATION] - ): - data_to_save[CUSTOM_CONFIGURATION].pop(section) - - # remove entire custom_configuration block if empty - if ( - not data_to_save.get(CUSTOM_CONFIGURATION) - and CUSTOM_CONFIGURATION in data_to_save - ): - data_to_save.pop(CUSTOM_CONFIGURATION) - - _LOGGER.info( - "Updating ZHA custom configuration options from %s to %s", - options, - data_to_save, - ) - - hass.config_entries.async_update_entry( - zha_gateway.config_entry, options=data_to_save - ) - status = await hass.config_entries.async_reload(zha_gateway.config_entry.entry_id) - connection.send_result(msg[ID], status) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/settings"}) -@websocket_api.async_response -async def websocket_get_network_settings( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA network settings.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - application_controller = zha_gateway.application_controller - - # Serialize the current network settings - backup = NetworkBackup( - node_info=application_controller.state.node_info, - network_info=application_controller.state.network_info, - ) - - connection.send_result( - msg[ID], - { - "radio_type": zha_gateway.config_entry.data[CONF_RADIO_TYPE], - "settings": backup.as_dict(), - }, - ) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/backups/list"}) -@websocket_api.async_response -async def websocket_list_network_backups( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA network settings.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - application_controller = zha_gateway.application_controller - - # Serialize known backups - connection.send_result( - msg[ID], [backup.as_dict() for backup in application_controller.backups] - ) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/backups/create"}) -@websocket_api.async_response -async def websocket_create_network_backup( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Create a ZHA network backup.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - application_controller = zha_gateway.application_controller - - # This can take 5-30s - backup = await application_controller.backups.create_backup(load_devices=True) - connection.send_result( - msg[ID], - { - "backup": backup.as_dict(), - "is_complete": backup.is_complete(), - }, - ) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/network/backups/restore", - vol.Required("backup"): _cv_zigpy_network_backup, - vol.Optional("ezsp_force_write_eui64", default=False): cv.boolean, - } -) -@websocket_api.async_response -async def websocket_restore_network_backup( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Restore a ZHA network backup.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - application_controller = zha_gateway.application_controller - backup = msg["backup"] - - if msg["ezsp_force_write_eui64"]: - backup.network_info.stack_specific.setdefault("ezsp", {})[ - EZSP_OVERWRITE_EUI64 - ] = True - - # This can take 30-40s - try: - await application_controller.backups.restore_backup(backup) - except ValueError as err: - connection.send_error(msg[ID], websocket_api.const.ERR_INVALID_FORMAT, str(err)) - else: - connection.send_result(msg[ID]) - - -@callback -def async_load_api(hass: HomeAssistant) -> None: - """Set up the web socket API.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - application_controller = zha_gateway.application_controller - - async def permit(service: ServiceCall) -> None: - """Allow devices to join this network.""" - duration: int = service.data[ATTR_DURATION] - ieee: EUI64 | None = service.data.get(ATTR_IEEE) - src_ieee: EUI64 - code: bytes - if ATTR_SOURCE_IEEE in service.data: - src_ieee = service.data[ATTR_SOURCE_IEEE] - code = service.data[ATTR_INSTALL_CODE] - _LOGGER.info("Allowing join for %s device with install code", src_ieee) - await application_controller.permit_with_key( - time_s=duration, node=src_ieee, code=code - ) - return - - if ATTR_QR_CODE in service.data: - src_ieee, code = service.data[ATTR_QR_CODE] - _LOGGER.info("Allowing join for %s device with install code", src_ieee) - await application_controller.permit_with_key( - time_s=duration, node=src_ieee, code=code - ) - return - - if ieee: - _LOGGER.info("Permitting joins for %ss on %s device", duration, ieee) - else: - _LOGGER.info("Permitting joins for %ss", duration) - await application_controller.permit(time_s=duration, node=ieee) - - async_register_admin_service( - hass, DOMAIN, SERVICE_PERMIT, permit, schema=SERVICE_SCHEMAS[SERVICE_PERMIT] - ) - - async def remove(service: ServiceCall) -> None: - """Remove a node from the network.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = service.data[ATTR_IEEE] - zha_device: ZHADevice | None = zha_gateway.get_device(ieee) - if zha_device is not None and zha_device.is_active_coordinator: - _LOGGER.info("Removing the coordinator (%s) is not allowed", ieee) - return - _LOGGER.info("Removing node %s", ieee) - await application_controller.remove(ieee) - - async_register_admin_service( - hass, DOMAIN, SERVICE_REMOVE, remove, schema=SERVICE_SCHEMAS[IEEE_SERVICE] - ) - - async def set_zigbee_cluster_attributes(service: ServiceCall) -> None: - """Set zigbee attribute for cluster on zha entity.""" - ieee: EUI64 = service.data[ATTR_IEEE] - endpoint_id: int = service.data[ATTR_ENDPOINT_ID] - cluster_id: int = service.data[ATTR_CLUSTER_ID] - cluster_type: str = service.data[ATTR_CLUSTER_TYPE] - attribute: int | str = service.data[ATTR_ATTRIBUTE] - value: int | bool | str = service.data[ATTR_VALUE] - manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) - zha_device = zha_gateway.get_device(ieee) - response = None - if zha_device is not None: - if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: - manufacturer = zha_device.manufacturer_code - response = await zha_device.write_zigbee_attribute( - endpoint_id, - cluster_id, - attribute, - value, - cluster_type=cluster_type, - manufacturer=manufacturer, - ) - _LOGGER.debug( - ( - "Set attribute for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s:" - " [%s] %s: [%s]" - ), - ATTR_CLUSTER_ID, - cluster_id, - ATTR_CLUSTER_TYPE, - cluster_type, - ATTR_ENDPOINT_ID, - endpoint_id, - ATTR_ATTRIBUTE, - attribute, - ATTR_VALUE, - value, - ATTR_MANUFACTURER, - manufacturer, - RESPONSE, - response, - ) - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE, - set_zigbee_cluster_attributes, - schema=SERVICE_SCHEMAS[SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE], - ) - - async def issue_zigbee_cluster_command(service: ServiceCall) -> None: - """Issue command on zigbee cluster on ZHA entity.""" - ieee: EUI64 = service.data[ATTR_IEEE] - endpoint_id: int = service.data[ATTR_ENDPOINT_ID] - cluster_id: int = service.data[ATTR_CLUSTER_ID] - cluster_type: str = service.data[ATTR_CLUSTER_TYPE] - command: int = service.data[ATTR_COMMAND] - command_type: str = service.data[ATTR_COMMAND_TYPE] - args: list | None = service.data.get(ATTR_ARGS) - params: dict | None = service.data.get(ATTR_PARAMS) - manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) - zha_device = zha_gateway.get_device(ieee) - if zha_device is not None: - if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: - manufacturer = zha_device.manufacturer_code - - await zha_device.issue_cluster_command( - endpoint_id, - cluster_id, - command, - command_type, - args, - params, - cluster_type=cluster_type, - manufacturer=manufacturer, - ) - _LOGGER.debug( - ( - "Issued command for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s]" - " %s: [%s] %s: [%s] %s: [%s]" - ), - ATTR_CLUSTER_ID, - cluster_id, - ATTR_CLUSTER_TYPE, - cluster_type, - ATTR_ENDPOINT_ID, - endpoint_id, - ATTR_COMMAND, - command, - ATTR_COMMAND_TYPE, - command_type, - ATTR_ARGS, - args, - ATTR_PARAMS, - params, - ATTR_MANUFACTURER, - manufacturer, - ) - else: - raise ValueError(f"Device with IEEE {str(ieee)} not found") - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND, - issue_zigbee_cluster_command, - schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND], - ) - - async def issue_zigbee_group_command(service: ServiceCall) -> None: - """Issue command on zigbee cluster on a zigbee group.""" - group_id: int = service.data[ATTR_GROUP] - cluster_id: int = service.data[ATTR_CLUSTER_ID] - command: int = service.data[ATTR_COMMAND] - args: list = service.data[ATTR_ARGS] - manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) - group = zha_gateway.get_group(group_id) - if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: - _LOGGER.error("Missing manufacturer attribute for cluster: %d", cluster_id) - response = None - if group is not None: - cluster = group.endpoint[cluster_id] - response = await cluster.command( - command, *args, manufacturer=manufacturer, expect_reply=True - ) - _LOGGER.debug( - "Issued group command for: %s: [%s] %s: [%s] %s: %s %s: [%s] %s: %s", - ATTR_CLUSTER_ID, - cluster_id, - ATTR_COMMAND, - command, - ATTR_ARGS, - args, - ATTR_MANUFACTURER, - manufacturer, - RESPONSE, - response, - ) - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND, - issue_zigbee_group_command, - schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND], - ) - - def _get_ias_wd_channel(zha_device): - """Get the IASWD channel for a device.""" - cluster_channels = { - ch.name: ch - for pool in zha_device.channels.pools - for ch in pool.claimed_channels.values() - } - return cluster_channels.get(CHANNEL_IAS_WD) - - async def warning_device_squawk(service: ServiceCall) -> None: - """Issue the squawk command for an IAS warning device.""" - ieee: EUI64 = service.data[ATTR_IEEE] - mode: int = service.data[ATTR_WARNING_DEVICE_MODE] - strobe: int = service.data[ATTR_WARNING_DEVICE_STROBE] - level: int = service.data[ATTR_LEVEL] - - if (zha_device := zha_gateway.get_device(ieee)) is not None: - if channel := _get_ias_wd_channel(zha_device): - await channel.issue_squawk(mode, strobe, level) - else: - _LOGGER.error( - "Squawking IASWD: %s: [%s] is missing the required IASWD channel!", - ATTR_IEEE, - str(ieee), - ) - else: - _LOGGER.error( - "Squawking IASWD: %s: [%s] could not be found!", ATTR_IEEE, str(ieee) - ) - _LOGGER.debug( - "Squawking IASWD: %s: [%s] %s: [%s] %s: [%s] %s: [%s]", - ATTR_IEEE, - str(ieee), - ATTR_WARNING_DEVICE_MODE, - mode, - ATTR_WARNING_DEVICE_STROBE, - strobe, - ATTR_LEVEL, - level, - ) - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_WARNING_DEVICE_SQUAWK, - warning_device_squawk, - schema=SERVICE_SCHEMAS[SERVICE_WARNING_DEVICE_SQUAWK], - ) - - async def warning_device_warn(service: ServiceCall) -> None: - """Issue the warning command for an IAS warning device.""" - ieee: EUI64 = service.data[ATTR_IEEE] - mode: int = service.data[ATTR_WARNING_DEVICE_MODE] - strobe: int = service.data[ATTR_WARNING_DEVICE_STROBE] - level: int = service.data[ATTR_LEVEL] - duration: int = service.data[ATTR_WARNING_DEVICE_DURATION] - duty_mode: int = service.data[ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE] - intensity: int = service.data[ATTR_WARNING_DEVICE_STROBE_INTENSITY] - - if (zha_device := zha_gateway.get_device(ieee)) is not None: - if channel := _get_ias_wd_channel(zha_device): - await channel.issue_start_warning( - mode, strobe, level, duration, duty_mode, intensity - ) - else: - _LOGGER.error( - "Warning IASWD: %s: [%s] is missing the required IASWD channel!", - ATTR_IEEE, - str(ieee), - ) - else: - _LOGGER.error( - "Warning IASWD: %s: [%s] could not be found!", ATTR_IEEE, str(ieee) - ) - _LOGGER.debug( - "Warning IASWD: %s: [%s] %s: [%s] %s: [%s] %s: [%s]", - ATTR_IEEE, - str(ieee), - ATTR_WARNING_DEVICE_MODE, - mode, - ATTR_WARNING_DEVICE_STROBE, - strobe, - ATTR_LEVEL, - level, - ) - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_WARNING_DEVICE_WARN, - warning_device_warn, - schema=SERVICE_SCHEMAS[SERVICE_WARNING_DEVICE_WARN], - ) - - websocket_api.async_register_command(hass, websocket_permit_devices) - websocket_api.async_register_command(hass, websocket_get_devices) - websocket_api.async_register_command(hass, websocket_get_groupable_devices) - websocket_api.async_register_command(hass, websocket_get_groups) - websocket_api.async_register_command(hass, websocket_get_device) - websocket_api.async_register_command(hass, websocket_get_group) - websocket_api.async_register_command(hass, websocket_add_group) - websocket_api.async_register_command(hass, websocket_remove_groups) - websocket_api.async_register_command(hass, websocket_add_group_members) - websocket_api.async_register_command(hass, websocket_remove_group_members) - websocket_api.async_register_command(hass, websocket_bind_group) - websocket_api.async_register_command(hass, websocket_unbind_group) - websocket_api.async_register_command(hass, websocket_reconfigure_node) - websocket_api.async_register_command(hass, websocket_device_clusters) - websocket_api.async_register_command(hass, websocket_device_cluster_attributes) - websocket_api.async_register_command(hass, websocket_device_cluster_commands) - websocket_api.async_register_command(hass, websocket_read_zigbee_cluster_attributes) - websocket_api.async_register_command(hass, websocket_get_bindable_devices) - websocket_api.async_register_command(hass, websocket_bind_devices) - websocket_api.async_register_command(hass, websocket_unbind_devices) - websocket_api.async_register_command(hass, websocket_update_topology) - websocket_api.async_register_command(hass, websocket_get_configuration) - websocket_api.async_register_command(hass, websocket_update_zha_configuration) - websocket_api.async_register_command(hass, websocket_get_network_settings) - websocket_api.async_register_command(hass, websocket_list_network_backups) - websocket_api.async_register_command(hass, websocket_create_network_backup) - websocket_api.async_register_command(hass, websocket_restore_network_backup) - - -@callback -def async_unload_api(hass: HomeAssistant) -> None: - """Unload the ZHA API.""" - hass.services.async_remove(DOMAIN, SERVICE_PERMIT) - hass.services.async_remove(DOMAIN, SERVICE_REMOVE) - hass.services.async_remove(DOMAIN, SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE) - hass.services.async_remove(DOMAIN, SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND) - hass.services.async_remove(DOMAIN, SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND) - hass.services.async_remove(DOMAIN, SERVICE_WARNING_DEVICE_SQUAWK) - hass.services.async_remove(DOMAIN, SERVICE_WARNING_DEVICE_WARN) + return config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 17ec04fa9e..9d40314e06 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -84,7 +84,7 @@ from .const import ( from .helpers import LogMixin, async_get_zha_config_value, convert_to_zcl_values if TYPE_CHECKING: - from ..api import ClusterBinding + from ..websocket_api import ClusterBinding from .gateway import ZHAGateway _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 1bc77d3f36..3f9ada1ed0 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -148,14 +148,8 @@ class ZHAGateway: self._unsubs: list[Callable[[], None]] = [] self.initialized: bool = False - async def async_initialize(self) -> None: - """Initialize controller and connect radio.""" - discovery.PROBE.initialize(self._hass) - discovery.GROUP_PROBE.initialize(self._hass) - - self.ha_device_registry = dr.async_get(self._hass) - self.ha_entity_registry = er.async_get(self._hass) - + def get_application_controller_data(self) -> tuple[ControllerApplication, dict]: + """Get an uninitialized instance of a zigpy `ControllerApplication`.""" radio_type = self.config_entry.data[CONF_RADIO_TYPE] app_controller_cls = RadioType[radio_type].controller @@ -178,7 +172,17 @@ class ZHAGateway: ): app_config[CONF_USE_THREAD] = False - app_config = app_controller_cls.SCHEMA(app_config) + return app_controller_cls, app_controller_cls.SCHEMA(app_config) + + async def async_initialize(self) -> None: + """Initialize controller and connect radio.""" + discovery.PROBE.initialize(self._hass) + discovery.GROUP_PROBE.initialize(self._hass) + + self.ha_device_registry = dr.async_get(self._hass) + self.ha_entity_registry = er.async_get(self._hass) + + app_controller_cls, app_config = self.get_application_controller_data() for attempt in range(STARTUP_RETRIES): try: diff --git a/homeassistant/components/zha/device_action.py b/homeassistant/components/zha/device_action.py index 9867bc5cfb..25a01f45ba 100644 --- a/homeassistant/components/zha/device_action.py +++ b/homeassistant/components/zha/device_action.py @@ -12,10 +12,10 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN -from .api import SERVICE_WARNING_DEVICE_SQUAWK, SERVICE_WARNING_DEVICE_WARN from .core.channels.manufacturerspecific import AllLEDEffectType, SingleLEDEffectType from .core.const import CHANNEL_IAS_WD, CHANNEL_INOVELLI from .core.helpers import async_get_zha_device +from .websocket_api import SERVICE_WARNING_DEVICE_SQUAWK, SERVICE_WARNING_DEVICE_WARN # mypy: disallow-any-generics diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py new file mode 100644 index 0000000000..d2da6af012 --- /dev/null +++ b/homeassistant/components/zha/websocket_api.py @@ -0,0 +1,1541 @@ +"""Web socket API for Zigbee Home Automation devices.""" +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar, cast + +import voluptuous as vol +import zigpy.backups +from zigpy.config.validators import cv_boolean +from zigpy.types.named import EUI64 +from zigpy.zcl.clusters.security import IasAce +import zigpy.zdo.types as zdo_types + +from homeassistant.components import websocket_api +from homeassistant.const import ATTR_COMMAND, ATTR_ID, ATTR_NAME +from homeassistant.core import HomeAssistant, ServiceCall, callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.service import async_register_admin_service + +from .api import async_get_active_network_settings, async_get_radio_type +from .core.const import ( + ATTR_ARGS, + ATTR_ATTRIBUTE, + ATTR_CLUSTER_ID, + ATTR_CLUSTER_TYPE, + ATTR_COMMAND_TYPE, + ATTR_ENDPOINT_ID, + ATTR_IEEE, + ATTR_LEVEL, + ATTR_MANUFACTURER, + ATTR_MEMBERS, + ATTR_PARAMS, + ATTR_TYPE, + ATTR_VALUE, + ATTR_WARNING_DEVICE_DURATION, + ATTR_WARNING_DEVICE_MODE, + ATTR_WARNING_DEVICE_STROBE, + ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE, + ATTR_WARNING_DEVICE_STROBE_INTENSITY, + BINDINGS, + CHANNEL_IAS_WD, + CLUSTER_COMMAND_SERVER, + CLUSTER_COMMANDS_CLIENT, + CLUSTER_COMMANDS_SERVER, + CLUSTER_TYPE_IN, + CLUSTER_TYPE_OUT, + CUSTOM_CONFIGURATION, + DATA_ZHA, + DATA_ZHA_GATEWAY, + DOMAIN, + EZSP_OVERWRITE_EUI64, + GROUP_ID, + GROUP_IDS, + GROUP_NAME, + MFG_CLUSTER_ID_START, + WARNING_DEVICE_MODE_EMERGENCY, + WARNING_DEVICE_SOUND_HIGH, + WARNING_DEVICE_SQUAWK_MODE_ARMED, + WARNING_DEVICE_STROBE_HIGH, + WARNING_DEVICE_STROBE_YES, + ZHA_ALARM_OPTIONS, + ZHA_CHANNEL_MSG, + ZHA_CONFIG_SCHEMAS, +) +from .core.gateway import EntityReference +from .core.group import GroupMember +from .core.helpers import ( + async_cluster_exists, + async_is_bindable_target, + cluster_command_schema_to_vol_schema, + convert_install_code, + get_matched_clusters, + qr_to_install_code, +) + +if TYPE_CHECKING: + from homeassistant.components.websocket_api.connection import ActiveConnection + + from .core.device import ZHADevice + from .core.gateway import ZHAGateway + +_LOGGER = logging.getLogger(__name__) + +TYPE = "type" +CLIENT = "client" +ID = "id" +RESPONSE = "response" +DEVICE_INFO = "device_info" + +ATTR_DURATION = "duration" +ATTR_GROUP = "group" +ATTR_IEEE_ADDRESS = "ieee_address" +ATTR_INSTALL_CODE = "install_code" +ATTR_SOURCE_IEEE = "source_ieee" +ATTR_TARGET_IEEE = "target_ieee" +ATTR_QR_CODE = "qr_code" + +SERVICE_PERMIT = "permit" +SERVICE_REMOVE = "remove" +SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE = "set_zigbee_cluster_attribute" +SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND = "issue_zigbee_cluster_command" +SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND = "issue_zigbee_group_command" +SERVICE_DIRECT_ZIGBEE_BIND = "issue_direct_zigbee_bind" +SERVICE_DIRECT_ZIGBEE_UNBIND = "issue_direct_zigbee_unbind" +SERVICE_WARNING_DEVICE_SQUAWK = "warning_device_squawk" +SERVICE_WARNING_DEVICE_WARN = "warning_device_warn" +SERVICE_ZIGBEE_BIND = "service_zigbee_bind" +IEEE_SERVICE = "ieee_based_service" + +IEEE_SCHEMA = vol.All(cv.string, EUI64.convert) + +# typing typevar +_T = TypeVar("_T") + + +def _ensure_list_if_present(value: _T | None) -> list[_T] | list[Any] | None: + """Wrap value in list if it is provided and not one.""" + if value is None: + return None + return cast("list[_T]", value) if isinstance(value, list) else [value] + + +SERVICE_PERMIT_PARAMS = { + vol.Optional(ATTR_IEEE): IEEE_SCHEMA, + vol.Optional(ATTR_DURATION, default=60): vol.All( + vol.Coerce(int), vol.Range(0, 254) + ), + vol.Inclusive(ATTR_SOURCE_IEEE, "install_code"): IEEE_SCHEMA, + vol.Inclusive(ATTR_INSTALL_CODE, "install_code"): vol.All( + cv.string, convert_install_code + ), + vol.Exclusive(ATTR_QR_CODE, "install_code"): vol.All(cv.string, qr_to_install_code), +} + +SERVICE_SCHEMAS = { + SERVICE_PERMIT: vol.Schema( + vol.All( + cv.deprecated(ATTR_IEEE_ADDRESS, replacement_key=ATTR_IEEE), + SERVICE_PERMIT_PARAMS, + ) + ), + IEEE_SERVICE: vol.Schema( + vol.All( + cv.deprecated(ATTR_IEEE_ADDRESS, replacement_key=ATTR_IEEE), + {vol.Required(ATTR_IEEE): IEEE_SCHEMA}, + ) + ), + SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE: vol.Schema( + { + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): cv.positive_int, + vol.Required(ATTR_CLUSTER_ID): cv.positive_int, + vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, + vol.Required(ATTR_ATTRIBUTE): vol.Any(cv.positive_int, str), + vol.Required(ATTR_VALUE): vol.Any(int, cv.boolean, cv.string), + vol.Optional(ATTR_MANUFACTURER): cv.positive_int, + } + ), + SERVICE_WARNING_DEVICE_SQUAWK: vol.Schema( + { + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Optional( + ATTR_WARNING_DEVICE_MODE, default=WARNING_DEVICE_SQUAWK_MODE_ARMED + ): cv.positive_int, + vol.Optional( + ATTR_WARNING_DEVICE_STROBE, default=WARNING_DEVICE_STROBE_YES + ): cv.positive_int, + vol.Optional( + ATTR_LEVEL, default=WARNING_DEVICE_SOUND_HIGH + ): cv.positive_int, + } + ), + SERVICE_WARNING_DEVICE_WARN: vol.Schema( + { + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Optional( + ATTR_WARNING_DEVICE_MODE, default=WARNING_DEVICE_MODE_EMERGENCY + ): cv.positive_int, + vol.Optional( + ATTR_WARNING_DEVICE_STROBE, default=WARNING_DEVICE_STROBE_YES + ): cv.positive_int, + vol.Optional( + ATTR_LEVEL, default=WARNING_DEVICE_SOUND_HIGH + ): cv.positive_int, + vol.Optional(ATTR_WARNING_DEVICE_DURATION, default=5): cv.positive_int, + vol.Optional( + ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE, default=0x00 + ): cv.positive_int, + vol.Optional( + ATTR_WARNING_DEVICE_STROBE_INTENSITY, default=WARNING_DEVICE_STROBE_HIGH + ): cv.positive_int, + } + ), + SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND: vol.All( + vol.Schema( + { + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): cv.positive_int, + vol.Required(ATTR_CLUSTER_ID): cv.positive_int, + vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, + vol.Required(ATTR_COMMAND): cv.positive_int, + vol.Required(ATTR_COMMAND_TYPE): cv.string, + vol.Exclusive(ATTR_ARGS, "attrs_params"): _ensure_list_if_present, + vol.Exclusive(ATTR_PARAMS, "attrs_params"): dict, + vol.Optional(ATTR_MANUFACTURER): cv.positive_int, + } + ), + cv.deprecated(ATTR_ARGS), + cv.has_at_least_one_key(ATTR_ARGS, ATTR_PARAMS), + ), + SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND: vol.Schema( + { + vol.Required(ATTR_GROUP): cv.positive_int, + vol.Required(ATTR_CLUSTER_ID): cv.positive_int, + vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, + vol.Required(ATTR_COMMAND): cv.positive_int, + vol.Optional(ATTR_ARGS, default=[]): cv.ensure_list, + vol.Optional(ATTR_MANUFACTURER): cv.positive_int, + } + ), +} + + +class ClusterBinding(NamedTuple): + """Describes a cluster binding.""" + + name: str + type: str + id: int + endpoint_id: int + + +def _cv_group_member(value: dict[str, Any]) -> GroupMember: + """Transform a group member.""" + return GroupMember( + ieee=value[ATTR_IEEE], + endpoint_id=value[ATTR_ENDPOINT_ID], + ) + + +def _cv_cluster_binding(value: dict[str, Any]) -> ClusterBinding: + """Transform a cluster binding.""" + return ClusterBinding( + name=value[ATTR_NAME], + type=value[ATTR_TYPE], + id=value[ATTR_ID], + endpoint_id=value[ATTR_ENDPOINT_ID], + ) + + +def _cv_zigpy_network_backup(value: dict[str, Any]) -> zigpy.backups.NetworkBackup: + """Transform a zigpy network backup.""" + + try: + return zigpy.backups.NetworkBackup.from_dict(value) + except ValueError as err: + raise vol.Invalid(str(err)) from err + + +GROUP_MEMBER_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): vol.Coerce(int), + } + ), + _cv_group_member, +) + + +CLUSTER_BINDING_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(ATTR_NAME): cv.string, + vol.Required(ATTR_TYPE): cv.string, + vol.Required(ATTR_ID): vol.Coerce(int), + vol.Required(ATTR_ENDPOINT_ID): vol.Coerce(int), + } + ), + _cv_cluster_binding, +) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "zha/devices/permit", + **SERVICE_PERMIT_PARAMS, + } +) +@websocket_api.async_response +async def websocket_permit_devices( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Permit ZHA zigbee devices.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + duration: int = msg[ATTR_DURATION] + ieee: EUI64 | None = msg.get(ATTR_IEEE) + + async def forward_messages(data): + """Forward events to websocket.""" + connection.send_message(websocket_api.event_message(msg["id"], data)) + + remove_dispatcher_function = async_dispatcher_connect( + hass, "zha_gateway_message", forward_messages + ) + + @callback + def async_cleanup() -> None: + """Remove signal listener and turn off debug mode.""" + zha_gateway.async_disable_debug_mode() + remove_dispatcher_function() + + connection.subscriptions[msg["id"]] = async_cleanup + zha_gateway.async_enable_debug_mode() + src_ieee: EUI64 + code: bytes + if ATTR_SOURCE_IEEE in msg: + src_ieee = msg[ATTR_SOURCE_IEEE] + code = msg[ATTR_INSTALL_CODE] + _LOGGER.debug("Allowing join for %s device with install code", src_ieee) + await zha_gateway.application_controller.permit_with_key( + time_s=duration, node=src_ieee, code=code + ) + elif ATTR_QR_CODE in msg: + src_ieee, code = msg[ATTR_QR_CODE] + _LOGGER.debug("Allowing join for %s device with install code", src_ieee) + await zha_gateway.application_controller.permit_with_key( + time_s=duration, node=src_ieee, code=code + ) + else: + await zha_gateway.application_controller.permit(time_s=duration, node=ieee) + connection.send_result(msg[ID]) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/devices"}) +@websocket_api.async_response +async def websocket_get_devices( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA devices.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + devices = [device.zha_device_info for device in zha_gateway.devices.values()] + connection.send_result(msg[ID], devices) + + +@callback +def _get_entity_name( + zha_gateway: ZHAGateway, entity_ref: EntityReference +) -> str | None: + entry = zha_gateway.ha_entity_registry.async_get(entity_ref.reference_id) + return entry.name if entry else None + + +@callback +def _get_entity_original_name( + zha_gateway: ZHAGateway, entity_ref: EntityReference +) -> str | None: + entry = zha_gateway.ha_entity_registry.async_get(entity_ref.reference_id) + return entry.original_name if entry else None + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/devices/groupable"}) +@websocket_api.async_response +async def websocket_get_groupable_devices( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA devices that can be grouped.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + + devices = [device for device in zha_gateway.devices.values() if device.is_groupable] + groupable_devices = [] + + for device in devices: + entity_refs = zha_gateway.device_registry[device.ieee] + for ep_id in device.async_get_groupable_endpoints(): + groupable_devices.append( + { + "endpoint_id": ep_id, + "entities": [ + { + "name": _get_entity_name(zha_gateway, entity_ref), + "original_name": _get_entity_original_name( + zha_gateway, entity_ref + ), + } + for entity_ref in entity_refs + if list(entity_ref.cluster_channels.values())[ + 0 + ].cluster.endpoint.endpoint_id + == ep_id + ], + "device": device.zha_device_info, + } + ) + + connection.send_result(msg[ID], groupable_devices) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/groups"}) +@websocket_api.async_response +async def websocket_get_groups( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA groups.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + groups = [group.group_info for group in zha_gateway.groups.values()] + connection.send_result(msg[ID], groups) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/device", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + } +) +@websocket_api.async_response +async def websocket_get_device( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA devices.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = msg[ATTR_IEEE] + + if not (zha_device := zha_gateway.devices.get(ieee)): + connection.send_message( + websocket_api.error_message( + msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Device not found" + ) + ) + return + + device_info = zha_device.zha_device_info + connection.send_result(msg[ID], device_info) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/group", + vol.Required(GROUP_ID): cv.positive_int, + } +) +@websocket_api.async_response +async def websocket_get_group( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA group.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + group_id: int = msg[GROUP_ID] + + if not (zha_group := zha_gateway.groups.get(group_id)): + connection.send_message( + websocket_api.error_message( + msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" + ) + ) + return + + group_info = zha_group.group_info + connection.send_result(msg[ID], group_info) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/group/add", + vol.Required(GROUP_NAME): cv.string, + vol.Optional(GROUP_ID): cv.positive_int, + vol.Optional(ATTR_MEMBERS): vol.All(cv.ensure_list, [GROUP_MEMBER_SCHEMA]), + } +) +@websocket_api.async_response +async def websocket_add_group( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Add a new ZHA group.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + group_name: str = msg[GROUP_NAME] + group_id: int | None = msg.get(GROUP_ID) + members: list[GroupMember] | None = msg.get(ATTR_MEMBERS) + group = await zha_gateway.async_create_zigpy_group(group_name, members, group_id) + assert group + connection.send_result(msg[ID], group.group_info) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/group/remove", + vol.Required(GROUP_IDS): vol.All(cv.ensure_list, [cv.positive_int]), + } +) +@websocket_api.async_response +async def websocket_remove_groups( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Remove the specified ZHA groups.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + group_ids: list[int] = msg[GROUP_IDS] + + if len(group_ids) > 1: + tasks = [] + for group_id in group_ids: + tasks.append(zha_gateway.async_remove_zigpy_group(group_id)) + await asyncio.gather(*tasks) + else: + await zha_gateway.async_remove_zigpy_group(group_ids[0]) + ret_groups = [group.group_info for group in zha_gateway.groups.values()] + connection.send_result(msg[ID], ret_groups) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/group/members/add", + vol.Required(GROUP_ID): cv.positive_int, + vol.Required(ATTR_MEMBERS): vol.All(cv.ensure_list, [GROUP_MEMBER_SCHEMA]), + } +) +@websocket_api.async_response +async def websocket_add_group_members( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Add members to a ZHA group.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + group_id: int = msg[GROUP_ID] + members: list[GroupMember] = msg[ATTR_MEMBERS] + + if not (zha_group := zha_gateway.groups.get(group_id)): + connection.send_message( + websocket_api.error_message( + msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" + ) + ) + return + + await zha_group.async_add_members(members) + ret_group = zha_group.group_info + connection.send_result(msg[ID], ret_group) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/group/members/remove", + vol.Required(GROUP_ID): cv.positive_int, + vol.Required(ATTR_MEMBERS): vol.All(cv.ensure_list, [GROUP_MEMBER_SCHEMA]), + } +) +@websocket_api.async_response +async def websocket_remove_group_members( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Remove members from a ZHA group.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + group_id: int = msg[GROUP_ID] + members: list[GroupMember] = msg[ATTR_MEMBERS] + + if not (zha_group := zha_gateway.groups.get(group_id)): + connection.send_message( + websocket_api.error_message( + msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" + ) + ) + return + + await zha_group.async_remove_members(members) + ret_group = zha_group.group_info + connection.send_result(msg[ID], ret_group) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/reconfigure", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + } +) +@websocket_api.async_response +async def websocket_reconfigure_node( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Reconfigure a ZHA nodes entities by its ieee address.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = msg[ATTR_IEEE] + device: ZHADevice | None = zha_gateway.get_device(ieee) + + async def forward_messages(data): + """Forward events to websocket.""" + connection.send_message(websocket_api.event_message(msg["id"], data)) + + remove_dispatcher_function = async_dispatcher_connect( + hass, ZHA_CHANNEL_MSG, forward_messages + ) + + @callback + def async_cleanup() -> None: + """Remove signal listener.""" + remove_dispatcher_function() + + connection.subscriptions[msg["id"]] = async_cleanup + + _LOGGER.debug("Reconfiguring node with ieee_address: %s", ieee) + assert device + hass.async_create_task(device.async_configure()) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/topology/update", + } +) +@websocket_api.async_response +async def websocket_update_topology( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Update the ZHA network topology.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + hass.async_create_task(zha_gateway.application_controller.topology.scan()) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/clusters", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + } +) +@websocket_api.async_response +async def websocket_device_clusters( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Return a list of device clusters.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = msg[ATTR_IEEE] + zha_device = zha_gateway.get_device(ieee) + response_clusters = [] + if zha_device is not None: + clusters_by_endpoint = zha_device.async_get_clusters() + for ep_id, clusters in clusters_by_endpoint.items(): + for c_id, cluster in clusters[CLUSTER_TYPE_IN].items(): + response_clusters.append( + { + TYPE: CLUSTER_TYPE_IN, + ID: c_id, + ATTR_NAME: cluster.__class__.__name__, + "endpoint_id": ep_id, + } + ) + for c_id, cluster in clusters[CLUSTER_TYPE_OUT].items(): + response_clusters.append( + { + TYPE: CLUSTER_TYPE_OUT, + ID: c_id, + ATTR_NAME: cluster.__class__.__name__, + "endpoint_id": ep_id, + } + ) + + connection.send_result(msg[ID], response_clusters) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/clusters/attributes", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): int, + vol.Required(ATTR_CLUSTER_ID): int, + vol.Required(ATTR_CLUSTER_TYPE): str, + } +) +@websocket_api.async_response +async def websocket_device_cluster_attributes( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Return a list of cluster attributes.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = msg[ATTR_IEEE] + endpoint_id: int = msg[ATTR_ENDPOINT_ID] + cluster_id: int = msg[ATTR_CLUSTER_ID] + cluster_type: str = msg[ATTR_CLUSTER_TYPE] + cluster_attributes: list[dict[str, Any]] = [] + zha_device = zha_gateway.get_device(ieee) + attributes = None + if zha_device is not None: + attributes = zha_device.async_get_cluster_attributes( + endpoint_id, cluster_id, cluster_type + ) + if attributes is not None: + for attr_id, attr in attributes.items(): + cluster_attributes.append({ID: attr_id, ATTR_NAME: attr.name}) + _LOGGER.debug( + "Requested attributes for: %s: %s, %s: '%s', %s: %s, %s: %s", + ATTR_CLUSTER_ID, + cluster_id, + ATTR_CLUSTER_TYPE, + cluster_type, + ATTR_ENDPOINT_ID, + endpoint_id, + RESPONSE, + cluster_attributes, + ) + + connection.send_result(msg[ID], cluster_attributes) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/clusters/commands", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): int, + vol.Required(ATTR_CLUSTER_ID): int, + vol.Required(ATTR_CLUSTER_TYPE): str, + } +) +@websocket_api.async_response +async def websocket_device_cluster_commands( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Return a list of cluster commands.""" + import voluptuous_serialize # pylint: disable=import-outside-toplevel + + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = msg[ATTR_IEEE] + endpoint_id: int = msg[ATTR_ENDPOINT_ID] + cluster_id: int = msg[ATTR_CLUSTER_ID] + cluster_type: str = msg[ATTR_CLUSTER_TYPE] + zha_device = zha_gateway.get_device(ieee) + cluster_commands: list[dict[str, Any]] = [] + commands = None + if zha_device is not None: + commands = zha_device.async_get_cluster_commands( + endpoint_id, cluster_id, cluster_type + ) + + if commands is not None: + for cmd_id, cmd in commands[CLUSTER_COMMANDS_CLIENT].items(): + cluster_commands.append( + { + TYPE: CLIENT, + ID: cmd_id, + ATTR_NAME: cmd.name, + "schema": voluptuous_serialize.convert( + cluster_command_schema_to_vol_schema(cmd.schema), + custom_serializer=cv.custom_serializer, + ), + } + ) + for cmd_id, cmd in commands[CLUSTER_COMMANDS_SERVER].items(): + cluster_commands.append( + { + TYPE: CLUSTER_COMMAND_SERVER, + ID: cmd_id, + ATTR_NAME: cmd.name, + "schema": voluptuous_serialize.convert( + cluster_command_schema_to_vol_schema(cmd.schema), + custom_serializer=cv.custom_serializer, + ), + } + ) + _LOGGER.debug( + "Requested commands for: %s: %s, %s: '%s', %s: %s, %s: %s", + ATTR_CLUSTER_ID, + cluster_id, + ATTR_CLUSTER_TYPE, + cluster_type, + ATTR_ENDPOINT_ID, + endpoint_id, + RESPONSE, + cluster_commands, + ) + + connection.send_result(msg[ID], cluster_commands) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/clusters/attributes/value", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): int, + vol.Required(ATTR_CLUSTER_ID): int, + vol.Required(ATTR_CLUSTER_TYPE): str, + vol.Required(ATTR_ATTRIBUTE): int, + vol.Optional(ATTR_MANUFACTURER): cv.positive_int, + } +) +@websocket_api.async_response +async def websocket_read_zigbee_cluster_attributes( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Read zigbee attribute for cluster on ZHA entity.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = msg[ATTR_IEEE] + endpoint_id: int = msg[ATTR_ENDPOINT_ID] + cluster_id: int = msg[ATTR_CLUSTER_ID] + cluster_type: str = msg[ATTR_CLUSTER_TYPE] + attribute: int = msg[ATTR_ATTRIBUTE] + manufacturer: int | None = msg.get(ATTR_MANUFACTURER) + zha_device = zha_gateway.get_device(ieee) + success = {} + failure = {} + if zha_device is not None: + if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: + manufacturer = zha_device.manufacturer_code + cluster = zha_device.async_get_cluster( + endpoint_id, cluster_id, cluster_type=cluster_type + ) + success, failure = await cluster.read_attributes( + [attribute], allow_cache=False, only_cache=False, manufacturer=manufacturer + ) + _LOGGER.debug( + ( + "Read attribute for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s]" + " %s: [%s]," + ), + ATTR_CLUSTER_ID, + cluster_id, + ATTR_CLUSTER_TYPE, + cluster_type, + ATTR_ENDPOINT_ID, + endpoint_id, + ATTR_ATTRIBUTE, + attribute, + ATTR_MANUFACTURER, + manufacturer, + RESPONSE, + str(success.get(attribute)), + "failure", + failure, + ) + connection.send_result(msg[ID], str(success.get(attribute))) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/bindable", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + } +) +@websocket_api.async_response +async def websocket_get_bindable_devices( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Directly bind devices.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + source_ieee: EUI64 = msg[ATTR_IEEE] + source_device = zha_gateway.get_device(source_ieee) + + devices = [ + device.zha_device_info + for device in zha_gateway.devices.values() + if async_is_bindable_target(source_device, device) + ] + + _LOGGER.debug( + "Get bindable devices: %s: [%s], %s: [%s]", + ATTR_SOURCE_IEEE, + source_ieee, + "bindable devices", + devices, + ) + + connection.send_message(websocket_api.result_message(msg[ID], devices)) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/bind", + vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_TARGET_IEEE): IEEE_SCHEMA, + } +) +@websocket_api.async_response +async def websocket_bind_devices( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Directly bind devices.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] + target_ieee: EUI64 = msg[ATTR_TARGET_IEEE] + await async_binding_operation( + zha_gateway, source_ieee, target_ieee, zdo_types.ZDOCmd.Bind_req + ) + _LOGGER.info( + "Devices bound: %s: [%s] %s: [%s]", + ATTR_SOURCE_IEEE, + source_ieee, + ATTR_TARGET_IEEE, + target_ieee, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/unbind", + vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_TARGET_IEEE): IEEE_SCHEMA, + } +) +@websocket_api.async_response +async def websocket_unbind_devices( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Remove a direct binding between devices.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] + target_ieee: EUI64 = msg[ATTR_TARGET_IEEE] + await async_binding_operation( + zha_gateway, source_ieee, target_ieee, zdo_types.ZDOCmd.Unbind_req + ) + _LOGGER.info( + "Devices un-bound: %s: [%s] %s: [%s]", + ATTR_SOURCE_IEEE, + source_ieee, + ATTR_TARGET_IEEE, + target_ieee, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/groups/bind", + vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, + vol.Required(GROUP_ID): cv.positive_int, + vol.Required(BINDINGS): vol.All(cv.ensure_list, [CLUSTER_BINDING_SCHEMA]), + } +) +@websocket_api.async_response +async def websocket_bind_group( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Directly bind a device to a group.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] + group_id: int = msg[GROUP_ID] + bindings: list[ClusterBinding] = msg[BINDINGS] + source_device = zha_gateway.get_device(source_ieee) + assert source_device + await source_device.async_bind_to_group(group_id, bindings) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/groups/unbind", + vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, + vol.Required(GROUP_ID): cv.positive_int, + vol.Required(BINDINGS): vol.All(cv.ensure_list, [CLUSTER_BINDING_SCHEMA]), + } +) +@websocket_api.async_response +async def websocket_unbind_group( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Unbind a device from a group.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] + group_id: int = msg[GROUP_ID] + bindings: list[ClusterBinding] = msg[BINDINGS] + source_device = zha_gateway.get_device(source_ieee) + assert source_device + await source_device.async_unbind_from_group(group_id, bindings) + + +async def async_binding_operation( + zha_gateway: ZHAGateway, + source_ieee: EUI64, + target_ieee: EUI64, + operation: zdo_types.ZDOCmd, +) -> None: + """Create or remove a direct zigbee binding between 2 devices.""" + + source_device = zha_gateway.get_device(source_ieee) + target_device = zha_gateway.get_device(target_ieee) + + assert source_device + assert target_device + clusters_to_bind = await get_matched_clusters(source_device, target_device) + + zdo = source_device.device.zdo + bind_tasks = [] + for binding_pair in clusters_to_bind: + op_msg = "cluster: %s %s --> [%s]" + op_params = ( + binding_pair.source_cluster.cluster_id, + operation.name, + target_ieee, + ) + zdo.debug(f"processing {op_msg}", *op_params) + + bind_tasks.append( + ( + zdo.request( + operation, + source_device.ieee, + binding_pair.source_cluster.endpoint.endpoint_id, + binding_pair.source_cluster.cluster_id, + binding_pair.destination_address, + ), + op_msg, + op_params, + ) + ) + res = await asyncio.gather(*(t[0] for t in bind_tasks), return_exceptions=True) + for outcome, log_msg in zip(res, bind_tasks): + if isinstance(outcome, Exception): + fmt = f"{log_msg[1]} failed: %s" + else: + fmt = f"{log_msg[1]} completed: %s" + zdo.debug(fmt, *(log_msg[2] + (outcome,))) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/configuration"}) +@websocket_api.async_response +async def websocket_get_configuration( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA configuration.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + import voluptuous_serialize # pylint: disable=import-outside-toplevel + + def custom_serializer(schema: Any) -> Any: + """Serialize additional types for voluptuous_serialize.""" + if schema is cv_boolean: + return {"type": "bool"} + if schema is vol.Schema: + return voluptuous_serialize.convert( + schema, custom_serializer=custom_serializer + ) + + return cv.custom_serializer(schema) + + data: dict[str, dict[str, Any]] = {"schemas": {}, "data": {}} + for section, schema in ZHA_CONFIG_SCHEMAS.items(): + if section == ZHA_ALARM_OPTIONS and not async_cluster_exists( + hass, IasAce.cluster_id + ): + continue + data["schemas"][section] = voluptuous_serialize.convert( + schema, custom_serializer=custom_serializer + ) + data["data"][section] = zha_gateway.config_entry.options.get( + CUSTOM_CONFIGURATION, {} + ).get(section, {}) + + # send default values for unconfigured options + for entry in data["schemas"][section]: + if data["data"][section].get(entry["name"]) is None: + data["data"][section][entry["name"]] = entry["default"] + + connection.send_result(msg[ID], data) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/configuration/update", + vol.Required("data"): ZHA_CONFIG_SCHEMAS, + } +) +@websocket_api.async_response +async def websocket_update_zha_configuration( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Update the ZHA configuration.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + options = zha_gateway.config_entry.options + data_to_save = {**options, **{CUSTOM_CONFIGURATION: msg["data"]}} + + for section, schema in ZHA_CONFIG_SCHEMAS.items(): + for entry in schema.schema: + # remove options that match defaults + if ( + data_to_save[CUSTOM_CONFIGURATION].get(section, {}).get(entry) + == entry.default() + ): + data_to_save[CUSTOM_CONFIGURATION][section].pop(entry) + # remove entire section block if empty + if ( + not data_to_save[CUSTOM_CONFIGURATION].get(section) + and section in data_to_save[CUSTOM_CONFIGURATION] + ): + data_to_save[CUSTOM_CONFIGURATION].pop(section) + + # remove entire custom_configuration block if empty + if ( + not data_to_save.get(CUSTOM_CONFIGURATION) + and CUSTOM_CONFIGURATION in data_to_save + ): + data_to_save.pop(CUSTOM_CONFIGURATION) + + _LOGGER.info( + "Updating ZHA custom configuration options from %s to %s", + options, + data_to_save, + ) + + hass.config_entries.async_update_entry( + zha_gateway.config_entry, options=data_to_save + ) + status = await hass.config_entries.async_reload(zha_gateway.config_entry.entry_id) + connection.send_result(msg[ID], status) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/settings"}) +@websocket_api.async_response +async def websocket_get_network_settings( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA network settings.""" + backup = async_get_active_network_settings(hass) + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + connection.send_result( + msg[ID], + { + "radio_type": async_get_radio_type(hass, zha_gateway.config_entry).name, + "settings": backup.as_dict(), + }, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/backups/list"}) +@websocket_api.async_response +async def websocket_list_network_backups( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA network settings.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + application_controller = zha_gateway.application_controller + + # Serialize known backups + connection.send_result( + msg[ID], [backup.as_dict() for backup in application_controller.backups] + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/backups/create"}) +@websocket_api.async_response +async def websocket_create_network_backup( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Create a ZHA network backup.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + application_controller = zha_gateway.application_controller + + # This can take 5-30s + backup = await application_controller.backups.create_backup(load_devices=True) + connection.send_result( + msg[ID], + { + "backup": backup.as_dict(), + "is_complete": backup.is_complete(), + }, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/network/backups/restore", + vol.Required("backup"): _cv_zigpy_network_backup, + vol.Optional("ezsp_force_write_eui64", default=False): cv.boolean, + } +) +@websocket_api.async_response +async def websocket_restore_network_backup( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Restore a ZHA network backup.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + application_controller = zha_gateway.application_controller + backup = msg["backup"] + + if msg["ezsp_force_write_eui64"]: + backup.network_info.stack_specific.setdefault("ezsp", {})[ + EZSP_OVERWRITE_EUI64 + ] = True + + # This can take 30-40s + try: + await application_controller.backups.restore_backup(backup) + except ValueError as err: + connection.send_error(msg[ID], websocket_api.const.ERR_INVALID_FORMAT, str(err)) + else: + connection.send_result(msg[ID]) + + +@callback +def async_load_api(hass: HomeAssistant) -> None: + """Set up the web socket API.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + application_controller = zha_gateway.application_controller + + async def permit(service: ServiceCall) -> None: + """Allow devices to join this network.""" + duration: int = service.data[ATTR_DURATION] + ieee: EUI64 | None = service.data.get(ATTR_IEEE) + src_ieee: EUI64 + code: bytes + if ATTR_SOURCE_IEEE in service.data: + src_ieee = service.data[ATTR_SOURCE_IEEE] + code = service.data[ATTR_INSTALL_CODE] + _LOGGER.info("Allowing join for %s device with install code", src_ieee) + await application_controller.permit_with_key( + time_s=duration, node=src_ieee, code=code + ) + return + + if ATTR_QR_CODE in service.data: + src_ieee, code = service.data[ATTR_QR_CODE] + _LOGGER.info("Allowing join for %s device with install code", src_ieee) + await application_controller.permit_with_key( + time_s=duration, node=src_ieee, code=code + ) + return + + if ieee: + _LOGGER.info("Permitting joins for %ss on %s device", duration, ieee) + else: + _LOGGER.info("Permitting joins for %ss", duration) + await application_controller.permit(time_s=duration, node=ieee) + + async_register_admin_service( + hass, DOMAIN, SERVICE_PERMIT, permit, schema=SERVICE_SCHEMAS[SERVICE_PERMIT] + ) + + async def remove(service: ServiceCall) -> None: + """Remove a node from the network.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = service.data[ATTR_IEEE] + zha_device: ZHADevice | None = zha_gateway.get_device(ieee) + if zha_device is not None and zha_device.is_active_coordinator: + _LOGGER.info("Removing the coordinator (%s) is not allowed", ieee) + return + _LOGGER.info("Removing node %s", ieee) + await application_controller.remove(ieee) + + async_register_admin_service( + hass, DOMAIN, SERVICE_REMOVE, remove, schema=SERVICE_SCHEMAS[IEEE_SERVICE] + ) + + async def set_zigbee_cluster_attributes(service: ServiceCall) -> None: + """Set zigbee attribute for cluster on zha entity.""" + ieee: EUI64 = service.data[ATTR_IEEE] + endpoint_id: int = service.data[ATTR_ENDPOINT_ID] + cluster_id: int = service.data[ATTR_CLUSTER_ID] + cluster_type: str = service.data[ATTR_CLUSTER_TYPE] + attribute: int | str = service.data[ATTR_ATTRIBUTE] + value: int | bool | str = service.data[ATTR_VALUE] + manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) + zha_device = zha_gateway.get_device(ieee) + response = None + if zha_device is not None: + if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: + manufacturer = zha_device.manufacturer_code + response = await zha_device.write_zigbee_attribute( + endpoint_id, + cluster_id, + attribute, + value, + cluster_type=cluster_type, + manufacturer=manufacturer, + ) + _LOGGER.debug( + ( + "Set attribute for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s:" + " [%s] %s: [%s]" + ), + ATTR_CLUSTER_ID, + cluster_id, + ATTR_CLUSTER_TYPE, + cluster_type, + ATTR_ENDPOINT_ID, + endpoint_id, + ATTR_ATTRIBUTE, + attribute, + ATTR_VALUE, + value, + ATTR_MANUFACTURER, + manufacturer, + RESPONSE, + response, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE, + set_zigbee_cluster_attributes, + schema=SERVICE_SCHEMAS[SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE], + ) + + async def issue_zigbee_cluster_command(service: ServiceCall) -> None: + """Issue command on zigbee cluster on ZHA entity.""" + ieee: EUI64 = service.data[ATTR_IEEE] + endpoint_id: int = service.data[ATTR_ENDPOINT_ID] + cluster_id: int = service.data[ATTR_CLUSTER_ID] + cluster_type: str = service.data[ATTR_CLUSTER_TYPE] + command: int = service.data[ATTR_COMMAND] + command_type: str = service.data[ATTR_COMMAND_TYPE] + args: list | None = service.data.get(ATTR_ARGS) + params: dict | None = service.data.get(ATTR_PARAMS) + manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) + zha_device = zha_gateway.get_device(ieee) + if zha_device is not None: + if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: + manufacturer = zha_device.manufacturer_code + + await zha_device.issue_cluster_command( + endpoint_id, + cluster_id, + command, + command_type, + args, + params, + cluster_type=cluster_type, + manufacturer=manufacturer, + ) + _LOGGER.debug( + ( + "Issued command for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s]" + " %s: [%s] %s: [%s] %s: [%s]" + ), + ATTR_CLUSTER_ID, + cluster_id, + ATTR_CLUSTER_TYPE, + cluster_type, + ATTR_ENDPOINT_ID, + endpoint_id, + ATTR_COMMAND, + command, + ATTR_COMMAND_TYPE, + command_type, + ATTR_ARGS, + args, + ATTR_PARAMS, + params, + ATTR_MANUFACTURER, + manufacturer, + ) + else: + raise ValueError(f"Device with IEEE {str(ieee)} not found") + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND, + issue_zigbee_cluster_command, + schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND], + ) + + async def issue_zigbee_group_command(service: ServiceCall) -> None: + """Issue command on zigbee cluster on a zigbee group.""" + group_id: int = service.data[ATTR_GROUP] + cluster_id: int = service.data[ATTR_CLUSTER_ID] + command: int = service.data[ATTR_COMMAND] + args: list = service.data[ATTR_ARGS] + manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) + group = zha_gateway.get_group(group_id) + if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: + _LOGGER.error("Missing manufacturer attribute for cluster: %d", cluster_id) + response = None + if group is not None: + cluster = group.endpoint[cluster_id] + response = await cluster.command( + command, *args, manufacturer=manufacturer, expect_reply=True + ) + _LOGGER.debug( + "Issued group command for: %s: [%s] %s: [%s] %s: %s %s: [%s] %s: %s", + ATTR_CLUSTER_ID, + cluster_id, + ATTR_COMMAND, + command, + ATTR_ARGS, + args, + ATTR_MANUFACTURER, + manufacturer, + RESPONSE, + response, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND, + issue_zigbee_group_command, + schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND], + ) + + def _get_ias_wd_channel(zha_device): + """Get the IASWD channel for a device.""" + cluster_channels = { + ch.name: ch + for pool in zha_device.channels.pools + for ch in pool.claimed_channels.values() + } + return cluster_channels.get(CHANNEL_IAS_WD) + + async def warning_device_squawk(service: ServiceCall) -> None: + """Issue the squawk command for an IAS warning device.""" + ieee: EUI64 = service.data[ATTR_IEEE] + mode: int = service.data[ATTR_WARNING_DEVICE_MODE] + strobe: int = service.data[ATTR_WARNING_DEVICE_STROBE] + level: int = service.data[ATTR_LEVEL] + + if (zha_device := zha_gateway.get_device(ieee)) is not None: + if channel := _get_ias_wd_channel(zha_device): + await channel.issue_squawk(mode, strobe, level) + else: + _LOGGER.error( + "Squawking IASWD: %s: [%s] is missing the required IASWD channel!", + ATTR_IEEE, + str(ieee), + ) + else: + _LOGGER.error( + "Squawking IASWD: %s: [%s] could not be found!", ATTR_IEEE, str(ieee) + ) + _LOGGER.debug( + "Squawking IASWD: %s: [%s] %s: [%s] %s: [%s] %s: [%s]", + ATTR_IEEE, + str(ieee), + ATTR_WARNING_DEVICE_MODE, + mode, + ATTR_WARNING_DEVICE_STROBE, + strobe, + ATTR_LEVEL, + level, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_WARNING_DEVICE_SQUAWK, + warning_device_squawk, + schema=SERVICE_SCHEMAS[SERVICE_WARNING_DEVICE_SQUAWK], + ) + + async def warning_device_warn(service: ServiceCall) -> None: + """Issue the warning command for an IAS warning device.""" + ieee: EUI64 = service.data[ATTR_IEEE] + mode: int = service.data[ATTR_WARNING_DEVICE_MODE] + strobe: int = service.data[ATTR_WARNING_DEVICE_STROBE] + level: int = service.data[ATTR_LEVEL] + duration: int = service.data[ATTR_WARNING_DEVICE_DURATION] + duty_mode: int = service.data[ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE] + intensity: int = service.data[ATTR_WARNING_DEVICE_STROBE_INTENSITY] + + if (zha_device := zha_gateway.get_device(ieee)) is not None: + if channel := _get_ias_wd_channel(zha_device): + await channel.issue_start_warning( + mode, strobe, level, duration, duty_mode, intensity + ) + else: + _LOGGER.error( + "Warning IASWD: %s: [%s] is missing the required IASWD channel!", + ATTR_IEEE, + str(ieee), + ) + else: + _LOGGER.error( + "Warning IASWD: %s: [%s] could not be found!", ATTR_IEEE, str(ieee) + ) + _LOGGER.debug( + "Warning IASWD: %s: [%s] %s: [%s] %s: [%s] %s: [%s]", + ATTR_IEEE, + str(ieee), + ATTR_WARNING_DEVICE_MODE, + mode, + ATTR_WARNING_DEVICE_STROBE, + strobe, + ATTR_LEVEL, + level, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_WARNING_DEVICE_WARN, + warning_device_warn, + schema=SERVICE_SCHEMAS[SERVICE_WARNING_DEVICE_WARN], + ) + + websocket_api.async_register_command(hass, websocket_permit_devices) + websocket_api.async_register_command(hass, websocket_get_devices) + websocket_api.async_register_command(hass, websocket_get_groupable_devices) + websocket_api.async_register_command(hass, websocket_get_groups) + websocket_api.async_register_command(hass, websocket_get_device) + websocket_api.async_register_command(hass, websocket_get_group) + websocket_api.async_register_command(hass, websocket_add_group) + websocket_api.async_register_command(hass, websocket_remove_groups) + websocket_api.async_register_command(hass, websocket_add_group_members) + websocket_api.async_register_command(hass, websocket_remove_group_members) + websocket_api.async_register_command(hass, websocket_bind_group) + websocket_api.async_register_command(hass, websocket_unbind_group) + websocket_api.async_register_command(hass, websocket_reconfigure_node) + websocket_api.async_register_command(hass, websocket_device_clusters) + websocket_api.async_register_command(hass, websocket_device_cluster_attributes) + websocket_api.async_register_command(hass, websocket_device_cluster_commands) + websocket_api.async_register_command(hass, websocket_read_zigbee_cluster_attributes) + websocket_api.async_register_command(hass, websocket_get_bindable_devices) + websocket_api.async_register_command(hass, websocket_bind_devices) + websocket_api.async_register_command(hass, websocket_unbind_devices) + websocket_api.async_register_command(hass, websocket_update_topology) + websocket_api.async_register_command(hass, websocket_get_configuration) + websocket_api.async_register_command(hass, websocket_update_zha_configuration) + websocket_api.async_register_command(hass, websocket_get_network_settings) + websocket_api.async_register_command(hass, websocket_list_network_backups) + websocket_api.async_register_command(hass, websocket_create_network_backup) + websocket_api.async_register_command(hass, websocket_restore_network_backup) + + +@callback +def async_unload_api(hass: HomeAssistant) -> None: + """Unload the ZHA API.""" + hass.services.async_remove(DOMAIN, SERVICE_PERMIT) + hass.services.async_remove(DOMAIN, SERVICE_REMOVE) + hass.services.async_remove(DOMAIN, SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE) + hass.services.async_remove(DOMAIN, SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND) + hass.services.async_remove(DOMAIN, SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND) + hass.services.async_remove(DOMAIN, SERVICE_WARNING_DEVICE_SQUAWK) + hass.services.async_remove(DOMAIN, SERVICE_WARNING_DEVICE_WARN) diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py index 8610c8cd7c..0d03b62bf8 100644 --- a/tests/components/zha/test_api.py +++ b/tests/components/zha/test_api.py @@ -1,842 +1,91 @@ """Test ZHA API.""" -from binascii import unhexlify -from copy import deepcopy -from unittest.mock import AsyncMock, patch +from unittest.mock import patch import pytest -import voluptuous as vol -import zigpy.backups -import zigpy.profiles.zha -import zigpy.types -import zigpy.zcl.clusters.general as general -import zigpy.zcl.clusters.security as security +import zigpy.state -from homeassistant.components.websocket_api import const -from homeassistant.components.zha import DOMAIN -from homeassistant.components.zha.api import ( - ATTR_DURATION, - ATTR_INSTALL_CODE, - ATTR_QR_CODE, - ATTR_SOURCE_IEEE, - ID, - SERVICE_PERMIT, - TYPE, - async_load_api, -) -from homeassistant.components.zha.core.const import ( - ATTR_CLUSTER_ID, - ATTR_CLUSTER_TYPE, - ATTR_ENDPOINT_ID, - ATTR_ENDPOINT_NAMES, - ATTR_IEEE, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_NEIGHBORS, - ATTR_QUIRK_APPLIED, - CLUSTER_TYPE_IN, - DATA_ZHA, - DATA_ZHA_GATEWAY, - EZSP_OVERWRITE_EUI64, - GROUP_ID, - GROUP_IDS, - GROUP_NAME, -) -from homeassistant.const import ATTR_NAME, Platform -from homeassistant.core import Context, HomeAssistant - -from .conftest import ( - FIXTURE_GRP_ID, - FIXTURE_GRP_NAME, - SIG_EP_INPUT, - SIG_EP_OUTPUT, - SIG_EP_PROFILE, - SIG_EP_TYPE, -) -from .data import BASE_CUSTOM_CONFIGURATION, CONFIG_WITH_ALARM_OPTIONS - -from tests.common import MockUser - -IEEE_SWITCH_DEVICE = "01:2d:6f:00:0a:90:69:e7" -IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" +from homeassistant.components import zha +from homeassistant.components.zha import api +from homeassistant.components.zha.core.const import RadioType @pytest.fixture(autouse=True) def required_platform_only(): """Only set up the required and required base platforms to speed up tests.""" - with patch( - "homeassistant.components.zha.PLATFORMS", - ( - Platform.ALARM_CONTROL_PANEL, - Platform.SELECT, - Platform.SENSOR, - Platform.SWITCH, - ), - ): + with patch("homeassistant.components.zha.PLATFORMS", ()): yield -@pytest.fixture -async def device_switch(hass, zigpy_device_mock, zha_device_joined): - """Test ZHA switch platform.""" +async def test_async_get_network_settings_active(hass, setup_zha): + """Test reading settings with an active ZHA installation.""" + await setup_zha() - zigpy_device = zigpy_device_mock( - { - 1: { - SIG_EP_INPUT: [general.OnOff.cluster_id, general.Basic.cluster_id], - SIG_EP_OUTPUT: [], - SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, - SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, - } - }, - ieee=IEEE_SWITCH_DEVICE, - ) - zha_device = await zha_device_joined(zigpy_device) - zha_device.available = True - return zha_device + settings = await api.async_get_network_settings(hass) + assert settings.network_info.channel == 15 -@pytest.fixture -async def device_ias_ace(hass, zigpy_device_mock, zha_device_joined): - """Test alarm control panel device.""" +async def test_async_get_network_settings_inactive( + hass, setup_zha, zigpy_app_controller +): + """Test reading settings with an inactive ZHA installation.""" + await setup_zha() - zigpy_device = zigpy_device_mock( - { - 1: { - SIG_EP_INPUT: [security.IasAce.cluster_id], - SIG_EP_OUTPUT: [], - SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.IAS_ANCILLARY_CONTROL, - SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, - } - }, - ) - zha_device = await zha_device_joined(zigpy_device) - zha_device.available = True - return zha_device + gateway = api._get_gateway(hass) + await zha.async_unload_entry(hass, gateway.config_entry) - -@pytest.fixture -async def device_groupable(hass, zigpy_device_mock, zha_device_joined): - """Test ZHA light platform.""" - - zigpy_device = zigpy_device_mock( - { - 1: { - SIG_EP_INPUT: [ - general.OnOff.cluster_id, - general.Basic.cluster_id, - general.Groups.cluster_id, - ], - SIG_EP_OUTPUT: [], - SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, - SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, - } - }, - ieee=IEEE_GROUPABLE_DEVICE, - ) - zha_device = await zha_device_joined(zigpy_device) - zha_device.available = True - return zha_device - - -@pytest.fixture -async def zha_client(hass, hass_ws_client, device_switch, device_groupable): - """Get ZHA WebSocket client.""" - - # load the ZHA API - async_load_api(hass) - return await hass_ws_client(hass) - - -async def test_device_clusters(hass: HomeAssistant, zha_client) -> None: - """Test getting device cluster info.""" - await zha_client.send_json( - {ID: 5, TYPE: "zha/devices/clusters", ATTR_IEEE: IEEE_SWITCH_DEVICE} - ) - - msg = await zha_client.receive_json() - - assert len(msg["result"]) == 2 - - cluster_infos = sorted(msg["result"], key=lambda k: k[ID]) - - cluster_info = cluster_infos[0] - assert cluster_info[TYPE] == CLUSTER_TYPE_IN - assert cluster_info[ID] == 0 - assert cluster_info[ATTR_NAME] == "Basic" - - cluster_info = cluster_infos[1] - assert cluster_info[TYPE] == CLUSTER_TYPE_IN - assert cluster_info[ID] == 6 - assert cluster_info[ATTR_NAME] == "OnOff" - - -async def test_device_cluster_attributes(zha_client) -> None: - """Test getting device cluster attributes.""" - await zha_client.send_json( - { - ID: 5, - TYPE: "zha/devices/clusters/attributes", - ATTR_ENDPOINT_ID: 1, - ATTR_IEEE: IEEE_SWITCH_DEVICE, - ATTR_CLUSTER_ID: 6, - ATTR_CLUSTER_TYPE: CLUSTER_TYPE_IN, - } - ) - - msg = await zha_client.receive_json() - - attributes = msg["result"] - assert len(attributes) == 7 - - for attribute in attributes: - assert attribute[ID] is not None - assert attribute[ATTR_NAME] is not None - - -async def test_device_cluster_commands(zha_client) -> None: - """Test getting device cluster commands.""" - await zha_client.send_json( - { - ID: 5, - TYPE: "zha/devices/clusters/commands", - ATTR_ENDPOINT_ID: 1, - ATTR_IEEE: IEEE_SWITCH_DEVICE, - ATTR_CLUSTER_ID: 6, - ATTR_CLUSTER_TYPE: CLUSTER_TYPE_IN, - } - ) - - msg = await zha_client.receive_json() - - commands = msg["result"] - assert len(commands) == 6 - - for command in commands: - assert command[ID] is not None - assert command[ATTR_NAME] is not None - assert command[TYPE] is not None - - -async def test_list_devices(zha_client) -> None: - """Test getting ZHA devices.""" - await zha_client.send_json({ID: 5, TYPE: "zha/devices"}) - - msg = await zha_client.receive_json() - - devices = msg["result"] - assert len(devices) == 2 - - msg_id = 100 - for device in devices: - msg_id += 1 - assert device[ATTR_IEEE] is not None - assert device[ATTR_MANUFACTURER] is not None - assert device[ATTR_MODEL] is not None - assert device[ATTR_NAME] is not None - assert device[ATTR_QUIRK_APPLIED] is not None - assert device["entities"] is not None - assert device[ATTR_NEIGHBORS] is not None - assert device[ATTR_ENDPOINT_NAMES] is not None - - for entity_reference in device["entities"]: - assert entity_reference[ATTR_NAME] is not None - assert entity_reference["entity_id"] is not None - - await zha_client.send_json( - {ID: msg_id, TYPE: "zha/device", ATTR_IEEE: device[ATTR_IEEE]} - ) - msg = await zha_client.receive_json() - device2 = msg["result"] - assert device == device2 - - -async def test_get_zha_config(zha_client) -> None: - """Test getting ZHA custom configuration.""" - await zha_client.send_json({ID: 5, TYPE: "zha/configuration"}) - - msg = await zha_client.receive_json() - - configuration = msg["result"] - assert configuration == BASE_CUSTOM_CONFIGURATION - - -async def test_get_zha_config_with_alarm( - hass: HomeAssistant, zha_client, device_ias_ace -) -> None: - """Test getting ZHA custom configuration.""" - await zha_client.send_json({ID: 5, TYPE: "zha/configuration"}) - - msg = await zha_client.receive_json() - - configuration = msg["result"] - assert configuration == CONFIG_WITH_ALARM_OPTIONS - - # test that the alarm options are not in the config when we remove the device - device_ias_ace.gateway.device_removed(device_ias_ace.device) - await hass.async_block_till_done() - await zha_client.send_json({ID: 6, TYPE: "zha/configuration"}) - - msg = await zha_client.receive_json() - - configuration = msg["result"] - assert configuration == BASE_CUSTOM_CONFIGURATION - - -async def test_update_zha_config(zha_client, zigpy_app_controller) -> None: - """Test updating ZHA custom configuration.""" - - configuration = deepcopy(CONFIG_WITH_ALARM_OPTIONS) - configuration["data"]["zha_options"]["default_light_transition"] = 10 + zigpy_app_controller.state.network_info.channel = 20 with patch( - "bellows.zigbee.application.ControllerApplication.new", + "bellows.zigbee.application.ControllerApplication.__new__", return_value=zigpy_app_controller, ): - await zha_client.send_json( - {ID: 5, TYPE: "zha/configuration/update", "data": configuration["data"]} - ) - msg = await zha_client.receive_json() - assert msg["success"] + settings = await api.async_get_network_settings(hass) - await zha_client.send_json({ID: 6, TYPE: "zha/configuration"}) - msg = await zha_client.receive_json() - configuration = msg["result"] - assert configuration == configuration + assert len(zigpy_app_controller._load_db.mock_calls) == 1 + assert len(zigpy_app_controller.start_network.mock_calls) == 0 + + assert settings.network_info.channel == 20 -async def test_device_not_found(zha_client) -> None: - """Test not found response from get device API.""" - await zha_client.send_json( - {ID: 6, TYPE: "zha/device", ATTR_IEEE: "28:6d:97:00:01:04:11:8c"} - ) - msg = await zha_client.receive_json() - assert msg["id"] == 6 - assert msg["type"] == const.TYPE_RESULT - assert not msg["success"] - assert msg["error"]["code"] == const.ERR_NOT_FOUND - - -async def test_list_groups(zha_client) -> None: - """Test getting ZHA zigbee groups.""" - await zha_client.send_json({ID: 7, TYPE: "zha/groups"}) - - msg = await zha_client.receive_json() - assert msg["id"] == 7 - assert msg["type"] == const.TYPE_RESULT - - groups = msg["result"] - assert len(groups) == 1 - - for group in groups: - assert group["group_id"] == FIXTURE_GRP_ID - assert group["name"] == FIXTURE_GRP_NAME - assert group["members"] == [] - - -async def test_get_group(zha_client) -> None: - """Test getting a specific ZHA zigbee group.""" - await zha_client.send_json({ID: 8, TYPE: "zha/group", GROUP_ID: FIXTURE_GRP_ID}) - - msg = await zha_client.receive_json() - assert msg["id"] == 8 - assert msg["type"] == const.TYPE_RESULT - - group = msg["result"] - assert group is not None - assert group["group_id"] == FIXTURE_GRP_ID - assert group["name"] == FIXTURE_GRP_NAME - assert group["members"] == [] - - -async def test_get_group_not_found(zha_client) -> None: - """Test not found response from get group API.""" - await zha_client.send_json({ID: 9, TYPE: "zha/group", GROUP_ID: 1_234_567}) - - msg = await zha_client.receive_json() - - assert msg["id"] == 9 - assert msg["type"] == const.TYPE_RESULT - assert not msg["success"] - assert msg["error"]["code"] == const.ERR_NOT_FOUND - - -async def test_list_groupable_devices(zha_client, device_groupable) -> None: - """Test getting ZHA devices that have a group cluster.""" - - await zha_client.send_json({ID: 10, TYPE: "zha/devices/groupable"}) - - msg = await zha_client.receive_json() - assert msg["id"] == 10 - assert msg["type"] == const.TYPE_RESULT - - device_endpoints = msg["result"] - assert len(device_endpoints) == 1 - - for endpoint in device_endpoints: - assert endpoint["device"][ATTR_IEEE] == "01:2d:6f:00:0a:90:69:e8" - assert endpoint["device"][ATTR_MANUFACTURER] is not None - assert endpoint["device"][ATTR_MODEL] is not None - assert endpoint["device"][ATTR_NAME] is not None - assert endpoint["device"][ATTR_QUIRK_APPLIED] is not None - assert endpoint["device"]["entities"] is not None - assert endpoint["endpoint_id"] is not None - assert endpoint["entities"] is not None - - for entity_reference in endpoint["device"]["entities"]: - assert entity_reference[ATTR_NAME] is not None - assert entity_reference["entity_id"] is not None - - for entity_reference in endpoint["entities"]: - assert entity_reference["original_name"] is not None - - # Make sure there are no groupable devices when the device is unavailable - # Make device unavailable - device_groupable.available = False - - await zha_client.send_json({ID: 11, TYPE: "zha/devices/groupable"}) - - msg = await zha_client.receive_json() - assert msg["id"] == 11 - assert msg["type"] == const.TYPE_RESULT - - device_endpoints = msg["result"] - assert len(device_endpoints) == 0 - - -async def test_add_group(zha_client) -> None: - """Test adding and getting a new ZHA zigbee group.""" - await zha_client.send_json({ID: 12, TYPE: "zha/group/add", GROUP_NAME: "new_group"}) - - msg = await zha_client.receive_json() - assert msg["id"] == 12 - assert msg["type"] == const.TYPE_RESULT - - added_group = msg["result"] - - assert added_group["name"] == "new_group" - assert added_group["members"] == [] - - await zha_client.send_json({ID: 13, TYPE: "zha/groups"}) - - msg = await zha_client.receive_json() - assert msg["id"] == 13 - assert msg["type"] == const.TYPE_RESULT - - groups = msg["result"] - assert len(groups) == 2 - - for group in groups: - assert group["name"] == FIXTURE_GRP_NAME or group["name"] == "new_group" - - -async def test_remove_group(zha_client) -> None: - """Test removing a new ZHA zigbee group.""" - - await zha_client.send_json({ID: 14, TYPE: "zha/groups"}) - - msg = await zha_client.receive_json() - assert msg["id"] == 14 - assert msg["type"] == const.TYPE_RESULT - - groups = msg["result"] - assert len(groups) == 1 - - await zha_client.send_json( - {ID: 15, TYPE: "zha/group/remove", GROUP_IDS: [FIXTURE_GRP_ID]} - ) - - msg = await zha_client.receive_json() - assert msg["id"] == 15 - assert msg["type"] == const.TYPE_RESULT - - groups_remaining = msg["result"] - assert len(groups_remaining) == 0 - - await zha_client.send_json({ID: 16, TYPE: "zha/groups"}) - - msg = await zha_client.receive_json() - assert msg["id"] == 16 - assert msg["type"] == const.TYPE_RESULT - - groups = msg["result"] - assert len(groups) == 0 - - -@pytest.fixture -async def app_controller(hass, setup_zha): - """Fixture for zigpy Application Controller.""" +async def test_async_get_network_settings_missing( + hass, setup_zha, zigpy_app_controller +): + """Test reading settings with an inactive ZHA installation, no valid channel.""" await setup_zha() - controller = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY].application_controller - p1 = patch.object(controller, "permit") - p2 = patch.object(controller, "permit_with_key", new=AsyncMock()) - with p1, p2: - yield controller + + gateway = api._get_gateway(hass) + await zha.async_unload_entry(hass, gateway.config_entry) + + # Network settings were never loaded for whatever reason + zigpy_app_controller.state.network_info = zigpy.state.NetworkInfo() + zigpy_app_controller.state.node_info = zigpy.state.NodeInfo() + + with patch( + "bellows.zigbee.application.ControllerApplication.__new__", + return_value=zigpy_app_controller, + ): + settings = await api.async_get_network_settings(hass) + + assert settings is None -@pytest.mark.parametrize( - ("params", "duration", "node"), - ( - ({}, 60, None), - ({ATTR_DURATION: 30}, 30, None), - ( - {ATTR_DURATION: 33, ATTR_IEEE: "aa:bb:cc:dd:aa:bb:cc:dd"}, - 33, - zigpy.types.EUI64.convert("aa:bb:cc:dd:aa:bb:cc:dd"), - ), - ( - {ATTR_IEEE: "aa:bb:cc:dd:aa:bb:cc:d1"}, - 60, - zigpy.types.EUI64.convert("aa:bb:cc:dd:aa:bb:cc:d1"), - ), - ), -) -async def test_permit_ha12( - hass: HomeAssistant, - app_controller, - hass_admin_user: MockUser, - params, - duration, - node, -) -> None: - """Test permit service.""" - - await hass.services.async_call( - DOMAIN, SERVICE_PERMIT, params, True, Context(user_id=hass_admin_user.id) - ) - assert app_controller.permit.await_count == 1 - assert app_controller.permit.await_args[1]["time_s"] == duration - assert app_controller.permit.await_args[1]["node"] == node - assert app_controller.permit_with_key.call_count == 0 +async def test_async_get_network_settings_failure(hass): + """Test reading settings with no ZHA config entries and no database.""" + with pytest.raises(ValueError): + await api.async_get_network_settings(hass) -IC_TEST_PARAMS = ( - ( - { - ATTR_SOURCE_IEEE: IEEE_SWITCH_DEVICE, - ATTR_INSTALL_CODE: "5279-7BF4-A508-4DAA-8E17-12B6-1741-CA02-4051", - }, - zigpy.types.EUI64.convert(IEEE_SWITCH_DEVICE), - unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), - ), - ( - { - ATTR_SOURCE_IEEE: IEEE_SWITCH_DEVICE, - ATTR_INSTALL_CODE: "52797BF4A5084DAA8E1712B61741CA024051", - }, - zigpy.types.EUI64.convert(IEEE_SWITCH_DEVICE), - unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), - ), -) +async def test_async_get_radio_type_active(hass, setup_zha): + """Test reading the radio type with an active ZHA installation.""" + await setup_zha() + + radio_type = api.async_get_radio_type(hass) + assert radio_type == RadioType.ezsp -@pytest.mark.parametrize(("params", "src_ieee", "code"), IC_TEST_PARAMS) -async def test_permit_with_install_code( - hass: HomeAssistant, - app_controller, - hass_admin_user: MockUser, - params, - src_ieee, - code, -) -> None: - """Test permit service with install code.""" +async def test_async_get_radio_path_active(hass, setup_zha): + """Test reading the radio path with an active ZHA installation.""" + await setup_zha() - await hass.services.async_call( - DOMAIN, SERVICE_PERMIT, params, True, Context(user_id=hass_admin_user.id) - ) - assert app_controller.permit.await_count == 0 - assert app_controller.permit_with_key.call_count == 1 - assert app_controller.permit_with_key.await_args[1]["time_s"] == 60 - assert app_controller.permit_with_key.await_args[1]["node"] == src_ieee - assert app_controller.permit_with_key.await_args[1]["code"] == code - - -IC_FAIL_PARAMS = ( - { - # wrong install code - ATTR_SOURCE_IEEE: IEEE_SWITCH_DEVICE, - ATTR_INSTALL_CODE: "5279-7BF4-A508-4DAA-8E17-12B6-1741-CA02-4052", - }, - # incorrect service params - {ATTR_INSTALL_CODE: "5279-7BF4-A508-4DAA-8E17-12B6-1741-CA02-4051"}, - {ATTR_SOURCE_IEEE: IEEE_SWITCH_DEVICE}, - { - # incorrect service params - ATTR_INSTALL_CODE: "5279-7BF4-A508-4DAA-8E17-12B6-1741-CA02-4051", - ATTR_QR_CODE: "Z:000D6FFFFED4163B$I:52797BF4A5084DAA8E1712B61741CA024051", - }, - { - # incorrect service params - ATTR_SOURCE_IEEE: IEEE_SWITCH_DEVICE, - ATTR_QR_CODE: "Z:000D6FFFFED4163B$I:52797BF4A5084DAA8E1712B61741CA024051", - }, - { - # good regex match, but bad code - ATTR_QR_CODE: "Z:000D6FFFFED4163B$I:52797BF4A5084DAA8E1712B61741CA024052" - }, - { - # good aqara regex match, but bad code - ATTR_QR_CODE: ( - "G$M:751$S:357S00001579$D:000000000F350FFD%Z$A:04CF8CDF" - "3C3C3C3C$I:52797BF4A5084DAA8E1712B61741CA024052" - ) - }, - # good consciot regex match, but bad code - {ATTR_QR_CODE: "000D6FFFFED4163B|52797BF4A5084DAA8E1712B61741CA024052"}, -) - - -@pytest.mark.parametrize("params", IC_FAIL_PARAMS) -async def test_permit_with_install_code_fail( - hass: HomeAssistant, app_controller, hass_admin_user: MockUser, params -) -> None: - """Test permit service with install code.""" - - with pytest.raises(vol.Invalid): - await hass.services.async_call( - DOMAIN, SERVICE_PERMIT, params, True, Context(user_id=hass_admin_user.id) - ) - assert app_controller.permit.await_count == 0 - assert app_controller.permit_with_key.call_count == 0 - - -IC_QR_CODE_TEST_PARAMS = ( - ( - {ATTR_QR_CODE: "000D6FFFFED4163B|52797BF4A5084DAA8E1712B61741CA024051"}, - zigpy.types.EUI64.convert("00:0D:6F:FF:FE:D4:16:3B"), - unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), - ), - ( - {ATTR_QR_CODE: "Z:000D6FFFFED4163B$I:52797BF4A5084DAA8E1712B61741CA024051"}, - zigpy.types.EUI64.convert("00:0D:6F:FF:FE:D4:16:3B"), - unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), - ), - ( - { - ATTR_QR_CODE: ( - "G$M:751$S:357S00001579$D:000000000F350FFD%Z$A:04CF8CDF" - "3C3C3C3C$I:52797BF4A5084DAA8E1712B61741CA024051" - ) - }, - zigpy.types.EUI64.convert("04:CF:8C:DF:3C:3C:3C:3C"), - unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), - ), -) - - -@pytest.mark.parametrize(("params", "src_ieee", "code"), IC_QR_CODE_TEST_PARAMS) -async def test_permit_with_qr_code( - hass: HomeAssistant, - app_controller, - hass_admin_user: MockUser, - params, - src_ieee, - code, -) -> None: - """Test permit service with install code from qr code.""" - - await hass.services.async_call( - DOMAIN, SERVICE_PERMIT, params, True, Context(user_id=hass_admin_user.id) - ) - assert app_controller.permit.await_count == 0 - assert app_controller.permit_with_key.call_count == 1 - assert app_controller.permit_with_key.await_args[1]["time_s"] == 60 - assert app_controller.permit_with_key.await_args[1]["node"] == src_ieee - assert app_controller.permit_with_key.await_args[1]["code"] == code - - -@pytest.mark.parametrize(("params", "src_ieee", "code"), IC_QR_CODE_TEST_PARAMS) -async def test_ws_permit_with_qr_code( - app_controller, zha_client, params, src_ieee, code -) -> None: - """Test permit service with install code from qr code.""" - - await zha_client.send_json( - {ID: 14, TYPE: f"{DOMAIN}/devices/{SERVICE_PERMIT}", **params} - ) - - msg = await zha_client.receive_json() - assert msg["id"] == 14 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] - - assert app_controller.permit.await_count == 0 - assert app_controller.permit_with_key.call_count == 1 - assert app_controller.permit_with_key.await_args[1]["time_s"] == 60 - assert app_controller.permit_with_key.await_args[1]["node"] == src_ieee - assert app_controller.permit_with_key.await_args[1]["code"] == code - - -@pytest.mark.parametrize("params", IC_FAIL_PARAMS) -async def test_ws_permit_with_install_code_fail( - app_controller, zha_client, params -) -> None: - """Test permit ws service with install code.""" - - await zha_client.send_json( - {ID: 14, TYPE: f"{DOMAIN}/devices/{SERVICE_PERMIT}", **params} - ) - - msg = await zha_client.receive_json() - assert msg["id"] == 14 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] is False - - assert app_controller.permit.await_count == 0 - assert app_controller.permit_with_key.call_count == 0 - - -@pytest.mark.parametrize( - ("params", "duration", "node"), - ( - ({}, 60, None), - ({ATTR_DURATION: 30}, 30, None), - ( - {ATTR_DURATION: 33, ATTR_IEEE: "aa:bb:cc:dd:aa:bb:cc:dd"}, - 33, - zigpy.types.EUI64.convert("aa:bb:cc:dd:aa:bb:cc:dd"), - ), - ( - {ATTR_IEEE: "aa:bb:cc:dd:aa:bb:cc:d1"}, - 60, - zigpy.types.EUI64.convert("aa:bb:cc:dd:aa:bb:cc:d1"), - ), - ), -) -async def test_ws_permit_ha12( - app_controller, zha_client, params, duration, node -) -> None: - """Test permit ws service.""" - - await zha_client.send_json( - {ID: 14, TYPE: f"{DOMAIN}/devices/{SERVICE_PERMIT}", **params} - ) - - msg = await zha_client.receive_json() - assert msg["id"] == 14 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] - - assert app_controller.permit.await_count == 1 - assert app_controller.permit.await_args[1]["time_s"] == duration - assert app_controller.permit.await_args[1]["node"] == node - assert app_controller.permit_with_key.call_count == 0 - - -async def test_get_network_settings(app_controller, zha_client) -> None: - """Test current network settings are returned.""" - - await app_controller.backups.create_backup() - - await zha_client.send_json({ID: 6, TYPE: f"{DOMAIN}/network/settings"}) - msg = await zha_client.receive_json() - - assert msg["id"] == 6 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] - assert "radio_type" in msg["result"] - assert "network_info" in msg["result"]["settings"] - - -async def test_list_network_backups(app_controller, zha_client) -> None: - """Test backups are serialized.""" - - await app_controller.backups.create_backup() - - await zha_client.send_json({ID: 6, TYPE: f"{DOMAIN}/network/backups/list"}) - msg = await zha_client.receive_json() - - assert msg["id"] == 6 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] - assert "network_info" in msg["result"][0] - - -async def test_create_network_backup(app_controller, zha_client) -> None: - """Test creating backup.""" - - assert not app_controller.backups.backups - await zha_client.send_json({ID: 6, TYPE: f"{DOMAIN}/network/backups/create"}) - msg = await zha_client.receive_json() - assert len(app_controller.backups.backups) == 1 - - assert msg["id"] == 6 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] - assert "backup" in msg["result"] and "is_complete" in msg["result"] - - -async def test_restore_network_backup_success(app_controller, zha_client) -> None: - """Test successfully restoring a backup.""" - - backup = zigpy.backups.NetworkBackup() - - with patch.object(app_controller.backups, "restore_backup", new=AsyncMock()) as p: - await zha_client.send_json( - { - ID: 6, - TYPE: f"{DOMAIN}/network/backups/restore", - "backup": backup.as_dict(), - } - ) - msg = await zha_client.receive_json() - - p.assert_called_once_with(backup) - assert "ezsp" not in backup.network_info.stack_specific - - assert msg["id"] == 6 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] - - -async def test_restore_network_backup_force_write_eui64( - app_controller, zha_client -) -> None: - """Test successfully restoring a backup.""" - - backup = zigpy.backups.NetworkBackup() - - with patch.object(app_controller.backups, "restore_backup", new=AsyncMock()) as p: - await zha_client.send_json( - { - ID: 6, - TYPE: f"{DOMAIN}/network/backups/restore", - "backup": backup.as_dict(), - "ezsp_force_write_eui64": True, - } - ) - msg = await zha_client.receive_json() - - # EUI64 will be overwritten - p.assert_called_once_with( - backup.replace( - network_info=backup.network_info.replace( - stack_specific={"ezsp": {EZSP_OVERWRITE_EUI64: True}} - ) - ) - ) - - assert msg["id"] == 6 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] - - -@patch("zigpy.backups.NetworkBackup.from_dict", new=lambda v: v) -async def test_restore_network_backup_failure(app_controller, zha_client) -> None: - """Test successfully restoring a backup.""" - - with patch.object( - app_controller.backups, - "restore_backup", - new=AsyncMock(side_effect=ValueError("Restore failed")), - ) as p: - await zha_client.send_json( - {ID: 6, TYPE: f"{DOMAIN}/network/backups/restore", "backup": "a backup"} - ) - msg = await zha_client.receive_json() - - p.assert_called_once_with("a backup") - - assert msg["id"] == 6 - assert msg["type"] == const.TYPE_RESULT - assert not msg["success"] - assert msg["error"]["code"] == const.ERR_INVALID_FORMAT + radio_path = api.async_get_radio_path(hass) + assert radio_path == "/dev/ttyUSB0" diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py index a92631f6da..23a76de4c2 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -120,7 +120,9 @@ async def test_config_depreciation(hass: HomeAssistant, zha_config) -> None: ], ) @patch("homeassistant.components.zha.setup_quirks", Mock(return_value=True)) -@patch("homeassistant.components.zha.api.async_load_api", Mock(return_value=True)) +@patch( + "homeassistant.components.zha.websocket_api.async_load_api", Mock(return_value=True) +) async def test_setup_with_v3_spaces_in_uri( hass: HomeAssistant, path: str, cleaned_path: str ) -> None: diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py new file mode 100644 index 0000000000..7a24daaa3b --- /dev/null +++ b/tests/components/zha/test_websocket_api.py @@ -0,0 +1,842 @@ +"""Test ZHA WebSocket API.""" +from binascii import unhexlify +from copy import deepcopy +from unittest.mock import AsyncMock, patch + +import pytest +import voluptuous as vol +import zigpy.backups +import zigpy.profiles.zha +import zigpy.types +import zigpy.zcl.clusters.general as general +import zigpy.zcl.clusters.security as security + +from homeassistant.components.websocket_api import const +from homeassistant.components.zha import DOMAIN +from homeassistant.components.zha.core.const import ( + ATTR_CLUSTER_ID, + ATTR_CLUSTER_TYPE, + ATTR_ENDPOINT_ID, + ATTR_ENDPOINT_NAMES, + ATTR_IEEE, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NEIGHBORS, + ATTR_QUIRK_APPLIED, + CLUSTER_TYPE_IN, + DATA_ZHA, + DATA_ZHA_GATEWAY, + EZSP_OVERWRITE_EUI64, + GROUP_ID, + GROUP_IDS, + GROUP_NAME, +) +from homeassistant.components.zha.websocket_api import ( + ATTR_DURATION, + ATTR_INSTALL_CODE, + ATTR_QR_CODE, + ATTR_SOURCE_IEEE, + ID, + SERVICE_PERMIT, + TYPE, + async_load_api, +) +from homeassistant.const import ATTR_NAME, Platform +from homeassistant.core import Context, HomeAssistant + +from .conftest import ( + FIXTURE_GRP_ID, + FIXTURE_GRP_NAME, + SIG_EP_INPUT, + SIG_EP_OUTPUT, + SIG_EP_PROFILE, + SIG_EP_TYPE, +) +from .data import BASE_CUSTOM_CONFIGURATION, CONFIG_WITH_ALARM_OPTIONS + +from tests.common import MockUser + +IEEE_SWITCH_DEVICE = "01:2d:6f:00:0a:90:69:e7" +IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" + + +@pytest.fixture(autouse=True) +def required_platform_only(): + """Only set up the required and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.ALARM_CONTROL_PANEL, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + ), + ): + yield + + +@pytest.fixture +async def device_switch(hass, zigpy_device_mock, zha_device_joined): + """Test ZHA switch platform.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [general.OnOff.cluster_id, general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + } + }, + ieee=IEEE_SWITCH_DEVICE, + ) + zha_device = await zha_device_joined(zigpy_device) + zha_device.available = True + return zha_device + + +@pytest.fixture +async def device_ias_ace(hass, zigpy_device_mock, zha_device_joined): + """Test alarm control panel device.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [security.IasAce.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.IAS_ANCILLARY_CONTROL, + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + } + }, + ) + zha_device = await zha_device_joined(zigpy_device) + zha_device.available = True + return zha_device + + +@pytest.fixture +async def device_groupable(hass, zigpy_device_mock, zha_device_joined): + """Test ZHA light platform.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + general.OnOff.cluster_id, + general.Basic.cluster_id, + general.Groups.cluster_id, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + } + }, + ieee=IEEE_GROUPABLE_DEVICE, + ) + zha_device = await zha_device_joined(zigpy_device) + zha_device.available = True + return zha_device + + +@pytest.fixture +async def zha_client(hass, hass_ws_client, device_switch, device_groupable): + """Get ZHA WebSocket client.""" + + # load the ZHA API + async_load_api(hass) + return await hass_ws_client(hass) + + +async def test_device_clusters(hass: HomeAssistant, zha_client) -> None: + """Test getting device cluster info.""" + await zha_client.send_json( + {ID: 5, TYPE: "zha/devices/clusters", ATTR_IEEE: IEEE_SWITCH_DEVICE} + ) + + msg = await zha_client.receive_json() + + assert len(msg["result"]) == 2 + + cluster_infos = sorted(msg["result"], key=lambda k: k[ID]) + + cluster_info = cluster_infos[0] + assert cluster_info[TYPE] == CLUSTER_TYPE_IN + assert cluster_info[ID] == 0 + assert cluster_info[ATTR_NAME] == "Basic" + + cluster_info = cluster_infos[1] + assert cluster_info[TYPE] == CLUSTER_TYPE_IN + assert cluster_info[ID] == 6 + assert cluster_info[ATTR_NAME] == "OnOff" + + +async def test_device_cluster_attributes(zha_client) -> None: + """Test getting device cluster attributes.""" + await zha_client.send_json( + { + ID: 5, + TYPE: "zha/devices/clusters/attributes", + ATTR_ENDPOINT_ID: 1, + ATTR_IEEE: IEEE_SWITCH_DEVICE, + ATTR_CLUSTER_ID: 6, + ATTR_CLUSTER_TYPE: CLUSTER_TYPE_IN, + } + ) + + msg = await zha_client.receive_json() + + attributes = msg["result"] + assert len(attributes) == 7 + + for attribute in attributes: + assert attribute[ID] is not None + assert attribute[ATTR_NAME] is not None + + +async def test_device_cluster_commands(zha_client) -> None: + """Test getting device cluster commands.""" + await zha_client.send_json( + { + ID: 5, + TYPE: "zha/devices/clusters/commands", + ATTR_ENDPOINT_ID: 1, + ATTR_IEEE: IEEE_SWITCH_DEVICE, + ATTR_CLUSTER_ID: 6, + ATTR_CLUSTER_TYPE: CLUSTER_TYPE_IN, + } + ) + + msg = await zha_client.receive_json() + + commands = msg["result"] + assert len(commands) == 6 + + for command in commands: + assert command[ID] is not None + assert command[ATTR_NAME] is not None + assert command[TYPE] is not None + + +async def test_list_devices(zha_client) -> None: + """Test getting ZHA devices.""" + await zha_client.send_json({ID: 5, TYPE: "zha/devices"}) + + msg = await zha_client.receive_json() + + devices = msg["result"] + assert len(devices) == 2 + + msg_id = 100 + for device in devices: + msg_id += 1 + assert device[ATTR_IEEE] is not None + assert device[ATTR_MANUFACTURER] is not None + assert device[ATTR_MODEL] is not None + assert device[ATTR_NAME] is not None + assert device[ATTR_QUIRK_APPLIED] is not None + assert device["entities"] is not None + assert device[ATTR_NEIGHBORS] is not None + assert device[ATTR_ENDPOINT_NAMES] is not None + + for entity_reference in device["entities"]: + assert entity_reference[ATTR_NAME] is not None + assert entity_reference["entity_id"] is not None + + await zha_client.send_json( + {ID: msg_id, TYPE: "zha/device", ATTR_IEEE: device[ATTR_IEEE]} + ) + msg = await zha_client.receive_json() + device2 = msg["result"] + assert device == device2 + + +async def test_get_zha_config(zha_client) -> None: + """Test getting ZHA custom configuration.""" + await zha_client.send_json({ID: 5, TYPE: "zha/configuration"}) + + msg = await zha_client.receive_json() + + configuration = msg["result"] + assert configuration == BASE_CUSTOM_CONFIGURATION + + +async def test_get_zha_config_with_alarm( + hass: HomeAssistant, zha_client, device_ias_ace +) -> None: + """Test getting ZHA custom configuration.""" + await zha_client.send_json({ID: 5, TYPE: "zha/configuration"}) + + msg = await zha_client.receive_json() + + configuration = msg["result"] + assert configuration == CONFIG_WITH_ALARM_OPTIONS + + # test that the alarm options are not in the config when we remove the device + device_ias_ace.gateway.device_removed(device_ias_ace.device) + await hass.async_block_till_done() + await zha_client.send_json({ID: 6, TYPE: "zha/configuration"}) + + msg = await zha_client.receive_json() + + configuration = msg["result"] + assert configuration == BASE_CUSTOM_CONFIGURATION + + +async def test_update_zha_config(zha_client, zigpy_app_controller) -> None: + """Test updating ZHA custom configuration.""" + + configuration = deepcopy(CONFIG_WITH_ALARM_OPTIONS) + configuration["data"]["zha_options"]["default_light_transition"] = 10 + + with patch( + "bellows.zigbee.application.ControllerApplication.new", + return_value=zigpy_app_controller, + ): + await zha_client.send_json( + {ID: 5, TYPE: "zha/configuration/update", "data": configuration["data"]} + ) + msg = await zha_client.receive_json() + assert msg["success"] + + await zha_client.send_json({ID: 6, TYPE: "zha/configuration"}) + msg = await zha_client.receive_json() + configuration = msg["result"] + assert configuration == configuration + + +async def test_device_not_found(zha_client) -> None: + """Test not found response from get device API.""" + await zha_client.send_json( + {ID: 6, TYPE: "zha/device", ATTR_IEEE: "28:6d:97:00:01:04:11:8c"} + ) + msg = await zha_client.receive_json() + assert msg["id"] == 6 + assert msg["type"] == const.TYPE_RESULT + assert not msg["success"] + assert msg["error"]["code"] == const.ERR_NOT_FOUND + + +async def test_list_groups(zha_client) -> None: + """Test getting ZHA zigbee groups.""" + await zha_client.send_json({ID: 7, TYPE: "zha/groups"}) + + msg = await zha_client.receive_json() + assert msg["id"] == 7 + assert msg["type"] == const.TYPE_RESULT + + groups = msg["result"] + assert len(groups) == 1 + + for group in groups: + assert group["group_id"] == FIXTURE_GRP_ID + assert group["name"] == FIXTURE_GRP_NAME + assert group["members"] == [] + + +async def test_get_group(zha_client) -> None: + """Test getting a specific ZHA zigbee group.""" + await zha_client.send_json({ID: 8, TYPE: "zha/group", GROUP_ID: FIXTURE_GRP_ID}) + + msg = await zha_client.receive_json() + assert msg["id"] == 8 + assert msg["type"] == const.TYPE_RESULT + + group = msg["result"] + assert group is not None + assert group["group_id"] == FIXTURE_GRP_ID + assert group["name"] == FIXTURE_GRP_NAME + assert group["members"] == [] + + +async def test_get_group_not_found(zha_client) -> None: + """Test not found response from get group API.""" + await zha_client.send_json({ID: 9, TYPE: "zha/group", GROUP_ID: 1_234_567}) + + msg = await zha_client.receive_json() + + assert msg["id"] == 9 + assert msg["type"] == const.TYPE_RESULT + assert not msg["success"] + assert msg["error"]["code"] == const.ERR_NOT_FOUND + + +async def test_list_groupable_devices(zha_client, device_groupable) -> None: + """Test getting ZHA devices that have a group cluster.""" + + await zha_client.send_json({ID: 10, TYPE: "zha/devices/groupable"}) + + msg = await zha_client.receive_json() + assert msg["id"] == 10 + assert msg["type"] == const.TYPE_RESULT + + device_endpoints = msg["result"] + assert len(device_endpoints) == 1 + + for endpoint in device_endpoints: + assert endpoint["device"][ATTR_IEEE] == "01:2d:6f:00:0a:90:69:e8" + assert endpoint["device"][ATTR_MANUFACTURER] is not None + assert endpoint["device"][ATTR_MODEL] is not None + assert endpoint["device"][ATTR_NAME] is not None + assert endpoint["device"][ATTR_QUIRK_APPLIED] is not None + assert endpoint["device"]["entities"] is not None + assert endpoint["endpoint_id"] is not None + assert endpoint["entities"] is not None + + for entity_reference in endpoint["device"]["entities"]: + assert entity_reference[ATTR_NAME] is not None + assert entity_reference["entity_id"] is not None + + for entity_reference in endpoint["entities"]: + assert entity_reference["original_name"] is not None + + # Make sure there are no groupable devices when the device is unavailable + # Make device unavailable + device_groupable.available = False + + await zha_client.send_json({ID: 11, TYPE: "zha/devices/groupable"}) + + msg = await zha_client.receive_json() + assert msg["id"] == 11 + assert msg["type"] == const.TYPE_RESULT + + device_endpoints = msg["result"] + assert len(device_endpoints) == 0 + + +async def test_add_group(zha_client) -> None: + """Test adding and getting a new ZHA zigbee group.""" + await zha_client.send_json({ID: 12, TYPE: "zha/group/add", GROUP_NAME: "new_group"}) + + msg = await zha_client.receive_json() + assert msg["id"] == 12 + assert msg["type"] == const.TYPE_RESULT + + added_group = msg["result"] + + assert added_group["name"] == "new_group" + assert added_group["members"] == [] + + await zha_client.send_json({ID: 13, TYPE: "zha/groups"}) + + msg = await zha_client.receive_json() + assert msg["id"] == 13 + assert msg["type"] == const.TYPE_RESULT + + groups = msg["result"] + assert len(groups) == 2 + + for group in groups: + assert group["name"] == FIXTURE_GRP_NAME or group["name"] == "new_group" + + +async def test_remove_group(zha_client) -> None: + """Test removing a new ZHA zigbee group.""" + + await zha_client.send_json({ID: 14, TYPE: "zha/groups"}) + + msg = await zha_client.receive_json() + assert msg["id"] == 14 + assert msg["type"] == const.TYPE_RESULT + + groups = msg["result"] + assert len(groups) == 1 + + await zha_client.send_json( + {ID: 15, TYPE: "zha/group/remove", GROUP_IDS: [FIXTURE_GRP_ID]} + ) + + msg = await zha_client.receive_json() + assert msg["id"] == 15 + assert msg["type"] == const.TYPE_RESULT + + groups_remaining = msg["result"] + assert len(groups_remaining) == 0 + + await zha_client.send_json({ID: 16, TYPE: "zha/groups"}) + + msg = await zha_client.receive_json() + assert msg["id"] == 16 + assert msg["type"] == const.TYPE_RESULT + + groups = msg["result"] + assert len(groups) == 0 + + +@pytest.fixture +async def app_controller(hass, setup_zha): + """Fixture for zigpy Application Controller.""" + await setup_zha() + controller = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY].application_controller + p1 = patch.object(controller, "permit") + p2 = patch.object(controller, "permit_with_key", new=AsyncMock()) + with p1, p2: + yield controller + + +@pytest.mark.parametrize( + ("params", "duration", "node"), + ( + ({}, 60, None), + ({ATTR_DURATION: 30}, 30, None), + ( + {ATTR_DURATION: 33, ATTR_IEEE: "aa:bb:cc:dd:aa:bb:cc:dd"}, + 33, + zigpy.types.EUI64.convert("aa:bb:cc:dd:aa:bb:cc:dd"), + ), + ( + {ATTR_IEEE: "aa:bb:cc:dd:aa:bb:cc:d1"}, + 60, + zigpy.types.EUI64.convert("aa:bb:cc:dd:aa:bb:cc:d1"), + ), + ), +) +async def test_permit_ha12( + hass: HomeAssistant, + app_controller, + hass_admin_user: MockUser, + params, + duration, + node, +) -> None: + """Test permit service.""" + + await hass.services.async_call( + DOMAIN, SERVICE_PERMIT, params, True, Context(user_id=hass_admin_user.id) + ) + assert app_controller.permit.await_count == 1 + assert app_controller.permit.await_args[1]["time_s"] == duration + assert app_controller.permit.await_args[1]["node"] == node + assert app_controller.permit_with_key.call_count == 0 + + +IC_TEST_PARAMS = ( + ( + { + ATTR_SOURCE_IEEE: IEEE_SWITCH_DEVICE, + ATTR_INSTALL_CODE: "5279-7BF4-A508-4DAA-8E17-12B6-1741-CA02-4051", + }, + zigpy.types.EUI64.convert(IEEE_SWITCH_DEVICE), + unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), + ), + ( + { + ATTR_SOURCE_IEEE: IEEE_SWITCH_DEVICE, + ATTR_INSTALL_CODE: "52797BF4A5084DAA8E1712B61741CA024051", + }, + zigpy.types.EUI64.convert(IEEE_SWITCH_DEVICE), + unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), + ), +) + + +@pytest.mark.parametrize(("params", "src_ieee", "code"), IC_TEST_PARAMS) +async def test_permit_with_install_code( + hass: HomeAssistant, + app_controller, + hass_admin_user: MockUser, + params, + src_ieee, + code, +) -> None: + """Test permit service with install code.""" + + await hass.services.async_call( + DOMAIN, SERVICE_PERMIT, params, True, Context(user_id=hass_admin_user.id) + ) + assert app_controller.permit.await_count == 0 + assert app_controller.permit_with_key.call_count == 1 + assert app_controller.permit_with_key.await_args[1]["time_s"] == 60 + assert app_controller.permit_with_key.await_args[1]["node"] == src_ieee + assert app_controller.permit_with_key.await_args[1]["code"] == code + + +IC_FAIL_PARAMS = ( + { + # wrong install code + ATTR_SOURCE_IEEE: IEEE_SWITCH_DEVICE, + ATTR_INSTALL_CODE: "5279-7BF4-A508-4DAA-8E17-12B6-1741-CA02-4052", + }, + # incorrect service params + {ATTR_INSTALL_CODE: "5279-7BF4-A508-4DAA-8E17-12B6-1741-CA02-4051"}, + {ATTR_SOURCE_IEEE: IEEE_SWITCH_DEVICE}, + { + # incorrect service params + ATTR_INSTALL_CODE: "5279-7BF4-A508-4DAA-8E17-12B6-1741-CA02-4051", + ATTR_QR_CODE: "Z:000D6FFFFED4163B$I:52797BF4A5084DAA8E1712B61741CA024051", + }, + { + # incorrect service params + ATTR_SOURCE_IEEE: IEEE_SWITCH_DEVICE, + ATTR_QR_CODE: "Z:000D6FFFFED4163B$I:52797BF4A5084DAA8E1712B61741CA024051", + }, + { + # good regex match, but bad code + ATTR_QR_CODE: "Z:000D6FFFFED4163B$I:52797BF4A5084DAA8E1712B61741CA024052" + }, + { + # good aqara regex match, but bad code + ATTR_QR_CODE: ( + "G$M:751$S:357S00001579$D:000000000F350FFD%Z$A:04CF8CDF" + "3C3C3C3C$I:52797BF4A5084DAA8E1712B61741CA024052" + ) + }, + # good consciot regex match, but bad code + {ATTR_QR_CODE: "000D6FFFFED4163B|52797BF4A5084DAA8E1712B61741CA024052"}, +) + + +@pytest.mark.parametrize("params", IC_FAIL_PARAMS) +async def test_permit_with_install_code_fail( + hass: HomeAssistant, app_controller, hass_admin_user: MockUser, params +) -> None: + """Test permit service with install code.""" + + with pytest.raises(vol.Invalid): + await hass.services.async_call( + DOMAIN, SERVICE_PERMIT, params, True, Context(user_id=hass_admin_user.id) + ) + assert app_controller.permit.await_count == 0 + assert app_controller.permit_with_key.call_count == 0 + + +IC_QR_CODE_TEST_PARAMS = ( + ( + {ATTR_QR_CODE: "000D6FFFFED4163B|52797BF4A5084DAA8E1712B61741CA024051"}, + zigpy.types.EUI64.convert("00:0D:6F:FF:FE:D4:16:3B"), + unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), + ), + ( + {ATTR_QR_CODE: "Z:000D6FFFFED4163B$I:52797BF4A5084DAA8E1712B61741CA024051"}, + zigpy.types.EUI64.convert("00:0D:6F:FF:FE:D4:16:3B"), + unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), + ), + ( + { + ATTR_QR_CODE: ( + "G$M:751$S:357S00001579$D:000000000F350FFD%Z$A:04CF8CDF" + "3C3C3C3C$I:52797BF4A5084DAA8E1712B61741CA024051" + ) + }, + zigpy.types.EUI64.convert("04:CF:8C:DF:3C:3C:3C:3C"), + unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), + ), +) + + +@pytest.mark.parametrize(("params", "src_ieee", "code"), IC_QR_CODE_TEST_PARAMS) +async def test_permit_with_qr_code( + hass: HomeAssistant, + app_controller, + hass_admin_user: MockUser, + params, + src_ieee, + code, +) -> None: + """Test permit service with install code from qr code.""" + + await hass.services.async_call( + DOMAIN, SERVICE_PERMIT, params, True, Context(user_id=hass_admin_user.id) + ) + assert app_controller.permit.await_count == 0 + assert app_controller.permit_with_key.call_count == 1 + assert app_controller.permit_with_key.await_args[1]["time_s"] == 60 + assert app_controller.permit_with_key.await_args[1]["node"] == src_ieee + assert app_controller.permit_with_key.await_args[1]["code"] == code + + +@pytest.mark.parametrize(("params", "src_ieee", "code"), IC_QR_CODE_TEST_PARAMS) +async def test_ws_permit_with_qr_code( + app_controller, zha_client, params, src_ieee, code +) -> None: + """Test permit service with install code from qr code.""" + + await zha_client.send_json( + {ID: 14, TYPE: f"{DOMAIN}/devices/{SERVICE_PERMIT}", **params} + ) + + msg = await zha_client.receive_json() + assert msg["id"] == 14 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + + assert app_controller.permit.await_count == 0 + assert app_controller.permit_with_key.call_count == 1 + assert app_controller.permit_with_key.await_args[1]["time_s"] == 60 + assert app_controller.permit_with_key.await_args[1]["node"] == src_ieee + assert app_controller.permit_with_key.await_args[1]["code"] == code + + +@pytest.mark.parametrize("params", IC_FAIL_PARAMS) +async def test_ws_permit_with_install_code_fail( + app_controller, zha_client, params +) -> None: + """Test permit ws service with install code.""" + + await zha_client.send_json( + {ID: 14, TYPE: f"{DOMAIN}/devices/{SERVICE_PERMIT}", **params} + ) + + msg = await zha_client.receive_json() + assert msg["id"] == 14 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] is False + + assert app_controller.permit.await_count == 0 + assert app_controller.permit_with_key.call_count == 0 + + +@pytest.mark.parametrize( + ("params", "duration", "node"), + ( + ({}, 60, None), + ({ATTR_DURATION: 30}, 30, None), + ( + {ATTR_DURATION: 33, ATTR_IEEE: "aa:bb:cc:dd:aa:bb:cc:dd"}, + 33, + zigpy.types.EUI64.convert("aa:bb:cc:dd:aa:bb:cc:dd"), + ), + ( + {ATTR_IEEE: "aa:bb:cc:dd:aa:bb:cc:d1"}, + 60, + zigpy.types.EUI64.convert("aa:bb:cc:dd:aa:bb:cc:d1"), + ), + ), +) +async def test_ws_permit_ha12( + app_controller, zha_client, params, duration, node +) -> None: + """Test permit ws service.""" + + await zha_client.send_json( + {ID: 14, TYPE: f"{DOMAIN}/devices/{SERVICE_PERMIT}", **params} + ) + + msg = await zha_client.receive_json() + assert msg["id"] == 14 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + + assert app_controller.permit.await_count == 1 + assert app_controller.permit.await_args[1]["time_s"] == duration + assert app_controller.permit.await_args[1]["node"] == node + assert app_controller.permit_with_key.call_count == 0 + + +async def test_get_network_settings(app_controller, zha_client) -> None: + """Test current network settings are returned.""" + + await app_controller.backups.create_backup() + + await zha_client.send_json({ID: 6, TYPE: f"{DOMAIN}/network/settings"}) + msg = await zha_client.receive_json() + + assert msg["id"] == 6 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + assert "radio_type" in msg["result"] + assert "network_info" in msg["result"]["settings"] + + +async def test_list_network_backups(app_controller, zha_client) -> None: + """Test backups are serialized.""" + + await app_controller.backups.create_backup() + + await zha_client.send_json({ID: 6, TYPE: f"{DOMAIN}/network/backups/list"}) + msg = await zha_client.receive_json() + + assert msg["id"] == 6 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + assert "network_info" in msg["result"][0] + + +async def test_create_network_backup(app_controller, zha_client) -> None: + """Test creating backup.""" + + assert not app_controller.backups.backups + await zha_client.send_json({ID: 6, TYPE: f"{DOMAIN}/network/backups/create"}) + msg = await zha_client.receive_json() + assert len(app_controller.backups.backups) == 1 + + assert msg["id"] == 6 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + assert "backup" in msg["result"] and "is_complete" in msg["result"] + + +async def test_restore_network_backup_success(app_controller, zha_client) -> None: + """Test successfully restoring a backup.""" + + backup = zigpy.backups.NetworkBackup() + + with patch.object(app_controller.backups, "restore_backup", new=AsyncMock()) as p: + await zha_client.send_json( + { + ID: 6, + TYPE: f"{DOMAIN}/network/backups/restore", + "backup": backup.as_dict(), + } + ) + msg = await zha_client.receive_json() + + p.assert_called_once_with(backup) + assert "ezsp" not in backup.network_info.stack_specific + + assert msg["id"] == 6 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + + +async def test_restore_network_backup_force_write_eui64( + app_controller, zha_client +) -> None: + """Test successfully restoring a backup.""" + + backup = zigpy.backups.NetworkBackup() + + with patch.object(app_controller.backups, "restore_backup", new=AsyncMock()) as p: + await zha_client.send_json( + { + ID: 6, + TYPE: f"{DOMAIN}/network/backups/restore", + "backup": backup.as_dict(), + "ezsp_force_write_eui64": True, + } + ) + msg = await zha_client.receive_json() + + # EUI64 will be overwritten + p.assert_called_once_with( + backup.replace( + network_info=backup.network_info.replace( + stack_specific={"ezsp": {EZSP_OVERWRITE_EUI64: True}} + ) + ) + ) + + assert msg["id"] == 6 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + + +@patch("zigpy.backups.NetworkBackup.from_dict", new=lambda v: v) +async def test_restore_network_backup_failure(app_controller, zha_client) -> None: + """Test successfully restoring a backup.""" + + with patch.object( + app_controller.backups, + "restore_backup", + new=AsyncMock(side_effect=ValueError("Restore failed")), + ) as p: + await zha_client.send_json( + {ID: 6, TYPE: f"{DOMAIN}/network/backups/restore", "backup": "a backup"} + ) + msg = await zha_client.receive_json() + + p.assert_called_once_with("a backup") + + assert msg["id"] == 6 + assert msg["type"] == const.TYPE_RESULT + assert not msg["success"] + assert msg["error"]["code"] == const.ERR_INVALID_FORMAT From cc4ff553471044fbf49f578b2694db87af300525 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 22 Mar 2023 17:02:49 +0100 Subject: [PATCH 1154/2810] Update pvo to 1.0.0 (#90109) --- homeassistant/components/pvoutput/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/pvoutput/manifest.json b/homeassistant/components/pvoutput/manifest.json index b8869cdee7..b78f49b74f 100644 --- a/homeassistant/components/pvoutput/manifest.json +++ b/homeassistant/components/pvoutput/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["pvo==0.2.2"] + "requirements": ["pvo==1.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 91b480e973..36bea3aeba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1421,7 +1421,7 @@ pushbullet.py==0.11.0 pushover_complete==1.1.1 # homeassistant.components.pvoutput -pvo==0.2.2 +pvo==1.0.0 # homeassistant.components.canary py-canary==0.5.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6b88020aff..a93f569179 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1039,7 +1039,7 @@ pushbullet.py==0.11.0 pushover_complete==1.1.1 # homeassistant.components.pvoutput -pvo==0.2.2 +pvo==1.0.0 # homeassistant.components.canary py-canary==0.5.3 From 94e247dc69a9b560ffd86b2f6cd922cc90f805d3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 22 Mar 2023 18:52:52 +0100 Subject: [PATCH 1155/2810] Fix islamic_prayer_times setup (#90122) --- homeassistant/components/islamic_prayer_times/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/islamic_prayer_times/__init__.py b/homeassistant/components/islamic_prayer_times/__init__.py index 95a7db632b..d8810b0ad4 100644 --- a/homeassistant/components/islamic_prayer_times/__init__.py +++ b/homeassistant/components/islamic_prayer_times/__init__.py @@ -23,7 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b config_entry.async_on_unload( config_entry.add_update_listener(async_options_updated) ) - hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True From 6db8867b81552343bc4e640fe64ec3e7a7c30a3d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 22 Mar 2023 18:55:50 +0100 Subject: [PATCH 1156/2810] Update wled to 0.16.0 (#90120) --- homeassistant/components/wled/coordinator.py | 4 ++-- homeassistant/components/wled/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/wled/snapshots/test_diagnostics.ambr | 1 + tests/components/wled/test_coordinator.py | 4 ++-- 6 files changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/wled/coordinator.py b/homeassistant/components/wled/coordinator.py index 5afb5a6b44..9ba3fd2cb3 100644 --- a/homeassistant/components/wled/coordinator.py +++ b/homeassistant/components/wled/coordinator.py @@ -1,7 +1,7 @@ """DataUpdateCoordinator for WLED.""" from __future__ import annotations -from wled import WLED, Device as WLEDDevice, WLEDConnectionClosed, WLEDError +from wled import WLED, Device as WLEDDevice, WLEDConnectionClosedError, WLEDError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP @@ -68,7 +68,7 @@ class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]): try: await self.wled.listen(callback=self.async_set_updated_data) - except WLEDConnectionClosed as err: + except WLEDConnectionClosedError as err: self.last_update_success = False self.logger.info(err) except WLEDError as err: diff --git a/homeassistant/components/wled/manifest.json b/homeassistant/components/wled/manifest.json index 99309b9f00..b6d205912c 100644 --- a/homeassistant/components/wled/manifest.json +++ b/homeassistant/components/wled/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "platinum", - "requirements": ["wled==0.15.0"], + "requirements": ["wled==0.16.0"], "zeroconf": ["_wled._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 36bea3aeba..42355c1073 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2638,7 +2638,7 @@ wirelesstagpy==0.8.1 withings-api==2.4.0 # homeassistant.components.wled -wled==0.15.0 +wled==0.16.0 # homeassistant.components.wolflink wolf_smartset==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a93f569179..f9c4ae8876 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1878,7 +1878,7 @@ wiffi==1.1.2 withings-api==2.4.0 # homeassistant.components.wled -wled==0.15.0 +wled==0.16.0 # homeassistant.components.wolflink wolf_smartset==0.1.11 diff --git a/tests/components/wled/snapshots/test_diagnostics.ambr b/tests/components/wled/snapshots/test_diagnostics.ambr index e06608033c..25db6a3116 100644 --- a/tests/components/wled/snapshots/test_diagnostics.ambr +++ b/tests/components/wled/snapshots/test_diagnostics.ambr @@ -92,6 +92,7 @@ 'effect_count': 81, 'filesystem': None, 'free_heap': 14600, + 'ip': 'Unknown', 'leds': dict({ '__type': "", 'repr': 'Leds(cct=False, count=30, fps=None, light_capabilities=None, max_power=850, max_segments=10, power=470, rgbw=False, wv=True, segment_light_capabilities=None)', diff --git a/tests/components/wled/test_coordinator.py b/tests/components/wled/test_coordinator.py index 04d1c8f435..89817fb856 100644 --- a/tests/components/wled/test_coordinator.py +++ b/tests/components/wled/test_coordinator.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock import pytest from wled import ( Device as WLEDDevice, - WLEDConnectionClosed, + WLEDConnectionClosedError, WLEDConnectionError, WLEDError, ) @@ -124,7 +124,7 @@ async def test_websocket( assert state.state == STATE_OFF # Resolve Future with a connection losed. - connection_finished.set_exception(WLEDConnectionClosed) + connection_finished.set_exception(WLEDConnectionClosedError) await hass.async_block_till_done() # Disconnect called, unsubbed Home Assistant stop listener From 3931e11fd92807f7d8ecbe110b2ff2df7fb73f36 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 22 Mar 2023 20:10:10 +0100 Subject: [PATCH 1157/2810] Try to load integration before starting option flow (#90111) * Try to load integration before starting option flow * Adjust tests --- homeassistant/config_entries.py | 45 +++++++++++-------- .../components/config/test_config_entries.py | 4 ++ .../test_config_flow.py | 3 ++ .../helpers/test_schema_config_entry_flow.py | 14 +++++- tests/test_config_entries.py | 4 ++ 5 files changed, 50 insertions(+), 20 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 3ab16f6967..b21ae391e2 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -954,25 +954,7 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): Handler key is the domain of the component that we want to set up. """ - try: - integration = await loader.async_get_integration(self.hass, handler_key) - except loader.IntegrationNotFound as err: - _LOGGER.error("Cannot find integration %s", handler_key) - raise data_entry_flow.UnknownHandler from err - - # Make sure requirements and dependencies of component are resolved - await async_process_deps_reqs(self.hass, self._hass_config, integration) - - try: - integration.get_platform("config_flow") - except ImportError as err: - _LOGGER.error( - "Error occurred loading configuration flow for integration %s: %s", - handler_key, - err, - ) - raise data_entry_flow.UnknownHandler - + await _load_integration(self.hass, handler_key, self._hass_config) if (handler := HANDLERS.get(handler_key)) is None: raise data_entry_flow.UnknownHandler @@ -1842,6 +1824,8 @@ class OptionsFlowManager(data_entry_flow.FlowManager): if entry is None: raise UnknownEntry(handler_key) + await _load_integration(self.hass, entry.domain, {}) + if entry.domain not in HANDLERS: raise data_entry_flow.UnknownHandler @@ -2006,3 +1990,26 @@ async def support_remove_from_device(hass: HomeAssistant, domain: str) -> bool: integration = await loader.async_get_integration(hass, domain) component = integration.get_component() return hasattr(component, "async_remove_config_entry_device") + + +async def _load_integration( + hass: HomeAssistant, domain: str, hass_config: ConfigType +) -> None: + try: + integration = await loader.async_get_integration(hass, domain) + except loader.IntegrationNotFound as err: + _LOGGER.error("Cannot find integration %s", domain) + raise data_entry_flow.UnknownHandler from err + + # Make sure requirements and dependencies of component are resolved + await async_process_deps_reqs(hass, hass_config, integration) + + try: + integration.get_platform("config_flow") + except ImportError as err: + _LOGGER.error( + "Error occurred loading flow for integration %s: %s", + domain, + err, + ) + raise data_entry_flow.UnknownHandler diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index cf8df6aef6..f861d887b9 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -793,6 +793,8 @@ async def test_options_flow(hass: HomeAssistant, client) -> None: return OptionsFlowHandler() + mock_integration(hass, MockModule("test")) + mock_entity_platform(hass, "config_flow.test", None) MockConfigEntry( domain="test", entry_id="test1", @@ -824,6 +826,7 @@ async def test_two_step_options_flow(hass: HomeAssistant, client) -> None: mock_integration( hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) ) + mock_entity_platform(hass, "config_flow.test", None) class TestFlow(core_ce.ConfigFlow): @staticmethod @@ -889,6 +892,7 @@ async def test_options_flow_with_invalid_data(hass: HomeAssistant, client) -> No mock_integration( hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) ) + mock_entity_platform(hass, "config_flow.test", None) class TestFlow(core_ce.ConfigFlow): @staticmethod diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index 6ef3d13636..c74adbf32e 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -11,6 +11,7 @@ from homeassistant.components.zha.core.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, MockModule, mock_integration @@ -159,6 +160,7 @@ async def test_option_flow_install_multi_pan_addon( start_addon, ) -> None: """Test installing the multi pan addon.""" + assert await async_setup_component(hass, "usb", {}) mock_integration(hass, MockModule("hassio")) # Setup the config entry @@ -253,6 +255,7 @@ async def test_option_flow_install_multi_pan_addon_zha( start_addon, ) -> None: """Test installing the multi pan addon when a zha config entry exists.""" + assert await async_setup_component(hass, "usb", {}) mock_integration(hass, MockModule("hassio")) # Setup the config entry diff --git a/tests/helpers/test_schema_config_entry_flow.py b/tests/helpers/test_schema_config_entry_flow.py index 9919a53839..0bc8e0f1ff 100644 --- a/tests/helpers/test_schema_config_entry_flow.py +++ b/tests/helpers/test_schema_config_entry_flow.py @@ -23,7 +23,13 @@ from homeassistant.helpers.schema_config_entry_flow import ( ) from homeassistant.util.decorator import Registry -from tests.common import MockConfigEntry, mock_platform +from tests.common import ( + MockConfigEntry, + MockModule, + mock_entity_platform, + mock_integration, + mock_platform, +) TEST_DOMAIN = "test" @@ -226,6 +232,8 @@ async def test_options_flow_advanced_option( config_flow = {} options_flow = OPTIONS_FLOW + mock_integration(hass, MockModule("test")) + mock_entity_platform(hass, "config_flow.test", None) config_entry = MockConfigEntry( data={}, domain="test", @@ -513,6 +521,8 @@ async def test_suggested_values( config_flow = {} options_flow = OPTIONS_FLOW + mock_integration(hass, MockModule("test")) + mock_entity_platform(hass, "config_flow.test", None) config_entry = MockConfigEntry( data={}, domain="test", @@ -624,6 +634,8 @@ async def test_options_flow_state(hass: HomeAssistant) -> None: config_flow = {} options_flow = OPTIONS_FLOW + mock_integration(hass, MockModule("test")) + mock_entity_platform(hass, "config_flow.test", None) config_entry = MockConfigEntry( data={}, domain="test", diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 29041730da..c8cdc56198 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -1101,6 +1101,8 @@ async def test_entry_options( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that we can set options on an entry.""" + mock_integration(hass, MockModule("test")) + mock_entity_platform(hass, "config_flow.test", None) entry = MockConfigEntry(domain="test", data={"first": True}, options=None) entry.add_to_manager(manager) @@ -1137,6 +1139,8 @@ async def test_entry_options_abort( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that we can abort options flow.""" + mock_integration(hass, MockModule("test")) + mock_entity_platform(hass, "config_flow.test", None) entry = MockConfigEntry(domain="test", data={"first": True}, options=None) entry.add_to_manager(manager) From 4c98495fe067acd7c51546c5a923417c1c938fa3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Mar 2023 09:19:43 -1000 Subject: [PATCH 1158/2810] Bump ulid-transform to 0.5.1 (#90123) changelog: https://github.com/bdraco/ulid-transform/compare/v0.4.2...v0.5.1 --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index edc88caef8..ffef59913e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -44,7 +44,7 @@ requests==2.28.2 scapy==2.5.0 sqlalchemy==2.0.6 typing-extensions>=4.5.0,<5.0 -ulid-transform==0.4.2 +ulid-transform==0.5.1 voluptuous-serialize==2.6.0 voluptuous==0.13.1 yarl==1.8.1 diff --git a/pyproject.toml b/pyproject.toml index 3ee9bc7be5..d8ba8e7475 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ dependencies = [ "pyyaml==6.0", "requests==2.28.2", "typing-extensions>=4.5.0,<5.0", - "ulid-transform==0.4.2", + "ulid-transform==0.5.1", "voluptuous==0.13.1", "voluptuous-serialize==2.6.0", "yarl==1.8.1", diff --git a/requirements.txt b/requirements.txt index 168488a54d..1b4874e2c4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,7 @@ python-slugify==4.0.1 pyyaml==6.0 requests==2.28.2 typing-extensions>=4.5.0,<5.0 -ulid-transform==0.4.2 +ulid-transform==0.5.1 voluptuous==0.13.1 voluptuous-serialize==2.6.0 yarl==1.8.1 From 1ea3312ed4d7dd53b8f878292a423cf305b2a020 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 22 Mar 2023 20:20:42 +0100 Subject: [PATCH 1159/2810] Deduplicate multiprotocol addon helper (#90102) * Deduplicate multiprotocol addon helper * Clarify --- .../silabs_multiprotocol_addon.py | 51 +++++++++++++ .../homeassistant_sky_connect/__init__.py | 71 +++---------------- .../homeassistant_yellow/__init__.py | 55 +++----------- .../homeassistant_sky_connect/test_init.py | 8 +-- 4 files changed, 75 insertions(+), 110 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index 20fdb97e38..bba6b447c7 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -26,6 +26,7 @@ from homeassistant.data_entry_flow import ( FlowManager, FlowResult, ) +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.singleton import singleton from .const import LOGGER, SILABS_MULTIPROTOCOL_ADDON_SLUG @@ -356,3 +357,53 @@ class OptionsFlowHandler(BaseMultiPanFlow, config_entries.OptionsFlow): if user_input is None: return self.async_show_form(step_id="addon_installed_other_device") return self.async_create_entry(title="", data={}) + + +async def check_multi_pan_addon(hass: HomeAssistant) -> None: + """Check the multi-PAN addon state, and start it if installed but not started. + + Does nothing if Hass.io is not loaded. + Raises on error or if the add-on is installed but not started. + """ + if not is_hassio(hass): + return + + addon_manager: AddonManager = get_addon_manager(hass) + try: + addon_info: AddonInfo = await addon_manager.async_get_addon_info() + except AddonError as err: + _LOGGER.error(err) + raise HomeAssistantError from err + + # Request the addon to start if it's not started + # addon_manager.async_start_addon returns as soon as the start request has been sent + # and does not wait for the addon to be started, so we raise below + if addon_info.state == AddonState.NOT_RUNNING: + await addon_manager.async_start_addon() + + if addon_info.state not in (AddonState.NOT_INSTALLED, AddonState.RUNNING): + _LOGGER.debug("Multi pan addon installed and in state %s", addon_info.state) + raise HomeAssistantError + + +async def get_multi_pan_addon_info( + hass: HomeAssistant, device_path: str +) -> AddonInfo | None: + """Return AddonInfo if the multi-PAN addon is using the given device. + + Returns None if Hass.io is not loaded, the addon is not running or the addon is + connected to another device. + """ + if not is_hassio(hass): + return None + + addon_manager: AddonManager = get_addon_manager(hass) + addon_info: AddonInfo = await addon_manager.async_get_addon_info() + + if addon_info.state != AddonState.RUNNING: + return None + + if addon_info.options["device"] != device_path: + return None + + return addon_info diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index 1de919b8c7..54c11fd379 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -1,75 +1,19 @@ """The Home Assistant SkyConnect integration.""" from __future__ import annotations -import logging - from homeassistant.components import usb -from homeassistant.components.hassio import ( - AddonError, - AddonInfo, - AddonManager, - AddonState, - is_hassio, -) from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( - get_addon_manager, + check_multi_pan_addon, + get_multi_pan_addon_info, get_zigbee_socket, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from .const import DOMAIN from .util import get_usb_service_info -_LOGGER = logging.getLogger(__name__) - - -async def _wait_multi_pan_addon(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Wait for multi-PAN info to be available.""" - if not is_hassio(hass): - return - - addon_manager: AddonManager = get_addon_manager(hass) - try: - addon_info: AddonInfo = await addon_manager.async_get_addon_info() - except AddonError as err: - _LOGGER.error(err) - raise ConfigEntryNotReady from err - - # Start the addon if it's not started - if addon_info.state == AddonState.NOT_RUNNING: - await addon_manager.async_start_addon() - - if addon_info.state not in (AddonState.NOT_INSTALLED, AddonState.RUNNING): - _LOGGER.debug( - "Multi pan addon in state %s, delaying yellow config entry setup", - addon_info.state, - ) - raise ConfigEntryNotReady - - -async def _multi_pan_addon_info( - hass: HomeAssistant, entry: ConfigEntry -) -> AddonInfo | None: - """Return AddonInfo if the multi-PAN addon is enabled for our SkyConnect.""" - if not is_hassio(hass): - return None - - addon_manager: AddonManager = get_addon_manager(hass) - addon_info: AddonInfo = await addon_manager.async_get_addon_info() - - if addon_info.state != AddonState.RUNNING: - return None - - usb_dev = entry.data["device"] - dev_path = await hass.async_add_executor_job(usb.get_serial_by_id, usb_dev) - - if addon_info.options["device"] != dev_path: - return None - - return addon_info - async def _async_usb_scan_done(hass: HomeAssistant, entry: ConfigEntry) -> None: """Finish Home Assistant SkyConnect config entry setup.""" @@ -87,7 +31,9 @@ async def _async_usb_scan_done(hass: HomeAssistant, entry: ConfigEntry) -> None: hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) return - addon_info = await _multi_pan_addon_info(hass, entry) + usb_dev = entry.data["device"] + dev_path = await hass.async_add_executor_job(usb.get_serial_by_id, usb_dev) + addon_info = await get_multi_pan_addon_info(hass, dev_path) if not addon_info: usb_info = get_usb_service_info(entry) @@ -115,7 +61,10 @@ async def _async_usb_scan_done(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Home Assistant SkyConnect config entry.""" - await _wait_multi_pan_addon(hass, entry) + try: + await check_multi_pan_addon(hass) + except HomeAssistantError as err: + raise ConfigEntryNotReady from err @callback def async_usb_scan_done() -> None: diff --git a/homeassistant/components/homeassistant_yellow/__init__.py b/homeassistant/components/homeassistant_yellow/__init__.py index 9e22736fc7..72df6a5707 100644 --- a/homeassistant/components/homeassistant_yellow/__init__.py +++ b/homeassistant/components/homeassistant_yellow/__init__.py @@ -1,58 +1,18 @@ """The Home Assistant Yellow integration.""" from __future__ import annotations -import logging - -from homeassistant.components.hassio import ( - AddonError, - AddonInfo, - AddonManager, - AddonState, - get_os_info, -) +from homeassistant.components.hassio import get_os_info from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( - get_addon_manager, + check_multi_pan_addon, + get_multi_pan_addon_info, get_zigbee_socket, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from .const import RADIO_DEVICE, ZHA_HW_DISCOVERY_DATA -_LOGGER = logging.getLogger(__name__) - - -async def _multi_pan_addon_info( - hass: HomeAssistant, entry: ConfigEntry -) -> AddonInfo | None: - """Return AddonInfo if the multi-PAN addon is enabled for the Yellow's radio.""" - addon_manager: AddonManager = get_addon_manager(hass) - try: - addon_info: AddonInfo = await addon_manager.async_get_addon_info() - except AddonError as err: - _LOGGER.error(err) - raise ConfigEntryNotReady from err - - # Start the addon if it's not started - if addon_info.state == AddonState.NOT_RUNNING: - await addon_manager.async_start_addon() - - if addon_info.state not in (AddonState.NOT_INSTALLED, AddonState.RUNNING): - _LOGGER.debug( - "Multi pan addon in state %s, delaying yellow config entry setup", - addon_info.state, - ) - raise ConfigEntryNotReady - - if addon_info.state == AddonState.NOT_INSTALLED: - return None - - if addon_info.options["device"] != RADIO_DEVICE: - return None - - return addon_info - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Home Assistant Yellow config entry.""" @@ -66,7 +26,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) return False - addon_info = await _multi_pan_addon_info(hass, entry) + try: + await check_multi_pan_addon(hass) + except HomeAssistantError as err: + raise ConfigEntryNotReady from err + + addon_info = await get_multi_pan_addon_info(hass, RADIO_DEVICE) if not addon_info: hw_discovery_data = ZHA_HW_DISCOVERY_DATA diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index 3794b91a9e..746e119082 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -172,7 +172,7 @@ async def test_setup_zha_multipan( ) as mock_is_plugged_in, patch( "homeassistant.components.onboarding.async_is_onboarded", return_value=False ), patch( - "homeassistant.components.homeassistant_sky_connect.is_hassio", + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", side_effect=Mock(return_value=True), ): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -226,7 +226,7 @@ async def test_setup_zha_multipan_other_device( ) as mock_is_plugged_in, patch( "homeassistant.components.onboarding.async_is_onboarded", return_value=False ), patch( - "homeassistant.components.homeassistant_sky_connect.is_hassio", + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", side_effect=Mock(return_value=True), ): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -304,7 +304,7 @@ async def test_setup_entry_addon_info_fails( ), patch( "homeassistant.components.onboarding.async_is_onboarded", return_value=False ), patch( - "homeassistant.components.homeassistant_sky_connect.is_hassio", + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", side_effect=Mock(return_value=True), ): assert not await hass.config_entries.async_setup(config_entry.entry_id) @@ -333,7 +333,7 @@ async def test_setup_entry_addon_not_running( ), patch( "homeassistant.components.onboarding.async_is_onboarded", return_value=False ), patch( - "homeassistant.components.homeassistant_sky_connect.is_hassio", + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", side_effect=Mock(return_value=True), ): assert not await hass.config_entries.async_setup(config_entry.entry_id) From 5948347b6bd3030ffc2d33d3ac2679d3a655bc95 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 22 Mar 2023 20:24:05 +0100 Subject: [PATCH 1160/2810] Fix switch_as_x entity naming (#89992) * Fix switch_as_x entity naming * Simplify name logic --- homeassistant/components/switch_as_x/cover.py | 6 +- .../components/switch_as_x/entity.py | 36 ++++++--- homeassistant/components/switch_as_x/fan.py | 6 +- homeassistant/components/switch_as_x/light.py | 6 +- homeassistant/components/switch_as_x/lock.py | 6 +- homeassistant/components/switch_as_x/siren.py | 6 +- tests/components/switch_as_x/test_cover.py | 23 +++--- tests/components/switch_as_x/test_fan.py | 23 +++--- tests/components/switch_as_x/test_init.py | 74 +++++++++++++++++-- tests/components/switch_as_x/test_light.py | 4 +- tests/components/switch_as_x/test_lock.py | 19 ++--- tests/components/switch_as_x/test_siren.py | 23 +++--- 12 files changed, 148 insertions(+), 84 deletions(-) diff --git a/homeassistant/components/switch_as_x/cover.py b/homeassistant/components/switch_as_x/cover.py index 9d7a7bf617..b7f8e5bf97 100644 --- a/homeassistant/components/switch_as_x/cover.py +++ b/homeassistant/components/switch_as_x/cover.py @@ -30,18 +30,14 @@ async def async_setup_entry( entity_id = er.async_validate_entity_id( registry, config_entry.options[CONF_ENTITY_ID] ) - wrapped_switch = registry.async_get(entity_id) - device_id = wrapped_switch.device_id if wrapped_switch else None - entity_category = wrapped_switch.entity_category if wrapped_switch else None async_add_entities( [ CoverSwitch( + hass, config_entry.title, entity_id, config_entry.entry_id, - device_id, - entity_category, ) ] ) diff --git a/homeassistant/components/switch_as_x/entity.py b/homeassistant/components/switch_as_x/entity.py index ac56b4c607..8432c46f85 100644 --- a/homeassistant/components/switch_as_x/entity.py +++ b/homeassistant/components/switch_as_x/entity.py @@ -10,32 +10,47 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_ON, STATE_UNAVAILABLE, - EntityCategory, ) -from homeassistant.core import Event, callback -from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity import Entity, ToggleEntity +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity import DeviceInfo, Entity, ToggleEntity from homeassistant.helpers.event import async_track_state_change_event from .const import DOMAIN as SWITCH_AS_X_DOMAIN class BaseEntity(Entity): - """Represents a Switch as a X.""" + """Represents a Switch as an X.""" _attr_should_poll = False def __init__( self, - name: str, + hass: HomeAssistant, + config_entry_title: str, switch_entity_id: str, unique_id: str | None, - device_id: str | None, - entity_category: EntityCategory | None, ) -> None: - """Initialize Light Switch.""" + """Initialize Switch as an X.""" + registry = er.async_get(hass) + device_registry = dr.async_get(hass) + wrapped_switch = registry.async_get(switch_entity_id) + device_id = wrapped_switch.device_id if wrapped_switch else None + entity_category = wrapped_switch.entity_category if wrapped_switch else None + has_entity_name = wrapped_switch.has_entity_name if wrapped_switch else False + + name: str | None = config_entry_title + if wrapped_switch: + name = wrapped_switch.name or wrapped_switch.original_name + self._device_id = device_id + if device_id and (device := device_registry.async_get(device_id)): + self._attr_device_info = DeviceInfo( + connections=device.connections, + identifiers=device.identifiers, + ) self._attr_entity_category = entity_category + self._attr_has_entity_name = has_entity_name self._attr_name = name self._attr_unique_id = unique_id self._switch_entity_id = switch_entity_id @@ -69,10 +84,9 @@ class BaseEntity(Entity): # Call once on adding _async_state_changed_listener() - # Add this entity to the wrapped switch's device + # Update entity options registry = er.async_get(self.hass) if registry.async_get(self.entity_id) is not None: - registry.async_update_entity(self.entity_id, device_id=self._device_id) registry.async_update_entity_options( self.entity_id, SWITCH_AS_X_DOMAIN, diff --git a/homeassistant/components/switch_as_x/fan.py b/homeassistant/components/switch_as_x/fan.py index bfc4d2e037..87a6c38729 100644 --- a/homeassistant/components/switch_as_x/fan.py +++ b/homeassistant/components/switch_as_x/fan.py @@ -23,18 +23,14 @@ async def async_setup_entry( entity_id = er.async_validate_entity_id( registry, config_entry.options[CONF_ENTITY_ID] ) - wrapped_switch = registry.async_get(entity_id) - device_id = wrapped_switch.device_id if wrapped_switch else None - entity_category = wrapped_switch.entity_category if wrapped_switch else None async_add_entities( [ FanSwitch( + hass, config_entry.title, entity_id, config_entry.entry_id, - device_id, - entity_category, ) ] ) diff --git a/homeassistant/components/switch_as_x/light.py b/homeassistant/components/switch_as_x/light.py index c8181bf35f..7bcdb659e9 100644 --- a/homeassistant/components/switch_as_x/light.py +++ b/homeassistant/components/switch_as_x/light.py @@ -21,18 +21,14 @@ async def async_setup_entry( entity_id = er.async_validate_entity_id( registry, config_entry.options[CONF_ENTITY_ID] ) - wrapped_switch = registry.async_get(entity_id) - device_id = wrapped_switch.device_id if wrapped_switch else None - entity_category = wrapped_switch.entity_category if wrapped_switch else None async_add_entities( [ LightSwitch( + hass, config_entry.title, entity_id, config_entry.entry_id, - device_id, - entity_category, ) ] ) diff --git a/homeassistant/components/switch_as_x/lock.py b/homeassistant/components/switch_as_x/lock.py index a0aac15a70..e3c29a1cf4 100644 --- a/homeassistant/components/switch_as_x/lock.py +++ b/homeassistant/components/switch_as_x/lock.py @@ -30,18 +30,14 @@ async def async_setup_entry( entity_id = er.async_validate_entity_id( registry, config_entry.options[CONF_ENTITY_ID] ) - wrapped_switch = registry.async_get(entity_id) - device_id = wrapped_switch.device_id if wrapped_switch else None - entity_category = wrapped_switch.entity_category if wrapped_switch else None async_add_entities( [ LockSwitch( + hass, config_entry.title, entity_id, config_entry.entry_id, - device_id, - entity_category, ) ] ) diff --git a/homeassistant/components/switch_as_x/siren.py b/homeassistant/components/switch_as_x/siren.py index 635aa4e2d7..88ff9a322d 100644 --- a/homeassistant/components/switch_as_x/siren.py +++ b/homeassistant/components/switch_as_x/siren.py @@ -21,18 +21,14 @@ async def async_setup_entry( entity_id = er.async_validate_entity_id( registry, config_entry.options[CONF_ENTITY_ID] ) - wrapped_switch = registry.async_get(entity_id) - device_id = wrapped_switch.device_id if wrapped_switch else None - entity_category = wrapped_switch.entity_category if wrapped_switch else None async_add_entities( [ SirenSwitch( + hass, config_entry.title, entity_id, config_entry.entry_id, - device_id, - entity_category, ) ] ) diff --git a/tests/components/switch_as_x/test_cover.py b/tests/components/switch_as_x/test_cover.py index d8317a51b8..d0aef0b949 100644 --- a/tests/components/switch_as_x/test_cover.py +++ b/tests/components/switch_as_x/test_cover.py @@ -45,6 +45,7 @@ async def test_default_state(hass: HomeAssistant) -> None: async def test_service_calls(hass: HomeAssistant) -> None: """Test service calls to cover.""" await async_setup_component(hass, "switch", {"switch": [{"platform": "demo"}]}) + await hass.async_block_till_done() config_entry = MockConfigEntry( data={}, domain=DOMAIN, @@ -52,43 +53,43 @@ async def test_service_calls(hass: HomeAssistant) -> None: CONF_ENTITY_ID: "switch.decorative_lights", CONF_TARGET_DOMAIN: Platform.COVER, }, - title="garage_door", + title="Title is ignored", ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert hass.states.get("cover.garage_door").state == STATE_OPEN + assert hass.states.get("cover.decorative_lights").state == STATE_OPEN await hass.services.async_call( COVER_DOMAIN, SERVICE_TOGGLE, - {CONF_ENTITY_ID: "cover.garage_door"}, + {CONF_ENTITY_ID: "cover.decorative_lights"}, blocking=True, ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("cover.garage_door").state == STATE_CLOSED + assert hass.states.get("cover.decorative_lights").state == STATE_CLOSED await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, - {CONF_ENTITY_ID: "cover.garage_door"}, + {CONF_ENTITY_ID: "cover.decorative_lights"}, blocking=True, ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("cover.garage_door").state == STATE_OPEN + assert hass.states.get("cover.decorative_lights").state == STATE_OPEN await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER, - {CONF_ENTITY_ID: "cover.garage_door"}, + {CONF_ENTITY_ID: "cover.decorative_lights"}, blocking=True, ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("cover.garage_door").state == STATE_CLOSED + assert hass.states.get("cover.decorative_lights").state == STATE_CLOSED await hass.services.async_call( SWITCH_DOMAIN, @@ -98,7 +99,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("cover.garage_door").state == STATE_OPEN + assert hass.states.get("cover.decorative_lights").state == STATE_OPEN await hass.services.async_call( SWITCH_DOMAIN, @@ -108,7 +109,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("cover.garage_door").state == STATE_CLOSED + assert hass.states.get("cover.decorative_lights").state == STATE_CLOSED await hass.services.async_call( SWITCH_DOMAIN, @@ -118,4 +119,4 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("cover.garage_door").state == STATE_OPEN + assert hass.states.get("cover.decorative_lights").state == STATE_OPEN diff --git a/tests/components/switch_as_x/test_fan.py b/tests/components/switch_as_x/test_fan.py index b7b746344b..cf6789d439 100644 --- a/tests/components/switch_as_x/test_fan.py +++ b/tests/components/switch_as_x/test_fan.py @@ -41,6 +41,7 @@ async def test_default_state(hass: HomeAssistant) -> None: async def test_service_calls(hass: HomeAssistant) -> None: """Test service calls affecting the switch as fan entity.""" await async_setup_component(hass, "switch", {"switch": [{"platform": "demo"}]}) + await hass.async_block_till_done() config_entry = MockConfigEntry( data={}, domain=DOMAIN, @@ -48,43 +49,43 @@ async def test_service_calls(hass: HomeAssistant) -> None: CONF_ENTITY_ID: "switch.decorative_lights", CONF_TARGET_DOMAIN: Platform.FAN, }, - title="wind_machine", + title="Title is ignored", ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert hass.states.get("fan.wind_machine").state == STATE_ON + assert hass.states.get("fan.decorative_lights").state == STATE_ON await hass.services.async_call( FAN_DOMAIN, SERVICE_TOGGLE, - {CONF_ENTITY_ID: "fan.wind_machine"}, + {CONF_ENTITY_ID: "fan.decorative_lights"}, blocking=True, ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("fan.wind_machine").state == STATE_OFF + assert hass.states.get("fan.decorative_lights").state == STATE_OFF await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_ON, - {CONF_ENTITY_ID: "fan.wind_machine"}, + {CONF_ENTITY_ID: "fan.decorative_lights"}, blocking=True, ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("fan.wind_machine").state == STATE_ON + assert hass.states.get("fan.decorative_lights").state == STATE_ON await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_OFF, - {CONF_ENTITY_ID: "fan.wind_machine"}, + {CONF_ENTITY_ID: "fan.decorative_lights"}, blocking=True, ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("fan.wind_machine").state == STATE_OFF + assert hass.states.get("fan.decorative_lights").state == STATE_OFF await hass.services.async_call( SWITCH_DOMAIN, @@ -94,7 +95,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("fan.wind_machine").state == STATE_ON + assert hass.states.get("fan.decorative_lights").state == STATE_ON await hass.services.async_call( SWITCH_DOMAIN, @@ -104,7 +105,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("fan.wind_machine").state == STATE_OFF + assert hass.states.get("fan.decorative_lights").state == STATE_OFF await hass.services.async_call( SWITCH_DOMAIN, @@ -114,4 +115,4 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("fan.wind_machine").state == STATE_ON + assert hass.states.get("fan.decorative_lights").state == STATE_ON diff --git a/tests/components/switch_as_x/test_init.py b/tests/components/switch_as_x/test_init.py index a95725999d..2d63ce9617 100644 --- a/tests/components/switch_as_x/test_init.py +++ b/tests/components/switch_as_x/test_init.py @@ -71,7 +71,9 @@ async def test_entity_registry_events( ) -> None: """Test entity registry events are tracked.""" registry = er.async_get(hass) - registry_entry = registry.async_get_or_create("switch", "test", "unique") + registry_entry = registry.async_get_or_create( + "switch", "test", "unique", original_name="ABC" + ) switch_entity_id = registry_entry.entity_id hass.states.async_set(switch_entity_id, STATE_ON) @@ -144,6 +146,7 @@ async def test_device_registry_config_entry_1( "unique", config_entry=switch_config_entry, device_id=device_entry.id, + original_name="ABC", ) # Add another config entry to the same device device_registry.async_update_device( @@ -202,6 +205,7 @@ async def test_device_registry_config_entry_2( "unique", config_entry=switch_config_entry, device_id=device_entry.id, + original_name="ABC", ) switch_as_x_config_entry = MockConfigEntry( @@ -272,7 +276,9 @@ async def test_config_entry_entity_id( async def test_config_entry_uuid(hass: HomeAssistant, target_domain: Platform) -> None: """Test light switch setup from config entry with entity registry id.""" registry = er.async_get(hass) - registry_entry = registry.async_get_or_create("switch", "test", "unique") + registry_entry = registry.async_get_or_create( + "switch", "test", "unique", original_name="ABC" + ) config_entry = MockConfigEntry( data={}, @@ -305,7 +311,7 @@ async def test_device(hass: HomeAssistant, target_domain: Platform) -> None: connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) switch_entity_entry = entity_registry.async_get_or_create( - "switch", "test", "unique", device_id=device_entry.id + "switch", "test", "unique", device_id=device_entry.id, original_name="ABC" ) switch_as_x_config_entry = MockConfigEntry( @@ -414,7 +420,9 @@ async def test_entity_category_inheritance( """Test the entity category is inherited from source device.""" registry = er.async_get(hass) - switch_entity_entry = registry.async_get_or_create("switch", "test", "unique") + switch_entity_entry = registry.async_get_or_create( + "switch", "test", "unique", original_name="ABC" + ) registry.async_update_entity( switch_entity_entry.entity_id, entity_category=EntityCategory.CONFIG ) @@ -448,7 +456,9 @@ async def test_entity_options( """Test the source entity is stored as an entity option.""" registry = er.async_get(hass) - switch_entity_entry = registry.async_get_or_create("switch", "test", "unique") + switch_entity_entry = registry.async_get_or_create( + "switch", "test", "unique", original_name="ABC" + ) registry.async_update_entity( switch_entity_entry.entity_id, entity_category=EntityCategory.CONFIG ) @@ -474,3 +484,57 @@ async def test_entity_options( assert entity_entry.options == { DOMAIN: {"entity_id": switch_entity_entry.entity_id} } + + +@pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) +async def test_entity_name( + hass: HomeAssistant, + target_domain: Platform, +) -> None: + """Test the source entity has entity_name set to True.""" + registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + switch_config_entry = MockConfigEntry() + + device_entry = device_registry.async_get_or_create( + config_entry_id=switch_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + name="Device name", + ) + + switch_entity_entry = registry.async_get_or_create( + "switch", + "test", + "unique", + device_id=device_entry.id, + has_entity_name=True, + ) + switch_entity_entry = registry.async_update_entity( + switch_entity_entry.entity_id, + config_entry_id=switch_config_entry.entry_id, + ) + + # Add the config entry + switch_as_x_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_ENTITY_ID: switch_entity_entry.id, + CONF_TARGET_DOMAIN: target_domain, + }, + title="ABC", + ) + switch_as_x_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(switch_as_x_config_entry.entry_id) + await hass.async_block_till_done() + + entity_entry = registry.async_get(f"{target_domain}.device_name") + assert entity_entry + assert entity_entry.device_id == switch_entity_entry.device_id + assert entity_entry.has_entity_name is True + assert entity_entry.original_name is None + assert entity_entry.options == { + DOMAIN: {"entity_id": switch_entity_entry.entity_id} + } diff --git a/tests/components/switch_as_x/test_light.py b/tests/components/switch_as_x/test_light.py index b5976f1784..9a33bab20a 100644 --- a/tests/components/switch_as_x/test_light.py +++ b/tests/components/switch_as_x/test_light.py @@ -58,6 +58,7 @@ async def test_default_state(hass: HomeAssistant) -> None: async def test_light_service_calls(hass: HomeAssistant) -> None: """Test service calls to light.""" await async_setup_component(hass, "switch", {"switch": [{"platform": "demo"}]}) + await hass.async_block_till_done() config_entry = MockConfigEntry( data={}, domain=DOMAIN, @@ -111,6 +112,7 @@ async def test_light_service_calls(hass: HomeAssistant) -> None: async def test_switch_service_calls(hass: HomeAssistant) -> None: """Test service calls to switch.""" await async_setup_component(hass, "switch", {"switch": [{"platform": "demo"}]}) + await hass.async_block_till_done() config_entry = MockConfigEntry( data={}, domain=DOMAIN, @@ -118,7 +120,7 @@ async def test_switch_service_calls(hass: HomeAssistant) -> None: CONF_ENTITY_ID: "switch.decorative_lights", CONF_TARGET_DOMAIN: Platform.LIGHT, }, - title="decorative_lights", + title="Title is ignored", ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/switch_as_x/test_lock.py b/tests/components/switch_as_x/test_lock.py index de4c729e49..6d30ac4646 100644 --- a/tests/components/switch_as_x/test_lock.py +++ b/tests/components/switch_as_x/test_lock.py @@ -44,6 +44,7 @@ async def test_default_state(hass: HomeAssistant) -> None: async def test_service_calls(hass: HomeAssistant) -> None: """Test service calls affecting the switch as lock entity.""" await async_setup_component(hass, "switch", {"switch": [{"platform": "demo"}]}) + await hass.async_block_till_done() config_entry = MockConfigEntry( data={}, domain=DOMAIN, @@ -51,33 +52,33 @@ async def test_service_calls(hass: HomeAssistant) -> None: CONF_ENTITY_ID: "switch.decorative_lights", CONF_TARGET_DOMAIN: Platform.LOCK, }, - title="candy_jar", + title="Title is ignored", ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert hass.states.get("lock.candy_jar").state == STATE_UNLOCKED + assert hass.states.get("lock.decorative_lights").state == STATE_UNLOCKED await hass.services.async_call( LOCK_DOMAIN, SERVICE_LOCK, - {CONF_ENTITY_ID: "lock.candy_jar"}, + {CONF_ENTITY_ID: "lock.decorative_lights"}, blocking=True, ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("lock.candy_jar").state == STATE_LOCKED + assert hass.states.get("lock.decorative_lights").state == STATE_LOCKED await hass.services.async_call( LOCK_DOMAIN, SERVICE_UNLOCK, - {CONF_ENTITY_ID: "lock.candy_jar"}, + {CONF_ENTITY_ID: "lock.decorative_lights"}, blocking=True, ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("lock.candy_jar").state == STATE_UNLOCKED + assert hass.states.get("lock.decorative_lights").state == STATE_UNLOCKED await hass.services.async_call( SWITCH_DOMAIN, @@ -87,7 +88,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("lock.candy_jar").state == STATE_LOCKED + assert hass.states.get("lock.decorative_lights").state == STATE_LOCKED await hass.services.async_call( SWITCH_DOMAIN, @@ -97,7 +98,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("lock.candy_jar").state == STATE_UNLOCKED + assert hass.states.get("lock.decorative_lights").state == STATE_UNLOCKED await hass.services.async_call( SWITCH_DOMAIN, @@ -107,4 +108,4 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("lock.candy_jar").state == STATE_LOCKED + assert hass.states.get("lock.decorative_lights").state == STATE_LOCKED diff --git a/tests/components/switch_as_x/test_siren.py b/tests/components/switch_as_x/test_siren.py index 2b3dedf6fb..f776ab2ae0 100644 --- a/tests/components/switch_as_x/test_siren.py +++ b/tests/components/switch_as_x/test_siren.py @@ -41,6 +41,7 @@ async def test_default_state(hass: HomeAssistant) -> None: async def test_service_calls(hass: HomeAssistant) -> None: """Test service calls affecting the switch as siren entity.""" await async_setup_component(hass, "switch", {"switch": [{"platform": "demo"}]}) + await hass.async_block_till_done() config_entry = MockConfigEntry( data={}, domain=DOMAIN, @@ -48,43 +49,43 @@ async def test_service_calls(hass: HomeAssistant) -> None: CONF_ENTITY_ID: "switch.decorative_lights", CONF_TARGET_DOMAIN: Platform.SIREN, }, - title="noise_maker", + title="Title is ignored", ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert hass.states.get("siren.noise_maker").state == STATE_ON + assert hass.states.get("siren.decorative_lights").state == STATE_ON await hass.services.async_call( SIREN_DOMAIN, SERVICE_TOGGLE, - {CONF_ENTITY_ID: "siren.noise_maker"}, + {CONF_ENTITY_ID: "siren.decorative_lights"}, blocking=True, ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("siren.noise_maker").state == STATE_OFF + assert hass.states.get("siren.decorative_lights").state == STATE_OFF await hass.services.async_call( SIREN_DOMAIN, SERVICE_TURN_ON, - {CONF_ENTITY_ID: "siren.noise_maker"}, + {CONF_ENTITY_ID: "siren.decorative_lights"}, blocking=True, ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("siren.noise_maker").state == STATE_ON + assert hass.states.get("siren.decorative_lights").state == STATE_ON await hass.services.async_call( SIREN_DOMAIN, SERVICE_TURN_OFF, - {CONF_ENTITY_ID: "siren.noise_maker"}, + {CONF_ENTITY_ID: "siren.decorative_lights"}, blocking=True, ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("siren.noise_maker").state == STATE_OFF + assert hass.states.get("siren.decorative_lights").state == STATE_OFF await hass.services.async_call( SWITCH_DOMAIN, @@ -94,7 +95,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("siren.noise_maker").state == STATE_ON + assert hass.states.get("siren.decorative_lights").state == STATE_ON await hass.services.async_call( SWITCH_DOMAIN, @@ -104,7 +105,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("siren.noise_maker").state == STATE_OFF + assert hass.states.get("siren.decorative_lights").state == STATE_OFF await hass.services.async_call( SWITCH_DOMAIN, @@ -114,4 +115,4 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("siren.noise_maker").state == STATE_ON + assert hass.states.get("siren.decorative_lights").state == STATE_ON From 4ebce9746db2da6f0f863d35126f4d67c7e5d4f6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Mar 2023 10:05:23 -1000 Subject: [PATCH 1161/2810] Add schema auto repairs for states tables (#90083) --- .../recorder/auto_repairs/schema.py | 218 ++++++++++++++ .../recorder/auto_repairs/states/schema.py | 39 +++ .../auto_repairs/statistics/schema.py | 280 +----------------- .../components/recorder/db_schema.py | 17 +- .../components/recorder/migration.py | 86 ++++-- pylint/plugins/hass_enforce_type_hints.py | 2 +- .../recorder/auto_repairs/states/__init__.py | 5 + .../auto_repairs/states/test_schema.py | 106 +++++++ .../auto_repairs/statistics/test_schema.py | 178 +---------- .../recorder/auto_repairs/test_schema.py | 253 ++++++++++++++++ tests/conftest.py | 32 +- 11 files changed, 731 insertions(+), 485 deletions(-) create mode 100644 homeassistant/components/recorder/auto_repairs/schema.py create mode 100644 homeassistant/components/recorder/auto_repairs/states/schema.py create mode 100644 tests/components/recorder/auto_repairs/states/__init__.py create mode 100644 tests/components/recorder/auto_repairs/states/test_schema.py create mode 100644 tests/components/recorder/auto_repairs/test_schema.py diff --git a/homeassistant/components/recorder/auto_repairs/schema.py b/homeassistant/components/recorder/auto_repairs/schema.py new file mode 100644 index 0000000000..ec05eafd14 --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/schema.py @@ -0,0 +1,218 @@ +"""Schema repairs.""" +from __future__ import annotations + +from collections.abc import Iterable, Mapping +import logging +from typing import TYPE_CHECKING + +from sqlalchemy.exc import OperationalError +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.orm.attributes import InstrumentedAttribute + +from ..const import SupportedDialect +from ..db_schema import DOUBLE_PRECISION_TYPE_SQL, DOUBLE_TYPE +from ..util import session_scope + +if TYPE_CHECKING: + from .. import Recorder + +_LOGGER = logging.getLogger(__name__) + +MYSQL_ERR_INCORRECT_STRING_VALUE = 1366 + +# This name can't be represented unless 4-byte UTF-8 unicode is supported +UTF8_NAME = "𓆚𓃗" + +# This number can't be accurately represented as a 32-bit float +PRECISE_NUMBER = 1.000000000000001 + + +def _get_precision_column_types( + table_object: type[DeclarativeBase], +) -> list[str]: + """Get the column names for the columns that need to be checked for precision.""" + return [ + column.key + for column in table_object.__table__.columns + if column.type is DOUBLE_TYPE + ] + + +def validate_table_schema_supports_utf8( + instance: Recorder, + table_object: type[DeclarativeBase], + columns: tuple[InstrumentedAttribute, ...], +) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + schema_errors: set[str] = set() + # Lack of full utf8 support is only an issue for MySQL / MariaDB + if instance.dialect_name != SupportedDialect.MYSQL: + return schema_errors + + try: + schema_errors = _validate_table_schema_supports_utf8( + instance, table_object, columns + ) + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception("Error when validating DB schema: %s", exc) + + _log_schema_errors(table_object, schema_errors) + return schema_errors + + +def _validate_table_schema_supports_utf8( + instance: Recorder, + table_object: type[DeclarativeBase], + columns: tuple[InstrumentedAttribute, ...], +) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + schema_errors: set[str] = set() + # Mark the session as read_only to ensure that the test data is not committed + # to the database and we always rollback when the scope is exited + with session_scope(session=instance.get_session(), read_only=True) as session: + db_object = table_object(**{column.key: UTF8_NAME for column in columns}) + table = table_object.__tablename__ + # Try inserting some data which needs utf8mb4 support + session.add(db_object) + try: + session.flush() + except OperationalError as err: + if err.orig and err.orig.args[0] == MYSQL_ERR_INCORRECT_STRING_VALUE: + _LOGGER.debug( + "Database %s statistics_meta does not support 4-byte UTF-8", + table, + ) + schema_errors.add(f"{table}.4-byte UTF-8") + return schema_errors + raise + finally: + session.rollback() + return schema_errors + + +def validate_db_schema_precision( + instance: Recorder, + table_object: type[DeclarativeBase], +) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + schema_errors: set[str] = set() + # Wrong precision is only an issue for MySQL / MariaDB / PostgreSQL + if instance.dialect_name not in ( + SupportedDialect.MYSQL, + SupportedDialect.POSTGRESQL, + ): + return schema_errors + try: + schema_errors = _validate_db_schema_precision(instance, table_object) + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception("Error when validating DB schema: %s", exc) + + _log_schema_errors(table_object, schema_errors) + return schema_errors + + +def _validate_db_schema_precision( + instance: Recorder, + table_object: type[DeclarativeBase], +) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + schema_errors: set[str] = set() + columns = _get_precision_column_types(table_object) + # Mark the session as read_only to ensure that the test data is not committed + # to the database and we always rollback when the scope is exited + with session_scope(session=instance.get_session(), read_only=True) as session: + db_object = table_object(**{column: PRECISE_NUMBER for column in columns}) + table = table_object.__tablename__ + try: + session.add(db_object) + session.flush() + session.refresh(db_object) + _check_columns( + schema_errors=schema_errors, + stored={column: getattr(db_object, column) for column in columns}, + expected={column: PRECISE_NUMBER for column in columns}, + columns=columns, + table_name=table, + supports="double precision", + ) + finally: + session.rollback() + return schema_errors + + +def _log_schema_errors( + table_object: type[DeclarativeBase], schema_errors: set[str] +) -> None: + """Log schema errors.""" + if not schema_errors: + return + _LOGGER.debug( + "Detected %s schema errors: %s", + table_object.__tablename__, + ", ".join(sorted(schema_errors)), + ) + + +def _check_columns( + schema_errors: set[str], + stored: Mapping, + expected: Mapping, + columns: Iterable[str], + table_name: str, + supports: str, +) -> None: + """Check that the columns in the table support the given feature. + + Errors are logged and added to the schema_errors set. + """ + for column in columns: + if stored[column] == expected[column]: + continue + schema_errors.add(f"{table_name}.{supports}") + _LOGGER.error( + "Column %s in database table %s does not support %s (stored=%s != expected=%s)", + column, + table_name, + supports, + stored[column], + expected[column], + ) + + +def correct_db_schema_utf8( + instance: Recorder, table_object: type[DeclarativeBase], schema_errors: set[str] +) -> None: + """Correct utf8 issues detected by validate_db_schema.""" + table_name = table_object.__tablename__ + if f"{table_name}.4-byte UTF-8" in schema_errors: + from ..migration import ( # pylint: disable=import-outside-toplevel + _correct_table_character_set_and_collation, + ) + + _correct_table_character_set_and_collation(table_name, instance.get_session) + + +def correct_db_schema_precision( + instance: Recorder, + table_object: type[DeclarativeBase], + schema_errors: set[str], +) -> None: + """Correct precision issues detected by validate_db_schema.""" + table_name = table_object.__tablename__ + + if f"{table_name}.double precision" in schema_errors: + from ..migration import ( # pylint: disable=import-outside-toplevel + _modify_columns, + ) + + precision_columns = _get_precision_column_types(table_object) + # Attempt to convert timestamp columns to µs precision + session_maker = instance.get_session + engine = instance.engine + assert engine is not None, "Engine should be set" + _modify_columns( + session_maker, + engine, + table_name, + [f"{column} {DOUBLE_PRECISION_TYPE_SQL}" for column in precision_columns], + ) diff --git a/homeassistant/components/recorder/auto_repairs/states/schema.py b/homeassistant/components/recorder/auto_repairs/states/schema.py new file mode 100644 index 0000000000..258e15cbb5 --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/states/schema.py @@ -0,0 +1,39 @@ +"""States schema repairs.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ...db_schema import StateAttributes, States +from ..schema import ( + correct_db_schema_precision, + correct_db_schema_utf8, + validate_db_schema_precision, + validate_table_schema_supports_utf8, +) + +if TYPE_CHECKING: + from ... import Recorder + +TABLE_UTF8_COLUMNS = { + States: (States.state,), + StateAttributes: (StateAttributes.shared_attrs,), +} + + +def validate_db_schema(instance: Recorder) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + schema_errors: set[str] = set() + for table, columns in TABLE_UTF8_COLUMNS.items(): + schema_errors |= validate_table_schema_supports_utf8(instance, table, columns) + schema_errors |= validate_db_schema_precision(instance, States) + return schema_errors + + +def correct_db_schema( + instance: Recorder, + schema_errors: set[str], +) -> None: + """Correct issues detected by validate_db_schema.""" + for table in (States, StateAttributes): + correct_db_schema_utf8(instance, table, schema_errors) + correct_db_schema_precision(instance, States, schema_errors) diff --git a/homeassistant/components/recorder/auto_repairs/statistics/schema.py b/homeassistant/components/recorder/auto_repairs/statistics/schema.py index bbf59080ac..9b4687cb72 100644 --- a/homeassistant/components/recorder/auto_repairs/statistics/schema.py +++ b/homeassistant/components/recorder/auto_repairs/statistics/schema.py @@ -1,28 +1,16 @@ """Statistics schema repairs.""" from __future__ import annotations -from collections.abc import Callable, Mapping -import contextlib -from datetime import datetime import logging from typing import TYPE_CHECKING -from sqlalchemy import text -from sqlalchemy.engine import Engine -from sqlalchemy.exc import OperationalError, SQLAlchemyError -from sqlalchemy.orm.session import Session - -from homeassistant.core import HomeAssistant -from homeassistant.util import dt as dt_util - -from ...const import DOMAIN, SupportedDialect -from ...db_schema import Statistics, StatisticsShortTerm -from ...models import StatisticData, StatisticMetaData, datetime_to_timestamp_or_none -from ...statistics import ( - _import_statistics_with_session, - _statistics_during_period_with_session, +from ...db_schema import Statistics, StatisticsMeta, StatisticsShortTerm +from ..schema import ( + correct_db_schema_precision, + correct_db_schema_utf8, + validate_db_schema_precision, + validate_table_schema_supports_utf8, ) -from ...util import session_scope if TYPE_CHECKING: from ... import Recorder @@ -30,200 +18,14 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -def _validate_db_schema_utf8( - instance: Recorder, session_maker: Callable[[], Session] -) -> set[str]: +def validate_db_schema(instance: Recorder) -> set[str]: """Do some basic checks for common schema errors caused by manual migration.""" schema_errors: set[str] = set() - - # Lack of full utf8 support is only an issue for MySQL / MariaDB - if instance.dialect_name != SupportedDialect.MYSQL: - return schema_errors - - # This name can't be represented unless 4-byte UTF-8 unicode is supported - utf8_name = "𓆚𓃗" - statistic_id = f"{DOMAIN}.db_test" - - metadata: StatisticMetaData = { - "has_mean": True, - "has_sum": True, - "name": utf8_name, - "source": DOMAIN, - "statistic_id": statistic_id, - "unit_of_measurement": None, - } - statistics_meta_manager = instance.statistics_meta_manager - - # Try inserting some metadata which needs utf8mb4 support - try: - # Mark the session as read_only to ensure that the test data is not committed - # to the database and we always rollback when the scope is exited - with session_scope(session=session_maker(), read_only=True) as session: - old_metadata_dict = statistics_meta_manager.get_many( - session, statistic_ids={statistic_id} - ) - try: - statistics_meta_manager.update_or_add( - session, metadata, old_metadata_dict - ) - statistics_meta_manager.delete(session, statistic_ids=[statistic_id]) - except OperationalError as err: - if err.orig and err.orig.args[0] == 1366: - _LOGGER.debug( - "Database table statistics_meta does not support 4-byte UTF-8" - ) - schema_errors.add("statistics_meta.4-byte UTF-8") - session.rollback() - else: - raise - except Exception as exc: # pylint: disable=broad-except - _LOGGER.exception("Error when validating DB schema: %s", exc) - return schema_errors - - -def _get_future_year() -> int: - """Get a year in the future.""" - return datetime.now().year + 1 - - -def _validate_db_schema( - hass: HomeAssistant, instance: Recorder, session_maker: Callable[[], Session] -) -> set[str]: - """Do some basic checks for common schema errors caused by manual migration.""" - schema_errors: set[str] = set() - statistics_meta_manager = instance.statistics_meta_manager - - # Wrong precision is only an issue for MySQL / MariaDB / PostgreSQL - if instance.dialect_name not in ( - SupportedDialect.MYSQL, - SupportedDialect.POSTGRESQL, - ): - return schema_errors - - # This number can't be accurately represented as a 32-bit float - precise_number = 1.000000000000001 - # This time can't be accurately represented unless datetimes have µs precision - # - # We want to insert statistics for a time in the future, in case they - # have conflicting metadata_id's with existing statistics that were - # never cleaned up. By inserting in the future, we can be sure that - # that by selecting the last inserted row, we will get the one we - # just inserted. - # - future_year = _get_future_year() - precise_time = datetime(future_year, 10, 6, microsecond=1, tzinfo=dt_util.UTC) - start_time = datetime(future_year, 10, 6, tzinfo=dt_util.UTC) - statistic_id = f"{DOMAIN}.db_test" - - metadata: StatisticMetaData = { - "has_mean": True, - "has_sum": True, - "name": None, - "source": DOMAIN, - "statistic_id": statistic_id, - "unit_of_measurement": None, - } - statistics: StatisticData = { - "last_reset": precise_time, - "max": precise_number, - "mean": precise_number, - "min": precise_number, - "start": precise_time, - "state": precise_number, - "sum": precise_number, - } - - def check_columns( - schema_errors: set[str], - stored: Mapping, - expected: Mapping, - columns: tuple[str, ...], - table_name: str, - supports: str, - ) -> None: - for column in columns: - if stored[column] != expected[column]: - schema_errors.add(f"{table_name}.{supports}") - _LOGGER.error( - "Column %s in database table %s does not support %s (stored=%s != expected=%s)", - column, - table_name, - supports, - stored[column], - expected[column], - ) - - # Insert / adjust a test statistics row in each of the tables - tables: tuple[type[Statistics | StatisticsShortTerm], ...] = ( - Statistics, - StatisticsShortTerm, + schema_errors |= validate_table_schema_supports_utf8( + instance, StatisticsMeta, (StatisticsMeta.statistic_id,) ) - try: - # Mark the session as read_only to ensure that the test data is not committed - # to the database and we always rollback when the scope is exited - with session_scope(session=session_maker(), read_only=True) as session: - for table in tables: - _import_statistics_with_session( - instance, session, metadata, (statistics,), table - ) - stored_statistics = _statistics_during_period_with_session( - hass, - session, - start_time, - None, - {statistic_id}, - "hour" if table == Statistics else "5minute", - None, - {"last_reset", "max", "mean", "min", "state", "sum"}, - ) - if not (stored_statistic := stored_statistics.get(statistic_id)): - _LOGGER.warning( - "Schema validation failed for table: %s", table.__tablename__ - ) - continue - - # We want to look at the last inserted row to make sure there - # is not previous garbage data in the table that would cause - # the test to produce an incorrect result. To achieve this, - # we inserted a row in the future, and now we select the last - # inserted row back. - last_stored_statistic = stored_statistic[-1] - check_columns( - schema_errors, - last_stored_statistic, - statistics, - ("max", "mean", "min", "state", "sum"), - table.__tablename__, - "double precision", - ) - assert statistics["last_reset"] - check_columns( - schema_errors, - last_stored_statistic, - { - "last_reset": datetime_to_timestamp_or_none( - statistics["last_reset"] - ), - "start": datetime_to_timestamp_or_none(statistics["start"]), - }, - ("start", "last_reset"), - table.__tablename__, - "µs precision", - ) - statistics_meta_manager.delete(session, statistic_ids=[statistic_id]) - except Exception as exc: # pylint: disable=broad-except - _LOGGER.exception("Error when validating DB schema: %s", exc) - - return schema_errors - - -def validate_db_schema( - hass: HomeAssistant, instance: Recorder, session_maker: Callable[[], Session] -) -> set[str]: - """Do some basic checks for common schema errors caused by manual migration.""" - schema_errors: set[str] = set() - schema_errors |= _validate_db_schema_utf8(instance, session_maker) - schema_errors |= _validate_db_schema(hass, instance, session_maker) + for table in (Statistics, StatisticsShortTerm): + schema_errors |= validate_db_schema_precision(instance, table) if schema_errors: _LOGGER.debug( "Detected statistics schema errors: %s", ", ".join(sorted(schema_errors)) @@ -233,63 +35,9 @@ def validate_db_schema( def correct_db_schema( instance: Recorder, - engine: Engine, - session_maker: Callable[[], Session], schema_errors: set[str], ) -> None: """Correct issues detected by validate_db_schema.""" - from ...migration import _modify_columns # pylint: disable=import-outside-toplevel - - if "statistics_meta.4-byte UTF-8" in schema_errors: - # Attempt to convert the table to utf8mb4 - _LOGGER.warning( - ( - "Updating character set and collation of table %s to utf8mb4. " - "Note: this can take several minutes on large databases and slow " - "computers. Please be patient!" - ), - "statistics_meta", - ) - with contextlib.suppress(SQLAlchemyError), session_scope( - session=session_maker() - ) as session: - connection = session.connection() - connection.execute( - # Using LOCK=EXCLUSIVE to prevent the database from corrupting - # https://github.com/home-assistant/core/issues/56104 - text( - "ALTER TABLE statistics_meta CONVERT TO CHARACTER SET utf8mb4" - " COLLATE utf8mb4_unicode_ci, LOCK=EXCLUSIVE" - ) - ) - - tables: tuple[type[Statistics | StatisticsShortTerm], ...] = ( - Statistics, - StatisticsShortTerm, - ) - for table in tables: - if f"{table.__tablename__}.double precision" in schema_errors: - # Attempt to convert float columns to double precision - _modify_columns( - session_maker, - engine, - table.__tablename__, - [ - "mean DOUBLE PRECISION", - "min DOUBLE PRECISION", - "max DOUBLE PRECISION", - "state DOUBLE PRECISION", - "sum DOUBLE PRECISION", - ], - ) - if f"{table.__tablename__}.µs precision" in schema_errors: - # Attempt to convert timestamp columns to µs precision - _modify_columns( - session_maker, - engine, - table.__tablename__, - [ - "last_reset_ts DOUBLE PRECISION", - "start_ts DOUBLE PRECISION", - ], - ) + correct_db_schema_utf8(instance, StatisticsMeta, schema_errors) + for table in (Statistics, StatisticsShortTerm): + correct_db_schema_precision(instance, table, schema_errors) diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 0bb0b846a3..cf4c0543c1 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -119,13 +119,17 @@ STATES_CONTEXT_ID_BIN_INDEX = "ix_states_context_id_bin" LEGACY_STATES_EVENT_ID_INDEX = "ix_states_event_id" CONTEXT_ID_BIN_MAX_LENGTH = 16 +MYSQL_COLLATE = "utf8mb4_unicode_ci" +MYSQL_DEFAULT_CHARSET = "utf8mb4" +MYSQL_ENGINE = "InnoDB" + _DEFAULT_TABLE_ARGS = { - "mysql_default_charset": "utf8mb4", - "mysql_collate": "utf8mb4_unicode_ci", - "mysql_engine": "InnoDB", - "mariadb_default_charset": "utf8mb4", - "mariadb_collate": "utf8mb4_unicode_ci", - "mariadb_engine": "InnoDB", + "mysql_default_charset": MYSQL_DEFAULT_CHARSET, + "mysql_collate": MYSQL_COLLATE, + "mysql_engine": MYSQL_ENGINE, + "mariadb_default_charset": MYSQL_DEFAULT_CHARSET, + "mariadb_collate": MYSQL_COLLATE, + "mariadb_engine": MYSQL_ENGINE, } @@ -154,6 +158,7 @@ DOUBLE_TYPE = ( .with_variant(oracle.DOUBLE_PRECISION(), "oracle") .with_variant(postgresql.DOUBLE_PRECISION(), "postgresql") ) +DOUBLE_PRECISION_TYPE_SQL = "DOUBLE PRECISION" TIMESTAMP_TYPE = DOUBLE_TYPE diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 6fc2138d91..927097b18f 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -28,6 +28,10 @@ from homeassistant.core import HomeAssistant from homeassistant.util.enum import try_parse_enum from homeassistant.util.ulid import ulid_to_bytes +from .auto_repairs.states.schema import ( + correct_db_schema as states_correct_db_schema, + validate_db_schema as states_validate_db_schema, +) from .auto_repairs.statistics.duplicates import ( delete_statistics_duplicates, delete_statistics_meta_duplicates, @@ -39,7 +43,10 @@ from .auto_repairs.statistics.schema import ( from .const import SupportedDialect from .db_schema import ( CONTEXT_ID_BIN_MAX_LENGTH, + DOUBLE_PRECISION_TYPE_SQL, LEGACY_STATES_EVENT_ID_INDEX, + MYSQL_COLLATE, + MYSQL_DEFAULT_CHARSET, SCHEMA_VERSION, STATISTICS_TABLES, TABLE_STATES, @@ -96,13 +103,13 @@ class _ColumnTypesForDialect: _MYSQL_COLUMN_TYPES = _ColumnTypesForDialect( big_int_type="INTEGER(20)", - timestamp_type="DOUBLE PRECISION", + timestamp_type=DOUBLE_PRECISION_TYPE_SQL, context_bin_type=f"BLOB({CONTEXT_ID_BIN_MAX_LENGTH})", ) _POSTGRESQL_COLUMN_TYPES = _ColumnTypesForDialect( big_int_type="INTEGER", - timestamp_type="DOUBLE PRECISION", + timestamp_type=DOUBLE_PRECISION_TYPE_SQL, context_bin_type="BYTEA", ) @@ -151,7 +158,7 @@ class SchemaValidationStatus: """Store schema validation status.""" current_version: int - statistics_schema_errors: set[str] + schema_errors: set[str] valid: bool @@ -178,13 +185,23 @@ def validate_db_schema( if is_current := _schema_is_current(current_version): # We can only check for further errors if the schema is current, because # columns may otherwise not exist etc. - schema_errors |= statistics_validate_db_schema(hass, instance, session_maker) + schema_errors = _find_schema_errors(hass, instance, session_maker) valid = is_current and not schema_errors return SchemaValidationStatus(current_version, schema_errors, valid) +def _find_schema_errors( + hass: HomeAssistant, instance: Recorder, session_maker: Callable[[], Session] +) -> set[str]: + """Find schema errors.""" + schema_errors: set[str] = set() + schema_errors |= statistics_validate_db_schema(instance) + schema_errors |= states_validate_db_schema(instance) + return schema_errors + + def live_migration(schema_status: SchemaValidationStatus) -> bool: """Check if live migration is possible.""" return schema_status.current_version >= LIVE_MIGRATION_MIN_SCHEMA_VERSION @@ -226,12 +243,13 @@ def migrate_schema( # so its clear that the upgrade is done _LOGGER.warning("Upgrade to version %s done", new_version) - if schema_errors := schema_status.statistics_schema_errors: + if schema_errors := schema_status.schema_errors: _LOGGER.warning( "Database is about to correct DB schema errors: %s", ", ".join(sorted(schema_errors)), ) - statistics_correct_db_schema(instance, engine, session_maker, schema_errors) + statistics_correct_db_schema(instance, schema_errors) + states_correct_db_schema(instance, schema_errors) if current_version != SCHEMA_VERSION: instance.queue_task(PostSchemaMigrationTask(current_version, SCHEMA_VERSION)) @@ -732,38 +750,15 @@ def _apply_update( # noqa: C901 engine, "statistics", [ - "mean DOUBLE PRECISION", - "min DOUBLE PRECISION", - "max DOUBLE PRECISION", - "state DOUBLE PRECISION", - "sum DOUBLE PRECISION", + f"{column} {DOUBLE_PRECISION_TYPE_SQL}" + for column in ("max", "mean", "min", "state", "sum") ], ) elif new_version == 21: # Try to change the character set of the statistic_meta table if engine.dialect.name == SupportedDialect.MYSQL: for table in ("events", "states", "statistics_meta"): - _LOGGER.warning( - ( - "Updating character set and collation of table %s to utf8mb4." - " Note: this can take several minutes on large databases and" - " slow computers. Please be patient!" - ), - table, - ) - with contextlib.suppress(SQLAlchemyError), session_scope( - session=session_maker() - ) as session: - connection = session.connection() - connection.execute( - # Using LOCK=EXCLUSIVE to prevent - # the database from corrupting - # https://github.com/home-assistant/core/issues/56104 - text( - f"ALTER TABLE {table} CONVERT TO CHARACTER SET utf8mb4" - " COLLATE utf8mb4_unicode_ci, LOCK=EXCLUSIVE" - ) - ) + _correct_table_character_set_and_collation(table, session_maker) elif new_version == 22: # Recreate the all statistics tables for Oracle DB with Identity columns # @@ -1090,6 +1085,33 @@ def _apply_update( # noqa: C901 raise ValueError(f"No schema migration defined for version {new_version}") +def _correct_table_character_set_and_collation( + table: str, + session_maker: Callable[[], Session], +) -> None: + """Correct issues detected by validate_db_schema.""" + # Attempt to convert the table to utf8mb4 + _LOGGER.warning( + "Updating character set and collation of table %s to utf8mb4. " + "Note: this can take several minutes on large databases and slow " + "computers. Please be patient!", + table, + ) + with contextlib.suppress(SQLAlchemyError), session_scope( + session=session_maker() + ) as session: + connection = session.connection() + connection.execute( + # Using LOCK=EXCLUSIVE to prevent the database from corrupting + # https://github.com/home-assistant/core/issues/56104 + text( + f"ALTER TABLE {table} CONVERT TO CHARACTER SET " + f"{MYSQL_DEFAULT_CHARSET} " + f"COLLATE {MYSQL_COLLATE}, LOCK=EXCLUSIVE" + ) + ) + + def post_schema_migration( instance: Recorder, old_version: int, diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 9430158fae..6394f84222 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -102,7 +102,7 @@ _TEST_FIXTURES: dict[str, list[str] | str] = { "enable_custom_integrations": "None", "enable_nightly_purge": "bool", "enable_statistics": "bool", - "enable_statistics_table_validation": "bool", + "enable_schema_validation": "bool", "entity_registry": "EntityRegistry", "freezer": "FrozenDateTimeFactory", "hass_access_token": "str", diff --git a/tests/components/recorder/auto_repairs/states/__init__.py b/tests/components/recorder/auto_repairs/states/__init__.py new file mode 100644 index 0000000000..6e98d881ea --- /dev/null +++ b/tests/components/recorder/auto_repairs/states/__init__.py @@ -0,0 +1,5 @@ +"""Tests for Recorder component.""" + +import pytest + +pytest.register_assert_rewrite("tests.components.recorder.common") diff --git a/tests/components/recorder/auto_repairs/states/test_schema.py b/tests/components/recorder/auto_repairs/states/test_schema.py new file mode 100644 index 0000000000..2e37001582 --- /dev/null +++ b/tests/components/recorder/auto_repairs/states/test_schema.py @@ -0,0 +1,106 @@ +"""The test repairing states schema.""" + +# pylint: disable=invalid-name +from unittest.mock import ANY, patch + +import pytest + +from homeassistant.core import HomeAssistant + +from ...common import async_wait_recording_done + +from tests.typing import RecorderInstanceGenerator + + +@pytest.mark.parametrize("enable_schema_validation", [True]) +@pytest.mark.parametrize("db_engine", ("mysql", "postgresql")) +async def test_validate_db_schema_fix_float_issue( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + db_engine, +) -> None: + """Test validating DB schema with postgresql and mysql. + + Note: The test uses SQLite, the purpose is only to exercise the code. + """ + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine + ), patch( + "homeassistant.components.recorder.auto_repairs.schema._validate_db_schema_precision", + return_value={"states.double precision"}, + ), patch( + "homeassistant.components.recorder.migration._modify_columns" + ) as modify_columns_mock: + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + assert "Schema validation failed" not in caplog.text + assert ( + "Database is about to correct DB schema errors: states.double precision" + in caplog.text + ) + modification = [ + "last_changed_ts DOUBLE PRECISION", + "last_updated_ts DOUBLE PRECISION", + ] + modify_columns_mock.assert_called_once_with(ANY, ANY, "states", modification) + + +@pytest.mark.parametrize("enable_schema_validation", [True]) +async def test_validate_db_schema_fix_utf8_issue_states( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema with MySQL. + + Note: The test uses SQLite, the purpose is only to exercise the code. + """ + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", "mysql" + ), patch( + "homeassistant.components.recorder.auto_repairs.schema._validate_table_schema_supports_utf8", + return_value={"states.4-byte UTF-8"}, + ): + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + assert "Schema validation failed" not in caplog.text + assert ( + "Database is about to correct DB schema errors: states.4-byte UTF-8" + in caplog.text + ) + assert ( + "Updating character set and collation of table states to utf8mb4" in caplog.text + ) + + +@pytest.mark.parametrize("enable_schema_validation", [True]) +async def test_validate_db_schema_fix_utf8_issue_state_attributes( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema with MySQL. + + Note: The test uses SQLite, the purpose is only to exercise the code. + """ + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", "mysql" + ), patch( + "homeassistant.components.recorder.auto_repairs.schema._validate_table_schema_supports_utf8", + return_value={"state_attributes.4-byte UTF-8"}, + ): + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + assert "Schema validation failed" not in caplog.text + assert ( + "Database is about to correct DB schema errors: state_attributes.4-byte UTF-8" + in caplog.text + ) + assert ( + "Updating character set and collation of table state_attributes to utf8mb4" + in caplog.text + ) diff --git a/tests/components/recorder/auto_repairs/statistics/test_schema.py b/tests/components/recorder/auto_repairs/statistics/test_schema.py index 2c4e06580f..dfe036355a 100644 --- a/tests/components/recorder/auto_repairs/statistics/test_schema.py +++ b/tests/components/recorder/auto_repairs/statistics/test_schema.py @@ -1,52 +1,18 @@ """The test repairing statistics schema.""" # pylint: disable=invalid-name -from datetime import datetime -from unittest.mock import ANY, DEFAULT, MagicMock, patch +from unittest.mock import ANY, patch import pytest -from sqlalchemy.exc import OperationalError -from homeassistant.components.recorder.auto_repairs.statistics.schema import ( - _get_future_year, -) -from homeassistant.components.recorder.statistics import ( - _statistics_during_period_with_session, -) -from homeassistant.components.recorder.table_managers.statistics_meta import ( - StatisticsMetaManager, -) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util from ...common import async_wait_recording_done from tests.typing import RecorderInstanceGenerator -@pytest.mark.parametrize("enable_statistics_table_validation", [True]) -@pytest.mark.parametrize("db_engine", ("mysql", "postgresql")) -async def test_validate_db_schema( - async_setup_recorder_instance: RecorderInstanceGenerator, - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - db_engine, -) -> None: - """Test validating DB schema with MySQL and PostgreSQL. - - Note: The test uses SQLite, the purpose is only to exercise the code. - """ - with patch( - "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine - ): - await async_setup_recorder_instance(hass) - await async_wait_recording_done(hass) - assert "Schema validation failed" not in caplog.text - assert "Detected statistics schema errors" not in caplog.text - assert "Database is about to correct DB schema errors" not in caplog.text - - -@pytest.mark.parametrize("enable_statistics_table_validation", [True]) +@pytest.mark.parametrize("enable_schema_validation", [True]) async def test_validate_db_schema_fix_utf8_issue( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, @@ -56,15 +22,11 @@ async def test_validate_db_schema_fix_utf8_issue( Note: The test uses SQLite, the purpose is only to exercise the code. """ - orig_error = MagicMock() - orig_error.args = [1366] - utf8_error = OperationalError("", "", orig=orig_error) with patch( "homeassistant.components.recorder.core.Recorder.dialect_name", "mysql" ), patch( - "homeassistant.components.recorder.table_managers.statistics_meta.StatisticsMetaManager.update_or_add", - wraps=StatisticsMetaManager.update_or_add, - side_effect=[utf8_error, DEFAULT, DEFAULT], + "homeassistant.components.recorder.auto_repairs.schema._validate_table_schema_supports_utf8", + return_value={"statistics_meta.4-byte UTF-8"}, ): await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) @@ -80,60 +42,25 @@ async def test_validate_db_schema_fix_utf8_issue( ) -@pytest.mark.parametrize("enable_statistics_table_validation", [True]) +@pytest.mark.parametrize("enable_schema_validation", [True]) +@pytest.mark.parametrize("table", ("statistics_short_term", "statistics")) @pytest.mark.parametrize("db_engine", ("mysql", "postgresql")) -@pytest.mark.parametrize( - ("table", "replace_index"), (("statistics", 0), ("statistics_short_term", 1)) -) -@pytest.mark.parametrize( - ("column", "value"), - (("max", 1.0), ("mean", 1.0), ("min", 1.0), ("state", 1.0), ("sum", 1.0)), -) async def test_validate_db_schema_fix_float_issue( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - db_engine, - table, - replace_index, - column, - value, + table: str, + db_engine: str, ) -> None: - """Test validating DB schema with MySQL. + """Test validating DB schema with postgresql and mysql. Note: The test uses SQLite, the purpose is only to exercise the code. """ - orig_error = MagicMock() - orig_error.args = [1366] - precise_number = 1.000000000000001 - fixed_future_year = _get_future_year() - precise_time = datetime(fixed_future_year, 10, 6, microsecond=1, tzinfo=dt_util.UTC) - statistics = { - "recorder.db_test": [ - { - "last_reset": precise_time.timestamp(), - "max": precise_number, - "mean": precise_number, - "min": precise_number, - "start": precise_time.timestamp(), - "state": precise_number, - "sum": precise_number, - } - ] - } - statistics["recorder.db_test"][0][column] = value - fake_statistics = [DEFAULT, DEFAULT] - fake_statistics[replace_index] = statistics - with patch( "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine ), patch( - "homeassistant.components.recorder.auto_repairs.statistics.schema._get_future_year", - return_value=fixed_future_year, - ), patch( - "homeassistant.components.recorder.auto_repairs.statistics.schema._statistics_during_period_with_session", - side_effect=fake_statistics, - wraps=_statistics_during_period_with_session, + "homeassistant.components.recorder.auto_repairs.schema._validate_db_schema_precision", + return_value={f"{table}.double precision"}, ), patch( "homeassistant.components.recorder.migration._modify_columns" ) as modify_columns_mock: @@ -146,90 +73,13 @@ async def test_validate_db_schema_fix_float_issue( in caplog.text ) modification = [ + "created_ts DOUBLE PRECISION", + "start_ts DOUBLE PRECISION", "mean DOUBLE PRECISION", "min DOUBLE PRECISION", "max DOUBLE PRECISION", + "last_reset_ts DOUBLE PRECISION", "state DOUBLE PRECISION", "sum DOUBLE PRECISION", ] modify_columns_mock.assert_called_once_with(ANY, ANY, table, modification) - - -@pytest.mark.parametrize("enable_statistics_table_validation", [True]) -@pytest.mark.parametrize( - ("db_engine", "modification"), - ( - ("mysql", ["last_reset_ts DOUBLE PRECISION", "start_ts DOUBLE PRECISION"]), - ( - "postgresql", - [ - "last_reset_ts DOUBLE PRECISION", - "start_ts DOUBLE PRECISION", - ], - ), - ), -) -@pytest.mark.parametrize( - ("table", "replace_index"), (("statistics", 0), ("statistics_short_term", 1)) -) -@pytest.mark.parametrize( - ("column", "value"), - ( - ("last_reset", "2020-10-06T00:00:00+00:00"), - ("start", "2020-10-06T00:00:00+00:00"), - ), -) -async def test_validate_db_schema_fix_statistics_datetime_issue( - async_setup_recorder_instance: RecorderInstanceGenerator, - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - db_engine, - modification, - table, - replace_index, - column, - value, -) -> None: - """Test validating DB schema with MySQL. - - Note: The test uses SQLite, the purpose is only to exercise the code. - """ - orig_error = MagicMock() - orig_error.args = [1366] - precise_number = 1.000000000000001 - precise_time = datetime(2020, 10, 6, microsecond=1, tzinfo=dt_util.UTC) - statistics = { - "recorder.db_test": [ - { - "last_reset": precise_time, - "max": precise_number, - "mean": precise_number, - "min": precise_number, - "start": precise_time, - "state": precise_number, - "sum": precise_number, - } - ] - } - statistics["recorder.db_test"][0][column] = value - fake_statistics = [DEFAULT, DEFAULT] - fake_statistics[replace_index] = statistics - - with patch( - "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine - ), patch( - "homeassistant.components.recorder.auto_repairs.statistics.schema._statistics_during_period_with_session", - side_effect=fake_statistics, - wraps=_statistics_during_period_with_session, - ), patch( - "homeassistant.components.recorder.migration._modify_columns" - ) as modify_columns_mock: - await async_setup_recorder_instance(hass) - await async_wait_recording_done(hass) - - assert "Schema validation failed" not in caplog.text - assert ( - f"Database is about to correct DB schema errors: {table}.µs precision" - in caplog.text - ) - modify_columns_mock.assert_called_once_with(ANY, ANY, table, modification) diff --git a/tests/components/recorder/auto_repairs/test_schema.py b/tests/components/recorder/auto_repairs/test_schema.py new file mode 100644 index 0000000000..510f46f98a --- /dev/null +++ b/tests/components/recorder/auto_repairs/test_schema.py @@ -0,0 +1,253 @@ +"""The test validating and repairing schema.""" + +# pylint: disable=invalid-name +from unittest.mock import patch + +import pytest +from sqlalchemy import text + +from homeassistant.components.recorder.auto_repairs.schema import ( + correct_db_schema_precision, + correct_db_schema_utf8, + validate_db_schema_precision, + validate_table_schema_supports_utf8, +) +from homeassistant.components.recorder.db_schema import States +from homeassistant.components.recorder.migration import _modify_columns +from homeassistant.components.recorder.util import get_instance, session_scope +from homeassistant.core import HomeAssistant + +from ..common import async_wait_recording_done + +from tests.typing import RecorderInstanceGenerator + + +@pytest.mark.parametrize("enable_schema_validation", [True]) +@pytest.mark.parametrize("db_engine", ("mysql", "postgresql")) +async def test_validate_db_schema( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + db_engine, +) -> None: + """Test validating DB schema with MySQL and PostgreSQL. + + Note: The test uses SQLite, the purpose is only to exercise the code. + """ + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine + ): + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + assert "Schema validation failed" not in caplog.text + assert "Detected statistics schema errors" not in caplog.text + assert "Database is about to correct DB schema errors" not in caplog.text + + +async def test_validate_db_schema_fix_utf8_issue_good_schema( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + recorder_db_url: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema with MySQL when the schema is correct.""" + if not recorder_db_url.startswith("mysql://"): + # This problem only happens on MySQL + return + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + instance = get_instance(hass) + schema_errors = await instance.async_add_executor_job( + validate_table_schema_supports_utf8, instance, States, (States.state,) + ) + assert schema_errors == set() + + +async def test_validate_db_schema_fix_utf8_issue_with_broken_schema( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + recorder_db_url: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema with MySQL when the schema is broken and repairing it.""" + if not recorder_db_url.startswith("mysql://"): + # This problem only happens on MySQL + return + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + instance = get_instance(hass) + session_maker = instance.get_session + + def _break_states_schema(): + with session_scope(session=session_maker()) as session: + session.execute( + text( + "ALTER TABLE states MODIFY state VARCHAR(255) " + "CHARACTER SET ascii COLLATE ascii_general_ci, " + "LOCK=EXCLUSIVE;" + ) + ) + + await instance.async_add_executor_job(_break_states_schema) + schema_errors = await instance.async_add_executor_job( + validate_table_schema_supports_utf8, instance, States, (States.state,) + ) + assert schema_errors == {"states.4-byte UTF-8"} + + # Now repair the schema + await instance.async_add_executor_job( + correct_db_schema_utf8, instance, States, schema_errors + ) + + # Now validate the schema again + schema_errors = await instance.async_add_executor_job( + validate_table_schema_supports_utf8, instance, States, ("state",) + ) + assert schema_errors == set() + + +async def test_validate_db_schema_fix_utf8_issue_with_broken_schema_unrepairable( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + recorder_db_url: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema with MySQL when the schema is broken and cannot be repaired.""" + if not recorder_db_url.startswith("mysql://"): + # This problem only happens on MySQL + return + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + instance = get_instance(hass) + session_maker = instance.get_session + + def _break_states_schema(): + with session_scope(session=session_maker()) as session: + session.execute( + text( + "ALTER TABLE states MODIFY state VARCHAR(255) " + "CHARACTER SET ascii COLLATE ascii_general_ci, " + "LOCK=EXCLUSIVE;" + ) + ) + _modify_columns( + session_maker, + instance.engine, + "states", + [ + "entity_id VARCHAR(255) NOT NULL", + ], + ) + + await instance.async_add_executor_job(_break_states_schema) + schema_errors = await instance.async_add_executor_job( + validate_table_schema_supports_utf8, instance, States, ("state",) + ) + assert schema_errors == set() + assert "Error when validating DB schema" in caplog.text + + +async def test_validate_db_schema_precision_good_schema( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + recorder_db_url: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema when the schema is correct.""" + if not recorder_db_url.startswith(("mysql://", "postgresql://")): + # This problem only happens on MySQL and PostgreSQL + return + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + instance = get_instance(hass) + schema_errors = await instance.async_add_executor_job( + validate_db_schema_precision, + instance, + States, + ) + assert schema_errors == set() + + +async def test_validate_db_schema_precision_with_broken_schema( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + recorder_db_url: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema when the schema is broken and than repair it.""" + if not recorder_db_url.startswith(("mysql://", "postgresql://")): + # This problem only happens on MySQL and PostgreSQL + return + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + instance = get_instance(hass) + session_maker = instance.get_session + + def _break_states_schema(): + _modify_columns( + session_maker, + instance.engine, + "states", + [ + "last_updated_ts FLOAT(4)", + "last_changed_ts FLOAT(4)", + ], + ) + + await instance.async_add_executor_job(_break_states_schema) + schema_errors = await instance.async_add_executor_job( + validate_db_schema_precision, + instance, + States, + ) + assert schema_errors == {"states.double precision"} + + # Now repair the schema + await instance.async_add_executor_job( + correct_db_schema_precision, instance, States, schema_errors + ) + + # Now validate the schema again + schema_errors = await instance.async_add_executor_job( + validate_db_schema_precision, + instance, + States, + ) + assert schema_errors == set() + + +async def test_validate_db_schema_precision_with_unrepairable_broken_schema( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + recorder_db_url: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema when the schema is broken and cannot be repaired.""" + if not recorder_db_url.startswith("mysql://"): + # This problem only happens on MySQL + return + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + instance = get_instance(hass) + session_maker = instance.get_session + + def _break_states_schema(): + _modify_columns( + session_maker, + instance.engine, + "states", + [ + "state VARCHAR(255) NOT NULL", + "last_updated_ts FLOAT(4)", + "last_changed_ts FLOAT(4)", + ], + ) + + await instance.async_add_executor_job(_break_states_schema) + schema_errors = await instance.async_add_executor_job( + validate_db_schema_precision, + instance, + States, + ) + assert "Error when validating DB schema" in caplog.text + assert schema_errors == set() diff --git a/tests/conftest.py b/tests/conftest.py index c5197dd2bd..397d3d55b2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1161,11 +1161,11 @@ def enable_statistics() -> bool: @pytest.fixture -def enable_statistics_table_validation() -> bool: +def enable_schema_validation() -> bool: """Fixture to control enabling of recorder's statistics table validation. To enable statistics table validation, tests can be marked with: - @pytest.mark.parametrize("enable_statistics_table_validation", [True]) + @pytest.mark.parametrize("enable_schema_validation", [True]) """ return False @@ -1272,7 +1272,7 @@ def hass_recorder( recorder_db_url: str, enable_nightly_purge: bool, enable_statistics: bool, - enable_statistics_table_validation: bool, + enable_schema_validation: bool, enable_migrate_context_ids: bool, enable_migrate_event_type_ids: bool, enable_migrate_entity_ids: bool, @@ -1283,16 +1283,16 @@ def hass_recorder( from homeassistant.components import recorder # pylint: disable-next=import-outside-toplevel - from homeassistant.components.recorder.auto_repairs.statistics import schema + from homeassistant.components.recorder import migration original_tz = dt_util.DEFAULT_TIME_ZONE hass = get_test_home_assistant() nightly = recorder.Recorder.async_nightly_tasks if enable_nightly_purge else None stats = recorder.Recorder.async_periodic_statistics if enable_statistics else None - stats_validate = ( - schema.validate_db_schema - if enable_statistics_table_validation + schema_validate = ( + migration._find_schema_errors + if enable_schema_validation else itertools.repeat(set()) ) migrate_states_context_ids = ( @@ -1322,8 +1322,8 @@ def hass_recorder( side_effect=stats, autospec=True, ), patch( - "homeassistant.components.recorder.migration.statistics_validate_db_schema", - side_effect=stats_validate, + "homeassistant.components.recorder.migration._find_schema_errors", + side_effect=schema_validate, autospec=True, ), patch( "homeassistant.components.recorder.Recorder._migrate_events_context_ids", @@ -1391,7 +1391,7 @@ async def async_setup_recorder_instance( recorder_db_url: str, enable_nightly_purge: bool, enable_statistics: bool, - enable_statistics_table_validation: bool, + enable_schema_validation: bool, enable_migrate_context_ids: bool, enable_migrate_event_type_ids: bool, enable_migrate_entity_ids: bool, @@ -1401,16 +1401,16 @@ async def async_setup_recorder_instance( from homeassistant.components import recorder # pylint: disable-next=import-outside-toplevel - from homeassistant.components.recorder.auto_repairs.statistics import schema + from homeassistant.components.recorder import migration # pylint: disable-next=import-outside-toplevel from .components.recorder.common import async_recorder_block_till_done nightly = recorder.Recorder.async_nightly_tasks if enable_nightly_purge else None stats = recorder.Recorder.async_periodic_statistics if enable_statistics else None - stats_validate = ( - schema.validate_db_schema - if enable_statistics_table_validation + schema_validate = ( + migration._find_schema_errors + if enable_schema_validation else itertools.repeat(set()) ) migrate_states_context_ids = ( @@ -1440,8 +1440,8 @@ async def async_setup_recorder_instance( side_effect=stats, autospec=True, ), patch( - "homeassistant.components.recorder.migration.statistics_validate_db_schema", - side_effect=stats_validate, + "homeassistant.components.recorder.migration._find_schema_errors", + side_effect=schema_validate, autospec=True, ), patch( "homeassistant.components.recorder.Recorder._migrate_events_context_ids", From 03aeaba7ef6fc0093cca1d9e9984796b0d473ad4 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 22 Mar 2023 22:34:23 +0100 Subject: [PATCH 1162/2810] Turn AVM FRITZ!Box Tools sensors into coordinator entities (#89953) * make sensors coordinator entities * apply suggestions * move _attr_has_entity_name up --- homeassistant/components/fritz/common.py | 85 +++++++++++++++++++++++- homeassistant/components/fritz/sensor.py | 53 ++++----------- 2 files changed, 95 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index 09103a0bcc..f6025e773e 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -35,7 +35,8 @@ from homeassistant.helpers import ( update_coordinator, ) from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util from .const import ( @@ -136,7 +137,9 @@ class HostInfo(TypedDict): status: bool -class FritzBoxTools(update_coordinator.DataUpdateCoordinator[None]): +class FritzBoxTools( + update_coordinator.DataUpdateCoordinator[dict[str, bool | StateType]] +): """FritzBoxTools class.""" def __init__( @@ -175,6 +178,9 @@ class FritzBoxTools(update_coordinator.DataUpdateCoordinator[None]): self._latest_firmware: str | None = None self._update_available: bool = False self._release_url: str | None = None + self._entity_update_functions: dict[ + str, Callable[[FritzStatus, StateType], Any] + ] = {} async def async_setup( self, options: MappingProxyType[str, Any] | None = None @@ -237,12 +243,36 @@ class FritzBoxTools(update_coordinator.DataUpdateCoordinator[None]): ) self.device_is_router = self.fritz_status.has_wan_enabled - async def _async_update_data(self) -> None: + def register_entity_updates( + self, key: str, update_fn: Callable[[FritzStatus, StateType], Any] + ) -> Callable[[], None]: + """Register an entity to be updated by coordinator.""" + + def unregister_entity_updates() -> None: + """Unregister an entity to be updated by coordinator.""" + if key in self._entity_update_functions: + _LOGGER.debug("unregister entity %s from updates", key) + self._entity_update_functions.pop(key) + + if key not in self._entity_update_functions: + _LOGGER.debug("register entity %s for updates", key) + self._entity_update_functions[key] = update_fn + return unregister_entity_updates + + async def _async_update_data(self) -> dict[str, bool | StateType]: """Update FritzboxTools data.""" + enity_data: dict[str, bool | StateType] = {} try: await self.async_scan_devices() + for key, update_fn in self._entity_update_functions.items(): + _LOGGER.debug("update entity %s", key) + enity_data[key] = await self.hass.async_add_executor_job( + update_fn, self.fritz_status, self.data.get(key) + ) except FRITZ_EXCEPTIONS as ex: raise update_coordinator.UpdateFailed(ex) from ex + _LOGGER.debug("enity_data: %s", enity_data) + return enity_data @property def unique_id(self) -> str: @@ -981,6 +1011,55 @@ class FritzBoxBaseEntity: ) +@dataclass +class FritzRequireKeysMixin: + """Fritz entity description mix in.""" + + value_fn: Callable[[FritzStatus, Any], Any] + + +@dataclass +class FritzEntityDescription(EntityDescription, FritzRequireKeysMixin): + """Fritz entity base description.""" + + +class FritzBoxBaseCoordinatorEntity(update_coordinator.CoordinatorEntity): + """Fritz host coordinator entity base class.""" + + coordinator: AvmWrapper + entity_description: FritzEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + avm_wrapper: AvmWrapper, + device_name: str, + description: FritzEntityDescription, + ) -> None: + """Init device info class.""" + super().__init__(avm_wrapper) + self.async_on_remove( + avm_wrapper.register_entity_updates(description.key, description.value_fn) + ) + self.entity_description = description + self._device_name = device_name + self._attr_name = description.name + self._attr_unique_id = f"{avm_wrapper.unique_id}-{description.key}" + + @property + def device_info(self) -> DeviceInfo: + """Return the device information.""" + return DeviceInfo( + configuration_url=f"http://{self.coordinator.host}", + connections={(dr.CONNECTION_NETWORK_MAC, self.coordinator.mac)}, + identifiers={(DOMAIN, self.coordinator.unique_id)}, + manufacturer="AVM", + model=self.coordinator.model, + name=self._device_name, + sw_version=self.coordinator.current_firmware, + ) + + @dataclass class ConnectionInfo: """Fritz sensor connection information class.""" diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 628d56dc45..4b15f3f92d 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -5,9 +5,7 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta import logging -from typing import Any -from fritzconnection.core.exceptions import FritzConnectionException from fritzconnection.lib.fritzstatus import FritzStatus from homeassistant.components.sensor import ( @@ -25,9 +23,15 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow -from .common import AvmWrapper, ConnectionInfo, FritzBoxBaseEntity +from .common import ( + AvmWrapper, + ConnectionInfo, + FritzBoxBaseCoordinatorEntity, + FritzEntityDescription, +) from .const import DOMAIN, DSL_CONNECTION, UPTIME_DEVIATION _LOGGER = logging.getLogger(__name__) @@ -139,14 +143,7 @@ def _retrieve_link_attenuation_received_state( @dataclass -class FritzRequireKeysMixin: - """Fritz sensor data class.""" - - value_fn: Callable[[FritzStatus, Any], Any] - - -@dataclass -class FritzSensorEntityDescription(SensorEntityDescription, FritzRequireKeysMixin): +class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescription): """Describes Fritz sensor entity.""" is_suitable: Callable[[ConnectionInfo], bool] = lambda info: info.wan_enabled @@ -304,36 +301,12 @@ async def async_setup_entry( async_add_entities(entities, True) -class FritzBoxSensor(FritzBoxBaseEntity, SensorEntity): +class FritzBoxSensor(FritzBoxBaseCoordinatorEntity, SensorEntity): """Define FRITZ!Box connectivity class.""" entity_description: FritzSensorEntityDescription - def __init__( - self, - avm_wrapper: AvmWrapper, - device_friendly_name: str, - description: FritzSensorEntityDescription, - ) -> None: - """Init FRITZ!Box connectivity class.""" - self.entity_description = description - self._last_device_value: str | None = None - self._attr_available = True - self._attr_name = f"{device_friendly_name} {description.name}" - self._attr_unique_id = f"{avm_wrapper.unique_id}-{description.key}" - super().__init__(avm_wrapper, device_friendly_name) - - def update(self) -> None: - """Update data.""" - _LOGGER.debug("Updating FRITZ!Box sensors") - - status: FritzStatus = self._avm_wrapper.fritz_status - try: - self._attr_native_value = ( - self._last_device_value - ) = self.entity_description.value_fn(status, self._last_device_value) - except FritzConnectionException: - _LOGGER.error("Error getting the state from the FRITZ!Box", exc_info=True) - self._attr_available = False - return - self._attr_available = True + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.coordinator.data.get(self.entity_description.key) From 99b58f157ec9e88fe6e97db1772631f9b5da90e9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Mar 2023 14:00:47 -1000 Subject: [PATCH 1163/2810] Bump PyJWT to 2.6.0 (#90134) * Bump PyJWT to 2.6.0 * fix time being frozen too late which makes the access token creation time in the future * revert zha change * fix repairs test * fix ical test --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- tests/components/alexa/test_smart_home.py | 25 +++---- .../bmw_connected_drive/test_diagnostics.py | 8 +- tests/components/ipma/test_weather.py | 4 +- .../local_calendar/test_diagnostics.py | 74 ++++++++++++++++--- tests/components/metoffice/test_init.py | 5 +- tests/components/metoffice/test_sensor.py | 10 ++- tests/components/metoffice/test_weather.py | 18 +++-- tests/components/recorder/test_util.py | 3 +- .../components/recorder/test_websocket_api.py | 12 ++- tests/components/repairs/test_init.py | 8 +- .../components/repairs/test_websocket_api.py | 3 +- tests/components/shelly/test_utils.py | 3 +- tests/components/tod/test_binary_sensor.py | 23 +++--- tests/components/tod/test_config_flow.py | 3 +- tests/components/tod/test_init.py | 5 +- .../unifiprotect/test_media_source.py | 7 +- 19 files changed, 143 insertions(+), 74 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ffef59913e..0fcae3ec80 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,4 +1,4 @@ -PyJWT==2.5.0 +PyJWT==2.6.0 PyNaCl==1.5.0 aiodiscover==1.4.14 aiohttp==3.8.4 diff --git a/pyproject.toml b/pyproject.toml index d8ba8e7475..5d39a99c03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ dependencies = [ "ifaddr==0.1.7", "jinja2==3.1.2", "lru-dict==1.1.8", - "PyJWT==2.5.0", + "PyJWT==2.6.0", # PyJWT has loose dependency. We want the latest one. "cryptography==39.0.1", # pyOpenSSL 23.0.0 is required to work with cryptography 39+ diff --git a/requirements.txt b/requirements.txt index 1b4874e2c4..2386015c84 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ home-assistant-bluetooth==1.9.3 ifaddr==0.1.7 jinja2==3.1.2 lru-dict==1.1.8 -PyJWT==2.5.0 +PyJWT==2.6.0 cryptography==39.0.1 pyOpenSSL==23.0.0 orjson==3.8.7 diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 49c6dc35ff..601f59fd11 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -1,7 +1,6 @@ """Test for smart home alexa support.""" from unittest.mock import patch -from freezegun import freeze_time import pytest from homeassistant.components.alexa import messages, smart_home @@ -158,7 +157,7 @@ def assert_endpoint_capabilities(endpoint, *interfaces): return capabilities -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_switch(hass: HomeAssistant, events: list[Event]) -> None: """Test switch discovery.""" device = ("switch.test", "on", {"friendly_name": "Test switch"}) @@ -212,7 +211,7 @@ async def test_outlet(hass: HomeAssistant, events: list[Event]) -> None: ) -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_light(hass: HomeAssistant) -> None: """Test light discovery.""" device = ("light.test_1", "on", {"friendly_name": "Test light 1"}) @@ -308,7 +307,7 @@ async def test_color_light( # tests -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_script(hass: HomeAssistant) -> None: """Test script discovery.""" device = ("script.test", "off", {"friendly_name": "Test script"}) @@ -329,7 +328,7 @@ async def test_script(hass: HomeAssistant) -> None: ) -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_input_boolean(hass: HomeAssistant) -> None: """Test input boolean discovery.""" device = ("input_boolean.test", "off", {"friendly_name": "Test input boolean"}) @@ -366,7 +365,7 @@ async def test_input_boolean(hass: HomeAssistant) -> None: assert {"name": "detectionState"} in properties["supported"] -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_scene(hass: HomeAssistant) -> None: """Test scene discovery.""" device = ("scene.test", "off", {"friendly_name": "Test scene"}) @@ -387,7 +386,7 @@ async def test_scene(hass: HomeAssistant) -> None: ) -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_fan(hass: HomeAssistant) -> None: """Test fan discovery.""" device = ("fan.test_1", "off", {"friendly_name": "Test fan 1"}) @@ -945,7 +944,7 @@ async def test_single_preset_mode_fan( caplog.clear() -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_humidifier( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -1117,7 +1116,7 @@ async def test_lock(hass: HomeAssistant) -> None: assert properties["value"] == "UNLOCKED" -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_media_player(hass: HomeAssistant) -> None: """Test media player discovery.""" device = ( @@ -1729,7 +1728,7 @@ async def test_media_player_seek_error(hass: HomeAssistant) -> None: assert msg["payload"]["type"] == "ACTION_NOT_PERMITTED_FOR_CONTENT" -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_alert(hass: HomeAssistant) -> None: """Test alert discovery.""" device = ("alert.test", "off", {"friendly_name": "Test alert"}) @@ -1747,7 +1746,7 @@ async def test_alert(hass: HomeAssistant) -> None: ) -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_automation(hass: HomeAssistant) -> None: """Test automation discovery.""" device = ("automation.test", "off", {"friendly_name": "Test automation"}) @@ -1769,7 +1768,7 @@ async def test_automation(hass: HomeAssistant) -> None: ) -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_group(hass: HomeAssistant) -> None: """Test group discovery.""" device = ("group.test", "off", {"friendly_name": "Test group"}) @@ -4183,7 +4182,7 @@ async def test_initialize_camera_stream( ) -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") @pytest.mark.parametrize( "domain", ["button", "input_button"], diff --git a/tests/components/bmw_connected_drive/test_diagnostics.py b/tests/components/bmw_connected_drive/test_diagnostics.py index 5858ae2e52..a186a52bcd 100644 --- a/tests/components/bmw_connected_drive/test_diagnostics.py +++ b/tests/components/bmw_connected_drive/test_diagnostics.py @@ -4,7 +4,7 @@ import json import os import time -from freezegun import freeze_time +import pytest from homeassistant.components.bmw_connected_drive.const import DOMAIN from homeassistant.core import HomeAssistant @@ -20,7 +20,7 @@ from tests.components.diagnostics import ( from tests.typing import ClientSessionGenerator -@freeze_time(datetime.datetime(2022, 7, 10, 11)) +@pytest.mark.freeze_time(datetime.datetime(2022, 7, 10, 11)) async def test_config_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, bmw_fixture ) -> None: @@ -43,7 +43,7 @@ async def test_config_entry_diagnostics( assert diagnostics == diagnostics_fixture -@freeze_time(datetime.datetime(2022, 7, 10, 11)) +@pytest.mark.freeze_time(datetime.datetime(2022, 7, 10, 11)) async def test_device_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, bmw_fixture ) -> None: @@ -72,7 +72,7 @@ async def test_device_diagnostics( assert diagnostics == diagnostics_fixture -@freeze_time(datetime.datetime(2022, 7, 10, 11)) +@pytest.mark.freeze_time(datetime.datetime(2022, 7, 10, 11)) async def test_device_diagnostics_vehicle_not_found( hass: HomeAssistant, hass_client: ClientSessionGenerator, bmw_fixture ) -> None: diff --git a/tests/components/ipma/test_weather.py b/tests/components/ipma/test_weather.py index c5b5a1298f..285f7ceacb 100644 --- a/tests/components/ipma/test_weather.py +++ b/tests/components/ipma/test_weather.py @@ -2,7 +2,7 @@ from datetime import datetime from unittest.mock import patch -from freezegun import freeze_time +import pytest from homeassistant.components.weather import ( ATTR_FORECAST, @@ -100,7 +100,7 @@ async def test_daily_forecast(hass: HomeAssistant) -> None: assert forecast.get(ATTR_FORECAST_WIND_BEARING) == "S" -@freeze_time("2020-01-14 23:00:00") +@pytest.mark.freeze_time("2020-01-14 23:00:00") async def test_hourly_forecast(hass: HomeAssistant) -> None: """Test for successfully getting daily forecast.""" with patch( diff --git a/tests/components/local_calendar/test_diagnostics.py b/tests/components/local_calendar/test_diagnostics.py index 8b033cf4fd..561f7588a5 100644 --- a/tests/components/local_calendar/test_diagnostics.py +++ b/tests/components/local_calendar/test_diagnostics.py @@ -1,19 +1,46 @@ """Tests for diagnostics platform of local calendar.""" +from aiohttp.test_utils import TestClient from freezegun import freeze_time import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.auth.models import Credentials from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .conftest import TEST_ENTITY, ClientFixture +from .conftest import TEST_ENTITY, Client, ClientFixture -from tests.common import MockConfigEntry +from tests.common import CLIENT_ID, MockConfigEntry, MockUser from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator +async def generate_new_hass_access_token( + hass: HomeAssistant, hass_admin_user: MockUser, hass_admin_credential: Credentials +) -> str: + """Return an access token to access Home Assistant.""" + await hass.auth.async_link_user(hass_admin_user, hass_admin_credential) + + refresh_token = await hass.auth.async_create_refresh_token( + hass_admin_user, CLIENT_ID, credential=hass_admin_credential + ) + return hass.auth.async_create_access_token(refresh_token) + + +def _get_test_client_generator( + hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, new_token: str +): + """Return a test client generator."".""" + + async def auth_client() -> TestClient: + return await aiohttp_client( + hass.http.app, headers={"Authorization": f"Bearer {new_token}"} + ) + + return auth_client + + @pytest.fixture(autouse=True) async def setup_diag(hass): """Set up diagnostics platform.""" @@ -24,12 +51,27 @@ async def setup_diag(hass): async def test_empty_calendar( hass: HomeAssistant, setup_integration: None, - hass_client: ClientSessionGenerator, + hass_admin_user: MockUser, + hass_admin_credential: Credentials, config_entry: MockConfigEntry, + aiohttp_client: ClientSessionGenerator, + socket_enabled: None, snapshot: SnapshotAssertion, ) -> None: """Test diagnostics against an empty calendar.""" - data = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + # Since we are freezing time only when we enter this test, we need to + # manually create a new token and clients since the token created by + # the fixtures would not be valid. + # + # Ideally we would use pytest.mark.freeze_time before the fixtures, but that does not + # work with the ical library and freezegun because + # `TypeError: '<' not supported between instances of 'FakeDatetimeMeta' and 'FakeDateMeta'` + new_token = await generate_new_hass_access_token( + hass, hass_admin_user, hass_admin_credential + ) + data = await get_diagnostics_for_config_entry( + hass, _get_test_client_generator(hass, aiohttp_client, new_token), config_entry + ) assert data == snapshot @@ -37,14 +79,26 @@ async def test_empty_calendar( async def test_api_date_time_event( hass: HomeAssistant, setup_integration: None, + hass_admin_user: MockUser, + hass_admin_credential: Credentials, config_entry: MockConfigEntry, - hass_client: ClientSessionGenerator, - ws_client: ClientFixture, + hass_ws_client: ClientFixture, + aiohttp_client: ClientSessionGenerator, + socket_enabled: None, snapshot: SnapshotAssertion, ) -> None: """Test an event with a start/end date time.""" - - client = await ws_client() + # Since we are freezing time only when we enter this test, we need to + # manually create a new token and clients since the token created by + # the fixtures would not be valid. + # + # Ideally we would use pytest.mark.freeze_time before the fixtures, but that does not + # work with the ical library and freezegun because + # `TypeError: '<' not supported between instances of 'FakeDatetimeMeta' and 'FakeDateMeta'` + new_token = await generate_new_hass_access_token( + hass, hass_admin_user, hass_admin_credential + ) + client = Client(await hass_ws_client(hass, access_token=new_token)) await client.cmd_result( "create", { @@ -58,5 +112,7 @@ async def test_api_date_time_event( }, ) - data = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + data = await get_diagnostics_for_config_entry( + hass, _get_test_client_generator(hass, aiohttp_client, new_token), config_entry + ) assert data == snapshot diff --git a/tests/components/metoffice/test_init.py b/tests/components/metoffice/test_init.py index 917c031edb..f21f3a1b26 100644 --- a/tests/components/metoffice/test_init.py +++ b/tests/components/metoffice/test_init.py @@ -3,7 +3,6 @@ from __future__ import annotations import datetime -from freezegun import freeze_time import pytest import requests_mock @@ -16,7 +15,9 @@ from .const import DOMAIN, METOFFICE_CONFIG_WAVERTREE, TEST_COORDINATES_WAVERTRE from tests.common import MockConfigEntry -@freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc)) +@pytest.mark.freeze_time( + datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc) +) @pytest.mark.parametrize( ("old_unique_id", "new_unique_id", "migration_needed"), [ diff --git a/tests/components/metoffice/test_sensor.py b/tests/components/metoffice/test_sensor.py index d2e5d55355..28bf8eda99 100644 --- a/tests/components/metoffice/test_sensor.py +++ b/tests/components/metoffice/test_sensor.py @@ -2,7 +2,7 @@ import datetime import json -from freezegun import freeze_time +import pytest import requests_mock from homeassistant.components.metoffice.const import ATTRIBUTION, DOMAIN @@ -24,7 +24,9 @@ from .const import ( from tests.common import MockConfigEntry, load_fixture -@freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc)) +@pytest.mark.freeze_time( + datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc) +) async def test_one_sensor_site_running( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: @@ -72,7 +74,9 @@ async def test_one_sensor_site_running( assert sensor.attributes.get("attribution") == ATTRIBUTION -@freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc)) +@pytest.mark.freeze_time( + datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc) +) async def test_two_sensor_sites_running( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: diff --git a/tests/components/metoffice/test_weather.py b/tests/components/metoffice/test_weather.py index d386004129..0e5a934c7d 100644 --- a/tests/components/metoffice/test_weather.py +++ b/tests/components/metoffice/test_weather.py @@ -3,7 +3,7 @@ import datetime from datetime import timedelta import json -from freezegun import freeze_time +import pytest import requests_mock from homeassistant.components.metoffice.const import DOMAIN @@ -23,7 +23,9 @@ from .const import ( from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture -@freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc)) +@pytest.mark.freeze_time( + datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc) +) async def test_site_cannot_connect( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: @@ -52,7 +54,9 @@ async def test_site_cannot_connect( assert sensor is None -@freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc)) +@pytest.mark.freeze_time( + datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc) +) async def test_site_cannot_update( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: @@ -100,7 +104,9 @@ async def test_site_cannot_update( assert weather.state == STATE_UNAVAILABLE -@freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc)) +@pytest.mark.freeze_time( + datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc) +) async def test_one_weather_site_running( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: @@ -183,7 +189,9 @@ async def test_one_weather_site_running( assert weather.attributes.get("forecast")[3]["wind_bearing"] == "SE" -@freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc)) +@pytest.mark.freeze_time( + datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc) +) async def test_two_weather_sites_running( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 383a083843..4cc4f4b94a 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -6,7 +6,6 @@ from pathlib import Path import sqlite3 from unittest.mock import MagicMock, Mock, patch -from freezegun import freeze_time import py import pytest from sqlalchemy import text @@ -934,7 +933,7 @@ def test_execute_stmt_lambda_element( assert rows == ["mock_row"] -@freeze_time(datetime(2022, 10, 21, 7, 25, tzinfo=timezone.utc)) +@pytest.mark.freeze_time(datetime(2022, 10, 21, 7, 25, tzinfo=timezone.utc)) async def test_resolve_period(hass: HomeAssistant) -> None: """Test statistic_during_period.""" diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 5244a33f0b..8e760b4010 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -217,7 +217,9 @@ async def test_statistics_during_period( } -@freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.timezone.utc)) +@pytest.mark.freeze_time( + datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.timezone.utc) +) @pytest.mark.parametrize("offset", (0, 1, 2)) async def test_statistic_during_period( recorder_mock: Recorder, @@ -632,7 +634,9 @@ async def test_statistic_during_period( } -@freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.timezone.utc)) +@pytest.mark.freeze_time( + datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.timezone.utc) +) async def test_statistic_during_period_hole( recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -795,7 +799,9 @@ async def test_statistic_during_period_hole( } -@freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.timezone.utc)) +@pytest.mark.freeze_time( + datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.timezone.utc) +) @pytest.mark.parametrize( ("calendar_period", "start_time", "end_time"), ( diff --git a/tests/components/repairs/test_init.py b/tests/components/repairs/test_init.py index bae71e71e2..ce787ad00b 100644 --- a/tests/components/repairs/test_init.py +++ b/tests/components/repairs/test_init.py @@ -1,7 +1,6 @@ """Test the repairs websocket API.""" from unittest.mock import AsyncMock, Mock -from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import pytest @@ -27,7 +26,7 @@ from tests.common import mock_platform from tests.typing import WebSocketGenerator -@freeze_time("2022-07-19 07:53:05") +@pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_create_update_issue( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -166,7 +165,7 @@ async def test_create_issue_invalid_version( assert msg["result"] == {"issues": []} -@freeze_time("2022-07-19 07:53:05") +@pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_ignore_issue( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -335,6 +334,7 @@ async def test_ignore_issue( } +@pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_delete_issue( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -487,7 +487,7 @@ async def test_non_compliant_platform( assert list(hass.data[DOMAIN]["platforms"].keys()) == ["fake_integration"] -@freeze_time("2022-07-21 08:22:00") +@pytest.mark.freeze_time("2022-07-21 08:22:00") async def test_sync_methods( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, diff --git a/tests/components/repairs/test_websocket_api.py b/tests/components/repairs/test_websocket_api.py index 4db5b6a9d1..c82337b484 100644 --- a/tests/components/repairs/test_websocket_api.py +++ b/tests/components/repairs/test_websocket_api.py @@ -5,7 +5,6 @@ from http import HTTPStatus from typing import Any from unittest.mock import ANY, AsyncMock, Mock -from freezegun import freeze_time import pytest import voluptuous as vol @@ -430,7 +429,7 @@ async def test_step_unauth( assert resp.status == HTTPStatus.UNAUTHORIZED -@freeze_time("2022-07-19 07:53:05") +@pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_list_issues( hass: HomeAssistant, hass_storage: dict[str, Any], hass_ws_client ) -> None: diff --git a/tests/components/shelly/test_utils.py b/tests/components/shelly/test_utils.py index b2be776098..701e8b487c 100644 --- a/tests/components/shelly/test_utils.py +++ b/tests/components/shelly/test_utils.py @@ -1,5 +1,4 @@ """Tests for Shelly utils.""" -from freezegun import freeze_time import pytest from homeassistant.components.shelly.utils import ( @@ -150,7 +149,7 @@ async def test_get_block_device_sleep_period(settings, sleep_period) -> None: assert get_block_device_sleep_period(settings) == sleep_period -@freeze_time("2019-01-10 18:43:00+00:00") +@pytest.mark.freeze_time("2019-01-10 18:43:00+00:00") async def test_get_device_uptime() -> None: """Test block test get device uptime.""" assert get_device_uptime( diff --git a/tests/components/tod/test_binary_sensor.py b/tests/components/tod/test_binary_sensor.py index 0f0a145645..c1823c23f8 100644 --- a/tests/components/tod/test_binary_sensor.py +++ b/tests/components/tod/test_binary_sensor.py @@ -1,7 +1,6 @@ """Test Times of the Day Binary Sensor.""" from datetime import datetime, timedelta -from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import pytest @@ -67,7 +66,7 @@ async def test_setup_no_sensors(hass: HomeAssistant) -> None: ) -@freeze_time("2019-01-10 18:43:00-08:00") +@pytest.mark.freeze_time("2019-01-10 18:43:00-08:00") async def test_in_period_on_start(hass: HomeAssistant) -> None: """Test simple setting.""" config = { @@ -87,7 +86,7 @@ async def test_in_period_on_start(hass: HomeAssistant) -> None: assert state.state == STATE_ON -@freeze_time("2019-01-10 22:30:00-08:00") +@pytest.mark.freeze_time("2019-01-10 22:30:00-08:00") async def test_midnight_turnover_before_midnight_inside_period( hass: HomeAssistant, ) -> None: @@ -131,7 +130,7 @@ async def test_midnight_turnover_after_midnight_inside_period( assert state.state == STATE_ON -@freeze_time("2019-01-10 20:30:00-08:00") +@pytest.mark.freeze_time("2019-01-10 20:30:00-08:00") async def test_midnight_turnover_before_midnight_outside_period( hass: HomeAssistant, ) -> None: @@ -148,7 +147,7 @@ async def test_midnight_turnover_before_midnight_outside_period( assert state.state == STATE_OFF -@freeze_time("2019-01-10 10:00:00-08:00") +@pytest.mark.freeze_time("2019-01-10 10:00:00-08:00") async def test_after_happens_tomorrow(hass: HomeAssistant) -> None: """Test when both before and after are in the future, and after is later than before.""" config = { @@ -643,7 +642,7 @@ async def test_dst( assert state.state == STATE_OFF -@freeze_time("2019-01-10 18:43:00") +@pytest.mark.freeze_time("2019-01-10 18:43:00") @pytest.mark.parametrize("hass_time_zone", ("UTC",)) async def test_simple_before_after_does_not_loop_utc_not_in_range( hass: HomeAssistant, @@ -669,7 +668,7 @@ async def test_simple_before_after_does_not_loop_utc_not_in_range( assert state.attributes["next_update"] == "2019-01-10T22:00:00+00:00" -@freeze_time("2019-01-10 22:43:00") +@pytest.mark.freeze_time("2019-01-10 22:43:00") @pytest.mark.parametrize("hass_time_zone", ("UTC",)) async def test_simple_before_after_does_not_loop_utc_in_range( hass: HomeAssistant, @@ -695,7 +694,7 @@ async def test_simple_before_after_does_not_loop_utc_in_range( assert state.attributes["next_update"] == "2019-01-11T06:00:00+00:00" -@freeze_time("2019-01-11 06:00:00") +@pytest.mark.freeze_time("2019-01-11 06:00:00") @pytest.mark.parametrize("hass_time_zone", ("UTC",)) async def test_simple_before_after_does_not_loop_utc_fire_at_before( hass: HomeAssistant, @@ -721,7 +720,7 @@ async def test_simple_before_after_does_not_loop_utc_fire_at_before( assert state.attributes["next_update"] == "2019-01-11T22:00:00+00:00" -@freeze_time("2019-01-10 22:00:00") +@pytest.mark.freeze_time("2019-01-10 22:00:00") @pytest.mark.parametrize("hass_time_zone", ("UTC",)) async def test_simple_before_after_does_not_loop_utc_fire_at_after( hass: HomeAssistant, @@ -747,7 +746,7 @@ async def test_simple_before_after_does_not_loop_utc_fire_at_after( assert state.attributes["next_update"] == "2019-01-11T06:00:00+00:00" -@freeze_time("2019-01-10 22:00:00") +@pytest.mark.freeze_time("2019-01-10 22:00:00") @pytest.mark.parametrize("hass_time_zone", ("UTC",)) async def test_simple_before_after_does_not_loop_utc_both_before_now( hass: HomeAssistant, @@ -773,7 +772,7 @@ async def test_simple_before_after_does_not_loop_utc_both_before_now( assert state.attributes["next_update"] == "2019-01-11T00:00:00+00:00" -@freeze_time("2019-01-10 17:43:00+01:00") +@pytest.mark.freeze_time("2019-01-10 17:43:00+01:00") @pytest.mark.parametrize("hass_time_zone", ("Europe/Berlin",)) async def test_simple_before_after_does_not_loop_berlin_not_in_range( hass: HomeAssistant, @@ -799,7 +798,7 @@ async def test_simple_before_after_does_not_loop_berlin_not_in_range( assert state.attributes["next_update"] == "2019-01-11T00:00:00+01:00" -@freeze_time("2019-01-11 00:43:00+01:00") +@pytest.mark.freeze_time("2019-01-11 00:43:00+01:00") @pytest.mark.parametrize("hass_time_zone", ("Europe/Berlin",)) async def test_simple_before_after_does_not_loop_berlin_in_range( hass: HomeAssistant, diff --git a/tests/components/tod/test_config_flow.py b/tests/components/tod/test_config_flow.py index 4d0e2a0619..6860d401ce 100644 --- a/tests/components/tod/test_config_flow.py +++ b/tests/components/tod/test_config_flow.py @@ -1,7 +1,6 @@ """Test the Times of the Day config flow.""" from unittest.mock import patch -from freezegun import freeze_time import pytest from homeassistant import config_entries @@ -66,7 +65,7 @@ def get_suggested(schema, key): raise Exception -@freeze_time("2022-03-16 17:37:00", tz_offset=-7) +@pytest.mark.freeze_time("2022-03-16 17:37:00", tz_offset=-7) async def test_options(hass: HomeAssistant) -> None: """Test reconfiguring.""" # Setup the config entry diff --git a/tests/components/tod/test_init.py b/tests/components/tod/test_init.py index 510bf848ad..4a9f55bdec 100644 --- a/tests/components/tod/test_init.py +++ b/tests/components/tod/test_init.py @@ -1,5 +1,6 @@ """Test the Times of the Day integration.""" -from freezegun import freeze_time + +import pytest from homeassistant.components.tod.const import DOMAIN from homeassistant.core import HomeAssistant @@ -8,7 +9,7 @@ from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry -@freeze_time("2022-03-16 17:37:00", tz_offset=-7) +@pytest.mark.freeze_time("2022-03-16 17:37:00", tz_offset=-7) async def test_setup_and_remove_config_entry(hass: HomeAssistant) -> None: """Test setting up and removing a config entry.""" registry = er.async_get(hass) diff --git a/tests/components/unifiprotect/test_media_source.py b/tests/components/unifiprotect/test_media_source.py index 1df0fbb168..e19985aea3 100644 --- a/tests/components/unifiprotect/test_media_source.py +++ b/tests/components/unifiprotect/test_media_source.py @@ -4,7 +4,6 @@ from datetime import datetime, timedelta from ipaddress import IPv4Address from unittest.mock import AsyncMock, Mock, patch -from freezegun import freeze_time import pytest import pytz from pyunifiprotect.data import ( @@ -465,7 +464,7 @@ TWO_MONTH_SIMPLE = ( ("start", "months"), [ONE_MONTH_SIMPLE, TWO_MONTH_SIMPLE], ) -@freeze_time("2022-09-15 03:00:00-07:00") +@pytest.mark.freeze_time("2022-09-15 03:00:00-07:00") async def test_browse_media_time( hass: HomeAssistant, ufp: MockUFPFixture, @@ -537,7 +536,7 @@ TWO_MONTH_TIMEZONE = ( ("start", "months"), [ONE_MONTH_TIMEZONE, TWO_MONTH_TIMEZONE], ) -@freeze_time("2022-08-31 21:00:00-07:00") +@pytest.mark.freeze_time("2022-08-31 21:00:00-07:00") async def test_browse_media_time_timezone( hass: HomeAssistant, ufp: MockUFPFixture, @@ -713,7 +712,7 @@ async def test_browse_media_eventthumb( assert browse.media_class == MediaClass.IMAGE -@freeze_time("2022-09-15 03:00:00-07:00") +@pytest.mark.freeze_time("2022-09-15 03:00:00-07:00") async def test_browse_media_day( hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ) -> None: From 31c988c4f0dac38c90ae2dcfe7c40dfbafd8d40e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Mar 2023 15:03:26 -1000 Subject: [PATCH 1164/2810] Fix index not being dropped on postgresql databases with a schema prefix (#90144) * Fix index not being dropped on postgresql databases with a schema prefix Added logging in case index drops fail so we can tell why in the future * coverage --- .../components/recorder/migration.py | 108 ++++++++---------- tests/components/recorder/test_migrate.py | 47 +++++++- 2 files changed, 92 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 927097b18f..931422b64f 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -297,6 +297,19 @@ def _create_index( _LOGGER.debug("Finished creating %s", index_name) +def _execute_or_collect_error( + session_maker: Callable[[], Session], query: str, errors: list[str] +) -> bool: + """Execute a query or collect an error.""" + with session_scope(session=session_maker()) as session: + try: + session.connection().execute(text(query)) + return True + except SQLAlchemyError as err: + errors.append(str(err)) + return False + + def _drop_index( session_maker: Callable[[], Session], table_name: str, @@ -322,74 +335,45 @@ def _drop_index( index_name, table_name, ) - success = False + index_to_drop: str | None = None + with session_scope(session=session_maker()) as session: + index_to_drop = get_index_by_name(session, table_name, index_name) - # Engines like DB2/Oracle - with session_scope(session=session_maker()) as session, contextlib.suppress( - SQLAlchemyError - ): - connection = session.connection() - connection.execute(text(f"DROP INDEX {index_name}")) - success = True - - # Engines like SQLite, SQL Server - if not success: - with session_scope(session=session_maker()) as session, contextlib.suppress( - SQLAlchemyError - ): - connection = session.connection() - connection.execute( - text( - "DROP INDEX {table}.{index}".format( - index=index_name, table=table_name - ) - ) - ) - success = True - - if not success: - # Engines like MySQL, MS Access - with session_scope(session=session_maker()) as session, contextlib.suppress( - SQLAlchemyError - ): - connection = session.connection() - connection.execute( - text( - "DROP INDEX {index} ON {table}".format( - index=index_name, table=table_name - ) - ) - ) - success = True - - if not success: - # Engines like postgresql may have a prefix - # ex idx_16532_ix_events_event_type_time_fired - with session_scope(session=session_maker()) as session, contextlib.suppress( - SQLAlchemyError - ): - if index_to_drop := get_index_by_name(session, table_name, index_name): - connection.execute(text(f"DROP INDEX {index_to_drop}")) - success = True - - if success: + if index_to_drop is None: _LOGGER.debug( - "Finished dropping index %s from table %s", index_name, table_name + "The index %s on table %s no longer exists", index_name, table_name ) return - if quiet: - return + errors: list[str] = [] + for query in ( + # Engines like DB2/Oracle + f"DROP INDEX {index_name}", + # Engines like SQLite, SQL Server + f"DROP INDEX {table_name}.{index_name}", + # Engines like MySQL, MS Access + f"DROP INDEX {index_name} ON {table_name}", + # Engines like postgresql may have a prefix + # ex idx_16532_ix_events_event_type_time_fired + f"DROP INDEX {index_to_drop}", + ): + if _execute_or_collect_error(session_maker, query, errors): + _LOGGER.debug( + "Finished dropping index %s from table %s", index_name, table_name + ) + return - _LOGGER.warning( - ( - "Failed to drop index `%s` from table `%s`. Schema " - "Migration will continue; this is not a " - "critical operation" - ), - index_name, - table_name, - ) + if not quiet: + _LOGGER.warning( + ( + "Failed to drop index `%s` from table `%s`. Schema " + "Migration will continue; this is not a " + "critical operation: %s" + ), + index_name, + table_name, + errors, + ) def _add_columns( diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index b23b7a2dfc..fe4f1e016f 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -15,6 +15,7 @@ from sqlalchemy.exc import ( InternalError, OperationalError, ProgrammingError, + SQLAlchemyError, ) from sqlalchemy.orm import Session from sqlalchemy.pool import StaticPool @@ -492,7 +493,51 @@ def test_forgiving_add_index(recorder_db_url: str) -> None: with Session(engine) as session: instance = Mock() instance.get_session = Mock(return_value=session) - migration._create_index(instance.get_session, "states", "ix_states_context_id") + migration._create_index( + instance.get_session, "states", "ix_states_context_id_bin" + ) + engine.dispose() + + +def test_forgiving_drop_index( + recorder_db_url: str, caplog: pytest.LogCaptureFixture +) -> None: + """Test that drop index will continue if index drop fails.""" + engine = create_engine(recorder_db_url, poolclass=StaticPool) + db_schema.Base.metadata.create_all(engine) + with Session(engine) as session: + instance = Mock() + instance.get_session = Mock(return_value=session) + migration._drop_index( + instance.get_session, "states", "ix_states_context_id_bin" + ) + migration._drop_index( + instance.get_session, "states", "ix_states_context_id_bin" + ) + + with patch( + "homeassistant.components.recorder.migration.get_index_by_name", + return_value="ix_states_context_id_bin", + ), patch.object( + session, "connection", side_effect=SQLAlchemyError("connection failure") + ): + migration._drop_index( + instance.get_session, "states", "ix_states_context_id_bin" + ) + assert "Failed to drop index" in caplog.text + assert "connection failure" in caplog.text + caplog.clear() + with patch( + "homeassistant.components.recorder.migration.get_index_by_name", + return_value="ix_states_context_id_bin", + ), patch.object( + session, "connection", side_effect=SQLAlchemyError("connection failure") + ): + migration._drop_index( + instance.get_session, "states", "ix_states_context_id_bin", quiet=True + ) + assert "Failed to drop index" not in caplog.text + assert "connection failure" not in caplog.text engine.dispose() From 8a591fa16e9fba77dfbeba1f52299e32b5ac06a6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Mar 2023 15:17:36 -1000 Subject: [PATCH 1165/2810] Add auto repairs for events schema (#90136) * Add auto repairs for events schema * Add auto repairs for events schema * Add auto repairs for events schema * Add auto repairs for events schema * Add auto repairs for events schema * fix bug - wrong table --- .../recorder/auto_repairs/events/__init__.py | 1 + .../recorder/auto_repairs/events/schema.py | 31 ++++++++ .../components/recorder/migration.py | 6 ++ .../recorder/auto_repairs/events/__init__.py | 1 + .../auto_repairs/events/test_schema.py | 76 +++++++++++++++++++ .../recorder/auto_repairs/states/__init__.py | 4 - .../auto_repairs/statistics/__init__.py | 4 - 7 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/recorder/auto_repairs/events/__init__.py create mode 100644 homeassistant/components/recorder/auto_repairs/events/schema.py create mode 100644 tests/components/recorder/auto_repairs/events/__init__.py create mode 100644 tests/components/recorder/auto_repairs/events/test_schema.py diff --git a/homeassistant/components/recorder/auto_repairs/events/__init__.py b/homeassistant/components/recorder/auto_repairs/events/__init__.py new file mode 100644 index 0000000000..66ae1a1407 --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/events/__init__.py @@ -0,0 +1 @@ +"""events repairs for Recorder.""" diff --git a/homeassistant/components/recorder/auto_repairs/events/schema.py b/homeassistant/components/recorder/auto_repairs/events/schema.py new file mode 100644 index 0000000000..e32cbd4df7 --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/events/schema.py @@ -0,0 +1,31 @@ +"""Events schema repairs.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ...db_schema import EventData, Events +from ..schema import ( + correct_db_schema_precision, + correct_db_schema_utf8, + validate_db_schema_precision, + validate_table_schema_supports_utf8, +) + +if TYPE_CHECKING: + from ... import Recorder + + +def validate_db_schema(instance: Recorder) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + return validate_table_schema_supports_utf8( + instance, EventData, (EventData.shared_data,) + ) | validate_db_schema_precision(instance, Events) + + +def correct_db_schema( + instance: Recorder, + schema_errors: set[str], +) -> None: + """Correct issues detected by validate_db_schema.""" + correct_db_schema_utf8(instance, EventData, schema_errors) + correct_db_schema_precision(instance, Events, schema_errors) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 931422b64f..0eee065a0c 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -28,6 +28,10 @@ from homeassistant.core import HomeAssistant from homeassistant.util.enum import try_parse_enum from homeassistant.util.ulid import ulid_to_bytes +from .auto_repairs.events.schema import ( + correct_db_schema as events_correct_db_schema, + validate_db_schema as events_validate_db_schema, +) from .auto_repairs.states.schema import ( correct_db_schema as states_correct_db_schema, validate_db_schema as states_validate_db_schema, @@ -199,6 +203,7 @@ def _find_schema_errors( schema_errors: set[str] = set() schema_errors |= statistics_validate_db_schema(instance) schema_errors |= states_validate_db_schema(instance) + schema_errors |= events_validate_db_schema(instance) return schema_errors @@ -250,6 +255,7 @@ def migrate_schema( ) statistics_correct_db_schema(instance, schema_errors) states_correct_db_schema(instance, schema_errors) + events_correct_db_schema(instance, schema_errors) if current_version != SCHEMA_VERSION: instance.queue_task(PostSchemaMigrationTask(current_version, SCHEMA_VERSION)) diff --git a/tests/components/recorder/auto_repairs/events/__init__.py b/tests/components/recorder/auto_repairs/events/__init__.py new file mode 100644 index 0000000000..fca6a655ba --- /dev/null +++ b/tests/components/recorder/auto_repairs/events/__init__.py @@ -0,0 +1 @@ +"""Tests for Recorder component.""" diff --git a/tests/components/recorder/auto_repairs/events/test_schema.py b/tests/components/recorder/auto_repairs/events/test_schema.py new file mode 100644 index 0000000000..b19ff4ca50 --- /dev/null +++ b/tests/components/recorder/auto_repairs/events/test_schema.py @@ -0,0 +1,76 @@ +"""The test repairing events schema.""" + +# pylint: disable=invalid-name +from unittest.mock import ANY, patch + +import pytest + +from homeassistant.core import HomeAssistant + +from ...common import async_wait_recording_done + +from tests.typing import RecorderInstanceGenerator + + +@pytest.mark.parametrize("enable_schema_validation", [True]) +@pytest.mark.parametrize("db_engine", ("mysql", "postgresql")) +async def test_validate_db_schema_fix_float_issue( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + db_engine, +) -> None: + """Test validating DB schema with postgresql and mysql. + + Note: The test uses SQLite, the purpose is only to exercise the code. + """ + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine + ), patch( + "homeassistant.components.recorder.auto_repairs.schema._validate_db_schema_precision", + return_value={"events.double precision"}, + ), patch( + "homeassistant.components.recorder.migration._modify_columns" + ) as modify_columns_mock: + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + assert "Schema validation failed" not in caplog.text + assert ( + "Database is about to correct DB schema errors: events.double precision" + in caplog.text + ) + modification = [ + "time_fired_ts DOUBLE PRECISION", + ] + modify_columns_mock.assert_called_once_with(ANY, ANY, "events", modification) + + +@pytest.mark.parametrize("enable_schema_validation", [True]) +async def test_validate_db_schema_fix_utf8_issue_event_data( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema with MySQL. + + Note: The test uses SQLite, the purpose is only to exercise the code. + """ + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", "mysql" + ), patch( + "homeassistant.components.recorder.auto_repairs.schema._validate_table_schema_supports_utf8", + return_value={"event_data.4-byte UTF-8"}, + ): + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + assert "Schema validation failed" not in caplog.text + assert ( + "Database is about to correct DB schema errors: event_data.4-byte UTF-8" + in caplog.text + ) + assert ( + "Updating character set and collation of table event_data to utf8mb4" + in caplog.text + ) diff --git a/tests/components/recorder/auto_repairs/states/__init__.py b/tests/components/recorder/auto_repairs/states/__init__.py index 6e98d881ea..fca6a655ba 100644 --- a/tests/components/recorder/auto_repairs/states/__init__.py +++ b/tests/components/recorder/auto_repairs/states/__init__.py @@ -1,5 +1 @@ """Tests for Recorder component.""" - -import pytest - -pytest.register_assert_rewrite("tests.components.recorder.common") diff --git a/tests/components/recorder/auto_repairs/statistics/__init__.py b/tests/components/recorder/auto_repairs/statistics/__init__.py index 6e98d881ea..fca6a655ba 100644 --- a/tests/components/recorder/auto_repairs/statistics/__init__.py +++ b/tests/components/recorder/auto_repairs/statistics/__init__.py @@ -1,5 +1 @@ """Tests for Recorder component.""" - -import pytest - -pytest.register_assert_rewrite("tests.components.recorder.common") From ca576d45acf44530c1fe932518132f9650ad12ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Mar 2023 16:03:41 -1000 Subject: [PATCH 1166/2810] Cache decode of JWT tokens (#90013) --- homeassistant/auth/__init__.py | 10 +- homeassistant/auth/jwt_wrapper.py | 116 +++++++++++++++++ homeassistant/components/http/auth.py | 3 +- tests/auth/test_init.py | 173 ++++++++++++++++++++++++++ tests/auth/test_jwt_wrapper.py | 12 ++ 5 files changed, 308 insertions(+), 6 deletions(-) create mode 100644 homeassistant/auth/jwt_wrapper.py create mode 100644 tests/auth/test_jwt_wrapper.py diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 5c401570de..9a53717427 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -14,7 +14,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.util import dt as dt_util -from . import auth_store, models +from . import auth_store, jwt_wrapper, models from .const import ACCESS_TOKEN_EXPIRATION, GROUP_ID_ADMIN from .mfa_modules import MultiFactorAuthModule, auth_mfa_module_from_config from .providers import AuthProvider, LoginFlow, auth_provider_from_config @@ -555,9 +555,7 @@ class AuthManager: ) -> models.RefreshToken | None: """Return refresh token if an access token is valid.""" try: - unverif_claims = jwt.decode( - token, algorithms=["HS256"], options={"verify_signature": False} - ) + unverif_claims = jwt_wrapper.unverified_hs256_token_decode(token) except jwt.InvalidTokenError: return None @@ -573,7 +571,9 @@ class AuthManager: issuer = refresh_token.id try: - jwt.decode(token, jwt_key, leeway=10, issuer=issuer, algorithms=["HS256"]) + jwt_wrapper.verify_and_decode( + token, jwt_key, leeway=10, issuer=issuer, algorithms=["HS256"] + ) except jwt.InvalidTokenError: return None diff --git a/homeassistant/auth/jwt_wrapper.py b/homeassistant/auth/jwt_wrapper.py new file mode 100644 index 0000000000..546e4afdcf --- /dev/null +++ b/homeassistant/auth/jwt_wrapper.py @@ -0,0 +1,116 @@ +"""Provide a wrapper around JWT that caches decoding tokens. + +Since we decode the same tokens over and over again +we can cache the result of the decode of valid tokens +to speed up the process. +""" +from __future__ import annotations + +from datetime import timedelta +from functools import lru_cache, partial +from typing import Any + +from jwt import DecodeError, PyJWS, PyJWT + +from homeassistant.util.json import json_loads + +JWT_TOKEN_CACHE_SIZE = 16 +MAX_TOKEN_SIZE = 8192 + +_VERIFY_KEYS = ("signature", "exp", "nbf", "iat", "aud", "iss") + +_VERIFY_OPTIONS: dict[str, Any] = {f"verify_{key}": True for key in _VERIFY_KEYS} | { + "require": [] +} +_NO_VERIFY_OPTIONS = {f"verify_{key}": False for key in _VERIFY_KEYS} + + +class _PyJWSWithLoadCache(PyJWS): + """PyJWS with a dedicated load implementation.""" + + @lru_cache(maxsize=JWT_TOKEN_CACHE_SIZE) + # We only ever have a global instance of this class + # so we do not have to worry about the LRU growing + # each time we create a new instance. + def _load(self, jwt: str | bytes) -> tuple[bytes, bytes, dict, bytes]: + """Load a JWS.""" + return super()._load(jwt) + + +_jws = _PyJWSWithLoadCache() + + +@lru_cache(maxsize=JWT_TOKEN_CACHE_SIZE) +def _decode_payload(json_payload: str) -> dict[str, Any]: + """Decode the payload from a JWS dictionary.""" + try: + payload = json_loads(json_payload) + except ValueError as err: + raise DecodeError(f"Invalid payload string: {err}") from err + if not isinstance(payload, dict): + raise DecodeError("Invalid payload string: must be a json object") + return payload + + +class _PyJWTWithVerify(PyJWT): + """PyJWT with a fast decode implementation.""" + + def decode_payload( + self, jwt: str, key: str, options: dict[str, Any], algorithms: list[str] + ) -> dict[str, Any]: + """Decode a JWT's payload.""" + if len(jwt) > MAX_TOKEN_SIZE: + # Avoid caching impossible tokens + raise DecodeError("Token too large") + return _decode_payload( + _jws.decode_complete( + jwt=jwt, + key=key, + algorithms=algorithms, + options=options, + )["payload"] + ) + + def verify_and_decode( + self, + jwt: str, + key: str, + algorithms: list[str], + issuer: str | None = None, + leeway: int | float | timedelta = 0, + options: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Verify a JWT's signature and claims.""" + merged_options = {**_VERIFY_OPTIONS, **(options or {})} + payload = self.decode_payload( + jwt=jwt, + key=key, + options=merged_options, + algorithms=algorithms, + ) + # These should never be missing since we verify them + # but this is an additional safeguard to make sure + # nothing slips through. + assert "exp" in payload, "exp claim is required" + assert "iat" in payload, "iat claim is required" + self._validate_claims( # type: ignore[no-untyped-call] + payload=payload, + options=merged_options, + issuer=issuer, + leeway=leeway, + ) + return payload + + +_jwt = _PyJWTWithVerify() # type: ignore[no-untyped-call] +verify_and_decode = _jwt.verify_and_decode +unverified_hs256_token_decode = lru_cache(maxsize=JWT_TOKEN_CACHE_SIZE)( + partial( + _jwt.decode_payload, key="", algorithms=["HS256"], options=_NO_VERIFY_OPTIONS + ) +) + +__all__ = [ + "unverified_hs256_token_decode", + "verify_and_decode", +] diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 5213cd1b07..ec8c7de989 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -13,6 +13,7 @@ from aiohttp.web import Application, Request, StreamResponse, middleware import jwt from yarl import URL +from homeassistant.auth import jwt_wrapper from homeassistant.auth.const import GROUP_ID_READ_ONLY from homeassistant.auth.models import User from homeassistant.components import websocket_api @@ -175,7 +176,7 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None: return False try: - claims = jwt.decode( + claims = jwt_wrapper.verify_and_decode( signature, secret, algorithms=["HS256"], options={"verify_iss": False} ) except jwt.InvalidTokenError: diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index 3d3674e137..83c08dd73e 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -3,6 +3,7 @@ from datetime import timedelta from typing import Any from unittest.mock import Mock, patch +from freezegun import freeze_time import jwt import pytest import voluptuous as vol @@ -1127,3 +1128,175 @@ async def test_event_user_updated_fires(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(events) == 1 + + +async def test_access_token_with_invalid_signature(mock_hass) -> None: + """Test rejecting access tokens with an invalid signature.""" + manager = await auth.auth_manager_from_config(mock_hass, [], []) + user = MockUser().add_to_auth_manager(manager) + refresh_token = await manager.async_create_refresh_token( + user, + client_name="Good Client", + token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + access_token_expiration=timedelta(days=3000), + ) + assert refresh_token.token_type == auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN + access_token = manager.async_create_access_token(refresh_token) + + rt = await manager.async_validate_access_token(access_token) + assert rt.id == refresh_token.id + + # Now we corrupt the signature + header, payload, signature = access_token.split(".") + invalid_signature = "a" * len(signature) + invalid_token = f"{header}.{payload}.{invalid_signature}" + + assert access_token != invalid_token + + result = await manager.async_validate_access_token(invalid_token) + assert result is None + + +async def test_access_token_with_null_signature(mock_hass) -> None: + """Test rejecting access tokens with a null signature.""" + manager = await auth.auth_manager_from_config(mock_hass, [], []) + user = MockUser().add_to_auth_manager(manager) + refresh_token = await manager.async_create_refresh_token( + user, + client_name="Good Client", + token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + access_token_expiration=timedelta(days=3000), + ) + assert refresh_token.token_type == auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN + access_token = manager.async_create_access_token(refresh_token) + + rt = await manager.async_validate_access_token(access_token) + assert rt.id == refresh_token.id + + # Now we make the signature all nulls + header, payload, signature = access_token.split(".") + invalid_signature = "\0" * len(signature) + invalid_token = f"{header}.{payload}.{invalid_signature}" + + assert access_token != invalid_token + + result = await manager.async_validate_access_token(invalid_token) + assert result is None + + +async def test_access_token_with_empty_signature(mock_hass) -> None: + """Test rejecting access tokens with an empty signature.""" + manager = await auth.auth_manager_from_config(mock_hass, [], []) + user = MockUser().add_to_auth_manager(manager) + refresh_token = await manager.async_create_refresh_token( + user, + client_name="Good Client", + token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + access_token_expiration=timedelta(days=3000), + ) + assert refresh_token.token_type == auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN + access_token = manager.async_create_access_token(refresh_token) + + rt = await manager.async_validate_access_token(access_token) + assert rt.id == refresh_token.id + + # Now we make the signature all nulls + header, payload, _ = access_token.split(".") + invalid_token = f"{header}.{payload}." + + assert access_token != invalid_token + + result = await manager.async_validate_access_token(invalid_token) + assert result is None + + +async def test_access_token_with_empty_key(mock_hass) -> None: + """Test rejecting access tokens with an empty key.""" + manager = await auth.auth_manager_from_config(mock_hass, [], []) + user = MockUser().add_to_auth_manager(manager) + refresh_token = await manager.async_create_refresh_token( + user, + client_name="Good Client", + token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + access_token_expiration=timedelta(days=3000), + ) + assert refresh_token.token_type == auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN + + access_token = manager.async_create_access_token(refresh_token) + + await manager.async_remove_refresh_token(refresh_token) + # Now remove the token from the keyring + # so we will get an empty key + + assert await manager.async_validate_access_token(access_token) is None + + +async def test_reject_access_token_with_impossible_large_size(mock_hass) -> None: + """Test rejecting access tokens with impossible sizes.""" + manager = await auth.auth_manager_from_config(mock_hass, [], []) + assert await manager.async_validate_access_token("a" * 10000) is None + + +async def test_reject_token_with_invalid_json_payload(mock_hass) -> None: + """Test rejecting access tokens with invalid json payload.""" + jws = jwt.PyJWS() + token_with_invalid_json = jws.encode( + b"invalid", b"invalid", "HS256", {"alg": "HS256", "typ": "JWT"} + ) + manager = await auth.auth_manager_from_config(mock_hass, [], []) + assert await manager.async_validate_access_token(token_with_invalid_json) is None + + +async def test_reject_token_with_not_dict_json_payload(mock_hass) -> None: + """Test rejecting access tokens with not a dict json payload.""" + jws = jwt.PyJWS() + token_not_a_dict_json = jws.encode( + b'["invalid"]', b"invalid", "HS256", {"alg": "HS256", "typ": "JWT"} + ) + manager = await auth.auth_manager_from_config(mock_hass, [], []) + assert await manager.async_validate_access_token(token_not_a_dict_json) is None + + +async def test_access_token_that_expires_soon(mock_hass) -> None: + """Test access token from refresh token that expires very soon.""" + now = dt_util.utcnow() + manager = await auth.auth_manager_from_config(mock_hass, [], []) + user = MockUser().add_to_auth_manager(manager) + refresh_token = await manager.async_create_refresh_token( + user, + client_name="Token that expires very soon", + token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + access_token_expiration=timedelta(seconds=1), + ) + assert refresh_token.token_type == auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN + access_token = manager.async_create_access_token(refresh_token) + + rt = await manager.async_validate_access_token(access_token) + assert rt.id == refresh_token.id + + with freeze_time(now + timedelta(minutes=1)): + assert await manager.async_validate_access_token(access_token) is None + + +async def test_access_token_from_the_future(mock_hass) -> None: + """Test we reject an access token from the future.""" + now = dt_util.utcnow() + manager = await auth.auth_manager_from_config(mock_hass, [], []) + user = MockUser().add_to_auth_manager(manager) + with freeze_time(now + timedelta(days=365)): + refresh_token = await manager.async_create_refresh_token( + user, + client_name="Token that expires very soon", + token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + access_token_expiration=timedelta(days=10), + ) + assert ( + refresh_token.token_type == auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN + ) + access_token = manager.async_create_access_token(refresh_token) + + assert await manager.async_validate_access_token(access_token) is None + + with freeze_time(now + timedelta(days=365)): + rt = await manager.async_validate_access_token(access_token) + assert rt.id == refresh_token.id diff --git a/tests/auth/test_jwt_wrapper.py b/tests/auth/test_jwt_wrapper.py new file mode 100644 index 0000000000..297d4dd5d7 --- /dev/null +++ b/tests/auth/test_jwt_wrapper.py @@ -0,0 +1,12 @@ +"""Tests for the Home Assistant auth jwt_wrapper module.""" + +import jwt +import pytest + +from homeassistant.auth import jwt_wrapper + + +async def test_reject_access_token_with_impossible_large_size() -> None: + """Test rejecting access tokens with impossible sizes.""" + with pytest.raises(jwt.DecodeError): + jwt_wrapper.unverified_hs256_token_decode("a" * 10000) From dcc52bd366b9a1f136f05ef22cd840443ff50335 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Mar 2023 16:10:47 -1000 Subject: [PATCH 1167/2810] Bump PySwitchbot to 0.37.4 (#90146) fixes #90090 fixes #89061 changelog: https://github.com/Danielhiversen/pySwitchbot/compare/0.37.3...0.37.4 --- homeassistant/components/switchbot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 2637f578b8..ada24bcee5 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -40,5 +40,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.37.3"] + "requirements": ["PySwitchbot==0.37.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 42355c1073..23ae790f36 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -40,7 +40,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.37.3 +PySwitchbot==0.37.4 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f9c4ae8876..dfd6517954 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -36,7 +36,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.37.3 +PySwitchbot==0.37.4 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 From 12352b2ce1ca27eb3ecbb54afa2d64e0c10d8d44 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 22 Mar 2023 22:54:09 -0400 Subject: [PATCH 1168/2810] Always enforce URL param ordering for signed URLs (#90148) Always enforce URL param ordering --- homeassistant/components/http/auth.py | 15 +++++++------- tests/components/http/test_auth.py | 28 ++++++++++++++++++++++----- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index ec8c7de989..f2cfe06740 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -61,9 +61,7 @@ def async_sign_path( url = URL(path) now = dt_util.utcnow() - params = dict(sorted(url.query.items())) - for param in SAFE_QUERY_PARAMS: - params.pop(param, None) + params = [itm for itm in url.query.items() if itm[0] not in SAFE_QUERY_PARAMS] encoded = jwt.encode( { "iss": refresh_token_id, @@ -76,7 +74,7 @@ def async_sign_path( algorithm="HS256", ) - params[SIGN_QUERY_PARAM] = encoded + params.append((SIGN_QUERY_PARAM, encoded)) url = url.with_query(params) return f"{url.path}?{url.query_string}" @@ -185,10 +183,11 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None: if claims["path"] != request.path: return False - params = dict(sorted(request.query.items())) - del params[SIGN_QUERY_PARAM] - for param in SAFE_QUERY_PARAMS: - params.pop(param, None) + params = [ + list(itm) # claims stores tuples as lists + for itm in request.query.items() + if itm[0] not in SAFE_QUERY_PARAMS and itm[0] != SIGN_QUERY_PARAM + ] if claims["params"] != params: return False diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index fb00640cdc..246572e64f 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -352,6 +352,12 @@ async def test_auth_access_signed_path_with_query_param( data = await req.json() assert data["user_id"] == refresh_token.user.id + # Without query params not allowed + url = yarl.URL(signed_path) + signed_path = f"{url.path}?{SIGN_QUERY_PARAM}={url.query.get(SIGN_QUERY_PARAM)}" + req = await client.get(signed_path) + assert req.status == HTTPStatus.UNAUTHORIZED + async def test_auth_access_signed_path_with_query_param_order( hass: HomeAssistant, @@ -374,12 +380,24 @@ async def test_auth_access_signed_path_with_query_param_order( refresh_token_id=refresh_token.id, ) url = yarl.URL(signed_path) - signed_path = f"{url.path}?{SIGN_QUERY_PARAM}={url.query.get(SIGN_QUERY_PARAM)}&foo=bar&test=test" - req = await client.get(signed_path) - assert req.status == HTTPStatus.OK - data = await req.json() - assert data["user_id"] == refresh_token.user.id + # Change order + req = await client.get( + f"{url.path}?{SIGN_QUERY_PARAM}={url.query.get(SIGN_QUERY_PARAM)}&foo=bar&test=test" + ) + assert req.status == HTTPStatus.UNAUTHORIZED + + # Duplicate a param + req = await client.get( + f"{url.path}?{SIGN_QUERY_PARAM}={url.query.get(SIGN_QUERY_PARAM)}&test=test&foo=aaa&foo=bar" + ) + assert req.status == HTTPStatus.UNAUTHORIZED + + # Remove a param + req = await client.get( + f"{url.path}?{SIGN_QUERY_PARAM}={url.query.get(SIGN_QUERY_PARAM)}&test=test" + ) + assert req.status == HTTPStatus.UNAUTHORIZED async def test_auth_access_signed_path_with_query_param_safe_param( From a5aa5c0c012b73a454948a4faa9174ff40488367 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 20 Mar 2023 04:35:45 +0100 Subject: [PATCH 1169/2810] Fix imap_email_content unknown status and replaying stale states (#89563) --- .../components/imap_email_content/sensor.py | 74 ++++++++++++------- .../imap_email_content/test_sensor.py | 26 +++++-- 2 files changed, 70 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/imap_email_content/sensor.py b/homeassistant/components/imap_email_content/sensor.py index b14de63268..53cb921860 100644 --- a/homeassistant/components/imap_email_content/sensor.py +++ b/homeassistant/components/imap_email_content/sensor.py @@ -95,9 +95,25 @@ class EmailReader: self._folder = folder self._verify_ssl = verify_ssl self._last_id = None + self._last_message = None self._unread_ids = deque([]) self.connection = None + @property + def last_id(self) -> int | None: + """Return last email uid that was processed.""" + return self._last_id + + @property + def last_unread_id(self) -> int | None: + """Return last email uid received.""" + # We assume the last id in the list is the last unread id + # We cannot know if that is the newest one, because it could arrive later + # https://stackoverflow.com/questions/12409862/python-imap-the-order-of-uids + if self._unread_ids: + return int(self._unread_ids[-1]) + return self._last_id + def connect(self): """Login and setup the connection.""" ssl_context = client_context() if self._verify_ssl else None @@ -128,21 +144,21 @@ class EmailReader: try: self.connection.select(self._folder, readonly=True) - if not self._unread_ids: - search = f"SINCE {datetime.date.today():%d-%b-%Y}" - if self._last_id is not None: - search = f"UID {self._last_id}:*" - - _, data = self.connection.uid("search", None, search) - self._unread_ids = deque(data[0].split()) + if self._last_id is None: + # search for today and yesterday + time_from = datetime.datetime.now() - datetime.timedelta(days=1) + search = f"SINCE {time_from:%d-%b-%Y}" + else: + search = f"UID {self._last_id}:*" + _, data = self.connection.uid("search", None, search) + self._unread_ids = deque(data[0].split()) while self._unread_ids: message_uid = self._unread_ids.popleft() if self._last_id is None or int(message_uid) > self._last_id: self._last_id = int(message_uid) - return self._fetch_message(message_uid) - - return self._fetch_message(str(self._last_id)) + self._last_message = self._fetch_message(message_uid) + return self._last_message except imaplib.IMAP4.error: _LOGGER.info("Connection to %s lost, attempting to reconnect", self._server) @@ -254,22 +270,30 @@ class EmailContentSensor(SensorEntity): def update(self) -> None: """Read emails and publish state change.""" email_message = self._email_reader.read_next() + while ( + self._last_id is None or self._last_id != self._email_reader.last_unread_id + ): + if email_message is None: + self._message = None + self._state_attributes = {} + return - if email_message is None: - self._message = None - self._state_attributes = {} - return + self._last_id = self._email_reader.last_id - if self.sender_allowed(email_message): - message = EmailContentSensor.get_msg_subject(email_message) + if self.sender_allowed(email_message): + message = EmailContentSensor.get_msg_subject(email_message) - if self._value_template is not None: - message = self.render_template(email_message) + if self._value_template is not None: + message = self.render_template(email_message) - self._message = message - self._state_attributes = { - ATTR_FROM: EmailContentSensor.get_msg_sender(email_message), - ATTR_SUBJECT: EmailContentSensor.get_msg_subject(email_message), - ATTR_DATE: email_message["Date"], - ATTR_BODY: EmailContentSensor.get_msg_text(email_message), - } + self._message = message + self._state_attributes = { + ATTR_FROM: EmailContentSensor.get_msg_sender(email_message), + ATTR_SUBJECT: EmailContentSensor.get_msg_subject(email_message), + ATTR_DATE: email_message["Date"], + ATTR_BODY: EmailContentSensor.get_msg_text(email_message), + } + + if self._last_id == self._email_reader.last_unread_id: + break + email_message = self._email_reader.read_next() diff --git a/tests/components/imap_email_content/test_sensor.py b/tests/components/imap_email_content/test_sensor.py index afa6116ff4..ba2b362af7 100644 --- a/tests/components/imap_email_content/test_sensor.py +++ b/tests/components/imap_email_content/test_sensor.py @@ -14,9 +14,16 @@ from homeassistant.helpers.template import Template class FakeEMailReader: """A test class for sending test emails.""" - def __init__(self, messages): + def __init__(self, messages) -> None: """Set up the fake email reader.""" self._messages = messages + self.last_id = 0 + self.last_unread_id = len(messages) + + def add_test_message(self, message): + """Add a new message.""" + self.last_unread_id += 1 + self._messages.append(message) def connect(self): """Stay always Connected.""" @@ -26,6 +33,7 @@ class FakeEMailReader: """Get the next email.""" if len(self._messages) == 0: return None + self.last_id += 1 return self._messages.popleft() @@ -146,7 +154,7 @@ async def test_multi_part_only_other_text(hass: HomeAssistant) -> None: async def test_multiple_emails(hass: HomeAssistant) -> None: - """Test multiple emails.""" + """Test multiple emails, discarding stale states.""" states = [] test_message1 = email.message.Message() @@ -158,9 +166,15 @@ async def test_multiple_emails(hass: HomeAssistant) -> None: test_message2 = email.message.Message() test_message2["From"] = "sender@test.com" test_message2["Subject"] = "Test 2" - test_message2["Date"] = datetime.datetime(2016, 1, 1, 12, 44, 57) + test_message2["Date"] = datetime.datetime(2016, 1, 1, 12, 44, 58) test_message2.set_payload("Test Message 2") + test_message3 = email.message.Message() + test_message3["From"] = "sender@test.com" + test_message3["Subject"] = "Test 3" + test_message3["Date"] = datetime.datetime(2016, 1, 1, 12, 50, 1) + test_message3.set_payload("Test Message 2") + def state_changed_listener(entity_id, from_s, to_s): states.append(to_s) @@ -178,11 +192,13 @@ async def test_multiple_emails(hass: HomeAssistant) -> None: sensor.async_schedule_update_ha_state(True) await hass.async_block_till_done() + # Fake a new received message + sensor._email_reader.add_test_message(test_message3) sensor.async_schedule_update_ha_state(True) await hass.async_block_till_done() - assert states[0].state == "Test" - assert states[1].state == "Test 2" + assert states[0].state == "Test 2" + assert states[1].state == "Test 3" assert sensor.extra_state_attributes["body"] == "Test Message 2" From 1e03ff68a2604b78db53092cabd30f47b753c753 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Mar 2023 16:44:49 -1000 Subject: [PATCH 1170/2810] Bump aioharmony to 0.2.10 (#89831) fixes #89823 --- homeassistant/components/harmony/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json index 2603ee613a..c6a6327046 100644 --- a/homeassistant/components/harmony/manifest.json +++ b/homeassistant/components/harmony/manifest.json @@ -13,7 +13,7 @@ "documentation": "https://www.home-assistant.io/integrations/harmony", "iot_class": "local_push", "loggers": ["aioharmony", "slixmpp"], - "requirements": ["aioharmony==0.2.9"], + "requirements": ["aioharmony==0.2.10"], "ssdp": [ { "manufacturer": "Logitech", diff --git a/requirements_all.txt b/requirements_all.txt index cdffe860f7..3fcf96ccb5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -171,7 +171,7 @@ aiogithubapi==22.10.1 aioguardian==2022.07.0 # homeassistant.components.harmony -aioharmony==0.2.9 +aioharmony==0.2.10 # homeassistant.components.homekit_controller aiohomekit==2.6.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c8f2b57a76..7061c3bf62 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -155,7 +155,7 @@ aiogithubapi==22.10.1 aioguardian==2022.07.0 # homeassistant.components.harmony -aioharmony==0.2.9 +aioharmony==0.2.10 # homeassistant.components.homekit_controller aiohomekit==2.6.1 From 713d3025f20b8649d823804bd669b778723f72ea Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Mon, 20 Mar 2023 12:08:27 +0100 Subject: [PATCH 1171/2810] Correct missing wordswap for S series nibe (#89866) Correct missing wordswap for nibe --- homeassistant/components/nibe_heatpump/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nibe_heatpump/__init__.py b/homeassistant/components/nibe_heatpump/__init__.py index fd77b5e234..89aac6bed6 100644 --- a/homeassistant/components/nibe_heatpump/__init__.py +++ b/homeassistant/components/nibe_heatpump/__init__.py @@ -62,13 +62,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Nibe Heat Pump from a config entry.""" heatpump = HeatPump(Model[entry.data[CONF_MODEL]]) + heatpump.word_swap = entry.data.get(CONF_WORD_SWAP, True) await heatpump.initialize() connection: Connection connection_type = entry.data[CONF_CONNECTION_TYPE] if connection_type == CONF_CONNECTION_TYPE_NIBEGW: - heatpump.word_swap = entry.data[CONF_WORD_SWAP] connection = NibeGW( heatpump, entry.data[CONF_IP_ADDRESS], From de6f55dcfb994929753d419bedee5056676d7f7d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 19 Mar 2023 01:57:40 +0100 Subject: [PATCH 1172/2810] Fix blocking MQTT entry unload (#89922) * Remove unneeded async_block_till_done * use await asyncio.sleep(0) instead --- homeassistant/components/mqtt/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index a1b194284c..ff126a2260 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -706,7 +706,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for component in PLATFORMS ) ) - await hass.async_block_till_done() + await asyncio.sleep(0) # Unsubscribe reload dispatchers while reload_dispatchers := mqtt_data.reload_dispatchers: reload_dispatchers.pop()() From 127f2289a1af45ac123e733947f11b7ba40df3f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Mar 2023 20:59:05 -1000 Subject: [PATCH 1173/2810] Remove async_block_till_done in freebox (#89928) async_block_till_done() is not meant to be called in integrations --- homeassistant/components/freebox/config_flow.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/freebox/config_flow.py b/homeassistant/components/freebox/config_flow.py index fd9252aaa1..dbee01c4e7 100644 --- a/homeassistant/components/freebox/config_flow.py +++ b/homeassistant/components/freebox/config_flow.py @@ -77,7 +77,6 @@ class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # Check permissions await fbx.system.get_config() await fbx.lan.get_hosts_list() - await self.hass.async_block_till_done() # Close connection await fbx.close() From 92fb978a0377e6cea5c848c6a7265ab2f62a5657 Mon Sep 17 00:00:00 2001 From: micha91 Date: Mon, 20 Mar 2023 11:59:27 +0100 Subject: [PATCH 1174/2810] Bump aiomusiccast to 0.14.8 (#89978) --- homeassistant/components/yamaha_musiccast/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yamaha_musiccast/manifest.json b/homeassistant/components/yamaha_musiccast/manifest.json index 9a19f61eb4..48b8de2060 100644 --- a/homeassistant/components/yamaha_musiccast/manifest.json +++ b/homeassistant/components/yamaha_musiccast/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/yamaha_musiccast", "iot_class": "local_push", "loggers": ["aiomusiccast"], - "requirements": ["aiomusiccast==0.14.7"], + "requirements": ["aiomusiccast==0.14.8"], "ssdp": [ { "manufacturer": "Yamaha Corporation" diff --git a/requirements_all.txt b/requirements_all.txt index 3fcf96ccb5..27eb15772e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -214,7 +214,7 @@ aiolyric==1.0.9 aiomodernforms==0.1.8 # homeassistant.components.yamaha_musiccast -aiomusiccast==0.14.7 +aiomusiccast==0.14.8 # homeassistant.components.nanoleaf aionanoleaf==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7061c3bf62..2168b2b42f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -195,7 +195,7 @@ aiolyric==1.0.9 aiomodernforms==0.1.8 # homeassistant.components.yamaha_musiccast -aiomusiccast==0.14.7 +aiomusiccast==0.14.8 # homeassistant.components.nanoleaf aionanoleaf==0.2.1 From 1f71068740a6947acaa485af86b2acb2bad36b46 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Mar 2023 17:49:59 -1000 Subject: [PATCH 1175/2810] Handle cancelation of wait_for_ble_connections_free in esphome bluetooth (#90014) Handle cancelation in wait_for_ble_connections_free If `wait_for_ble_connections_free` was canceled due to timeout or the esp disconnecting from Home Assistant the future would get canceled. When we reconnect and get the next callback we need to handle it being done. fixes ``` 2023-03-21 02:34:36.876 ERROR (MainThread) [homeassistant] Error doing job: Fatal error: protocol.data_received() call failed. Traceback (most recent call last): File "/usr/local/lib/python3.10/asyncio/selector_events.py", line 868, in _read_ready__data_received self._protocol.data_received(data) File "/usr/local/lib/python3.10/site-packages/aioesphomeapi/_frame_helper.py", line 195, in data_received self._callback_packet(msg_type_int, bytes(packet_data)) File "/usr/local/lib/python3.10/site-packages/aioesphomeapi/_frame_helper.py", line 110, in _callback_packet self._on_pkt(Packet(type_, data)) File "/usr/local/lib/python3.10/site-packages/aioesphomeapi/connection.py", line 688, in _process_packet handler(msg) File "/usr/local/lib/python3.10/site-packages/aioesphomeapi/client.py", line 482, in on_msg on_bluetooth_connections_free_update(resp.free, resp.limit) File "/usr/src/homeassistant/homeassistant/components/esphome/entry_data.py", line 136, in async_update_ble_connection_limits fut.set_result(free) asyncio.exceptions.InvalidStateError: invalid state ``` --- homeassistant/components/esphome/entry_data.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 0aed6ce43a..a389d74643 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -130,10 +130,15 @@ class RuntimeEntryData: ) self.ble_connections_free = free self.ble_connections_limit = limit - if free: - for fut in self._ble_connection_free_futures: + if not free: + return + for fut in self._ble_connection_free_futures: + # If wait_for_ble_connections_free gets cancelled, it will + # leave a future in the list. We need to check if it's done + # before setting the result. + if not fut.done(): fut.set_result(free) - self._ble_connection_free_futures.clear() + self._ble_connection_free_futures.clear() async def wait_for_ble_connections_free(self) -> int: """Wait until there are free BLE connections.""" From 2a18261efb4b476b38e774740d59d04f09f9c8fc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Mar 2023 17:47:26 -1000 Subject: [PATCH 1176/2810] Bump yalexs_ble to 2.1.0 (#89772) switches to using cryptography to reduce the number of deps changelog: https://github.com/bdraco/yalexs-ble/compare/v2.0.4...v2.1.0 --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index dedfc9127a..eba6e2c1b3 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.2.7", "yalexs_ble==2.0.4"] + "requirements": ["yalexs==1.2.7", "yalexs_ble==2.1.0"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index e34ace05e1..e793fe2728 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.0.4"] + "requirements": ["yalexs-ble==2.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 27eb15772e..debc7d480c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2670,13 +2670,13 @@ xs1-api-client==3.0.0 yalesmartalarmclient==0.3.9 # homeassistant.components.yalexs_ble -yalexs-ble==2.0.4 +yalexs-ble==2.1.0 # homeassistant.components.august yalexs==1.2.7 # homeassistant.components.august -yalexs_ble==2.0.4 +yalexs_ble==2.1.0 # homeassistant.components.yeelight yeelight==0.7.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2168b2b42f..09afa5970b 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.4 +yalexs-ble==2.1.0 # homeassistant.components.august yalexs==1.2.7 # homeassistant.components.august -yalexs_ble==2.0.4 +yalexs_ble==2.1.0 # homeassistant.components.yeelight yeelight==0.7.10 From 53d400ca9679650f24bafac61f3e3fa8f2102fc3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Mar 2023 17:49:30 -1000 Subject: [PATCH 1177/2810] Bump yalexs-ble to 2.1.1 (#90015) * Bump yalexs-ble to 2.1.1 There was another task that could be prematurely GCed changelog: https://github.com/bdraco/yalexs-ble/compare/v2.1.0...v2.1.1 * fixes --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 6 ++---- requirements_test_all.txt | 6 ++---- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index eba6e2c1b3..213f0237e1 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.2.7", "yalexs_ble==2.1.0"] + "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.1"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index e793fe2728..6bb58752a0 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.1.0"] + "requirements": ["yalexs-ble==2.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index debc7d480c..e763e3037a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2669,15 +2669,13 @@ xs1-api-client==3.0.0 # homeassistant.components.yale_smart_alarm yalesmartalarmclient==0.3.9 +# homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.0 +yalexs-ble==2.1.1 # homeassistant.components.august yalexs==1.2.7 -# homeassistant.components.august -yalexs_ble==2.1.0 - # homeassistant.components.yeelight yeelight==0.7.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 09afa5970b..73feda7628 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1894,15 +1894,13 @@ xmltodict==0.13.0 # homeassistant.components.yale_smart_alarm yalesmartalarmclient==0.3.9 +# homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.0 +yalexs-ble==2.1.1 # homeassistant.components.august yalexs==1.2.7 -# homeassistant.components.august -yalexs_ble==2.1.0 - # homeassistant.components.yeelight yeelight==0.7.10 From 3747fd5dcb74842b6eab31630238bceccc54fc0d Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Mon, 13 Mar 2023 15:00:50 +0100 Subject: [PATCH 1178/2810] Bump easyEnergy to v0.2.1 (#89630) --- homeassistant/components/easyenergy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json index 6b88dd84c8..fc0a4fd773 100644 --- a/homeassistant/components/easyenergy/manifest.json +++ b/homeassistant/components/easyenergy/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/easyenergy", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["easyenergy==0.1.2"] + "requirements": ["easyenergy==0.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index e763e3037a..ba96fabc1a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -625,7 +625,7 @@ dynalite_devices==0.1.47 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==0.1.2 +easyenergy==0.2.1 # homeassistant.components.ebusd ebusdpy==0.0.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 73feda7628..0331bdf76e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -490,7 +490,7 @@ dynalite_devices==0.1.47 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==0.1.2 +easyenergy==0.2.1 # homeassistant.components.elgato elgato==4.0.1 From 146347e31aba16345f2ecc51496601e80d24d7c1 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Wed, 22 Mar 2023 10:46:17 +0100 Subject: [PATCH 1179/2810] Bump easyEnergy to v0.2.2 (#90080) --- homeassistant/components/easyenergy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json index fc0a4fd773..0954269628 100644 --- a/homeassistant/components/easyenergy/manifest.json +++ b/homeassistant/components/easyenergy/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/easyenergy", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["easyenergy==0.2.1"] + "requirements": ["easyenergy==0.2.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index ba96fabc1a..f282e59eeb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -625,7 +625,7 @@ dynalite_devices==0.1.47 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==0.2.1 +easyenergy==0.2.2 # homeassistant.components.ebusd ebusdpy==0.0.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0331bdf76e..6200c913d3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -490,7 +490,7 @@ dynalite_devices==0.1.47 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==0.2.1 +easyenergy==0.2.2 # homeassistant.components.elgato elgato==4.0.1 From 406e92511bb489cf8dbd80e7c37090a2721ab942 Mon Sep 17 00:00:00 2001 From: Luke Date: Tue, 21 Mar 2023 23:09:38 -0400 Subject: [PATCH 1180/2810] Bump to oralb-ble 0.17.6 (#90081) --- homeassistant/components/oralb/manifest.json | 3 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/oralb/manifest.json b/homeassistant/components/oralb/manifest.json index 37b043e543..a1071cc0a1 100644 --- a/homeassistant/components/oralb/manifest.json +++ b/homeassistant/components/oralb/manifest.json @@ -11,5 +11,6 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/oralb", "iot_class": "local_push", - "requirements": ["oralb-ble==0.17.5"] + "loggers": ["oralb-ble"], + "requirements": ["oralb-ble==0.17.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index f282e59eeb..fe86666d9c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1299,7 +1299,7 @@ openwrt-luci-rpc==1.1.11 openwrt-ubus-rpc==0.0.2 # homeassistant.components.oralb -oralb-ble==0.17.5 +oralb-ble==0.17.6 # homeassistant.components.oru oru==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6200c913d3..87663416f0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -947,7 +947,7 @@ openai==0.26.2 openerz-api==0.2.0 # homeassistant.components.oralb -oralb-ble==0.17.5 +oralb-ble==0.17.6 # homeassistant.components.ovo_energy ovoenergy==1.2.0 From a7b5a0297ef913ab305b22d2ebaf0cbdf0f0c4b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Mar 2023 16:10:47 -1000 Subject: [PATCH 1181/2810] Bump PySwitchbot to 0.37.4 (#90146) fixes #90090 fixes #89061 changelog: https://github.com/Danielhiversen/pySwitchbot/compare/0.37.3...0.37.4 --- homeassistant/components/switchbot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 2637f578b8..ada24bcee5 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -40,5 +40,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.37.3"] + "requirements": ["PySwitchbot==0.37.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index fe86666d9c..9b63f54a60 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -40,7 +40,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.37.3 +PySwitchbot==0.37.4 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 87663416f0..e71f661b4e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -36,7 +36,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.37.3 +PySwitchbot==0.37.4 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 From 174342860b828040b7018210861d98d1ae503de9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 22 Mar 2023 22:54:09 -0400 Subject: [PATCH 1182/2810] Always enforce URL param ordering for signed URLs (#90148) Always enforce URL param ordering --- homeassistant/components/http/auth.py | 15 +++++++------- tests/components/http/test_auth.py | 28 ++++++++++++++++++++++----- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 5213cd1b07..d10bd677e4 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -60,9 +60,7 @@ def async_sign_path( url = URL(path) now = dt_util.utcnow() - params = dict(sorted(url.query.items())) - for param in SAFE_QUERY_PARAMS: - params.pop(param, None) + params = [itm for itm in url.query.items() if itm[0] not in SAFE_QUERY_PARAMS] encoded = jwt.encode( { "iss": refresh_token_id, @@ -75,7 +73,7 @@ def async_sign_path( algorithm="HS256", ) - params[SIGN_QUERY_PARAM] = encoded + params.append((SIGN_QUERY_PARAM, encoded)) url = url.with_query(params) return f"{url.path}?{url.query_string}" @@ -184,10 +182,11 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None: if claims["path"] != request.path: return False - params = dict(sorted(request.query.items())) - del params[SIGN_QUERY_PARAM] - for param in SAFE_QUERY_PARAMS: - params.pop(param, None) + params = [ + list(itm) # claims stores tuples as lists + for itm in request.query.items() + if itm[0] not in SAFE_QUERY_PARAMS and itm[0] != SIGN_QUERY_PARAM + ] if claims["params"] != params: return False diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index fb00640cdc..246572e64f 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -352,6 +352,12 @@ async def test_auth_access_signed_path_with_query_param( data = await req.json() assert data["user_id"] == refresh_token.user.id + # Without query params not allowed + url = yarl.URL(signed_path) + signed_path = f"{url.path}?{SIGN_QUERY_PARAM}={url.query.get(SIGN_QUERY_PARAM)}" + req = await client.get(signed_path) + assert req.status == HTTPStatus.UNAUTHORIZED + async def test_auth_access_signed_path_with_query_param_order( hass: HomeAssistant, @@ -374,12 +380,24 @@ async def test_auth_access_signed_path_with_query_param_order( refresh_token_id=refresh_token.id, ) url = yarl.URL(signed_path) - signed_path = f"{url.path}?{SIGN_QUERY_PARAM}={url.query.get(SIGN_QUERY_PARAM)}&foo=bar&test=test" - req = await client.get(signed_path) - assert req.status == HTTPStatus.OK - data = await req.json() - assert data["user_id"] == refresh_token.user.id + # Change order + req = await client.get( + f"{url.path}?{SIGN_QUERY_PARAM}={url.query.get(SIGN_QUERY_PARAM)}&foo=bar&test=test" + ) + assert req.status == HTTPStatus.UNAUTHORIZED + + # Duplicate a param + req = await client.get( + f"{url.path}?{SIGN_QUERY_PARAM}={url.query.get(SIGN_QUERY_PARAM)}&test=test&foo=aaa&foo=bar" + ) + assert req.status == HTTPStatus.UNAUTHORIZED + + # Remove a param + req = await client.get( + f"{url.path}?{SIGN_QUERY_PARAM}={url.query.get(SIGN_QUERY_PARAM)}&test=test" + ) + assert req.status == HTTPStatus.UNAUTHORIZED async def test_auth_access_signed_path_with_query_param_safe_param( From 117113cdfc4c81f6df6a1ef71d53ac3e58ade025 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 22 Mar 2023 22:59:47 -0400 Subject: [PATCH 1183/2810] Bumped version to 2023.3.6 --- 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 f8e7859bf4..ed959488dd 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 = "5" +PATCH_VERSION: Final = "6" __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 a9c70089c7..6702915a85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.3.5" +version = "2023.3.6" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 98787383210ffb3ba655d2f957349ae4721e0393 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Mar 2023 18:14:54 -1000 Subject: [PATCH 1184/2810] Use rel_url for looking up frontend panels (#90149) * Use rel_url for looking up frontend panels request.url builds a new URL every time where-as rel_url is always available https://docs.aiohttp.org/en/stable/web_reference.html#aiohttp.web.BaseRequest.rel_url * Use rel_url for looking up frontend panels request.url builds a new URL every time where-as rel_url is always available https://docs.aiohttp.org/en/stable/web_reference.html#aiohttp.web.BaseRequest.rel_url --- homeassistant/components/frontend/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index b152b2d65d..8c04e59196 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -530,8 +530,9 @@ class IndexView(web_urldispatcher.AbstractResource): """ if ( request.path != "/" - and len(request.url.parts) > 1 - and request.url.parts[1] not in self.hass.data[DATA_PANELS] + and (parts := request.rel_url.parts) + and len(parts) > 1 + and parts[1] not in self.hass.data[DATA_PANELS] ): return None, set() From 1e64a55a1a8d14ffc332e72d37158dc1b8239a04 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 23 Mar 2023 08:08:52 +0100 Subject: [PATCH 1185/2810] Add missing translation for invalid imap folder (#90154) Add missing translation for invalid folder --- homeassistant/components/imap/strings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/imap/strings.json b/homeassistant/components/imap/strings.json index 2fedef55f6..bb03f82bb7 100644 --- a/homeassistant/components/imap/strings.json +++ b/homeassistant/components/imap/strings.json @@ -24,6 +24,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_charset": "The specified charset is not supported", + "invalid_folder": "The selected folder is invalid", "invalid_search": "The selected search is invalid" }, "abort": { From b1519236195ea54c5aece60bb65935d4f61b4106 Mon Sep 17 00:00:00 2001 From: solazs Date: Thu, 23 Mar 2023 08:56:47 +0100 Subject: [PATCH 1186/2810] Add health mode to gree integration (#89764) Add health mode to gree integration. --- homeassistant/components/gree/switch.py | 37 +++++++++++++++++++++++++ tests/components/gree/test_switch.py | 35 +++++++++++++++++++---- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/gree/switch.py b/homeassistant/components/gree/switch.py index 62189fdde0..ffef6b08a9 100644 --- a/homeassistant/components/gree/switch.py +++ b/homeassistant/components/gree/switch.py @@ -26,6 +26,7 @@ async def async_setup_entry( async_add_entities( [ GreePanelLightSwitchEntity(coordinator), + GreeHealthModeSwitchEntity(coordinator), GreeQuietModeSwitchEntity(coordinator), GreeFreshAirSwitchEntity(coordinator), GreeXFanSwitchEntity(coordinator), @@ -75,6 +76,42 @@ class GreePanelLightSwitchEntity(GreeEntity, SwitchEntity): self.async_write_ha_state() +class GreeHealthModeSwitchEntity(GreeEntity, SwitchEntity): + """Representation of the health mode on the device.""" + + def __init__(self, coordinator): + """Initialize the Gree device.""" + super().__init__(coordinator, "Health mode") + self._attr_entity_registry_enabled_default = False + + @property + def icon(self) -> str | None: + """Return the icon for the device.""" + return "mdi:pine-tree" + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return SwitchDeviceClass.SWITCH + + @property + def is_on(self) -> bool: + """Return if the health mode is turned on.""" + return self.coordinator.device.anion + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + self.coordinator.device.anion = True + await self.coordinator.push_state_update() + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + self.coordinator.device.anion = False + await self.coordinator.push_state_update() + self.async_write_ha_state() + + class GreeQuietModeSwitchEntity(GreeEntity, SwitchEntity): """Representation of the quiet mode state of the device.""" diff --git a/tests/components/gree/test_switch.py b/tests/components/gree/test_switch.py index 75af20d37e..85b9a41caf 100644 --- a/tests/components/gree/test_switch.py +++ b/tests/components/gree/test_switch.py @@ -14,11 +14,13 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry ENTITY_ID_LIGHT_PANEL = f"{DOMAIN}.fake_device_1_panel_light" +ENTITY_ID_HEALTH_MODE = f"{DOMAIN}.fake_device_1_health_mode" ENTITY_ID_QUIET = f"{DOMAIN}.fake_device_1_quiet" ENTITY_ID_FRESH_AIR = f"{DOMAIN}.fake_device_1_fresh_air" ENTITY_ID_XFAN = f"{DOMAIN}.fake_device_1_xfan" @@ -31,16 +33,29 @@ async def async_setup_gree(hass): await hass.async_block_till_done() +async def test_health_mode_disabled_by_default(hass): + """Test for making sure health mode is disabled on first load.""" + await async_setup_gree(hass) + + assert ( + er.async_get(hass).async_get(ENTITY_ID_HEALTH_MODE).disabled_by + == er.RegistryEntryDisabler.INTEGRATION + ) + + @pytest.mark.parametrize( "entity", [ ENTITY_ID_LIGHT_PANEL, + ENTITY_ID_HEALTH_MODE, ENTITY_ID_QUIET, ENTITY_ID_FRESH_AIR, ENTITY_ID_XFAN, ], ) -async def test_send_switch_on(hass: HomeAssistant, entity) -> None: +async def test_send_switch_on( + hass: HomeAssistant, entity, entity_registry_enabled_by_default +) -> None: """Test for sending power on command to the device.""" await async_setup_gree(hass) @@ -60,13 +75,14 @@ async def test_send_switch_on(hass: HomeAssistant, entity) -> None: "entity", [ ENTITY_ID_LIGHT_PANEL, + ENTITY_ID_HEALTH_MODE, ENTITY_ID_QUIET, ENTITY_ID_FRESH_AIR, ENTITY_ID_XFAN, ], ) async def test_send_switch_on_device_timeout( - hass: HomeAssistant, device, entity + hass: HomeAssistant, device, entity, entity_registry_enabled_by_default ) -> None: """Test for sending power on command to the device with a device timeout.""" device().push_state_update.side_effect = DeviceTimeoutError @@ -89,12 +105,15 @@ async def test_send_switch_on_device_timeout( "entity", [ ENTITY_ID_LIGHT_PANEL, + ENTITY_ID_HEALTH_MODE, ENTITY_ID_QUIET, ENTITY_ID_FRESH_AIR, ENTITY_ID_XFAN, ], ) -async def test_send_switch_off(hass: HomeAssistant, entity) -> None: +async def test_send_switch_off( + hass: HomeAssistant, entity, entity_registry_enabled_by_default +) -> None: """Test for sending power on command to the device.""" await async_setup_gree(hass) @@ -114,12 +133,15 @@ async def test_send_switch_off(hass: HomeAssistant, entity) -> None: "entity", [ ENTITY_ID_LIGHT_PANEL, + ENTITY_ID_HEALTH_MODE, ENTITY_ID_QUIET, ENTITY_ID_FRESH_AIR, ENTITY_ID_XFAN, ], ) -async def test_send_switch_toggle(hass: HomeAssistant, entity) -> None: +async def test_send_switch_toggle( + hass: HomeAssistant, entity, entity_registry_enabled_by_default +) -> None: """Test for sending power on command to the device.""" await async_setup_gree(hass) @@ -164,12 +186,15 @@ async def test_send_switch_toggle(hass: HomeAssistant, entity) -> None: ("entity", "name"), [ (ENTITY_ID_LIGHT_PANEL, "Panel Light"), + (ENTITY_ID_HEALTH_MODE, "Health mode"), (ENTITY_ID_QUIET, "Quiet"), (ENTITY_ID_FRESH_AIR, "Fresh Air"), (ENTITY_ID_XFAN, "XFan"), ], ) -async def test_entity_name(hass: HomeAssistant, entity, name) -> None: +async def test_entity_name( + hass: HomeAssistant, entity, name, entity_registry_enabled_by_default +) -> None: """Test for name property.""" await async_setup_gree(hass) state = hass.states.get(entity) From 6739542a5d72496b27740492a6f8b3c21914ceee Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 23 Mar 2023 09:18:35 +0100 Subject: [PATCH 1187/2810] Simplify some multi pan code (#90135) * Simplify some multi pan code * Adjust ZHA config flow --- homeassistant/components/hassio/__init__.py | 5 ++++ .../silabs_multiprotocol_addon.py | 24 +++++++++---------- .../homeassistant_sky_connect/__init__.py | 7 +++--- .../homeassistant_yellow/__init__.py | 8 +++---- homeassistant/components/zha/config_flow.py | 2 +- tests/components/hassio/test_init.py | 10 ++++++++ 6 files changed, 34 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 25482ddde9..d5449cf927 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -244,6 +244,11 @@ HARDWARE_INTEGRATIONS = { } +def hostname_from_addon_slug(addon_slug: str) -> str: + """Return hostname of add-on.""" + return addon_slug.replace("_", "-") + + @callback @bind_hass def get_info(hass: HomeAssistant) -> dict[str, Any] | None: diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index bba6b447c7..41f16462cd 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -15,6 +15,7 @@ from homeassistant.components.hassio import ( AddonInfo, AddonManager, AddonState, + hostname_from_addon_slug, is_hassio, ) from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN @@ -64,12 +65,13 @@ class SerialPortSettings: flow_control: bool -def get_zigbee_socket(hass: HomeAssistant, addon_info: AddonInfo) -> str: +def get_zigbee_socket() -> str: """Return the zigbee socket. Raises AddonError on error """ - return f"socket://{addon_info.hostname}:9999" + hostname = hostname_from_addon_slug(SILABS_MULTIPROTOCOL_ADDON_SLUG) + return f"socket://{hostname}:9999" class BaseMultiPanFlow(FlowHandler, ABC): @@ -290,7 +292,7 @@ class OptionsFlowHandler(BaseMultiPanFlow, config_entries.OptionsFlow): "new_discovery_info": { "name": self._zha_name(), "port": { - "path": get_zigbee_socket(self.hass, addon_info), + "path": get_zigbee_socket(), }, "radio_type": "ezsp", }, @@ -386,24 +388,22 @@ async def check_multi_pan_addon(hass: HomeAssistant) -> None: raise HomeAssistantError -async def get_multi_pan_addon_info( - hass: HomeAssistant, device_path: str -) -> AddonInfo | None: - """Return AddonInfo if the multi-PAN addon is using the given device. +async def multi_pan_addon_using_device(hass: HomeAssistant, device_path: str) -> bool: + """Return True if the multi-PAN addon is using the given device. - Returns None if Hass.io is not loaded, the addon is not running or the addon is + Returns False if Hass.io is not loaded, the addon is not running or the addon is connected to another device. """ if not is_hassio(hass): - return None + return False addon_manager: AddonManager = get_addon_manager(hass) addon_info: AddonInfo = await addon_manager.async_get_addon_info() if addon_info.state != AddonState.RUNNING: - return None + return False if addon_info.options["device"] != device_path: - return None + return False - return addon_info + return True diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index 54c11fd379..0f7ec70471 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -4,8 +4,8 @@ from __future__ import annotations from homeassistant.components import usb from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( check_multi_pan_addon, - get_multi_pan_addon_info, get_zigbee_socket, + multi_pan_addon_using_device, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -33,9 +33,8 @@ async def _async_usb_scan_done(hass: HomeAssistant, entry: ConfigEntry) -> None: usb_dev = entry.data["device"] dev_path = await hass.async_add_executor_job(usb.get_serial_by_id, usb_dev) - addon_info = await get_multi_pan_addon_info(hass, dev_path) - if not addon_info: + if not await multi_pan_addon_using_device(hass, dev_path): usb_info = get_usb_service_info(entry) await hass.config_entries.flow.async_init( "zha", @@ -47,7 +46,7 @@ async def _async_usb_scan_done(hass: HomeAssistant, entry: ConfigEntry) -> None: hw_discovery_data = { "name": "SkyConnect Multi-PAN", "port": { - "path": get_zigbee_socket(hass, addon_info), + "path": get_zigbee_socket(), }, "radio_type": "ezsp", } diff --git a/homeassistant/components/homeassistant_yellow/__init__.py b/homeassistant/components/homeassistant_yellow/__init__.py index 72df6a5707..30015d1bae 100644 --- a/homeassistant/components/homeassistant_yellow/__init__.py +++ b/homeassistant/components/homeassistant_yellow/__init__.py @@ -4,8 +4,8 @@ from __future__ import annotations from homeassistant.components.hassio import get_os_info from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( check_multi_pan_addon, - get_multi_pan_addon_info, get_zigbee_socket, + multi_pan_addon_using_device, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -31,15 +31,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except HomeAssistantError as err: raise ConfigEntryNotReady from err - addon_info = await get_multi_pan_addon_info(hass, RADIO_DEVICE) - - if not addon_info: + if not await multi_pan_addon_using_device(hass, RADIO_DEVICE): hw_discovery_data = ZHA_HW_DISCOVERY_DATA else: hw_discovery_data = { "name": "Yellow Multi-PAN", "port": { - "path": get_zigbee_socket(hass, addon_info), + "path": get_zigbee_socket(), }, "radio_type": "ezsp", } diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 05dc67314e..53c4e33881 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -101,7 +101,7 @@ async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]: if addon_info is not None and addon_info.state != AddonState.NOT_INSTALLED: addon_port = ListPortInfo( - device=silabs_multiprotocol_addon.get_zigbee_socket(hass, addon_info), + device=silabs_multiprotocol_addon.get_zigbee_socket(), skip_link_detection=True, ) diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index ead65d8129..1d86699d09 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -14,6 +14,7 @@ from homeassistant.components.hassio import ( DOMAIN, STORAGE_KEY, async_get_addon_store_info, + hostname_from_addon_slug, ) from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN @@ -871,3 +872,12 @@ async def test_get_store_addon_info( data = await async_get_addon_store_info(hass, "test") assert data["name"] == "bla" assert aioclient_mock.call_count == 1 + + +def test_hostname_from_addon_slug() -> None: + """Test hostname_from_addon_slug.""" + assert hostname_from_addon_slug("mqtt") == "mqtt" + assert ( + hostname_from_addon_slug("core_silabs_multiprotocol") + == "core-silabs-multiprotocol" + ) From 1a2fa51ac99bcf3abb5eba7fa2a170cef7280e05 Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Thu, 23 Mar 2023 10:01:01 +0100 Subject: [PATCH 1188/2810] Bump bimmer_connected to 0.13.0 (#90127) --- .../bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../bmw_connected_drive/__init__.py | 51 +++++- .../diagnostics/diagnostics_config_entry.json | 154 +++++++++++++++++- .../diagnostics/diagnostics_device.json | 154 +++++++++++++++++- ...x-crccs_v2_vehicles_WBY00000000REXI01.json | 60 +++++++ .../bmw-eadrax-vcs_v4_vehicles.json} | 0 ..._v4_vehicles_state_WBY00000000REXI01.json} | 0 9 files changed, 407 insertions(+), 18 deletions(-) create mode 100644 tests/components/bmw_connected_drive/fixtures/vehicles/I01_REX/bmw-eadrax-crccs_v2_vehicles_WBY00000000REXI01.json rename tests/components/bmw_connected_drive/fixtures/vehicles/{I01/vehicles_v2_bmw_0.json => I01_REX/bmw-eadrax-vcs_v4_vehicles.json} (100%) rename tests/components/bmw_connected_drive/fixtures/vehicles/{I01/state_WBY00000000REXI01_0.json => I01_REX/bmw-eadrax-vcs_v4_vehicles_state_WBY00000000REXI01.json} (100%) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index cafaced522..f1768d5a0c 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "requirements": ["bimmer_connected==0.12.1"] + "requirements": ["bimmer_connected==0.13.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 23ae790f36..a3ddeac5db 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -425,7 +425,7 @@ beautifulsoup4==4.11.1 bellows==0.34.10 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.12.1 +bimmer_connected==0.13.0 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dfd6517954..5237078f5e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -358,7 +358,7 @@ beautifulsoup4==4.11.1 bellows==0.34.10 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.12.1 +bimmer_connected==0.13.0 # homeassistant.components.bluetooth bleak-retry-connector==3.0.1 diff --git a/tests/components/bmw_connected_drive/__init__.py b/tests/components/bmw_connected_drive/__init__.py index ed31b4308b..12957db5ca 100644 --- a/tests/components/bmw_connected_drive/__init__.py +++ b/tests/components/bmw_connected_drive/__init__.py @@ -1,10 +1,13 @@ """Tests for the for the BMW Connected Drive integration.""" -import json from pathlib import Path from bimmer_connected.api.authentication import MyBMWAuthentication -from bimmer_connected.const import VEHICLE_STATE_URL, VEHICLES_URL +from bimmer_connected.const import ( + VEHICLE_CHARGING_DETAILS_URL, + VEHICLE_STATE_URL, + VEHICLES_URL, +) import httpx import respx @@ -17,7 +20,12 @@ from homeassistant.components.bmw_connected_drive.const import ( from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, get_fixture_path, load_fixture +from tests.common import ( + MockConfigEntry, + get_fixture_path, + load_json_array_fixture, + load_json_object_fixture, +) FIXTURE_USER_INPUT = { CONF_USERNAME: "user@domain.com", @@ -42,6 +50,17 @@ FIXTURE_CONFIG_ENTRY = { } FIXTURE_PATH = Path(get_fixture_path("", integration=BMW_DOMAIN)) +FIXTURE_FILES = { + "vehicles": sorted(FIXTURE_PATH.rglob("*-eadrax-vcs_v4_vehicles.json")), + "states": { + p.stem.split("_")[-1]: p + for p in FIXTURE_PATH.rglob("*-eadrax-vcs_v4_vehicles_state_*.json") + }, + "charging": { + p.stem.split("_")[-1]: p + for p in FIXTURE_PATH.rglob("*-eadrax-crccs_v2_vehicles_*.json") + }, +} def vehicles_sideeffect(request: httpx.Request) -> httpx.Response: @@ -49,17 +68,31 @@ def vehicles_sideeffect(request: httpx.Request) -> httpx.Response: x_user_agent = request.headers.get("x-user-agent", "").split(";") brand = x_user_agent[1] vehicles = [] - for vehicle_file in FIXTURE_PATH.rglob(f"vehicles_v2_{brand}_*.json"): - vehicles.extend(json.loads(load_fixture(vehicle_file, integration=BMW_DOMAIN))) + for vehicle_file in FIXTURE_FILES["vehicles"]: + if vehicle_file.name.startswith(brand): + vehicles.extend( + load_json_array_fixture(vehicle_file, integration=BMW_DOMAIN) + ) return httpx.Response(200, json=vehicles) def vehicle_state_sideeffect(request: httpx.Request) -> httpx.Response: """Return /vehicles/state response.""" - state_file = next(FIXTURE_PATH.rglob(f"state_{request.headers['bmw-vin']}_*.json")) try: + state_file = FIXTURE_FILES["states"][request.headers["bmw-vin"]] return httpx.Response( - 200, json=json.loads(load_fixture(state_file, integration=BMW_DOMAIN)) + 200, json=load_json_object_fixture(state_file, integration=BMW_DOMAIN) + ) + except KeyError: + return httpx.Response(404) + + +def vehicle_charging_sideeffect(request: httpx.Request) -> httpx.Response: + """Return /vehicles/state response.""" + try: + charging_file = FIXTURE_FILES["charging"][request.headers["bmw-vin"]] + return httpx.Response( + 200, json=load_json_object_fixture(charging_file, integration=BMW_DOMAIN) ) except KeyError: return httpx.Response(404) @@ -75,6 +108,10 @@ def mock_vehicles() -> respx.Router: # Get vehicle state router.get(VEHICLE_STATE_URL).mock(side_effect=vehicle_state_sideeffect) + # Get vehicle charging details + router.get(VEHICLE_CHARGING_DETAILS_URL).mock( + side_effect=vehicle_charging_sideeffect + ) return router diff --git a/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_config_entry.json b/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_config_entry.json index 9c56e0595b..12e85bb852 100644 --- a/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_config_entry.json +++ b/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_config_entry.json @@ -45,6 +45,64 @@ "mappingStatus": "CONFIRMED" }, "vin": "**REDACTED**", + "charging_settings": { + "chargeAndClimateSettings": { + "chargeAndClimateTimer": { "showDepartureTimers": false } + }, + "chargeAndClimateTimerDetail": { + "chargingMode": { + "chargingPreference": "CHARGING_WINDOW", + "endTimeSlot": "0001-01-01T01:30:00", + "startTimeSlot": "0001-01-01T18:01:00", + "type": "TIME_SLOT" + }, + "departureTimer": { + "type": "WEEKLY_DEPARTURE_TIMER", + "weeklyTimers": [ + { + "daysOfTheWeek": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY" + ], + "id": 1, + "time": "0001-01-01T07:35:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY", + "SATURDAY", + "SUNDAY" + ], + "id": 2, + "time": "0001-01-01T18:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 3, + "time": "0001-01-01T07:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 4, + "time": "0001-01-01T00:00:00", + "timerAction": "DEACTIVATE" + } + ] + }, + "isPreconditionForDepartureActive": false + }, + "servicePack": "TCB1" + }, "is_metric": true, "fetched_at": "2022-07-10T11:00:00+00:00", "capabilities": { @@ -230,6 +288,7 @@ "charging_start_time_no_tz": "2022-07-10T18:01:00", "charging_end_time": null, "is_charger_connected": true, + "charging_target": 100, "account_timezone": { "_std_offset": "0:00:00", "_dst_offset": "0:00:00", @@ -376,7 +435,10 @@ "start_time": "18:01:00" }, "charging_preferences": "CHARGING_WINDOW", - "charging_mode": "DELAYED_CHARGING" + "charging_mode": "DELAYED_CHARGING", + "ac_current_limit": null, + "ac_available_limits": null, + "charging_preferences_service_pack": "TCB1" }, "available_attributes": [ "gps_position", @@ -384,6 +446,7 @@ "remaining_range_total", "mileage", "charging_time_remaining", + "charging_start_time", "charging_end_time", "charging_time_label", "charging_status", @@ -391,6 +454,11 @@ "remaining_battery_percent", "remaining_range_electric", "last_charging_end_result", + "ac_current_limit", + "charging_target", + "charging_mode", + "charging_preferences", + "is_pre_entry_climatization_enabled", "remaining_fuel", "remaining_range_fuel", "remaining_fuel_percent", @@ -407,6 +475,7 @@ "remaining_range_total", "mileage", "charging_time_remaining", + "charging_start_time", "charging_end_time", "charging_time_label", "charging_status", @@ -414,6 +483,11 @@ "remaining_battery_percent", "remaining_range_electric", "last_charging_end_result", + "ac_current_limit", + "charging_target", + "charging_mode", + "charging_preferences", + "is_pre_entry_climatization_enabled", "remaining_fuel", "remaining_range_fuel", "remaining_fuel_percent" @@ -422,6 +496,17 @@ "has_electric_drivetrain": true, "is_charging_plan_supported": true, "is_lsc_enabled": true, + "is_remote_charge_start_enabled": false, + "is_remote_charge_stop_enabled": false, + "is_remote_climate_start_enabled": true, + "is_remote_climate_stop_enabled": false, + "is_remote_horn_enabled": true, + "is_remote_lights_enabled": true, + "is_remote_lock_enabled": true, + "is_remote_sendpoi_enabled": true, + "is_remote_set_ac_limit_enabled": false, + "is_remote_set_target_soc_enabled": false, + "is_remote_unlock_enabled": true, "is_vehicle_active": false, "is_vehicle_tracking_enabled": false, "lsc_type": "ACTIVATED", @@ -433,7 +518,7 @@ ], "fingerprint": [ { - "filename": "bmw-vehicles.json", + "filename": "bmw-eadrax-vcs_v4_vehicles.json", "content": [ { "appVehicleType": "CONNECTED", @@ -476,9 +561,9 @@ } ] }, - { "filename": "mini-vehicles.json", "content": [] }, + { "filename": "mini-eadrax-vcs_v4_vehicles.json", "content": [] }, { - "filename": "bmw-vehicles_state_WBY0FINGERPRINT01.json", + "filename": "bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT01.json", "content": { "capabilities": { "climateFunction": "AIR_CONDITIONING", @@ -652,6 +737,67 @@ } } } + }, + { + "filename": "bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT01.json", + "content": { + "chargeAndClimateSettings": { + "chargeAndClimateTimer": { "showDepartureTimers": false } + }, + "chargeAndClimateTimerDetail": { + "chargingMode": { + "chargingPreference": "CHARGING_WINDOW", + "endTimeSlot": "0001-01-01T01:30:00", + "startTimeSlot": "0001-01-01T18:01:00", + "type": "TIME_SLOT" + }, + "departureTimer": { + "type": "WEEKLY_DEPARTURE_TIMER", + "weeklyTimers": [ + { + "daysOfTheWeek": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY" + ], + "id": 1, + "time": "0001-01-01T07:35:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY", + "SATURDAY", + "SUNDAY" + ], + "id": 2, + "time": "0001-01-01T18:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 3, + "time": "0001-01-01T07:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 4, + "time": "0001-01-01T00:00:00", + "timerAction": "DEACTIVATE" + } + ] + }, + "isPreconditionForDepartureActive": false + }, + "servicePack": "TCB1" + } } ] } diff --git a/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_device.json b/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_device.json index d76f2c8071..8e1fe5019c 100644 --- a/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_device.json +++ b/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_device.json @@ -44,6 +44,64 @@ "mappingStatus": "CONFIRMED" }, "vin": "**REDACTED**", + "charging_settings": { + "chargeAndClimateSettings": { + "chargeAndClimateTimer": { "showDepartureTimers": false } + }, + "chargeAndClimateTimerDetail": { + "chargingMode": { + "chargingPreference": "CHARGING_WINDOW", + "endTimeSlot": "0001-01-01T01:30:00", + "startTimeSlot": "0001-01-01T18:01:00", + "type": "TIME_SLOT" + }, + "departureTimer": { + "type": "WEEKLY_DEPARTURE_TIMER", + "weeklyTimers": [ + { + "daysOfTheWeek": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY" + ], + "id": 1, + "time": "0001-01-01T07:35:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY", + "SATURDAY", + "SUNDAY" + ], + "id": 2, + "time": "0001-01-01T18:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 3, + "time": "0001-01-01T07:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 4, + "time": "0001-01-01T00:00:00", + "timerAction": "DEACTIVATE" + } + ] + }, + "isPreconditionForDepartureActive": false + }, + "servicePack": "TCB1" + }, "is_metric": true, "fetched_at": "2022-07-10T11:00:00+00:00", "capabilities": { @@ -229,6 +287,7 @@ "charging_start_time_no_tz": "2022-07-10T18:01:00", "charging_end_time": null, "is_charger_connected": true, + "charging_target": 100, "account_timezone": { "_std_offset": "0:00:00", "_dst_offset": "0:00:00", @@ -375,7 +434,10 @@ "start_time": "18:01:00" }, "charging_preferences": "CHARGING_WINDOW", - "charging_mode": "DELAYED_CHARGING" + "charging_mode": "DELAYED_CHARGING", + "ac_current_limit": null, + "ac_available_limits": null, + "charging_preferences_service_pack": "TCB1" }, "available_attributes": [ "gps_position", @@ -383,6 +445,7 @@ "remaining_range_total", "mileage", "charging_time_remaining", + "charging_start_time", "charging_end_time", "charging_time_label", "charging_status", @@ -390,6 +453,11 @@ "remaining_battery_percent", "remaining_range_electric", "last_charging_end_result", + "ac_current_limit", + "charging_target", + "charging_mode", + "charging_preferences", + "is_pre_entry_climatization_enabled", "remaining_fuel", "remaining_range_fuel", "remaining_fuel_percent", @@ -406,6 +474,7 @@ "remaining_range_total", "mileage", "charging_time_remaining", + "charging_start_time", "charging_end_time", "charging_time_label", "charging_status", @@ -413,6 +482,11 @@ "remaining_battery_percent", "remaining_range_electric", "last_charging_end_result", + "ac_current_limit", + "charging_target", + "charging_mode", + "charging_preferences", + "is_pre_entry_climatization_enabled", "remaining_fuel", "remaining_range_fuel", "remaining_fuel_percent" @@ -421,6 +495,17 @@ "has_electric_drivetrain": true, "is_charging_plan_supported": true, "is_lsc_enabled": true, + "is_remote_charge_start_enabled": false, + "is_remote_charge_stop_enabled": false, + "is_remote_climate_start_enabled": true, + "is_remote_climate_stop_enabled": false, + "is_remote_horn_enabled": true, + "is_remote_lights_enabled": true, + "is_remote_lock_enabled": true, + "is_remote_sendpoi_enabled": true, + "is_remote_set_ac_limit_enabled": false, + "is_remote_set_target_soc_enabled": false, + "is_remote_unlock_enabled": true, "is_vehicle_active": false, "is_vehicle_tracking_enabled": false, "lsc_type": "ACTIVATED", @@ -431,7 +516,7 @@ }, "fingerprint": [ { - "filename": "bmw-vehicles.json", + "filename": "bmw-eadrax-vcs_v4_vehicles.json", "content": [ { "appVehicleType": "CONNECTED", @@ -474,9 +559,9 @@ } ] }, - { "filename": "mini-vehicles.json", "content": [] }, + { "filename": "mini-eadrax-vcs_v4_vehicles.json", "content": [] }, { - "filename": "bmw-vehicles_state_WBY0FINGERPRINT01.json", + "filename": "bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT01.json", "content": { "capabilities": { "climateFunction": "AIR_CONDITIONING", @@ -650,6 +735,67 @@ } } } + }, + { + "filename": "bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT01.json", + "content": { + "chargeAndClimateSettings": { + "chargeAndClimateTimer": { "showDepartureTimers": false } + }, + "chargeAndClimateTimerDetail": { + "chargingMode": { + "chargingPreference": "CHARGING_WINDOW", + "endTimeSlot": "0001-01-01T01:30:00", + "startTimeSlot": "0001-01-01T18:01:00", + "type": "TIME_SLOT" + }, + "departureTimer": { + "type": "WEEKLY_DEPARTURE_TIMER", + "weeklyTimers": [ + { + "daysOfTheWeek": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY" + ], + "id": 1, + "time": "0001-01-01T07:35:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY", + "SATURDAY", + "SUNDAY" + ], + "id": 2, + "time": "0001-01-01T18:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 3, + "time": "0001-01-01T07:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 4, + "time": "0001-01-01T00:00:00", + "timerAction": "DEACTIVATE" + } + ] + }, + "isPreconditionForDepartureActive": false + }, + "servicePack": "TCB1" + } } ] } diff --git a/tests/components/bmw_connected_drive/fixtures/vehicles/I01_REX/bmw-eadrax-crccs_v2_vehicles_WBY00000000REXI01.json b/tests/components/bmw_connected_drive/fixtures/vehicles/I01_REX/bmw-eadrax-crccs_v2_vehicles_WBY00000000REXI01.json new file mode 100644 index 0000000000..03bfc1cae0 --- /dev/null +++ b/tests/components/bmw_connected_drive/fixtures/vehicles/I01_REX/bmw-eadrax-crccs_v2_vehicles_WBY00000000REXI01.json @@ -0,0 +1,60 @@ +{ + "chargeAndClimateSettings": { + "chargeAndClimateTimer": { + "showDepartureTimers": false + } + }, + "chargeAndClimateTimerDetail": { + "chargingMode": { + "chargingPreference": "CHARGING_WINDOW", + "endTimeSlot": "0001-01-01T01:30:00", + "startTimeSlot": "0001-01-01T18:01:00", + "type": "TIME_SLOT" + }, + "departureTimer": { + "type": "WEEKLY_DEPARTURE_TIMER", + "weeklyTimers": [ + { + "daysOfTheWeek": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY" + ], + "id": 1, + "time": "0001-01-01T07:35:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY", + "SATURDAY", + "SUNDAY" + ], + "id": 2, + "time": "0001-01-01T18:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 3, + "time": "0001-01-01T07:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 4, + "time": "0001-01-01T00:00:00", + "timerAction": "DEACTIVATE" + } + ] + }, + "isPreconditionForDepartureActive": false + }, + "servicePack": "TCB1" +} diff --git a/tests/components/bmw_connected_drive/fixtures/vehicles/I01/vehicles_v2_bmw_0.json b/tests/components/bmw_connected_drive/fixtures/vehicles/I01_REX/bmw-eadrax-vcs_v4_vehicles.json similarity index 100% rename from tests/components/bmw_connected_drive/fixtures/vehicles/I01/vehicles_v2_bmw_0.json rename to tests/components/bmw_connected_drive/fixtures/vehicles/I01_REX/bmw-eadrax-vcs_v4_vehicles.json diff --git a/tests/components/bmw_connected_drive/fixtures/vehicles/I01/state_WBY00000000REXI01_0.json b/tests/components/bmw_connected_drive/fixtures/vehicles/I01_REX/bmw-eadrax-vcs_v4_vehicles_state_WBY00000000REXI01.json similarity index 100% rename from tests/components/bmw_connected_drive/fixtures/vehicles/I01/state_WBY00000000REXI01_0.json rename to tests/components/bmw_connected_drive/fixtures/vehicles/I01_REX/bmw-eadrax-vcs_v4_vehicles_state_WBY00000000REXI01.json From 10cf92246fb3144cd20d627b50c6c8a3abe8f42e Mon Sep 17 00:00:00 2001 From: On Freund Date: Thu, 23 Mar 2023 11:05:08 +0200 Subject: [PATCH 1189/2810] Bump pyrympro to 0.0.7 (#90118) --- homeassistant/components/rympro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rympro/manifest.json b/homeassistant/components/rympro/manifest.json index 613a1c3361..e14ac9af71 100644 --- a/homeassistant/components/rympro/manifest.json +++ b/homeassistant/components/rympro/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/rympro", "iot_class": "cloud_polling", - "requirements": ["pyrympro==0.0.4"] + "requirements": ["pyrympro==0.0.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index a3ddeac5db..0b472aafbd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1923,7 +1923,7 @@ pyroute2==0.7.5 pyruckus==0.16 # homeassistant.components.rympro -pyrympro==0.0.4 +pyrympro==0.0.7 # homeassistant.components.sabnzbd pysabnzbd==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5237078f5e..1dcc06fa41 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1397,7 +1397,7 @@ pyroute2==0.7.5 pyruckus==0.16 # homeassistant.components.rympro -pyrympro==0.0.4 +pyrympro==0.0.7 # homeassistant.components.sabnzbd pysabnzbd==1.1.1 From 2cb4ec82df5eec8ab1d9b7f8b87ac5cbd33a43a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Mar 2023 23:08:06 -1000 Subject: [PATCH 1190/2810] Bump yalexs-ble to 2.1.2 (#90156) --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 213f0237e1..7bbc6f042e 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.1"] + "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.2"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 6bb58752a0..bb95a70386 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.1.1"] + "requirements": ["yalexs-ble==2.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0b472aafbd..34e4707607 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2671,7 +2671,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.1 +yalexs-ble==2.1.2 # homeassistant.components.august yalexs==1.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1dcc06fa41..fbc282040f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1905,7 +1905,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.1 +yalexs-ble==2.1.2 # homeassistant.components.august yalexs==1.2.7 From 4c26741e40316a966caf4bdff783cea5f3277ef8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Mar 2023 10:09:03 +0100 Subject: [PATCH 1191/2810] Bump actions/stale from 7.0.0 to 8.0.0 (#90155) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/stale.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index d8aaa998ac..5fb977f74d 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -17,7 +17,7 @@ jobs: # - No PRs marked as no-stale # - No issues (-1) - name: 90 days stale PRs policy - uses: actions/stale@v7.0.0 + uses: actions/stale@v8.0.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-stale: 90 @@ -53,7 +53,7 @@ jobs: # - No issues marked as no-stale or help-wanted # - No PRs (-1) - name: 90 days stale issues - uses: actions/stale@v7.0.0 + uses: actions/stale@v8.0.0 with: repo-token: ${{ steps.token.outputs.token }} days-before-stale: 90 @@ -83,7 +83,7 @@ jobs: # - No Issues marked as no-stale or help-wanted # - No PRs (-1) - name: Needs more information stale issues policy - uses: actions/stale@v7.0.0 + uses: actions/stale@v8.0.0 with: repo-token: ${{ steps.token.outputs.token }} only-labels: "needs-more-information" From 50ea0c5cf2beba8fa535ae4091ad53985a1036fa Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 23 Mar 2023 10:12:42 +0100 Subject: [PATCH 1192/2810] Tweak multiprotocol tests (#90163) --- .../test_silabs_multiprotocol_addon.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index 57e4a23ab5..424e4126e0 100644 --- a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py +++ b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py @@ -25,7 +25,7 @@ from tests.common import ( TEST_DOMAIN = "test" -class TestConfigFlow(ConfigFlow): +class FakeConfigFlow(ConfigFlow): """Handle a config flow for the silabs multiprotocol add-on.""" VERSION = 1 @@ -34,9 +34,9 @@ class TestConfigFlow(ConfigFlow): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> TestOptionsFlow: + ) -> FakeOptionsFlow: """Return the options flow.""" - return TestOptionsFlow(config_entry) + return FakeOptionsFlow(config_entry) async def async_step_system(self, data: dict[str, Any] | None = None) -> FlowResult: """Handle the initial step.""" @@ -46,7 +46,7 @@ class TestConfigFlow(ConfigFlow): return self.async_create_entry(title="Test HW", data={}) -class TestOptionsFlow(silabs_multiprotocol_addon.OptionsFlowHandler): +class FakeOptionsFlow(silabs_multiprotocol_addon.OptionsFlowHandler): """Handle an option flow for the silabs multiprotocol add-on.""" async def _async_serial_port_settings( @@ -89,10 +89,10 @@ class TestOptionsFlow(silabs_multiprotocol_addon.OptionsFlowHandler): @pytest.fixture(autouse=True) def config_flow_handler( hass: HomeAssistant, current_request_with_host: Any -) -> Generator[TestConfigFlow, None, None]: +) -> Generator[FakeConfigFlow, None, None]: """Fixture for a test config flow.""" mock_platform(hass, f"{TEST_DOMAIN}.config_flow") - with mock_config_flow(TEST_DOMAIN, TestConfigFlow): + with mock_config_flow(TEST_DOMAIN, FakeConfigFlow): yield From 60ae1f99e023fd4d2280e4a98aa42c46f29a8b8d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Mar 2023 23:13:18 -1000 Subject: [PATCH 1193/2810] Update powerwall strings for newer models (#90151) --- homeassistant/components/powerwall/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/powerwall/strings.json b/homeassistant/components/powerwall/strings.json index 213b7cc03d..db8b212cc5 100644 --- a/homeassistant/components/powerwall/strings.json +++ b/homeassistant/components/powerwall/strings.json @@ -3,15 +3,15 @@ "flow_title": "{name} ({ip_address})", "step": { "user": { - "title": "Connect to the powerwall", - "description": "The password is usually the last 5 characters of the serial number for Backup Gateway and can be found in the Tesla app or the last 5 characters of the password found inside the door for Backup Gateway 2.", + "title": "Connect to the Powerwall", + "description": "The default password is printed inside the Backup Gateway for newer models. For older models, the default password is the last five characters of the serial number for Backup Gateway and can be found in the Tesla app.", "data": { "ip_address": "[%key:common::config_flow::data::ip%]", "password": "[%key:common::config_flow::data::password%]" } }, "reauth_confim": { - "title": "Reauthenticate the powerwall", + "title": "Reauthenticate the Powerwall", "description": "[%key:component::powerwall::config::step::user::description%]", "data": { "password": "[%key:common::config_flow::data::password%]" @@ -24,7 +24,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "wrong_version": "Your powerwall uses a software version that is not supported. Please consider upgrading or reporting this issue so it can be resolved.", + "wrong_version": "Your Powerwall uses a software version that is not supported. Please consider upgrading or reporting this issue so it can be resolved.", "unknown": "[%key:common::config_flow::error::unknown%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, From 92bcb04e4fea58c74baa83708aeb93fe9a3db40f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 23 Mar 2023 10:13:53 +0100 Subject: [PATCH 1194/2810] Adjust scaffold docstring (#90157) Co-authored-by: Martin Hjelmare --- script/scaffold/templates/config_flow/tests/conftest.py | 2 +- script/scaffold/templates/config_flow_helper/tests/conftest.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/script/scaffold/templates/config_flow/tests/conftest.py b/script/scaffold/templates/config_flow/tests/conftest.py index dab3d971a3..05993acc32 100644 --- a/script/scaffold/templates/config_flow/tests/conftest.py +++ b/script/scaffold/templates/config_flow/tests/conftest.py @@ -1,4 +1,4 @@ -"""Test the NEW_NAME config flow.""" +"""Common fixtures for the NEW_NAME tests.""" from collections.abc import Generator from unittest.mock import AsyncMock, patch diff --git a/script/scaffold/templates/config_flow_helper/tests/conftest.py b/script/scaffold/templates/config_flow_helper/tests/conftest.py index dab3d971a3..05993acc32 100644 --- a/script/scaffold/templates/config_flow_helper/tests/conftest.py +++ b/script/scaffold/templates/config_flow_helper/tests/conftest.py @@ -1,4 +1,4 @@ -"""Test the NEW_NAME config flow.""" +"""Common fixtures for the NEW_NAME tests.""" from collections.abc import Generator from unittest.mock import AsyncMock, patch From 568a731e2d28ca062af78c9038bbdd2e2891f8bf Mon Sep 17 00:00:00 2001 From: Vincent Knoop Pathuis <48653141+vpathuis@users.noreply.github.com> Date: Thu, 23 Mar 2023 10:30:31 +0100 Subject: [PATCH 1195/2810] Use snapshot test for Landis+Gyr (#90126) Initial commit for snapshot test --- .../snapshots/test_sensor.ambr | 303 ++++++++++++++++++ .../landisgyr_heat_meter/test_sensor.py | 54 +--- 2 files changed, 310 insertions(+), 47 deletions(-) create mode 100644 tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr diff --git a/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr b/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr new file mode 100644 index 0000000000..e149073d9c --- /dev/null +++ b/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr @@ -0,0 +1,303 @@ +# serializer version: 1 +# name: test_create_sensors + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume', + 'friendly_name': 'Heat Meter Volume usage', + 'icon': 'mdi:fire', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_volume_usage', + 'last_changed': , + 'last_updated': , + 'state': '456.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat Meter Heat usage GJ', + 'icon': 'mdi:fire', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_heat_usage_gj', + 'last_changed': , + 'last_updated': , + 'state': '123.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Heat previous year GJ', + 'icon': 'mdi:fire', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_heat_previous_year_gj', + 'last_changed': , + 'last_updated': , + 'state': '111.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume', + 'friendly_name': 'Heat Meter Volume usage previous year', + 'icon': 'mdi:fire', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_volume_usage_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Ownership number', + 'icon': 'mdi:identifier', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_ownership_number', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Error number', + 'icon': 'mdi:home-alert', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_error_number', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Device number', + 'icon': 'mdi:identifier', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_device_number', + 'last_changed': , + 'last_updated': , + 'state': 'devicenr_789', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Measurement period minutes', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_measurement_period_minutes', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Heat Meter Power max', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_power_max', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Heat Meter Power max previous year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_power_max_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Flowrate max', + 'icon': 'mdi:water-outline', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flowrate_max', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Flowrate max previous year', + 'icon': 'mdi:water-outline', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flowrate_max_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Heat Meter Return temperature max', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_return_temperature_max', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Heat Meter Return temperature max previous year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_return_temperature_max_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Heat Meter Flow temperature max', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flow_temperature_max', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Heat Meter Flow temperature max previous year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flow_temperature_max_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Operating hours', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_operating_hours', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Flow hours', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flow_hours', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Fault hours', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_fault_hours', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Fault hours previous year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_fault_hours_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Yearly set day', + 'icon': 'mdi:clock-outline', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_yearly_set_day', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Monthly set day', + 'icon': 'mdi:clock-outline', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_monthly_set_day', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Heat Meter Meter date time', + 'icon': 'mdi:clock-outline', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_meter_date_time', + 'last_changed': , + 'last_updated': , + 'state': '2022-05-20T02:41:17+00:00', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Measuring range', + 'icon': 'mdi:water-outline', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_measuring_range', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Settings and firmware', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_settings_and_firmware', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- diff --git a/tests/components/landisgyr_heat_meter/test_sensor.py b/tests/components/landisgyr_heat_meter/test_sensor.py index a37fab65a1..4de58a206e 100644 --- a/tests/components/landisgyr_heat_meter/test_sensor.py +++ b/tests/components/landisgyr_heat_meter/test_sensor.py @@ -4,23 +4,11 @@ import datetime from unittest.mock import patch import serial +from syrupy import SnapshotAssertion from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN from homeassistant.components.landisgyr_heat_meter.const import DOMAIN, POLLING_INTERVAL -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - SensorDeviceClass, - SensorStateClass, -) -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, - ATTR_UNIT_OF_MEASUREMENT, - STATE_UNAVAILABLE, - EntityCategory, - UnitOfEnergy, - UnitOfVolume, -) +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -46,7 +34,10 @@ class MockHeatMeterResponse: @patch(API_HEAT_METER_SERVICE) async def test_create_sensors( - mock_heat_meter, hass: HomeAssistant, entity_registry: er.EntityRegistry + mock_heat_meter, + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test sensor.""" entry_data = { @@ -55,7 +46,6 @@ async def test_create_sensors( "device_number": "123456789", } mock_entry = MockConfigEntry(domain=DOMAIN, unique_id=DOMAIN, data=entry_data) - mock_entry.add_to_hass(hass) mock_heat_meter_response = MockHeatMeterResponse( @@ -72,37 +62,7 @@ async def test_create_sensors( await async_setup_component(hass, HA_DOMAIN, {}) await hass.async_block_till_done() - # check if 26 attributes have been created - assert len(hass.states.async_all()) == 25 - - state = hass.states.get("sensor.heat_meter_heat_usage_gj") - assert state - assert state.state == "123.0" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.GIGA_JOULE - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY - - state = hass.states.get("sensor.heat_meter_volume_usage") - assert state - assert state.state == "456.0" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL - - state = hass.states.get("sensor.heat_meter_device_number") - assert state - assert state.state == "devicenr_789" - assert state.attributes.get(ATTR_STATE_CLASS) is None - entity_registry_entry = entity_registry.async_get("sensor.heat_meter_device_number") - assert entity_registry_entry.entity_category == EntityCategory.DIAGNOSTIC - - state = hass.states.get("sensor.heat_meter_meter_date_time") - assert state - assert state.attributes.get(ATTR_ICON) == "mdi:clock-outline" - assert state.attributes.get(ATTR_STATE_CLASS) is None - entity_registry_entry = entity_registry.async_get( - "sensor.heat_meter_meter_date_time" - ) - assert entity_registry_entry.entity_category == EntityCategory.DIAGNOSTIC + assert hass.states.async_all() == snapshot @patch(API_HEAT_METER_SERVICE) From 2b4514ae25b795926a5d07b2dc2472a3d8415ef5 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 23 Mar 2023 11:53:22 +0100 Subject: [PATCH 1196/2810] Add codeowner Workday (#90167) codeowner --- CODEOWNERS | 4 ++-- homeassistant/components/workday/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index afab5f8885..617fc46c27 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1356,8 +1356,8 @@ build.json @home-assistant/supervisor /tests/components/wled/ @frenck /homeassistant/components/wolflink/ @adamkrol93 /tests/components/wolflink/ @adamkrol93 -/homeassistant/components/workday/ @fabaff -/tests/components/workday/ @fabaff +/homeassistant/components/workday/ @fabaff @gjohansson-ST +/tests/components/workday/ @fabaff @gjohansson-ST /homeassistant/components/worldclock/ @fabaff /tests/components/worldclock/ @fabaff /homeassistant/components/ws66i/ @ssaenger diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 442456066c..4c10141406 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -1,7 +1,7 @@ { "domain": "workday", "name": "Workday", - "codeowners": ["@fabaff"], + "codeowners": ["@fabaff", "@gjohansson-ST"], "documentation": "https://www.home-assistant.io/integrations/workday", "iot_class": "local_polling", "loggers": [ From dd4a3089ec52f97d9dd0b8a4828362395d078124 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 23 Mar 2023 11:54:15 +0100 Subject: [PATCH 1197/2810] Add constants file for Brottsplatskartan (#90165) * bpk constants * not used --- .../components/brottsplatskartan/const.py | 33 +++++++++++++++++++ .../components/brottsplatskartan/sensor.py | 33 ++----------------- 2 files changed, 35 insertions(+), 31 deletions(-) create mode 100644 homeassistant/components/brottsplatskartan/const.py diff --git a/homeassistant/components/brottsplatskartan/const.py b/homeassistant/components/brottsplatskartan/const.py new file mode 100644 index 0000000000..87c42b01f4 --- /dev/null +++ b/homeassistant/components/brottsplatskartan/const.py @@ -0,0 +1,33 @@ +"""Adds constants for brottsplatskartan integration.""" + +import logging + +LOGGER = logging.getLogger(__package__) + +CONF_AREA = "area" +DEFAULT_NAME = "Brottsplatskartan" + +AREAS = [ + "N/A", + "Blekinge län", + "Dalarnas län", + "Gotlands län", + "Gävleborgs län", + "Hallands län", + "Jämtlands län", + "Jönköpings län", + "Kalmar län", + "Kronobergs län", + "Norrbottens län", + "Skåne län", + "Stockholms län", + "Södermanlands län", + "Uppsala län", + "Värmlands län", + "Västerbottens län", + "Västernorrlands län", + "Västmanlands län", + "Västra Götalands län", + "Örebro län", + "Östergötlands län", +] diff --git a/homeassistant/components/brottsplatskartan/sensor.py b/homeassistant/components/brottsplatskartan/sensor.py index d76cb7c8a5..da53a9fc0e 100644 --- a/homeassistant/components/brottsplatskartan/sensor.py +++ b/homeassistant/components/brottsplatskartan/sensor.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections import defaultdict from datetime import timedelta -import logging import uuid import brottsplatskartan @@ -16,38 +15,10 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -_LOGGER = logging.getLogger(__name__) - -CONF_AREA = "area" - -DEFAULT_NAME = "Brottsplatskartan" +from .const import AREAS, CONF_AREA, DEFAULT_NAME, LOGGER SCAN_INTERVAL = timedelta(minutes=30) -AREAS = [ - "Blekinge län", - "Dalarnas län", - "Gotlands län", - "Gävleborgs län", - "Hallands län", - "Jämtlands län", - "Jönköpings län", - "Kalmar län", - "Kronobergs län", - "Norrbottens län", - "Skåne län", - "Stockholms län", - "Södermanlands län", - "Uppsala län", - "Värmlands län", - "Västerbottens län", - "Västernorrlands län", - "Västmanlands län", - "Västra Götalands län", - "Örebro län", - "Östergötlands län", -] - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Inclusive(CONF_LATITUDE, "coordinates"): cv.latitude, @@ -99,7 +70,7 @@ class BrottsplatskartanSensor(SensorEntity): incidents = self._brottsplatskartan.get_incidents() if incidents is False: - _LOGGER.debug("Problems fetching incidents") + LOGGER.debug("Problems fetching incidents") return for incident in incidents: From b1370cbd428ce39a97cb4f66f69030c53be08b25 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 23 Mar 2023 12:01:03 +0100 Subject: [PATCH 1198/2810] Add constants file to workday (#90168) Constants workday --- .../components/workday/binary_sensor.py | 56 +++++++++---------- homeassistant/components/workday/const.py | 25 +++++++++ 2 files changed, 50 insertions(+), 31 deletions(-) create mode 100644 homeassistant/components/workday/const.py diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index e66efa039a..cfd04dd30d 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import date, timedelta -import logging from typing import Any import holidays @@ -13,31 +12,28 @@ from homeassistant.components.binary_sensor import ( PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, BinarySensorEntity, ) -from homeassistant.const import CONF_NAME, WEEKDAYS +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt -_LOGGER = logging.getLogger(__name__) - -ALLOWED_DAYS = WEEKDAYS + ["holiday"] - -CONF_COUNTRY = "country" -CONF_PROVINCE = "province" -CONF_WORKDAYS = "workdays" -CONF_EXCLUDES = "excludes" -CONF_OFFSET = "days_offset" -CONF_ADD_HOLIDAYS = "add_holidays" -CONF_REMOVE_HOLIDAYS = "remove_holidays" - -# By default, Monday - Friday are workdays -DEFAULT_WORKDAYS = ["mon", "tue", "wed", "thu", "fri"] -# By default, public holidays, Saturdays and Sundays are excluded from workdays -DEFAULT_EXCLUDES = ["sat", "sun", "holiday"] -DEFAULT_NAME = "Workday Sensor" -DEFAULT_OFFSET = 0 +from .const import ( + ALLOWED_DAYS, + CONF_ADD_HOLIDAYS, + CONF_COUNTRY, + CONF_EXCLUDES, + CONF_OFFSET, + CONF_PROVINCE, + CONF_REMOVE_HOLIDAYS, + CONF_WORKDAYS, + DEFAULT_EXCLUDES, + DEFAULT_NAME, + DEFAULT_OFFSET, + DEFAULT_WORKDAYS, + LOGGER, +) def valid_country(value: Any) -> str: @@ -106,14 +102,14 @@ def setup_platform( ): obj_holidays = getattr(holidays, country)(subdiv=province, years=year) else: - _LOGGER.error("There is no subdivision %s in country %s", province, country) + LOGGER.error("There is no subdivision %s in country %s", province, country) return # Add custom holidays try: obj_holidays.append(add_holidays) except TypeError: - _LOGGER.debug("No custom holidays or invalid holidays") + LOGGER.debug("No custom holidays or invalid holidays") # Remove holidays try: @@ -123,25 +119,23 @@ def setup_platform( if dt.parse_date(remove_holiday): # remove holiday by date removed = obj_holidays.pop(remove_holiday) - _LOGGER.debug("Removed %s", remove_holiday) + LOGGER.debug("Removed %s", remove_holiday) else: # remove holiday by name - _LOGGER.debug("Treating '%s' as named holiday", remove_holiday) + LOGGER.debug("Treating '%s' as named holiday", remove_holiday) removed = obj_holidays.pop_named(remove_holiday) for holiday in removed: - _LOGGER.debug( - "Removed %s by name '%s'", holiday, remove_holiday - ) + LOGGER.debug("Removed %s by name '%s'", holiday, remove_holiday) except KeyError as unmatched: - _LOGGER.warning("No holiday found matching %s", unmatched) + LOGGER.warning("No holiday found matching %s", unmatched) except TypeError: - _LOGGER.debug("No holidays to remove or invalid holidays") + LOGGER.debug("No holidays to remove or invalid holidays") - _LOGGER.debug("Found the following holidays for your configuration:") + LOGGER.debug("Found the following holidays for your configuration:") for holiday_date, name in sorted(obj_holidays.items()): # Make explicit str variable to avoid "Incompatible types in assignment" _holiday_string = holiday_date.strftime("%Y-%m-%d") - _LOGGER.debug("%s %s", _holiday_string, name) + LOGGER.debug("%s %s", _holiday_string, name) add_entities( [IsWorkdaySensor(obj_holidays, workdays, excludes, days_offset, sensor_name)], diff --git a/homeassistant/components/workday/const.py b/homeassistant/components/workday/const.py new file mode 100644 index 0000000000..9ebf85f1c2 --- /dev/null +++ b/homeassistant/components/workday/const.py @@ -0,0 +1,25 @@ +"""Add constants for Workday integration.""" +from __future__ import annotations + +import logging + +from homeassistant.const import WEEKDAYS + +LOGGER = logging.getLogger(__name__) + +ALLOWED_DAYS = WEEKDAYS + ["holiday"] + +CONF_COUNTRY = "country" +CONF_PROVINCE = "province" +CONF_WORKDAYS = "workdays" +CONF_EXCLUDES = "excludes" +CONF_OFFSET = "days_offset" +CONF_ADD_HOLIDAYS = "add_holidays" +CONF_REMOVE_HOLIDAYS = "remove_holidays" + +# By default, Monday - Friday are workdays +DEFAULT_WORKDAYS = ["mon", "tue", "wed", "thu", "fri"] +# By default, public holidays, Saturdays and Sundays are excluded from workdays +DEFAULT_EXCLUDES = ["sat", "sun", "holiday"] +DEFAULT_NAME = "Workday Sensor" +DEFAULT_OFFSET = 0 From d5f949f4d8a09d9ba31c7859466535e341bf09ee Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 23 Mar 2023 13:42:39 +0100 Subject: [PATCH 1199/2810] Update pydantic to 1.10.7 (#90164) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index e2db9d1e9f..fa16881520 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -14,7 +14,7 @@ freezegun==1.2.2 mock-open==1.4.0 mypy==1.1.1 pre-commit==3.1.0 -pydantic==1.10.6 +pydantic==1.10.7 pylint==2.17.0 pylint-per-file-ignores==1.1.0 pipdeptree==2.5.0 From 504793882d50a1f8b878f0f1cc3c81fc9fe97ad7 Mon Sep 17 00:00:00 2001 From: Nalin Mahajan Date: Thu, 23 Mar 2023 10:09:00 -0500 Subject: [PATCH 1200/2810] Remove unecessary variable in control4 (#90176) Remove unecessary expiration value --- homeassistant/components/control4/__init__.py | 2 -- homeassistant/components/control4/const.py | 1 - homeassistant/components/control4/director_utils.py | 10 +--------- 3 files changed, 1 insertion(+), 12 deletions(-) diff --git a/homeassistant/components/control4/__init__.py b/homeassistant/components/control4/__init__.py index c99af1f89c..de4c8208ee 100644 --- a/homeassistant/components/control4/__init__.py +++ b/homeassistant/components/control4/__init__.py @@ -35,7 +35,6 @@ from .const import ( CONF_DIRECTOR_ALL_ITEMS, CONF_DIRECTOR_MODEL, CONF_DIRECTOR_SW_VERSION, - CONF_DIRECTOR_TOKEN_EXPIRATION, DEFAULT_SCAN_INTERVAL, DOMAIN, ) @@ -79,7 +78,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: config[CONF_HOST], director_token_dict[CONF_TOKEN], director_session ) entry_data[CONF_DIRECTOR] = director - entry_data[CONF_DIRECTOR_TOKEN_EXPIRATION] = director_token_dict["token_expiration"] # Add Control4 controller to device registry controller_href = (await account.getAccountControllers())["href"] diff --git a/homeassistant/components/control4/const.py b/homeassistant/components/control4/const.py index 2759088198..677610a161 100644 --- a/homeassistant/components/control4/const.py +++ b/homeassistant/components/control4/const.py @@ -7,7 +7,6 @@ MIN_SCAN_INTERVAL = 1 CONF_ACCOUNT = "account" CONF_DIRECTOR = "director" -CONF_DIRECTOR_TOKEN_EXPIRATION = "director_token_expiry" CONF_DIRECTOR_SW_VERSION = "director_sw_version" CONF_DIRECTOR_MODEL = "director_model" CONF_DIRECTOR_ALL_ITEMS = "director_all_items" diff --git a/homeassistant/components/control4/director_utils.py b/homeassistant/components/control4/director_utils.py index fc4ca9e358..bab8c8634c 100644 --- a/homeassistant/components/control4/director_utils.py +++ b/homeassistant/components/control4/director_utils.py @@ -10,13 +10,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TOKEN, CONF_USERN from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from .const import ( - CONF_ACCOUNT, - CONF_CONTROLLER_UNIQUE_ID, - CONF_DIRECTOR, - CONF_DIRECTOR_TOKEN_EXPIRATION, - DOMAIN, -) +from .const import CONF_ACCOUNT, CONF_CONTROLLER_UNIQUE_ID, CONF_DIRECTOR, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -53,10 +47,8 @@ async def refresh_tokens(hass: HomeAssistant, entry: ConfigEntry): director = C4Director( config[CONF_HOST], director_token_dict[CONF_TOKEN], director_session ) - director_token_expiry = director_token_dict["token_expiration"] _LOGGER.debug("Saving new tokens in hass data") entry_data = hass.data[DOMAIN][entry.entry_id] entry_data[CONF_ACCOUNT] = account entry_data[CONF_DIRECTOR] = director - entry_data[CONF_DIRECTOR_TOKEN_EXPIRATION] = director_token_expiry From 73ed6e039ae77c6d7e0b19d1750fae2efedc8314 Mon Sep 17 00:00:00 2001 From: PeteRager <76050312+PeteRager@users.noreply.github.com> Date: Thu, 23 Mar 2023 11:15:55 -0400 Subject: [PATCH 1201/2810] Improve logging for unavailable sonos hosts (#90172) * Repeated warning messages on unavailable manually specified hosts Sonos logs warning messages every 1 minute 12 seconds for hosts that are not on-line. This fixes the issue and the warning will be logged the first time, and subsequent logs messages will be at DEBUG level * Update homeassistant/components/sonos/__init__.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Log info message when reconnect succeeds * Use pop to simplify code * Add additional test, fix key error with pop * Use pop with default return value * Update tests/components/sonos/test_init.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/sonos/test_init.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/sonos/test_init.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/sonos/test_init.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/sonos/test_init.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update comment, remove unneeded line of code --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/sonos/__init__.py | 16 ++++- tests/components/sonos/test_init.py | 71 +++++++++++++++++++++- 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index e181e995c7..4b68030f84 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -177,6 +177,7 @@ class SonosDiscoveryManager: self.entry = entry self.data = data self.hosts = set(hosts) + self.hosts_in_error: dict[str, bool] = {} self.discovery_lock = asyncio.Lock() self.creation_lock = asyncio.Lock() self._known_invisible: set[SoCo] = set() @@ -353,10 +354,19 @@ class SonosDiscoveryManager: soco, ) except (OSError, SoCoException, Timeout) as ex: - _LOGGER.warning( - "Could not get visible Sonos devices from %s: %s", ip_addr, ex - ) + if not self.hosts_in_error.get(ip_addr): + _LOGGER.warning( + "Could not get visible Sonos devices from %s: %s", ip_addr, ex + ) + self.hosts_in_error[ip_addr] = True + else: + _LOGGER.debug( + "Could not get visible Sonos devices from %s: %s", ip_addr, ex + ) + else: + if self.hosts_in_error.pop(ip_addr, None): + _LOGGER.info("Connection restablished to Sonos device %s", ip_addr) if new_hosts := { x.ip_address for x in visible_zones diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index fc063991e6..6cc79e1b2f 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -1,8 +1,13 @@ """Tests for the Sonos config flow.""" -from unittest.mock import patch +import logging +from unittest.mock import AsyncMock, patch + +import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components import sonos, zeroconf +from homeassistant.components.sonos import SonosDiscoveryManager +from homeassistant.components.sonos.const import DATA_SONOS_DISCOVERY_MANAGER from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -63,3 +68,67 @@ async def test_not_configuring_sonos_not_creates_entry(hass: HomeAssistant) -> N await hass.async_block_till_done() assert len(mock_setup.mock_calls) == 0 + + +async def test_async_poll_manual_hosts_warnings( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test that host warnings are not logged repeatedly.""" + await async_setup_component( + hass, + sonos.DOMAIN, + {"sonos": {"media_player": {"interface_addr": "127.0.0.1"}}}, + ) + await hass.async_block_till_done() + manager: SonosDiscoveryManager = hass.data[DATA_SONOS_DISCOVERY_MANAGER] + manager.hosts.add("10.10.10.10") + with caplog.at_level(logging.DEBUG), patch.object( + manager, "_async_handle_discovery_message" + ), patch("homeassistant.components.sonos.async_call_later"), patch( + "homeassistant.components.sonos.async_dispatcher_send" + ), patch.object( + hass, "async_add_executor_job", new=AsyncMock() + ) as mock_async_add_executor_job: + mock_async_add_executor_job.side_effect = [ + OSError(), + OSError(), + [], + [], + OSError(), + ] + # First call fails, it should be logged as a WARNING message + caplog.clear() + await manager.async_poll_manual_hosts() + assert len(caplog.messages) == 1 + record = caplog.records[0] + assert record.levelname == "WARNING" + assert "Could not get visible Sonos devices from" in record.message + + # Second call fails again, it should be logged as a DEBUG message + caplog.clear() + await manager.async_poll_manual_hosts() + assert len(caplog.messages) == 1 + record = caplog.records[0] + assert record.levelname == "DEBUG" + assert "Could not get visible Sonos devices from" in record.message + + # Third call succeeds, it should log an info message + caplog.clear() + await manager.async_poll_manual_hosts() + assert len(caplog.messages) == 1 + record = caplog.records[0] + assert record.levelname == "INFO" + assert "Connection restablished to Sonos device" in record.message + + # Fourth call succeeds again, no need to log + caplog.clear() + await manager.async_poll_manual_hosts() + assert len(caplog.messages) == 0 + + # Fifth call fail again again, should be logged as a WARNING message + caplog.clear() + await manager.async_poll_manual_hosts() + assert len(caplog.messages) == 1 + record = caplog.records[0] + assert record.levelname == "WARNING" + assert "Could not get visible Sonos devices from" in record.message From cb578c71e03e0187b5fc6a516e5d9ec11421a8e1 Mon Sep 17 00:00:00 2001 From: Vincent Knoop Pathuis <48653141+vpathuis@users.noreply.github.com> Date: Thu, 23 Mar 2023 17:21:21 +0100 Subject: [PATCH 1202/2810] Add Landis+Gyr missing device class (#90182) Add missing device class for heat_previous_year_gj --- homeassistant/components/landisgyr_heat_meter/sensor.py | 1 + tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr | 1 + 2 files changed, 2 insertions(+) diff --git a/homeassistant/components/landisgyr_heat_meter/sensor.py b/homeassistant/components/landisgyr_heat_meter/sensor.py index af96629742..244515a07d 100644 --- a/homeassistant/components/landisgyr_heat_meter/sensor.py +++ b/homeassistant/components/landisgyr_heat_meter/sensor.py @@ -77,6 +77,7 @@ HEAT_METER_SENSOR_TYPES = ( icon="mdi:fire", name="Heat previous year GJ", native_unit_of_measurement=UnitOfEnergy.GIGA_JOULE, + device_class=SensorDeviceClass.ENERGY, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda res: getattr(res, "heat_previous_year_gj", None), ), diff --git a/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr b/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr index e149073d9c..9c62ca3f94 100644 --- a/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr +++ b/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr @@ -31,6 +31,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'energy', 'friendly_name': 'Heat Meter Heat previous year GJ', 'icon': 'mdi:fire', 'unit_of_measurement': , From e290febb384d7757f96febdf285bbdc817e255ab Mon Sep 17 00:00:00 2001 From: Nalin Mahajan Date: Thu, 23 Mar 2023 11:34:38 -0500 Subject: [PATCH 1203/2810] Bump pyControl4 to 1.1.0 (#90115) * Bump pyControl4 to 1.1.0 * Remove mock token_expiration from control4 --- homeassistant/components/control4/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/control4/test_config_flow.py | 6 +----- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/control4/manifest.json b/homeassistant/components/control4/manifest.json index 125e3c2e38..765f0dce78 100644 --- a/homeassistant/components/control4/manifest.json +++ b/homeassistant/components/control4/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/control4", "iot_class": "local_polling", "loggers": ["pyControl4"], - "requirements": ["pyControl4==0.0.6"], + "requirements": ["pyControl4==1.1.0"], "ssdp": [ { "st": "c4:director" diff --git a/requirements_all.txt b/requirements_all.txt index 34e4707607..7d1670833f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1457,7 +1457,7 @@ py17track==2021.12.2 pyCEC==0.5.2 # homeassistant.components.control4 -pyControl4==0.0.6 +pyControl4==1.1.0 # homeassistant.components.met_eireann pyMetEireann==2021.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fbc282040f..3bb56a1be4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1066,7 +1066,7 @@ py17track==2021.12.2 pyCEC==0.5.2 # homeassistant.components.control4 -pyControl4==0.0.6 +pyControl4==1.1.0 # homeassistant.components.met_eireann pyMetEireann==2021.8.0 diff --git a/tests/components/control4/test_config_flow.py b/tests/components/control4/test_config_flow.py index a40047eccd..4909ead6c4 100644 --- a/tests/components/control4/test_config_flow.py +++ b/tests/components/control4/test_config_flow.py @@ -1,5 +1,4 @@ """Test the Control4 config flow.""" -import datetime from unittest.mock import AsyncMock, patch from pyControl4.account import C4Account @@ -25,10 +24,7 @@ def _get_mock_c4_account( "href": "https://apis.control4.com/account/v3/rest/accounts/000000", "name": "Name", }, - getDirectorBearerToken={ - "token": "token", - "token_expiration": datetime.datetime(2020, 7, 15, 13, 50, 15, 26940), - }, + getDirectorBearerToken={"token": "token"}, ): c4_account_mock = AsyncMock(C4Account) From 87475e8ff6e4b5cb9ad01bf5bdc59115ad0772b3 Mon Sep 17 00:00:00 2001 From: Vincent Knoop Pathuis <48653141+vpathuis@users.noreply.github.com> Date: Thu, 23 Mar 2023 17:34:57 +0100 Subject: [PATCH 1204/2810] Cleanup some leftovers for Landis+Gyr (#90183) Cleanup some leftovers --- homeassistant/components/landisgyr_heat_meter/const.py | 1 - homeassistant/components/landisgyr_heat_meter/manifest.json | 5 +---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/landisgyr_heat_meter/const.py b/homeassistant/components/landisgyr_heat_meter/const.py index 56f5980a83..079bcad253 100644 --- a/homeassistant/components/landisgyr_heat_meter/const.py +++ b/homeassistant/components/landisgyr_heat_meter/const.py @@ -4,6 +4,5 @@ from datetime import timedelta DOMAIN = "landisgyr_heat_meter" -GJ_TO_MWH = 0.277778 # conversion factor ULTRAHEAT_TIMEOUT = 30 # reading the IR port can take some time POLLING_INTERVAL = timedelta(days=1) # Polling is only daily to prevent battery drain. diff --git a/homeassistant/components/landisgyr_heat_meter/manifest.json b/homeassistant/components/landisgyr_heat_meter/manifest.json index 5e10f39418..a056f1f656 100644 --- a/homeassistant/components/landisgyr_heat_meter/manifest.json +++ b/homeassistant/components/landisgyr_heat_meter/manifest.json @@ -5,9 +5,6 @@ "config_flow": true, "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/landisgyr_heat_meter", - "homekit": {}, "iot_class": "local_polling", - "requirements": ["ultraheat-api==0.5.1"], - "ssdp": [], - "zeroconf": [] + "requirements": ["ultraheat-api==0.5.1"] } From 8fd88d6703ce2d6f9b4235e2fd9015774abc2ca4 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 23 Mar 2023 19:13:36 +0100 Subject: [PATCH 1205/2810] Prepare MQTT platform tests part2 (#90105) * Tests button * Tests camera * Tests climate --- tests/components/mqtt/test_button.py | 88 +-- tests/components/mqtt/test_camera.py | 81 ++- tests/components/mqtt/test_climate.py | 902 ++++++++++++++++---------- 3 files changed, 650 insertions(+), 421 deletions(-) diff --git a/tests/components/mqtt/test_button.py b/tests/components/mqtt/test_button.py index cdb3d0fbf3..37636ff4bf 100644 --- a/tests/components/mqtt/test_button.py +++ b/tests/components/mqtt/test_button.py @@ -13,7 +13,6 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -57,13 +56,9 @@ def button_platform_only(): @pytest.mark.freeze_time("2021-11-08 13:31:44+00:00") -async def test_sending_mqtt_commands( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the sending MQTT commands.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { button.DOMAIN: { @@ -74,10 +69,14 @@ async def test_sending_mqtt_commands( "qos": "2", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending MQTT commands.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("button.test_button") assert state.state == STATE_UNKNOWN @@ -98,13 +97,9 @@ async def test_sending_mqtt_commands( assert state.state == "2021-11-08T13:31:44+00:00" -async def test_command_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the sending of MQTT commands through a command template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { button.DOMAIN: { @@ -114,10 +109,14 @@ async def test_command_template( "payload_press": "milky_way_press", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_command_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending of MQTT commands through a command template.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("button.test") assert state.state == STATE_UNKNOWN @@ -436,11 +435,9 @@ async def test_entity_debug_info_message( ) -async def test_invalid_device_class(hass: HomeAssistant) -> None: - """Test device_class option with invalid value.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { button.DOMAIN: { @@ -449,17 +446,20 @@ async def test_invalid_device_class(hass: HomeAssistant) -> None: "device_class": "foobarnotreal", } } - }, - ) - - -async def test_valid_device_class( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + } + ], +) +async def test_invalid_device_class( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: - """Test device_class option with valid values.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, + """Test device_class option with invalid value.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() + + +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { button.DOMAIN: [ @@ -479,10 +479,14 @@ async def test_valid_device_class( }, ] } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_valid_device_class( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test device_class option with valid values.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("button.test_1") assert state.attributes["device_class"] == button.ButtonDeviceClass.UPDATE diff --git a/tests/components/mqtt/test_camera.py b/tests/components/mqtt/test_camera.py index 90020bce48..27d575ce4e 100644 --- a/tests/components/mqtt/test_camera.py +++ b/tests/components/mqtt/test_camera.py @@ -10,7 +10,6 @@ from homeassistant.components import camera, mqtt from homeassistant.components.mqtt.camera import MQTT_CAMERA_ATTRIBUTES_BLOCKED from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -56,20 +55,18 @@ def camera_platform_only(): yield +@pytest.mark.parametrize( + "hass_config", + [{mqtt.DOMAIN: {camera.DOMAIN: {"topic": "test/camera", "name": "Test Camera"}}}], +) async def test_run_camera_setup( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test that it fetches the given payload.""" topic = "test/camera" - await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {camera.DOMAIN: {"topic": topic, "name": "Test Camera"}}}, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() url = hass.states.get("camera.test_camera").attributes["entity_picture"] @@ -82,28 +79,28 @@ async def test_run_camera_setup( assert body == "beer" -async def test_run_camera_b64_encoded( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, -) -> None: - """Test that it fetches the given encoded payload.""" - topic = "test/camera" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { camera.DOMAIN: { - "topic": topic, + "topic": "test/camera", "name": "Test Camera", "image_encoding": "b64", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_run_camera_b64_encoded( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test that it fetches the given encoded payload.""" + topic = "test/camera" + await mqtt_mock_entry_no_yaml_config() url = hass.states.get("camera.test_camera").attributes["entity_picture"] @@ -116,31 +113,31 @@ async def test_run_camera_b64_encoded( assert body == "grass" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + "camera": { + "topic": "test/camera", + "name": "Test Camera", + "encoding": "utf-8", + "image_encoding": "b64", + "availability": {"topic": "test/camera_availability"}, + } + } + } + ], +) async def test_camera_b64_encoded_with_availability( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test availability works if b64 encoding is turned on.""" topic = "test/camera" topic_availability = "test/camera_availability" - await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - "camera": { - "topic": topic, - "name": "Test Camera", - "encoding": "utf-8", - "image_encoding": "b64", - "availability": {"topic": topic_availability}, - } - } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() # Make sure we are available async_fire_mqtt_message(hass, topic_availability, "online") diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 9a9a9d81e8..36894ba2cd 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -30,9 +30,9 @@ from homeassistant.components.climate import ( from homeassistant.components.mqtt.climate import MQTT_CLIMATE_ATTRIBUTES_BLOCKED from homeassistant.const import ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -102,13 +102,12 @@ def climate_platform_only(): yield +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_setup_params( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the initial parameters.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("temperature") == 21 @@ -121,49 +120,101 @@ async def test_setup_params( assert state.attributes.get("max_humidity") == DEFAULT_MAX_HUMIDITY +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"preset_modes": ["auto", "home", "none"]},), + ) + ], +) async def test_preset_none_in_preset_modes( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test the preset mode payload reset configuration.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][climate.DOMAIN]) - config["preset_modes"].append("none") - assert not await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {climate.DOMAIN: config}} - ) + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert "Invalid config for [mqtt]: not a valid value" in caplog.text @pytest.mark.parametrize( - ("parameter", "config_value"), + ("hass_config", "parameter"), [ - ("away_mode_command_topic", "away-mode-command-topic"), - ("away_mode_state_topic", "away-mode-state-topic"), - ("away_mode_state_template", "{{ value_json }}"), - ("hold_mode_command_topic", "hold-mode-command-topic"), - ("hold_mode_command_template", "hold-mode-command-template"), - ("hold_mode_state_topic", "hold-mode-state-topic"), - ("hold_mode_state_template", "{{ value_json }}"), + ( + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"away_mode_command_topic": "away-mode-command-topic"},), + ), + "away_mode_command_topic", + ), + ( + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"away_mode_state_topic": "away-mode-state-topic"},), + ), + "away_mode_state_topic", + ), + ( + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"away_mode_state_template": "{{ value_json }}"},), + ), + "away_mode_state_template", + ), + ( + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"hold_mode_command_topic": "hold-mode-command-topic"},), + ), + "hold_mode_command_topic", + ), + ( + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"hold_mode_command_template": "hold-mode-command-template"},), + ), + "hold_mode_command_template", + ), + ( + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"hold_mode_state_topic": "hold-mode-state-topic"},), + ), + "hold_mode_state_topic", + ), + ( + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"hold_mode_state_template": "{{ value_json }}"},), + ), + "hold_mode_state_template", + ), ], ) async def test_preset_modes_deprecation_guard( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, parameter, config_value + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, parameter: str ) -> None: """Test the configuration for invalid legacy parameters.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][climate.DOMAIN]) - config[parameter] = config_value - assert not await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {climate.DOMAIN: config}} - ) assert f"[{parameter}] is an invalid option for [mqtt]. Check: mqtt->mqtt->climate->0->{parameter}" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_supported_features( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the supported_features.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) support = ( @@ -179,13 +230,12 @@ async def test_supported_features( assert state.attributes.get("supported_features") == support +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_get_hvac_modes( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that the operation list returns the correct modes.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) modes = state.attributes.get("hvac_modes") @@ -199,18 +249,17 @@ async def test_get_hvac_modes( ] == modes +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_operation_bad_attr_and_state( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test setting operation mode without required attribute. Also check the state. """ - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.state == "off" @@ -224,13 +273,12 @@ async def test_set_operation_bad_attr_and_state( assert state.state == "off" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_operation( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting of new operation mode.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.state == "off" @@ -241,15 +289,20 @@ async def test_set_operation( mqtt_mock.async_publish.assert_called_once_with("mode-topic", "cool", 0, False) +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, DEFAULT_CONFIG, ({"mode_state_topic": "mode-state"},) + ) + ], +) async def test_set_operation_pessimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting operation mode in pessimistic mode.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["mode_state_topic"] = "mode-state" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.state == "unknown" @@ -267,16 +320,21 @@ async def test_set_operation_pessimistic( assert state.state == "cool" +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"mode_state_topic": "mode-state", "optimistic": True},), + ) + ], +) async def test_set_operation_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting operation mode in optimistic mode.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["mode_state_topic"] = "mode-state" - config["climate"]["optimistic"] = True - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.state == "off" @@ -297,15 +355,19 @@ async def test_set_operation_optimistic( # CONF_POWER_COMMAND_TOPIC, CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE are deprecated, # support for CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE was already removed or never added # support was deprecated with release 2023.2 and will be removed with release 2023.8 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, DEFAULT_CONFIG, ({"power_command_topic": "power-command"},) + ) + ], +) async def test_set_operation_with_power_command( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting of new operation mode with power command enabled.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["power_command_topic"] = "power-command" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.state == "off" @@ -326,15 +388,14 @@ async def test_set_operation_with_power_command( mqtt_mock.async_publish.reset_mock() +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_fan_mode_bad_attr( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test setting fan mode without required attribute.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("fan_mode") == "low" @@ -347,15 +408,19 @@ async def test_set_fan_mode_bad_attr( assert state.attributes.get("fan_mode") == "low" +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, DEFAULT_CONFIG, ({"fan_mode_state_topic": "fan-state"},) + ) + ], +) async def test_set_fan_mode_pessimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting of new fan mode in pessimistic mode.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["fan_mode_state_topic"] = "fan-state" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("fan_mode") is None @@ -373,16 +438,21 @@ async def test_set_fan_mode_pessimistic( assert state.attributes.get("fan_mode") == "high" +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"fan_mode_state_topic": "fan-state", "optimistic": True},), + ) + ], +) async def test_set_fan_mode_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting of new fan mode in optimistic mode.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["fan_mode_state_topic"] = "fan-state" - config["climate"]["optimistic"] = True - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("fan_mode") == "low" @@ -400,13 +470,12 @@ async def test_set_fan_mode_optimistic( assert state.attributes.get("fan_mode") == "low" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_fan_mode( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting of new fan mode.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("fan_mode") == "low" @@ -416,15 +485,14 @@ async def test_set_fan_mode( assert state.attributes.get("fan_mode") == "high" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_swing_mode_bad_attr( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test setting swing mode without required attribute.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "off" @@ -437,15 +505,19 @@ async def test_set_swing_mode_bad_attr( assert state.attributes.get("swing_mode") == "off" +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, DEFAULT_CONFIG, ({"swing_mode_state_topic": "swing-state"},) + ) + ], +) async def test_set_swing_pessimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting swing mode in pessimistic mode.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["swing_mode_state_topic"] = "swing-state" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") is None @@ -463,16 +535,21 @@ async def test_set_swing_pessimistic( assert state.attributes.get("swing_mode") == "on" +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"swing_mode_state_topic": "swing-state", "optimistic": True},), + ) + ], +) async def test_set_swing_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting swing mode in optimistic mode.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["swing_mode_state_topic"] = "swing-state" - config["climate"]["optimistic"] = True - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "off" @@ -490,13 +567,12 @@ async def test_set_swing_optimistic( assert state.attributes.get("swing_mode") == "off" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_swing( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting of new swing mode.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "off" @@ -506,13 +582,12 @@ async def test_set_swing( assert state.attributes.get("swing_mode") == "on" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_target_temperature( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting the target temperature.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("temperature") == 21 @@ -545,13 +620,12 @@ async def test_set_target_temperature( mqtt_mock.async_publish.reset_mock() +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_target_humidity( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting the target humidity.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("humidity") is None @@ -562,15 +636,21 @@ async def test_set_target_humidity( mqtt_mock.async_publish.reset_mock() +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"temperature_state_topic": "temperature-state"},), + ) + ], +) async def test_set_target_temperature_pessimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting the target temperature.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["temperature_state_topic"] = "temperature-state" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("temperature") is None @@ -588,16 +668,21 @@ async def test_set_target_temperature_pessimistic( assert state.attributes.get("temperature") == 1701 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"temperature_state_topic": "temperature-state", "optimistic": True},), + ) + ], +) async def test_set_target_temperature_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting the target temperature optimistic.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["temperature_state_topic"] = "temperature-state" - config["climate"]["optimistic"] = True - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("temperature") == 21 @@ -615,13 +700,12 @@ async def test_set_target_temperature_optimistic( assert state.attributes.get("temperature") == 18 +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_target_temperature_low_high( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting the low/high target temperature.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await common.async_set_temperature( hass, target_temp_low=20, target_temp_high=23, entity_id=ENTITY_CLIMATE @@ -633,16 +717,26 @@ async def test_set_target_temperature_low_high( mqtt_mock.async_publish.assert_any_call("temperature-high-topic", "23.0", 0, False) +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ( + { + "temperature_low_state_topic": "temperature-low-state", + "temperature_high_state_topic": "temperature-high-state", + }, + ), + ) + ], +) async def test_set_target_temperature_low_highpessimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting the low/high target temperature.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["temperature_low_state_topic"] = "temperature-low-state" - config["climate"]["temperature_high_state_topic"] = "temperature-high-state" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("target_temp_low") is None @@ -673,17 +767,27 @@ async def test_set_target_temperature_low_highpessimistic( assert state.attributes.get("target_temp_high") == 1703 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ( + { + "temperature_low_state_topic": "temperature-low-state", + "temperature_high_state_topic": "temperature-high-state", + "optimistic": True, + }, + ), + ) + ], +) async def test_set_target_temperature_low_high_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting the low/high target temperature optimistic.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["optimistic"] = True - config["climate"]["temperature_low_state_topic"] = "temperature-low-state" - config["climate"]["temperature_high_state_topic"] = "temperature-high-state" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("target_temp_low") == 21 @@ -714,16 +818,21 @@ async def test_set_target_temperature_low_high_optimistic( assert state.attributes.get("target_temp_high") == 25 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"target_humidity_state_topic": "humidity-state", "optimistic": True},), + ) + ], +) async def test_set_target_humidity_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting the target humidity optimistic.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["target_humidity_state_topic"] = "humidity-state" - config["climate"]["optimistic"] = True - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("humidity") is None @@ -740,15 +849,21 @@ async def test_set_target_humidity_optimistic( assert state.attributes.get("humidity") == 53 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"target_humidity_state_topic": "humidity-state"},), + ) + ], +) async def test_set_target_humidity_pessimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting the target humidity.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["target_humidity_state_topic"] = "humidity-state" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("humidity") is None @@ -765,45 +880,63 @@ async def test_set_target_humidity_pessimistic( assert state.attributes.get("humidity") == 80 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"current_temperature_topic": "current_temperature"},), + ) + ], +) async def test_receive_mqtt_temperature( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test getting the current temperature via MQTT.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["current_temperature_topic"] = "current_temperature" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "current_temperature", "47") state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("current_temperature") == 47 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"current_humidity_topic": "current_humidity"},), + ) + ], +) async def test_receive_mqtt_humidity( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test getting the current humidity via MQTT.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["current_humidity_topic"] = "current_humidity" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "current_humidity", "35") state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("current_humidity") == 35 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"target_humidity_state_topic": "humidity-state"},), + ) + ], +) async def test_handle_target_humidity_received( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting the target humidity via MQTT.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["target_humidity_state_topic"] = "humidity-state" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("humidity") is None @@ -814,15 +947,15 @@ async def test_handle_target_humidity_received( assert state.attributes.get("humidity") == 65 +@pytest.mark.parametrize( + "hass_config", + [help_custom_config(climate.DOMAIN, DEFAULT_CONFIG, ({"action_topic": "action"},))], +) async def test_handle_action_received( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test getting the action received via MQTT.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["action_topic"] = "action" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() # Cycle through valid modes and also check for wrong input such as "None" (str(None)) async_fire_mqtt_message(hass, "action", "None") @@ -839,16 +972,14 @@ async def test_handle_action_received( assert hvac_action == action +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_preset_mode_optimistic( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test setting of the preset mode.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "none" @@ -889,18 +1020,23 @@ async def test_set_preset_mode_optimistic( assert "'invalid' is not a valid preset mode" in caplog.text +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"preset_mode_state_topic": "preset-mode-state", "optimistic": True},), + ) + ], +) async def test_set_preset_mode_explicit_optimistic( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test setting of the preset mode.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["optimistic"] = True - config["climate"]["preset_mode_state_topic"] = "preset-mode-state" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "none" @@ -941,17 +1077,23 @@ async def test_set_preset_mode_explicit_optimistic( assert "'invalid' is not a valid preset mode" in caplog.text +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"preset_mode_state_topic": "preset-mode-state"},), + ) + ], +) async def test_set_preset_mode_pessimistic( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test setting of the preset mode.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["preset_mode_state_topic"] = "preset-mode-state" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "none" @@ -990,15 +1132,19 @@ async def test_set_preset_mode_pessimistic( assert state.attributes.get("preset_mode") == "home" +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, DEFAULT_CONFIG, ({"aux_state_topic": "aux-state"},) + ) + ], +) async def test_set_aux_pessimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting of the aux heating in pessimistic mode.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["aux_state_topic"] = "aux-state" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("aux_heat") == "off" @@ -1020,13 +1166,12 @@ async def test_set_aux_pessimistic( assert state.attributes.get("aux_heat") == "off" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_aux( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting of the aux heating.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("aux_heat") == "off" @@ -1080,21 +1225,30 @@ async def test_custom_availability_payload( ) +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ( + { + "temperature_low_state_topic": "temperature-state", + "temperature_high_state_topic": "temperature-state", + "temperature_low_state_template": "{{ value_json.temp_low }}", + "temperature_high_state_template": "{{ value_json.temp_high }}", + }, + ), + ) + ], +) async def test_get_target_temperature_low_high_with_templates( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test getting temperature high/low with templates.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["temperature_low_state_topic"] = "temperature-state" - config["climate"]["temperature_high_state_topic"] = "temperature-state" - config["climate"]["temperature_low_state_template"] = "{{ value_json.temp_low }}" - config["climate"]["temperature_high_state_template"] = "{{ value_json.temp_high }}" - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) @@ -1148,34 +1302,60 @@ async def test_get_target_temperature_low_high_with_templates( assert "Could not parse temperature_high_state_template from" not in caplog.text +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + climate.DOMAIN: { + "name": "test", + "mode_command_topic": "mode-topic", + "target_humidity_command_topic": "humidity-topic", + "temperature_command_topic": "temperature-topic", + "temperature_low_command_topic": "temperature-low-topic", + "temperature_high_command_topic": "temperature-high-topic", + "fan_mode_command_topic": "fan-mode-topic", + "swing_mode_command_topic": "swing-mode-topic", + "aux_command_topic": "aux-topic", + "preset_mode_command_topic": "preset-mode-topic", + "preset_modes": [ + "eco", + "away", + "boost", + "comfort", + "home", + "sleep", + "activity", + ], + # By default, just unquote the JSON-strings + "value_template": "{{ value_json }}", + "action_template": "{{ value_json }}", + # Rendering to a bool for aux heat + "aux_state_template": "{{ value == 'switchmeon' }}", + # Rendering preset_mode + "preset_mode_value_template": "{{ value_json.attribute }}", + "action_topic": "action", + "mode_state_topic": "mode-state", + "fan_mode_state_topic": "fan-state", + "swing_mode_state_topic": "swing-state", + "temperature_state_topic": "temperature-state", + "target_humidity_state_topic": "humidity-state", + "aux_state_topic": "aux-state", + "current_temperature_topic": "current-temperature", + "current_humidity_topic": "current-humidity", + "preset_mode_state_topic": "current-preset-mode", + } + } + } + ], +) async def test_get_with_templates( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test getting various attributes with templates.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - # By default, just unquote the JSON-strings - config["climate"]["value_template"] = "{{ value_json }}" - config["climate"]["action_template"] = "{{ value_json }}" - # Rendering to a bool for aux heat - config["climate"]["aux_state_template"] = "{{ value == 'switchmeon' }}" - # Rendering preset_mode - config["climate"]["preset_mode_value_template"] = "{{ value_json.attribute }}" - - config["climate"]["action_topic"] = "action" - config["climate"]["mode_state_topic"] = "mode-state" - config["climate"]["fan_mode_state_topic"] = "fan-state" - config["climate"]["swing_mode_state_topic"] = "swing-state" - config["climate"]["temperature_state_topic"] = "temperature-state" - config["climate"]["target_humidity_state_topic"] = "humidity-state" - config["climate"]["aux_state_topic"] = "aux-state" - config["climate"]["current_temperature_topic"] = "current-temperature" - config["climate"]["current_humidity_topic"] = "current-humidity" - config["climate"]["preset_mode_state_topic"] = "current-preset-mode" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() # Operation Mode state = hass.states.get(ENTITY_CLIMATE) @@ -1286,26 +1466,52 @@ async def test_get_with_templates( assert state.attributes.get("hvac_action") == "cooling" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + climate.DOMAIN: { + "name": "test", + "mode_command_topic": "mode-topic", + "target_humidity_command_topic": "humidity-topic", + "temperature_command_topic": "temperature-topic", + "temperature_low_command_topic": "temperature-low-topic", + "temperature_high_command_topic": "temperature-high-topic", + "fan_mode_command_topic": "fan-mode-topic", + "swing_mode_command_topic": "swing-mode-topic", + "aux_command_topic": "aux-topic", + "preset_mode_command_topic": "preset-mode-topic", + "preset_modes": [ + "eco", + "away", + "boost", + "comfort", + "home", + "sleep", + "activity", + ], + # Create simple templates + "fan_mode_command_template": "fan_mode: {{ value }}", + "preset_mode_command_template": "preset_mode: {{ value }}", + "mode_command_template": "mode: {{ value }}", + "swing_mode_command_template": "swing_mode: {{ value }}", + "temperature_command_template": "temp: {{ value }}", + "temperature_high_command_template": "temp_hi: {{ value }}", + "temperature_low_command_template": "temp_lo: {{ value }}", + "target_humidity_command_template": "humidity: {{ value }}", + } + } + } + ], +) async def test_set_and_templates( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test setting various attributes with templates.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - # Create simple templates - config["climate"]["fan_mode_command_template"] = "fan_mode: {{ value }}" - config["climate"]["preset_mode_command_template"] = "preset_mode: {{ value }}" - config["climate"]["mode_command_template"] = "mode: {{ value }}" - config["climate"]["swing_mode_command_template"] = "swing_mode: {{ value }}" - config["climate"]["temperature_command_template"] = "temp: {{ value }}" - config["climate"]["temperature_high_command_template"] = "temp_hi: {{ value }}" - config["climate"]["temperature_low_command_template"] = "temp_lo: {{ value }}" - config["climate"]["target_humidity_command_template"] = "humidity: {{ value }}" - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() # Fan Mode await common.async_set_fan_mode(hass, "high", ENTITY_CLIMATE) @@ -1378,16 +1584,15 @@ async def test_set_and_templates( assert state.attributes.get("humidity") == 82 +@pytest.mark.parametrize( + "hass_config", + [help_custom_config(climate.DOMAIN, DEFAULT_CONFIG, ({"min_temp": 26},))], +) async def test_min_temp_custom( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test a custom min temp.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["min_temp"] = 26 - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) min_temp = state.attributes.get("min_temp") @@ -1396,16 +1601,15 @@ async def test_min_temp_custom( assert state.attributes.get("min_temp") == 26 +@pytest.mark.parametrize( + "hass_config", + [help_custom_config(climate.DOMAIN, DEFAULT_CONFIG, ({"max_temp": 60},))], +) async def test_max_temp_custom( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test a custom max temp.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["max_temp"] = 60 - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) max_temp = state.attributes.get("max_temp") @@ -1414,16 +1618,15 @@ async def test_max_temp_custom( assert max_temp == 60 +@pytest.mark.parametrize( + "hass_config", + [help_custom_config(climate.DOMAIN, DEFAULT_CONFIG, ({"min_humidity": 42},))], +) async def test_min_humidity_custom( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test a custom min humidity.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["min_humidity"] = 42 - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) min_humidity = state.attributes.get("min_humidity") @@ -1432,16 +1635,15 @@ async def test_min_humidity_custom( assert state.attributes.get("min_humidity") == 42 +@pytest.mark.parametrize( + "hass_config", + [help_custom_config(climate.DOMAIN, DEFAULT_CONFIG, ({"max_humidity": 58},))], +) async def test_max_humidity_custom( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test a custom max humidity.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["max_humidity"] = 58 - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) max_humidity = state.attributes.get("max_humidity") @@ -1450,16 +1652,15 @@ async def test_max_humidity_custom( assert max_humidity == 58 +@pytest.mark.parametrize( + "hass_config", + [help_custom_config(climate.DOMAIN, DEFAULT_CONFIG, ({"temp_step": 0.01},))], +) async def test_temp_step_custom( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test a custom temp step.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["temp_step"] = 0.01 - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) temp_step = state.attributes.get("target_temp_step") @@ -1468,17 +1669,26 @@ async def test_temp_step_custom( assert temp_step == 0.01 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ( + { + "temperature_unit": "F", + "current_temperature_topic": "current_temperature", + }, + ), + ) + ], +) async def test_temperature_unit( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that setting temperature unit converts temperature values.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["temperature_unit"] = "F" - config["climate"]["current_temperature_topic"] = "current_temperature" - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "current_temperature", "77") @@ -1783,13 +1993,12 @@ async def test_entity_debug_info_message( ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_precision_default( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that setting precision to tenths works as intended.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await common.async_set_temperature( hass, temperature=23.67, entity_id=ENTITY_CLIMATE @@ -1799,15 +2008,15 @@ async def test_precision_default( mqtt_mock.async_publish.reset_mock() +@pytest.mark.parametrize( + "hass_config", + [help_custom_config(climate.DOMAIN, DEFAULT_CONFIG, ({"precision": 0.5},))], +) async def test_precision_halves( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that setting precision to halves works as intended.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["precision"] = 0.5 - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await common.async_set_temperature( hass, temperature=23.67, entity_id=ENTITY_CLIMATE @@ -1817,15 +2026,15 @@ async def test_precision_halves( mqtt_mock.async_publish.reset_mock() +@pytest.mark.parametrize( + "hass_config", + [help_custom_config(climate.DOMAIN, DEFAULT_CONFIG, ({"precision": 1.0},))], +) async def test_precision_whole( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that setting precision to whole works as intended.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["precision"] = 1.0 - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await common.async_set_temperature( hass, temperature=23.67, entity_id=ENTITY_CLIMATE @@ -1950,61 +2159,80 @@ async def test_publishing_with_custom_encoding( @pytest.mark.parametrize( - ("config", "valid"), + ("hass_config", "valid"), [ ( { - "name": "test_valid_humidity_min_max", - "min_humidity": 20, - "max_humidity": 80, + mqtt.DOMAIN: { + climate.DOMAIN: { + "name": "test_valid_humidity_min_max", + "min_humidity": 20, + "max_humidity": 80, + }, + } }, True, ), ( { - "name": "test_invalid_humidity_min_max_1", - "min_humidity": 0, - "max_humidity": 101, + mqtt.DOMAIN: { + climate.DOMAIN: { + "name": "test_invalid_humidity_min_max_1", + "min_humidity": 0, + "max_humidity": 101, + }, + } }, False, ), ( { - "name": "test_invalid_humidity_min_max_2", - "max_humidity": 20, - "min_humidity": 40, + mqtt.DOMAIN: { + climate.DOMAIN: { + "name": "test_invalid_humidity_min_max_2", + "max_humidity": 20, + "min_humidity": 40, + }, + } }, False, ), ( { - "name": "test_valid_humidity_state", - "target_humidity_state_topic": "humidity-state", - "target_humidity_command_topic": "humidity-command", + mqtt.DOMAIN: { + climate.DOMAIN: { + "name": "test_valid_humidity_state", + "target_humidity_state_topic": "humidity-state", + "target_humidity_command_topic": "humidity-command", + }, + } }, True, ), ( { - "name": "test_invalid_humidity_state", - "target_humidity_state_topic": "humidity-state", + mqtt.DOMAIN: { + climate.DOMAIN: { + "name": "test_invalid_humidity_state", + "target_humidity_state_topic": "humidity-state", + }, + } }, False, ), ], ) async def test_humidity_configuration_validity( - hass: HomeAssistant, config, valid + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + valid: bool, ) -> None: """Test the validity of humidity configurations.""" - assert ( - await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {climate.DOMAIN: config}}, - ) - is valid - ) + if valid: + await mqtt_mock_entry_no_yaml_config() + return + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() async def test_reloadable( From 44add1dc1101d91e641489bfa8c1bae13fb6f655 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 23 Mar 2023 19:14:08 +0100 Subject: [PATCH 1206/2810] Prepare MQTT platform tests part3 (#90106) * Tests cover * Tests fan --- tests/components/mqtt/test_cover.py | 1557 ++++++++++++++------------- tests/components/mqtt/test_fan.py | 551 +++++----- 2 files changed, 1133 insertions(+), 975 deletions(-) diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index a09edcd25e..dd28d0919b 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -45,7 +45,6 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -91,13 +90,9 @@ def cover_platform_only(): yield -async def test_state_via_state_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -110,10 +105,14 @@ async def test_state_via_state_topic( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_state_via_state_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -130,13 +129,9 @@ async def test_state_via_state_topic( assert state.state == STATE_OPEN -async def test_opening_and_closing_state_via_custom_state_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling opening and closing state via a custom payload.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -151,10 +146,14 @@ async def test_opening_and_closing_state_via_custom_state_payload( "state_closing": "--43", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_opening_and_closing_state_via_custom_state_payload( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling opening and closing state via a custom payload.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -176,13 +175,9 @@ async def test_opening_and_closing_state_via_custom_state_payload( assert state.state == STATE_CLOSED -async def test_open_closed_state_from_position_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the state after setting the position using optimistic mode.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -196,10 +191,14 @@ async def test_open_closed_state_from_position_optimistic( "optimistic": True, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_open_closed_state_from_position_optimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the state after setting the position using optimistic mode.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -227,13 +226,9 @@ async def test_open_closed_state_from_position_optimistic( assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_position_via_position_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -248,10 +243,14 @@ async def test_position_via_position_topic( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_position_via_position_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -268,13 +267,9 @@ async def test_position_via_position_topic( assert state.state == STATE_OPEN -async def test_state_via_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -290,10 +285,14 @@ async def test_state_via_template( {% endif %}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_state_via_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -309,13 +308,9 @@ async def test_state_via_template( assert state.state == STATE_CLOSED -async def test_state_via_template_and_entity_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -331,10 +326,14 @@ async def test_state_via_template_and_entity_id( {% endif %}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_state_via_template_and_entity_id( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -352,15 +351,9 @@ async def test_state_via_template_and_entity_id( assert state.state == STATE_CLOSED -async def test_state_via_template_with_json_value( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the controlling state via topic with JSON value.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -371,10 +364,16 @@ async def test_state_via_template_with_json_value( "value_template": "{{ value_json.Var1 }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_state_via_template_with_json_value( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic with JSON value.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -397,13 +396,9 @@ async def test_state_via_template_with_json_value( ) in caplog.text -async def test_position_via_template_and_entity_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -419,10 +414,14 @@ async def test_position_via_template_and_entity_id( {% endif %}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_position_via_template_and_entity_id( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -443,30 +442,77 @@ async def test_position_via_template_and_entity_id( @pytest.mark.parametrize( - ("config", "assumed_state"), + ("hass_config", "assumed_state"), [ - ({"command_topic": "abc"}, True), - ({"command_topic": "abc", "state_topic": "abc"}, False), + ( + { + mqtt.DOMAIN: { + cover.DOMAIN: {"name": "test", "qos": 0, "command_topic": "abc"} + } + }, + True, + ), + ( + { + mqtt.DOMAIN: { + cover.DOMAIN: { + "name": "test", + "qos": 0, + "command_topic": "abc", + "state_topic": "abc", + } + } + }, + False, + ), # ({"set_position_topic": "abc"}, True), - not a valid configuration - ({"set_position_topic": "abc", "position_topic": "abc"}, False), - ({"tilt_command_topic": "abc"}, True), - ({"tilt_command_topic": "abc", "tilt_status_topic": "abc"}, False), + ( + { + mqtt.DOMAIN: { + cover.DOMAIN: { + "name": "test", + "qos": 0, + "set_position_topic": "abc", + "position_topic": "abc", + } + } + }, + False, + ), + ( + { + mqtt.DOMAIN: { + cover.DOMAIN: { + "name": "test", + "qos": 0, + "tilt_command_topic": "abc", + } + } + }, + True, + ), + ( + { + mqtt.DOMAIN: { + cover.DOMAIN: { + "name": "test", + "qos": 0, + "tilt_command_topic": "abc", + "tilt_status_topic": "abc", + } + } + }, + False, + ), ], ) async def test_optimistic_flag( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - config, - assumed_state, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + assumed_state: bool, ) -> None: """Test assumed_state is set correctly.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {cover.DOMAIN: {**config, "name": "test", "qos": 0}}}, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -476,13 +522,9 @@ async def test_optimistic_flag( assert ATTR_ASSUMED_STATE not in state.attributes -async def test_optimistic_state_change( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test changing state optimistically.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -491,10 +533,14 @@ async def test_optimistic_state_change( "qos": 0, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_optimistic_state_change( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test changing state optimistically.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -536,13 +582,9 @@ async def test_optimistic_state_change( assert state.state == STATE_CLOSED -async def test_optimistic_state_change_with_position( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test changing state optimistically.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -553,10 +595,14 @@ async def test_optimistic_state_change_with_position( "qos": 0, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_optimistic_state_change_with_position( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test changing state optimistically.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -603,13 +649,9 @@ async def test_optimistic_state_change_with_position( assert state.attributes.get(ATTR_CURRENT_POSITION) == 0 -async def test_send_open_cover_command( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the sending of open_cover.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -619,10 +661,14 @@ async def test_send_open_cover_command( "qos": 2, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_send_open_cover_command( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending of open_cover.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -636,13 +682,9 @@ async def test_send_open_cover_command( assert state.state == STATE_UNKNOWN -async def test_send_close_cover_command( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the sending of close_cover.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -652,10 +694,14 @@ async def test_send_close_cover_command( "qos": 2, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_send_close_cover_command( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending of close_cover.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -669,13 +715,9 @@ async def test_send_close_cover_command( assert state.state == STATE_UNKNOWN -async def test_send_stop__cover_command( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the sending of stop_cover.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -685,10 +727,14 @@ async def test_send_stop__cover_command( "qos": 2, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_send_stop_cover_command( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending of stop_cover.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -702,13 +748,9 @@ async def test_send_stop__cover_command( assert state.state == STATE_UNKNOWN -async def test_current_cover_position( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the current cover position.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -722,10 +764,14 @@ async def test_current_cover_position( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_current_cover_position( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the current cover position.""" + await mqtt_mock_entry_no_yaml_config() state_attributes_dict = hass.states.get("cover.test").attributes assert ATTR_CURRENT_POSITION not in state_attributes_dict @@ -757,13 +803,9 @@ async def test_current_cover_position( assert current_cover_position == 100 -async def test_current_cover_position_inverted( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the current cover position.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -777,10 +819,14 @@ async def test_current_cover_position_inverted( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_current_cover_position_inverted( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the current cover position.""" + await mqtt_mock_entry_no_yaml_config() state_attributes_dict = hass.states.get("cover.test").attributes assert ATTR_CURRENT_POSITION not in state_attributes_dict @@ -823,13 +869,9 @@ async def test_current_cover_position_inverted( assert hass.states.get("cover.test").state == STATE_CLOSED -async def test_optimistic_position( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test optimistic position is not supported.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -838,21 +880,26 @@ async def test_optimistic_position( "set_position_topic": "set-position-topic", } } - }, - ) + } + ], +) +async def test_optimistic_position( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test optimistic position is not supported.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( "Invalid config for [mqtt]: 'set_position_topic' must be set together with 'position_topic'" in caplog.text ) -async def test_position_update( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test cover position update from received MQTT message.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -867,10 +914,14 @@ async def test_position_update( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_position_update( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test cover position update from received MQTT message.""" + await mqtt_mock_entry_no_yaml_config() state_attributes_dict = hass.states.get("cover.test").attributes assert ATTR_CURRENT_POSITION not in state_attributes_dict @@ -888,39 +939,58 @@ async def test_position_update( @pytest.mark.parametrize( - ("pos_template", "pos_call", "pos_message"), - [("{{position-1}}", 43, "42"), ("{{100-62}}", 100, "38")], + ("hass_config", "pos_call", "pos_message"), + [ + ( + { + mqtt.DOMAIN: { + cover.DOMAIN: { + "name": "test", + "position_topic": "get-position-topic", + "command_topic": "command-topic", + "position_open": 100, + "position_closed": 0, + "set_position_topic": "set-position-topic", + "set_position_template": "{{position-1}}", + "payload_open": "OPEN", + "payload_close": "CLOSE", + "payload_stop": "STOP", + } + } + }, + 43, + "42", + ), + ( + { + mqtt.DOMAIN: { + cover.DOMAIN: { + "name": "test", + "position_topic": "get-position-topic", + "command_topic": "command-topic", + "position_open": 100, + "position_closed": 0, + "set_position_topic": "set-position-topic", + "set_position_template": "{{100-62}}", + "payload_open": "OPEN", + "payload_close": "CLOSE", + "payload_stop": "STOP", + } + } + }, + 100, + "38", + ), + ], ) async def test_set_position_templated( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - pos_template, - pos_call, - pos_message, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + pos_call: int, + pos_message: str, ) -> None: """Test setting cover position via template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - cover.DOMAIN: { - "name": "test", - "position_topic": "get-position-topic", - "command_topic": "command-topic", - "position_open": 100, - "position_closed": 0, - "set_position_topic": "set-position-topic", - "set_position_template": pos_template, - "payload_open": "OPEN", - "payload_close": "CLOSE", - "payload_stop": "STOP", - } - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -934,13 +1004,9 @@ async def test_set_position_templated( ) -async def test_set_position_templated_and_attributes( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test setting cover position via template and using entities attributes.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -965,10 +1031,14 @@ async def test_set_position_templated_and_attributes( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_set_position_templated_and_attributes( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test setting cover position via template and using entities attributes.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -980,13 +1050,9 @@ async def test_set_position_templated_and_attributes( mqtt_mock.async_publish.assert_called_once_with("set-position-topic", "5", 0, False) -async def test_set_tilt_templated( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test setting cover tilt position via template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1004,10 +1070,14 @@ async def test_set_tilt_templated( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_set_tilt_templated( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test setting cover tilt position via template.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1021,13 +1091,9 @@ async def test_set_tilt_templated( ) -async def test_set_tilt_templated_and_attributes( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test setting cover tilt position via template and using entities attributes.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1049,10 +1115,14 @@ async def test_set_tilt_templated_and_attributes( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_set_tilt_templated_and_attributes( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test setting cover tilt position via template and using entities attributes.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1111,13 +1181,9 @@ async def test_set_tilt_templated_and_attributes( ) -async def test_set_position_untemplated( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test setting cover position via template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1130,10 +1196,14 @@ async def test_set_position_untemplated( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_set_position_untemplated( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test setting cover position via template.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1145,13 +1215,9 @@ async def test_set_position_untemplated( mqtt_mock.async_publish.assert_called_once_with("position-topic", "62", 0, False) -async def test_set_position_untemplated_custom_percentage_range( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test setting cover position via template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1166,10 +1232,14 @@ async def test_set_position_untemplated_custom_percentage_range( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_set_position_untemplated_custom_percentage_range( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test setting cover position via template.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1181,13 +1251,9 @@ async def test_set_position_untemplated_custom_percentage_range( mqtt_mock.async_publish.assert_called_once_with("position-topic", "62", 0, False) -async def test_no_command_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test with no command topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1200,21 +1266,21 @@ async def test_no_command_topic( "tilt_status_topic": "tilt-status", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_no_command_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test with no command topic.""" + await mqtt_mock_entry_no_yaml_config() assert hass.states.get("cover.test").attributes["supported_features"] == 240 -async def test_no_payload_close( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test with no close payload.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1226,21 +1292,21 @@ async def test_no_payload_close( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_no_payload_close( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test with no close payload.""" + await mqtt_mock_entry_no_yaml_config() assert hass.states.get("cover.test").attributes["supported_features"] == 9 -async def test_no_payload_open( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test with no open payload.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1252,21 +1318,21 @@ async def test_no_payload_open( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_no_payload_open( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test with no open payload.""" + await mqtt_mock_entry_no_yaml_config() assert hass.states.get("cover.test").attributes["supported_features"] == 10 -async def test_no_payload_stop( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test with no stop payload.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1278,21 +1344,21 @@ async def test_no_payload_stop( "payload_stop": None, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_no_payload_stop( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test with no stop payload.""" + await mqtt_mock_entry_no_yaml_config() assert hass.states.get("cover.test").attributes["supported_features"] == 3 -async def test_with_command_topic_and_tilt( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test with command topic and tilt config.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1306,21 +1372,21 @@ async def test_with_command_topic_and_tilt( "tilt_status_topic": "tilt-status", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_with_command_topic_and_tilt( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test with command topic and tilt config.""" + await mqtt_mock_entry_no_yaml_config() assert hass.states.get("cover.test").attributes["supported_features"] == 251 -async def test_tilt_defaults( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the defaults.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1335,23 +1401,23 @@ async def test_tilt_defaults( "tilt_status_topic": "tilt-status", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_defaults( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the defaults.""" + await mqtt_mock_entry_no_yaml_config() state_attributes_dict = hass.states.get("cover.test").attributes # Tilt position is not yet known assert ATTR_CURRENT_TILT_POSITION not in state_attributes_dict -async def test_tilt_via_invocation_defaults( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilt defaults on close/open.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1366,10 +1432,15 @@ async def test_tilt_via_invocation_defaults( "tilt_status_topic": "tilt-status-topic", } } - }, - ) + } + ], +) +async def test_tilt_via_invocation_defaults( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilt defaults on close/open.""" await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1431,13 +1502,9 @@ async def test_tilt_via_invocation_defaults( mqtt_mock.async_publish.assert_called_once_with("tilt-command-topic", "0", 0, False) -async def test_tilt_given_value( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilting to a given value.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1454,10 +1521,14 @@ async def test_tilt_given_value( "tilt_closed_value": 25, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_given_value( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilting to a given value.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1523,13 +1594,9 @@ async def test_tilt_given_value( ) -async def test_tilt_given_value_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilting to a given value.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1547,10 +1614,14 @@ async def test_tilt_given_value_optimistic( "tilt_optimistic": True, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_given_value_optimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilting to a given value.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1603,13 +1674,9 @@ async def test_tilt_given_value_optimistic( ) -async def test_tilt_given_value_altered_range( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilting to a given value.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1629,10 +1696,14 @@ async def test_tilt_given_value_altered_range( "tilt_optimistic": True, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_given_value_altered_range( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilting to a given value.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1683,13 +1754,9 @@ async def test_tilt_given_value_altered_range( ) -async def test_tilt_via_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilt by updating status via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1704,10 +1771,14 @@ async def test_tilt_via_topic( "tilt_status_topic": "tilt-status-topic", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_via_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilt by updating status via MQTT.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "tilt-status-topic", "0") @@ -1724,13 +1795,9 @@ async def test_tilt_via_topic( assert current_cover_tilt_position == 50 -async def test_tilt_via_topic_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilt by updating status via MQTT and template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1748,10 +1815,14 @@ async def test_tilt_via_topic_template( "tilt_closed_value": 125, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_via_topic_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilt by updating status via MQTT and template.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "tilt-status-topic", "99") @@ -1768,15 +1839,9 @@ async def test_tilt_via_topic_template( assert current_cover_tilt_position == 50 -async def test_tilt_via_topic_template_json_value( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test tilt by updating status via MQTT and template with JSON value.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1794,10 +1859,16 @@ async def test_tilt_via_topic_template_json_value( "tilt_closed_value": 125, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_via_topic_template_json_value( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test tilt by updating status via MQTT and template with JSON value.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "tilt-status-topic", '{"Var1": 9, "Var2": 30}') @@ -1820,13 +1891,9 @@ async def test_tilt_via_topic_template_json_value( ) in caplog.text -async def test_tilt_via_topic_altered_range( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilt status via MQTT with altered tilt range.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1843,10 +1910,14 @@ async def test_tilt_via_topic_altered_range( "tilt_max": 50, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_via_topic_altered_range( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilt status via MQTT with altered tilt range.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "tilt-status-topic", "0") @@ -1870,15 +1941,9 @@ async def test_tilt_via_topic_altered_range( assert current_cover_tilt_position == 50 -async def test_tilt_status_out_of_range_warning( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, -) -> None: - """Test tilt status via MQTT tilt out of range warning message.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1895,10 +1960,16 @@ async def test_tilt_status_out_of_range_warning( "tilt_max": 50, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_status_out_of_range_warning( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test tilt status via MQTT tilt out of range warning message.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "tilt-status-topic", "60") @@ -1907,15 +1978,9 @@ async def test_tilt_status_out_of_range_warning( ) in caplog.text -async def test_tilt_status_not_numeric_warning( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, -) -> None: - """Test tilt status via MQTT tilt not numeric warning message.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1932,23 +1997,25 @@ async def test_tilt_status_not_numeric_warning( "tilt_max": 50, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_status_not_numeric_warning( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test tilt status via MQTT tilt not numeric warning message.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "tilt-status-topic", "abc") assert ("Payload 'abc' is not numeric") in caplog.text -async def test_tilt_via_topic_altered_range_inverted( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilt status via MQTT with altered tilt range and inverted tilt position.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1965,10 +2032,14 @@ async def test_tilt_via_topic_altered_range_inverted( "tilt_max": 0, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_via_topic_altered_range_inverted( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilt status via MQTT with altered tilt range and inverted tilt position.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "tilt-status-topic", "0") @@ -1992,13 +2063,9 @@ async def test_tilt_via_topic_altered_range_inverted( assert current_cover_tilt_position == 50 -async def test_tilt_via_topic_template_altered_range( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilt status via MQTT and template with altered tilt range.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -2018,10 +2085,14 @@ async def test_tilt_via_topic_template_altered_range( "tilt_max": 50, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_via_topic_template_altered_range( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilt status via MQTT and template with altered tilt range.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "tilt-status-topic", "99") @@ -2045,13 +2116,9 @@ async def test_tilt_via_topic_template_altered_range( assert current_cover_tilt_position == 50 -async def test_tilt_position( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilt via method invocation.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -2066,10 +2133,14 @@ async def test_tilt_position( "tilt_status_topic": "tilt-status-topic", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_position( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilt via method invocation.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -2083,13 +2154,9 @@ async def test_tilt_position( ) -async def test_tilt_position_templated( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilt position via template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -2105,10 +2172,14 @@ async def test_tilt_position_templated( "tilt_command_template": "{{100-32}}", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_position_templated( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilt position via template.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -2122,13 +2193,9 @@ async def test_tilt_position_templated( ) -async def test_tilt_position_altered_range( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilt via method invocation with altered range.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -2147,10 +2214,14 @@ async def test_tilt_position_altered_range( "tilt_max": 50, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_position_altered_range( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilt via method invocation with altered range.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -2164,6 +2235,7 @@ async def test_tilt_position_altered_range( ) +@pytest.mark.parametrize("hass_config", []) async def test_find_percentage_in_range_defaults(hass: HomeAssistant) -> None: """Test find percentage in range with default range.""" mqtt_cover = MqttCover( @@ -2546,13 +2618,9 @@ async def test_custom_availability_payload( ) -async def test_valid_device_class( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the setting of a valid device class.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -2561,22 +2629,22 @@ async def test_valid_device_class( "state_topic": "test-topic", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_valid_device_class( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the setting of a valid device class.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.attributes.get("device_class") == "garage" -async def test_invalid_device_class( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test the setting of an invalid device class.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -2585,8 +2653,17 @@ async def test_invalid_device_class( "state_topic": "test-topic", } } - }, - ) + } + ], +) +async def test_invalid_device_class( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test the setting of an invalid device class.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert "Invalid config for [mqtt]: expected CoverDeviceClass" in caplog.text @@ -2821,13 +2898,9 @@ async def test_entity_debug_info_message( ) -async def test_state_and_position_topics_state_not_set_via_position_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test state is not set via position topic when both state and position topics are set.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -2842,10 +2915,14 @@ async def test_state_and_position_topics_state_not_set_via_position_topic( "qos": 0, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_state_and_position_topics_state_not_set_via_position_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test state is not set via position topic when both state and position topics are set.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -2882,13 +2959,9 @@ async def test_state_and_position_topics_state_not_set_via_position_topic( assert state.state == STATE_CLOSED -async def test_set_state_via_position_using_stopped_state( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via position topic using stopped state.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -2904,10 +2977,14 @@ async def test_set_state_via_position_using_stopped_state( "qos": 0, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_set_state_via_position_using_stopped_state( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via position topic using stopped state.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -2939,13 +3016,9 @@ async def test_set_state_via_position_using_stopped_state( assert state.state == STATE_OPEN -async def test_position_via_position_topic_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test position by updating status via position template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -2957,10 +3030,14 @@ async def test_position_via_position_topic_template( "position_template": "{{ (value | multiply(0.01)) | int }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_position_via_position_topic_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test position by updating status via position template.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "get-position-topic", "99") @@ -2977,15 +3054,9 @@ async def test_position_via_position_topic_template( assert current_cover_position_position == 50 -async def test_position_via_position_topic_template_json_value( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test position by updating status via position template with a JSON value.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -2997,10 +3068,16 @@ async def test_position_via_position_topic_template_json_value( "position_template": "{{ value_json.Var1 }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_position_via_position_topic_template_json_value( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test position by updating status via position template with a JSON value.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "get-position-topic", '{"Var1": 9, "Var2": 60}') @@ -3023,13 +3100,9 @@ async def test_position_via_position_topic_template_json_value( ) in caplog.text -async def test_position_template_with_entity_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test position by updating status via position template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3046,10 +3119,14 @@ async def test_position_template_with_entity_id( {% endif %}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_position_template_with_entity_id( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test position by updating status via position template.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "get-position-topic", "10") @@ -3066,13 +3143,9 @@ async def test_position_template_with_entity_id( assert current_cover_position_position == 20 -async def test_position_via_position_topic_template_return_json( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test position by updating status via position template and returning json.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3084,10 +3157,14 @@ async def test_position_via_position_topic_template_return_json( "position_template": '{{ {"position" : value} | tojson }}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_position_via_position_topic_template_return_json( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test position by updating status via position template and returning json.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "get-position-topic", "55") @@ -3097,15 +3174,9 @@ async def test_position_via_position_topic_template_return_json( assert current_cover_position_position == 55 -async def test_position_via_position_topic_template_return_json_warning( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, -) -> None: - """Test position by updating status via position template returning json without position attribute.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3117,10 +3188,16 @@ async def test_position_via_position_topic_template_return_json_warning( "position_template": '{{ {"pos" : value} | tojson }}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_position_via_position_topic_template_return_json_warning( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test position by updating status via position template returning json without position attribute.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "get-position-topic", "55") @@ -3130,13 +3207,9 @@ async def test_position_via_position_topic_template_return_json_warning( ) -async def test_position_and_tilt_via_position_topic_template_return_json( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test position and tilt by updating the position via position template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3149,10 +3222,14 @@ async def test_position_and_tilt_via_position_topic_template_return_json( {{ {"position" : value, "tilt_position" : (value | int / 2)| int } | tojson }}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_position_and_tilt_via_position_topic_template_return_json( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test position and tilt by updating the position via position template.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "get-position-topic", "0") @@ -3174,13 +3251,9 @@ async def test_position_and_tilt_via_position_topic_template_return_json( assert current_cover_position == 99 and current_tilt_position == 49 -async def test_position_via_position_topic_template_all_variables( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test position by updating status via position template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3203,10 +3276,14 @@ async def test_position_via_position_topic_template_all_variables( {% endif %}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_position_via_position_topic_template_all_variables( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test position by updating status via position template.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "get-position-topic", "0") @@ -3222,13 +3299,9 @@ async def test_position_via_position_topic_template_all_variables( assert current_cover_position == 100 -async def test_set_state_via_stopped_state_no_position_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via stopped state when no position topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3244,10 +3317,14 @@ async def test_set_state_via_stopped_state_no_position_topic( "optimistic": False, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_set_state_via_stopped_state_no_position_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via stopped state when no position topic.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "state-topic", "OPEN") @@ -3275,15 +3352,9 @@ async def test_set_state_via_stopped_state_no_position_topic( assert state.state == STATE_CLOSED -async def test_position_via_position_topic_template_return_invalid_json( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, -) -> None: - """Test position by updating status via position template and returning invalid json.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3295,23 +3366,25 @@ async def test_position_via_position_topic_template_return_invalid_json( "position_template": '{{ {"position" : invalid_json} }}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_position_via_position_topic_template_return_invalid_json( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test position by updating status via position template and returning invalid json.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "get-position-topic", "55") assert ("Payload '{'position': Undefined}' is not numeric") in caplog.text -async def test_set_position_topic_without_get_position_topic_error( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test error when set_position_topic is used without position_topic.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3321,21 +3394,25 @@ async def test_set_position_topic_without_get_position_topic_error( "value_template": "{{100-62}}", } } - }, - ) + } + ], +) +async def test_set_position_topic_without_get_position_topic_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test error when set_position_topic is used without position_topic.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( f"'{CONF_SET_POSITION_TOPIC}' must be set together with '{CONF_GET_POSITION_TOPIC}'." ) in caplog.text -async def test_value_template_without_state_topic_error( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test error when value_template is used and state_topic is missing.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3344,20 +3421,25 @@ async def test_value_template_without_state_topic_error( "value_template": "{{100-62}}", } } - }, - ) + } + ], +) +async def test_value_template_without_state_topic_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test error when value_template is used and state_topic is missing.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( f"'{CONF_VALUE_TEMPLATE}' must be set together with '{CONF_STATE_TOPIC}'." ) in caplog.text -async def test_position_template_without_position_topic_error( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test error when position_template is used and position_topic is missing.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3366,22 +3448,26 @@ async def test_position_template_without_position_topic_error( "position_template": "{{100-52}}", } } - }, - ) + } + ], +) +async def test_position_template_without_position_topic_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test error when position_template is used and position_topic is missing.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( f"'{CONF_GET_POSITION_TEMPLATE}' must be set together with '{CONF_GET_POSITION_TOPIC}'." in caplog.text ) -async def test_set_position_template_without_set_position_topic( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test error when set_position_template is used and set_position_topic is missing.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3390,21 +3476,26 @@ async def test_set_position_template_without_set_position_topic( "set_position_template": "{{100-42}}", } } - }, - ) + } + ], +) +async def test_set_position_template_without_set_position_topic( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test error when set_position_template is used and set_position_topic is missing.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( f"'{CONF_SET_POSITION_TEMPLATE}' must be set together with '{CONF_SET_POSITION_TOPIC}'." in caplog.text ) -async def test_tilt_command_template_without_tilt_command_topic( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test error when tilt_command_template is used and tilt_command_topic is missing.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3413,21 +3504,26 @@ async def test_tilt_command_template_without_tilt_command_topic( "tilt_command_template": "{{100-32}}", } } - }, - ) + } + ], +) +async def test_tilt_command_template_without_tilt_command_topic( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test error when tilt_command_template is used and tilt_command_topic is missing.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( f"'{CONF_TILT_COMMAND_TEMPLATE}' must be set together with '{CONF_TILT_COMMAND_TOPIC}'." in caplog.text ) -async def test_tilt_status_template_without_tilt_status_topic_topic( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test error when tilt_status_template is used and tilt_status_topic is missing.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3436,8 +3532,17 @@ async def test_tilt_status_template_without_tilt_status_topic_topic( "tilt_status_template": "{{100-22}}", } } - }, - ) + } + ], +) +async def test_tilt_status_template_without_tilt_status_topic_topic( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test error when tilt_status_template is used and tilt_status_topic is missing.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( f"'{CONF_TILT_STATUS_TEMPLATE}' must be set together with '{CONF_TILT_STATUS_TOPIC}'." in caplog.text diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 41ff43aba8..9882b9102e 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -32,7 +32,6 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -85,30 +84,24 @@ def fan_platform_only(): yield +@pytest.mark.parametrize("hass_config", [{mqtt.DOMAIN: {fan.DOMAIN: {"name": "test"}}}]) async def test_fail_setup_if_no_command_topic( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test if command fails with command topic.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {fan.DOMAIN: {"name": "test"}}}, - ) + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( "Invalid config for [mqtt]: required key not provided @ data['mqtt']['fan'][0]['command_topic']" in caplog.text ) -async def test_controlling_state_via_topic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: { @@ -139,10 +132,16 @@ async def test_controlling_state_via_topic( "payload_reset_preset_mode": "rEset_preset_mode", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -224,15 +223,9 @@ async def test_controlling_state_via_topic( assert state.state == STATE_UNKNOWN -async def test_controlling_state_via_topic_with_different_speed_range( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the controlling state via topic using an alternate speed range.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: [ @@ -262,10 +255,16 @@ async def test_controlling_state_via_topic_with_different_speed_range( }, ] } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic_with_different_speed_range( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic using an alternate speed range.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "percentage-state-topic1", "100") state = hass.states.get("fan.test1") @@ -288,15 +287,9 @@ async def test_controlling_state_via_topic_with_different_speed_range( caplog.clear() -async def test_controlling_state_via_topic_no_percentage_topics( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the controlling state via topic without percentage topics.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: { @@ -314,10 +307,16 @@ async def test_controlling_state_via_topic_no_percentage_topics( ], } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic_no_percentage_topics( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic without percentage topics.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -347,15 +346,9 @@ async def test_controlling_state_via_topic_no_percentage_topics( caplog.clear() -async def test_controlling_state_via_topic_and_json_message( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the controlling state via topic and JSON message (percentage mode).""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: { @@ -384,10 +377,16 @@ async def test_controlling_state_via_topic_and_json_message( "speed_range_max": 100, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic_and_json_message( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic and JSON message (percentage mode).""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -454,15 +453,9 @@ async def test_controlling_state_via_topic_and_json_message( assert state.attributes.get("preset_mode") is None -async def test_controlling_state_via_topic_and_json_message_shared_topic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the controlling state via topic and JSON message using a shared topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: { @@ -491,10 +484,16 @@ async def test_controlling_state_via_topic_and_json_message_shared_topic( "speed_range_max": 100, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic_and_json_message_shared_topic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic and JSON message using a shared topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -544,15 +543,9 @@ async def test_controlling_state_via_topic_and_json_message_shared_topic( caplog.clear() -async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test optimistic mode without state topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: { @@ -572,10 +565,15 @@ async def test_sending_mqtt_commands_and_optimistic( ], } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_and_optimistic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test optimistic mode without state topic.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -671,13 +669,9 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_sending_mqtt_commands_with_alternate_speed_range( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via topic using an alternate speed range.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: [ @@ -707,10 +701,14 @@ async def test_sending_mqtt_commands_with_alternate_speed_range( }, ] } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_with_alternate_speed_range( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via topic using an alternate speed range.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await common.async_set_percentage(hass, "fan.test1", 0) mqtt_mock.async_publish.assert_called_once_with( @@ -777,15 +775,9 @@ async def test_sending_mqtt_commands_with_alternate_speed_range( assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_sending_mqtt_commands_and_optimistic_no_legacy( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test optimistic mode without state topic without legacy speed command topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: { @@ -800,10 +792,16 @@ async def test_sending_mqtt_commands_and_optimistic_no_legacy( ], } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_and_optimistic_no_legacy( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test optimistic mode without state topic without legacy speed command topic.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -911,15 +909,9 @@ async def test_sending_mqtt_commands_and_optimistic_no_legacy( await common.async_turn_on(hass, "fan.test", preset_mode="freaking-high") -async def test_sending_mqtt_command_templates_( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test optimistic mode without state topic without legacy speed command topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: { @@ -939,10 +931,15 @@ async def test_sending_mqtt_command_templates_( ], } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_command_templates_( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test optimistic mode without state topic without legacy speed command topic.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -1056,15 +1053,9 @@ async def test_sending_mqtt_command_templates_( await common.async_turn_on(hass, "fan.test", preset_mode="low") -async def test_sending_mqtt_commands_and_optimistic_no_percentage_topic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test optimistic mode without state topic without percentage command topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: { @@ -1080,10 +1071,15 @@ async def test_sending_mqtt_commands_and_optimistic_no_percentage_topic( ], } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_and_optimistic_no_percentage_topic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test optimistic mode without state topic without percentage command topic.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -1120,15 +1116,9 @@ async def test_sending_mqtt_commands_and_optimistic_no_percentage_topic( assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_sending_mqtt_commands_and_explicit_optimistic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test optimistic mode with state topic and turn on attributes.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: { @@ -1149,10 +1139,15 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( "optimistic": True, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_and_explicit_optimistic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test optimistic mode with state topic and turn on attributes.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -1395,15 +1390,9 @@ async def test_encoding_subscribable_topics( ) -async def test_attributes( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test attributes.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: { @@ -1418,10 +1407,16 @@ async def test_attributes( ], } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_attributes( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -1452,13 +1447,17 @@ async def test_attributes( @pytest.mark.parametrize( - ("name", "config", "success", "features"), + ("name", "hass_config", "success", "features"), [ ( "test1", { - "name": "test1", - "command_topic": "command-topic", + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test1", + "command_topic": "command-topic", + } + } }, True, 0, @@ -1466,29 +1465,41 @@ async def test_attributes( ( "test2", { - "name": "test2", - "command_topic": "command-topic", - "oscillation_command_topic": "oscillation-command-topic", + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test2", + "command_topic": "command-topic", + "oscillation_command_topic": "oscillation-command-topic", + } + } }, True, - fan.SUPPORT_OSCILLATE, + fan.FanEntityFeature.OSCILLATE, ), ( "test3", { - "name": "test3", - "command_topic": "command-topic", - "percentage_command_topic": "percentage-command-topic", + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test3", + "command_topic": "command-topic", + "percentage_command_topic": "percentage-command-topic", + } + } }, True, - fan.SUPPORT_SET_SPEED, + fan.FanEntityFeature.SET_SPEED, ), ( "test4", { - "name": "test4", - "command_topic": "command-topic", - "preset_mode_command_topic": "preset-mode-command-topic", + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test4", + "command_topic": "command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + } + } }, False, None, @@ -1496,100 +1507,136 @@ async def test_attributes( ( "test5", { - "name": "test5", - "command_topic": "command-topic", - "preset_mode_command_topic": "preset-mode-command-topic", - "preset_modes": ["eco", "auto"], + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test5", + "command_topic": "command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_modes": ["eco", "auto"], + } + } }, True, - fan.SUPPORT_PRESET_MODE, + fan.FanEntityFeature.PRESET_MODE, ), ( "test6", { - "name": "test6", - "command_topic": "command-topic", - "preset_mode_command_topic": "preset-mode-command-topic", - "preset_modes": ["eco", "smart", "auto"], + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test6", + "command_topic": "command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_modes": ["eco", "smart", "auto"], + } + } }, True, - fan.SUPPORT_PRESET_MODE, + fan.FanEntityFeature.PRESET_MODE, ), ( "test7", { - "name": "test7", - "command_topic": "command-topic", - "percentage_command_topic": "percentage-command-topic", + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test7", + "command_topic": "command-topic", + "percentage_command_topic": "percentage-command-topic", + } + } }, True, - fan.SUPPORT_SET_SPEED, + fan.FanEntityFeature.SET_SPEED, ), ( "test8", { - "name": "test8", - "command_topic": "command-topic", - "oscillation_command_topic": "oscillation-command-topic", - "percentage_command_topic": "percentage-command-topic", + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test8", + "command_topic": "command-topic", + "oscillation_command_topic": "oscillation-command-topic", + "percentage_command_topic": "percentage-command-topic", + } + } }, True, - fan.SUPPORT_OSCILLATE | fan.SUPPORT_SET_SPEED, + fan.FanEntityFeature.OSCILLATE | fan.FanEntityFeature.SET_SPEED, ), ( "test9", { - "name": "test9", - "command_topic": "command-topic", - "preset_mode_command_topic": "preset-mode-command-topic", - "preset_modes": ["Mode1", "Mode2", "Mode3"], + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test9", + "command_topic": "command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_modes": ["Mode1", "Mode2", "Mode3"], + } + } }, True, - fan.SUPPORT_PRESET_MODE, + fan.FanEntityFeature.PRESET_MODE, ), ( "test10", { - "name": "test10", - "command_topic": "command-topic", - "preset_mode_command_topic": "preset-mode-command-topic", - "preset_modes": ["whoosh", "silent", "auto"], + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test10", + "command_topic": "command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_modes": ["whoosh", "silent", "auto"], + } + } }, True, - fan.SUPPORT_PRESET_MODE, + fan.FanEntityFeature.PRESET_MODE, ), ( "test11", { - "name": "test11", - "command_topic": "command-topic", - "oscillation_command_topic": "oscillation-command-topic", - "preset_mode_command_topic": "preset-mode-command-topic", - "preset_modes": ["Mode1", "Mode2", "Mode3"], + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test11", + "command_topic": "command-topic", + "oscillation_command_topic": "oscillation-command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_modes": ["Mode1", "Mode2", "Mode3"], + } + } }, True, - fan.SUPPORT_PRESET_MODE | fan.SUPPORT_OSCILLATE, + fan.FanEntityFeature.PRESET_MODE | fan.FanEntityFeature.OSCILLATE, ), ( "test12", { - "name": "test12", - "command_topic": "command-topic", - "percentage_command_topic": "percentage-command-topic", - "speed_range_min": 1, - "speed_range_max": 40, + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test12", + "command_topic": "command-topic", + "percentage_command_topic": "percentage-command-topic", + "speed_range_min": 1, + "speed_range_max": 40, + } + } }, True, - fan.SUPPORT_SET_SPEED, + fan.FanEntityFeature.SET_SPEED, ), ( "test13", { - "name": "test13", - "command_topic": "command-topic", - "percentage_command_topic": "percentage-command-topic", - "speed_range_min": 50, - "speed_range_max": 40, + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test13", + "command_topic": "command-topic", + "percentage_command_topic": "percentage-command-topic", + "speed_range_min": 50, + "speed_range_max": 40, + } + } }, False, None, @@ -1597,11 +1644,15 @@ async def test_attributes( ( "test14", { - "name": "test14", - "command_topic": "command-topic", - "percentage_command_topic": "percentage-command-topic", - "speed_range_min": 0, - "speed_range_max": 40, + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test14", + "command_topic": "command-topic", + "percentage_command_topic": "percentage-command-topic", + "speed_range_min": 0, + "speed_range_max": 40, + } + } }, False, None, @@ -1609,10 +1660,14 @@ async def test_attributes( ( "test15", { - "name": "test7reset_payload_in_preset_modes_a", - "command_topic": "command-topic", - "preset_mode_command_topic": "preset-mode-command-topic", - "preset_modes": ["auto", "smart", "normal", "None"], + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test7reset_payload_in_preset_modes_a", + "command_topic": "command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_modes": ["auto", "smart", "normal", "None"], + } + } }, False, None, @@ -1620,39 +1675,37 @@ async def test_attributes( ( "test16", { - "name": "test16", - "command_topic": "command-topic", - "preset_mode_command_topic": "preset-mode-command-topic", - "preset_modes": ["whoosh", "silent", "auto", "None"], - "payload_reset_preset_mode": "normal", + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test16", + "command_topic": "command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_modes": ["whoosh", "silent", "auto", "None"], + "payload_reset_preset_mode": "normal", + } + } }, True, - fan.SUPPORT_PRESET_MODE, + fan.FanEntityFeature.PRESET_MODE, ), ], ) async def test_supported_features( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - name, - config, - success, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + name: str, + success: bool, features, ) -> None: """Test optimistic mode without state topic.""" - - assert ( - await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {fan.DOMAIN: config}} - ) - is success - ) if success: - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(f"fan.{name}") assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == features + return + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) From 2c1b59be0eac5b4264a1fb6321bd3359f71325a0 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 23 Mar 2023 19:14:44 +0100 Subject: [PATCH 1207/2810] Prepare MQTT platform tests part4 (#90107) * Tests humidifier * Tests legacy_vacuum --- tests/components/mqtt/test_humidifier.py | 422 +++++++++++--------- tests/components/mqtt/test_legacy_vacuum.py | 390 ++++++++---------- 2 files changed, 398 insertions(+), 414 deletions(-) diff --git a/tests/components/mqtt/test_humidifier.py b/tests/components/mqtt/test_humidifier.py index 89afe0a397..0517e8e6a9 100644 --- a/tests/components/mqtt/test_humidifier.py +++ b/tests/components/mqtt/test_humidifier.py @@ -14,7 +14,6 @@ from homeassistant.components.humidifier import ( SERVICE_SET_HUMIDITY, SERVICE_SET_MODE, ) -from homeassistant.components.mqtt import CONFIG_SCHEMA from homeassistant.components.mqtt.humidifier import ( CONF_MODE_COMMAND_TOPIC, CONF_MODE_STATE_TOPIC, @@ -34,7 +33,6 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -130,30 +128,26 @@ async def async_set_humidity( await hass.services.async_call(DOMAIN, SERVICE_SET_HUMIDITY, data, blocking=True) +@pytest.mark.parametrize( + "hass_config", [{mqtt.DOMAIN: {humidifier.DOMAIN: {"name": "test"}}}] +) async def test_fail_setup_if_no_command_topic( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test if command fails with command topic.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {humidifier.DOMAIN: {"name": "test"}}}, - ) + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( "Invalid config for [mqtt]: required key not provided @ data['mqtt']['humidifier'][0]['command_topic']. Got None" in caplog.text ) -async def test_controlling_state_via_topic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { humidifier.DOMAIN: { @@ -178,10 +172,16 @@ async def test_controlling_state_via_topic( "payload_reset_mode": "rEset_mode", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -252,15 +252,9 @@ async def test_controlling_state_via_topic( assert state.state == STATE_UNKNOWN -async def test_controlling_state_via_topic_and_json_message( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the controlling state via topic and JSON message.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { humidifier.DOMAIN: { @@ -281,10 +275,17 @@ async def test_controlling_state_via_topic_and_json_message( "mode_state_template": "{{ value_json.val }}", } } - }, - ) + } + ], +) +async def test_controlling_state_via_topic_and_json_message( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic and JSON message.""" await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -343,15 +344,9 @@ async def test_controlling_state_via_topic_and_json_message( assert state.state == STATE_UNKNOWN -async def test_controlling_state_via_topic_and_json_message_shared_topic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the controlling state via topic and JSON message using a shared topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { humidifier.DOMAIN: { @@ -372,10 +367,16 @@ async def test_controlling_state_via_topic_and_json_message_shared_topic( "mode_state_template": "{{ value_json.mode }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic_and_json_message_shared_topic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic and JSON message using a shared topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -422,15 +423,9 @@ async def test_controlling_state_via_topic_and_json_message_shared_topic( caplog.clear() -async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test optimistic mode without state topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { humidifier.DOMAIN: { @@ -447,10 +442,16 @@ async def test_sending_mqtt_commands_and_optimistic( ], } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_and_optimistic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test optimistic mode without state topic.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -521,15 +522,9 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_sending_mqtt_command_templates_( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Testing command templates with optimistic mode without state topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { humidifier.DOMAIN: { @@ -547,10 +542,16 @@ async def test_sending_mqtt_command_templates_( ], } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_command_templates_( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Testing command templates with optimistic mode without state topic.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -621,15 +622,9 @@ async def test_sending_mqtt_command_templates_( assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_sending_mqtt_commands_and_explicit_optimistic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test optimistic mode with state topic and turn on attributes.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { humidifier.DOMAIN: { @@ -648,10 +643,16 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( "optimistic": True, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_and_explicit_optimistic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test optimistic mode with state topic and turn on attributes.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -774,15 +775,9 @@ async def test_encoding_subscribable_topics( ) -async def test_attributes( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test attributes.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { humidifier.DOMAIN: { @@ -796,10 +791,16 @@ async def test_attributes( ], } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_attributes( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -826,105 +827,142 @@ async def test_attributes( @pytest.mark.parametrize( - ("config", "valid"), + ("hass_config", "valid"), [ ( { - "name": "test_valid_1", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test_valid_1", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + } + } }, True, ), ( { - "name": "test_valid_2", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", - "device_class": "humidifier", + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test_valid_2", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "device_class": "humidifier", + } + } }, True, ), ( { - "name": "test_valid_3", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", - "device_class": "dehumidifier", + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test_valid_3", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "device_class": "dehumidifier", + } + } }, True, ), ( { - "name": "test_invalid_device_class", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", - "device_class": "notsupporedSpeci@l", + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test_invalid_device_class", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "device_class": "notsupporedSpeci@l", + } + } }, False, ), ( { - "name": "test_mode_command_without_modes", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", - "mode_command_topic": "mode-command-topic", + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test_mode_command_without_modes", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "mode_command_topic": "mode-command-topic", + } + } }, False, ), ( { - "name": "test_invalid_humidity_min_max_1", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", - "min_humidity": 0, - "max_humidity": 101, + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test_invalid_humidity_min_max_1", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "min_humidity": 0, + "max_humidity": 101, + } + } }, False, ), ( { - "name": "test_invalid_humidity_min_max_2", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", - "max_humidity": 20, - "min_humidity": 40, + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test_invalid_humidity_min_max_2", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "max_humidity": 20, + "min_humidity": 40, + } + } }, False, ), ( { - "name": "test_invalid_mode_is_reset", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", - "mode_command_topic": "mode-command-topic", - "modes": ["eco", "None"], + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test_invalid_mode_is_reset", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "mode_command_topic": "mode-command-topic", + "modes": ["eco", "None"], + } + } }, False, ), ], ) -async def test_validity_configurations(hass: HomeAssistant, config, valid) -> None: +async def test_validity_configurations( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + valid: bool, +) -> None: """Test validity of configurations.""" - assert ( - await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {humidifier.DOMAIN: config}}, - ) - is valid - ) + if valid: + await mqtt_mock_entry_no_yaml_config() + return + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() @pytest.mark.parametrize( - ("name", "config", "success", "features"), + ("name", "hass_config", "success", "features"), [ ( "test1", { - "name": "test1", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test1", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + } + } }, True, 0, @@ -932,21 +970,29 @@ async def test_validity_configurations(hass: HomeAssistant, config, valid) -> No ( "test2", { - "name": "test2", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", - "mode_command_topic": "mode-command-topic", - "modes": ["eco", "auto"], + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test2", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "mode_command_topic": "mode-command-topic", + "modes": ["eco", "auto"], + } + } }, True, - humidifier.SUPPORT_MODES, + humidifier.HumidifierEntityFeature.MODES, ), ( "test3", { - "name": "test3", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test3", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + } + } }, True, 0, @@ -954,20 +1000,28 @@ async def test_validity_configurations(hass: HomeAssistant, config, valid) -> No ( "test4", { - "name": "test4", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", - "mode_command_topic": "mode-command-topic", - "modes": ["eco", "auto"], + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test4", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "mode_command_topic": "mode-command-topic", + "modes": ["eco", "auto"], + } + } }, True, - humidifier.SUPPORT_MODES, + humidifier.HumidifierEntityFeature.MODES, ), ( "test5", { - "name": "test5", - "command_topic": "command-topic", + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test5", + "command_topic": "command-topic", + } + } }, False, None, @@ -975,8 +1029,12 @@ async def test_validity_configurations(hass: HomeAssistant, config, valid) -> No ( "test6", { - "name": "test6", - "target_humidity_command_topic": "humidity-command-topic", + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test6", + "target_humidity_command_topic": "humidity-command-topic", + } + } }, False, None, @@ -985,27 +1043,20 @@ async def test_validity_configurations(hass: HomeAssistant, config, valid) -> No ) async def test_supported_features( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - name, - config, - success, - features, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + name: str, + success: bool, + features: humidifier.HumidifierEntityFeature | None, ) -> None: """Test supported features.""" - assert ( - await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {humidifier.DOMAIN: config}}, - ) - is success - ) if success: - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(f"humidifier.{name}") assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == features + return + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) @@ -1388,17 +1439,6 @@ async def test_setup_manual_entity_from_yaml( assert hass.states.get(f"{platform}.test") -async def test_config_schema_validation(hass: HomeAssistant) -> None: - """Test invalid platform options in the config schema do not pass the config validation.""" - platform = humidifier.DOMAIN - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][platform]) - config["name"] = "test" - CONFIG_SCHEMA({mqtt.DOMAIN: {platform: config}}) - CONFIG_SCHEMA({mqtt.DOMAIN: {platform: [config]}}) - with pytest.raises(MultipleInvalid): - CONFIG_SCHEMA({mqtt.DOMAIN: {platform: [{"bla": "bla"}]}}) - - async def test_unload_config_entry( hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index 42077fee0a..e4cf5bb803 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -32,9 +32,10 @@ from homeassistant.components.vacuum import ( ) from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component +from homeassistant.helpers.typing import ConfigType from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -92,6 +93,28 @@ DEFAULT_CONFIG = { DEFAULT_CONFIG_2 = {mqtt.DOMAIN: {vacuum.DOMAIN: {"name": "test"}}} +DEFAULT_CONFIG_ALL_SERVICES = help_custom_config( + vacuum.DOMAIN, + DEFAULT_CONFIG, + ( + { + mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( + ALL_SERVICES, SERVICE_TO_STRING + ) + }, + ), +) + + +def filter_options(default_config: ConfigType, options: set[str]) -> ConfigType: + """Generate a config from a default config with omitted options.""" + options_base: ConfigType = default_config[mqtt.DOMAIN][vacuum.DOMAIN] + config = deepcopy(default_config) + config[mqtt.DOMAIN][vacuum.DOMAIN] = { + key: value for key, value in options_base.items() if key not in options + } + return config + @pytest.fixture(autouse=True) def vacuum_platform_only(): @@ -100,13 +123,12 @@ def vacuum_platform_only(): yield +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_default_supported_features( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that the correct supported features.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() entity = hass.states.get("vacuum.mqtttest") entity_features = entity.attributes.get(mqttvacuum.CONF_SUPPORTED_FEATURES, 0) assert sorted(services_to_strings(entity_features, SERVICE_TO_STRING)) == sorted( @@ -122,20 +144,15 @@ async def test_default_supported_features( ) +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG_ALL_SERVICES], +) async def test_all_commands( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test simple commands to the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await common.async_turn_on(hass, "vacuum.mqtttest") mqtt_mock.async_publish.assert_called_once_with( @@ -206,21 +223,27 @@ async def test_all_commands( } +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + vacuum.DOMAIN, + DEFAULT_CONFIG, + ( + { + mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( + mqttvacuum.STRING_TO_SERVICE["status"], SERVICE_TO_STRING + ) + }, + ), + ) + ], +) async def test_commands_without_supported_features( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test commands which are not supported by the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - services = mqttvacuum.STRING_TO_SERVICE["status"] - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - services, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await common.async_turn_on(hass, "vacuum.mqtttest") mqtt_mock.async_publish.assert_not_called() @@ -259,21 +282,27 @@ async def test_commands_without_supported_features( mqtt_mock.async_publish.reset_mock() +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + vacuum.DOMAIN, + DEFAULT_CONFIG, + ( + { + mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( + mqttvacuum.STRING_TO_SERVICE["turn_on"], SERVICE_TO_STRING + ) + }, + ), + ) + ], +) async def test_attributes_without_supported_features( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test attributes which are not supported by the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - services = mqttvacuum.STRING_TO_SERVICE["turn_on"] - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - services, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() message = """{ "battery_level": 54, @@ -291,20 +320,15 @@ async def test_attributes_without_supported_features( assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG_ALL_SERVICES], +) async def test_status( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() message = """{ "battery_level": 54, @@ -336,20 +360,15 @@ async def test_status( assert state.attributes.get(ATTR_FAN_SPEED) == "min" +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG_ALL_SERVICES], +) async def test_status_battery( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() message = """{ "battery_level": 54 @@ -359,20 +378,16 @@ async def test_status_battery( assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-50" +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG_ALL_SERVICES], +) async def test_status_cleaning( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() message = """{ "cleaning": true @@ -382,20 +397,15 @@ async def test_status_cleaning( assert state.state == STATE_ON +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG_ALL_SERVICES], +) async def test_status_docked( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() message = """{ "docked": true @@ -405,20 +415,15 @@ async def test_status_docked( assert state.state == STATE_OFF +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG_ALL_SERVICES], +) async def test_status_charging( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() message = """{ "charging": true @@ -428,20 +433,12 @@ async def test_status_charging( assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-outline" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_ALL_SERVICES]) async def test_status_fan_speed( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() message = """{ "fan_speed": "max" @@ -451,62 +448,52 @@ async def test_status_fan_speed( assert state.attributes.get(ATTR_FAN_SPEED) == "max" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_ALL_SERVICES]) async def test_status_fan_speed_list( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("vacuum.mqtttest") assert state.attributes.get(ATTR_FAN_SPEED_LIST) == ["min", "medium", "high", "max"] +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + vacuum.DOMAIN, + DEFAULT_CONFIG, + ( + { + mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( + ALL_SERVICES - VacuumEntityFeature.FAN_SPEED, SERVICE_TO_STRING + ) + }, + ), + ) + ], +) async def test_status_no_fan_speed_list( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum. If the vacuum doesn't support fan speed, fan speed list should be None. """ - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - services = ALL_SERVICES - VacuumEntityFeature.FAN_SPEED - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - services, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("vacuum.mqtttest") assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_ALL_SERVICES]) async def test_status_error( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() message = """{ "error": "Error1" @@ -523,26 +510,26 @@ async def test_status_error( assert state.attributes.get(ATTR_STATUS) == "Stopped" +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + vacuum.DOMAIN, + DEFAULT_CONFIG, + ( + { + mqttvacuum.CONF_BATTERY_LEVEL_TOPIC: "retroroomba/battery_level", + mqttvacuum.CONF_BATTERY_LEVEL_TEMPLATE: "{{ value }}", + }, + ), + ) + ], +) async def test_battery_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that you can use non-default templates for battery_level.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config.update( - { - mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ), - mqttvacuum.CONF_BATTERY_LEVEL_TOPIC: "retroroomba/battery_level", - mqttvacuum.CONF_BATTERY_LEVEL_TEMPLATE: "{{ value }}", - } - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "retroroomba/battery_level", "54") state = hass.states.get("vacuum.mqtttest") @@ -550,20 +537,12 @@ async def test_battery_template( assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-50" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_ALL_SERVICES]) async def test_status_invalid_json( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test to make sure nothing breaks if the vacuum sends bad JSON.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "vacuum/state", '{"asdfasas false}') state = hass.states.get("vacuum.mqtttest") @@ -571,63 +550,28 @@ async def test_status_invalid_json( assert state.attributes.get(ATTR_STATUS) == "Stopped" -async def test_missing_battery_template(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "hass_config", + [ + filter_options(DEFAULT_CONFIG, {mqttvacuum.CONF_BATTERY_LEVEL_TEMPLATE}), + filter_options(DEFAULT_CONFIG, {mqttvacuum.CONF_CHARGING_TEMPLATE}), + filter_options(DEFAULT_CONFIG, {mqttvacuum.CONF_CLEANING_TEMPLATE}), + filter_options(DEFAULT_CONFIG, {mqttvacuum.CONF_DOCKED_TEMPLATE}), + filter_options(DEFAULT_CONFIG, {mqttvacuum.CONF_ERROR_TEMPLATE}), + filter_options(DEFAULT_CONFIG, {mqttvacuum.CONF_FAN_SPEED_TEMPLATE}), + ], +) +async def test_missing_templates( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: """Test to make sure missing template is not allowed.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config.pop(mqttvacuum.CONF_BATTERY_LEVEL_TEMPLATE) - - assert not await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - - -async def test_missing_charging_template(hass: HomeAssistant) -> None: - """Test to make sure missing template is not allowed.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config.pop(mqttvacuum.CONF_CHARGING_TEMPLATE) - - assert not await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - - -async def test_missing_cleaning_template(hass: HomeAssistant) -> None: - """Test to make sure missing template is not allowed.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config.pop(mqttvacuum.CONF_CLEANING_TEMPLATE) - - assert not await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - - -async def test_missing_docked_template(hass: HomeAssistant) -> None: - """Test to make sure missing template is not allowed.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config.pop(mqttvacuum.CONF_DOCKED_TEMPLATE) - - assert not await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - - -async def test_missing_error_template(hass: HomeAssistant) -> None: - """Test to make sure missing template is not allowed.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config.pop(mqttvacuum.CONF_ERROR_TEMPLATE) - - assert not await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - - -async def test_missing_fan_speed_template(hass: HomeAssistant) -> None: - """Test to make sure missing template is not allowed.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config.pop(mqttvacuum.CONF_FAN_SPEED_TEMPLATE) - - assert not await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() + assert ( + "Invalid config for [mqtt]: some but not all values in the same group of inclusion" + in caplog.text ) From db63c8584e22bd8b2afe3789d5204cc2dbd81da6 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 23 Mar 2023 19:16:54 +0100 Subject: [PATCH 1208/2810] Prepare MQTT platform tests part8 (#90132) * Tests state_vacuum * Tests siren --- tests/components/mqtt/test_siren.py | 279 +++++++++++---------- tests/components/mqtt/test_state_vacuum.py | 123 +++++---- 2 files changed, 214 insertions(+), 188 deletions(-) diff --git a/tests/components/mqtt/test_siren.py b/tests/components/mqtt/test_siren.py index 9837a3cc8a..7b20e802a5 100644 --- a/tests/components/mqtt/test_siren.py +++ b/tests/components/mqtt/test_siren.py @@ -19,9 +19,9 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -86,13 +86,9 @@ async def async_turn_off( await hass.services.async_call(siren.DOMAIN, SERVICE_TURN_OFF, data, blocking=True) -async def test_controlling_state_via_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { siren.DOMAIN: { @@ -103,10 +99,14 @@ async def test_controlling_state_via_topic( "payload_off": 0, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("siren.test") assert state.state == STATE_UNKNOWN @@ -123,13 +123,9 @@ async def test_controlling_state_via_topic( assert state.state == STATE_OFF -async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the sending MQTT commands in optimistic mode.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { siren.DOMAIN: { @@ -140,10 +136,15 @@ async def test_sending_mqtt_commands_and_optimistic( "qos": "2", } } - }, - ) + } + ], +) +async def test_sending_mqtt_commands_and_optimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending MQTT commands in optimistic mode.""" await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("siren.test") assert state.state == STATE_OFF @@ -167,15 +168,9 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.state == STATE_OFF -async def test_controlling_state_via_topic_and_json_message( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the controlling state via topic and JSON message.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { siren.DOMAIN: { @@ -187,10 +182,16 @@ async def test_controlling_state_via_topic_and_json_message( "state_value_template": "{{ value_json.val }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic_and_json_message( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic and JSON message.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("siren.test") assert state.state == STATE_UNKNOWN @@ -210,15 +211,9 @@ async def test_controlling_state_via_topic_and_json_message( assert state.state == STATE_OFF -async def test_controlling_state_and_attributes_with_json_message_without_template( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the controlling state via topic and JSON message without a value template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { siren.DOMAIN: { @@ -230,10 +225,16 @@ async def test_controlling_state_and_attributes_with_json_message_without_templa "available_tones": ["ping", "siren", "bell"], } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_and_attributes_with_json_message_without_template( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic and JSON message without a value template.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("siren.test") assert state.state == STATE_UNKNOWN @@ -292,31 +293,41 @@ async def test_controlling_state_and_attributes_with_json_message_without_templa assert state.attributes.get(siren.ATTR_VOLUME_LEVEL) == 0.6 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + siren.DOMAIN, + { + mqtt.DOMAIN: { + siren.DOMAIN: { + "command_topic": "command-topic", + } + } + }, + ( + { + "name": "test1", + "available_tones": ["ping", "siren", "bell"], + "support_duration": False, + }, + { + "name": "test2", + "available_tones": ["ping", "siren", "bell"], + "support_volume_set": False, + }, + { + "name": "test3", + }, + ), + ) + ], +) async def test_filtering_not_supported_attributes_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting attributes with support flags optimistic.""" - config = { - "command_topic": "command-topic", - "available_tones": ["ping", "siren", "bell"], - } - config1 = copy.deepcopy(config) - config1["name"] = "test1" - config1["support_duration"] = False - config2 = copy.deepcopy(config) - config2["name"] = "test2" - config2["support_volume_set"] = False - config3 = copy.deepcopy(config) - config3["name"] = "test3" - del config3["available_tones"] - - assert await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {siren.DOMAIN: [config1, config2, config3]}}, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state1 = hass.states.get("siren.test1") assert state1.state == STATE_OFF @@ -377,34 +388,44 @@ async def test_filtering_not_supported_attributes_optimistic( assert state3.attributes.get(siren.ATTR_VOLUME_LEVEL) == 0.88 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + siren.DOMAIN, + { + mqtt.DOMAIN: { + siren.DOMAIN: { + "command_topic": "command-topic", + } + } + }, + ( + { + "name": "test1", + "state_topic": "state-topic1", + "available_tones": ["ping", "siren", "bell"], + "support_duration": False, + }, + { + "name": "test2", + "state_topic": "state-topic2", + "available_tones": ["ping", "siren", "bell"], + "support_volume_set": False, + }, + { + "name": "test3", + "state_topic": "state-topic3", + }, + ), + ) + ], +) async def test_filtering_not_supported_attributes_via_state( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting attributes with support flags via state.""" - config = { - "command_topic": "command-topic", - "available_tones": ["ping", "siren", "bell"], - } - config1 = copy.deepcopy(config) - config1["name"] = "test1" - config1["state_topic"] = "state-topic1" - config1["support_duration"] = False - config2 = copy.deepcopy(config) - config2["name"] = "test2" - config2["state_topic"] = "state-topic2" - config2["support_volume_set"] = False - config3 = copy.deepcopy(config) - config3["name"] = "test3" - config3["state_topic"] = "state-topic3" - del config3["available_tones"] - - assert await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {siren.DOMAIN: [config1, config2, config3]}}, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state1 = hass.states.get("siren.test1") assert state1.state == STATE_UNKNOWN @@ -529,13 +550,9 @@ async def test_custom_availability_payload( ) -async def test_custom_state_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the state payload.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { siren.DOMAIN: { @@ -548,10 +565,14 @@ async def test_custom_state_payload( "state_off": "LOW", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_custom_state_payload( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the state payload.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("siren.test") assert state.state == STATE_UNKNOWN @@ -762,30 +783,36 @@ async def test_discovery_update_siren_template( ) +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + siren.DOMAIN, + DEFAULT_CONFIG, + ( + { + "name": "Beer", + "available_tones": ["ping", "chimes"], + "command_template": "CMD: {{ value }}, DURATION: {{ duration }}," + " TONE: {{ tone }}, VOLUME: {{ volume_level }}", + }, + { + "name": "Milk", + "available_tones": ["ping", "chimes"], + "command_template": "CMD: {{ value }}, DURATION: {{ duration }}," + " TONE: {{ tone }}, VOLUME: {{ volume_level }}", + "command_off_template": "CMD_OFF: {{ value }}", + }, + ), + ) + ], +) async def test_command_templates( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test siren with command templates optimistic.""" - config1 = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][siren.DOMAIN]) - config1["name"] = "Beer" - config1["available_tones"] = ["ping", "chimes"] - config1[ - "command_template" - ] = "CMD: {{ value }}, DURATION: {{ duration }}, TONE: {{ tone }}, VOLUME: {{ volume_level }}" - - config2 = copy.deepcopy(config1) - config2["name"] = "Milk" - config2["command_off_template"] = "CMD_OFF: {{ value }}" - - assert await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {siren.DOMAIN: [config1, config2]}}, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state1 = hass.states.get("siren.beer") assert state1.state == STATE_OFF diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_state_vacuum.py index a5c8381310..dffaebca17 100644 --- a/tests/components/mqtt/test_state_vacuum.py +++ b/tests/components/mqtt/test_state_vacuum.py @@ -29,9 +29,9 @@ from homeassistant.components.vacuum import ( ) from homeassistant.const import CONF_NAME, ENTITY_MATCH_ALL, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -83,6 +83,18 @@ DEFAULT_CONFIG = { DEFAULT_CONFIG_2 = {mqtt.DOMAIN: {vacuum.DOMAIN: {"schema": "state", "name": "test"}}} +CONFIG_ALL_SERVICES = help_custom_config( + vacuum.DOMAIN, + DEFAULT_CONFIG, + ( + { + mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( + mqttvacuum.ALL_SERVICES, SERVICE_TO_STRING + ) + }, + ), +) + @pytest.fixture(autouse=True) def vacuum_platform_only(): @@ -91,13 +103,12 @@ def vacuum_platform_only(): yield +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_default_supported_features( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that the correct supported features.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() entity = hass.states.get("vacuum.mqtttest") entity_features = entity.attributes.get(mqttvacuum.CONF_SUPPORTED_FEATURES, 0) assert sorted(services_to_strings(entity_features, SERVICE_TO_STRING)) == sorted( @@ -105,20 +116,12 @@ async def test_default_supported_features( ) +@pytest.mark.parametrize("hass_config", [CONFIG_ALL_SERVICES]) async def test_all_commands( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test simple commands send to the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - mqttvacuum.ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( DOMAIN, SERVICE_START, {"entity_id": ENTITY_MATCH_ALL}, blocking=True @@ -181,21 +184,27 @@ async def test_all_commands( } +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + vacuum.DOMAIN, + DEFAULT_CONFIG, + ( + { + mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( + mqttvacuum.STRING_TO_SERVICE["status"], SERVICE_TO_STRING + ) + }, + ), + ) + ], +) async def test_commands_without_supported_features( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test commands which are not supported by the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - services = mqttvacuum.STRING_TO_SERVICE["status"] - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - services, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( DOMAIN, SERVICE_START, {"entity_id": ENTITY_MATCH_ALL}, blocking=True @@ -243,20 +252,12 @@ async def test_commands_without_supported_features( mqtt_mock.async_publish.assert_not_called() +@pytest.mark.parametrize("hass_config", [CONFIG_ALL_SERVICES]) async def test_status( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - mqttvacuum.ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("vacuum.mqtttest") assert state.state == STATE_UNKNOWN @@ -292,21 +293,27 @@ async def test_status( assert state.state == STATE_UNKNOWN +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + vacuum.DOMAIN, + DEFAULT_CONFIG, + ( + { + mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( + mqttvacuum.DEFAULT_SERVICES, SERVICE_TO_STRING + ) + }, + ), + ) + ], +) async def test_no_fan_vacuum( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum when fan is not supported.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - del config[mqttvacuum.CONF_FAN_SPEED_LIST] - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - mqttvacuum.DEFAULT_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() message = """{ "battery_level": 54, @@ -347,21 +354,13 @@ async def test_no_fan_vacuum( assert state.attributes.get(ATTR_BATTERY_LEVEL) == 61 +@pytest.mark.parametrize("hass_config", [CONFIG_ALL_SERVICES]) @pytest.mark.no_fail_on_log_exception async def test_status_invalid_json( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test to make sure nothing breaks if the vacuum sends bad JSON.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - mqttvacuum.ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "vacuum/state", '{"asdfasas false}') state = hass.states.get("vacuum.mqtttest") From 185d6d74d75290cb331c2ebc98546c2ad17d9beb Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 23 Mar 2023 19:17:27 +0100 Subject: [PATCH 1209/2810] Prepare MQTT platform tests part9 (#90133) * Tests switch * Tests text * Tests update --- tests/components/mqtt/test_switch.py | 117 +++++++------ tests/components/mqtt/test_text.py | 137 +++++++-------- tests/components/mqtt/test_update.py | 238 +++++++++++++-------------- 3 files changed, 249 insertions(+), 243 deletions(-) diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py index 83580edf00..79f2bcc4a7 100644 --- a/tests/components/mqtt/test_switch.py +++ b/tests/components/mqtt/test_switch.py @@ -15,7 +15,6 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, State -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -62,13 +61,9 @@ def switch_platform_only(): yield -async def test_controlling_state_via_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { switch.DOMAIN: { @@ -80,10 +75,14 @@ async def test_controlling_state_via_topic( "device_class": "switch", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("switch.test") assert state.state == STATE_UNKNOWN @@ -106,16 +105,9 @@ async def test_controlling_state_via_topic( assert state.state == STATE_UNKNOWN -async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the sending MQTT commands in optimistic mode.""" - fake_state = State("switch.test", "on") - mock_restore_cache(hass, (fake_state,)) - - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { switch.DOMAIN: { @@ -126,10 +118,17 @@ async def test_sending_mqtt_commands_and_optimistic( "qos": "2", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_and_optimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending MQTT commands in optimistic mode.""" + fake_state = State("switch.test", "on") + mock_restore_cache(hass, (fake_state,)) + + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("switch.test") assert state.state == STATE_ON @@ -153,13 +152,9 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.state == STATE_OFF -async def test_sending_inital_state_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the initial state in optimistic mode.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { switch.DOMAIN: { @@ -167,23 +162,23 @@ async def test_sending_inital_state_and_optimistic( "command_topic": "command-topic", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_inital_state_and_optimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the initial state in optimistic mode.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("switch.test") assert state.state == STATE_UNKNOWN assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_controlling_state_via_topic_and_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via topic and JSON message.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { switch.DOMAIN: { @@ -195,10 +190,14 @@ async def test_controlling_state_via_topic_and_json_message( "value_template": "{{ value_json.val }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic_and_json_message( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via topic and JSON message.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("switch.test") assert state.state == STATE_UNKNOWN @@ -292,13 +291,9 @@ async def test_custom_availability_payload( ) -async def test_custom_state_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the state payload.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { switch.DOMAIN: { @@ -311,10 +306,14 @@ async def test_custom_state_payload( "state_off": "LOW", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_custom_state_payload( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the state payload.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("switch.test") assert state.state == STATE_UNKNOWN diff --git a/tests/components/mqtt/test_text.py b/tests/components/mqtt/test_text.py index d12a03a9fa..10e9f0780d 100644 --- a/tests/components/mqtt/test_text.py +++ b/tests/components/mqtt/test_text.py @@ -14,7 +14,6 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -72,13 +71,9 @@ async def async_set_value( ) -async def test_controlling_state_via_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { text.DOMAIN: { @@ -88,10 +83,14 @@ async def test_controlling_state_via_topic( "mode": "password", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("text.test") assert state.state == STATE_UNKNOWN @@ -114,15 +113,9 @@ async def test_controlling_state_via_topic( assert state.state == "" -async def test_controlling_validation_state_via_topic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the validation of a received state.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { text.DOMAIN: { @@ -135,10 +128,16 @@ async def test_controlling_validation_state_via_topic( "pattern": "(y|n)", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_validation_state_via_topic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the validation of a received state.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("text.test") assert state.state == STATE_UNKNOWN @@ -188,11 +187,9 @@ async def test_controlling_validation_state_via_topic( assert state.state == "no" -async def test_attribute_validation_max_greater_then_min(hass: HomeAssistant) -> None: - """Test the validation of min and max configuration attributes.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { text.DOMAIN: { @@ -202,17 +199,20 @@ async def test_attribute_validation_max_greater_then_min(hass: HomeAssistant) -> "max": 10, } } - }, - ) - - -async def test_attribute_validation_max_not_greater_then_max_state_length( - hass: HomeAssistant, + } + ], +) +async def test_attribute_validation_max_greater_then_min( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: - """Test the max value of of max configuration attribute.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, + """Test the validation of min and max configuration attributes.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() + + +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { text.DOMAIN: { @@ -222,17 +222,20 @@ async def test_attribute_validation_max_not_greater_then_max_state_length( "max": 257, } } - }, - ) - - -async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + } + ], +) +async def test_attribute_validation_max_not_greater_then_max_state_length( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: - """Test the sending MQTT commands in optimistic mode.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, + """Test the max value of of max configuration attribute.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() + + +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { text.DOMAIN: { @@ -241,10 +244,14 @@ async def test_sending_mqtt_commands_and_optimistic( "qos": "2", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_and_optimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending MQTT commands in optimistic mode.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("text.test") assert state.state == STATE_UNKNOWN @@ -269,13 +276,9 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.state == "some new state" -async def test_set_text_validation( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the initial state in optimistic mode.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { text.DOMAIN: { @@ -287,10 +290,14 @@ async def test_set_text_validation( "pattern": "(y|n)", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_set_text_validation( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the initial state in optimistic mode.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("text.test") assert state.state == STATE_UNKNOWN diff --git a/tests/components/mqtt/test_update.py b/tests/components/mqtt/test_update.py index 200a3ca6dd..bdd85768b8 100644 --- a/tests/components/mqtt/test_update.py +++ b/tests/components/mqtt/test_update.py @@ -14,7 +14,6 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -63,20 +62,14 @@ def update_platform_only(): yield -async def test_run_update_setup( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that it fetches the given payload.""" - installed_version_topic = "test/installed-version" - latest_version_topic = "test/latest-version" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { update.DOMAIN: { - "state_topic": installed_version_topic, - "latest_version_topic": latest_version_topic, + "state_topic": "test/installed-version", + "latest_version_topic": "test/latest-version", "name": "Test Update", "release_summary": "Test release summary", "release_url": "https://example.com/release", @@ -84,10 +77,16 @@ async def test_run_update_setup( "entity_picture": "https://example.com/icon.png", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_run_update_setup( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that it fetches the given payload.""" + installed_version_topic = "test/installed-version" + latest_version_topic = "test/latest-version" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, installed_version_topic, "1.9.0") async_fire_mqtt_message(hass, latest_version_topic, "1.9.0") @@ -113,20 +112,14 @@ async def test_run_update_setup( assert state.attributes.get("latest_version") == "2.0.0" -async def test_run_update_setup_float( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that it fetches the given payload when the version is parsable as a number.""" - installed_version_topic = "test/installed-version" - latest_version_topic = "test/latest-version" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { update.DOMAIN: { - "state_topic": installed_version_topic, - "latest_version_topic": latest_version_topic, + "state_topic": "test/installed-version", + "latest_version_topic": "test/latest-version", "name": "Test Update", "release_summary": "Test release summary", "release_url": "https://example.com/release", @@ -134,10 +127,16 @@ async def test_run_update_setup_float( "entity_picture": "https://example.com/icon.png", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_run_update_setup_float( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that it fetches the given payload when the version is parsable as a number.""" + installed_version_topic = "test/installed-version" + latest_version_topic = "test/latest-version" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, installed_version_topic, "1.9") async_fire_mqtt_message(hass, latest_version_topic, "1.9") @@ -163,29 +162,29 @@ async def test_run_update_setup_float( assert state.attributes.get("latest_version") == "2.0" -async def test_value_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that it fetches the given payload with a template.""" - installed_version_topic = "test/installed-version" - latest_version_topic = "test/latest-version" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { update.DOMAIN: { - "state_topic": installed_version_topic, + "state_topic": "test/installed-version", "value_template": "{{ value_json.installed }}", - "latest_version_topic": latest_version_topic, + "latest_version_topic": "test/latest-version", "latest_version_template": "{{ value_json.latest }}", "name": "Test Update", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_value_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that it fetches the given payload with a template.""" + installed_version_topic = "test/installed-version" + latest_version_topic = "test/latest-version" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, installed_version_topic, '{"installed":"1.9.0"}') async_fire_mqtt_message(hass, latest_version_topic, '{"latest":"1.9.0"}') @@ -211,29 +210,29 @@ async def test_value_template( assert state.attributes.get("latest_version") == "2.0.0" -async def test_value_template_float( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that it fetches the given payload with a template when the version is parsable as a number.""" - installed_version_topic = "test/installed-version" - latest_version_topic = "test/latest-version" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { update.DOMAIN: { - "state_topic": installed_version_topic, + "state_topic": "test/installed-version", "value_template": "{{ value_json.installed }}", - "latest_version_topic": latest_version_topic, + "latest_version_topic": "test/latest-version", "latest_version_template": "{{ value_json.latest }}", "name": "Test Update", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_value_template_float( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that it fetches the given payload with a template when the version is parsable as a number.""" + installed_version_topic = "test/installed-version" + latest_version_topic = "test/latest-version" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, installed_version_topic, '{"installed":"1.9"}') async_fire_mqtt_message(hass, latest_version_topic, '{"latest":"1.9"}') @@ -259,25 +258,25 @@ async def test_value_template_float( assert state.attributes.get("latest_version") == "2.0" -async def test_empty_json_state_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test an empty JSON payload.""" - state_topic = "test/state-topic" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { update.DOMAIN: { - "state_topic": state_topic, + "state_topic": "test/state-topic", "name": "Test Update", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_empty_json_state_message( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test an empty JSON payload.""" + state_topic = "test/state-topic" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, state_topic, "{}") @@ -287,25 +286,25 @@ async def test_empty_json_state_message( assert state.state == STATE_UNKNOWN -async def test_json_state_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test whether it fetches data from a JSON payload.""" - state_topic = "test/state-topic" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { update.DOMAIN: { - "state_topic": state_topic, + "state_topic": "test/state-topic", "name": "Test Update", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_json_state_message( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test whether it fetches data from a JSON payload.""" + state_topic = "test/state-topic" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message( hass, @@ -343,26 +342,27 @@ async def test_json_state_message( assert state.attributes.get("entity_picture") == "https://example.com/icon2.png" -async def test_json_state_message_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test whether it fetches data from a JSON payload with template.""" - state_topic = "test/state-topic" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { update.DOMAIN: { - "state_topic": state_topic, - "value_template": '{{ {"installed_version": value_json.installed, "latest_version": value_json.latest} | to_json }}', + "state_topic": "test/state-topic", + "value_template": '{{ {"installed_version": value_json.installed, ' + '"latest_version": value_json.latest} | to_json }}', "name": "Test Update", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_json_state_message_with_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test whether it fetches data from a JSON payload with template.""" + state_topic = "test/state-topic" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, state_topic, '{"installed":"1.9.0","latest":"1.9.0"}') @@ -383,31 +383,31 @@ async def test_json_state_message_with_template( assert state.attributes.get("latest_version") == "2.0.0" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + update.DOMAIN: { + "state_topic": "test/installed-version", + "latest_version_topic": "test/latest-version", + "command_topic": "test/install-command", + "payload_install": "install", + "name": "Test Update", + } + } + } + ], +) async def test_run_install_service( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that install service works.""" installed_version_topic = "test/installed-version" latest_version_topic = "test/latest-version" command_topic = "test/install-command" - await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - update.DOMAIN: { - "state_topic": installed_version_topic, - "latest_version_topic": latest_version_topic, - "command_topic": command_topic, - "payload_install": "install", - "name": "Test Update", - } - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, installed_version_topic, "1.9.0") async_fire_mqtt_message(hass, latest_version_topic, "2.0.0") From 3e3ece4e56d26b74445dfc235102c68e57ac15c3 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 23 Mar 2023 13:44:19 -0500 Subject: [PATCH 1210/2810] Add speech to text over binary websocket to pipeline (#90082) * Allow passing binary to the WS connection * Expand test coverage * Test non-existing handler * Add text to speech and stages to pipeline * Default to "cloud" TTS when engine is None * Refactor pipeline request to split text/audio * Refactor with PipelineRun * Generate pipeline from language * Clean up * Restore TTS code * Add audio pipeline test * Clean TTS cache in test * Clean up tests and pipeline base class * Stop pylint and pytest magics from fighting * Include mock_get_cache_files * Working on STT * Preparing to test * First successful test * Send handler_id * Allow signaling end of stream using empty payloads * Store handlers in a list * Handle binary handlers raising exceptions * Add stt/tts dependencies to voice_assistant * Include STT audio in pipeline test * Working on tests * Refactoring with stages * Fix tests * Add more tests * Add method docs * Change stt demo/cloud to AsyncIterable * Add pipeline error events * Move handler id to separate message before pipeline * Add test for invalid stage order * Change "finish" to "end" * Use enum --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/cloud/stt.py | 5 +- homeassistant/components/demo/stt.py | 6 +- homeassistant/components/stt/__init__.py | 5 +- .../components/voice_assistant/manifest.json | 2 +- .../components/voice_assistant/pipeline.py | 356 ++++++++++--- .../voice_assistant/websocket_api.py | 127 +++-- tests/components/stt/test_init.py | 4 +- .../voice_assistant/test_pipeline.py | 110 ---- .../voice_assistant/test_websocket.py | 479 +++++++++++++++++- 9 files changed, 860 insertions(+), 234 deletions(-) delete mode 100644 tests/components/voice_assistant/test_pipeline.py diff --git a/homeassistant/components/cloud/stt.py b/homeassistant/components/cloud/stt.py index 70618ab38e..bdce055c3c 100644 --- a/homeassistant/components/cloud/stt.py +++ b/homeassistant/components/cloud/stt.py @@ -1,7 +1,8 @@ """Support for the cloud for speech to text service.""" from __future__ import annotations -from aiohttp import StreamReader +from collections.abc import AsyncIterable + from hass_nabucasa import Cloud from hass_nabucasa.voice import VoiceError @@ -88,7 +89,7 @@ class CloudProvider(Provider): return [AudioChannels.CHANNEL_MONO] async def async_process_audio_stream( - self, metadata: SpeechMetadata, stream: StreamReader + self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] ) -> SpeechResult: """Process an audio stream to STT service.""" content = ( diff --git a/homeassistant/components/demo/stt.py b/homeassistant/components/demo/stt.py index 9c3cf89d80..923092fad2 100644 --- a/homeassistant/components/demo/stt.py +++ b/homeassistant/components/demo/stt.py @@ -1,7 +1,7 @@ """Support for the demo for speech to text service.""" from __future__ import annotations -from aiohttp import StreamReader +from collections.abc import AsyncIterable from homeassistant.components.stt import ( AudioBitRates, @@ -63,12 +63,12 @@ class DemoProvider(Provider): return [AudioChannels.CHANNEL_STEREO] async def async_process_audio_stream( - self, metadata: SpeechMetadata, stream: StreamReader + self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] ) -> SpeechResult: """Process an audio stream to STT service.""" # Read available data - async for _ in stream.iter_chunked(4096): + async for _ in stream: pass return SpeechResult("Turn the Kitchen Lights on", SpeechResultState.SUCCESS) diff --git a/homeassistant/components/stt/__init__.py b/homeassistant/components/stt/__init__.py index 94e08d2536..6319940219 100644 --- a/homeassistant/components/stt/__init__.py +++ b/homeassistant/components/stt/__init__.py @@ -3,11 +3,12 @@ from __future__ import annotations from abc import ABC, abstractmethod import asyncio +from collections.abc import AsyncIterable from dataclasses import asdict, dataclass import logging from typing import Any -from aiohttp import StreamReader, web +from aiohttp import web from aiohttp.hdrs import istr from aiohttp.web_exceptions import ( HTTPBadRequest, @@ -153,7 +154,7 @@ class Provider(ABC): @abstractmethod async def async_process_audio_stream( - self, metadata: SpeechMetadata, stream: StreamReader + self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] ) -> SpeechResult: """Process an audio stream to STT service. diff --git a/homeassistant/components/voice_assistant/manifest.json b/homeassistant/components/voice_assistant/manifest.json index 6d353660b3..644c49e945 100644 --- a/homeassistant/components/voice_assistant/manifest.json +++ b/homeassistant/components/voice_assistant/manifest.json @@ -2,7 +2,7 @@ "domain": "voice_assistant", "name": "Voice Assistant", "codeowners": ["@balloob", "@synesthesiam"], - "dependencies": ["conversation"], + "dependencies": ["conversation", "stt", "tts"], "documentation": "https://www.home-assistant.io/integrations/voice_assistant", "iot_class": "local_push", "quality_scale": "internal" diff --git a/homeassistant/components/voice_assistant/pipeline.py b/homeassistant/components/voice_assistant/pipeline.py index 0b55d72455..0070154bd4 100644 --- a/homeassistant/components/voice_assistant/pipeline.py +++ b/homeassistant/components/voice_assistant/pipeline.py @@ -1,33 +1,80 @@ """Classes for voice assistant pipelines.""" from __future__ import annotations -from abc import ABC, abstractmethod import asyncio -from collections.abc import Callable -from dataclasses import dataclass, field +from collections.abc import AsyncIterable, Callable +from dataclasses import asdict, dataclass, field +import logging from typing import Any from homeassistant.backports.enum import StrEnum -from homeassistant.components import conversation -from homeassistant.components.media_source import async_resolve_media +from homeassistant.components import conversation, media_source, stt from homeassistant.components.tts.media_source import ( generate_media_source_id as tts_generate_media_source_id, ) -from homeassistant.core import Context, HomeAssistant +from homeassistant.core import Context, HomeAssistant, callback from homeassistant.util.dt import utcnow +from .const import DOMAIN + DEFAULT_TIMEOUT = 30 # seconds +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_get_pipeline( + hass: HomeAssistant, pipeline_id: str | None = None, language: str | None = None +) -> Pipeline | None: + """Get a pipeline by id or create one for a language.""" + if pipeline_id is not None: + return hass.data[DOMAIN].get(pipeline_id) + + # Construct a pipeline for the required/configured language + language = language or hass.config.language + return Pipeline( + name=language, + language=language, + stt_engine=None, # first engine + conversation_engine=None, # first agent + tts_engine=None, # first engine + ) + + +class PipelineError(Exception): + """Base class for pipeline errors.""" + + def __init__(self, code: str, message: str) -> None: + """Set error message.""" + self.code = code + self.message = message + + super().__init__(f"Pipeline error code={code}, message={message}") + + +class SpeechToTextError(PipelineError): + """Error in speech to text portion of pipeline.""" + + +class IntentRecognitionError(PipelineError): + """Error in intent recognition portion of pipeline.""" + + +class TextToSpeechError(PipelineError): + """Error in text to speech portion of pipeline.""" + class PipelineEventType(StrEnum): """Event types emitted during a pipeline run.""" RUN_START = "run-start" - RUN_FINISH = "run-finish" + RUN_END = "run-end" + STT_START = "stt-start" + STT_END = "stt-end" INTENT_START = "intent-start" - INTENT_FINISH = "intent-finish" + INTENT_END = "intent-end" TTS_START = "tts-start" - TTS_FINISH = "tts-finish" + TTS_END = "tts-end" ERROR = "error" @@ -54,10 +101,44 @@ class Pipeline: name: str language: str | None + stt_engine: str | None conversation_engine: str | None tts_engine: str | None +class PipelineStage(StrEnum): + """Stages of a pipeline.""" + + STT = "stt" + INTENT = "intent" + TTS = "tts" + + +PIPELINE_STAGE_ORDER = [ + PipelineStage.STT, + PipelineStage.INTENT, + PipelineStage.TTS, +] + + +class PipelineRunValidationError(Exception): + """Error when a pipeline run is not valid.""" + + +class InvalidPipelineStagesError(PipelineRunValidationError): + """Error when given an invalid combination of start/end stages.""" + + def __init__( + self, + start_stage: PipelineStage, + end_stage: PipelineStage, + ) -> None: + """Set error message.""" + super().__init__( + f"Invalid stage combination: start={start_stage}, end={end_stage}" + ) + + @dataclass class PipelineRun: """Running context for a pipeline.""" @@ -65,6 +146,8 @@ class PipelineRun: hass: HomeAssistant context: Context pipeline: Pipeline + start_stage: PipelineStage + end_stage: PipelineStage event_callback: Callable[[PipelineEvent], None] language: str = None # type: ignore[assignment] @@ -72,6 +155,12 @@ class PipelineRun: """Set language for pipeline.""" self.language = self.pipeline.language or self.hass.config.language + # stt -> intent -> tts + if PIPELINE_STAGE_ORDER.index(self.end_stage) < PIPELINE_STAGE_ORDER.index( + self.start_stage + ): + raise InvalidPipelineStagesError(self.start_stage, self.end_stage) + def start(self): """Emit run start event.""" self.event_callback( @@ -84,18 +173,86 @@ class PipelineRun: ) ) - def finish(self): - """Emit run finish event.""" + def end(self): + """Emit run end event.""" self.event_callback( PipelineEvent( - PipelineEventType.RUN_FINISH, + PipelineEventType.RUN_END, ) ) + async def speech_to_text( + self, + metadata: stt.SpeechMetadata, + stream: AsyncIterable[bytes], + ) -> str: + """Run speech to text portion of pipeline. Returns the spoken text.""" + engine = self.pipeline.stt_engine or "default" + self.event_callback( + PipelineEvent( + PipelineEventType.STT_START, + { + "engine": engine, + "metadata": asdict(metadata), + }, + ) + ) + + try: + # Load provider + stt_provider = stt.async_get_provider(self.hass, self.pipeline.stt_engine) + assert stt_provider is not None + except Exception as src_error: + stt_error = SpeechToTextError( + code="stt-provider-missing", + message=f"No speech to text provider for: {engine}", + ) + _LOGGER.exception(stt_error.message) + self.event_callback( + PipelineEvent( + PipelineEventType.ERROR, + {"code": stt_error.code, "message": stt_error.message}, + ) + ) + raise stt_error from src_error + + try: + # Transcribe audio stream + result = await stt_provider.async_process_audio_stream(metadata, stream) + assert (result.text is not None) and ( + result.result == stt.SpeechResultState.SUCCESS + ) + except Exception as src_error: + stt_error = SpeechToTextError( + code="stt-stream-failed", + message="Unexpected error during speech to text", + ) + _LOGGER.exception(stt_error.message) + self.event_callback( + PipelineEvent( + PipelineEventType.ERROR, + {"code": stt_error.code, "message": stt_error.message}, + ) + ) + raise stt_error from src_error + + self.event_callback( + PipelineEvent( + PipelineEventType.STT_END, + { + "stt_output": { + "text": result.text, + } + }, + ) + ) + + return result.text + async def recognize_intent( self, intent_input: str, conversation_id: str | None - ) -> conversation.ConversationResult: - """Run intent recognition portion of pipeline.""" + ) -> str: + """Run intent recognition portion of pipeline. Returns text to speak.""" self.event_callback( PipelineEvent( PipelineEventType.INTENT_START, @@ -106,23 +263,39 @@ class PipelineRun: ) ) - conversation_result = await conversation.async_converse( - hass=self.hass, - text=intent_input, - conversation_id=conversation_id, - context=self.context, - language=self.language, - agent_id=self.pipeline.conversation_engine, - ) + try: + conversation_result = await conversation.async_converse( + hass=self.hass, + text=intent_input, + conversation_id=conversation_id, + context=self.context, + language=self.language, + agent_id=self.pipeline.conversation_engine, + ) + except Exception as src_error: + intent_error = IntentRecognitionError( + code="intent-failed", + message="Unexpected error during intent recognition", + ) + _LOGGER.exception(intent_error.message) + self.event_callback( + PipelineEvent( + PipelineEventType.ERROR, + {"code": intent_error.code, "message": intent_error.message}, + ) + ) + raise intent_error from src_error self.event_callback( PipelineEvent( - PipelineEventType.INTENT_FINISH, + PipelineEventType.INTENT_END, {"intent_output": conversation_result.as_dict()}, ) ) - return conversation_result + speech = conversation_result.response.speech.get("plain", {}).get("speech", "") + + return speech async def text_to_speech(self, tts_input: str) -> str: """Run text to speech portion of pipeline. Returns URL of TTS audio.""" @@ -136,29 +309,57 @@ class PipelineRun: ) ) - tts_media = await async_resolve_media( - self.hass, - tts_generate_media_source_id( + try: + # Synthesize audio and get URL + tts_media = await media_source.async_resolve_media( self.hass, - tts_input, - engine=self.pipeline.tts_engine, - ), - ) - tts_url = tts_media.url + tts_generate_media_source_id( + self.hass, + tts_input, + engine=self.pipeline.tts_engine, + ), + ) + except Exception as src_error: + tts_error = TextToSpeechError( + code="tts-failed", + message="Unexpected error during text to speech", + ) + _LOGGER.exception(tts_error.message) + self.event_callback( + PipelineEvent( + PipelineEventType.ERROR, + {"code": tts_error.code, "message": tts_error.message}, + ) + ) + raise tts_error from src_error self.event_callback( PipelineEvent( - PipelineEventType.TTS_FINISH, - {"tts_output": tts_url}, + PipelineEventType.TTS_END, + {"tts_output": asdict(tts_media)}, ) ) - return tts_url + return tts_media.url @dataclass -class PipelineRequest(ABC): - """Request to for a pipeline run.""" +class PipelineInput: + """Input to a pipeline run.""" + + stt_metadata: stt.SpeechMetadata | None = None + """Metadata of stt input audio. Required when start_stage = stt.""" + + stt_stream: AsyncIterable[bytes] | None = None + """Input audio for stt. Required when start_stage = stt.""" + + intent_input: str | None = None + """Input for conversation agent. Required when start_stage = intent.""" + + tts_input: str | None = None + """Input for text to speech. Required when start_stage = tts.""" + + conversation_id: str | None = None async def execute( self, run: PipelineRun, timeout: int | float | None = DEFAULT_TIMEOUT @@ -169,47 +370,60 @@ class PipelineRequest(ABC): timeout=timeout, ) - @abstractmethod async def _execute(self, run: PipelineRun): - """Run pipeline with request info and context.""" + self._validate(run.start_stage) - -@dataclass -class TextPipelineRequest(PipelineRequest): - """Request to run the text portion only of a pipeline.""" - - intent_input: str - conversation_id: str | None = None - - async def _execute( - self, - run: PipelineRun, - ): + # stt -> intent -> tts run.start() - await run.recognize_intent(self.intent_input, self.conversation_id) - run.finish() + current_stage = run.start_stage + # Speech to text + intent_input = self.intent_input + if current_stage == PipelineStage.STT: + assert self.stt_metadata is not None + assert self.stt_stream is not None + intent_input = await run.speech_to_text( + self.stt_metadata, + self.stt_stream, + ) + current_stage = PipelineStage.INTENT -@dataclass -class AudioPipelineRequest(PipelineRequest): - """Request to full pipeline from audio input (stt) to audio output (tts).""" + if run.end_stage != PipelineStage.STT: + tts_input = self.tts_input - intent_input: str # this will be changed to stt audio - conversation_id: str | None = None + if current_stage == PipelineStage.INTENT: + assert intent_input is not None + tts_input = await run.recognize_intent( + intent_input, self.conversation_id + ) + current_stage = PipelineStage.TTS - async def _execute(self, run: PipelineRun): - run.start() + if run.end_stage != PipelineStage.INTENT: + if current_stage == PipelineStage.TTS: + assert tts_input is not None + await run.text_to_speech(tts_input) - # stt will go here + run.end() - conversation_result = await run.recognize_intent( - self.intent_input, self.conversation_id - ) + def _validate(self, stage: PipelineStage): + """Validate pipeline input against start stage.""" + if stage == PipelineStage.STT: + if self.stt_metadata is None: + raise PipelineRunValidationError( + "stt_metadata is required for speech to text" + ) - tts_input = conversation_result.response.speech.get("plain", {}).get( - "speech", "" - ) - - await run.text_to_speech(tts_input) - - run.finish() + if self.stt_stream is None: + raise PipelineRunValidationError( + "stt_stream is required for speech to text" + ) + elif stage == PipelineStage.INTENT: + if self.intent_input is None: + raise PipelineRunValidationError( + "intent_input is required for intent recognition" + ) + elif stage == PipelineStage.TTS: + if self.tts_input is None: + raise PipelineRunValidationError( + "tts_input is required for text to speech" + ) diff --git a/homeassistant/components/voice_assistant/websocket_api.py b/homeassistant/components/voice_assistant/websocket_api.py index 54e87e292a..cc4799f13e 100644 --- a/homeassistant/components/voice_assistant/websocket_api.py +++ b/homeassistant/components/voice_assistant/websocket_api.py @@ -1,13 +1,24 @@ """Voice Assistant Websocket API.""" +import asyncio +from collections.abc import Callable +import logging from typing import Any import voluptuous as vol -from homeassistant.components import websocket_api +from homeassistant.components import stt, websocket_api from homeassistant.core import HomeAssistant, callback -from .const import DOMAIN -from .pipeline import DEFAULT_TIMEOUT, Pipeline, PipelineRun, TextPipelineRequest +from .pipeline import ( + DEFAULT_TIMEOUT, + PipelineError, + PipelineInput, + PipelineRun, + PipelineStage, + async_get_pipeline, +) + +_LOGGER = logging.getLogger(__name__) @callback @@ -19,9 +30,13 @@ def async_register_websocket_api(hass: HomeAssistant) -> None: @websocket_api.websocket_command( { vol.Required("type"): "voice_assistant/run", + # pylint: disable-next=unnecessary-lambda + vol.Required("start_stage"): lambda val: PipelineStage(val), + # pylint: disable-next=unnecessary-lambda + vol.Required("end_stage"): lambda val: PipelineStage(val), + vol.Optional("input"): {"text": str}, vol.Optional("language"): str, vol.Optional("pipeline"): str, - vol.Required("intent_input"): str, vol.Optional("conversation_id"): vol.Any(str, None), vol.Optional("timeout"): vol.Any(float, int), } @@ -33,39 +48,74 @@ async def websocket_run( msg: dict[str, Any], ) -> None: """Run a pipeline.""" + language = msg.get("language", hass.config.language) pipeline_id = msg.get("pipeline") - if pipeline_id is not None: - pipeline = hass.data[DOMAIN].get(pipeline_id) - if pipeline is None: - connection.send_error( - msg["id"], - "pipeline_not_found", - f"Pipeline not found: {pipeline_id}", - ) - return + pipeline = async_get_pipeline( + hass, + pipeline_id=pipeline_id, + language=language, + ) + if pipeline is None: + connection.send_error( + msg["id"], + "pipeline-not-found", + f"Pipeline not found: id={pipeline_id}, language={language}", + ) + return - else: - # Construct a pipeline for the required/configured language - language = msg.get("language", hass.config.language) - pipeline = Pipeline( - name=language, - language=language, - conversation_engine=None, - tts_engine=None, + timeout = msg.get("timeout", DEFAULT_TIMEOUT) + start_stage = PipelineStage(msg["start_stage"]) + end_stage = PipelineStage(msg["end_stage"]) + handler_id: int | None = None + unregister_handler: Callable[[], None] | None = None + + # Arguments to PipelineInput + input_args: dict[str, Any] = { + "conversation_id": msg.get("conversation_id"), + } + + if start_stage == PipelineStage.STT: + # Audio pipeline that will receive audio as binary websocket messages + audio_queue: "asyncio.Queue[bytes]" = asyncio.Queue() + + async def stt_stream(): + # Yield until we receive an empty chunk + while chunk := await audio_queue.get(): + yield chunk + + def handle_binary(_hass, _connection, data: bytes): + # Forward to STT audio stream + audio_queue.put_nowait(data) + + handler_id, unregister_handler = connection.async_register_binary_handler( + handle_binary ) - # Run pipeline with a timeout. - # Events are sent over the websocket connection. - timeout = msg.get("timeout", DEFAULT_TIMEOUT) + # Audio input must be raw PCM at 16Khz with 16-bit mono samples + input_args["stt_metadata"] = stt.SpeechMetadata( + language=language, + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ) + input_args["stt_stream"] = stt_stream() + elif start_stage == PipelineStage.INTENT: + # Input to conversation agent + input_args["intent_input"] = msg["input"]["text"] + elif start_stage == PipelineStage.TTS: + # Input to text to speech system + input_args["tts_input"] = msg["input"]["text"] + run_task = hass.async_create_task( - TextPipelineRequest( - intent_input=msg["intent_input"], - conversation_id=msg.get("conversation_id"), - ).execute( + PipelineInput(**input_args).execute( PipelineRun( hass, - connection.context(msg), - pipeline, + context=connection.context(msg), + pipeline=pipeline, + start_stage=start_stage, + end_stage=end_stage, event_callback=lambda event: connection.send_event( msg["id"], event.as_dict() ), @@ -77,7 +127,20 @@ async def websocket_run( # Cancel pipeline if user unsubscribes connection.subscriptions[msg["id"]] = run_task.cancel + # Confirm subscription connection.send_result(msg["id"]) - # Task contains a timeout - await run_task + if handler_id is not None: + # Send handler id to client + connection.send_event(msg["id"], {"handler_id": handler_id}) + + try: + # Task contains a timeout + await run_task + except PipelineError as error: + # Report more specific error when possible + connection.send_error(msg["id"], error.code, error.message) + finally: + if unregister_handler is not None: + # Unregister binary handler + unregister_handler() diff --git a/tests/components/stt/test_init.py b/tests/components/stt/test_init.py index e36b8af3f6..3d20dbc540 100644 --- a/tests/components/stt/test_init.py +++ b/tests/components/stt/test_init.py @@ -1,5 +1,5 @@ """Test STT component setup.""" -from asyncio import StreamReader +from collections.abc import AsyncIterable from http import HTTPStatus from unittest.mock import AsyncMock, Mock @@ -64,7 +64,7 @@ class MockProvider(Provider): return [AudioChannels.CHANNEL_MONO] async def async_process_audio_stream( - self, metadata: SpeechMetadata, stream: StreamReader + self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] ) -> SpeechResult: """Process an audio stream.""" self.calls.append((metadata, stream)) diff --git a/tests/components/voice_assistant/test_pipeline.py b/tests/components/voice_assistant/test_pipeline.py deleted file mode 100644 index 343719a49f..0000000000 --- a/tests/components/voice_assistant/test_pipeline.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Pipeline tests for Voice Assistant integration.""" -from unittest.mock import MagicMock, patch - -import pytest - -from homeassistant.components.voice_assistant.pipeline import ( - AudioPipelineRequest, - Pipeline, - PipelineEventType, - PipelineRun, -) -from homeassistant.core import Context -from homeassistant.setup import async_setup_component - -from tests.components.tts.conftest import ( # noqa: F401, pylint: disable=unused-import - mock_get_cache_files, - mock_init_cache_dir, -) - - -@pytest.fixture(autouse=True) -async def init_components(hass): - """Initialize relevant components with empty configs.""" - assert await async_setup_component(hass, "voice_assistant", {}) - - -@pytest.fixture -async def mock_get_tts_audio(hass): - """Set up media source.""" - assert await async_setup_component(hass, "media_source", {}) - assert await async_setup_component( - hass, - "tts", - { - "tts": { - "platform": "demo", - } - }, - ) - - with patch( - "homeassistant.components.demo.tts.DemoProvider.get_tts_audio", - return_value=("mp3", b""), - ) as mock_get_tts: - yield mock_get_tts - - -async def test_audio_pipeline(hass, mock_get_tts_audio): - """Run audio pipeline with mock TTS.""" - pipeline = Pipeline( - name="test", - language=hass.config.language, - conversation_engine=None, - tts_engine=None, - ) - - event_callback = MagicMock() - await AudioPipelineRequest(intent_input="Are the lights on?").execute( - PipelineRun( - hass, - context=Context(), - pipeline=pipeline, - event_callback=event_callback, - language=hass.config.language, - ) - ) - - calls = event_callback.mock_calls - assert calls[0].args[0].type == PipelineEventType.RUN_START - assert calls[0].args[0].data == { - "pipeline": "test", - "language": hass.config.language, - } - - assert calls[1].args[0].type == PipelineEventType.INTENT_START - assert calls[1].args[0].data == { - "engine": "default", - "intent_input": "Are the lights on?", - } - assert calls[2].args[0].type == PipelineEventType.INTENT_FINISH - assert calls[2].args[0].data == { - "intent_output": { - "conversation_id": None, - "response": { - "card": {}, - "data": {"code": "no_intent_match"}, - "language": hass.config.language, - "response_type": "error", - "speech": { - "plain": { - "extra_data": None, - "speech": "Sorry, I couldn't understand that", - } - }, - }, - } - } - - assert calls[3].args[0].type == PipelineEventType.TTS_START - assert calls[3].args[0].data == { - "engine": "default", - "tts_input": "Sorry, I couldn't understand that", - } - assert calls[4].args[0].type == PipelineEventType.TTS_FINISH - assert ( - calls[4].args[0].data["tts_output"] - == f"/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_{hass.config.language}_-_demo.mp3" - ) - - assert calls[5].args[0].type == PipelineEventType.RUN_FINISH diff --git a/tests/components/voice_assistant/test_websocket.py b/tests/components/voice_assistant/test_websocket.py index 2fec6cdfb0..a1ba8b5f7c 100644 --- a/tests/components/voice_assistant/test_websocket.py +++ b/tests/components/voice_assistant/test_websocket.py @@ -1,20 +1,94 @@ """Websocket tests for Voice Assistant integration.""" import asyncio -from unittest.mock import patch +from collections.abc import AsyncIterable +from unittest.mock import MagicMock, patch import pytest +from homeassistant.components import stt from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.components.tts.conftest import ( # noqa: F401, pylint: disable=unused-import + mock_get_cache_files, + mock_init_cache_dir, +) from tests.typing import WebSocketGenerator +_TRANSCRIPT = "test transcript" + + +class MockSttProvider(stt.Provider): + """Mock STT provider.""" + + def __init__(self, hass: HomeAssistant, text: str) -> None: + """Init test provider.""" + self.hass = hass + self.text = text + + @property + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + return [self.hass.config.language] + + @property + def supported_formats(self) -> list[stt.AudioFormats]: + """Return a list of supported formats.""" + return [stt.AudioFormats.WAV] + + @property + def supported_codecs(self) -> list[stt.AudioCodecs]: + """Return a list of supported codecs.""" + return [stt.AudioCodecs.PCM] + + @property + def supported_bit_rates(self) -> list[stt.AudioBitRates]: + """Return a list of supported bitrates.""" + return [stt.AudioBitRates.BITRATE_16] + + @property + def supported_sample_rates(self) -> list[stt.AudioSampleRates]: + """Return a list of supported samplerates.""" + return [stt.AudioSampleRates.SAMPLERATE_16000] + + @property + def supported_channels(self) -> list[stt.AudioChannels]: + """Return a list of supported channels.""" + return [stt.AudioChannels.CHANNEL_MONO] + + async def async_process_audio_stream( + self, metadata: stt.SpeechMetadata, stream: AsyncIterable[bytes] + ) -> stt.SpeechResult: + """Process an audio stream.""" + return stt.SpeechResult(self.text, stt.SpeechResultState.SUCCESS) + @pytest.fixture(autouse=True) async def init_components(hass): """Initialize relevant components with empty configs.""" + assert await async_setup_component(hass, "media_source", {}) + assert await async_setup_component( + hass, + "tts", + { + "tts": { + "platform": "demo", + } + }, + ) + assert await async_setup_component(hass, "stt", {}) + + # mock_platform fails because it can't import + hass.data[stt.DOMAIN] = {"test": MockSttProvider(hass, _TRANSCRIPT)} + assert await async_setup_component(hass, "voice_assistant", {}) + with patch( + "homeassistant.components.demo.tts.DemoProvider.get_tts_audio", + return_value=("mp3", b""), + ) as mock_get_tts: + yield mock_get_tts + async def test_text_only_pipeline( hass: HomeAssistant, @@ -27,7 +101,9 @@ async def test_text_only_pipeline( { "id": 5, "type": "voice_assistant/run", - "intent_input": "Are the lights on?", + "start_stage": "intent", + "end_stage": "intent", + "input": {"text": "Are the lights on?"}, } ) @@ -52,7 +128,7 @@ async def test_text_only_pipeline( } msg = await client.receive_json() - assert msg["event"]["type"] == "intent-finish" + assert msg["event"]["type"] == "intent-end" assert msg["event"]["data"] == { "intent_output": { "response": { @@ -71,13 +147,120 @@ async def test_text_only_pipeline( } } - # run finish + # run end msg = await client.receive_json() - assert msg["event"]["type"] == "run-finish" + assert msg["event"]["type"] == "run-end" assert msg["event"]["data"] == {} -async def test_conversation_timeout( +async def test_audio_pipeline( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test events from a pipeline run with audio input/output.""" + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 5, + "type": "voice_assistant/run", + "start_stage": "stt", + "end_stage": "tts", + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # handler id + msg = await client.receive_json() + assert msg["event"]["handler_id"] == 1 + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + assert msg["event"]["data"] == { + "pipeline": hass.config.language, + "language": hass.config.language, + } + + # stt + msg = await client.receive_json() + assert msg["event"]["type"] == "stt-start" + assert msg["event"]["data"] == { + "engine": "default", + "metadata": { + "bit_rate": 16, + "channel": 1, + "codec": "pcm", + "format": "wav", + "language": "en", + "sample_rate": 16000, + }, + } + + # End of audio stream (handler id + empty payload) + await client.send_bytes(b"1") + + msg = await client.receive_json() + assert msg["event"]["type"] == "stt-end" + assert msg["event"]["data"] == { + "stt_output": {"text": _TRANSCRIPT}, + } + + # intent + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-start" + assert msg["event"]["data"] == { + "engine": "default", + "intent_input": _TRANSCRIPT, + } + + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-end" + assert msg["event"]["data"] == { + "intent_output": { + "response": { + "speech": { + "plain": { + "speech": "Sorry, I couldn't understand that", + "extra_data": None, + } + }, + "card": {}, + "language": "en", + "response_type": "error", + "data": {"code": "no_intent_match"}, + }, + "conversation_id": None, + } + } + + # text to speech + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-start" + assert msg["event"]["data"] == { + "engine": "default", + "tts_input": "Sorry, I couldn't understand that", + } + + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-end" + assert msg["event"]["data"] == { + "tts_output": { + "url": f"/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_{hass.config.language}_-_demo.mp3", + "mime_type": "audio/mpeg", + }, + } + + # run end + msg = await client.receive_json() + assert msg["event"]["type"] == "run-end" + assert msg["event"]["data"] == {} + + +async def test_intent_timeout( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components ) -> None: """Test partial pipeline run with conversation agent timeout.""" @@ -94,7 +277,9 @@ async def test_conversation_timeout( { "id": 5, "type": "voice_assistant/run", - "intent_input": "Are the lights on?", + "start_stage": "intent", + "end_stage": "intent", + "input": {"text": "Are the lights on?"}, "timeout": 0.00001, } ) @@ -125,24 +310,26 @@ async def test_conversation_timeout( assert msg["error"]["code"] == "timeout" -async def test_pipeline_timeout( +async def test_text_pipeline_timeout( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components ) -> None: - """Test pipeline run with immediate timeout.""" + """Test text-only pipeline run with immediate timeout.""" client = await hass_ws_client(hass) async def sleepy_run(*args, **kwargs): await asyncio.sleep(3600) with patch( - "homeassistant.components.voice_assistant.pipeline.TextPipelineRequest._execute", + "homeassistant.components.voice_assistant.pipeline.PipelineInput._execute", new=sleepy_run, ): await client.send_json( { "id": 5, "type": "voice_assistant/run", - "intent_input": "Are the lights on?", + "start_stage": "intent", + "end_stage": "intent", + "input": {"text": "Are the lights on?"}, "timeout": 0.0001, } ) @@ -155,3 +342,273 @@ async def test_pipeline_timeout( msg = await client.receive_json() assert not msg["success"] assert msg["error"]["code"] == "timeout" + + +async def test_intent_failed( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components +) -> None: + """Test text-only pipeline run with conversation agent error.""" + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.conversation.async_converse", + new=MagicMock(return_value=RuntimeError), + ): + await client.send_json( + { + "id": 5, + "type": "voice_assistant/run", + "start_stage": "intent", + "end_stage": "intent", + "input": {"text": "Are the lights on?"}, + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + assert msg["event"]["data"] == { + "pipeline": hass.config.language, + "language": hass.config.language, + } + + # intent start + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-start" + assert msg["event"]["data"] == { + "engine": "default", + "intent_input": "Are the lights on?", + } + + # intent error + msg = await client.receive_json() + assert msg["event"]["type"] == "error" + assert msg["event"]["data"]["code"] == "intent-failed" + + +async def test_audio_pipeline_timeout( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components +) -> None: + """Test audio pipeline run with immediate timeout.""" + client = await hass_ws_client(hass) + + async def sleepy_run(*args, **kwargs): + await asyncio.sleep(3600) + + with patch( + "homeassistant.components.voice_assistant.pipeline.PipelineInput._execute", + new=sleepy_run, + ): + await client.send_json( + { + "id": 5, + "type": "voice_assistant/run", + "start_stage": "stt", + "end_stage": "tts", + "timeout": 0.0001, + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # handler id + msg = await client.receive_json() + assert msg["event"]["handler_id"] == 1 + + # timeout error + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "timeout" + + +async def test_stt_provider_missing( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test events from a pipeline run with a non-existent STT provider.""" + with patch( + "homeassistant.components.stt.async_get_provider", + new=MagicMock(return_value=None), + ): + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 5, + "type": "voice_assistant/run", + "start_stage": "stt", + "end_stage": "tts", + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # handler id + msg = await client.receive_json() + assert msg["event"]["handler_id"] == 1 + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + assert msg["event"]["data"] == { + "pipeline": hass.config.language, + "language": hass.config.language, + } + + # stt + msg = await client.receive_json() + assert msg["event"]["type"] == "stt-start" + assert msg["event"]["data"] == { + "engine": "default", + "metadata": { + "bit_rate": 16, + "channel": 1, + "codec": "pcm", + "format": "wav", + "language": "en", + "sample_rate": 16000, + }, + } + + # End of audio stream (handler id + empty payload) + await client.send_bytes(b"1") + + # stt error + msg = await client.receive_json() + assert msg["event"]["type"] == "error" + assert msg["event"]["data"]["code"] == "stt-provider-missing" + + +async def test_stt_stream_failed( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test events from a pipeline run with a non-existent STT provider.""" + with patch( + "tests.components.voice_assistant.test_websocket.MockSttProvider.async_process_audio_stream", + new=MagicMock(side_effect=RuntimeError), + ): + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 5, + "type": "voice_assistant/run", + "start_stage": "stt", + "end_stage": "tts", + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # handler id + msg = await client.receive_json() + assert msg["event"]["handler_id"] == 1 + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + assert msg["event"]["data"] == { + "pipeline": hass.config.language, + "language": hass.config.language, + } + + # stt + msg = await client.receive_json() + assert msg["event"]["type"] == "stt-start" + assert msg["event"]["data"] == { + "engine": "default", + "metadata": { + "bit_rate": 16, + "channel": 1, + "codec": "pcm", + "format": "wav", + "language": "en", + "sample_rate": 16000, + }, + } + + # End of audio stream (handler id + empty payload) + await client.send_bytes(b"1") + + # stt error + msg = await client.receive_json() + assert msg["event"]["type"] == "error" + assert msg["event"]["data"]["code"] == "stt-stream-failed" + + +async def test_tts_failed( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components +) -> None: + """Test pipeline run with text to speech error.""" + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.media_source.async_resolve_media", + new=MagicMock(return_value=RuntimeError), + ): + await client.send_json( + { + "id": 5, + "type": "voice_assistant/run", + "start_stage": "tts", + "end_stage": "tts", + "input": {"text": "Lights are on."}, + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + assert msg["event"]["data"] == { + "pipeline": hass.config.language, + "language": hass.config.language, + } + + # tts start + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-start" + assert msg["event"]["data"] == { + "engine": "default", + "tts_input": "Lights are on.", + } + + # tts error + msg = await client.receive_json() + assert msg["event"]["type"] == "error" + assert msg["event"]["data"]["code"] == "tts-failed" + + +async def test_invalid_stage_order( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components +) -> None: + """Test pipeline run with invalid stage order.""" + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 5, + "type": "voice_assistant/run", + "start_stage": "tts", + "end_stage": "stt", + "input": {"text": "Lights are on."}, + } + ) + + # result + msg = await client.receive_json() + assert not msg["success"] From 38a4f08e157c5992510b8f972ac7ee2fdfc9ddde Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 23 Mar 2023 20:10:51 +0100 Subject: [PATCH 1211/2810] Fix missing mock in islamic_prayer_times (#90178) * Fix missing mock in islamic_prayer_times * Restore 100% coverage * Update test_config_flow.py --- .../islamic_prayer_times/conftest.py | 15 +++++++++++ .../islamic_prayer_times/test_config_flow.py | 25 ++++++------------- .../islamic_prayer_times/test_init.py | 21 ++++++++++++++++ 3 files changed, 44 insertions(+), 17 deletions(-) create mode 100644 tests/components/islamic_prayer_times/conftest.py diff --git a/tests/components/islamic_prayer_times/conftest.py b/tests/components/islamic_prayer_times/conftest.py new file mode 100644 index 0000000000..63c6ad8414 --- /dev/null +++ b/tests/components/islamic_prayer_times/conftest.py @@ -0,0 +1,15 @@ +"""Common fixtures for the islamic_prayer_times tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.islamic_prayer_times.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/islamic_prayer_times/test_config_flow.py b/tests/components/islamic_prayer_times/test_config_flow.py index 6643093873..a25b8ba0f0 100644 --- a/tests/components/islamic_prayer_times/test_config_flow.py +++ b/tests/components/islamic_prayer_times/test_config_flow.py @@ -1,15 +1,15 @@ """Tests for Islamic Prayer Times config flow.""" -from unittest.mock import patch +import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components import islamic_prayer_times from homeassistant.components.islamic_prayer_times.const import CONF_CALC_METHOD, DOMAIN from homeassistant.core import HomeAssistant -from . import PRAYER_TIMES - from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + async def test_flow_works(hass: HomeAssistant) -> None: """Test user config.""" @@ -19,13 +19,11 @@ async def test_flow_works(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" - with patch( - "homeassistant.components.islamic_prayer_times.async_setup_entry", - return_value=True, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result["title"] == "Islamic Prayer Times" @@ -40,13 +38,6 @@ async def test_options(hass: HomeAssistant) -> None: ) entry.add_to_hass(hass) - with patch( - "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", - return_value=PRAYER_TIMES, - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] == data_entry_flow.FlowResultType.FORM diff --git a/tests/components/islamic_prayer_times/test_init.py b/tests/components/islamic_prayer_times/test_init.py index d641a22590..b1cf8f2c9a 100644 --- a/tests/components/islamic_prayer_times/test_init.py +++ b/tests/components/islamic_prayer_times/test_init.py @@ -8,6 +8,7 @@ import pytest from homeassistant import config_entries from homeassistant.components import islamic_prayer_times +from homeassistant.components.islamic_prayer_times.const import CONF_CALC_METHOD from homeassistant.core import HomeAssistant from . import ( @@ -85,6 +86,26 @@ async def test_unload_entry(hass: HomeAssistant) -> None: assert islamic_prayer_times.DOMAIN not in hass.data +async def test_options_listener(hass: HomeAssistant) -> None: + """Ensure updating options triggers a coordinator refresh.""" + entry = MockConfigEntry(domain=islamic_prayer_times.DOMAIN, data={}) + entry.add_to_hass(hass) + + with patch( + "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", + return_value=PRAYER_TIMES, + ) as mock_fetch_prayer_times: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert mock_fetch_prayer_times.call_count == 1 + + hass.config_entries.async_update_entry( + entry, options={CONF_CALC_METHOD: "makkah"} + ) + await hass.async_block_till_done() + assert mock_fetch_prayer_times.call_count == 2 + + async def test_islamic_prayer_times_timestamp_format(hass: HomeAssistant) -> None: """Test Islamic prayer times timestamp format.""" entry = MockConfigEntry(domain=islamic_prayer_times.DOMAIN, data={}) From dd0f05b98069cc331b57614bcdab4e7a4b174003 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Mar 2023 09:55:02 -1000 Subject: [PATCH 1212/2810] Avoid calling the http access logging when logging is disabled (#90152) --- homeassistant/components/http/__init__.py | 24 ++++++++- tests/components/http/test_init.py | 60 ++++++++++++++++++++++- 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 04b94dc3b8..3106eea05f 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -12,6 +12,7 @@ from typing import Any, Final, TypedDict, cast from aiohttp import web from aiohttp.typedefs import StrOrURL from aiohttp.web_exceptions import HTTPMovedPermanently, HTTPRedirection +from aiohttp.web_log import AccessLogger from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa @@ -220,6 +221,25 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +class HomeAssistantAccessLogger(AccessLogger): + """Access logger for Home Assistant that does not log when disabled.""" + + def log( + self, request: web.BaseRequest, response: web.StreamResponse, time: float + ) -> None: + """Log the request. + + The default implementation logs the request to the logger + with the INFO level and than throws it away if the logger + is not enabled for the INFO level. This implementation + does not log the request if the logger is not enabled for + the INFO level. + """ + if not self.logger.isEnabledFor(logging.INFO): + return + super().log(request, response, time) + + class HomeAssistantHTTP: """HTTP server for Home Assistant.""" @@ -462,7 +482,9 @@ class HomeAssistantHTTP: # pylint: disable-next=protected-access self.app._router.freeze = lambda: None # type: ignore[method-assign] - self.runner = web.AppRunner(self.app) + self.runner = web.AppRunner( + self.app, access_log_class=HomeAssistantAccessLogger + ) await self.runner.setup() self.site = HomeAssistantTCPSite( diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 578fcc60c7..0c346ab947 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -1,10 +1,12 @@ """The tests for the Home Assistant HTTP component.""" +import asyncio from datetime import timedelta from http import HTTPStatus from ipaddress import ip_network import logging import pathlib -from unittest.mock import Mock, patch +import time +from unittest.mock import MagicMock, Mock, patch import py import pytest @@ -20,6 +22,7 @@ from homeassistant.util import dt as dt_util from homeassistant.util.ssl import server_context_intermediate, server_context_modern from tests.common import async_fire_time_changed +from tests.test_util.aiohttp import AiohttpClientMockResponse from tests.typing import ClientSessionGenerator @@ -463,3 +466,58 @@ async def test_storing_config( restored["trusted_proxies"][0] = ip_network(restored["trusted_proxies"][0]) assert restored == http.HTTP_SCHEMA(config) + + +async def test_logging( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Testing the access log works.""" + await asyncio.gather( + *( + async_setup_component(hass, component, {}) + for component in ("http", "logger", "api") + ) + ) + hass.states.async_set("logging.entity", "hello") + await hass.services.async_call( + "logger", + "set_level", + {"aiohttp.access": "info"}, + blocking=True, + ) + client = await hass_client() + response = await client.get("/api/states/logging.entity") + assert response.status == HTTPStatus.OK + + assert "GET /api/states/logging.entity" in caplog.text + caplog.clear() + await hass.services.async_call( + "logger", + "set_level", + {"aiohttp.access": "warning"}, + blocking=True, + ) + response = await client.get("/api/states/logging.entity") + assert response.status == HTTPStatus.OK + assert "GET /api/states/logging.entity" not in caplog.text + + +async def test_hass_access_logger_at_info_level( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test that logging happens at info level.""" + test_logger = logging.getLogger("test.aiohttp.logger") + logger = http.HomeAssistantAccessLogger(test_logger) + mock_request = MagicMock() + response = AiohttpClientMockResponse( + "POST", "http://127.0.0.1", status=HTTPStatus.OK + ) + setattr(response, "body_length", 42) + logger.log(mock_request, response, time.time()) + assert "42" in caplog.text + caplog.clear() + test_logger.setLevel(logging.WARNING) + logger.log(mock_request, response, time.time()) + assert "42" not in caplog.text From d49fbc17dfbfad9821779bd2fa9277b67263c1e1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Mar 2023 14:52:37 -1000 Subject: [PATCH 1213/2810] Fix recorder attribute excludes not being effective until after startup (#90198) * Fix attribute excludes not being effective until after startup fixes #90016 * reduce --- homeassistant/components/recorder/__init__.py | 41 ++++++++++++++++--- homeassistant/components/recorder/const.py | 13 ++++++ .../components/recorder/statistics.py | 26 ++++++++---- homeassistant/components/recorder/tasks.py | 5 +-- tests/components/recorder/test_init.py | 8 +++- 5 files changed, 75 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 2621db9cb7..750f504d09 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -28,6 +28,9 @@ from .const import ( # noqa: F401 EVENT_RECORDER_5MIN_STATISTICS_GENERATED, EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, EXCLUDE_ATTRIBUTES, + INTEGRATION_PLATFORM_COMPILE_STATISTICS, + INTEGRATION_PLATFORM_EXCLUDE_ATTRIBUTES, + INTEGRATION_PLATFORMS_LOAD_IN_RECORDER_THREAD, SQLITE_URL_PREFIX, ) from .core import Recorder @@ -165,14 +168,40 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_register_services(hass, instance) websocket_api.async_setup(hass) entity_registry.async_setup(hass) - await async_process_integration_platforms(hass, DOMAIN, _process_recorder_platform) + + await _async_setup_integration_platform( + hass, instance, exclude_attributes_by_domain + ) return await instance.async_db_ready -async def _process_recorder_platform( - hass: HomeAssistant, domain: str, platform: Any +async def _async_setup_integration_platform( + hass: HomeAssistant, + instance: Recorder, + exclude_attributes_by_domain: dict[str, set[str]], ) -> None: - """Process a recorder platform.""" - instance = get_instance(hass) - instance.queue_task(AddRecorderPlatformTask(domain, platform)) + """Set up a recorder integration platform.""" + + async def _process_recorder_platform( + hass: HomeAssistant, domain: str, platform: Any + ) -> None: + """Process a recorder platform.""" + # We need to add this before as soon as the component is loaded + # to ensure by the time the state is recorded that the excluded + # attributes are known. This is safe to modify in the event loop + # since exclude_attributes_by_domain is never iterated over. + if exclude_attributes := getattr( + platform, INTEGRATION_PLATFORM_EXCLUDE_ATTRIBUTES, None + ): + exclude_attributes_by_domain[domain] = exclude_attributes(hass) + + # If the platform has a compile_statistics method, we need to + # add it to the recorder queue to be processed. + if any( + hasattr(platform, _attr) + for _attr in INTEGRATION_PLATFORMS_LOAD_IN_RECORDER_THREAD + ): + instance.queue_task(AddRecorderPlatformTask(domain, platform)) + + await async_process_integration_platforms(hass, DOMAIN, _process_recorder_platform) diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index 6bf46efd36..fbec19a2d1 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -51,6 +51,19 @@ STATES_META_SCHEMA_VERSION = 38 LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28 +INTEGRATION_PLATFORM_EXCLUDE_ATTRIBUTES = "exclude_attributes" + +INTEGRATION_PLATFORM_COMPILE_STATISTICS = "compile_statistics" +INTEGRATION_PLATFORM_VALIDATE_STATISTICS = "validate_statistics" +INTEGRATION_PLATFORM_LIST_STATISTIC_IDS = "list_statistic_ids" + +INTEGRATION_PLATFORMS_LOAD_IN_RECORDER_THREAD = { + INTEGRATION_PLATFORM_COMPILE_STATISTICS, + INTEGRATION_PLATFORM_VALIDATE_STATISTICS, + INTEGRATION_PLATFORM_LIST_STATISTIC_IDS, +} + + class SupportedDialect(StrEnum): """Supported dialects.""" diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 82fbf7798f..8025616d24 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -47,6 +47,9 @@ from .const import ( DOMAIN, EVENT_RECORDER_5MIN_STATISTICS_GENERATED, EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, + INTEGRATION_PLATFORM_COMPILE_STATISTICS, + INTEGRATION_PLATFORM_LIST_STATISTIC_IDS, + INTEGRATION_PLATFORM_VALIDATE_STATISTICS, SupportedDialect, ) from .db_schema import ( @@ -502,9 +505,13 @@ def _compile_statistics( current_metadata: dict[str, tuple[int, StatisticMetaData]] = {} # Collect statistics from all platforms implementing support for domain, platform in instance.hass.data[DOMAIN].recorder_platforms.items(): - if not hasattr(platform, "compile_statistics"): + if not ( + platform_compile_statistics := getattr( + platform, INTEGRATION_PLATFORM_COMPILE_STATISTICS, None + ) + ): continue - compiled: PlatformCompiledStatistics = platform.compile_statistics( + compiled: PlatformCompiledStatistics = platform_compile_statistics( instance.hass, start, end ) _LOGGER.debug( @@ -783,9 +790,13 @@ def list_statistic_ids( # # Query all integrations with a registered recorder platform for platform in hass.data[DOMAIN].recorder_platforms.values(): - if not hasattr(platform, "list_statistic_ids"): + if not ( + platform_list_statistic_ids := getattr( + platform, INTEGRATION_PLATFORM_LIST_STATISTIC_IDS, None + ) + ): continue - platform_statistic_ids = platform.list_statistic_ids( + platform_statistic_ids = platform_list_statistic_ids( hass, statistic_ids=statistic_ids, statistic_type=statistic_type ) @@ -1931,9 +1942,10 @@ def validate_statistics(hass: HomeAssistant) -> dict[str, list[ValidationIssue]] """Validate statistics.""" platform_validation: dict[str, list[ValidationIssue]] = {} for platform in hass.data[DOMAIN].recorder_platforms.values(): - if not hasattr(platform, "validate_statistics"): - continue - platform_validation.update(platform.validate_statistics(hass)) + if platform_validate_statistics := getattr( + platform, INTEGRATION_PLATFORM_VALIDATE_STATISTICS, None + ): + platform_validation.update(platform_validate_statistics(hass)) return platform_validation diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index 7b8fa4867b..ef11885705 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -14,7 +14,7 @@ from homeassistant.core import Event from homeassistant.helpers.typing import UndefinedType from . import entity_registry, purge, statistics -from .const import DOMAIN, EXCLUDE_ATTRIBUTES +from .const import DOMAIN from .db_schema import Statistics, StatisticsShortTerm from .models import StatisticData, StatisticMetaData from .util import periodic_db_cleanups @@ -317,11 +317,8 @@ class AddRecorderPlatformTask(RecorderTask): hass = instance.hass domain = self.domain platform = self.platform - platforms: dict[str, Any] = hass.data[DOMAIN].recorder_platforms platforms[domain] = platform - if hasattr(self.platform, "exclude_attributes"): - hass.data[EXCLUDE_ATTRIBUTES][domain] = platform.exclude_attributes(hass) @dataclass diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 3232b10fdc..8fb45cb3d4 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -2112,10 +2112,15 @@ async def test_connect_args_priority(hass: HomeAssistant, config_url) -> None: assert connect_params[0]["charset"] == "utf8mb4" +@pytest.mark.parametrize("core_state", [CoreState.starting, CoreState.running]) async def test_excluding_attributes_by_integration( - recorder_mock: Recorder, hass: HomeAssistant, entity_registry: er.EntityRegistry + recorder_mock: Recorder, + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + core_state: CoreState, ) -> None: """Test that an integration's recorder platform can exclude attributes.""" + hass.state = core_state state = "restoring_from_db" attributes = {"test_attr": 5, "excluded": 10} mock_platform( @@ -2131,6 +2136,7 @@ async def test_excluding_attributes_by_integration( platform = MockEntityPlatform(hass, platform_name="fake_integration") entity_platform = MockEntity(entity_id=entity_id, extra_state_attributes=attributes) await platform.async_add_entities([entity_platform]) + await hass.async_block_till_done() await async_wait_recording_done(hass) From e7e7f603c24dda93c114968c36ae9fc890971bb2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Mar 2023 14:53:18 -1000 Subject: [PATCH 1214/2810] Remove async_response from websocket apis where nothing was being awaited (#90204) --- homeassistant/components/logger/websocket_api.py | 4 ++-- homeassistant/components/websocket_api/commands.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/logger/websocket_api.py b/homeassistant/components/logger/websocket_api.py index 1b4e5cb36a..89026a07b8 100644 --- a/homeassistant/components/logger/websocket_api.py +++ b/homeassistant/components/logger/websocket_api.py @@ -27,9 +27,9 @@ def async_load_websocket_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, handle_module_log_level) +@callback @websocket_api.websocket_command({vol.Required("type"): "logger/log_info"}) -@websocket_api.async_response -async def handle_integration_log_info( +def handle_integration_log_info( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle integrations logger info.""" diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index fa5c6aac29..2b146d9447 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -399,9 +399,9 @@ async def handle_manifest_get( connection.send_error(msg["id"], const.ERR_NOT_FOUND, "Integration not found") +@callback @decorators.websocket_command({vol.Required("type"): "integration/setup_info"}) -@decorators.async_response -async def handle_integration_setup_info( +def handle_integration_setup_info( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle integrations command.""" @@ -648,6 +648,7 @@ async def handle_execute_script( connection.send_result(msg["id"], {"context": context}) +@callback @decorators.websocket_command( { vol.Required("type"): "fire_event", @@ -656,8 +657,7 @@ async def handle_execute_script( } ) @decorators.require_admin -@decorators.async_response -async def handle_fire_event( +def handle_fire_event( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle fire event command.""" From a44d6f30c9744241807f63d6c0659786d8f270da Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Mar 2023 14:56:04 -1000 Subject: [PATCH 1215/2810] Fix refactoring error in states/events context id migration (#90193) fixes #90074 --- homeassistant/components/recorder/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index bbdab2690d..8e522a2bbd 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -718,7 +718,7 @@ class Recorder(threading.Thread): if ( self.schema_version < CONTEXT_ID_AS_BINARY_SCHEMA_VERSION or execute_stmt_lambda_element( - session, has_events_context_ids_to_migrate() + session, has_states_context_ids_to_migrate() ) ): self.queue_task(StatesContextIDMigrationTask()) @@ -726,7 +726,7 @@ class Recorder(threading.Thread): if ( self.schema_version < CONTEXT_ID_AS_BINARY_SCHEMA_VERSION or execute_stmt_lambda_element( - session, has_states_context_ids_to_migrate() + session, has_events_context_ids_to_migrate() ) ): self.queue_task(EventsContextIDMigrationTask()) From f1ec77b8e07eb9c8fd140a32327f6085040ad355 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Mar 2023 14:56:58 -1000 Subject: [PATCH 1216/2810] Small cleanups to logbook statement generator (#90200) We should only convert the context id to binary if its going to be used. Avoid some intermediate vars that are no longer needed --- .../components/logbook/queries/__init__.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/logbook/queries/__init__.py b/homeassistant/components/logbook/queries/__init__.py index cfef16bf77..0172700df4 100644 --- a/homeassistant/components/logbook/queries/__init__.py +++ b/homeassistant/components/logbook/queries/__init__.py @@ -30,10 +30,10 @@ def statement_for_request( """Generate the logbook statement for a logbook request.""" start_day = dt_util.utc_to_timestamp(start_day_dt) end_day = dt_util.utc_to_timestamp(end_day_dt) - context_id_bin = ulid_to_bytes_or_none(context_id) # No entities: logbook sends everything for the timeframe # limited by the context_id and the yaml configured filter if not entity_ids and not device_ids: + context_id_bin = ulid_to_bytes_or_none(context_id) states_entity_filter = ( filters.states_metadata_entity_filter() if filters else None ) @@ -54,34 +54,30 @@ def statement_for_request( # entities and devices: logbook sends everything for the timeframe for the entities and devices if entity_ids and device_ids: - json_quoted_entity_ids = [json_dumps(entity_id) for entity_id in entity_ids] - json_quoted_device_ids = [json_dumps(device_id) for device_id in device_ids] return entities_devices_stmt( start_day, end_day, event_types, states_metadata_ids or [], - json_quoted_entity_ids, - json_quoted_device_ids, + [json_dumps(entity_id) for entity_id in entity_ids], + [json_dumps(device_id) for device_id in device_ids], ) # entities: logbook sends everything for the timeframe for the entities if entity_ids: - json_quoted_entity_ids = [json_dumps(entity_id) for entity_id in entity_ids] return entities_stmt( start_day, end_day, event_types, states_metadata_ids or [], - json_quoted_entity_ids, + [json_dumps(entity_id) for entity_id in entity_ids], ) # devices: logbook sends everything for the timeframe for the devices assert device_ids is not None - json_quoted_device_ids = [json_dumps(device_id) for device_id in device_ids] return devices_stmt( start_day, end_day, event_types, - json_quoted_device_ids, + [json_dumps(device_id) for device_id in device_ids], ) From ca157f4d19edfa30c52a32d02439a26efdf576a7 Mon Sep 17 00:00:00 2001 From: Chris Xiao <30990835+chrisx8@users.noreply.github.com> Date: Fri, 24 Mar 2023 02:23:05 -0400 Subject: [PATCH 1217/2810] Add icons for qbittorrent speed sensors (#90203) add icons for qbittorrent speed sensors --- homeassistant/components/qbittorrent/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index e7b75954d5..26605a8765 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -44,6 +44,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=SENSOR_TYPE_DOWNLOAD_SPEED, name="Down Speed", + icon="mdi:cloud-download", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, @@ -51,6 +52,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=SENSOR_TYPE_UPLOAD_SPEED, name="Up Speed", + icon="mdi:cloud-upload", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, From 1f2268a878f095bda28cf8ee09da6c68007c90a2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Mar 2023 21:40:47 -1000 Subject: [PATCH 1218/2810] Fix httpx client creating a new ssl context with each client (memory leak) (#90191) * Fix httpx client creating a new ssl context with each client While working on https://github.com/home-assistant/core/issues/83524 it was discovered that each new httpx client creates a new ssl context https://github.com/encode/httpx/blob/f1157dbc4102ac8e227a0a0bb12a877f592eff58/httpx/_transports/default.py#L261 If an ssl context is passed in creating a new one is avoided here https://github.com/encode/httpx/blob/f1157dbc4102ac8e227a0a0bb12a877f592eff58/httpx/_config.py#L110 This change makes httpx ssl no-verify behavior match aiohttp ssl no-verify behavior https://github.com/aio-libs/aiohttp/blob/6da04694fd87a39af9c3856048c9ff23ca815f88/aiohttp/connector.py#L892 aiohttp solved this by wrapping the code that generates the ssl context in an lru_cache * compact --- homeassistant/helpers/aiohttp_client.py | 2 +- homeassistant/helpers/httpx_client.py | 7 +++++-- homeassistant/util/ssl.py | 27 +++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 53c3cc1cf2..78a8051df1 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -273,7 +273,7 @@ def _async_get_connector( if verify_ssl: ssl_context: bool | SSLContext = ssl_util.get_default_context() else: - ssl_context = False + ssl_context = ssl_util.get_default_no_verify_context() connector = aiohttp.TCPConnector( enable_cleanup_closed=True, diff --git a/homeassistant/helpers/httpx_client.py b/homeassistant/helpers/httpx_client.py index 1e9d2e776c..44ad81c73e 100644 --- a/homeassistant/helpers/httpx_client.py +++ b/homeassistant/helpers/httpx_client.py @@ -11,7 +11,7 @@ from typing_extensions import Self from homeassistant.const import APPLICATION_NAME, EVENT_HOMEASSISTANT_CLOSE, __version__ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.loader import bind_hass -from homeassistant.util import ssl as ssl_util +from homeassistant.util.ssl import get_default_context, get_default_no_verify_context from .frame import warn_use @@ -65,8 +65,11 @@ def create_async_httpx_client( This method must be run in the event loop. """ + ssl_context = ( + get_default_context() if verify_ssl else get_default_no_verify_context() + ) client = HassHttpXAsyncClient( - verify=ssl_util.get_default_context() if verify_ssl else False, + verify=ssl_context, headers={USER_AGENT: SERVER_SOFTWARE}, **kwargs, ) diff --git a/homeassistant/util/ssl.py b/homeassistant/util/ssl.py index 9c945ef275..5b8830e657 100644 --- a/homeassistant/util/ssl.py +++ b/homeassistant/util/ssl.py @@ -1,10 +1,31 @@ """Helper to create SSL contexts.""" +import contextlib from os import environ import ssl import certifi +def create_no_verify_ssl_context() -> ssl.SSLContext: + """Return an SSL context that does not verify the server certificate. + + This is a copy of aiohttp's create_default_context() function, with the + ssl verify turned off. + + https://github.com/aio-libs/aiohttp/blob/33953f110e97eecc707e1402daa8d543f38a189b/aiohttp/connector.py#L911 + """ + sslcontext = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + sslcontext.options |= ssl.OP_NO_SSLv2 + sslcontext.options |= ssl.OP_NO_SSLv3 + sslcontext.check_hostname = False + sslcontext.verify_mode = ssl.CERT_NONE + with contextlib.suppress(AttributeError): + # This only works for OpenSSL >= 1.0.0 + sslcontext.options |= ssl.OP_NO_COMPRESSION + sslcontext.set_default_verify_paths() + return sslcontext + + def client_context() -> ssl.SSLContext: """Return an SSL context for making requests.""" @@ -18,6 +39,7 @@ def client_context() -> ssl.SSLContext: # Create this only once and reuse it _DEFAULT_SSL_CONTEXT = client_context() +_DEFAULT_NO_VERIFY_SSL_CONTEXT = create_no_verify_ssl_context() def get_default_context() -> ssl.SSLContext: @@ -25,6 +47,11 @@ def get_default_context() -> ssl.SSLContext: return _DEFAULT_SSL_CONTEXT +def get_default_no_verify_context() -> ssl.SSLContext: + """Return the default SSL context that does not verify the server certificate.""" + return _DEFAULT_NO_VERIFY_SSL_CONTEXT + + def server_context_modern() -> ssl.SSLContext: """Return an SSL context following the Mozilla recommendations. From a404d5f6d74783463db0cb5c944e467ba9d19d37 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 24 Mar 2023 08:41:04 +0100 Subject: [PATCH 1219/2810] Prepare MQTT platform tests part5 (#90108) * Tests light_json * Tests light_template * Tests light --- tests/components/mqtt/test_light.py | 1217 ++++++++++-------- tests/components/mqtt/test_light_json.py | 635 ++++----- tests/components/mqtt/test_light_template.py | 323 ++--- 3 files changed, 1175 insertions(+), 1000 deletions(-) diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index 7d0c0333b7..6ad52103a0 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -196,7 +196,6 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, State -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -243,26 +242,26 @@ def light_platform_only(): yield +@pytest.mark.parametrize( + "hass_config", [{mqtt.DOMAIN: {light.DOMAIN: {"name": "test"}}}] +) async def test_fail_setup_if_no_command_topic( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test if command fails with command topic.""" - assert not await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {light.DOMAIN: {"name": "test"}}} - ) + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( "Invalid config for [mqtt]: required key not provided @ data['mqtt']['light'][0]['command_topic']. Got None." in caplog.text ) -async def test_no_color_brightness_color_temp_hs_white_xy_if_no_topics( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test if there is no color and brightness if no topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -271,10 +270,14 @@ async def test_no_color_brightness_color_temp_hs_white_xy_if_no_topics( "command_topic": "test_light_rgb/set", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_no_color_brightness_color_temp_hs_white_xy_if_no_topics( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test if there is no color and brightness if no topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -315,41 +318,46 @@ async def test_no_color_brightness_color_temp_hs_white_xy_if_no_topics( assert state.state == STATE_UNKNOWN +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "state_topic": "test_light_rgb/status", + "command_topic": "test_light_rgb/set", + "brightness_state_topic": "test_light_rgb/brightness/status", + "brightness_command_topic": "test_light_rgb/brightness/set", + "rgb_state_topic": "test_light_rgb/rgb/status", + "rgb_command_topic": "test_light_rgb/rgb/set", + "rgbw_state_topic": "test_light_rgb/rgbw/status", + "rgbw_command_topic": "test_light_rgb/rgbw/set", + "rgbww_state_topic": "test_light_rgb/rgbww/status", + "rgbww_command_topic": "test_light_rgb/rgbww/set", + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "effect_state_topic": "test_light_rgb/effect/status", + "effect_command_topic": "test_light_rgb/effect/set", + "hs_state_topic": "test_light_rgb/hs/status", + "hs_command_topic": "test_light_rgb/hs/set", + "xy_state_topic": "test_light_rgb/xy/status", + "xy_command_topic": "test_light_rgb/xy/set", + "qos": "0", + "payload_on": 1, + "payload_off": 0, + } + } + } + ], +) async def test_controlling_state_via_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the controlling of the state via topic.""" - config = { - light.DOMAIN: { - "name": "test", - "state_topic": "test_light_rgb/status", - "command_topic": "test_light_rgb/set", - "brightness_state_topic": "test_light_rgb/brightness/status", - "brightness_command_topic": "test_light_rgb/brightness/set", - "rgb_state_topic": "test_light_rgb/rgb/status", - "rgb_command_topic": "test_light_rgb/rgb/set", - "rgbw_state_topic": "test_light_rgb/rgbw/status", - "rgbw_command_topic": "test_light_rgb/rgbw/set", - "rgbww_state_topic": "test_light_rgb/rgbww/status", - "rgbww_command_topic": "test_light_rgb/rgbww/set", - "color_temp_state_topic": "test_light_rgb/color_temp/status", - "color_temp_command_topic": "test_light_rgb/color_temp/set", - "effect_state_topic": "test_light_rgb/effect/status", - "effect_command_topic": "test_light_rgb/effect/set", - "hs_state_topic": "test_light_rgb/hs/status", - "hs_command_topic": "test_light_rgb/hs/set", - "xy_state_topic": "test_light_rgb/xy/status", - "xy_command_topic": "test_light_rgb/xy/set", - "qos": "0", - "payload_on": 1, - "payload_off": 0, - } - } color_modes = ["color_temp", "hs", "rgb", "rgbw", "rgbww", "xy"] - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -436,43 +444,47 @@ async def test_controlling_state_via_topic( assert light_state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "state_topic": "test_light_rgb/status", + "command_topic": "test_light_rgb/set", + "brightness_state_topic": "test_light_rgb/brightness/status", + "brightness_command_topic": "test_light_rgb/brightness/set", + "color_mode_state_topic": "test_light_rgb/color_mode/status", + "rgb_state_topic": "test_light_rgb/rgb/status", + "rgb_command_topic": "test_light_rgb/rgb/set", + "rgbw_state_topic": "test_light_rgb/rgbw/status", + "rgbw_command_topic": "test_light_rgb/rgbw/set", + "rgbww_state_topic": "test_light_rgb/rgbww/status", + "rgbww_command_topic": "test_light_rgb/rgbww/set", + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "effect_state_topic": "test_light_rgb/effect/status", + "effect_command_topic": "test_light_rgb/effect/set", + "hs_state_topic": "test_light_rgb/hs/status", + "hs_command_topic": "test_light_rgb/hs/set", + "xy_state_topic": "test_light_rgb/xy/status", + "xy_command_topic": "test_light_rgb/xy/set", + "qos": "0", + "payload_on": 1, + "payload_off": 0, + } + } + } + ], +) async def test_invalid_state_via_topic( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test handling of empty data via topic.""" - config = { - light.DOMAIN: { - "name": "test", - "state_topic": "test_light_rgb/status", - "command_topic": "test_light_rgb/set", - "brightness_state_topic": "test_light_rgb/brightness/status", - "brightness_command_topic": "test_light_rgb/brightness/set", - "color_mode_state_topic": "test_light_rgb/color_mode/status", - "rgb_state_topic": "test_light_rgb/rgb/status", - "rgb_command_topic": "test_light_rgb/rgb/set", - "rgbw_state_topic": "test_light_rgb/rgbw/status", - "rgbw_command_topic": "test_light_rgb/rgbw/set", - "rgbww_state_topic": "test_light_rgb/rgbww/status", - "rgbww_command_topic": "test_light_rgb/rgbww/set", - "color_temp_state_topic": "test_light_rgb/color_temp/status", - "color_temp_command_topic": "test_light_rgb/color_temp/set", - "effect_state_topic": "test_light_rgb/effect/status", - "effect_command_topic": "test_light_rgb/effect/set", - "hs_state_topic": "test_light_rgb/hs/status", - "hs_command_topic": "test_light_rgb/hs/set", - "xy_state_topic": "test_light_rgb/xy/status", - "xy_command_topic": "test_light_rgb/xy/set", - "qos": "0", - "payload_on": 1, - "payload_off": 0, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -564,13 +576,9 @@ async def test_invalid_state_via_topic( assert light_state.attributes["color_temp"] == 153 -async def test_brightness_controlling_scale( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the brightness controlling scale.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -585,10 +593,14 @@ async def test_brightness_controlling_scale( "payload_off": "off", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_brightness_controlling_scale( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the brightness controlling scale.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -614,13 +626,9 @@ async def test_brightness_controlling_scale( assert light_state.attributes["brightness"] == 255 -async def test_brightness_from_rgb_controlling_scale( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the brightness controlling scale.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -634,9 +642,14 @@ async def test_brightness_from_rgb_controlling_scale( "payload_off": "off", } } - }, - ) - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_brightness_from_rgb_controlling_scale( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the brightness controlling scale.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.async_block_till_done() state = hass.states.get("light.test") @@ -675,47 +688,52 @@ async def test_brightness_from_rgb_controlling_scale( assert state.attributes.get("rgb_color") == (255, 127, 63) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "state_topic": "test_light_rgb/status", + "command_topic": "test_light_rgb/set", + "brightness_command_topic": "test_light_rgb/brightness/set", + "rgb_command_topic": "test_light_rgb/rgb/set", + "rgbw_command_topic": "test_light_rgb/rgbw/set", + "rgbww_command_topic": "test_light_rgb/rgbw/set", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "effect_command_topic": "test_light_rgb/effect/set", + "hs_command_topic": "test_light_rgb/hs/set", + "xy_command_topic": "test_light_rgb/xy/set", + "brightness_state_topic": "test_light_rgb/brightness/status", + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "effect_state_topic": "test_light_rgb/effect/status", + "hs_state_topic": "test_light_rgb/hs/status", + "rgb_state_topic": "test_light_rgb/rgb/status", + "rgbw_state_topic": "test_light_rgb/rgbw/status", + "rgbww_state_topic": "test_light_rgb/rgbww/status", + "xy_state_topic": "test_light_rgb/xy/status", + "state_value_template": "{{ value_json.hello }}", + "brightness_value_template": "{{ value_json.hello }}", + "color_temp_value_template": "{{ value_json.hello }}", + "effect_value_template": "{{ value_json.hello }}", + "hs_value_template": '{{ value_json.hello | join(",") }}', + "rgb_value_template": '{{ value_json.hello | join(",") }}', + "rgbw_value_template": '{{ value_json.hello | join(",") }}', + "rgbww_value_template": '{{ value_json.hello | join(",") }}', + "xy_value_template": '{{ value_json.hello | join(",") }}', + } + } + } + ], +) async def test_controlling_state_via_topic_with_templates( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of the state with a template.""" - config = { - light.DOMAIN: { - "name": "test", - "state_topic": "test_light_rgb/status", - "command_topic": "test_light_rgb/set", - "brightness_command_topic": "test_light_rgb/brightness/set", - "rgb_command_topic": "test_light_rgb/rgb/set", - "rgbw_command_topic": "test_light_rgb/rgbw/set", - "rgbww_command_topic": "test_light_rgb/rgbw/set", - "color_temp_command_topic": "test_light_rgb/color_temp/set", - "effect_command_topic": "test_light_rgb/effect/set", - "hs_command_topic": "test_light_rgb/hs/set", - "xy_command_topic": "test_light_rgb/xy/set", - "brightness_state_topic": "test_light_rgb/brightness/status", - "color_temp_state_topic": "test_light_rgb/color_temp/status", - "effect_state_topic": "test_light_rgb/effect/status", - "hs_state_topic": "test_light_rgb/hs/status", - "rgb_state_topic": "test_light_rgb/rgb/status", - "rgbw_state_topic": "test_light_rgb/rgbw/status", - "rgbww_state_topic": "test_light_rgb/rgbww/status", - "xy_state_topic": "test_light_rgb/xy/status", - "state_value_template": "{{ value_json.hello }}", - "brightness_value_template": "{{ value_json.hello }}", - "color_temp_value_template": "{{ value_json.hello }}", - "effect_value_template": "{{ value_json.hello }}", - "hs_value_template": '{{ value_json.hello | join(",") }}', - "rgb_value_template": '{{ value_json.hello | join(",") }}', - "rgbw_value_template": '{{ value_json.hello | join(",") }}', - "rgbww_value_template": '{{ value_json.hello | join(",") }}', - "xy_value_template": '{{ value_json.hello | join(",") }}', - } - } color_modes = ["color_temp", "hs", "rgb", "rgbw", "rgbww", "xy"] - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -777,28 +795,35 @@ async def test_controlling_state_via_topic_with_templates( assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_rgb/set", + "brightness_command_topic": "test_light_rgb/brightness/set", + "rgb_command_topic": "test_light_rgb/rgb/set", + "rgbw_command_topic": "test_light_rgb/rgbw/set", + "rgbww_command_topic": "test_light_rgb/rgbww/set", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "effect_command_topic": "test_light_rgb/effect/set", + "hs_command_topic": "test_light_rgb/hs/set", + "xy_command_topic": "test_light_rgb/xy/set", + "effect_list": ["colorloop", "random"], + "qos": 2, + "payload_on": "on", + "payload_off": "off", + } + } + } + ], +) async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the sending of command in optimistic mode.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light_rgb/set", - "brightness_command_topic": "test_light_rgb/brightness/set", - "rgb_command_topic": "test_light_rgb/rgb/set", - "rgbw_command_topic": "test_light_rgb/rgbw/set", - "rgbww_command_topic": "test_light_rgb/rgbww/set", - "color_temp_command_topic": "test_light_rgb/color_temp/set", - "effect_command_topic": "test_light_rgb/effect/set", - "hs_command_topic": "test_light_rgb/hs/set", - "xy_command_topic": "test_light_rgb/xy/set", - "effect_list": ["colorloop", "random"], - "qos": 2, - "payload_on": "on", - "payload_off": "off", - } - } color_modes = ["color_temp", "hs", "rgb", "rgbw", "rgbww", "xy"] fake_state = State( "light.test", @@ -813,9 +838,7 @@ async def test_sending_mqtt_commands_and_optimistic( ) mock_restore_cache(hass, (fake_state,)) - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_ON @@ -966,26 +989,30 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_rgb/set", + "rgb_command_topic": "test_light_rgb/rgb/set", + "rgb_command_template": '{{ "#%02x%02x%02x" | ' + "format(red, green, blue)}}", + "payload_on": "on", + "payload_off": "off", + "qos": 0, + } + } + } + ], +) async def test_sending_mqtt_rgb_command_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the sending of RGB command with template.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light_rgb/set", - "rgb_command_topic": "test_light_rgb/rgb/set", - "rgb_command_template": '{{ "#%02x%02x%02x" | ' - "format(red, green, blue)}}", - "payload_on": "on", - "payload_off": "off", - "qos": 0, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1005,26 +1032,30 @@ async def test_sending_mqtt_rgb_command_with_template( assert state.attributes["rgb_color"] == (255, 128, 64) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_rgb/set", + "rgbw_command_topic": "test_light_rgb/rgbw/set", + "rgbw_command_template": '{{ "#%02x%02x%02x%02x" | ' + "format(red, green, blue, white)}}", + "payload_on": "on", + "payload_off": "off", + "qos": 0, + } + } + } + ], +) async def test_sending_mqtt_rgbw_command_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the sending of RGBW command with template.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light_rgb/set", - "rgbw_command_topic": "test_light_rgb/rgbw/set", - "rgbw_command_template": '{{ "#%02x%02x%02x%02x" | ' - "format(red, green, blue, white)}}", - "payload_on": "on", - "payload_off": "off", - "qos": 0, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1044,26 +1075,30 @@ async def test_sending_mqtt_rgbw_command_with_template( assert state.attributes["rgbw_color"] == (255, 128, 64, 32) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_rgb/set", + "rgbww_command_topic": "test_light_rgb/rgbww/set", + "rgbww_command_template": '{{ "#%02x%02x%02x%02x%02x" | ' + "format(red, green, blue, cold_white, warm_white)}}", + "payload_on": "on", + "payload_off": "off", + "qos": 0, + } + } + } + ], +) async def test_sending_mqtt_rgbww_command_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the sending of RGBWW command with template.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light_rgb/set", - "rgbww_command_topic": "test_light_rgb/rgbww/set", - "rgbww_command_template": '{{ "#%02x%02x%02x%02x%02x" | ' - "format(red, green, blue, cold_white, warm_white)}}", - "payload_on": "on", - "payload_off": "off", - "qos": 0, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1083,25 +1118,29 @@ async def test_sending_mqtt_rgbww_command_with_template( assert state.attributes["rgbww_color"] == (255, 128, 64, 32, 16) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_color_temp/set", + "color_temp_command_topic": "test_light_color_temp/color_temp/set", + "color_temp_command_template": "{{ (1000 / value) | round(0) }}", + "payload_on": "on", + "payload_off": "off", + "qos": 0, + } + } + } + ], +) async def test_sending_mqtt_color_temp_command_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the sending of Color Temp command with template.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light_color_temp/set", - "color_temp_command_topic": "test_light_color_temp/color_temp/set", - "color_temp_command_template": "{{ (1000 / value) | round(0) }}", - "payload_on": "on", - "payload_off": "off", - "qos": 0, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1121,22 +1160,26 @@ async def test_sending_mqtt_color_temp_command_with_template( assert state.attributes["color_temp"] == 100 +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light/set", + "brightness_command_topic": "test_light/bright", + "on_command_type": "first", + } + } + } + ], +) async def test_on_command_first( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test on command being sent before brightness.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light/set", - "brightness_command_topic": "test_light/bright", - "on_command_type": "first", - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1159,21 +1202,25 @@ async def test_on_command_first( mqtt_mock.async_publish.assert_called_once_with("test_light/set", "OFF", 0, False) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light/set", + "brightness_command_topic": "test_light/bright", + } + } + } + ], +) async def test_on_command_last( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test on command being sent after brightness.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light/set", - "brightness_command_topic": "test_light/bright", - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1196,23 +1243,27 @@ async def test_on_command_last( mqtt_mock.async_publish.assert_called_once_with("test_light/set", "OFF", 0, False) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light/set", + "brightness_command_topic": "test_light/bright", + "rgb_command_topic": "test_light/rgb", + "on_command_type": "brightness", + } + } + } + ], +) async def test_on_command_brightness( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test on command being sent as only brightness.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light/set", - "brightness_command_topic": "test_light/bright", - "rgb_command_topic": "test_light/rgb", - "on_command_type": "brightness", - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1253,24 +1304,28 @@ async def test_on_command_brightness( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light/set", + "brightness_command_topic": "test_light/bright", + "brightness_scale": 100, + "rgb_command_topic": "test_light/rgb", + "on_command_type": "brightness", + } + } + } + ], +) async def test_on_command_brightness_scaled( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test brightness scale.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light/set", - "brightness_command_topic": "test_light/bright", - "brightness_scale": 100, - "rgb_command_topic": "test_light/rgb", - "on_command_type": "brightness", - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1325,21 +1380,25 @@ async def test_on_command_brightness_scaled( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light/set", + "rgb_command_topic": "test_light/rgb", + } + } + } + ], +) async def test_on_command_rgb( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test on command in RGB brightness mode.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light/set", - "rgb_command_topic": "test_light/rgb", - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1417,21 +1476,25 @@ async def test_on_command_rgb( mqtt_mock.async_publish.reset_mock() +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light/set", + "rgbw_command_topic": "test_light/rgbw", + } + } + } + ], +) async def test_on_command_rgbw( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test on command in RGBW brightness mode.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light/set", - "rgbw_command_topic": "test_light/rgbw", - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1509,21 +1572,25 @@ async def test_on_command_rgbw( mqtt_mock.async_publish.reset_mock() +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light/set", + "rgbww_command_topic": "test_light/rgbww", + } + } + } + ], +) async def test_on_command_rgbww( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test on command in RGBWW brightness mode.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light/set", - "rgbww_command_topic": "test_light/rgbww", - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1601,22 +1668,26 @@ async def test_on_command_rgbww( mqtt_mock.async_publish.reset_mock() +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light/set", + "rgb_command_topic": "test_light/rgb", + "rgb_command_template": "{{ red }}/{{ green }}/{{ blue }}", + } + } + } + ], +) async def test_on_command_rgb_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test on command in RGB brightness mode with RGB template.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light/set", - "rgb_command_topic": "test_light/rgb", - "rgb_command_template": "{{ red }}/{{ green }}/{{ blue }}", - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1640,22 +1711,26 @@ async def test_on_command_rgb_template( mqtt_mock.async_publish.assert_called_once_with("test_light/set", "OFF", 0, False) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light/set", + "rgbw_command_topic": "test_light/rgbw", + "rgbw_command_template": "{{ red }}/{{ green }}/{{ blue }}/{{ white }}", + } + } + } + ], +) async def test_on_command_rgbw_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test on command in RGBW brightness mode with RGBW template.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light/set", - "rgbw_command_topic": "test_light/rgbw", - "rgbw_command_template": "{{ red }}/{{ green }}/{{ blue }}/{{ white }}", - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1678,22 +1753,27 @@ async def test_on_command_rgbw_template( mqtt_mock.async_publish.assert_called_once_with("test_light/set", "OFF", 0, False) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light/set", + "rgbww_command_topic": "test_light/rgbww", + "rgbww_command_template": "{{ red }}/{{ green }}/{{ blue }}" + "/{{ cold_white }}/{{ warm_white }}", + } + } + } + ], +) async def test_on_command_rgbww_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test on command in RGBWW brightness mode with RGBWW template.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light/set", - "rgbww_command_topic": "test_light/rgbww", - "rgbww_command_template": "{{ red }}/{{ green }}/{{ blue }}/{{ cold_white }}/{{ warm_white }}", - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1717,34 +1797,39 @@ async def test_on_command_rgbww_template( mqtt_mock.async_publish.assert_called_once_with("test_light/set", "OFF", 0, False) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "tasmota_B94927/cmnd/POWER", + "state_value_template": "{{ value_json.POWER }}", + "payload_off": "OFF", + "payload_on": "ON", + "brightness_command_topic": "tasmota_B94927/cmnd/Dimmer", + "brightness_scale": 100, + "on_command_type": "brightness", + "brightness_value_template": "{{ value_json.Dimmer }}", + "rgb_command_topic": "tasmota_B94927/cmnd/Color2", + "rgb_value_template": "{{value_json.Color.split(',')[0:3]|join(',')}}", + "white_command_topic": "tasmota_B94927/cmnd/White", + "white_scale": 100, + "color_mode_value_template": "{% if value_json.White %} white {% else %} rgb {% endif %}", + "qos": "0", + } + } + } + ], +) async def test_on_command_white( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test sending commands for RGB + white light.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "tasmota_B94927/cmnd/POWER", - "state_value_template": "{{ value_json.POWER }}", - "payload_off": "OFF", - "payload_on": "ON", - "brightness_command_topic": "tasmota_B94927/cmnd/Dimmer", - "brightness_scale": 100, - "on_command_type": "brightness", - "brightness_value_template": "{{ value_json.Dimmer }}", - "rgb_command_topic": "tasmota_B94927/cmnd/Color2", - "rgb_value_template": "{{value_json.Color.split(',')[0:3]|join(',')}}", - "white_command_topic": "tasmota_B94927/cmnd/White", - "white_scale": 100, - "color_mode_value_template": "{% if value_json.White %} white {% else %} rgb {% endif %}", - "qos": "0", - } - } color_modes = ["rgb", "white"] - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1796,42 +1881,47 @@ async def test_on_command_white( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "state_topic": "test_light_rgb/status", + "command_topic": "test_light_rgb/set", + "color_mode_state_topic": "test_light_rgb/color_mode/status", + "brightness_state_topic": "test_light_rgb/brightness/status", + "brightness_command_topic": "test_light_rgb/brightness/set", + "rgb_state_topic": "test_light_rgb/rgb/status", + "rgb_command_topic": "test_light_rgb/rgb/set", + "rgbw_state_topic": "test_light_rgb/rgbw/status", + "rgbw_command_topic": "test_light_rgb/rgbw/set", + "rgbww_state_topic": "test_light_rgb/rgbww/status", + "rgbww_command_topic": "test_light_rgb/rgbww/set", + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "effect_state_topic": "test_light_rgb/effect/status", + "effect_command_topic": "test_light_rgb/effect/set", + "hs_state_topic": "test_light_rgb/hs/status", + "hs_command_topic": "test_light_rgb/hs/set", + "xy_state_topic": "test_light_rgb/xy/status", + "xy_command_topic": "test_light_rgb/xy/set", + "qos": "0", + "payload_on": 1, + "payload_off": 0, + } + } + } + ], +) async def test_explicit_color_mode( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test explicit color mode over mqtt.""" - config = { - light.DOMAIN: { - "name": "test", - "state_topic": "test_light_rgb/status", - "command_topic": "test_light_rgb/set", - "color_mode_state_topic": "test_light_rgb/color_mode/status", - "brightness_state_topic": "test_light_rgb/brightness/status", - "brightness_command_topic": "test_light_rgb/brightness/set", - "rgb_state_topic": "test_light_rgb/rgb/status", - "rgb_command_topic": "test_light_rgb/rgb/set", - "rgbw_state_topic": "test_light_rgb/rgbw/status", - "rgbw_command_topic": "test_light_rgb/rgbw/set", - "rgbww_state_topic": "test_light_rgb/rgbww/status", - "rgbww_command_topic": "test_light_rgb/rgbww/set", - "color_temp_state_topic": "test_light_rgb/color_temp/status", - "color_temp_command_topic": "test_light_rgb/color_temp/set", - "effect_state_topic": "test_light_rgb/effect/status", - "effect_command_topic": "test_light_rgb/effect/set", - "hs_state_topic": "test_light_rgb/hs/status", - "hs_command_topic": "test_light_rgb/hs/set", - "xy_state_topic": "test_light_rgb/xy/status", - "xy_command_topic": "test_light_rgb/xy/set", - "qos": "0", - "payload_on": 1, - "payload_off": 0, - } - } color_modes = ["color_temp", "hs", "rgb", "rgbw", "rgbww", "xy"] - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1946,33 +2036,38 @@ async def test_explicit_color_mode( assert light_state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "state_topic": "test_light_rgb/status", + "command_topic": "test_light_rgb/set", + "color_mode_state_topic": "test_light_rgb/color_mode/status", + "color_mode_value_template": "{{ value_json.color_mode }}", + "brightness_state_topic": "test_light_rgb/brightness/status", + "brightness_command_topic": "test_light_rgb/brightness/set", + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "hs_state_topic": "test_light_rgb/hs/status", + "hs_command_topic": "test_light_rgb/hs/set", + "qos": "0", + "payload_on": 1, + "payload_off": 0, + } + } + } + ], +) async def test_explicit_color_mode_templated( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test templated explicit color mode over mqtt.""" - config = { - light.DOMAIN: { - "name": "test", - "state_topic": "test_light_rgb/status", - "command_topic": "test_light_rgb/set", - "color_mode_state_topic": "test_light_rgb/color_mode/status", - "color_mode_value_template": "{{ value_json.color_mode }}", - "brightness_state_topic": "test_light_rgb/brightness/status", - "brightness_command_topic": "test_light_rgb/brightness/set", - "color_temp_state_topic": "test_light_rgb/color_temp/status", - "color_temp_command_topic": "test_light_rgb/color_temp/set", - "hs_state_topic": "test_light_rgb/hs/status", - "hs_command_topic": "test_light_rgb/hs/set", - "qos": "0", - "payload_on": 1, - "payload_off": 0, - } - } color_modes = ["color_temp", "hs"] - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -2029,38 +2124,45 @@ async def test_explicit_color_mode_templated( assert light_state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "state_topic": "tasmota_B94927/tele/STATE", + "command_topic": "tasmota_B94927/cmnd/POWER", + "state_value_template": "{{ value_json.POWER }}", + "payload_off": "OFF", + "payload_on": "ON", + "brightness_command_topic": "tasmota_B94927/cmnd/Dimmer", + "brightness_state_topic": "tasmota_B94927/tele/STATE", + "brightness_scale": 100, + "on_command_type": "brightness", + "brightness_value_template": "{{ value_json.Dimmer }}", + "rgb_command_topic": "tasmota_B94927/cmnd/Color2", + "rgb_state_topic": "tasmota_B94927/tele/STATE", + "rgb_value_template": "{{value_json.Color.split(',')" + "[0:3]|join(',')}}", + "white_command_topic": "tasmota_B94927/cmnd/White", + "white_scale": 100, + "color_mode_state_topic": "tasmota_B94927/tele/STATE", + "color_mode_value_template": "{% if value_json.White %} white " + "{% else %} rgb {% endif %}", + "qos": "0", + } + } + } + ], +) async def test_white_state_update( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test state updates for RGB + white light.""" - config = { - light.DOMAIN: { - "name": "test", - "state_topic": "tasmota_B94927/tele/STATE", - "command_topic": "tasmota_B94927/cmnd/POWER", - "state_value_template": "{{ value_json.POWER }}", - "payload_off": "OFF", - "payload_on": "ON", - "brightness_command_topic": "tasmota_B94927/cmnd/Dimmer", - "brightness_state_topic": "tasmota_B94927/tele/STATE", - "brightness_scale": 100, - "on_command_type": "brightness", - "brightness_value_template": "{{ value_json.Dimmer }}", - "rgb_command_topic": "tasmota_B94927/cmnd/Color2", - "rgb_state_topic": "tasmota_B94927/tele/STATE", - "rgb_value_template": "{{value_json.Color.split(',')[0:3]|join(',')}}", - "white_command_topic": "tasmota_B94927/cmnd/White", - "white_scale": 100, - "color_mode_state_topic": "tasmota_B94927/tele/STATE", - "color_mode_value_template": "{% if value_json.White %} white {% else %} rgb {% endif %}", - "qos": "0", - } - } color_modes = ["rgb", "white"] - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -2095,22 +2197,26 @@ async def test_white_state_update( assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light/set", + "effect_command_topic": "test_light/effect/set", + "effect_list": ["rainbow", "colorloop"], + } + } + } + ], +) async def test_effect( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test effect.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light/set", - "effect_command_topic": "test_light/effect/set", - "effect_list": ["rainbow", "colorloop"], - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -2893,22 +2999,26 @@ async def test_entity_debug_info_message( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_max_mireds/set", + "color_temp_command_topic": "test_max_mireds/color_temp/set", + "max_mireds": 370, + } + } + } + ], +) async def test_max_mireds( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting min_mireds and max_mireds.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_max_mireds/set", - "color_temp_command_topic": "test_max_mireds/color_temp/set", - "max_mireds": 370, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.attributes.get("min_mireds") == 153 @@ -3145,25 +3255,29 @@ async def test_encoding_subscribable_topics_brightness( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_brightness/set", + "brightness_command_topic": "test_light_brightness/brightness/set", + "brightness_command_template": "{{ (1000 / value) | round(0) }}", + "payload_on": "on", + "payload_off": "off", + "qos": 0, + } + } + } + ], +) async def test_sending_mqtt_brightness_command_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the sending of Brightness command with template.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light_brightness/set", - "brightness_command_topic": "test_light_brightness/brightness/set", - "brightness_command_template": "{{ (1000 / value) | round(0) }}", - "payload_on": "on", - "payload_off": "off", - "qos": 0, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -3183,27 +3297,31 @@ async def test_sending_mqtt_brightness_command_with_template( assert state.attributes["brightness"] == 100 +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_brightness/set", + "brightness_command_topic": "test_light_brightness/brightness/set", + "effect_command_topic": "test_light_brightness/effect/set", + "effect_command_template": '{ "effect": "{{ value }}" }', + "effect_list": ["colorloop", "random"], + "payload_on": "on", + "payload_off": "off", + "qos": 0, + } + } + } + ], +) async def test_sending_mqtt_effect_command_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the sending of Effect command with template.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light_brightness/set", - "brightness_command_topic": "test_light_brightness/brightness/set", - "effect_command_topic": "test_light_brightness/effect/set", - "effect_command_template": '{ "effect": "{{ value }}" }', - "effect_list": ["colorloop", "random"], - "payload_on": "on", - "payload_off": "off", - "qos": 0, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -3227,23 +3345,27 @@ async def test_sending_mqtt_effect_command_with_template( assert state.attributes.get("effect") == "colorloop" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_hs/set", + "hs_command_topic": "test_light_hs/hs_color/set", + "hs_command_template": '{"hue": {{ hue | int }}, "sat": {{ sat | int}}}', + "qos": 0, + } + } + } + ], +) async def test_sending_mqtt_hs_command_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the sending of HS Color command with template.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light_hs/set", - "hs_command_topic": "test_light_hs/hs_color/set", - "hs_command_template": '{"hue": {{ hue | int }}, "sat": {{ sat | int}}}', - "qos": 0, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -3263,23 +3385,30 @@ async def test_sending_mqtt_hs_command_with_template( assert state.attributes["hs_color"] == (30, 100) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_xy/set", + "xy_command_topic": "test_light_xy/xy_color/set", + "xy_command_template": "{" + '"Color": "{{ (x * 65536) | round | int }},' + '{{ (y * 65536) | round | int }}"' + "}", + "qos": 0, + } + } + } + ], +) async def test_sending_mqtt_xy_command_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the sending of XY Color command with template.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light_xy/set", - "xy_command_topic": "test_light_xy/xy_color/set", - "xy_command_template": '{"Color": "{{ (x * 65536) | round | int }},{{ (y * 65536) | round | int }}"}', - "qos": 0, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index db39cea784..1b24f7636f 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -98,10 +98,10 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, State -from homeassistant.setup import async_setup_component from homeassistant.util.json import JsonValueType, json_loads from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -144,6 +144,30 @@ DEFAULT_CONFIG = { } +COLOR_MODES_CONFIG = { + mqtt.DOMAIN: { + light.DOMAIN: { + "brightness": True, + "color_mode": True, + "effect": True, + "command_topic": "test_light_rgb/set", + "name": "test", + "schema": "json", + "supported_color_modes": [ + "color_temp", + "hs", + "rgb", + "rgbw", + "rgbww", + "white", + "xy", + ], + "qos": 0, + } + } +} + + @pytest.fixture(autouse=True) def light_platform_only(): """Only setup the light platform to speed up tests.""" @@ -163,44 +187,40 @@ class JsonValidator: return json_loads(self.jsondata) == json_loads(other) +@pytest.mark.parametrize( + "hass_config", [{mqtt.DOMAIN: {light.DOMAIN: {"schema": "json", "name": "test"}}}] +) async def test_fail_setup_if_no_command_topic( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test if setup fails with no command topic.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {light.DOMAIN: {"schema": "json", "name": "test"}}}, - ) + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( "Invalid config for [mqtt]: required key not provided @ data['mqtt']['light'][0]['command_topic']. Got None." in caplog.text ) -@pytest.mark.parametrize("deprecated", ("color_temp", "hs", "rgb", "xy")) +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config(light.DOMAIN, COLOR_MODES_CONFIG, ({"color_temp": True},)), + help_custom_config(light.DOMAIN, COLOR_MODES_CONFIG, ({"hs": True},)), + help_custom_config(light.DOMAIN, COLOR_MODES_CONFIG, ({"rgb": True},)), + help_custom_config(light.DOMAIN, COLOR_MODES_CONFIG, ({"xy": True},)), + ], +) async def test_fail_setup_if_color_mode_deprecated( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, deprecated + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test if setup fails if color mode is combined with deprecated config keys.""" - supported_color_modes = ["color_temp", "hs", "rgb", "rgbw", "rgbww", "xy"] - - config = { - light.DOMAIN: { - "brightness": True, - "color_mode": True, - "command_topic": "test_light_rgb/set", - "name": "test", - "schema": "json", - "supported_color_modes": supported_color_modes, - } - } - config[light.DOMAIN][deprecated] = True - assert not await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: config}, - ) + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( "Invalid config for [mqtt]: color_mode must not be combined with any of" in caplog.text @@ -208,42 +228,49 @@ async def test_fail_setup_if_color_mode_deprecated( @pytest.mark.parametrize( - ("supported_color_modes", "error"), + ("hass_config", "error"), [ - (["onoff", "rgb"], "Unknown error calling mqtt CONFIG_SCHEMA"), - (["brightness", "rgb"], "Unknown error calling mqtt CONFIG_SCHEMA"), - (["unknown"], "Invalid config for [mqtt]: value must be one of [ None: """Test if setup fails if supported color modes is invalid.""" - config = { - light.DOMAIN: { - "brightness": True, - "color_mode": True, - "command_topic": "test_light_rgb/set", - "name": "test", - "schema": "json", - "supported_color_modes": supported_color_modes, - } - } - assert not await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: config}, - ) + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert error in caplog.text -async def test_legacy_rgb_light( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test legacy RGB light flags expected features and color modes.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -253,10 +280,14 @@ async def test_legacy_rgb_light( "rgb": True, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_legacy_rgb_light( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test legacy RGB light flags expected features and color modes.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") color_modes = [light.ColorMode.HS] @@ -265,13 +296,9 @@ async def test_legacy_rgb_light( assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features -async def test_no_color_brightness_color_temp_if_no_topics( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test for no RGB, brightness, color temp, effector XY.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -281,10 +308,14 @@ async def test_no_color_brightness_color_temp_if_no_topics( "command_topic": "test_light_rgb/set", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_no_color_brightness_color_temp_if_no_topics( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test for no RGB, brightness, color temp, effector XY.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -319,13 +350,9 @@ async def test_no_color_brightness_color_temp_if_no_topics( assert state.state == STATE_UNKNOWN -async def test_controlling_state_via_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling of the state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -342,10 +369,14 @@ async def test_controlling_state_via_topic( "qos": "0", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling of the state via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -463,35 +494,22 @@ async def test_controlling_state_via_topic( assert light_state.attributes.get("effect") == "colorloop" +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + light.DOMAIN, COLOR_MODES_CONFIG, ({"state_topic": "test_light_rgb"},) + ) + ], +) async def test_controlling_state_via_topic2( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test the controlling of the state via topic for a light supporting color mode.""" supported_color_modes = ["color_temp", "hs", "rgb", "rgbw", "rgbww", "white", "xy"] - - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - light.DOMAIN: { - "brightness": True, - "color_mode": True, - "command_topic": "test_light_rgb/set", - "effect": True, - "name": "test", - "qos": "0", - "schema": "json", - "state_topic": "test_light_rgb", - "supported_color_modes": supported_color_modes, - } - } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -641,25 +659,9 @@ async def test_controlling_state_via_topic2( ) -async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the sending of command in optimistic mode.""" - fake_state = State( - "light.test", - "on", - { - "brightness": 95, - "hs_color": [100, 100], - "effect": "random", - "color_temp": 100, - }, - ) - mock_restore_cache(hass, (fake_state,)) - - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -675,10 +677,26 @@ async def test_sending_mqtt_commands_and_optimistic( "qos": 2, } } + } + ], +) +async def test_sending_mqtt_commands_and_optimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending of command in optimistic mode.""" + fake_state = State( + "light.test", + "on", + { + "brightness": 95, + "hs_color": [100, 100], + "effect": "random", + "color_temp": 100, }, ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mock_restore_cache(hass, (fake_state,)) + + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_ON @@ -788,8 +806,35 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.attributes["xy_color"] == (0.611, 0.375) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "brightness": True, + "color_mode": True, + "command_topic": "test_light_rgb/set", + "effect": True, + "name": "test", + "qos": 2, + "schema": "json", + "supported_color_modes": [ + "color_temp", + "hs", + "rgb", + "rgbw", + "rgbww", + "white", + "xy", + ], + } + } + } + ], +) async def test_sending_mqtt_commands_and_optimistic2( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the sending of command in optimistic mode for a light supporting color mode.""" supported_color_modes = ["color_temp", "hs", "rgb", "rgbw", "rgbww", "white", "xy"] @@ -806,26 +851,7 @@ async def test_sending_mqtt_commands_and_optimistic2( ) mock_restore_cache(hass, (fake_state,)) - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - light.DOMAIN: { - "brightness": True, - "color_mode": True, - "command_topic": "test_light_rgb/set", - "effect": True, - "name": "test", - "qos": 2, - "schema": "json", - "supported_color_modes": supported_color_modes, - } - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_ON @@ -1020,13 +1046,9 @@ async def test_sending_mqtt_commands_and_optimistic2( mqtt_mock.async_publish.reset_mock() -async def test_sending_hs_color( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test light.turn_on with hs color sends hs color parameters.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1037,10 +1059,14 @@ async def test_sending_hs_color( "hs": True, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_hs_color( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test light.turn_on with hs color sends hs color parameters.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1083,13 +1109,9 @@ async def test_sending_hs_color( ) -async def test_sending_rgb_color_no_brightness( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test light.turn_on with hs color sends rgb color parameters.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1099,10 +1121,15 @@ async def test_sending_rgb_color_no_brightness( "rgb": True, } } - }, - ) + } + ], +) +async def test_sending_rgb_color_no_brightness( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test light.turn_on with hs color sends rgb color parameters.""" await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1140,14 +1167,9 @@ async def test_sending_rgb_color_no_brightness( ) -async def test_sending_rgb_color_no_brightness2( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test light.turn_on with hs color sends rgb color parameters.""" - supported_color_modes = ["rgb", "rgbw", "rgbww"] - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1155,13 +1177,17 @@ async def test_sending_rgb_color_no_brightness2( "command_topic": "test_light_rgb/set", "name": "test", "schema": "json", - "supported_color_modes": supported_color_modes, + "supported_color_modes": ["rgb", "rgbw", "rgbww"], } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_rgb_color_no_brightness2( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test light.turn_on with hs color sends rgb color parameters.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1221,13 +1247,9 @@ async def test_sending_rgb_color_no_brightness2( ) -async def test_sending_rgb_color_with_brightness( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test light.turn_on with hs color sends rgb color parameters.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1238,10 +1260,14 @@ async def test_sending_rgb_color_with_brightness( "rgb": True, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_rgb_color_with_brightness( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test light.turn_on with hs color sends rgb color parameters.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1289,13 +1315,9 @@ async def test_sending_rgb_color_with_brightness( ) -async def test_sending_rgb_color_with_scaled_brightness( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test light.turn_on with hs color sends rgb color parameters.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1307,10 +1329,15 @@ async def test_sending_rgb_color_with_scaled_brightness( "rgb": True, } } - }, - ) + } + ], +) +async def test_sending_rgb_color_with_scaled_brightness( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test light.turn_on with hs color sends rgb color parameters.""" await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1358,13 +1385,9 @@ async def test_sending_rgb_color_with_scaled_brightness( ) -async def test_sending_scaled_white( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test light.turn_on with scaled white.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1378,10 +1401,14 @@ async def test_sending_scaled_white( "white_scale": 50, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_scaled_white( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test light.turn_on with scaled white.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1405,13 +1432,9 @@ async def test_sending_scaled_white( mqtt_mock.async_publish.reset_mock() -async def test_sending_xy_color( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test light.turn_on with hs color sends xy color parameters.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1422,10 +1445,14 @@ async def test_sending_xy_color( "xy": True, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_xy_color( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test light.turn_on with hs color sends xy color parameters.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1467,13 +1494,9 @@ async def test_sending_xy_color( ) -async def test_effect( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test for effect being sent when included.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1484,10 +1507,14 @@ async def test_effect( "qos": 0, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_effect( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test for effect being sent when included.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1533,13 +1560,9 @@ async def test_effect( assert state.attributes.get("effect") == "colorloop" -async def test_flash_short_and_long( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test for flash length being sent when included.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1551,10 +1574,14 @@ async def test_flash_short_and_long( "qos": 0, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_flash_short_and_long( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test for flash length being sent when included.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1598,13 +1625,9 @@ async def test_flash_short_and_long( assert state.state == STATE_OFF -async def test_transition( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test for transition time being sent when included.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1614,10 +1637,14 @@ async def test_transition( "qos": 0, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_transition( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test for transition time being sent when included.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1648,13 +1675,9 @@ async def test_transition( assert state.state == STATE_OFF -async def test_brightness_scale( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test for brightness scaling.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1666,10 +1689,14 @@ async def test_brightness_scale( "brightness_scale": 99, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_brightness_scale( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test for brightness scaling.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1693,13 +1720,9 @@ async def test_brightness_scale( assert state.attributes.get("brightness") == 255 -async def test_white_scale( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test for white scaling.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1714,10 +1737,14 @@ async def test_white_scale( "white_scale": 50, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_white_scale( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test for white scaling.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1754,13 +1781,9 @@ async def test_white_scale( assert state.attributes.get("brightness") == 128 -async def test_invalid_values( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that invalid color/brightness/etc. values are ignored.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1774,10 +1797,14 @@ async def test_invalid_values( "qos": "0", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_invalid_values( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that invalid color/brightness/etc. values are ignored.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -2203,23 +2230,27 @@ async def test_entity_debug_info_message( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_max_mireds/set", + "color_temp": True, + "max_mireds": 370, + } + } + } + ], +) async def test_max_mireds( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting min_mireds and max_mireds.""" - config = { - light.DOMAIN: { - "schema": "json", - "name": "test", - "command_topic": "test_max_mireds/set", - "color_temp": True, - "max_mireds": 370, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.attributes.get("min_mireds") == 153 diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index c8018d4847..166687b859 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -44,7 +44,6 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, State -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -100,53 +99,60 @@ def light_platform_only(): @pytest.mark.parametrize( - "test_config", + "hass_config", [ - ({"schema": "template", "name": "test"},), + ({mqtt.DOMAIN: {light.DOMAIN: {"schema": "template", "name": "test"}}},), ( { - "schema": "template", - "name": "test", - "command_topic": "test_topic", + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "template", + "name": "test", + "command_topic": "test_topic", + } + } }, ), ( { - "schema": "template", - "name": "test", - "command_topic": "test_topic", - "command_on_template": "on", + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "template", + "name": "test", + "command_topic": "test_topic", + "command_on_template": "on", + } + } }, ), ( { - "schema": "template", - "name": "test", - "command_topic": "test_topic", - "command_off_template": "off", + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "template", + "name": "test", + "command_topic": "test_topic", + "command_off_template": "off", + } + } }, ), ], ) async def test_setup_fails( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, test_config + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test that setup fails with missing required configuration items.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {light.DOMAIN: test_config}}, - ) - assert "Invalid config for [mqtt]" in caplog.text + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() + assert "Invalid config" in caplog.text -async def test_rgb_light( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test RGB light flags brightness support.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -160,10 +166,14 @@ async def test_rgb_light( "blue_template": '{{ value.split(",")[4].' 'split("-")[2] }}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_rgb_light( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test RGB light flags brightness support.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -173,13 +183,9 @@ async def test_rgb_light( assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features -async def test_state_change_via_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test state change via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -197,10 +203,14 @@ async def test_state_change_via_topic( "state_template": '{{ value.split(",")[0] }}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_state_change_via_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test state change via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -228,13 +238,9 @@ async def test_state_change_via_topic( assert state.state == STATE_UNKNOWN -async def test_state_brightness_color_effect_temp_change_via_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test state, bri, color, effect, color temp change.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -254,16 +260,20 @@ async def test_state_brightness_color_effect_temp_change_via_topic( "state_template": '{{ value.split(",")[0] }}', "brightness_template": '{{ value.split(",")[1] }}', "color_temp_template": '{{ value.split(",")[2] }}', - "red_template": '{{ value.split(",")[3].' 'split("-")[0] }}', - "green_template": '{{ value.split(",")[3].' 'split("-")[1] }}', - "blue_template": '{{ value.split(",")[3].' 'split("-")[2] }}', + "red_template": '{{ value.split(",")[3].split("-")[0] }}', + "green_template": '{{ value.split(",")[3].split("-")[1] }}', + "blue_template": '{{ value.split(",")[3].split("-")[2] }}', "effect_template": '{{ value.split(",")[4] }}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_state_brightness_color_effect_temp_change_via_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test state, bri, color, effect, color temp change.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -339,25 +349,9 @@ async def test_state_brightness_color_effect_temp_change_via_topic( assert light_state.attributes.get("effect") == "rainbow" -async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the sending of command in optimistic mode.""" - fake_state = State( - "light.test", - "on", - { - "brightness": 95, - "hs_color": [100, 100], - "effect": "random", - "color_temp": 100, - }, - ) - mock_restore_cache(hass, (fake_state,)) - - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -384,10 +378,26 @@ async def test_sending_mqtt_commands_and_optimistic( "qos": 2, } } + } + ], +) +async def test_sending_mqtt_commands_and_optimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending of command in optimistic mode.""" + fake_state = State( + "light.test", + "on", + { + "brightness": 95, + "hs_color": [100, 100], + "effect": "random", + "color_temp": 100, }, ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mock_restore_cache(hass, (fake_state,)) + + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_ON @@ -481,13 +491,9 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.attributes.get("rgb_color") == (0, 255, 127) -async def test_sending_mqtt_commands_non_optimistic_brightness_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the sending of command in optimistic mode.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -508,16 +514,20 @@ async def test_sending_mqtt_commands_non_optimistic_brightness_template( "state_template": '{{ value.split(",")[0] }}', "brightness_template": '{{ value.split(",")[1] }}', "color_temp_template": '{{ value.split(",")[2] }}', - "red_template": '{{ value.split(",")[3].' 'split("-")[0] }}', - "green_template": '{{ value.split(",")[3].' 'split("-")[1] }}', - "blue_template": '{{ value.split(",")[3].' 'split("-")[2] }}', + "red_template": '{{ value.split(",")[3].split("-")[0] }}', + "green_template": '{{ value.split(",")[3].split("-")[1] }}', + "blue_template": '{{ value.split(",")[3].split("-")[2] }}', "effect_template": '{{ value.split(",")[4] }}', } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_non_optimistic_brightness_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending of command in optimistic mode.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -604,13 +614,9 @@ async def test_sending_mqtt_commands_non_optimistic_brightness_template( state = hass.states.get("light.test") -async def test_effect( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test effect sent over MQTT in optimistic mode.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -623,10 +629,14 @@ async def test_effect( "qos": 0, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_effect( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test effect sent over MQTT in optimistic mode.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -659,13 +669,9 @@ async def test_effect( assert state.attributes.get("effect") == "colorloop" -async def test_flash( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test flash sent over MQTT in optimistic mode.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -677,10 +683,14 @@ async def test_flash( "qos": 0, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_flash( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test flash sent over MQTT in optimistic mode.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -710,13 +720,9 @@ async def test_flash( assert state.state == STATE_ON -async def test_transition( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test for transition time being sent when included.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -728,10 +734,14 @@ async def test_transition( "qos": 1, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_transition( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test for transition time being sent when included.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -754,13 +764,9 @@ async def test_transition( assert state.state == STATE_OFF -async def test_invalid_values( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that invalid values are ignored.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -780,16 +786,21 @@ async def test_invalid_values( "state_template": '{{ value.split(",")[0] }}', "brightness_template": '{{ value.split(",")[1] }}', "color_temp_template": '{{ value.split(",")[2] }}', - "red_template": '{{ value.split(",")[3].' 'split("-")[0] }}', - "green_template": '{{ value.split(",")[3].' 'split("-")[1] }}', - "blue_template": '{{ value.split(",")[3].' 'split("-")[2] }}', + "red_template": '{{ value.split(",")[3].split("-")[0] }}', + "green_template": '{{ value.split(",")[3].split("-")[1] }}', + "blue_template": '{{ value.split(",")[3].split("-")[2] }}', "effect_template": '{{ value.split(",")[4] }}', } } - }, - ) + } + ], +) +async def test_invalid_values( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that invalid values are ignored.""" await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1173,25 +1184,29 @@ async def test_entity_debug_info_message( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "template", + "name": "test", + "command_topic": "test_max_mireds/set", + "command_on_template": "on", + "command_off_template": "off", + "color_temp_template": "{{ value }}", + "max_mireds": 370, + } + } + } + ], +) async def test_max_mireds( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting min_mireds and max_mireds.""" - config = { - light.DOMAIN: { - "schema": "template", - "name": "test", - "command_topic": "test_max_mireds/set", - "command_on_template": "on", - "command_off_template": "off", - "color_temp_template": "{{ value }}", - "max_mireds": 370, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.attributes.get("min_mireds") == 153 From 0570405a3cd81deb59fdfeb296bb5b8c99a5894e Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 24 Mar 2023 08:41:36 +0100 Subject: [PATCH 1220/2810] Prepare MQTT platform tests part6 (#90129) * Tests lock * Tests mixins * Tests number * Tests scene --- tests/components/mqtt/test_lock.py | 407 +++++++++++----------- tests/components/mqtt/test_mixins.py | 37 +- tests/components/mqtt/test_number.py | 490 +++++++++++++++------------ tests/components/mqtt/test_scene.py | 29 +- 4 files changed, 506 insertions(+), 457 deletions(-) diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index 0c8b6680c5..f13de40353 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -25,9 +25,9 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -63,6 +63,22 @@ DEFAULT_CONFIG = { mqtt.DOMAIN: {lock.DOMAIN: {"name": "test", "command_topic": "test-topic"}} } +CONFIG_WITH_STATES = { + mqtt.DOMAIN: { + lock.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_lock": "LOCK", + "payload_unlock": "UNLOCK", + "state_locked": "closed", + "state_locking": "closing", + "state_unlocked": "open", + "state_unlocking": "opening", + } + } +} + @pytest.fixture(autouse=True) def lock_platform_only(): @@ -72,42 +88,22 @@ def lock_platform_only(): @pytest.mark.parametrize( - ("payload", "lock_state"), + ("hass_config", "payload", "lock_state"), [ - ("LOCKED", STATE_LOCKED), - ("LOCKING", STATE_LOCKING), - ("UNLOCKED", STATE_UNLOCKED), - ("UNLOCKING", STATE_UNLOCKING), + (CONFIG_WITH_STATES, "closed", STATE_LOCKED), + (CONFIG_WITH_STATES, "closing", STATE_LOCKING), + (CONFIG_WITH_STATES, "open", STATE_UNLOCKED), + (CONFIG_WITH_STATES, "opening", STATE_UNLOCKING), ], ) async def test_controlling_state_via_topic( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - payload, - lock_state, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + payload: str, + lock_state: str, ) -> None: """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - lock.DOMAIN: { - "name": "test", - "state_topic": "state-topic", - "command_topic": "command-topic", - "payload_lock": "LOCK", - "payload_unlock": "UNLOCK", - "state_locked": "LOCKED", - "state_locking": "LOCKING", - "state_unlocked": "UNLOCKED", - "state_unlocking": "UNLOCKING", - } - } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -115,48 +111,29 @@ async def test_controlling_state_via_topic( assert not state.attributes.get(ATTR_SUPPORTED_FEATURES) async_fire_mqtt_message(hass, "state-topic", payload) + await hass.async_block_till_done() state = hass.states.get("lock.test") assert state.state is lock_state @pytest.mark.parametrize( - ("payload", "lock_state"), + ("hass_config", "payload", "lock_state"), [ - ("closed", STATE_LOCKED), - ("closing", STATE_LOCKING), - ("open", STATE_UNLOCKED), - ("opening", STATE_UNLOCKING), + (CONFIG_WITH_STATES, "closed", STATE_LOCKED), + (CONFIG_WITH_STATES, "closing", STATE_LOCKING), + (CONFIG_WITH_STATES, "open", STATE_UNLOCKED), + (CONFIG_WITH_STATES, "opening", STATE_UNLOCKING), ], ) async def test_controlling_non_default_state_via_topic( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - payload, - lock_state, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + payload: str, + lock_state: str, ) -> None: """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - lock.DOMAIN: { - "name": "test", - "state_topic": "state-topic", - "command_topic": "command-topic", - "payload_lock": "LOCK", - "payload_unlock": "UNLOCK", - "state_locked": "closed", - "state_locking": "closing", - "state_unlocked": "open", - "state_unlocking": "opening", - } - } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -169,43 +146,54 @@ async def test_controlling_non_default_state_via_topic( @pytest.mark.parametrize( - ("payload", "lock_state"), + ("hass_config", "payload", "lock_state"), [ - ('{"val":"LOCKED"}', STATE_LOCKED), - ('{"val":"LOCKING"}', STATE_LOCKING), - ('{"val":"UNLOCKED"}', STATE_UNLOCKED), - ('{"val":"UNLOCKING"}', STATE_UNLOCKING), + ( + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ({"value_template": "{{ value_json.val }}"},), + ), + '{"val":"closed"}', + STATE_LOCKED, + ), + ( + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ({"value_template": "{{ value_json.val }}"},), + ), + '{"val":"closing"}', + STATE_LOCKING, + ), + ( + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ({"value_template": "{{ value_json.val }}"},), + ), + '{"val":"opening"}', + STATE_UNLOCKING, + ), + ( + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ({"value_template": "{{ value_json.val }}"},), + ), + '{"val":"open"}', + STATE_UNLOCKED, + ), ], ) async def test_controlling_state_via_topic_and_json_message( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - payload, - lock_state, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + payload: str, + lock_state: str, ) -> None: """Test the controlling state via topic and JSON message.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - lock.DOMAIN: { - "name": "test", - "state_topic": "state-topic", - "command_topic": "command-topic", - "payload_lock": "LOCK", - "payload_unlock": "UNLOCK", - "state_locked": "LOCKED", - "state_locking": "LOCKING", - "state_unlocked": "UNLOCKED", - "state_unlocking": "UNLOCKING", - "value_template": "{{ value_json.val }}", - } - } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -217,43 +205,54 @@ async def test_controlling_state_via_topic_and_json_message( @pytest.mark.parametrize( - ("payload", "lock_state"), + ("hass_config", "payload", "lock_state"), [ - ('{"val":"closed"}', STATE_LOCKED), - ('{"val":"closing"}', STATE_LOCKING), - ('{"val":"open"}', STATE_UNLOCKED), - ('{"val":"opening"}', STATE_UNLOCKING), + ( + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ({"value_template": "{{ value_json.val }}"},), + ), + '{"val":"closed"}', + STATE_LOCKED, + ), + ( + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ({"value_template": "{{ value_json.val }}"},), + ), + '{"val":"closing"}', + STATE_LOCKING, + ), + ( + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ({"value_template": "{{ value_json.val }}"},), + ), + '{"val":"open"}', + STATE_UNLOCKED, + ), + ( + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ({"value_template": "{{ value_json.val }}"},), + ), + '{"val":"opening"}', + STATE_UNLOCKING, + ), ], ) async def test_controlling_non_default_state_via_topic_and_json_message( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - payload, - lock_state, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + payload: str, + lock_state: str, ) -> None: """Test the controlling state via topic and JSON message.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - lock.DOMAIN: { - "name": "test", - "state_topic": "state-topic", - "command_topic": "command-topic", - "payload_lock": "LOCK", - "payload_unlock": "UNLOCK", - "state_locked": "closed", - "state_locking": "closing", - "state_unlocked": "open", - "state_unlocking": "opening", - "value_template": "{{ value_json.val }}", - } - } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -264,13 +263,9 @@ async def test_controlling_non_default_state_via_topic_and_json_message( assert state.state is lock_state -async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test optimistic mode without state topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { lock.DOMAIN: { @@ -282,10 +277,14 @@ async def test_sending_mqtt_commands_and_optimistic( "state_unlocked": "UNLOCKED", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_and_optimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test optimistic mode without state topic.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -312,13 +311,9 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_sending_mqtt_commands_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test sending commands with template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { lock.DOMAIN: { @@ -333,10 +328,14 @@ async def test_sending_mqtt_commands_with_template( "state_unlocked": "UNLOCKED", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_with_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test sending commands with template.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -373,30 +372,30 @@ async def test_sending_mqtt_commands_with_template( assert state.attributes.get(ATTR_ASSUMED_STATE) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + lock.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_lock": "LOCK", + "payload_unlock": "UNLOCK", + "state_locked": "LOCKED", + "state_unlocked": "UNLOCKED", + "optimistic": True, + } + } + } + ], +) async def test_sending_mqtt_commands_and_explicit_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test optimistic mode without state topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - lock.DOMAIN: { - "name": "test", - "state_topic": "state-topic", - "command_topic": "command-topic", - "payload_lock": "LOCK", - "payload_unlock": "UNLOCK", - "state_locked": "LOCKED", - "state_unlocked": "UNLOCKED", - "optimistic": True, - } - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -423,29 +422,29 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( assert state.attributes.get(ATTR_ASSUMED_STATE) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + lock.DOMAIN: { + "name": "test", + "command_topic": "command-topic", + "payload_lock": "LOCK", + "payload_unlock": "UNLOCK", + "payload_open": "OPEN", + "state_locked": "LOCKED", + "state_unlocked": "UNLOCKED", + } + } + } + ], +) async def test_sending_mqtt_commands_support_open_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test open function of the lock without state topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - lock.DOMAIN: { - "name": "test", - "command_topic": "command-topic", - "payload_lock": "LOCK", - "payload_unlock": "UNLOCK", - "payload_open": "OPEN", - "state_locked": "LOCKED", - "state_unlocked": "UNLOCKED", - } - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -483,13 +482,9 @@ async def test_sending_mqtt_commands_support_open_and_optimistic( assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_sending_mqtt_commands_support_open_and_explicit_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test open function of the lock without state topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { lock.DOMAIN: { @@ -504,10 +499,14 @@ async def test_sending_mqtt_commands_support_open_and_explicit_optimistic( "optimistic": True, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_support_open_and_explicit_optimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test open function of the lock without state topic.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -545,13 +544,9 @@ async def test_sending_mqtt_commands_support_open_and_explicit_optimistic( assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_sending_mqtt_commands_pessimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test function of the lock with state topics.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { lock.DOMAIN: { @@ -568,10 +563,14 @@ async def test_sending_mqtt_commands_pessimistic( "state_jammed": "JAMMED", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_pessimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test function of the lock with state topics.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index c9bcd07f26..18d59f9867 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -2,28 +2,19 @@ from unittest.mock import patch +import pytest + from homeassistant.components import mqtt, sensor from homeassistant.const import EVENT_STATE_CHANGED, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.setup import async_setup_component from tests.common import async_fire_mqtt_message from tests.typing import MqttMockHAClientGenerator -@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SENSOR]) -async def test_availability_with_shared_state_topic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, -) -> None: - """Test the state is not changed twice. - - When an entity with a shared state_topic and availability_topic becomes available - The state should only change once. - """ - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -36,10 +27,20 @@ async def test_availability_with_shared_state_topic( "availability_template": "{{ value != '0' }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SENSOR]) +async def test_availability_with_shared_state_topic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test the state is not changed twice. + + When an entity with a shared state_topic and availability_topic becomes available + The state should only change once. + """ + await mqtt_mock_entry_no_yaml_config() events = [] diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index eb2a640228..bd28d75ac9 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -30,7 +30,6 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, State -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -76,31 +75,30 @@ def number_platform_only(): yield -async def test_run_number_setup( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that it fetches the given payload.""" - topic = "test/number" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { number.DOMAIN: { - "state_topic": topic, - "command_topic": topic, + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", "name": "Test Number", "device_class": "temperature", - "unit_of_measurement": UnitOfTemperature.FAHRENHEIT, + "unit_of_measurement": UnitOfTemperature.FAHRENHEIT.value, "payload_reset": "reset!", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_run_number_setup( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that it fetches the given payload.""" + await mqtt_mock_entry_no_yaml_config() - async_fire_mqtt_message(hass, topic, "10") + async_fire_mqtt_message(hass, "test/state_number", "10") await hass.async_block_till_done() @@ -109,7 +107,7 @@ async def test_run_number_setup( assert state.attributes.get(ATTR_DEVICE_CLASS) == NumberDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "°C" - async_fire_mqtt_message(hass, topic, "20.5") + async_fire_mqtt_message(hass, "test/state_number", "20.5") await hass.async_block_till_done() @@ -118,7 +116,7 @@ async def test_run_number_setup( assert state.attributes.get(ATTR_DEVICE_CLASS) == NumberDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "°C" - async_fire_mqtt_message(hass, topic, "reset!") + async_fire_mqtt_message(hass, "test/state_number", "reset!") await hass.async_block_till_done() @@ -128,27 +126,27 @@ async def test_run_number_setup( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "°C" -async def test_value_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that it fetches the given payload with a template.""" - topic = "test/number" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { number.DOMAIN: { - "state_topic": topic, - "command_topic": topic, + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", "name": "Test Number", "value_template": "{{ value_json.val }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_value_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that it fetches the given payload with a template.""" + topic = "test/state_number" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, topic, '{"val":10}') @@ -172,11 +170,25 @@ async def test_value_template( assert state.state == "unknown" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + number.DOMAIN: { + "command_topic": "test/number", + "device_class": "temperature", + "unit_of_measurement": UnitOfTemperature.FAHRENHEIT.value, + "name": "Test Number", + } + } + } + ], +) async def test_restore_native_value( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that the stored native_value is restored.""" - topic = "test/number" RESTORE_DATA = { "native_max_value": None, # Ignored by MQTT number @@ -189,30 +201,28 @@ async def test_restore_native_value( mock_restore_cache_with_extra_data( hass, ((State("number.test_number", "abc"), RESTORE_DATA),) ) - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - number.DOMAIN: { - "command_topic": topic, - "device_class": "temperature", - "unit_of_measurement": UnitOfTemperature.FAHRENHEIT, - "name": "Test Number", - } - } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("number.test_number") assert state.state == "37.8" assert state.attributes.get(ATTR_ASSUMED_STATE) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + number.DOMAIN: { + "command_topic": "test/number", + "name": "Test Number", + } + } + } + ], +) async def test_run_number_service_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that set_value service works in optimistic mode.""" topic = "test/number" @@ -228,20 +238,8 @@ async def test_run_number_service_optimistic( mock_restore_cache_with_extra_data( hass, ((State("number.test_number", "abc"), RESTORE_DATA),) ) - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - number.DOMAIN: { - "command_topic": topic, - "name": "Test Number", - } - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("number.test_number") assert state.state == "3" @@ -287,8 +285,22 @@ async def test_run_number_service_optimistic( assert state.state == "42.1" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + number.DOMAIN: { + "command_topic": "test/number", + "name": "Test Number", + "command_template": '{"number": {{ value }} }', + } + } + } + ], +) async def test_run_number_service_optimistic_with_command_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that set_value service works in optimistic mode and with a command_template.""" topic = "test/number" @@ -304,21 +316,7 @@ async def test_run_number_service_optimistic_with_command_template( mock_restore_cache_with_extra_data( hass, ((State("number.test_number", "abc"), RESTORE_DATA),) ) - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - number.DOMAIN: { - "command_topic": topic, - "name": "Test Number", - "command_template": '{"number": {{ value }} }', - } - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("number.test_number") assert state.state == "3" @@ -366,28 +364,28 @@ async def test_run_number_service_optimistic_with_command_template( assert state.state == "42.1" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + number.DOMAIN: { + "command_topic": "test/number/set", + "state_topic": "test/number", + "name": "Test Number", + } + } + } + ], +) async def test_run_number_service( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that set_value service works in non optimistic mode.""" cmd_topic = "test/number/set" state_topic = "test/number" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - number.DOMAIN: { - "command_topic": cmd_topic, - "state_topic": state_topic, - "name": "Test Number", - } - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, state_topic, "32") state = hass.states.get("number.test_number") @@ -404,29 +402,29 @@ async def test_run_number_service( assert state.state == "32" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + number.DOMAIN: { + "command_topic": "test/number/set", + "state_topic": "test/number", + "name": "Test Number", + "command_template": '{"number": {{ value }} }', + } + } + } + ], +) async def test_run_number_service_with_command_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that set_value service works in non optimistic mode and with a command_template.""" cmd_topic = "test/number/set" state_topic = "test/number" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - number.DOMAIN: { - "command_topic": cmd_topic, - "state_topic": state_topic, - "name": "Test Number", - "command_template": '{"number": {{ value }} }', - } - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, state_topic, "32") state = hass.states.get("number.test_number") @@ -732,29 +730,28 @@ async def test_entity_debug_info_message( ) -async def test_min_max_step_attributes( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test min/max/step attributes.""" - topic = "test/number" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { number.DOMAIN: { - "state_topic": topic, - "command_topic": topic, + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", "name": "Test Number", "min": 5, "max": 110, "step": 20, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_min_max_step_attributes( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test min/max/step attributes.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("number.test_number") assert state.attributes.get(ATTR_MIN) == 5 @@ -762,129 +759,180 @@ async def test_min_max_step_attributes( assert state.attributes.get(ATTR_STEP) == 20 -async def test_invalid_min_max_attributes( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test invalid min/max attributes.""" - topic = "test/number" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { number.DOMAIN: { - "state_topic": topic, - "command_topic": topic, + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", "name": "Test Number", "min": 35, "max": 10, } } - }, - ) - + } + ], +) +async def test_invalid_min_max_attributes( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test invalid min/max attributes.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert f"'{CONF_MAX}' must be > '{CONF_MIN}'" in caplog.text -async def test_default_mode( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test default mode.""" - topic = "test/number" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { number.DOMAIN: { - "state_topic": topic, - "command_topic": topic, + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", "name": "Test Number", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_default_mode( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test default mode.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("number.test_number") assert state.attributes.get(ATTR_MODE) == "auto" -@pytest.mark.parametrize("mode", ("auto", "box", "slider")) +@pytest.mark.parametrize( + ("hass_config", "mode"), + [ + ( + { + mqtt.DOMAIN: { + number.DOMAIN: { + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", + "name": "Test Number", + "mode": "auto", + } + } + }, + "auto", + ), + ( + { + mqtt.DOMAIN: { + number.DOMAIN: { + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", + "name": "Test Number", + "mode": "box", + } + } + }, + "box", + ), + ( + { + mqtt.DOMAIN: { + number.DOMAIN: { + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", + "name": "Test Number", + "mode": "slider", + } + } + }, + "slider", + ), + ], +) async def test_mode( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, mode, ) -> None: """Test mode.""" - topic = "test/number" - await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - number.DOMAIN: { - "state_topic": topic, - "command_topic": topic, - "name": "Test Number", - "mode": mode, - } - } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("number.test_number") assert state.attributes.get(ATTR_MODE) == mode -@pytest.mark.parametrize(("mode", "valid"), [("bleh", False), ("auto", True)]) -async def test_invalid_mode(hass: HomeAssistant, mode, valid) -> None: - """Test invalid mode.""" - topic = "test/number" - assert ( - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + ("hass_config", "valid"), + [ + ( { mqtt.DOMAIN: { number.DOMAIN: { - "state_topic": topic, - "command_topic": topic, + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", "name": "Test Number", - "mode": mode, + "mode": "bleh", } } }, - ) - is valid - ) - - -async def test_mqtt_payload_not_a_number_warning( + False, + ), + ( + { + mqtt.DOMAIN: { + number.DOMAIN: { + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", + "name": "Test Number", + "mode": "auto", + } + } + }, + True, + ), + ], +) +async def test_invalid_mode( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + valid: bool, ) -> None: - """Test warning for MQTT payload which is not a number.""" - topic = "test/number" - assert await async_setup_component( - hass, - mqtt.DOMAIN, + """Test invalid mode.""" + if valid: + await mqtt_mock_entry_no_yaml_config() + return + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() + + +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { number.DOMAIN: { - "state_topic": topic, - "command_topic": topic, + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", "name": "Test Number", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_mqtt_payload_not_a_number_warning( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test warning for MQTT payload which is not a number.""" + topic = "test/state_number" + + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, topic, "not_a_number") @@ -893,30 +941,32 @@ async def test_mqtt_payload_not_a_number_warning( assert "Payload 'not_a_number' is not a Number" in caplog.text -async def test_mqtt_payload_out_of_range_error( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, -) -> None: - """Test error when MQTT payload is out of min/max range.""" - topic = "test/number" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { number.DOMAIN: { - "state_topic": topic, - "command_topic": topic, + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", "name": "Test Number", "min": 5, "max": 110, } } - }, - ) + } + ], +) +async def test_mqtt_payload_out_of_range_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test error when MQTT payload is out of min/max range.""" + topic = "test/state_number" + await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, topic, "115.5") diff --git a/tests/components/mqtt/test_scene.py b/tests/components/mqtt/test_scene.py index 3da5fd4f36..56350c90c0 100644 --- a/tests/components/mqtt/test_scene.py +++ b/tests/components/mqtt/test_scene.py @@ -7,7 +7,6 @@ import pytest from homeassistant.components import mqtt, scene from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant, State -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -44,16 +43,9 @@ def scene_platform_only(): yield -async def test_sending_mqtt_commands( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the sending MQTT commands.""" - fake_state = State("scene.test", STATE_UNKNOWN) - mock_restore_cache(hass, (fake_state,)) - - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { scene.DOMAIN: { @@ -62,10 +54,17 @@ async def test_sending_mqtt_commands( "payload_on": "beer on", }, } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending MQTT commands.""" + fake_state = State("scene.test", STATE_UNKNOWN) + mock_restore_cache(hass, (fake_state,)) + + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("scene.test") assert state.state == STATE_UNKNOWN From f2b4c95a04e38f53febf4793790b2e8d348f8bf5 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 24 Mar 2023 08:42:00 +0100 Subject: [PATCH 1221/2810] Prepare MQTT platform tests part7 (#90130) * Tests select * Tests sensor * Deduplicate test code --- tests/components/mqtt/test_select.py | 279 +++++------ tests/components/mqtt/test_sensor.py | 677 +++++++++++++++------------ 2 files changed, 521 insertions(+), 435 deletions(-) diff --git a/tests/components/mqtt/test_select.py b/tests/components/mqtt/test_select.py index 3a639ecf08..bbda9c88de 100644 --- a/tests/components/mqtt/test_select.py +++ b/tests/components/mqtt/test_select.py @@ -1,4 +1,5 @@ """The tests for mqtt select component.""" +from collections.abc import Generator import copy import json from typing import Any @@ -21,7 +22,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, State -from homeassistant.setup import async_setup_component +from homeassistant.helpers.typing import ConfigType from .test_common import ( help_test_availability_when_connection_lost, @@ -74,27 +75,35 @@ def select_platform_only(): yield -async def test_run_select_setup( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that it fetches the given payload.""" - topic = "test/select" - await async_setup_component( - hass, - mqtt.DOMAIN, +def _test_run_select_setup_params( + topic: str, +) -> Generator[tuple[ConfigType, str], None]: + yield ( { mqtt.DOMAIN: { select.DOMAIN: { "state_topic": topic, - "command_topic": topic, + "command_topic": "test/select_cmd", "name": "Test Select", "options": ["milk", "beer"], } } }, + topic, ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + + +@pytest.mark.parametrize( + ("hass_config", "topic"), + _test_run_select_setup_params("test/select_stat"), +) +async def test_run_select_setup( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, +) -> None: + """Test that it fetches the given payload.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, topic, "milk") @@ -111,44 +120,43 @@ async def test_run_select_setup( assert state.state == "beer" -async def test_value_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that it fetches the given payload with a template.""" - topic = "test/select" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { select.DOMAIN: { - "state_topic": topic, - "command_topic": topic, + "state_topic": "test/select_stat", + "command_topic": "test/select_cmd", "name": "Test Select", "options": ["milk", "beer"], "value_template": "{{ value_json.val }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_value_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that it fetches the given payload with a template.""" + await mqtt_mock_entry_no_yaml_config() - async_fire_mqtt_message(hass, topic, '{"val":"milk"}') + async_fire_mqtt_message(hass, "test/select_stat", '{"val":"milk"}') await hass.async_block_till_done() state = hass.states.get("select.test_select") assert state.state == "milk" - async_fire_mqtt_message(hass, topic, '{"val":"beer"}') + async_fire_mqtt_message(hass, "test/select_stat", '{"val":"beer"}') await hass.async_block_till_done() state = hass.states.get("select.test_select") assert state.state == "beer" - async_fire_mqtt_message(hass, topic, '{"val": null}') + async_fire_mqtt_message(hass, "test/select_stat", '{"val": null}') await hass.async_block_till_done() @@ -156,30 +164,28 @@ async def test_value_template( assert state.state == STATE_UNKNOWN -async def test_run_select_service_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that set_value service works in optimistic mode.""" - topic = "test/select" - - fake_state = State("select.test_select", "milk") - mock_restore_cache(hass, (fake_state,)) - - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { select.DOMAIN: { - "command_topic": topic, + "command_topic": "test/select_cmd", "name": "Test Select", "options": ["milk", "beer"], } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_run_select_service_optimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that set_value service works in optimistic mode.""" + fake_state = State("select.test_select", "milk") + mock_restore_cache(hass, (fake_state,)) + + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("select.test_select") assert state.state == "milk" @@ -192,37 +198,35 @@ async def test_run_select_service_optimistic( blocking=True, ) - mqtt_mock.async_publish.assert_called_once_with(topic, "beer", 0, False) + mqtt_mock.async_publish.assert_called_once_with("test/select_cmd", "beer", 0, False) mqtt_mock.async_publish.reset_mock() state = hass.states.get("select.test_select") assert state.state == "beer" -async def test_run_select_service_optimistic_with_command_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that set_value service works in optimistic mode and with a command_template.""" - topic = "test/select" - - fake_state = State("select.test_select", "milk") - mock_restore_cache(hass, (fake_state,)) - - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { select.DOMAIN: { - "command_topic": topic, + "command_topic": "test/select_cmd", "name": "Test Select", "options": ["milk", "beer"], "command_template": '{"option": "{{ value }}"}', } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_run_select_service_optimistic_with_command_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that set_value service works in optimistic mode and with a command_template.""" + fake_state = State("select.test_select", "milk") + mock_restore_cache(hass, (fake_state,)) + + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("select.test_select") assert state.state == "milk" @@ -236,36 +240,36 @@ async def test_run_select_service_optimistic_with_command_template( ) mqtt_mock.async_publish.assert_called_once_with( - topic, '{"option": "beer"}', 0, False + "test/select_cmd", '{"option": "beer"}', 0, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("select.test_select") assert state.state == "beer" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + select.DOMAIN: { + "command_topic": "test/select/set", + "state_topic": "test/select", + "name": "Test Select", + "options": ["milk", "beer"], + } + } + } + ], +) async def test_run_select_service( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that set_value service works in non optimistic mode.""" cmd_topic = "test/select/set" state_topic = "test/select" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - select.DOMAIN: { - "command_topic": cmd_topic, - "state_topic": state_topic, - "name": "Test Select", - "options": ["milk", "beer"], - } - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, state_topic, "beer") state = hass.states.get("select.test_select") @@ -282,30 +286,30 @@ async def test_run_select_service( assert state.state == "beer" -async def test_run_select_service_with_command_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that set_value service works in non optimistic mode and with a command_template.""" - cmd_topic = "test/select/set" - state_topic = "test/select" - - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { select.DOMAIN: { - "command_topic": cmd_topic, - "state_topic": state_topic, + "command_topic": "test/select/set", + "state_topic": "test/select", "name": "Test Select", "options": ["milk", "beer"], "command_template": '{"option": "{{ value }}"}', } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_run_select_service_with_command_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that set_value service works in non optimistic mode and with a command_template.""" + cmd_topic = "test/select/set" + state_topic = "test/select" + + mqtt_mock = await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, state_topic, "beer") state = hass.states.get("select.test_select") @@ -609,60 +613,65 @@ async def test_entity_debug_info_message( ) -@pytest.mark.parametrize("options", [["milk", "beer"], ["milk"], []]) +def _test_options_attributes_options_config( + request: tuple[list[str]], +) -> Generator[tuple[ConfigType, list[str]], None]: + for option in request: + yield ( + { + mqtt.DOMAIN: { + select.DOMAIN: { + "command_topic": "test/select/set", + "state_topic": "test/select", + "name": "Test select", + "options": option, + } + } + }, + option, + ) + + +@pytest.mark.parametrize( + ("hass_config", "options"), + _test_options_attributes_options_config((["milk", "beer"], ["milk"], [])), +) async def test_options_attributes( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - options, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + options: list[str], ) -> None: """Test options attribute.""" - topic = "test/select" - await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - select.DOMAIN: { - "state_topic": topic, - "command_topic": topic, - "name": "Test select", - "options": options, - } - } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("select.test_select") assert state.attributes.get(ATTR_OPTIONS) == options -async def test_mqtt_payload_not_an_option_warning( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, -) -> None: - """Test warning for MQTT payload which is not a valid option.""" - topic = "test/select" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { select.DOMAIN: { - "state_topic": topic, - "command_topic": topic, + "state_topic": "test/select_stat", + "command_topic": "test/select_cmd", "name": "Test Select", "options": ["milk", "beer"], } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_mqtt_payload_not_an_option_warning( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test warning for MQTT payload which is not a valid option.""" + await mqtt_mock_entry_no_yaml_config() - async_fire_mqtt_message(hass, topic, "öl") + async_fire_mqtt_message(hass, "test/select_stat", "öl") await hass.async_block_till_done() diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 6889069c8c..01c897a9d8 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -18,12 +18,13 @@ from homeassistant.const import ( Platform, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, State, callback +from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers import device_registry as dr -from homeassistant.setup import async_setup_component +from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -82,13 +83,9 @@ def sensor_platform_only(): yield -async def test_setting_sensor_value_via_mqtt_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the setting of the value via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -98,10 +95,14 @@ async def test_setting_sensor_value_via_mqtt_message( "suggested_display_precision": 1, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_value_via_mqtt_message( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the setting of the value via MQTT.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "test-topic", "100.22") state = hass.states.get("sensor.test") @@ -113,64 +114,118 @@ async def test_setting_sensor_value_via_mqtt_message( @pytest.mark.parametrize( - ("device_class", "native_value", "state_value", "log"), + ("hass_config", "device_class", "native_value", "state_value", "log"), [ - (sensor.SensorDeviceClass.DATE, "2021-11-18", "2021-11-18", False), - (sensor.SensorDeviceClass.DATE, "invalid", STATE_UNKNOWN, True), ( + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ({"device_class": sensor.SensorDeviceClass.DATE},), + ), + sensor.SensorDeviceClass.DATE, + "2021-11-18", + "2021-11-18", + False, + ), + ( + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ({"device_class": sensor.SensorDeviceClass.DATE},), + ), + sensor.SensorDeviceClass.DATE, + "invalid", + STATE_UNKNOWN, + True, + ), + ( + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ({"device_class": sensor.SensorDeviceClass.TIMESTAMP},), + ), sensor.SensorDeviceClass.TIMESTAMP, "2021-11-18T20:25:00+00:00", "2021-11-18T20:25:00+00:00", False, ), ( + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ({"device_class": sensor.SensorDeviceClass.TIMESTAMP},), + ), sensor.SensorDeviceClass.TIMESTAMP, "2021-11-18 20:25:00+00:00", "2021-11-18T20:25:00+00:00", False, ), ( + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ({"device_class": sensor.SensorDeviceClass.TIMESTAMP},), + ), sensor.SensorDeviceClass.TIMESTAMP, "2021-11-18 20:25:00+01:00", "2021-11-18T19:25:00+00:00", False, ), ( + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ({"device_class": sensor.SensorDeviceClass.TIMESTAMP},), + ), sensor.SensorDeviceClass.TIMESTAMP, "2021-13-18T35:25:00+00:00", STATE_UNKNOWN, True, ), - (sensor.SensorDeviceClass.TIMESTAMP, "invalid", STATE_UNKNOWN, True), - (sensor.SensorDeviceClass.ENUM, "some_value", "some_value", False), - (None, "some_value", "some_value", False), + ( + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ({"device_class": sensor.SensorDeviceClass.TIMESTAMP},), + ), + sensor.SensorDeviceClass.TIMESTAMP, + "invalid", + STATE_UNKNOWN, + True, + ), + ( + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ({"device_class": sensor.SensorDeviceClass.ENUM},), + ), + sensor.SensorDeviceClass.ENUM, + "some_value", + "some_value", + False, + ), + ( + help_custom_config( + sensor.DOMAIN, DEFAULT_CONFIG, ({"device_class": None},) + ), + None, + "some_value", + "some_value", + False, + ), ], ) async def test_setting_sensor_native_value_handling_via_mqtt_message( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - device_class, - native_value, - state_value, - log, + device_class: sensor.SensorDeviceClass | None, + native_value: str, + state_value: str, + log: bool, ) -> None: """Test the setting of the value via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - sensor.DOMAIN: { - "name": "test", - "state_topic": "test-topic", - "device_class": device_class, - } - } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "test-topic", native_value) state = hass.states.get("sensor.test") @@ -180,14 +235,9 @@ async def test_setting_sensor_native_value_handling_via_mqtt_message( assert log == ("Invalid state message" in caplog.text) -async def test_setting_numeric_sensor_native_value_handling_via_mqtt_message( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, -) -> None: - """Test the setting of a numeric sensor value via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -198,10 +248,16 @@ async def test_setting_numeric_sensor_native_value_handling_via_mqtt_message( "unit_of_measurement": "W", } } - }, - ) + } + ], +) +async def test_setting_numeric_sensor_native_value_handling_via_mqtt_message( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test the setting of a numeric sensor value via MQTT.""" await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() # float value async_fire_mqtt_message(hass, "test-topic", '{ "power": 45.3, "current": 5.24 }') @@ -235,15 +291,9 @@ async def test_setting_numeric_sensor_native_value_handling_via_mqtt_message( assert state.state == "21" -async def test_setting_sensor_value_expires_availability_topic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the expiration of the value.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -254,10 +304,14 @@ async def test_setting_sensor_value_expires_availability_topic( "availability_topic": "availability-topic", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_value_expires_availability_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the expiration of the value.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("sensor.test") assert state.state == STATE_UNAVAILABLE @@ -268,18 +322,12 @@ async def test_setting_sensor_value_expires_availability_topic( state = hass.states.get("sensor.test") assert state.state == STATE_UNAVAILABLE - await expires_helper(hass, caplog) + await expires_helper(hass) -async def test_setting_sensor_value_expires( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the expiration of the value.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -290,19 +338,23 @@ async def test_setting_sensor_value_expires( "force_update": True, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_value_expires( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the expiration of the value.""" + await mqtt_mock_entry_no_yaml_config() # State should be unavailable since expire_after is defined and > 0 state = hass.states.get("sensor.test") assert state.state == STATE_UNAVAILABLE - await expires_helper(hass, caplog) + await expires_helper(hass) -async def expires_helper(hass: HomeAssistant, caplog) -> None: +async def expires_helper(hass: HomeAssistant) -> None: """Run the basic expiry code.""" realnow = dt_util.utcnow() now = datetime(realnow.year + 1, 1, 1, 1, tzinfo=dt_util.UTC) @@ -353,13 +405,9 @@ async def expires_helper(hass: HomeAssistant, caplog) -> None: assert state.state == STATE_UNAVAILABLE -async def test_setting_sensor_value_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the setting of the value via MQTT with JSON payload.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -368,10 +416,14 @@ async def test_setting_sensor_value_via_mqtt_json_message( "value_template": "{{ value_json.val }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_value_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the setting of the value via MQTT with JSON payload.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "test-topic", '{ "val": "100" }') state = hass.states.get("sensor.test") @@ -385,13 +437,9 @@ async def test_setting_sensor_value_via_mqtt_json_message( assert state.state == "" -async def test_setting_sensor_value_via_mqtt_json_message_and_default_current_state( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the setting of the value via MQTT with fall back to current state.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -400,10 +448,14 @@ async def test_setting_sensor_value_via_mqtt_json_message_and_default_current_st "value_template": "{{ value_json.val | is_defined }}-{{ value_json.par }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_value_via_mqtt_json_message_and_default_current_state( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the setting of the value via MQTT with fall back to current state.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message( hass, "test-topic", '{ "val": "valcontent", "par": "parcontent" }' @@ -418,15 +470,9 @@ async def test_setting_sensor_value_via_mqtt_json_message_and_default_current_st assert state.state == "valcontent-parcontent" -async def test_setting_sensor_last_reset_via_mqtt_message( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the setting of the last_reset property via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -437,10 +483,16 @@ async def test_setting_sensor_last_reset_via_mqtt_message( "last_reset_topic": "last-reset-topic", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_last_reset_via_mqtt_message( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the setting of the last_reset property via MQTT.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "last-reset-topic", "2020-01-02 08:11:00") state = hass.states.get("sensor.test") @@ -452,17 +504,9 @@ async def test_setting_sensor_last_reset_via_mqtt_message( ) -@pytest.mark.parametrize("datestring", ["2020-21-02 08:11:00", "Hello there!"]) -async def test_setting_sensor_bad_last_reset_via_mqtt_message( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - datestring, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, -) -> None: - """Test the setting of the last_reset property via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -473,10 +517,18 @@ async def test_setting_sensor_bad_last_reset_via_mqtt_message( "last_reset_topic": "last-reset-topic", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +@pytest.mark.parametrize("datestring", ["2020-21-02 08:11:00", "Hello there!"]) +async def test_setting_sensor_bad_last_reset_via_mqtt_message( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + datestring, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test the setting of the last_reset property via MQTT.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "last-reset-topic", datestring) state = hass.states.get("sensor.test") @@ -484,13 +536,9 @@ async def test_setting_sensor_bad_last_reset_via_mqtt_message( assert "Invalid last_reset message" in caplog.text -async def test_setting_sensor_empty_last_reset_via_mqtt_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the setting of the last_reset property via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -501,23 +549,23 @@ async def test_setting_sensor_empty_last_reset_via_mqtt_message( "last_reset_topic": "last-reset-topic", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_empty_last_reset_via_mqtt_message( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the setting of the last_reset property via MQTT.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "last-reset-topic", "") state = hass.states.get("sensor.test") assert state.attributes.get("last_reset") is None -async def test_setting_sensor_last_reset_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the setting of the value via MQTT with JSON payload.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -529,10 +577,14 @@ async def test_setting_sensor_last_reset_via_mqtt_json_message( "last_reset_value_template": "{{ value_json.last_reset }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_last_reset_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the setting of the value via MQTT with JSON payload.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message( hass, "last-reset-topic", '{ "last_reset": "2020-01-02 08:11:00" }' @@ -541,35 +593,44 @@ async def test_setting_sensor_last_reset_via_mqtt_json_message( assert state.attributes.get("last_reset") == "2020-01-02T08:11:00" -@pytest.mark.parametrize("extra", [{}, {"last_reset_topic": "test-topic"}]) -async def test_setting_sensor_last_reset_via_mqtt_json_message_2( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - extra, -) -> None: - """Test the setting of the value via MQTT with JSON payload.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { - **{ - "name": "test", - "state_class": "total", - "state_topic": "test-topic", - "unit_of_measurement": "kWh", - "value_template": "{{ value_json.value | float / 60000 }}", - "last_reset_value_template": "{{ utcnow().fromtimestamp(value_json.time / 1000, tz=utcnow().tzinfo) }}", - }, - **extra, - } + "name": "test", + "state_class": "total", + "state_topic": "test-topic", + "unit_of_measurement": "kWh", + "value_template": "{{ value_json.value | float / 60000 }}", + "last_reset_value_template": "{{ utcnow().fromtimestamp(value_json.time / 1000, tz=utcnow().tzinfo) }}", + }, } }, - ) + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_class": "total", + "state_topic": "test-topic", + "unit_of_measurement": "kWh", + "value_template": "{{ value_json.value | float / 60000 }}", + "last_reset_value_template": "{{ utcnow().fromtimestamp(value_json.time / 1000, tz=utcnow().tzinfo) }}", + "last_reset_topic": "test-topic", + }, + } + }, + ], +) +async def test_setting_sensor_last_reset_via_mqtt_json_message_2( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the setting of the value via MQTT with JSON payload.""" await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message( hass, @@ -586,13 +647,9 @@ async def test_setting_sensor_last_reset_via_mqtt_json_message_2( ) -async def test_force_update_disabled( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test force update option.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -601,15 +658,19 @@ async def test_force_update_disabled( "unit_of_measurement": "fav unit", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_force_update_disabled( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test force update option.""" + await mqtt_mock_entry_no_yaml_config() - events = [] + events: list[Event] = [] @callback - def test_callback(event) -> None: + def test_callback(event: Event) -> None: events.append(event) hass.bus.async_listen(EVENT_STATE_CHANGED, test_callback) @@ -623,13 +684,9 @@ async def test_force_update_disabled( assert len(events) == 1 -async def test_force_update_enabled( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test force update option.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -639,15 +696,19 @@ async def test_force_update_enabled( "force_update": True, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_force_update_enabled( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test force update option.""" + await mqtt_mock_entry_no_yaml_config() - events = [] + events: list[Event] = [] @callback - def test_callback(event) -> None: + def test_callback(event: Event) -> None: events.append(event) hass.bus.async_listen(EVENT_STATE_CHANGED, test_callback) @@ -747,13 +808,9 @@ async def test_discovery_update_availability( ) -async def test_invalid_device_class( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test device_class option with invalid value.""" - assert await async_setup_component( - hass, - sensor.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -762,22 +819,25 @@ async def test_invalid_device_class( "device_class": "foobarnotreal", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_no_yaml_config() - - state = hass.states.get("sensor.test") - assert state is None - - -async def test_valid_device_class( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + } + ], +) +async def test_invalid_device_class( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: - """Test device_class option with valid values.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, + """Test device_class option with invalid value.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() + assert ( + "Invalid config for [mqtt]: expected SensorDeviceClass or one of" in caplog.text + ) + + +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: [ @@ -794,10 +854,14 @@ async def test_valid_device_class( }, ] } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_valid_device_class( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test device_class option with valid values.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("sensor.test_1") assert state.attributes["device_class"] == "temperature" @@ -807,13 +871,9 @@ async def test_valid_device_class( assert "device_class" not in state.attributes -async def test_invalid_state_class( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test state_class option with invalid value.""" - assert await async_setup_component( - hass, - sensor.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -822,22 +882,25 @@ async def test_invalid_state_class( "state_class": "foobarnotreal", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_no_yaml_config() - - state = hass.states.get("sensor.test") - assert state is None - - -async def test_valid_state_class( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + } + ], +) +async def test_invalid_state_class( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: - """Test state_class option with valid values.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, + """Test state_class option with invalid value.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() + assert ( + "Invalid config for [mqtt]: expected SensorStateClass or one of" in caplog.text + ) + + +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: [ @@ -854,10 +917,14 @@ async def test_valid_state_class( }, ] } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_valid_state_class( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test state_class option with valid values.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("sensor.test_1") assert state.attributes["state_class"] == "measurement" @@ -1237,13 +1304,9 @@ async def test_entity_category( ) -async def test_value_template_with_entity_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the access to attributes in value_template via the entity_id.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -1258,10 +1321,14 @@ async def test_value_template_with_entity_id( {% endif %}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_value_template_with_entity_id( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the access to attributes in value_template via the entity_id.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "test-topic", "100") state = hass.states.get("sensor.test") @@ -1279,38 +1346,43 @@ async def test_reloadable( await help_test_reloadable(hass, mqtt_client_mock, domain, config) +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ( + { + "name": "test1", + "expire_after": 30, + "state_topic": "test-topic1", + "device_class": "temperature", + "unit_of_measurement": UnitOfTemperature.FAHRENHEIT.value, + }, + { + "name": "test2", + "expire_after": 5, + "state_topic": "test-topic2", + "device_class": "temperature", + "unit_of_measurement": UnitOfTemperature.CELSIUS.value, + }, + ), + ) + ], +) async def test_cleanup_triggers_and_restoring_state( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, tmp_path: Path, freezer: FrozenDateTimeFactory, + hass_config: ConfigType, ) -> None: """Test cleanup old triggers at reloading and restoring the state.""" - domain = sensor.DOMAIN - config1 = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][domain]) - config1["name"] = "test1" - config1["expire_after"] = 30 - config1["state_topic"] = "test-topic1" - config1["device_class"] = "temperature" - config1["unit_of_measurement"] = UnitOfTemperature.FAHRENHEIT.value - - config2 = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][domain]) - config2["name"] = "test2" - config2["expire_after"] = 5 - config2["state_topic"] = "test-topic2" - config2["device_class"] = "temperature" - config2["unit_of_measurement"] = UnitOfTemperature.CELSIUS.value - freezer.move_to("2022-02-02 12:01:00+01:00") - assert await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {domain: [config1, config2]}}, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "test-topic1", "100") state = hass.states.get("sensor.test1") assert state.state == "38" # 100 °F -> 38 °C @@ -1321,9 +1393,7 @@ async def test_cleanup_triggers_and_restoring_state( freezer.move_to("2022-02-02 12:01:10+01:00") - await help_test_reload_with_config( - hass, caplog, tmp_path, {mqtt.DOMAIN: {domain: [config1, config2]}} - ) + await help_test_reload_with_config(hass, caplog, tmp_path, hass_config) await hass.async_block_till_done() state = hass.states.get("sensor.test1") @@ -1341,19 +1411,30 @@ async def test_cleanup_triggers_and_restoring_state( assert state.state == "201" +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ( + { + "name": "test3", + "expire_after": 10, + "state_topic": "test-topic3", + }, + ), + ) + ], +) async def test_skip_restoring_state_with_over_due_expire_trigger( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, freezer: FrozenDateTimeFactory, ) -> None: """Test restoring a state with over due expire timer.""" freezer.move_to("2022-02-02 12:02:00+01:00") - domain = sensor.DOMAIN - config3 = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][domain]) - config3["name"] = "test3" - config3["expire_after"] = 10 - config3["state_topic"] = "test-topic3" fake_state = State( "sensor.test3", "300", @@ -1363,11 +1444,7 @@ async def test_skip_restoring_state_with_over_due_expire_trigger( fake_extra_data = MagicMock() mock_restore_cache_with_extra_data(hass, ((fake_state, fake_extra_data),)) - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {domain: config3}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("sensor.test3") assert state.state == STATE_UNAVAILABLE From 1224b1aff607957b3fd1baae2c36adae0667069c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 24 Mar 2023 08:42:23 +0100 Subject: [PATCH 1222/2810] Use helper on tests MQTT fan platform (#90196) Use helper on tests fan --- tests/components/mqtt/test_fan.py | 118 ++++++++++++++++-------------- 1 file changed, 62 insertions(+), 56 deletions(-) diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 9882b9102e..3e3f6219b0 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -34,6 +34,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -226,36 +227,37 @@ async def test_controlling_state_via_topic( @pytest.mark.parametrize( "hass_config", [ - { - mqtt.DOMAIN: { - fan.DOMAIN: [ - { - "name": "test1", + help_custom_config( + fan.DOMAIN, + { + mqtt.DOMAIN: { + fan.DOMAIN: { "command_topic": "command-topic", - "percentage_state_topic": "percentage-state-topic1", - "percentage_command_topic": "percentage-command-topic1", - "speed_range_min": 1, - "speed_range_max": 100, - }, - { - "name": "test2", - "command_topic": "command-topic", - "percentage_state_topic": "percentage-state-topic2", - "percentage_command_topic": "percentage-command-topic2", - "speed_range_min": 1, - "speed_range_max": 200, - }, - { - "name": "test3", - "command_topic": "command-topic", - "percentage_state_topic": "percentage-state-topic3", - "percentage_command_topic": "percentage-command-topic3", - "speed_range_min": 81, - "speed_range_max": 1023, - }, - ] - } - } + "percentage_command_topic": "percentage-command-topic", + } + } + }, + ( + { + "name": "test1", + "percentage_state_topic": "percentage-state-topic1", + "speed_range_min": 1, + "speed_range_max": 100, + }, + { + "name": "test2", + "percentage_state_topic": "percentage-state-topic2", + "speed_range_min": 1, + "speed_range_max": 200, + }, + { + "name": "test3", + "percentage_state_topic": "percentage-state-topic3", + "speed_range_min": 81, + "speed_range_max": 1023, + }, + ), + ), ], ) async def test_controlling_state_via_topic_with_different_speed_range( @@ -672,36 +674,40 @@ async def test_sending_mqtt_commands_and_optimistic( @pytest.mark.parametrize( "hass_config", [ - { - mqtt.DOMAIN: { - fan.DOMAIN: [ - { + help_custom_config( + fan.DOMAIN, + { + mqtt.DOMAIN: { + fan.DOMAIN: { "name": "test1", "command_topic": "command-topic", - "percentage_state_topic": "percentage-state-topic1", - "percentage_command_topic": "percentage-command-topic1", + "percentage_state_topic": "percentage-state-topic", "speed_range_min": 1, "speed_range_max": 3, - }, - { - "name": "test2", - "command_topic": "command-topic", - "percentage_state_topic": "percentage-state-topic2", - "percentage_command_topic": "percentage-command-topic2", - "speed_range_min": 1, - "speed_range_max": 200, - }, - { - "name": "test3", - "command_topic": "command-topic", - "percentage_state_topic": "percentage-state-topic3", - "percentage_command_topic": "percentage-command-topic3", - "speed_range_min": 81, - "speed_range_max": 1023, - }, - ] - } - } + } + } + }, + ( + { + "name": "test1", + "percentage_command_topic": "percentage-command-topic1", + "speed_range_min": 1, + "speed_range_max": 3, + }, + { + "name": "test2", + "percentage_command_topic": "percentage-command-topic2", + "speed_range_min": 1, + "speed_range_max": 200, + }, + { + "name": "test3", + "percentage_command_topic": "percentage-command-topic3", + "speed_range_min": 81, + "speed_range_max": 1023, + }, + ), + ), ], ) async def test_sending_mqtt_commands_with_alternate_speed_range( @@ -1460,7 +1466,7 @@ async def test_attributes( } }, True, - 0, + fan.FanEntityFeature(0), ), ( "test2", From 34324c98de241b0abc2bbba5badcd034917145cc Mon Sep 17 00:00:00 2001 From: Nalin Mahajan Date: Fri, 24 Mar 2023 02:44:35 -0500 Subject: [PATCH 1223/2810] Rename create_api_object to be private (#90187) --- homeassistant/components/control4/light.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/control4/light.py b/homeassistant/components/control4/light.py index 2c92010901..5748664119 100644 --- a/homeassistant/components/control4/light.py +++ b/homeassistant/components/control4/light.py @@ -175,7 +175,7 @@ class Control4Light(Control4Entity, LightEntity): self._attr_color_mode = ColorMode.ONOFF self._attr_supported_color_modes = {ColorMode.ONOFF} - def create_api_object(self): + def _create_api_object(self): """Create a pyControl4 device object. This exists so the director token used is always the latest one, without needing to re-init the entire entity. @@ -203,7 +203,7 @@ class Control4Light(Control4Entity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - c4_light = self.create_api_object() + c4_light = self._create_api_object() if self._is_dimmer: if ATTR_TRANSITION in kwargs: transition_length = kwargs[ATTR_TRANSITION] * 1000 @@ -226,7 +226,7 @@ class Control4Light(Control4Entity, LightEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - c4_light = self.create_api_object() + c4_light = self._create_api_object() if self._is_dimmer: if ATTR_TRANSITION in kwargs: transition_length = kwargs[ATTR_TRANSITION] * 1000 From 7364e6ecb3194f44482ea1b34fe14766943c1f75 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 24 Mar 2023 09:38:43 +0100 Subject: [PATCH 1224/2810] Remove incorrect parametrize decorator (#90219) --- tests/components/mqtt/test_cover.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index dd28d0919b..3fb008b218 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -2235,7 +2235,6 @@ async def test_tilt_position_altered_range( ) -@pytest.mark.parametrize("hass_config", []) async def test_find_percentage_in_range_defaults(hass: HomeAssistant) -> None: """Test find percentage in range with default range.""" mqtt_cover = MqttCover( From ee74e21541286cf1a29fc505de335b244d5924b1 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Fri, 24 Mar 2023 10:06:09 +0100 Subject: [PATCH 1225/2810] Rework UniFi wireless client "wired bug" logic (#89757) --- homeassistant/components/unifi/__init__.py | 42 +++++++++++++++---- homeassistant/components/unifi/controller.py | 34 +-------------- .../components/unifi/device_tracker.py | 2 +- homeassistant/components/unifi/sensor.py | 12 +++--- tests/components/unifi/test_controller.py | 41 +----------------- tests/components/unifi/test_init.py | 12 ++---- 6 files changed, 47 insertions(+), 96 deletions(-) diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index adaa7c977f..d6405d1171 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -1,5 +1,7 @@ """Integration to UniFi Network and its various features.""" +from aiounifi.models.client import Client + from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback @@ -91,33 +93,55 @@ def async_remove_poe_client_entities( class UnifiWirelessClients: """Class to store clients known to be wireless. - This is needed since wireless devices going offline might get marked as wired by UniFi. + This is needed since wireless devices going offline + might get marked as wired by UniFi. """ def __init__(self, hass: HomeAssistant) -> None: """Set up client storage.""" self.hass = hass - self.data: dict[str, dict[str, list[str]]] = {} + self.data: dict[str, dict[str, list[str]] | list[str]] = {} + self.wireless_clients: set[str] = set() self._store: Store = Store(hass, STORAGE_VERSION, STORAGE_KEY) async def async_load(self) -> None: """Load data from file.""" if (data := await self._store.async_load()) is not None: self.data = data + if "wireless_clients" not in data: + data["wireless_clients"] = [ + obj_id + for config_entry in data + for obj_id in data[config_entry]["wireless_devices"] + ] + self.wireless_clients.update(data["wireless_clients"]) @callback - def get_data(self, config_entry: ConfigEntry) -> set[str]: - """Get data related to a specific controller.""" - data = self.data.get(config_entry.entry_id, {"wireless_devices": []}) - return set(data["wireless_devices"]) + def is_wireless(self, client: Client) -> bool: + """Is client known to be wireless. + + Store if client is wireless and not known. + """ + if not client.is_wired and client.mac not in self.wireless_clients: + self.wireless_clients.add(client.mac) + self._store.async_delay_save(self._data_to_save, SAVE_DELAY) + + return client.mac in self.wireless_clients @callback - def update_data(self, data: set[str], config_entry: ConfigEntry) -> None: + def update_clients(self, clients: set[Client]) -> None: """Update data and schedule to save to file.""" - self.data[config_entry.entry_id] = {"wireless_devices": list(data)} + self.wireless_clients.update( + {client.mac for client in clients if not client.is_wired} + ) self._store.async_delay_save(self._data_to_save, SAVE_DELAY) @callback - def _data_to_save(self) -> dict[str, dict[str, list[str]]]: + def _data_to_save(self) -> dict[str, dict[str, list[str]] | list[str]]: """Return data of UniFi wireless clients to store in a file.""" + self.data["wireless_clients"] = list(self.wireless_clients) return self.data + + def __contains__(self, obj_id: int | str) -> bool: + """Validate membership of item ID.""" + return obj_id in self.wireless_clients diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 8a047606c6..a5f3c4d772 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -10,8 +10,6 @@ from typing import Any from aiohttp import CookieJar import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent -from aiounifi.interfaces.messages import DATA_EVENT -from aiounifi.models.event import EventKey from aiounifi.websocket import WebsocketSignal, WebsocketState import async_timeout @@ -86,8 +84,7 @@ class UniFiController: api.callback = self.async_unifi_signalling_callback self.available = True - self.progress = None - self.wireless_clients = None + self.wireless_clients = hass.data[UNIFI_WIRELESS_CLIENTS] self.site_id: str = "" self._site_name = None @@ -247,15 +244,6 @@ class UniFiController: else: LOGGER.info("Connected to UniFi Network") - elif signal == WebsocketSignal.DATA and DATA_EVENT in data: - for event in data[DATA_EVENT]: - if event.key in ( - EventKey.WIRELESS_CLIENT_CONNECTED, - EventKey.WIRELESS_GUEST_CONNECTED, - ): - self.update_wireless_clients() - break - @property def signal_reachable(self) -> str: """Integration specific event to signal a change in connection status.""" @@ -271,22 +259,6 @@ class UniFiController: """Event specific per UniFi device tracker to signal new heartbeat missed.""" return "unifi-heartbeat-missed" - def update_wireless_clients(self): - """Update set of known to be wireless clients.""" - new_wireless_clients = set() - - for client_id in self.api.clients: - if ( - client_id not in self.wireless_clients - and not self.api.clients[client_id].is_wired - ): - new_wireless_clients.add(client_id) - - if new_wireless_clients: - self.wireless_clients |= new_wireless_clients - unifi_wireless_clients = self.hass.data[UNIFI_WIRELESS_CLIENTS] - unifi_wireless_clients.update_data(self.wireless_clients, self.config_entry) - async def initialize(self): """Set up a UniFi Network instance.""" await self.api.initialize() @@ -326,9 +298,7 @@ class UniFiController: client.mac, ) - wireless_clients = self.hass.data[UNIFI_WIRELESS_CLIENTS] - self.wireless_clients = wireless_clients.get_data(self.config_entry) - self.update_wireless_clients() + self.wireless_clients.update_clients(set(self.api.clients.values())) self.config_entry.add_update_listener(self.async_config_entry_updated) diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index a5b153d7f3..f891416c63 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -105,7 +105,7 @@ def async_client_is_connected_fn(controller: UniFiController, obj_id: str) -> bo """Check if device object is disabled.""" client = controller.api.clients[obj_id] - if client.is_wired != (obj_id not in controller.wireless_clients): + if controller.wireless_clients.is_wireless(client) and client.is_wired: if not controller.option_ignore_wired_bug: return False # Wired bug in action diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 05598589fe..420fc3803c 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -45,17 +45,17 @@ from .entity import ( @callback def async_client_rx_value_fn(controller: UniFiController, client: Client) -> float: """Calculate receiving data transfer value.""" - if client.mac not in controller.wireless_clients: - return client.wired_rx_bytes_r / 1000000 - return client.rx_bytes_r / 1000000 + if controller.wireless_clients.is_wireless(client): + return client.rx_bytes_r / 1000000 + return client.wired_rx_bytes_r / 1000000 @callback def async_client_tx_value_fn(controller: UniFiController, client: Client) -> float: """Calculate transmission data transfer value.""" - if client.mac not in controller.wireless_clients: - return client.wired_tx_bytes_r / 1000000 - return client.tx_bytes_r / 1000000 + if controller.wireless_clients.is_wireless(client): + return client.tx_bytes_r / 1000000 + return client.wired_tx_bytes_r / 1000000 @callback diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py index 931c0fccdf..e3efaef915 100644 --- a/tests/components/unifi/test_controller.py +++ b/tests/components/unifi/test_controller.py @@ -6,8 +6,6 @@ from http import HTTPStatus from unittest.mock import Mock, patch import aiounifi -from aiounifi.models.event import EventKey -from aiounifi.models.message import MessageKey from aiounifi.websocket import WebsocketState import pytest @@ -182,8 +180,8 @@ async def setup_unifi_integration( config_entry.add_to_hass(hass) if known_wireless_clients: - hass.data[UNIFI_WIRELESS_CLIENTS].update_data( - known_wireless_clients, config_entry + hass.data[UNIFI_WIRELESS_CLIENTS].wireless_clients.update( + known_wireless_clients ) if aioclient_mock: @@ -383,41 +381,6 @@ async def test_connection_state_signalling( assert hass.states.get("device_tracker.client").state == "home" -async def test_wireless_client_event_calls_update_wireless_devices( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket -) -> None: - """Call update_wireless_devices method when receiving wireless client event.""" - client_1_dict = { - "essid": "ssid", - "disabled": False, - "hostname": "client_1", - "ip": "10.0.0.4", - "is_wired": False, - "last_seen": dt_util.as_timestamp(dt_util.utcnow()), - "mac": "00:00:00:00:00:01", - } - await setup_unifi_integration( - hass, - aioclient_mock, - clients_response=[client_1_dict], - known_wireless_clients=(client_1_dict["mac"],), - ) - - with patch( - "homeassistant.components.unifi.controller.UniFiController.update_wireless_clients", - return_value=None, - ) as wireless_clients_mock: - event = { - "datetime": "2020-01-20T19:37:04Z", - "user": "00:00:00:00:00:01", - "key": EventKey.WIRELESS_CLIENT_CONNECTED.value, - "msg": "User[11:22:33:44:55:66] has connected to WLAN", - "time": 1579549024893, - } - mock_unifi_websocket(message=MessageKey.EVENT, data=event) - assert wireless_clients_mock.assert_called_once - - async def test_reconnect_mechanism( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket ) -> None: diff --git a/tests/components/unifi/test_init.py b/tests/components/unifi/test_init.py index cb232445eb..cce26ac84c 100644 --- a/tests/components/unifi/test_init.py +++ b/tests/components/unifi/test_init.py @@ -89,19 +89,13 @@ async def test_wireless_clients( "is_wired": False, "mac": "00:00:00:00:00:02", } - config_entry = await setup_unifi_integration( + await setup_unifi_integration( hass, aioclient_mock, clients_response=[client_1, client_2] ) await flush_store(hass.data[unifi.UNIFI_WIRELESS_CLIENTS]._store) - for mac in [ + assert sorted(hass_storage[unifi.STORAGE_KEY]["data"]["wireless_clients"]) == [ "00:00:00:00:00:00", "00:00:00:00:00:01", "00:00:00:00:00:02", - ]: - assert ( - mac - in hass_storage[unifi.STORAGE_KEY]["data"][config_entry.entry_id][ - "wireless_devices" - ] - ) + ] From 31575799926e3f6b9421fef2466643bb07f3b005 Mon Sep 17 00:00:00 2001 From: Felix Rotthowe Date: Fri, 24 Mar 2023 12:59:59 +0100 Subject: [PATCH 1226/2810] Remove duplicate code in livisi coordinator (#90227) * Simplify coordinator * remove window sensor specific code (isOpen) * parameter order, type hinta * Update homeassistant/components/livisi/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/livisi/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/livisi/climate.py | 13 ++-- .../components/livisi/coordinator.py | 76 +++++-------------- homeassistant/components/livisi/switch.py | 4 +- 3 files changed, 28 insertions(+), 65 deletions(-) diff --git a/homeassistant/components/livisi/climate.py b/homeassistant/components/livisi/climate.py index a6680a19af..3a0a219d94 100644 --- a/homeassistant/components/livisi/climate.py +++ b/homeassistant/components/livisi/climate.py @@ -99,14 +99,15 @@ class LivisiClimate(LivisiEntity, ClimateEntity): await super().async_added_to_hass() - target_temperature = await self.coordinator.async_get_vrcc_target_temperature( - self._target_temperature_capability + target_temperature = await self.coordinator.async_get_device_state( + self._target_temperature_capability, + "setpointTemperature" if self.coordinator.is_avatar else "pointTemperature", ) - temperature = await self.coordinator.async_get_vrcc_temperature( - self._temperature_capability + temperature = await self.coordinator.async_get_device_state( + self._temperature_capability, "temperature" ) - humidity = await self.coordinator.async_get_vrcc_humidity( - self._humidity_capability + humidity = await self.coordinator.async_get_device_state( + self._humidity_capability, "humidity" ) if temperature is None: self._attr_current_temperature = None diff --git a/homeassistant/components/livisi/coordinator.py b/homeassistant/components/livisi/coordinator.py index e6c29f7151..58124dfa04 100644 --- a/homeassistant/components/livisi/coordinator.py +++ b/homeassistant/components/livisi/coordinator.py @@ -58,6 +58,10 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): except ClientConnectorError as exc: raise UpdateFailed("Failed to get LIVISI the devices") from exc + def _async_dispatcher_send(self, event: str, source: str, data: Any) -> None: + if data is not None: + async_dispatcher_send(self.hass, f"{event}_{source}", data) + async def async_setup(self) -> None: """Set up the Livisi Smart Home Controller.""" if not self.aiolivisi.livisi_connection_data: @@ -83,44 +87,14 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): """Set the discovered devices list.""" return await self.aiolivisi.async_get_devices() - async def async_get_pss_state(self, capability: str) -> bool | None: - """Set the PSS state.""" - response: dict[str, Any] | None = await self.aiolivisi.async_get_device_state( + async def async_get_device_state(self, capability: str, key: str) -> Any | None: + """Get state from livisi devices.""" + response: dict[str, Any] = await self.aiolivisi.async_get_device_state( capability[1:] ) if response is None: return None - on_state = response["onState"] - return on_state["value"] - - async def async_get_vrcc_target_temperature(self, capability: str) -> float | None: - """Get the target temperature of the climate device.""" - response: dict[str, Any] | None = await self.aiolivisi.async_get_device_state( - capability[1:] - ) - if response is None: - return None - if self.is_avatar: - return response["setpointTemperature"]["value"] - return response["pointTemperature"]["value"] - - async def async_get_vrcc_temperature(self, capability: str) -> float | None: - """Get the temperature of the climate device.""" - response: dict[str, Any] | None = await self.aiolivisi.async_get_device_state( - capability[1:] - ) - if response is None: - return None - return response["temperature"]["value"] - - async def async_get_vrcc_humidity(self, capability: str) -> int | None: - """Get the humidity of the climate device.""" - response: dict[str, Any] | None = await self.aiolivisi.async_get_device_state( - capability[1:] - ) - if response is None: - return None - return response["humidity"]["value"] + return response.get(key, {}).get("value") async def async_set_all_rooms(self) -> None: """Set the room list.""" @@ -132,34 +106,20 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): def on_data(self, event_data: LivisiEvent) -> None: """Define a handler to fire when the data is received.""" - if event_data.onState is not None: - async_dispatcher_send( - self.hass, - f"{LIVISI_STATE_CHANGE}_{event_data.source}", - event_data.onState, - ) - if event_data.vrccData is not None: - async_dispatcher_send( - self.hass, - f"{LIVISI_STATE_CHANGE}_{event_data.source}", - event_data.vrccData, - ) - if event_data.isReachable is not None: - async_dispatcher_send( - self.hass, - f"{LIVISI_REACHABILITY_CHANGE}_{event_data.source}", - event_data.isReachable, - ) + self._async_dispatcher_send( + LIVISI_STATE_CHANGE, event_data.source, event_data.onState + ) + self._async_dispatcher_send( + LIVISI_STATE_CHANGE, event_data.source, event_data.vrccData + ) + self._async_dispatcher_send( + LIVISI_REACHABILITY_CHANGE, event_data.source, event_data.isReachable + ) async def on_close(self) -> None: """Define a handler to fire when the websocket is closed.""" for device_id in self.devices: - is_reachable: bool = False - async_dispatcher_send( - self.hass, - f"{LIVISI_REACHABILITY_CHANGE}_{device_id}", - is_reachable, - ) + self._async_dispatcher_send(LIVISI_REACHABILITY_CHANGE, device_id, False) await self.websocket.connect(self.on_data, self.on_close, self.port) diff --git a/homeassistant/components/livisi/switch.py b/homeassistant/components/livisi/switch.py index 1a5789ea24..2c5a2b5137 100644 --- a/homeassistant/components/livisi/switch.py +++ b/homeassistant/components/livisi/switch.py @@ -81,7 +81,9 @@ class LivisiSwitch(LivisiEntity, SwitchEntity): """Register callbacks.""" await super().async_added_to_hass() - response = await self.coordinator.async_get_pss_state(self._capability_id) + response = await self.coordinator.async_get_device_state( + self._capability_id, "onState" + ) if response is None: self._attr_is_on = False self._attr_available = False From a66bef6fdf34536fb546754d48ff023c13ed1fd7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Mar 2023 13:55:17 +0100 Subject: [PATCH 1227/2810] Bump actions/checkout from 3.4.0 to 3.5.0 (#90215) Bumps [actions/checkout](https://github.com/actions/checkout) from 3.4.0 to 3.5.0. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v3.4.0...v3.5.0) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 12 +++++------ .github/workflows/ci.yaml | 32 +++++++++++++++--------------- .github/workflows/translations.yml | 2 +- .github/workflows/wheels.yml | 6 +++--- 4 files changed, 26 insertions(+), 26 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index bc21a2e3c7..ff53757bdd 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -24,7 +24,7 @@ jobs: publish: ${{ steps.version.outputs.publish }} steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 with: fetch-depth: 0 @@ -67,7 +67,7 @@ jobs: if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 @@ -105,7 +105,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' @@ -249,7 +249,7 @@ jobs: - yellow steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set build additional args run: | @@ -292,7 +292,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Initialize git uses: home-assistant/actions/helpers/git-init@master @@ -331,7 +331,7 @@ jobs: - "homeassistant" steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Login to DockerHub if: matrix.registry == 'homeassistant' diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d0dafda421..f4e04059d1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -79,7 +79,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Generate partial Python venv restore key id: generate_python_cache_key run: >- @@ -203,7 +203,7 @@ jobs: - info steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.5.0 @@ -248,7 +248,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 id: python @@ -294,7 +294,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 id: python @@ -343,7 +343,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 id: python @@ -381,7 +381,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 id: python @@ -487,7 +487,7 @@ jobs: python-version: ${{ fromJSON(needs.info.outputs.python_versions) }} steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.5.0 @@ -555,7 +555,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.5.0 @@ -587,7 +587,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.5.0 @@ -620,7 +620,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.5.0 @@ -664,7 +664,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.5.0 @@ -730,7 +730,7 @@ jobs: name: Run pip check ${{ matrix.python-version }} steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.5.0 @@ -783,7 +783,7 @@ jobs: bluez \ ffmpeg - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.5.0 @@ -909,7 +909,7 @@ jobs: ffmpeg \ libmariadb-dev-compat - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.5.0 @@ -1017,7 +1017,7 @@ jobs: ffmpeg \ postgresql-server-dev-14 - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.5.0 @@ -1093,7 +1093,7 @@ jobs: - pytest steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Download all coverage artifacts uses: actions/download-artifact@v3 - name: Upload coverage to Codecov (full coverage) diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index b8cbd9204b..86bfa5f9bb 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 1086738283..63069b86ef 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -22,7 +22,7 @@ jobs: architectures: ${{ steps.info.outputs.architectures }} steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Get information id: info @@ -82,7 +82,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Download env_file uses: actions/download-artifact@v3 @@ -119,7 +119,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Download env_file uses: actions/download-artifact@v3 From 8149652f9f3f808261c01e8a43b1c692f2bae084 Mon Sep 17 00:00:00 2001 From: Chris Xiao <30990835+chrisx8@users.noreply.github.com> Date: Fri, 24 Mar 2023 09:20:37 -0400 Subject: [PATCH 1228/2810] Move qbittorrent constants to const.py (#90201) * move qbittorrent constants to const.py * move SENSOR_TYPE_* consts back to sensors.py --- homeassistant/components/qbittorrent/const.py | 3 +++ homeassistant/components/qbittorrent/sensor.py | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/qbittorrent/const.py diff --git a/homeassistant/components/qbittorrent/const.py b/homeassistant/components/qbittorrent/const.py new file mode 100644 index 0000000000..5f9ad42f7f --- /dev/null +++ b/homeassistant/components/qbittorrent/const.py @@ -0,0 +1,3 @@ +"""Constants for qBittorrent.""" + +DEFAULT_NAME = "qBittorrent" diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index 26605a8765..bee7a5d61a 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -28,14 +28,14 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .const import DEFAULT_NAME + _LOGGER = logging.getLogger(__name__) SENSOR_TYPE_CURRENT_STATUS = "current_status" SENSOR_TYPE_DOWNLOAD_SPEED = "download_speed" SENSOR_TYPE_UPLOAD_SPEED = "upload_speed" -DEFAULT_NAME = "qBittorrent" - SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=SENSOR_TYPE_CURRENT_STATUS, From 4c45c3c63bc164210112c111bbb7b00afef64548 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Mar 2023 03:39:55 -1000 Subject: [PATCH 1229/2810] Add a faster query for get_last_state_changes when the number of states is 1 (#90211) * Add a faster query for get_last_state_changes when the number of states is 1 related issue #90113 * Add a faster query for get_last_state_changes when the number of states is 1 related issue #90113 * coverage * Apply suggestions from code review --- .../components/recorder/history/modern.py | 46 +++++++++++++++---- tests/components/recorder/test_history.py | 36 +++++++++++++++ 2 files changed, 72 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index 50e6102703..22bfdc3ee9 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -406,16 +406,38 @@ def _get_last_state_changes_stmt( stmt, join_attributes = _lambda_stmt_and_join_attributes( False, include_last_changed=False ) - stmt += lambda q: q.where( - States.state_id - == ( - select(States.state_id) - .filter(States.metadata_id == metadata_id) - .order_by(States.last_updated_ts.desc()) - .limit(number_of_states) - .subquery() - ).c.state_id - ) + if number_of_states == 1: + stmt += lambda q: q.join( + ( + lastest_state_for_metadata_id := ( + select( + States.metadata_id.label("max_metadata_id"), + # https://github.com/sqlalchemy/sqlalchemy/issues/9189 + # pylint: disable-next=not-callable + func.max(States.last_updated_ts).label("max_last_updated"), + ) + .filter(States.metadata_id == metadata_id) + .group_by(States.metadata_id) + .subquery() + ) + ), + and_( + States.metadata_id == lastest_state_for_metadata_id.c.max_metadata_id, + States.last_updated_ts + == lastest_state_for_metadata_id.c.max_last_updated, + ), + ) + else: + stmt += lambda q: q.where( + States.state_id + == ( + select(States.state_id) + .filter(States.metadata_id == metadata_id) + .order_by(States.last_updated_ts.desc()) + .limit(number_of_states) + .subquery() + ).c.state_id + ) if join_attributes: stmt += lambda q: q.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id @@ -432,6 +454,10 @@ def get_last_state_changes( entity_id_lower = entity_id.lower() entity_ids = [entity_id_lower] + # Calling this function with number_of_states > 1 can cause instability + # because it has to scan the table to find the last number_of_states states + # because the metadata_id_last_updated_ts index is in ascending order. + with session_scope(hass=hass, read_only=True) as session: instance = recorder.get_instance(hass) if not ( diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index e39cb1945f..e3aed8a398 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -382,6 +382,42 @@ def test_get_last_state_changes(hass_recorder: Callable[..., HomeAssistant]) -> assert_multiple_states_equal_without_context(states, hist[entity_id]) +def test_get_last_state_change(hass_recorder: Callable[..., HomeAssistant]) -> None: + """Test getting the last state change for an entity.""" + hass = hass_recorder() + entity_id = "sensor.test" + + def set_state(state): + """Set the state.""" + hass.states.set(entity_id, state) + wait_recording_done(hass) + return hass.states.get(entity_id) + + start = dt_util.utcnow() - timedelta(minutes=2) + point = start + timedelta(minutes=1) + point2 = point + timedelta(minutes=1, seconds=1) + + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start + ): + set_state("1") + + states = [] + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point + ): + set_state("2") + + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point2 + ): + states.append(set_state("3")) + + hist = history.get_last_state_changes(hass, 1, entity_id) + + assert_multiple_states_equal_without_context(states, hist[entity_id]) + + def test_ensure_state_can_be_copied( hass_recorder: Callable[..., HomeAssistant] ) -> None: From 0bb0b4bfc50d0d1395e8b0c5d95317f8d2f28fa0 Mon Sep 17 00:00:00 2001 From: Felix Rotthowe Date: Fri, 24 Mar 2023 14:52:50 +0100 Subject: [PATCH 1230/2810] Add livisi window sensor (WDS) (#90220) * Added support for livisi window sensor * Add const strings * added postpix for device_id * Remove unnecessary import * Fix imports * Fix lint errors, remove redundant device class property * Format code * Update .coveragerc * Finish basic window door sensor support * currently, only one binary sensor (wds) is supported * Remove unused imports * Fix isort issue * Simplify code as suggested in PR * rename get_device_response to get_device_state * fix ruff issue * Be more defensive in interpreting what we get from aiolivisi * Simplify coordinator * remove window sensor specific code (isOpen) * parameter order, type hinta * Update homeassistant/components/livisi/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/livisi/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/livisi/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/livisi/binary_sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/livisi/binary_sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/livisi/binary_sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: Tecotix <78791840+Tecotix@users.noreply.github.com> Co-authored-by: Erik Montnemery Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .coveragerc | 1 + homeassistant/components/livisi/__init__.py | 2 +- .../components/livisi/binary_sensor.py | 110 ++++++++++++++++++ homeassistant/components/livisi/const.py | 2 + .../components/livisi/coordinator.py | 3 + 5 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/livisi/binary_sensor.py diff --git a/.coveragerc b/.coveragerc index e59c60ddcc..24f324313a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -642,6 +642,7 @@ omit = homeassistant/components/linux_battery/sensor.py homeassistant/components/lirc/* homeassistant/components/livisi/__init__.py + homeassistant/components/livisi/binary_sensor.py homeassistant/components/livisi/climate.py homeassistant/components/livisi/coordinator.py homeassistant/components/livisi/entity.py diff --git a/homeassistant/components/livisi/__init__.py b/homeassistant/components/livisi/__init__.py index b8d8fdbfb0..b0387c6dcc 100644 --- a/homeassistant/components/livisi/__init__.py +++ b/homeassistant/components/livisi/__init__.py @@ -16,7 +16,7 @@ from homeassistant.helpers import aiohttp_client, device_registry as dr from .const import DOMAIN from .coordinator import LivisiDataUpdateCoordinator -PLATFORMS: Final = [Platform.CLIMATE, Platform.SWITCH] +PLATFORMS: Final = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SWITCH] async def async_setup_entry(hass: core.HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/livisi/binary_sensor.py b/homeassistant/components/livisi/binary_sensor.py new file mode 100644 index 0000000000..42170bbeb4 --- /dev/null +++ b/homeassistant/components/livisi/binary_sensor.py @@ -0,0 +1,110 @@ +"""Code to handle a Livisi Binary Sensor.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, LIVISI_STATE_CHANGE, LOGGER, WDS_DEVICE_TYPE +from .coordinator import LivisiDataUpdateCoordinator +from .entity import LivisiEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary_sensor device.""" + coordinator: LivisiDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + known_devices = set() + + @callback + def handle_coordinator_update() -> None: + """Add Window Sensor.""" + shc_devices: list[dict[str, Any]] = coordinator.data + entities: list[BinarySensorEntity] = [] + for device in shc_devices: + if device["id"] not in known_devices and device["type"] == WDS_DEVICE_TYPE: + livisi_binary: BinarySensorEntity = LivisiWindowDoorSensor( + config_entry, coordinator, device + ) + LOGGER.debug("Include device type: %s", device["type"]) + coordinator.devices.add(device["id"]) + known_devices.add(device["id"]) + entities.append(livisi_binary) + async_add_entities(entities) + + config_entry.async_on_unload( + coordinator.async_add_listener(handle_coordinator_update) + ) + + +class LivisiBinarySensor(LivisiEntity, BinarySensorEntity): + """Represents a Livisi Binary Sensor.""" + + def __init__( + self, + config_entry: ConfigEntry, + coordinator: LivisiDataUpdateCoordinator, + device: dict[str, Any], + capability_name: str, + ) -> None: + """Initialize the Livisi sensor.""" + super().__init__(config_entry, coordinator, device) + self._capability_id = self.capabilities[capability_name] + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + await super().async_added_to_hass() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{LIVISI_STATE_CHANGE}_{self._capability_id}", + self.update_states, + ) + ) + + @callback + def update_states(self, state: bool) -> None: + """Update the state of the device.""" + self._attr_is_on = state + self.async_write_ha_state() + + +class LivisiWindowDoorSensor(LivisiBinarySensor): + """Represents a Livisi Window/Door Sensor as a Binary Sensor Entity.""" + + def __init__( + self, + config_entry: ConfigEntry, + coordinator: LivisiDataUpdateCoordinator, + device: dict[str, Any], + ) -> None: + """Initialize the Livisi window/door sensor.""" + super().__init__(config_entry, coordinator, device, "WindowDoorSensor") + + self._attr_device_class = ( + BinarySensorDeviceClass.DOOR + if (device.get("tags", {}).get("typeCategory") == "TCDoorId") + else BinarySensorDeviceClass.WINDOW + ) + + async def async_added_to_hass(self) -> None: + """Get current state.""" + await super().async_added_to_hass() + response = await self.coordinator.async_get_device_state( + self._capability_id, "isOpen" + ) + if response is None: + self._attr_available = False + else: + self._attr_is_on = response diff --git a/homeassistant/components/livisi/const.py b/homeassistant/components/livisi/const.py index 98e0b7816c..f6435298f1 100644 --- a/homeassistant/components/livisi/const.py +++ b/homeassistant/components/livisi/const.py @@ -16,6 +16,8 @@ LIVISI_REACHABILITY_CHANGE: Final = "livisi_reachability_change" SWITCH_DEVICE_TYPES: Final = ["ISS", "ISS2", "PSS", "PSSO"] VRCC_DEVICE_TYPE: Final = "VRCC" +WDS_DEVICE_TYPE: Final = "WDS" + MAX_TEMPERATURE: Final = 30.0 MIN_TEMPERATURE: Final = 6.0 diff --git a/homeassistant/components/livisi/coordinator.py b/homeassistant/components/livisi/coordinator.py index 58124dfa04..f745a66e82 100644 --- a/homeassistant/components/livisi/coordinator.py +++ b/homeassistant/components/livisi/coordinator.py @@ -115,6 +115,9 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): self._async_dispatcher_send( LIVISI_REACHABILITY_CHANGE, event_data.source, event_data.isReachable ) + self._async_dispatcher_send( + LIVISI_STATE_CHANGE, event_data.source, event_data.isOpen + ) async def on_close(self) -> None: """Define a handler to fire when the websocket is closed.""" From 72b09bfee7dd54d28db4f6eada3153a9c58b3966 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Mar 2023 09:37:43 -1000 Subject: [PATCH 1231/2810] Subclass aiohttp requests to use json helper (#90214) * Subclass aiohttp requests to use json helper * Subclass aiohttp requests to use json helper * remove unneeded * revert for new pr * override loads is never used so drop it * override loads is never used so drop it --- homeassistant/components/http/__init__.py | 44 +++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 3106eea05f..2d306ba5ee 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -1,6 +1,7 @@ """Support to serve the Home Assistant API as WSGI application.""" from __future__ import annotations +import asyncio import datetime from ipaddress import IPv4Network, IPv6Network, ip_network import logging @@ -10,9 +11,13 @@ from tempfile import NamedTemporaryFile from typing import Any, Final, TypedDict, cast from aiohttp import web -from aiohttp.typedefs import StrOrURL +from aiohttp.abc import AbstractStreamWriter +from aiohttp.http_parser import RawRequestMessage +from aiohttp.streams import StreamReader +from aiohttp.typedefs import JSONDecoder, StrOrURL from aiohttp.web_exceptions import HTTPMovedPermanently, HTTPRedirection from aiohttp.web_log import AccessLogger +from aiohttp.web_protocol import RequestHandler from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa @@ -31,6 +36,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.setup import async_start_setup, async_when_setup_or_start from homeassistant.util import ssl as ssl_util +from homeassistant.util.json import json_loads from .auth import async_setup_auth from .ban import setup_bans @@ -240,6 +246,40 @@ class HomeAssistantAccessLogger(AccessLogger): super().log(request, response, time) +class HomeAssistantRequest(web.Request): + """Home Assistant request object.""" + + async def json(self, *, loads: JSONDecoder = json_loads) -> Any: + """Return body as JSON.""" + # json_loads is a wrapper around orjson.loads that handles + # bytes and str. We can pass the bytes directly to json_loads. + return json_loads(await self.read()) + + +class HomeAssistantApplication(web.Application): + """Home Assistant application.""" + + def _make_request( + self, + message: RawRequestMessage, + payload: StreamReader, + protocol: RequestHandler, + writer: AbstractStreamWriter, + task: asyncio.Task[None], + _cls: type[web.Request] = HomeAssistantRequest, + ) -> web.Request: + """Create request instance.""" + return _cls( + message, + payload, + protocol, + writer, + task, + loop=self._loop, + client_max_size=self._client_max_size, + ) + + class HomeAssistantHTTP: """HTTP server for Home Assistant.""" @@ -255,7 +295,7 @@ class HomeAssistantHTTP: ssl_profile: str, ) -> None: """Initialize the HTTP Home Assistant server.""" - self.app = web.Application( + self.app = HomeAssistantApplication( middlewares=[], client_max_size=MAX_CLIENT_SIZE, handler_args={ From 8e07b716444431a78a9ec9a18cc30c2c404440ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Mar 2023 09:38:08 -1000 Subject: [PATCH 1232/2810] Use the json load helper in a few more incoming web requests (#90194) * Use the json load helper in a few more incoming web requests * drop hassio change as there is no coverage there * Remove everything except emulated_hue since its has its own site/web --- homeassistant/components/emulated_hue/hue_api.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 41c25943a7..f779f5d8e9 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -64,6 +64,7 @@ from homeassistant.const import ( ) from homeassistant.core import State from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.util.json import json_loads from homeassistant.util.network import is_local from .config import Config @@ -138,7 +139,7 @@ class HueUsernameView(HomeAssistantView): return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) try: - data = await request.json() + data = await request.json(loads=json_loads) except ValueError: return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) From e17cefd61cbd234e7dab8b94d4847858b29c0e40 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Mar 2023 10:24:02 -1000 Subject: [PATCH 1233/2810] Clear recorder startup tasks from memory after processing (#90240) Co-authored-by: Paulus Schoutsen --- homeassistant/components/recorder/core.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 8e522a2bbd..3b92698c83 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -779,6 +779,10 @@ class Recorder(threading.Thread): for task in startup_tasks: self._guarded_process_one_task_or_recover(task) + # Clear startup tasks since this thread runs forever + # and we don't want to hold them in memory + del startup_tasks + self.stop_requested = False while not self.stop_requested: self._guarded_process_one_task_or_recover(queue_.get()) From 7f1fff12effa5d35c32c950bde1c59dfe572f5e8 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 25 Mar 2023 00:27:16 +0100 Subject: [PATCH 1234/2810] Bump aiounifi to v45 (#90250) * Bump aiounifi to v45 * Replace local TypeVar with library TypeVar --- .../components/unifi/device_tracker.py | 10 ++++---- homeassistant/components/unifi/entity.py | 23 ++++++++----------- homeassistant/components/unifi/manifest.json | 2 +- homeassistant/components/unifi/sensor.py | 14 +++++------ homeassistant/components/unifi/switch.py | 14 +++++------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 31 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index f891416c63..f31176afe3 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -12,6 +12,7 @@ import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent from aiounifi.interfaces.clients import Clients from aiounifi.interfaces.devices import Devices +from aiounifi.models.api import ApiItemT from aiounifi.models.client import Client from aiounifi.models.device import Device from aiounifi.models.event import Event, EventKey @@ -26,7 +27,6 @@ import homeassistant.util.dt as dt_util from .const import DOMAIN as UNIFI_DOMAIN from .controller import UniFiController from .entity import ( - DataT, HandlerT, UnifiEntity, UnifiEntityDescription, @@ -136,7 +136,7 @@ def async_device_heartbeat_timedelta_fn( @dataclass -class UnifiEntityTrackerDescriptionMixin(Generic[HandlerT, DataT]): +class UnifiEntityTrackerDescriptionMixin(Generic[HandlerT, ApiItemT]): """Device tracker local functions.""" heartbeat_timedelta_fn: Callable[[UniFiController, str], timedelta] @@ -147,8 +147,8 @@ class UnifiEntityTrackerDescriptionMixin(Generic[HandlerT, DataT]): @dataclass class UnifiTrackerEntityDescription( - UnifiEntityDescription[HandlerT, DataT], - UnifiEntityTrackerDescriptionMixin[HandlerT, DataT], + UnifiEntityDescription[HandlerT, ApiItemT], + UnifiEntityTrackerDescriptionMixin[HandlerT, ApiItemT], ): """Class describing UniFi device tracker entity.""" @@ -211,7 +211,7 @@ async def async_setup_entry( ) -class UnifiScannerEntity(UnifiEntity[HandlerT, DataT], ScannerEntity): +class UnifiScannerEntity(UnifiEntity[HandlerT, ApiItemT], ScannerEntity): """Representation of a UniFi scanner.""" entity_description: UnifiTrackerEntityDescription diff --git a/homeassistant/components/unifi/entity.py b/homeassistant/components/unifi/entity.py index 5d763ecfe8..18a132be6a 100644 --- a/homeassistant/components/unifi/entity.py +++ b/homeassistant/components/unifi/entity.py @@ -13,12 +13,8 @@ from aiounifi.interfaces.api_handlers import ( ItemEvent, UnsubscribeType, ) -from aiounifi.interfaces.outlets import Outlets -from aiounifi.interfaces.ports import Ports -from aiounifi.models.api import APIItem +from aiounifi.models.api import ApiItemT from aiounifi.models.event import Event, EventKey -from aiounifi.models.outlet import Outlet -from aiounifi.models.port import Port from homeassistant.core import callback from homeassistant.helpers import entity_registry as er @@ -31,8 +27,7 @@ from .const import ATTR_MANUFACTURER if TYPE_CHECKING: from .controller import UniFiController -DataT = TypeVar("DataT", bound=APIItem | Outlet | Port) -HandlerT = TypeVar("HandlerT", bound=APIHandler | Outlets | Ports) +HandlerT = TypeVar("HandlerT", bound=APIHandler) SubscriptionT = Callable[[CallbackType, ItemEvent], UnsubscribeType] @@ -64,7 +59,7 @@ def async_device_device_info_fn(api: aiounifi.Controller, obj_id: str) -> Device @dataclass -class UnifiDescription(Generic[HandlerT, DataT]): +class UnifiDescription(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" allowed_fn: Callable[[UniFiController, str], bool] @@ -73,21 +68,21 @@ class UnifiDescription(Generic[HandlerT, DataT]): device_info_fn: Callable[[aiounifi.Controller, str], DeviceInfo | None] event_is_on: tuple[EventKey, ...] | None event_to_subscribe: tuple[EventKey, ...] | None - name_fn: Callable[[DataT], str | None] - object_fn: Callable[[aiounifi.Controller, str], DataT] + name_fn: Callable[[ApiItemT], str | None] + object_fn: Callable[[aiounifi.Controller, str], ApiItemT] supported_fn: Callable[[UniFiController, str], bool | None] unique_id_fn: Callable[[UniFiController, str], str] @dataclass -class UnifiEntityDescription(EntityDescription, UnifiDescription[HandlerT, DataT]): +class UnifiEntityDescription(EntityDescription, UnifiDescription[HandlerT, ApiItemT]): """UniFi Entity Description.""" -class UnifiEntity(Entity, Generic[HandlerT, DataT]): +class UnifiEntity(Entity, Generic[HandlerT, ApiItemT]): """Representation of a UniFi entity.""" - entity_description: UnifiEntityDescription[HandlerT, DataT] + entity_description: UnifiEntityDescription[HandlerT, ApiItemT] _attr_should_poll = False _attr_unique_id: str @@ -96,7 +91,7 @@ class UnifiEntity(Entity, Generic[HandlerT, DataT]): self, obj_id: str, controller: UniFiController, - description: UnifiEntityDescription[HandlerT, DataT], + description: UnifiEntityDescription[HandlerT, ApiItemT], ) -> None: """Set up UniFi switch entity.""" self._obj_id = obj_id diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 92f879c104..7fde8a2ad7 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["aiounifi"], "quality_scale": "platinum", - "requirements": ["aiounifi==44"], + "requirements": ["aiounifi==45"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 420fc3803c..3682fa0bf6 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -14,6 +14,7 @@ import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent from aiounifi.interfaces.clients import Clients from aiounifi.interfaces.ports import Ports +from aiounifi.models.api import ApiItemT from aiounifi.models.client import Client from aiounifi.models.port import Port @@ -33,7 +34,6 @@ import homeassistant.util.dt as dt_util from .const import DOMAIN as UNIFI_DOMAIN from .controller import UniFiController from .entity import ( - DataT, HandlerT, UnifiEntity, UnifiEntityDescription, @@ -80,17 +80,17 @@ def async_client_device_info_fn(api: aiounifi.Controller, obj_id: str) -> Device @dataclass -class UnifiSensorEntityDescriptionMixin(Generic[HandlerT, DataT]): +class UnifiSensorEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" - value_fn: Callable[[UniFiController, DataT], datetime | float | str | None] + value_fn: Callable[[UniFiController, ApiItemT], datetime | float | str | None] @dataclass class UnifiSensorEntityDescription( SensorEntityDescription, - UnifiEntityDescription[HandlerT, DataT], - UnifiSensorEntityDescriptionMixin[HandlerT, DataT], + UnifiEntityDescription[HandlerT, ApiItemT], + UnifiSensorEntityDescriptionMixin[HandlerT, ApiItemT], ): """Class describing UniFi sensor entity.""" @@ -182,10 +182,10 @@ async def async_setup_entry( ) -class UnifiSensorEntity(UnifiEntity[HandlerT, DataT], SensorEntity): +class UnifiSensorEntity(UnifiEntity[HandlerT, ApiItemT], SensorEntity): """Base representation of a UniFi sensor.""" - entity_description: UnifiSensorEntityDescription[HandlerT, DataT] + entity_description: UnifiSensorEntityDescription[HandlerT, ApiItemT] @callback def async_update_state(self, event: ItemEvent, obj_id: str) -> None: diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index bf724cec1f..bd0166516d 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -17,6 +17,7 @@ from aiounifi.interfaces.clients import Clients from aiounifi.interfaces.dpi_restriction_groups import DPIRestrictionGroups from aiounifi.interfaces.outlets import Outlets from aiounifi.interfaces.ports import Ports +from aiounifi.models.api import ApiItemT from aiounifi.models.client import Client, ClientBlockRequest from aiounifi.models.device import ( DeviceSetOutletRelayRequest, @@ -47,7 +48,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ATTR_MANUFACTURER, DOMAIN as UNIFI_DOMAIN from .controller import UniFiController from .entity import ( - DataT, HandlerT, SubscriptionT, UnifiEntity, @@ -136,18 +136,18 @@ async def async_poe_port_control_fn( @dataclass -class UnifiSwitchEntityDescriptionMixin(Generic[HandlerT, DataT]): +class UnifiSwitchEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" control_fn: Callable[[aiounifi.Controller, str, bool], Coroutine[Any, Any, None]] - is_on_fn: Callable[[UniFiController, DataT], bool] + is_on_fn: Callable[[UniFiController, ApiItemT], bool] @dataclass class UnifiSwitchEntityDescription( SwitchEntityDescription, - UnifiEntityDescription[HandlerT, DataT], - UnifiSwitchEntityDescriptionMixin[HandlerT, DataT], + UnifiEntityDescription[HandlerT, ApiItemT], + UnifiSwitchEntityDescriptionMixin[HandlerT, ApiItemT], ): """Class describing UniFi switch entity.""" @@ -255,10 +255,10 @@ async def async_setup_entry( ) -class UnifiSwitchEntity(UnifiEntity[HandlerT, DataT], SwitchEntity): +class UnifiSwitchEntity(UnifiEntity[HandlerT, ApiItemT], SwitchEntity): """Base representation of a UniFi switch.""" - entity_description: UnifiSwitchEntityDescription[HandlerT, DataT] + entity_description: UnifiSwitchEntityDescription[HandlerT, ApiItemT] only_event_for_state_change = False @callback diff --git a/requirements_all.txt b/requirements_all.txt index 7d1670833f..e4b5a9999d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -291,7 +291,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.5 # homeassistant.components.unifi -aiounifi==44 +aiounifi==45 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3bb56a1be4..2e31358bca 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -272,7 +272,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.5 # homeassistant.components.unifi -aiounifi==44 +aiounifi==45 # homeassistant.components.vlc_telnet aiovlc==0.1.0 From 5f3868b1419e5286777da7d5483e1a3266d98486 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 25 Mar 2023 03:34:01 +0100 Subject: [PATCH 1235/2810] Add missing type hints to tests (#90218) * Add type hints to tests * Revert gree as handled in #90222 --- tests/components/calendar/test_trigger.py | 2 +- .../local_calendar/test_diagnostics.py | 7 +++---- tests/components/zha/test_api.py | 19 +++++++++++-------- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/tests/components/calendar/test_trigger.py b/tests/components/calendar/test_trigger.py index 9e15a1996d..e210bd7ac3 100644 --- a/tests/components/calendar/test_trigger.py +++ b/tests/components/calendar/test_trigger.py @@ -668,7 +668,7 @@ async def test_trigger_timestamp_window_edge( async def test_event_start_trigger_dst( - hass: HomeAssistant, calls, fake_schedule, freezer + hass: HomeAssistant, calls, fake_schedule, freezer: FrozenDateTimeFactory ) -> None: """Test a calendar event trigger happening at the start of daylight savings time.""" tzinfo = zoneinfo.ZoneInfo("America/Los_Angeles") diff --git a/tests/components/local_calendar/test_diagnostics.py b/tests/components/local_calendar/test_diagnostics.py index 561f7588a5..9a1da25d77 100644 --- a/tests/components/local_calendar/test_diagnostics.py +++ b/tests/components/local_calendar/test_diagnostics.py @@ -1,5 +1,4 @@ """Tests for diagnostics platform of local calendar.""" - from aiohttp.test_utils import TestClient from freezegun import freeze_time import pytest @@ -9,11 +8,11 @@ from homeassistant.auth.models import Credentials from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .conftest import TEST_ENTITY, Client, ClientFixture +from .conftest import TEST_ENTITY, Client from tests.common import CLIENT_ID, MockConfigEntry, MockUser from tests.components.diagnostics import get_diagnostics_for_config_entry -from tests.typing import ClientSessionGenerator +from tests.typing import ClientSessionGenerator, WebSocketGenerator async def generate_new_hass_access_token( @@ -82,7 +81,7 @@ async def test_api_date_time_event( hass_admin_user: MockUser, hass_admin_credential: Credentials, config_entry: MockConfigEntry, - hass_ws_client: ClientFixture, + hass_ws_client: WebSocketGenerator, aiohttp_client: ClientSessionGenerator, socket_enabled: None, snapshot: SnapshotAssertion, diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py index 0d03b62bf8..c607909980 100644 --- a/tests/components/zha/test_api.py +++ b/tests/components/zha/test_api.py @@ -7,6 +7,7 @@ import zigpy.state from homeassistant.components import zha from homeassistant.components.zha import api from homeassistant.components.zha.core.const import RadioType +from homeassistant.core import HomeAssistant @pytest.fixture(autouse=True) @@ -16,7 +17,9 @@ def required_platform_only(): yield -async def test_async_get_network_settings_active(hass, setup_zha): +async def test_async_get_network_settings_active( + hass: HomeAssistant, setup_zha +) -> None: """Test reading settings with an active ZHA installation.""" await setup_zha() @@ -25,8 +28,8 @@ async def test_async_get_network_settings_active(hass, setup_zha): async def test_async_get_network_settings_inactive( - hass, setup_zha, zigpy_app_controller -): + hass: HomeAssistant, setup_zha, zigpy_app_controller +) -> None: """Test reading settings with an inactive ZHA installation.""" await setup_zha() @@ -48,8 +51,8 @@ async def test_async_get_network_settings_inactive( async def test_async_get_network_settings_missing( - hass, setup_zha, zigpy_app_controller -): + hass: HomeAssistant, setup_zha, zigpy_app_controller +) -> None: """Test reading settings with an inactive ZHA installation, no valid channel.""" await setup_zha() @@ -69,13 +72,13 @@ async def test_async_get_network_settings_missing( assert settings is None -async def test_async_get_network_settings_failure(hass): +async def test_async_get_network_settings_failure(hass: HomeAssistant) -> None: """Test reading settings with no ZHA config entries and no database.""" with pytest.raises(ValueError): await api.async_get_network_settings(hass) -async def test_async_get_radio_type_active(hass, setup_zha): +async def test_async_get_radio_type_active(hass: HomeAssistant, setup_zha) -> None: """Test reading the radio type with an active ZHA installation.""" await setup_zha() @@ -83,7 +86,7 @@ async def test_async_get_radio_type_active(hass, setup_zha): assert radio_type == RadioType.ezsp -async def test_async_get_radio_path_active(hass, setup_zha): +async def test_async_get_radio_path_active(hass: HomeAssistant, setup_zha) -> None: """Test reading the radio path with an active ZHA installation.""" await setup_zha() From f56bf134d280fe9085e7fb56c5f419101937e73d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 25 Mar 2023 05:49:12 +0100 Subject: [PATCH 1236/2810] Improve browse_media type hints in media player (#90060) * Improve browse_media type hints in media player * Adjust components * Adjust base entity --- homeassistant/components/braviatv/media_player.py | 2 +- homeassistant/components/dlna_dmr/media_player.py | 2 +- homeassistant/components/esphome/media_player.py | 4 +++- homeassistant/components/forked_daapd/media_player.py | 2 +- homeassistant/components/frontier_silicon/media_player.py | 4 +++- homeassistant/components/fully_kiosk/media_player.py | 2 +- homeassistant/components/gstreamer/media_player.py | 4 +++- homeassistant/components/heos/media_player.py | 4 +++- homeassistant/components/jellyfin/media_player.py | 4 +++- homeassistant/components/kodi/media_player.py | 4 +++- homeassistant/components/media_player/__init__.py | 2 +- homeassistant/components/mpd/media_player.py | 4 +++- homeassistant/components/openhome/media_player.py | 4 +++- homeassistant/components/panasonic_viera/media_player.py | 4 +++- homeassistant/components/philips_js/media_player.py | 4 +++- homeassistant/components/plex/media_player.py | 4 +++- homeassistant/components/roku/media_player.py | 2 +- homeassistant/components/roon/media_player.py | 4 +++- homeassistant/components/slimproto/media_player.py | 4 +++- homeassistant/components/sonos/media_player.py | 4 +++- homeassistant/components/soundtouch/media_player.py | 4 +++- homeassistant/components/spotify/media_player.py | 4 +++- homeassistant/components/universal/media_player.py | 2 +- pylint/plugins/hass_enforce_type_hints.py | 2 +- 24 files changed, 56 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/braviatv/media_player.py b/homeassistant/components/braviatv/media_player.py index 917bd1d541..c09df32aea 100644 --- a/homeassistant/components/braviatv/media_player.py +++ b/homeassistant/components/braviatv/media_player.py @@ -136,7 +136,7 @@ class BraviaTVMediaPlayer(BraviaTVEntity, MediaPlayerEntity): async def async_browse_media( self, - media_content_type: str | None = None, + media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Browse apps and channels.""" diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index a866b911f3..eddb2633be 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -767,7 +767,7 @@ class DlnaDmrEntity(MediaPlayerEntity): async def async_browse_media( self, - media_content_type: str | None = None, + media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper. diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index f8566e863c..673a90580e 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -115,7 +115,9 @@ class EsphomeMediaPlayer( ) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" return await media_source.async_browse_media( diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index ca7e0cce27..d5f40c37b5 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -836,7 +836,7 @@ class ForkedDaapdMaster(MediaPlayerEntity): async def async_browse_media( self, - media_content_type: str | None = None, + media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index b05ba272a1..7f73823239 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -328,7 +328,9 @@ class AFSAPIDevice(MediaPlayerEntity): await self.fs_device.set_eq_preset(mode) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Browse media library and preset stations.""" if not media_content_id: diff --git a/homeassistant/components/fully_kiosk/media_player.py b/homeassistant/components/fully_kiosk/media_player.py index 0fcd8c3543..8c73d47dd7 100644 --- a/homeassistant/components/fully_kiosk/media_player.py +++ b/homeassistant/components/fully_kiosk/media_player.py @@ -72,7 +72,7 @@ class FullyMediaPlayer(FullyKioskEntity, MediaPlayerEntity): async def async_browse_media( self, - media_content_type: str | None = None, + media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Implement the WebSocket media browsing helper.""" diff --git a/homeassistant/components/gstreamer/media_player.py b/homeassistant/components/gstreamer/media_player.py index 04e91e4317..cb221d4941 100644 --- a/homeassistant/components/gstreamer/media_player.py +++ b/homeassistant/components/gstreamer/media_player.py @@ -166,7 +166,9 @@ class GstreamerDevice(MediaPlayerEntity): return self._album async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" return await media_source.async_browse_media( diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 3147c1e166..9ad33caf07 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -427,7 +427,9 @@ class HeosMediaPlayer(MediaPlayerEntity): return self._player.volume / 100 async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" return await media_source.async_browse_media( diff --git a/homeassistant/components/jellyfin/media_player.py b/homeassistant/components/jellyfin/media_player.py index 32ca1d59d7..2025e1a2a6 100644 --- a/homeassistant/components/jellyfin/media_player.py +++ b/homeassistant/components/jellyfin/media_player.py @@ -283,7 +283,9 @@ class JellyfinMediaPlayer(JellyfinEntity, MediaPlayerEntity): self.coordinator.api_client.jellyfin.remote_unmute(self.session_id) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Return a BrowseMedia instance. diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index 029eedb242..63875236be 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -884,7 +884,9 @@ class KodiEntity(MediaPlayerEntity): return sorted(out, key=lambda out: out[1], reverse=True) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" is_internal = is_internal_request(self.hass) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 3938cc64f7..8810ea165d 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -1037,7 +1037,7 @@ class MediaPlayerEntity(Entity): async def async_browse_media( self, - media_content_type: str | None = None, + media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Return a BrowseMedia instance. diff --git a/homeassistant/components/mpd/media_player.py b/homeassistant/components/mpd/media_player.py index 7395777320..457f905824 100644 --- a/homeassistant/components/mpd/media_player.py +++ b/homeassistant/components/mpd/media_player.py @@ -509,7 +509,9 @@ class MpdDevice(MediaPlayerEntity): await self._client.seekcur(position) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" return await media_source.async_browse_media( diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index 68357c862c..b625d9976d 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -347,7 +347,9 @@ class OpenhomeDevice(MediaPlayerEntity): await self._device.set_mute(mute) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" return await media_source.async_browse_media( diff --git a/homeassistant/components/panasonic_viera/media_player.py b/homeassistant/components/panasonic_viera/media_player.py index 8b676f37c2..5e2ed77233 100644 --- a/homeassistant/components/panasonic_viera/media_player.py +++ b/homeassistant/components/panasonic_viera/media_player.py @@ -203,7 +203,9 @@ class PanasonicVieraTVEntity(MediaPlayerEntity): await self._remote.async_play_media(media_type, media_id) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" return await media_source.async_browse_media(self.hass, media_content_id) diff --git a/homeassistant/components/philips_js/media_player.py b/homeassistant/components/philips_js/media_player.py index 89cb29f0a0..e8250dc8eb 100644 --- a/homeassistant/components/philips_js/media_player.py +++ b/homeassistant/components/philips_js/media_player.py @@ -391,7 +391,9 @@ class PhilipsTVMediaPlayer( ) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" if not self._tv.on: diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index c1a3ac5bd3..be57267960 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -541,7 +541,9 @@ class PlexMediaPlayer(MediaPlayerEntity): ) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" is_internal = is_internal_request(self.hass) diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index b0191f605d..cf6563519f 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -278,7 +278,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): async def async_browse_media( self, - media_content_type: str | None = None, + media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index 307765da5c..3bcafe4ba9 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -498,7 +498,9 @@ class RoonDevice(MediaPlayerEntity): ) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" return await self.hass.async_add_executor_job( diff --git a/homeassistant/components/slimproto/media_player.py b/homeassistant/components/slimproto/media_player.py index 597ed50f42..641d3b8ae4 100644 --- a/homeassistant/components/slimproto/media_player.py +++ b/homeassistant/components/slimproto/media_player.py @@ -195,7 +195,9 @@ class SlimProtoPlayer(MediaPlayerEntity): await self.player.play_url(media_id, mime_type=to_send_media_type) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" return await media_source.async_browse_media( diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index fbd74e5774..1ef86429cb 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -712,7 +712,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): return (None, None) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" return await media_browser.async_browse_media( diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py index 111a13c2c9..7211843133 100644 --- a/homeassistant/components/soundtouch/media_player.py +++ b/homeassistant/components/soundtouch/media_player.py @@ -398,7 +398,9 @@ class SoundTouchMediaPlayer(MediaPlayerEntity): return attributes async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" return await media_source.async_browse_media(self.hass, media_content_id) diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index 7c583eb533..b63a951381 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -398,7 +398,9 @@ class SpotifyMediaPlayer(MediaPlayerEntity): self._playlist = self.data.client.playlist(current["context"]["uri"]) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py index 21d741d345..fd73ad33e1 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -630,7 +630,7 @@ class UniversalMediaPlayer(MediaPlayerEntity): async def async_browse_media( self, - media_content_type: str | None = None, + media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Return a BrowseMedia instance.""" diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 6394f84222..f25b8db84a 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2003,7 +2003,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { TypeHintMatch( function_name="async_browse_media", arg_types={ - 1: "str | None", + 1: "MediaType | str | None", 2: "str | None", }, return_type="BrowseMedia", From 970036b32873f31c76ab5979aebdc81181af83f0 Mon Sep 17 00:00:00 2001 From: Jeef Date: Sat, 25 Mar 2023 02:15:46 -0600 Subject: [PATCH 1237/2810] Refactor Gree switch to use EntityDescription (#90143) * Post-rebase * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * feat: Tests passing! * Removing custom attributes as no longer needed * removed extraneous class * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/gree/test_switch.py Co-authored-by: solazs * Update tests/components/gree/test_switch.py Co-authored-by: solazs --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> Co-authored-by: solazs --- homeassistant/components/gree/switch.py | 193 ++++++------------------ 1 file changed, 42 insertions(+), 151 deletions(-) diff --git a/homeassistant/components/gree/switch.py b/homeassistant/components/gree/switch.py index ffef6b08a9..0ac7406713 100644 --- a/homeassistant/components/gree/switch.py +++ b/homeassistant/components/gree/switch.py @@ -1,9 +1,13 @@ """Support for interface with a Gree climate systems.""" from __future__ import annotations -from typing import Any +from typing import Any, cast -from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -12,6 +16,29 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DISPATCHERS, DOMAIN from .entity import GreeEntity +GREE_SWITCHES: tuple[SwitchEntityDescription, ...] = ( + SwitchEntityDescription( + icon="mdi:lightbulb", + name="Panel Light", + key="light", + ), + SwitchEntityDescription( + name="Quiet", + key="quiet", + ), + SwitchEntityDescription( + name="Fresh Air", + key="fresh_air", + ), + SwitchEntityDescription(name="XFan", key="xfan"), + SwitchEntityDescription( + icon="mdi:pine-tree", + name="Health mode", + key="anion", + entity_registry_enabled_default=False, + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -23,14 +50,10 @@ async def async_setup_entry( @callback def init_device(coordinator): """Register the device.""" + async_add_entities( - [ - GreePanelLightSwitchEntity(coordinator), - GreeHealthModeSwitchEntity(coordinator), - GreeQuietModeSwitchEntity(coordinator), - GreeFreshAirSwitchEntity(coordinator), - GreeXFanSwitchEntity(coordinator), - ] + GreeSwitch(coordinator=coordinator, description=description) + for description in GREE_SWITCHES ) for coordinator in hass.data[DOMAIN][COORDINATORS]: @@ -41,162 +64,30 @@ async def async_setup_entry( ) -class GreePanelLightSwitchEntity(GreeEntity, SwitchEntity): - """Representation of the front panel light on the device.""" +class GreeSwitch(GreeEntity, SwitchEntity): + """Generic Gree switch entity.""" - def __init__(self, coordinator): + _attr_device_class = SwitchDeviceClass.SWITCH + + def __init__(self, coordinator, description: SwitchEntityDescription) -> None: """Initialize the Gree device.""" - super().__init__(coordinator, "Panel Light") + self.entity_description = description - @property - def icon(self) -> str | None: - """Return the icon for the device.""" - return "mdi:lightbulb" - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return SwitchDeviceClass.SWITCH - - @property - def is_on(self) -> bool: - """Return if the light is turned on.""" - return self.coordinator.device.light - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the entity on.""" - self.coordinator.device.light = True - await self.coordinator.push_state_update() - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the entity off.""" - self.coordinator.device.light = False - await self.coordinator.push_state_update() - self.async_write_ha_state() - - -class GreeHealthModeSwitchEntity(GreeEntity, SwitchEntity): - """Representation of the health mode on the device.""" - - def __init__(self, coordinator): - """Initialize the Gree device.""" - super().__init__(coordinator, "Health mode") - self._attr_entity_registry_enabled_default = False - - @property - def icon(self) -> str | None: - """Return the icon for the device.""" - return "mdi:pine-tree" - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return SwitchDeviceClass.SWITCH - - @property - def is_on(self) -> bool: - """Return if the health mode is turned on.""" - return self.coordinator.device.anion - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the entity on.""" - self.coordinator.device.anion = True - await self.coordinator.push_state_update() - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the entity off.""" - self.coordinator.device.anion = False - await self.coordinator.push_state_update() - self.async_write_ha_state() - - -class GreeQuietModeSwitchEntity(GreeEntity, SwitchEntity): - """Representation of the quiet mode state of the device.""" - - def __init__(self, coordinator): - """Initialize the Gree device.""" - super().__init__(coordinator, "Quiet") - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return SwitchDeviceClass.SWITCH + super().__init__(coordinator, cast(str, description.name)) @property def is_on(self) -> bool: """Return if the state is turned on.""" - return self.coordinator.device.quiet + return getattr(self.coordinator.device, self.entity_description.key) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - self.coordinator.device.quiet = True + setattr(self.coordinator.device, self.entity_description.key, True) await self.coordinator.push_state_update() self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - self.coordinator.device.quiet = False - await self.coordinator.push_state_update() - self.async_write_ha_state() - - -class GreeFreshAirSwitchEntity(GreeEntity, SwitchEntity): - """Representation of the fresh air mode state of the device.""" - - def __init__(self, coordinator): - """Initialize the Gree device.""" - super().__init__(coordinator, "Fresh Air") - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return SwitchDeviceClass.SWITCH - - @property - def is_on(self) -> bool: - """Return if the state is turned on.""" - return self.coordinator.device.fresh_air - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the entity on.""" - self.coordinator.device.fresh_air = True - await self.coordinator.push_state_update() - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the entity off.""" - self.coordinator.device.fresh_air = False - await self.coordinator.push_state_update() - self.async_write_ha_state() - - -class GreeXFanSwitchEntity(GreeEntity, SwitchEntity): - """Representation of the extra fan mode state of the device.""" - - def __init__(self, coordinator): - """Initialize the Gree device.""" - super().__init__(coordinator, "XFan") - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return SwitchDeviceClass.SWITCH - - @property - def is_on(self) -> bool: - """Return if the state is turned on.""" - return self.coordinator.device.xfan - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the entity on.""" - self.coordinator.device.xfan = True - await self.coordinator.push_state_update() - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the entity off.""" - self.coordinator.device.xfan = False + setattr(self.coordinator.device, self.entity_description.key, False) await self.coordinator.push_state_update() self.async_write_ha_state() From 52a94dd2ac50b9035555e69100db33e968e9365d Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Fri, 24 Mar 2023 21:36:23 -1100 Subject: [PATCH 1238/2810] Check for empty lists in KNX address configuration (#90249) --- homeassistant/components/knx/schema.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index c6206e883e..a505714c0d 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -101,7 +101,11 @@ def ga_validator(value: Any) -> str | int: ) -ga_list_validator = vol.All(cv.ensure_list, [ga_validator]) +ga_list_validator = vol.All( + cv.ensure_list, + [ga_validator], + vol.IsTrue("value must be a group address or a list containing group addresses"), +) ia_validator = vol.Any( vol.All(str, str.strip, cv.matches_regex(IndividualAddress.ADDRESS_RE.pattern)), From 02ef7d445d8a723fe57712f37ca9c27595c5d1a2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Mar 2023 04:11:14 -1000 Subject: [PATCH 1239/2810] Allow passing an optional name to async_track_time_interval (#90244) * Allow passing an optional name to async_track_time_interval This is the same idea as passing a name to asyncio.create_task which makes it easier to track down bugs * more * short * still cannot find it * add a few more * test --- .../components/analytics/__init__.py | 4 +++- homeassistant/components/august/subscriber.py | 2 +- .../components/bluetooth/base_scanner.py | 10 +++++++-- homeassistant/components/bluetooth/manager.py | 1 + homeassistant/components/bond/entity.py | 5 ++++- homeassistant/components/camera/__init__.py | 4 +++- .../components/device_tracker/legacy.py | 7 +++++- homeassistant/components/dhcp/__init__.py | 2 +- .../homekit_controller/connection.py | 6 ++++- homeassistant/components/recorder/core.py | 15 ++++++++++--- homeassistant/components/ssdp/__init__.py | 2 +- homeassistant/helpers/entity_platform.py | 1 + homeassistant/helpers/event.py | 10 ++++++--- homeassistant/helpers/restore_state.py | 5 ++++- tests/helpers/test_event.py | 22 +++++++++++++++++++ 15 files changed, 79 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/analytics/__init__.py b/homeassistant/components/analytics/__init__.py index ad53fb0311..7bf55480eb 100644 --- a/homeassistant/components/analytics/__init__.py +++ b/homeassistant/components/analytics/__init__.py @@ -27,7 +27,9 @@ async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool: async_call_later(hass, 900, analytics.send_analytics) # Send every day - async_track_time_interval(hass, analytics.send_analytics, INTERVAL) + async_track_time_interval( + hass, analytics.send_analytics, INTERVAL, "analytics daily" + ) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule) diff --git a/homeassistant/components/august/subscriber.py b/homeassistant/components/august/subscriber.py index 5223b8b4a3..e0982fe9fb 100644 --- a/homeassistant/components/august/subscriber.py +++ b/homeassistant/components/august/subscriber.py @@ -38,7 +38,7 @@ class AugustSubscriberMixin: def _async_setup_listeners(self): """Create interval and stop listeners.""" self._unsub_interval = async_track_time_interval( - self._hass, self._async_refresh, self._update_interval + self._hass, self._async_refresh, self._update_interval, "august refresh" ) @callback diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index 1c16639d61..f1ffd6ecf5 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -98,7 +98,10 @@ class BaseHaScanner(ABC): self._start_time = self._last_detection = MONOTONIC_TIME() if not self._cancel_watchdog: self._cancel_watchdog = async_track_time_interval( - self.hass, self._async_scanner_watchdog, SCANNER_WATCHDOG_INTERVAL + self.hass, + self._async_scanner_watchdog, + SCANNER_WATCHDOG_INTERVAL, + f"{self.name} Bluetooth scanner watchdog", ) @hass_callback @@ -224,7 +227,10 @@ class BaseHaRemoteScanner(BaseHaScanner): self._async_expire_devices(dt_util.utcnow()) cancel_track = async_track_time_interval( - self.hass, self._async_expire_devices, timedelta(seconds=30) + self.hass, + self._async_expire_devices, + timedelta(seconds=30), + f"{self.name} Bluetooth scanner device expire", ) cancel_stop = self.hass.bus.async_listen( EVENT_HOMEASSISTANT_STOP, self._async_save_history diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index bc21051656..7932520b45 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -276,6 +276,7 @@ class BluetoothManager: self.hass, self._async_check_unavailable, timedelta(seconds=UNAVAILABLE_TRACK_SECONDS), + "Bluetooth manager unavailable tracking", ) @hass_callback diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index 8c9fef6bd7..d00646d6ff 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -174,7 +174,10 @@ class BondEntity(Entity): self._bpup_subs.subscribe(self._device_id, self._async_bpup_callback) self.async_on_remove( async_track_time_interval( - self.hass, self._async_update_if_bpup_not_alive, _FALLBACK_SCAN_INTERVAL + self.hass, + self._async_update_if_bpup_not_alive, + _FALLBACK_SCAN_INTERVAL, + f"Bond {self.entity_id} fallback polling", ) ) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index e368779e94..673009268c 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -379,7 +379,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: entity.async_update_token() entity.async_write_ha_state() - unsub = async_track_time_interval(hass, update_tokens, TOKEN_CHANGE_INTERVAL) + unsub = async_track_time_interval( + hass, update_tokens, TOKEN_CHANGE_INTERVAL, "Camera update tokens" + ) @callback def unsub_track_time_interval(_event: Event) -> None: diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index bc792ee892..af70939ee6 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -423,7 +423,12 @@ def async_setup_scanner_platform( hass.async_create_task(async_see_device(**kwargs)) - async_track_time_interval(hass, async_device_tracker_scan, interval) + async_track_time_interval( + hass, + async_device_tracker_scan, + interval, + f"device_tracker {platform} legacy scan", + ) hass.async_create_task(async_device_tracker_scan(None)) diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index 74dd1f66ab..ea5a5fb79a 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -260,7 +260,7 @@ class NetworkWatcher(WatcherBase): """Start scanning for new devices on the network.""" self._discover_hosts = DiscoverHosts() self._unsub = async_track_time_interval( - self.hass, self.async_start_discover, SCAN_INTERVAL + self.hass, self.async_start_discover, SCAN_INTERVAL, "DHCP network watcher" ) self.async_start_discover() diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 4814e7833c..9e56c7c24e 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -272,6 +272,7 @@ class HKDevice: self.hass, self.async_update_available_state, timedelta(seconds=BLE_AVAILABILITY_CHECK_INTERVAL), + f"HomeKit Controller {self.unique_id} BLE availability check poll", ) ) # BLE devices always get an RSSI sensor as well @@ -286,7 +287,10 @@ class HKDevice: # in the log about concurrent polling. self.config_entry.async_on_unload( async_track_time_interval( - self.hass, self.async_request_update, self.pairing.poll_interval + self.hass, + self.async_request_update, + self.pairing.poll_interval, + f"HomeKit Controller {self.unique_id} availability check poll", ) ) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 3b92698c83..fbc929b17d 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -296,7 +296,10 @@ class Recorder(threading.Thread): run_immediately=True, ) self._queue_watcher = async_track_time_interval( - self.hass, self._async_check_queue, timedelta(minutes=10) + self.hass, + self._async_check_queue, + timedelta(minutes=10), + "Recorder queue watcher", ) @callback @@ -596,13 +599,19 @@ class Recorder(threading.Thread): # to prevent errors from unexpected disconnects if self.dialect_name != SupportedDialect.SQLITE: self._keep_alive_listener = async_track_time_interval( - self.hass, self._async_keep_alive, timedelta(seconds=KEEPALIVE_TIME) + self.hass, + self._async_keep_alive, + timedelta(seconds=KEEPALIVE_TIME), + "Recorder keep alive", ) # If the commit interval is not 0, we need to commit periodically if self.commit_interval: self._commit_listener = async_track_time_interval( - self.hass, self._async_commit, timedelta(seconds=self.commit_interval) + self.hass, + self._async_commit, + timedelta(seconds=self.commit_interval), + "Recorder commit", ) # Run nightly tasks at 4:12am diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index b7e28f2704..b7ed28885c 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -401,7 +401,7 @@ class Scanner: self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) self._cancel_scan = async_track_time_interval( - self.hass, self.async_scan, SCAN_INTERVAL + self.hass, self.async_scan, SCAN_INTERVAL, "SSDP scanner" ) # Trigger the initial-scan. diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 6687af6a27..7b7b809404 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -479,6 +479,7 @@ class EntityPlatform: self.hass, self._update_entity_states, self.scan_interval, + f"EntityPlatform poll {self.domain}.{self.platform_name}", ) def _entity_id_already_exists(self, entity_id: str) -> tuple[bool, bool]: diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 3ac715426e..a456e12fa1 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -1397,6 +1397,7 @@ def async_track_time_interval( hass: HomeAssistant, action: Callable[[datetime], Coroutine[Any, Any, None] | None], interval: timedelta, + name: str | None = None, ) -> CALLBACK_TYPE: """Add a listener that fires repetitively at every timedelta interval.""" remove: CALLBACK_TYPE @@ -1419,9 +1420,12 @@ def async_track_time_interval( ) hass.async_run_hass_job(job, now) - interval_listener_job = HassJob( - interval_listener, f"track time interval listener {interval}" - ) + if name: + job_name = f"{name}: track time interval {interval}" + else: + job_name = f"track time interval {interval}" + + interval_listener_job = HassJob(interval_listener, job_name) remove = async_track_point_in_utc_time(hass, interval_listener_job, next_interval()) def remove_listener() -> None: diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index 0263bd2868..e35a66ada8 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -216,7 +216,10 @@ class RestoreStateData: # Dump states periodically cancel_interval = async_track_time_interval( - self.hass, _async_dump_states, STATE_DUMP_INTERVAL + self.hass, + _async_dump_states, + STATE_DUMP_INTERVAL, + "RestoreStateData dump states", ) async def _async_dump_states_at_stop(*_: Any) -> None: diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 211663babc..7e84d634ef 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -3438,6 +3438,28 @@ async def test_track_time_interval(hass: HomeAssistant) -> None: assert len(specific_runs) == 2 +async def test_track_time_interval_name(hass: HomeAssistant) -> None: + """Test tracking time interval name. + + This test is to ensure that when a name is passed to async_track_time_interval, + that the name can be found in the TimerHandle when stringified. + """ + specific_runs = [] + unique_string = "xZ13" + unsub = async_track_time_interval( + hass, + callback(lambda x: specific_runs.append(x)), + timedelta(seconds=10), + unique_string, + ) + scheduled = getattr(hass.loop, "_scheduled") + assert any(handle for handle in scheduled if unique_string in str(handle)) + unsub() + + assert all(handle for handle in scheduled if unique_string not in str(handle)) + await hass.async_block_till_done() + + async def test_track_sunrise(hass: HomeAssistant) -> None: """Test track the sunrise.""" latitude = 32.87336 From 6d8eaa0beecee7d229929ccd6ec665ee24dde771 Mon Sep 17 00:00:00 2001 From: Luca Angemi Date: Sat, 25 Mar 2023 17:43:49 +0100 Subject: [PATCH 1240/2810] Add location field to calendar create_event service supported by Google Calendar and Local Calendar (#90098) * Update __init__.py * Update __init__.py * Update __init__.py * Update calendar.py * Update calendar.py * Update services.yaml * Update services.yaml * Update calendar.py * Update calendar.py * Update __init__.py * Update services.yaml * Update services.yaml * Update test_calendar.py * Update test_init.py * Update test_init.py * Update test_init.py * Update test_init.py * Update __init__.py * Update const.py * Address changes to service.yaml * Address changes to service.yaml * Update test_calendar.py * Update test_calendar.py * Update test_calendar.py * Update conftest.py * Update conftest.py * Update calendar.py * Update __init__.py --- homeassistant/components/calendar/__init__.py | 3 +++ homeassistant/components/calendar/services.yaml | 6 ++++++ homeassistant/components/google/__init__.py | 3 +++ homeassistant/components/google/calendar.py | 3 +++ homeassistant/components/google/const.py | 1 + homeassistant/components/google/services.yaml | 6 ++++++ homeassistant/components/local_calendar/calendar.py | 1 + tests/components/google/test_calendar.py | 2 ++ tests/components/google/test_init.py | 5 +++++ tests/components/local_calendar/conftest.py | 4 +++- tests/components/local_calendar/test_calendar.py | 4 ++++ 11 files changed, 37 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index d09a389ce8..9af3244656 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -42,6 +42,7 @@ from .const import ( EVENT_IN, EVENT_IN_DAYS, EVENT_IN_WEEKS, + EVENT_LOCATION, EVENT_RECURRENCE_ID, EVENT_RECURRENCE_RANGE, EVENT_RRULE, @@ -176,6 +177,7 @@ CREATE_EVENT_SCHEMA = vol.All( { vol.Required(EVENT_SUMMARY): cv.string, vol.Optional(EVENT_DESCRIPTION, default=""): cv.string, + vol.Optional(EVENT_LOCATION): cv.string, vol.Inclusive( EVENT_START_DATE, "dates", "Start and end dates must both be specified" ): cv.date, @@ -213,6 +215,7 @@ WEBSOCKET_EVENT_SCHEMA = vol.Schema( vol.Required(EVENT_END): vol.Any(cv.date, cv.datetime), vol.Required(EVENT_SUMMARY): cv.string, vol.Optional(EVENT_DESCRIPTION): cv.string, + vol.Optional(EVENT_LOCATION): cv.string, vol.Optional(EVENT_RRULE): _validate_rrule, }, _has_same_type(EVENT_START, EVENT_END), diff --git a/homeassistant/components/calendar/services.yaml b/homeassistant/components/calendar/services.yaml index dfe278a92d..5d1a3ccf0f 100644 --- a/homeassistant/components/calendar/services.yaml +++ b/homeassistant/components/calendar/services.yaml @@ -46,3 +46,9 @@ create_event: name: In description: Days or weeks that you want to create the event in. example: '{"days": 2} or {"weeks": 2}' + location: + name: Location + description: The location of the event. + example: "Conference Room - F123, Bldg. 002" + selector: + text: diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index 934b34c126..25993760d8 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -43,6 +43,7 @@ from .const import ( EVENT_IN, EVENT_IN_DAYS, EVENT_IN_WEEKS, + EVENT_LOCATION, EVENT_START_DATE, EVENT_START_DATETIME, EVENT_SUMMARY, @@ -116,6 +117,7 @@ ADD_EVENT_SERVICE_SCHEMA = vol.All( vol.Required(EVENT_CALENDAR_ID): cv.string, vol.Required(EVENT_SUMMARY): cv.string, vol.Optional(EVENT_DESCRIPTION, default=""): cv.string, + vol.Optional(EVENT_LOCATION, default=""): cv.string, vol.Inclusive( EVENT_START_DATE, "dates", "Start and end dates must both be specified" ): cv.date, @@ -290,6 +292,7 @@ async def async_setup_add_event_service( Event( summary=call.data[EVENT_SUMMARY], description=call.data[EVENT_DESCRIPTION], + location=call.data[EVENT_LOCATION], start=start, end=end, ), diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index d20155ad90..1e1072940a 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -24,6 +24,7 @@ from homeassistant.components.calendar import ( ENTITY_ID_FORMAT, EVENT_DESCRIPTION, EVENT_END, + EVENT_LOCATION, EVENT_RRULE, EVENT_START, EVENT_SUMMARY, @@ -507,6 +508,7 @@ class GoogleCalendarEntity( "start": start, "end": end, EVENT_DESCRIPTION: kwargs.get(EVENT_DESCRIPTION), + EVENT_LOCATION: kwargs.get(EVENT_LOCATION), } ) if rrule := kwargs.get(EVENT_RRULE): @@ -603,6 +605,7 @@ async def async_create_event(entity: GoogleCalendarEntity, call: ServiceCall) -> Event( summary=call.data[EVENT_SUMMARY], description=call.data[EVENT_DESCRIPTION], + location=call.data[EVENT_LOCATION], start=start, end=end, ), diff --git a/homeassistant/components/google/const.py b/homeassistant/components/google/const.py index 6a2c1974f6..add98441e3 100644 --- a/homeassistant/components/google/const.py +++ b/homeassistant/components/google/const.py @@ -38,6 +38,7 @@ EVENT_END_DATETIME = "end_date_time" EVENT_IN = "in" EVENT_IN_DAYS = "days" EVENT_IN_WEEKS = "weeks" +EVENT_LOCATION = "location" EVENT_START_DATE = "start_date" EVENT_START_DATETIME = "start_date_time" EVENT_SUMMARY = "summary" diff --git a/homeassistant/components/google/services.yaml b/homeassistant/components/google/services.yaml index a303ad7e18..e7eeef7594 100644 --- a/homeassistant/components/google/services.yaml +++ b/homeassistant/components/google/services.yaml @@ -103,3 +103,9 @@ create_event: example: '"days": 2 or "weeks": 2' selector: object: + location: + name: Location + description: The location of the event. Optional. + example: "Conference Room - F123, Bldg. 002" + selector: + text: diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index 9cb6878ca5..2905e98caa 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -196,4 +196,5 @@ def _get_calendar_event(event: Event) -> CalendarEvent: uid=event.uid, rrule=event.rrule.as_rrule_str() if event.rrule else None, recurrence_id=event.recurrence_id, + location=event.location, ) diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 8b544a828e..6d0ea7c51f 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -888,6 +888,7 @@ async def test_websocket_create( assert aioclient_mock.mock_calls[0][2] == { "summary": "Bastille Day Party", "description": None, + "location": None, "start": { "dateTime": "1997-07-14T11:00:00-06:00", "timeZone": "America/Regina", @@ -931,6 +932,7 @@ async def test_websocket_create_all_day( assert aioclient_mock.mock_calls[0][2] == { "summary": "Bastille Day Party", "description": None, + "location": None, "start": { "date": "1997-07-14", }, diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index eac3bff585..938dd2c28e 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -42,6 +42,7 @@ HassApi = Callable[[], Awaitable[dict[str, Any]]] TEST_EVENT_SUMMARY = "Test Summary" TEST_EVENT_DESCRIPTION = "Test Description" +TEST_EVENT_LOCATION = "Test Location" def assert_state(actual: State | None, expected: State | None) -> None: @@ -93,6 +94,7 @@ def add_event_call_service( **params, "summary": TEST_EVENT_SUMMARY, "description": TEST_EVENT_DESCRIPTION, + "location": TEST_EVENT_LOCATION, }, target=target, blocking=True, @@ -484,6 +486,7 @@ async def test_add_event_date_in_x( assert aioclient_mock.mock_calls[0][2] == { "summary": TEST_EVENT_SUMMARY, "description": TEST_EVENT_DESCRIPTION, + "location": TEST_EVENT_LOCATION, "start": {"date": start_date.date().isoformat()}, "end": {"date": end_date.date().isoformat()}, } @@ -524,6 +527,7 @@ async def test_add_event_date( assert aioclient_mock.mock_calls[0][2] == { "summary": TEST_EVENT_SUMMARY, "description": TEST_EVENT_DESCRIPTION, + "location": TEST_EVENT_LOCATION, "start": {"date": today.isoformat()}, "end": {"date": end_date.isoformat()}, } @@ -564,6 +568,7 @@ async def test_add_event_date_time( assert aioclient_mock.mock_calls[0][2] == { "summary": TEST_EVENT_SUMMARY, "description": TEST_EVENT_DESCRIPTION, + "location": TEST_EVENT_LOCATION, "start": { "dateTime": start_datetime.isoformat(timespec="seconds"), "timeZone": "America/Regina", diff --git a/tests/components/local_calendar/conftest.py b/tests/components/local_calendar/conftest.py index bde9c226ba..b083bbac78 100644 --- a/tests/components/local_calendar/conftest.py +++ b/tests/components/local_calendar/conftest.py @@ -108,7 +108,9 @@ def get_events_fixture(hass_client: ClientSessionGenerator) -> GetEventsFn: def event_fields(data: dict[str, str]) -> dict[str, str]: """Filter event API response to minimum fields.""" return { - k: data[k] for k in ["summary", "start", "end", "recurrence_id"] if data.get(k) + k: data[k] + for k in ["summary", "start", "end", "recurrence_id", "location"] + if data.get(k) } diff --git a/tests/components/local_calendar/test_calendar.py b/tests/components/local_calendar/test_calendar.py index 319a352f62..6bdb58cf65 100644 --- a/tests/components/local_calendar/test_calendar.py +++ b/tests/components/local_calendar/test_calendar.py @@ -873,6 +873,7 @@ async def test_create_event_service( "start_date_time": start_date_time, "end_date_time": end_date_time, "summary": "Bastille Day Party", + "location": "Test Location", }, target={"entity_id": TEST_ENTITY}, blocking=True, @@ -886,6 +887,7 @@ async def test_create_event_service( "summary": "Bastille Day Party", "start": {"dateTime": "1997-07-14T11:00:00-06:00"}, "end": {"dateTime": "1997-07-14T22:00:00-06:00"}, + "location": "Test Location", } ] @@ -895,6 +897,7 @@ async def test_create_event_service( "summary": "Bastille Day Party", "start": {"dateTime": "1997-07-14T11:00:00-06:00"}, "end": {"dateTime": "1997-07-14T22:00:00-06:00"}, + "location": "Test Location", } ] @@ -909,5 +912,6 @@ async def test_create_event_service( "summary": "Bastille Day Party", "start": {"dateTime": "1997-07-14T11:00:00-06:00"}, "end": {"dateTime": "1997-07-14T22:00:00-06:00"}, + "location": "Test Location", } ] From 7cbe705ebb8176ed378da9c615d77e4deae46dca Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 25 Mar 2023 18:00:15 +0100 Subject: [PATCH 1241/2810] Update vehicle to 1.0.0 (#90189) --- homeassistant/components/rdw/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rdw/manifest.json b/homeassistant/components/rdw/manifest.json index 2cb660921c..5ec3a6ae19 100644 --- a/homeassistant/components/rdw/manifest.json +++ b/homeassistant/components/rdw/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["vehicle==0.4.0"] + "requirements": ["vehicle==1.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e4b5a9999d..68cba63edf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2574,7 +2574,7 @@ uvcclient==0.11.0 vallox-websocket-api==3.0.0 # homeassistant.components.rdw -vehicle==0.4.0 +vehicle==1.0.0 # homeassistant.components.velbus velbus-aio==2023.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2e31358bca..a3e7cd970c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1832,7 +1832,7 @@ uvcclient==0.11.0 vallox-websocket-api==3.0.0 # homeassistant.components.rdw -vehicle==0.4.0 +vehicle==1.0.0 # homeassistant.components.velbus velbus-aio==2023.2.0 From 7bceedfc95f10ffb2ff6a0f68b87908ea25a3620 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Mar 2023 07:05:35 -1000 Subject: [PATCH 1242/2810] Bump sqlalchemy to 2.0.7 (#90256) --- homeassistant/components/recorder/manifest.json | 2 +- homeassistant/components/sql/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 4f87c19ca7..c64c38fb7e 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -6,5 +6,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["sqlalchemy==2.0.6", "fnvhash==0.1.0"] + "requirements": ["sqlalchemy==2.0.7", "fnvhash==0.1.0"] } diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 7513bbd8c7..2fed7d9794 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["sqlalchemy==2.0.6"] + "requirements": ["sqlalchemy==2.0.7"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0fcae3ec80..2f0cff1319 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -42,7 +42,7 @@ pyudev==0.23.2 pyyaml==6.0 requests==2.28.2 scapy==2.5.0 -sqlalchemy==2.0.6 +sqlalchemy==2.0.7 typing-extensions>=4.5.0,<5.0 ulid-transform==0.5.1 voluptuous-serialize==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 68cba63edf..cf804be500 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2398,7 +2398,7 @@ spotipy==2.22.1 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==2.0.6 +sqlalchemy==2.0.7 # homeassistant.components.srp_energy srpenergy==1.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a3e7cd970c..b0ee47f771 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1707,7 +1707,7 @@ spotipy==2.22.1 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==2.0.6 +sqlalchemy==2.0.7 # homeassistant.components.srp_energy srpenergy==1.3.6 From cc337c4ff6dbb648714a5d2df6aae9c016296034 Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Sat, 25 Mar 2023 18:09:33 +0100 Subject: [PATCH 1243/2810] Add Re-Auth to bmw_connected_drive (#90251) * Add Re-Auth to bmw_connected_drive * Always store refresh token to entry * Fix tests * Typo --------- Co-authored-by: rikroe --- .../bmw_connected_drive/config_flow.py | 42 ++++++++++++--- .../bmw_connected_drive/coordinator.py | 4 +- .../bmw_connected_drive/strings.json | 3 +- .../bmw_connected_drive/test_config_flow.py | 51 ++++++++++++++++++- 4 files changed, 89 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py index 4f05794e31..0cde37ba6b 100644 --- a/homeassistant/components/bmw_connected_drive/config_flow.py +++ b/homeassistant/components/bmw_connected_drive/config_flow.py @@ -1,6 +1,7 @@ """Config flow for BMW ConnectedDrive integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from bimmer_connected.api.authentication import MyBMWAuthentication @@ -55,36 +56,61 @@ class BMWConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 + _reauth_entry: config_entries.ConfigEntry | None = None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle the initial step.""" errors: dict[str, str] = {} + if user_input is not None: unique_id = f"{user_input[CONF_REGION]}-{user_input[CONF_USERNAME]}" - await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured() + if not self._reauth_entry: + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() info = None try: info = await validate_input(self.hass, user_input) + entry_data = { + **user_input, + CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN), + } except CannotConnect: errors["base"] = "cannot_connect" if info: + if self._reauth_entry: + self.hass.config_entries.async_update_entry( + self._reauth_entry, data=entry_data + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload( + self._reauth_entry.entry_id + ) + ) + return self.async_abort(reason="reauth_successful") + return self.async_create_entry( title=info["title"], - data={ - **user_input, - CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN), - }, + data=entry_data, ) - return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + schema = self.add_suggested_values_to_schema( + DATA_SCHEMA, self._reauth_entry.data if self._reauth_entry else {} ) + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle configuration by re-auth.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_user() + @staticmethod @callback def async_get_options_flow( diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py index 0f03505ff2..ae139d4c64 100644 --- a/homeassistant/components/bmw_connected_drive/coordinator.py +++ b/homeassistant/components/bmw_connected_drive/coordinator.py @@ -12,6 +12,7 @@ from httpx import HTTPError, HTTPStatusError, TimeoutException from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_READ_ONLY, CONF_REFRESH_TOKEN, DOMAIN @@ -65,8 +66,9 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]): 401, 403, ): - # Clear refresh token only on issues with authorization + # Clear refresh token only and trigger reauth self._update_config_entry_refresh_token(None) + raise ConfigEntryAuthFailed(str(err)) from err raise UpdateFailed(f"Error communicating with BMW API: {err}") from err if self.account.refresh_token != old_refresh_token: diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json index 3e93cccb8c..506175becd 100644 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -14,7 +14,8 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "options": { diff --git a/tests/components/bmw_connected_drive/test_config_flow.py b/tests/components/bmw_connected_drive/test_config_flow.py index e441be8826..4db57ad302 100644 --- a/tests/components/bmw_connected_drive/test_config_flow.py +++ b/tests/components/bmw_connected_drive/test_config_flow.py @@ -1,4 +1,5 @@ """Test the for the BMW Connected Drive config flow.""" +from copy import deepcopy from unittest.mock import patch from bimmer_connected.api.authentication import MyBMWAuthentication @@ -10,7 +11,7 @@ from homeassistant.components.bmw_connected_drive.const import ( CONF_READ_ONLY, CONF_REFRESH_TOKEN, ) -from homeassistant.const import CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from . import FIXTURE_CONFIG_ENTRY, FIXTURE_REFRESH_TOKEN, FIXTURE_USER_INPUT @@ -110,3 +111,51 @@ async def test_options_flow_implementation(hass: HomeAssistant) -> None: } assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth(hass: HomeAssistant) -> None: + """Test the reauth form.""" + with patch( + "bimmer_connected.api.authentication.MyBMWAuthentication.login", + side_effect=login_sideeffect, + autospec=True, + ), patch( + "homeassistant.components.bmw_connected_drive.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + wrong_password = "wrong" + + config_entry_with_wrong_password = deepcopy(FIXTURE_CONFIG_ENTRY) + config_entry_with_wrong_password["data"][CONF_PASSWORD] = wrong_password + + config_entry = MockConfigEntry(**config_entry_with_wrong_password) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.data == config_entry_with_wrong_password["data"] + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": config_entry.unique_id, + "entry_id": config_entry.entry_id, + }, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert config_entry.data == FIXTURE_COMPLETE_ENTRY + + assert len(mock_setup_entry.mock_calls) == 1 From fd460996ba8f618a828b847cd9b09082c5cb23dc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Mar 2023 09:21:05 -1000 Subject: [PATCH 1244/2810] Bump onvif-zeep-async to 1.2.2 to fix memory leak (#90216) --- homeassistant/components/onvif/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index db9f76189d..4b998bdd6c 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==1.2.1", "WSDiscovery==2.0.0"] + "requirements": ["onvif-zeep-async==1.2.2", "WSDiscovery==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index cf804be500..6c51034376 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1263,7 +1263,7 @@ ondilo==0.2.0 onkyo-eiscp==1.2.7 # homeassistant.components.onvif -onvif-zeep-async==1.2.1 +onvif-zeep-async==1.2.2 # homeassistant.components.opengarage open-garage==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0ee47f771..c4c70e7be2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -935,7 +935,7 @@ omnilogic==0.4.5 ondilo==0.2.0 # homeassistant.components.onvif -onvif-zeep-async==1.2.1 +onvif-zeep-async==1.2.2 # homeassistant.components.opengarage open-garage==0.2.0 From 668b2726fe599972aa9ca96b0c5e3d4f962c4839 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Mar 2023 10:12:48 -1000 Subject: [PATCH 1245/2810] Bump yalexs-ble to 2.1.4 (#90276) --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 7bbc6f042e..4e05224495 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.2"] + "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.4"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index bb95a70386..37f148a45c 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.1.2"] + "requirements": ["yalexs-ble==2.1.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6c51034376..3e6213bca1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2671,7 +2671,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.2 +yalexs-ble==2.1.4 # homeassistant.components.august yalexs==1.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c4c70e7be2..1a572d5ccb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1905,7 +1905,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.2 +yalexs-ble==2.1.4 # homeassistant.components.august yalexs==1.2.7 From 5c839e23679e78fc951d797180d66c2158113bf1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 25 Mar 2023 23:06:03 +0100 Subject: [PATCH 1246/2810] Add entity name translations to Elgato (#89629) --- homeassistant/components/elgato/button.py | 4 +-- homeassistant/components/elgato/sensor.py | 10 +++--- homeassistant/components/elgato/strings.json | 35 +++++++++++++++++++ homeassistant/components/elgato/switch.py | 4 +-- .../elgato/snapshots/test_button.ambr | 4 +-- .../elgato/snapshots/test_sensor.ambr | 10 +++--- .../elgato/snapshots/test_switch.ambr | 4 +-- 7 files changed, 53 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/elgato/button.py b/homeassistant/components/elgato/button.py index 0dd602f1ec..97673a79b9 100644 --- a/homeassistant/components/elgato/button.py +++ b/homeassistant/components/elgato/button.py @@ -40,14 +40,14 @@ class ElgatoButtonEntityDescription( BUTTONS = [ ElgatoButtonEntityDescription( key="identify", - name="Identify", + translation_key="identify", icon="mdi:help", entity_category=EntityCategory.CONFIG, press_fn=lambda client: client.identify(), ), ElgatoButtonEntityDescription( key="restart", - name="Restart", + translation_key="restart", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, press_fn=lambda client: client.restart(), diff --git a/homeassistant/components/elgato/sensor.py b/homeassistant/components/elgato/sensor.py index 2692cf1085..371840de01 100644 --- a/homeassistant/components/elgato/sensor.py +++ b/homeassistant/components/elgato/sensor.py @@ -45,7 +45,7 @@ class ElgatoSensorEntityDescription( SENSORS = [ ElgatoSensorEntityDescription( key="battery", - name="Battery", + translation_key="battery", device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, @@ -56,7 +56,7 @@ SENSORS = [ ), ElgatoSensorEntityDescription( key="voltage", - name="Battery voltage", + translation_key="voltage", entity_registry_enabled_default=False, device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, @@ -69,7 +69,7 @@ SENSORS = [ ), ElgatoSensorEntityDescription( key="input_charge_current", - name="Charging current", + translation_key="input_charge_current", entity_registry_enabled_default=False, device_class=SensorDeviceClass.CURRENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -82,7 +82,7 @@ SENSORS = [ ), ElgatoSensorEntityDescription( key="charge_power", - name="Charging power", + translation_key="charge_power", entity_registry_enabled_default=False, device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, @@ -94,7 +94,7 @@ SENSORS = [ ), ElgatoSensorEntityDescription( key="input_charge_voltage", - name="Charging voltage", + translation_key="input_charge_voltage", entity_registry_enabled_default=False, device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/elgato/strings.json b/homeassistant/components/elgato/strings.json index fc0007ac30..c5fc016aeb 100644 --- a/homeassistant/components/elgato/strings.json +++ b/homeassistant/components/elgato/strings.json @@ -21,5 +21,40 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } + }, + "entity": { + "button": { + "identify": { + "name": "Identify" + }, + "restart": { + "name": "[%key:component::button::entity_component::restart::name%]" + } + }, + "sensor": { + "battery": { + "name": "[%key:component::sensor::entity_component::battery::name%]" + }, + "charge_power": { + "name": "Charging power" + }, + "input_charge_current": { + "name": "Charging current" + }, + "input_charge_voltage": { + "name": "Charging voltage" + }, + "voltage": { + "name": "Battery voltage" + } + }, + "switch": { + "bypass": { + "name": "Studio mode" + }, + "energy_saving": { + "name": "Energy saving" + } + } } } diff --git a/homeassistant/components/elgato/switch.py b/homeassistant/components/elgato/switch.py index 0015909971..78af3adfa5 100644 --- a/homeassistant/components/elgato/switch.py +++ b/homeassistant/components/elgato/switch.py @@ -39,7 +39,7 @@ class ElgatoSwitchEntityDescription( SWITCHES = [ ElgatoSwitchEntityDescription( key="bypass", - name="Studio mode", + translation_key="bypass", icon="mdi:battery-off-outline", entity_category=EntityCategory.CONFIG, has_fn=lambda x: x.battery is not None, @@ -48,7 +48,7 @@ SWITCHES = [ ), ElgatoSwitchEntityDescription( key="energy_saving", - name="Energy saving", + translation_key="energy_saving", icon="mdi:leaf", entity_category=EntityCategory.CONFIG, has_fn=lambda x: x.battery is not None, diff --git a/tests/components/elgato/snapshots/test_button.ambr b/tests/components/elgato/snapshots/test_button.ambr index 900a3f316f..cb420c486b 100644 --- a/tests/components/elgato/snapshots/test_button.ambr +++ b/tests/components/elgato/snapshots/test_button.ambr @@ -37,7 +37,7 @@ 'original_name': 'Identify', 'platform': 'elgato', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'identify', 'unique_id': 'GW24L1A02987_identify', 'unit_of_measurement': None, }) @@ -111,7 +111,7 @@ 'original_name': 'Restart', 'platform': 'elgato', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'restart', 'unique_id': 'GW24L1A02987_restart', 'unit_of_measurement': None, }) diff --git a/tests/components/elgato/snapshots/test_sensor.ambr b/tests/components/elgato/snapshots/test_sensor.ambr index fa22ca1dfa..35429b8a32 100644 --- a/tests/components/elgato/snapshots/test_sensor.ambr +++ b/tests/components/elgato/snapshots/test_sensor.ambr @@ -44,7 +44,7 @@ 'original_name': 'Battery', 'platform': 'elgato', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'battery', 'unique_id': 'GW24L1A02987_battery', 'unit_of_measurement': '%', }) @@ -128,7 +128,7 @@ 'original_name': 'Battery voltage', 'platform': 'elgato', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'voltage', 'unique_id': 'GW24L1A02987_voltage', 'unit_of_measurement': , }) @@ -212,7 +212,7 @@ 'original_name': 'Charging current', 'platform': 'elgato', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'input_charge_current', 'unique_id': 'GW24L1A02987_input_charge_current', 'unit_of_measurement': , }) @@ -293,7 +293,7 @@ 'original_name': 'Charging power', 'platform': 'elgato', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'charge_power', 'unique_id': 'GW24L1A02987_charge_power', 'unit_of_measurement': , }) @@ -377,7 +377,7 @@ 'original_name': 'Charging voltage', 'platform': 'elgato', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'input_charge_voltage', 'unique_id': 'GW24L1A02987_input_charge_voltage', 'unit_of_measurement': , }) diff --git a/tests/components/elgato/snapshots/test_switch.ambr b/tests/components/elgato/snapshots/test_switch.ambr index 02f32d22f9..dcba00c0a9 100644 --- a/tests/components/elgato/snapshots/test_switch.ambr +++ b/tests/components/elgato/snapshots/test_switch.ambr @@ -37,7 +37,7 @@ 'original_name': 'Energy saving', 'platform': 'elgato', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'energy_saving', 'unique_id': 'GW24L1A02987_energy_saving', 'unit_of_measurement': None, }) @@ -111,7 +111,7 @@ 'original_name': 'Studio mode', 'platform': 'elgato', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'bypass', 'unique_id': 'GW24L1A02987_bypass', 'unit_of_measurement': None, }) From 89d00ac733588e90ef9cc25730eebf19f1d3e7d0 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 25 Mar 2023 23:31:01 +0100 Subject: [PATCH 1247/2810] Fix default ipv6 resolver (#90269) --- homeassistant/components/dnsip/const.py | 2 +- tests/components/dnsip/test_config_flow.py | 14 +++++++------- tests/components/dnsip/test_init.py | 2 +- tests/components/dnsip/test_sensor.py | 4 ++-- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/dnsip/const.py b/homeassistant/components/dnsip/const.py index a4f2c2fee2..56215d3d9a 100644 --- a/homeassistant/components/dnsip/const.py +++ b/homeassistant/components/dnsip/const.py @@ -15,4 +15,4 @@ DEFAULT_HOSTNAME = "myip.opendns.com" DEFAULT_IPV6 = False DEFAULT_NAME = "myip" DEFAULT_RESOLVER = "208.67.222.222" -DEFAULT_RESOLVER_IPV6 = "2620:0:ccc::2" +DEFAULT_RESOLVER_IPV6 = "2620:119:53::53" diff --git a/tests/components/dnsip/test_config_flow.py b/tests/components/dnsip/test_config_flow.py index 990fd4df15..7e219326ee 100644 --- a/tests/components/dnsip/test_config_flow.py +++ b/tests/components/dnsip/test_config_flow.py @@ -60,7 +60,7 @@ async def test_form(hass: HomeAssistant) -> None: } assert result2["options"] == { "resolver": "208.67.222.222", - "resolver_ipv6": "2620:0:ccc::2", + "resolver_ipv6": "2620:119:53::53", } assert len(mock_setup_entry.mock_calls) == 1 @@ -87,7 +87,7 @@ async def test_form_adv(hass: HomeAssistant) -> None: { CONF_HOSTNAME: "home-assistant.io", CONF_RESOLVER: "8.8.8.8", - CONF_RESOLVER_IPV6: "2620:0:ccc::2", + CONF_RESOLVER_IPV6: "2620:119:53::53", }, ) await hass.async_block_till_done() @@ -102,7 +102,7 @@ async def test_form_adv(hass: HomeAssistant) -> None: } assert result2["options"] == { "resolver": "8.8.8.8", - "resolver_ipv6": "2620:0:ccc::2", + "resolver_ipv6": "2620:119:53::53", } assert len(mock_setup_entry.mock_calls) == 1 @@ -143,7 +143,7 @@ async def test_flow_already_exist(hass: HomeAssistant) -> None: }, options={ CONF_RESOLVER: "208.67.222.222", - CONF_RESOLVER_IPV6: "2620:0:ccc::2", + CONF_RESOLVER_IPV6: "2620:119:53::5", }, unique_id="home-assistant.io", ).add_to_hass(hass) @@ -185,7 +185,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: }, options={ CONF_RESOLVER: "208.67.222.222", - CONF_RESOLVER_IPV6: "2620:0:ccc::2", + CONF_RESOLVER_IPV6: "2620:119:53::5", }, ) entry.add_to_hass(hass) @@ -227,7 +227,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: CONF_HOSTNAME: "home-assistant.io", CONF_NAME: "home-assistant.io", CONF_RESOLVER: "208.67.222.222", - CONF_RESOLVER_IPV6: "2620:0:ccc::2", + CONF_RESOLVER_IPV6: "2620:119:53::5", CONF_IPV4: True, CONF_IPV6: False, }, @@ -235,7 +235,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: CONF_HOSTNAME: "home-assistant.io", CONF_NAME: "home-assistant.io", CONF_RESOLVER: "208.67.222.222", - CONF_RESOLVER_IPV6: "2620:0:ccc::2", + CONF_RESOLVER_IPV6: "2620:119:53::5", CONF_IPV4: False, CONF_IPV6: True, }, diff --git a/tests/components/dnsip/test_init.py b/tests/components/dnsip/test_init.py index 1c8cf04c78..2869f13ca8 100644 --- a/tests/components/dnsip/test_init.py +++ b/tests/components/dnsip/test_init.py @@ -34,7 +34,7 @@ async def test_load_unload_entry(hass: HomeAssistant) -> None: }, options={ CONF_RESOLVER: "208.67.222.222", - CONF_RESOLVER_IPV6: "2620:0:ccc::2", + CONF_RESOLVER_IPV6: "2620:119:53::53", }, entry_id="1", unique_id="home-assistant.io", diff --git a/tests/components/dnsip/test_sensor.py b/tests/components/dnsip/test_sensor.py index f44d58d212..75e5f5ebf8 100644 --- a/tests/components/dnsip/test_sensor.py +++ b/tests/components/dnsip/test_sensor.py @@ -37,7 +37,7 @@ async def test_sensor(hass: HomeAssistant) -> None: }, options={ CONF_RESOLVER: "208.67.222.222", - CONF_RESOLVER_IPV6: "2620:0:ccc::2", + CONF_RESOLVER_IPV6: "2620:119:53::53", }, entry_id="1", unique_id="home-assistant.io", @@ -71,7 +71,7 @@ async def test_sensor_no_response(hass: HomeAssistant) -> None: }, options={ CONF_RESOLVER: "208.67.222.222", - CONF_RESOLVER_IPV6: "2620:0:ccc::2", + CONF_RESOLVER_IPV6: "2620:119:53::53", }, entry_id="1", unique_id="home-assistant.io", From 7f6406127ead60ebeecfab5f3921a1c5556fbd42 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 25 Mar 2023 23:43:44 +0100 Subject: [PATCH 1248/2810] Remove platform yaml radiotherm (#90284) --- .../components/radiotherm/climate.py | 70 +------------------ .../components/radiotherm/config_flow.py | 19 ----- .../components/radiotherm/strings.json | 6 -- .../components/radiotherm/test_config_flow.py | 39 +---------- 4 files changed, 3 insertions(+), 131 deletions(-) diff --git a/homeassistant/components/radiotherm/climate.py b/homeassistant/components/radiotherm/climate.py index a800061b58..2c71eac019 100644 --- a/homeassistant/components/radiotherm/climate.py +++ b/homeassistant/components/radiotherm/climate.py @@ -1,17 +1,14 @@ """Support for Radio Thermostat wifi-enabled home thermostats.""" from __future__ import annotations -import logging from typing import Any import radiotherm -import voluptuous as vol from homeassistant.components.climate import ( FAN_AUTO, FAN_OFF, FAN_ON, - PLATFORM_SCHEMA, PRESET_AWAY, PRESET_HOME, ClimateEntity, @@ -19,25 +16,15 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - ATTR_TEMPERATURE, - CONF_HOST, - PRECISION_HALVES, - UnitOfTemperature, -) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN from .coordinator import RadioThermUpdateCoordinator from .entity import RadioThermostatEntity -_LOGGER = logging.getLogger(__name__) - ATTR_FAN_ACTION = "fan_action" PRESET_HOLIDAY = "holiday" @@ -102,14 +89,6 @@ def round_temp(temperature): return round(temperature * 2.0) / 2.0 -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_HOST): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_HOLD_TEMP, default=False): cv.boolean, - } -) - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, @@ -120,51 +99,6 @@ async def async_setup_entry( async_add_entities([RadioThermostat(coordinator)]) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Radio Thermostat.""" - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2022.9.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - _LOGGER.warning( - "Configuration of the Radio Thermostat climate platform in YAML is deprecated" - " and will be removed in Home Assistant 2022.9; Your existing configuration has" - " been imported into the UI automatically and can be safely removed from your" - " configuration.yaml file" - ) - - hosts: list[str] = [] - if CONF_HOST in config: - hosts = config[CONF_HOST] - else: - hosts.append( - await hass.async_add_executor_job(radiotherm.discover.discover_address) - ) - - if not hosts: - _LOGGER.error("No Radiotherm Thermostats detected") - return - - for host in hosts: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_HOST: host}, - ) - ) - - class RadioThermostat(RadioThermostatEntity, ClimateEntity): """Representation of a Radio Thermostat.""" diff --git a/homeassistant/components/radiotherm/config_flow.py b/homeassistant/components/radiotherm/config_flow.py index a3acc2e438..ca488ade46 100644 --- a/homeassistant/components/radiotherm/config_flow.py +++ b/homeassistant/components/radiotherm/config_flow.py @@ -83,25 +83,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): description_placeholders=placeholders, ) - async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult: - """Import from yaml.""" - host = import_info[CONF_HOST] - self._async_abort_entries_match({CONF_HOST: host}) - _LOGGER.debug("Importing entry for host: %s", host) - try: - init_data = await validate_connection(self.hass, host) - except CannotConnect as ex: - _LOGGER.debug("Importing failed for %s", host, exc_info=ex) - return self.async_abort(reason="cannot_connect") - await self.async_set_unique_id(init_data.mac, raise_on_progress=False) - self._abort_if_unique_id_configured( - updates={CONF_HOST: host}, reload_on_update=False - ) - return self.async_create_entry( - title=init_data.name, - data={CONF_HOST: import_info[CONF_HOST]}, - ) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/radiotherm/strings.json b/homeassistant/components/radiotherm/strings.json index f0b31cdb4d..21f53d72bf 100644 --- a/homeassistant/components/radiotherm/strings.json +++ b/homeassistant/components/radiotherm/strings.json @@ -19,12 +19,6 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, - "issues": { - "deprecated_yaml": { - "title": "The Radio Thermostat YAML configuration is being removed", - "description": "Configuring the Radio Thermostat climate platform using YAML is being removed in Home Assistant 2022.9.\n\nYour existing configuration has been imported into the UI automatically. Remove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } - }, "options": { "step": { "init": { diff --git a/tests/components/radiotherm/test_config_flow.py b/tests/components/radiotherm/test_config_flow.py index 053bca0aa6..5625a50a4c 100644 --- a/tests/components/radiotherm/test_config_flow.py +++ b/tests/components/radiotherm/test_config_flow.py @@ -1,5 +1,5 @@ """Test the Radio Thermostat config flow.""" -import socket + from unittest.mock import MagicMock, patch from radiotherm import CommonThermostat @@ -98,43 +98,6 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: assert result2["errors"] == {CONF_HOST: "cannot_connect"} -async def test_import(hass: HomeAssistant) -> None: - """Test we get can import from yaml.""" - with patch( - "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", - return_value=_mock_radiotherm(), - ), patch( - "homeassistant.components.radiotherm.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_HOST: "1.2.3.4"}, - ) - - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["title"] == "My Name" - assert result["data"] == {CONF_HOST: "1.2.3.4"} - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_cannot_connect(hass: HomeAssistant) -> None: - """Test we abort if we cannot connect on import from yaml.""" - with patch( - "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", - side_effect=socket.timeout, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_HOST: "1.2.3.4"}, - ) - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "cannot_connect" - - async def test_dhcp_can_confirm(hass: HomeAssistant) -> None: """Test DHCP discovery flow can confirm right away.""" From 92beb48a415333d793e2bc1bdefe987876ef8230 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 26 Mar 2023 00:24:43 +0100 Subject: [PATCH 1249/2810] Add sensor platform to Sun (#81045) * Sun sensor * remove extra attr * Add tests * Add back attributes * position sensors disabled default * entity id * unique id * test init to attributes * Fix test init * Fix test sensor * test unique id * uom * remove rising * Remove not needed uom property * Fix reload issue * degree --- homeassistant/components/sun/__init__.py | 13 ++- homeassistant/components/sun/sensor.py | 133 +++++++++++++++++++++++ tests/components/sun/test_init.py | 7 +- tests/components/sun/test_sensor.py | 101 +++++++++++++++++ 4 files changed, 246 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/sun/sensor.py create mode 100644 tests/components/sun/test_sensor.py diff --git a/homeassistant/components/sun/__init__.py b/homeassistant/components/sun/__init__.py index 65836e0c61..a43bf4fd80 100644 --- a/homeassistant/components/sun/__init__.py +++ b/homeassistant/components/sun/__init__.py @@ -12,6 +12,7 @@ from homeassistant.const import ( EVENT_CORE_CONFIG_UPDATE, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, + Platform, ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers import event @@ -97,15 +98,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # we will create entities before firing EVENT_COMPONENT_LOADED await async_process_integration_platform_for_component(hass, DOMAIN) hass.data[DOMAIN] = Sun(hass) + await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR]) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - sun = hass.data.pop(DOMAIN) - sun.remove_listeners() - hass.states.async_remove(sun.entity_id) - return True + if unload_ok := await hass.config_entries.async_unload_platforms( + entry, [Platform.SENSOR] + ): + sun: Sun = hass.data.pop(DOMAIN) + sun.remove_listeners() + hass.states.async_remove(sun.entity_id) + return unload_ok class Sun(Entity): diff --git a/homeassistant/components/sun/sensor.py b/homeassistant/components/sun/sensor.py new file mode 100644 index 0000000000..527ccc4069 --- /dev/null +++ b/homeassistant/components/sun/sensor.py @@ -0,0 +1,133 @@ +"""Sensor platform for Sun integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEGREE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import Sun +from .const import DOMAIN + +ENTITY_ID_SENSOR_FORMAT = SENSOR_DOMAIN + ".sun_{}" + + +@dataclass +class SunEntityDescriptionMixin: + """Mixin for required Sun base description keys.""" + + value_fn: Callable[[Sun], StateType | datetime] + + +@dataclass +class SunSensorEntityDescription(SensorEntityDescription, SunEntityDescriptionMixin): + """Describes Sun sensor entity.""" + + +SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( + SunSensorEntityDescription( + key="next_dawn", + device_class=SensorDeviceClass.TIMESTAMP, + name="Next dawn", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_dawn, + ), + SunSensorEntityDescription( + key="next_dusk", + device_class=SensorDeviceClass.TIMESTAMP, + name="Next dusk", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_dusk, + ), + SunSensorEntityDescription( + key="next_midnight", + device_class=SensorDeviceClass.TIMESTAMP, + name="Next midnight", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_midnight, + ), + SunSensorEntityDescription( + key="next_noon", + device_class=SensorDeviceClass.TIMESTAMP, + name="Next noon", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_noon, + ), + SunSensorEntityDescription( + key="next_rising", + device_class=SensorDeviceClass.TIMESTAMP, + name="Next rising", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_rising, + ), + SunSensorEntityDescription( + key="next_setting", + device_class=SensorDeviceClass.TIMESTAMP, + name="Next setting", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_setting, + ), + SunSensorEntityDescription( + key="solar_elevation", + name="Solar elevation", + icon="mdi:theme-light-dark", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.solar_elevation, + entity_registry_enabled_default=False, + native_unit_of_measurement=DEGREE, + ), + SunSensorEntityDescription( + key="solar_azimuth", + name="Solar azimuth", + icon="mdi:sun-angle", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.solar_azimuth, + entity_registry_enabled_default=False, + native_unit_of_measurement=DEGREE, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Sun sensor platform.""" + + sun: Sun = hass.data[DOMAIN] + + async_add_entities( + [SunSensor(sun, description, entry.entry_id) for description in SENSOR_TYPES] + ) + + +class SunSensor(SensorEntity): + """Representation of a Sun Sensor.""" + + entity_description: SunSensorEntityDescription + + def __init__( + self, sun: Sun, entity_description: SunSensorEntityDescription, entry_id: str + ) -> None: + """Initiate Sun Sensor.""" + self.entity_description = entity_description + self.entity_id = ENTITY_ID_SENSOR_FORMAT.format(entity_description.key) + self._attr_unique_id = f"{entry_id}-{entity_description.key}" + self.sun = sun + + @property + def native_value(self) -> StateType | datetime: + """Return value of sensor.""" + state = self.entity_description.value_fn(self.sun) + return state diff --git a/tests/components/sun/test_init.py b/tests/components/sun/test_init.py index 2795330bf7..fef9bd4e04 100644 --- a/tests/components/sun/test_init.py +++ b/tests/components/sun/test_init.py @@ -5,10 +5,9 @@ from unittest.mock import patch from freezegun import freeze_time import pytest -import homeassistant.components.sun as sun +from homeassistant.components import sun from homeassistant.const import EVENT_STATE_CHANGED -import homeassistant.core as ha -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -196,7 +195,7 @@ async def test_state_change_count(hass: HomeAssistant) -> None: events = [] - @ha.callback + @callback def state_change_listener(event): if event.data.get("entity_id") == "sun.sun": events.append(event) diff --git a/tests/components/sun/test_sensor.py b/tests/components/sun/test_sensor.py new file mode 100644 index 0000000000..13f4fd0d62 --- /dev/null +++ b/tests/components/sun/test_sensor.py @@ -0,0 +1,101 @@ +"""The tests for the Sun sensor platform.""" +from datetime import datetime, timedelta + +from astral import LocationInfo +import astral.sun +from freezegun import freeze_time + +from homeassistant.components import sun +from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util + + +async def test_setting_rising(hass: HomeAssistant) -> None: + """Test retrieving sun setting and rising.""" + utc_now = datetime(2016, 11, 1, 8, 0, 0, tzinfo=dt_util.UTC) + with freeze_time(utc_now): + await async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) + + await hass.async_block_till_done() + + utc_today = utc_now.date() + + location = LocationInfo( + latitude=hass.config.latitude, longitude=hass.config.longitude + ) + + mod = -1 + while True: + next_dawn = astral.sun.dawn( + location.observer, date=utc_today + timedelta(days=mod) + ) + if next_dawn > utc_now: + break + mod += 1 + + mod = -1 + while True: + next_dusk = astral.sun.dusk( + location.observer, date=utc_today + timedelta(days=mod) + ) + if next_dusk > utc_now: + break + mod += 1 + + mod = -1 + while True: + next_midnight = astral.sun.midnight( + location.observer, date=utc_today + timedelta(days=mod) + ) + if next_midnight > utc_now: + break + mod += 1 + + mod = -1 + while True: + next_noon = astral.sun.noon( + location.observer, date=utc_today + timedelta(days=mod) + ) + if next_noon > utc_now: + break + mod += 1 + + mod = -1 + while True: + next_rising = astral.sun.sunrise( + location.observer, date=utc_today + timedelta(days=mod) + ) + if next_rising > utc_now: + break + mod += 1 + + mod = -1 + while True: + next_setting = astral.sun.sunset( + location.observer, date=utc_today + timedelta(days=mod) + ) + if next_setting > utc_now: + break + mod += 1 + + state1 = hass.states.get("sensor.sun_next_dawn") + state2 = hass.states.get("sensor.sun_next_dusk") + state3 = hass.states.get("sensor.sun_next_midnight") + state4 = hass.states.get("sensor.sun_next_noon") + state5 = hass.states.get("sensor.sun_next_rising") + state6 = hass.states.get("sensor.sun_next_setting") + assert next_dawn.replace(microsecond=0) == dt_util.parse_datetime(state1.state) + assert next_dusk.replace(microsecond=0) == dt_util.parse_datetime(state2.state) + assert next_midnight.replace(microsecond=0) == dt_util.parse_datetime(state3.state) + assert next_noon.replace(microsecond=0) == dt_util.parse_datetime(state4.state) + assert next_rising.replace(microsecond=0) == dt_util.parse_datetime(state5.state) + assert next_setting.replace(microsecond=0) == dt_util.parse_datetime(state6.state) + + entry_ids = hass.config_entries.async_entries("sun") + + entity_reg = er.async_get(hass) + entity = entity_reg.async_get("sensor.sun_next_dawn") + + assert entity.unique_id == f"{entry_ids[0].entry_id}-next_dawn" From 255f12ec05d7e70f9617df78af1a2153f08c32dd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Mar 2023 15:48:03 -1000 Subject: [PATCH 1250/2810] Bump bleak-retry-connector to 3.0.2 (#90279) changelog: https://github.com/Bluetooth-Devices/bleak-retry-connector/compare/v3.0.1...v3.0.2 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index f6cbe5b3e5..87a584f0be 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -16,7 +16,7 @@ "quality_scale": "internal", "requirements": [ "bleak==0.20.0", - "bleak-retry-connector==3.0.1", + "bleak-retry-connector==3.0.2", "bluetooth-adapters==0.15.3", "bluetooth-auto-recovery==1.0.3", "bluetooth-data-tools==0.3.1", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2f0cff1319..4af85540de 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -10,7 +10,7 @@ atomicwrites-homeassistant==1.4.1 attrs==22.2.0 awesomeversion==22.9.0 bcrypt==4.0.1 -bleak-retry-connector==3.0.1 +bleak-retry-connector==3.0.2 bleak==0.20.0 bluetooth-adapters==0.15.3 bluetooth-auto-recovery==1.0.3 diff --git a/requirements_all.txt b/requirements_all.txt index 3e6213bca1..c1a05be4e5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -431,7 +431,7 @@ bimmer_connected==0.13.0 bizkaibus==0.1.1 # homeassistant.components.bluetooth -bleak-retry-connector==3.0.1 +bleak-retry-connector==3.0.2 # homeassistant.components.bluetooth bleak==0.20.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1a572d5ccb..f2c15b7771 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -361,7 +361,7 @@ bellows==0.34.10 bimmer_connected==0.13.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.0.1 +bleak-retry-connector==3.0.2 # homeassistant.components.bluetooth bleak==0.20.0 From 53726cb4a194f61d8a9284ce7b64ff6b6743e6c6 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 26 Mar 2023 04:02:10 +0200 Subject: [PATCH 1251/2810] Remove Magicseaweed (#90277) --- .coveragerc | 1 - .../components/magicseaweed/__init__.py | 1 - .../components/magicseaweed/manifest.json | 9 - .../components/magicseaweed/sensor.py | 227 ------------------ .../components/magicseaweed/strings.json | 8 - homeassistant/generated/integrations.json | 6 - requirements_all.txt | 3 - 7 files changed, 255 deletions(-) delete mode 100644 homeassistant/components/magicseaweed/__init__.py delete mode 100644 homeassistant/components/magicseaweed/manifest.json delete mode 100644 homeassistant/components/magicseaweed/sensor.py delete mode 100644 homeassistant/components/magicseaweed/strings.json diff --git a/.coveragerc b/.coveragerc index 24f324313a..f3dbc34791 100644 --- a/.coveragerc +++ b/.coveragerc @@ -678,7 +678,6 @@ omit = homeassistant/components/lyric/api.py homeassistant/components/lyric/climate.py homeassistant/components/lyric/sensor.py - homeassistant/components/magicseaweed/sensor.py homeassistant/components/mailgun/notify.py homeassistant/components/map/* homeassistant/components/mastodon/notify.py diff --git a/homeassistant/components/magicseaweed/__init__.py b/homeassistant/components/magicseaweed/__init__.py deleted file mode 100644 index 848d02967f..0000000000 --- a/homeassistant/components/magicseaweed/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The magicseaweed component.""" diff --git a/homeassistant/components/magicseaweed/manifest.json b/homeassistant/components/magicseaweed/manifest.json deleted file mode 100644 index 4858e6be4f..0000000000 --- a/homeassistant/components/magicseaweed/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "magicseaweed", - "name": "Magicseaweed", - "codeowners": [], - "documentation": "https://www.home-assistant.io/integrations/magicseaweed", - "iot_class": "cloud_polling", - "loggers": ["magicseaweed"], - "requirements": ["magicseaweed==1.0.3"] -} diff --git a/homeassistant/components/magicseaweed/sensor.py b/homeassistant/components/magicseaweed/sensor.py deleted file mode 100644 index aa59553ef8..0000000000 --- a/homeassistant/components/magicseaweed/sensor.py +++ /dev/null @@ -1,227 +0,0 @@ -"""Support for magicseaweed data from magicseaweed.com.""" -from __future__ import annotations - -from datetime import timedelta -import logging - -import magicseaweed -import voluptuous as vol - -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, - SensorEntity, - SensorEntityDescription, -) -from homeassistant.const import CONF_API_KEY, CONF_MONITORED_CONDITIONS, CONF_NAME -from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle -import homeassistant.util.dt as dt_util -from homeassistant.util.unit_system import METRIC_SYSTEM - -_LOGGER = logging.getLogger(__name__) - -CONF_HOURS = "hours" -CONF_SPOT_ID = "spot_id" -CONF_UNITS = "units" - -DEFAULT_UNIT = "us" -DEFAULT_NAME = "MSW" - -ICON = "mdi:waves" - -HOURS = ["12AM", "3AM", "6AM", "9AM", "12PM", "3PM", "6PM", "9PM"] - -SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( - key="max_breaking_swell", - name="Max", - ), - SensorEntityDescription( - key="min_breaking_swell", - name="Min", - ), - SensorEntityDescription( - key="swell_forecast", - name="Forecast", - ), -) - -SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] - - -UNITS = ["eu", "uk", "us"] - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_MONITORED_CONDITIONS): vol.All( - cv.ensure_list, [vol.In(SENSOR_KEYS)] - ), - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_SPOT_ID): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_HOURS, default=None): vol.All( - cv.ensure_list, [vol.In(HOURS)] - ), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_UNITS): vol.In(UNITS), - } -) - -# Return cached results if last scan was less then this time ago. -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Magicseaweed sensor.""" - create_issue( - hass, - "magicseaweed", - "pending_removal", - breaks_in_ha_version="2023.3.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="pending_removal", - ) - _LOGGER.warning( - "The Magicseaweed integration is deprecated" - " and will be removed in Home Assistant 2023.3" - ) - - name = config.get(CONF_NAME) - spot_id = config[CONF_SPOT_ID] - api_key = config[CONF_API_KEY] - hours = config.get(CONF_HOURS) - - if CONF_UNITS in config: - units = config.get(CONF_UNITS) - elif hass.config.units is METRIC_SYSTEM: - units = UNITS[0] - else: - units = UNITS[2] - - forecast_data = MagicSeaweedData(api_key=api_key, spot_id=spot_id, units=units) - forecast_data.update() - - # If connection failed don't setup platform. - if forecast_data.currently is None or forecast_data.hourly is None: - return - - monitored_conditions = config[CONF_MONITORED_CONDITIONS] - sensors = [ - MagicSeaweedSensor(forecast_data, name, units, description) - for description in SENSOR_TYPES - if description.key in monitored_conditions - ] - if hours is not None: - sensors.extend( - [ - MagicSeaweedSensor(forecast_data, name, units, description, hour) - for description in SENSOR_TYPES - if description.key in monitored_conditions - and "forecast" not in description.key - for hour in hours - ] - ) - add_entities(sensors, True) - - -class MagicSeaweedSensor(SensorEntity): - """Implementation of a MagicSeaweed sensor.""" - - _attr_attribution = "Data provided by magicseaweed.com" - _attr_icon = ICON - - def __init__( - self, - forecast_data, - name, - unit_system, - description: SensorEntityDescription, - hour=None, - ) -> None: - """Initialize the sensor.""" - self.entity_description = description - self.client_name = name - self.data = forecast_data - self.hour = hour - self._unit_system = unit_system - - if hour is None and "forecast" in description.key: - self._attr_name = f"{name} {description.name}" - elif hour is None: - self._attr_name = f"Current {name} {description.name}" - else: - self._attr_name = f"{hour} {name} {description.name}" - - self._attr_extra_state_attributes = {} - - @property - def unit_system(self): - """Return the unit system of this entity.""" - return self._unit_system - - def update(self) -> None: - """Get the latest data from Magicseaweed and updates the states.""" - self.data.update() - if self.hour is None: - forecast = self.data.currently - else: - forecast = self.data.hourly[self.hour] - - self._attr_native_unit_of_measurement = forecast.swell_unit - sensor_type = self.entity_description.key - if sensor_type == "min_breaking_swell": - self._attr_native_value = forecast.swell_minBreakingHeight - elif sensor_type == "max_breaking_swell": - self._attr_native_value = forecast.swell_maxBreakingHeight - elif sensor_type == "swell_forecast": - summary = ( - f"{forecast.swell_minBreakingHeight} -" - f" {forecast.swell_maxBreakingHeight}" - ) - self._attr_native_value = summary - if self.hour is None: - for hour, data in self.data.hourly.items(): - occurs = hour - hr_summary = ( - f"{data.swell_minBreakingHeight} -" - f" {data.swell_maxBreakingHeight} {data.swell_unit}" - ) - self._attr_extra_state_attributes[occurs] = hr_summary - - if sensor_type != "swell_forecast": - self._attr_extra_state_attributes.update(forecast.attrs) - - -class MagicSeaweedData: - """Get the latest data from MagicSeaweed.""" - - def __init__(self, api_key, spot_id, units): - """Initialize the data object.""" - self._msw = magicseaweed.MSW_Forecast(api_key, spot_id, None, units) - self.currently = None - self.hourly = {} - - # Apply throttling to methods using configured interval - self.update = Throttle(MIN_TIME_BETWEEN_UPDATES)(self._update) - - def _update(self): - """Get the latest data from MagicSeaweed.""" - try: - forecasts = self._msw.get_future() - self.currently = forecasts.data[0] - for forecast in forecasts.data[:8]: - hour = dt_util.utc_from_timestamp(forecast.localTimestamp).strftime( - "%-I%p" - ) - self.hourly[hour] = forecast - except ConnectionError: - _LOGGER.error("Unable to retrieve data from Magicseaweed") diff --git a/homeassistant/components/magicseaweed/strings.json b/homeassistant/components/magicseaweed/strings.json deleted file mode 100644 index 0aa8a58419..0000000000 --- a/homeassistant/components/magicseaweed/strings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "issues": { - "pending_removal": { - "title": "The Magicseaweed integration is being removed", - "description": "The Magicseaweed integration is pending removal from Home Assistant and will no longer be available as of Home Assistant 2023.3.\n\nRemove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } - } -} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index efd1899c5b..843b8ed006 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3078,12 +3078,6 @@ "config_flow": false, "iot_class": "local_polling" }, - "magicseaweed": { - "name": "Magicseaweed", - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_polling" - }, "mailgun": { "name": "Mailgun", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index c1a05be4e5..5da7cafec3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1092,9 +1092,6 @@ lxml==4.9.1 # homeassistant.components.nmap_tracker mac-vendor-lookup==0.1.12 -# homeassistant.components.magicseaweed -magicseaweed==1.0.3 - # homeassistant.components.matrix matrix-client==0.4.0 From 5f59bab9ecb75fd5686899f4d9cedd1c3d63168f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Mar 2023 16:29:26 -1000 Subject: [PATCH 1252/2810] Bump bleak to 0.20.1 (#90282) Co-authored-by: Charles Garwood --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 87a584f0be..31b9bdb5d5 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -15,7 +15,7 @@ ], "quality_scale": "internal", "requirements": [ - "bleak==0.20.0", + "bleak==0.20.1", "bleak-retry-connector==3.0.2", "bluetooth-adapters==0.15.3", "bluetooth-auto-recovery==1.0.3", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4af85540de..c91ab060b5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -11,7 +11,7 @@ attrs==22.2.0 awesomeversion==22.9.0 bcrypt==4.0.1 bleak-retry-connector==3.0.2 -bleak==0.20.0 +bleak==0.20.1 bluetooth-adapters==0.15.3 bluetooth-auto-recovery==1.0.3 bluetooth-data-tools==0.3.1 diff --git a/requirements_all.txt b/requirements_all.txt index 5da7cafec3..8517577dd1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -434,7 +434,7 @@ bizkaibus==0.1.1 bleak-retry-connector==3.0.2 # homeassistant.components.bluetooth -bleak==0.20.0 +bleak==0.20.1 # homeassistant.components.blebox blebox_uniapi==2.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f2c15b7771..2cd15e9af9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -364,7 +364,7 @@ bimmer_connected==0.13.0 bleak-retry-connector==3.0.2 # homeassistant.components.bluetooth -bleak==0.20.0 +bleak==0.20.1 # homeassistant.components.blebox blebox_uniapi==2.1.4 From bd08d888123e17bb1b21550267355003613b26ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Mar 2023 17:27:35 -1000 Subject: [PATCH 1253/2810] Bump yalexs-ble to 2.1.5 (#90287) Bump yalexs-ble 2.1.5 Some of the lever locks need a bit longer debounce time since they still report stale state for up to 6s changelog: https://github.com/bdraco/yalexs-ble/compare/v2.1.4...v2.1.5 --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 4e05224495..3db09bdc34 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.4"] + "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.5"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 37f148a45c..ba314ecf2a 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.1.4"] + "requirements": ["yalexs-ble==2.1.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8517577dd1..66edf53480 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2668,7 +2668,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.4 +yalexs-ble==2.1.5 # homeassistant.components.august yalexs==1.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2cd15e9af9..bf5998ed50 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1905,7 +1905,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.4 +yalexs-ble==2.1.5 # homeassistant.components.august yalexs==1.2.7 From 0b8fb36a7e6183309b621ef4f8bf00e7569e80ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Mar 2023 17:28:38 -1000 Subject: [PATCH 1254/2810] Fix onvif binary sensors (#90202) * Fix httpx client creating a new ssl context with each client While working on https://github.com/home-assistant/core/issues/83524 it was discovered that each new httpx client creates a new ssl context https://github.com/encode/httpx/blob/f1157dbc4102ac8e227a0a0bb12a877f592eff58/httpx/_transports/default.py#L261 If an ssl context is passed in creating a new one is avoided here https://github.com/encode/httpx/blob/f1157dbc4102ac8e227a0a0bb12a877f592eff58/httpx/_config.py#L110 This change makes httpx ssl no-verify behavior match aiohttp ssl no-verify behavior https://github.com/aio-libs/aiohttp/blob/6da04694fd87a39af9c3856048c9ff23ca815f88/aiohttp/connector.py#L892 aiohttp solved this by wrapping the code that generates the ssl context in an lru_cache * compact * Fix onvif binary sensors fixes #83524 needs https://github.com/hunterjm/python-onvif-zeep-async/pull/9 first to avoid recreating the memory leak * Fix memory leak in onvif Work around until https://github.com/hunterjm/python-onvif-zeep-async/pull/9 followup to https://github.com/home-assistant/core/pull/83006 * move check * onvif-zeep-async 1.2.2 * fix unloading --- homeassistant/components/onvif/event.py | 58 +++++++++++++++---------- 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index 54c5b3b007..84d75bf804 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -27,6 +27,13 @@ SUBSCRIPTION_ERRORS = ( ) +def _stringify_onvif_error(error: Exception) -> str: + """Stringify ONVIF error.""" + if isinstance(error, Fault): + return error.message or str(error) or "Device sent empty error" + return str(error) + + class EventManager: """ONVIF Event Manager.""" @@ -79,30 +86,30 @@ class EventManager: async def async_start(self) -> bool: """Start polling events.""" - if await self.device.create_pullpoint_subscription(): - # Create subscription manager - self._subscription = self.device.create_subscription_service( - "PullPointSubscription" - ) + if not await self.device.create_pullpoint_subscription(): + return False - # Renew immediately - await self.async_renew() + # Create subscription manager + self._subscription = self.device.create_subscription_service( + "PullPointSubscription" + ) - # Initialize events - pullpoint = self.device.create_pullpoint_service() - with suppress(*SUBSCRIPTION_ERRORS): - await pullpoint.SetSynchronizationPoint() - response = await pullpoint.PullMessages( - {"MessageLimit": 100, "Timeout": dt.timedelta(seconds=5)} - ) + # Renew immediately + await self.async_renew() - # Parse event initialization - await self.async_parse_messages(response.NotificationMessage) + # Initialize events + pullpoint = self.device.create_pullpoint_service() + with suppress(*SUBSCRIPTION_ERRORS): + await pullpoint.SetSynchronizationPoint() + response = await pullpoint.PullMessages( + {"MessageLimit": 100, "Timeout": dt.timedelta(seconds=5)} + ) - self.started = True - return True + # Parse event initialization + await self.async_parse_messages(response.NotificationMessage) - return False + self.started = True + return True async def async_stop(self) -> None: """Unsubscribe from events.""" @@ -112,7 +119,8 @@ class EventManager: if not self._subscription: return - await self._subscription.Unsubscribe() + with suppress(*SUBSCRIPTION_ERRORS): + await self._subscription.Unsubscribe() self._subscription = None async def async_restart(self, _now: dt.datetime | None = None) -> None: @@ -148,7 +156,7 @@ class EventManager: "Retrying later: %s" ), self.unique_id, - err, + _stringify_onvif_error(err), ) if not restarted: @@ -170,7 +178,11 @@ class EventManager: .isoformat(timespec="seconds") .replace("+00:00", "Z") ) - await self._subscription.Renew(termination_time) + with suppress(*SUBSCRIPTION_ERRORS): + # The first time we renew, we may get a Fault error so we + # suppress it. The subscription will be restarted in + # async_restart later. + await self._subscription.Renew(termination_time) def async_schedule_pull(self) -> None: """Schedule async_pull_messages to run.""" @@ -203,7 +215,7 @@ class EventManager: " '%s': %s" ), self.unique_id, - err, + _stringify_onvif_error(err), ) # Treat errors as if the camera restarted. Assume that the pullpoint # subscription is no longer valid. From 40131d811c1f0af9040a9f46de25b47a8bce576c Mon Sep 17 00:00:00 2001 From: Felix Rotthowe Date: Sun, 26 Mar 2023 09:35:49 +0200 Subject: [PATCH 1255/2810] Handle Livisi TokenExpiredException (#90258) * reauth * Request new Token on TokenExpiredException * relogin using stored auth data * fix imports * import formatting --- homeassistant/components/livisi/coordinator.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/livisi/coordinator.py b/homeassistant/components/livisi/coordinator.py index f745a66e82..56e928307c 100644 --- a/homeassistant/components/livisi/coordinator.py +++ b/homeassistant/components/livisi/coordinator.py @@ -6,6 +6,7 @@ from typing import Any from aiohttp import ClientConnectorError from aiolivisi import AioLivisi, LivisiEvent, Websocket +from aiolivisi.errors import TokenExpiredException from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -55,8 +56,11 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): """Get device configuration from LIVISI.""" try: return await self.async_get_devices() + except TokenExpiredException: + await self.aiolivisi.async_set_token(self.aiolivisi.livisi_connection_data) + return await self.async_get_devices() except ClientConnectorError as exc: - raise UpdateFailed("Failed to get LIVISI the devices") from exc + raise UpdateFailed("Failed to get livisi devices from controller") from exc def _async_dispatcher_send(self, event: str, source: str, data: Any) -> None: if data is not None: From b3f3f234c69cf276f0cea34150aa3726e5d3b77d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 26 Mar 2023 09:40:07 +0200 Subject: [PATCH 1256/2810] Remove pushbullet platform yaml import (#90285) Depr pushbullet yaml --- .../components/pushbullet/config_flow.py | 5 -- homeassistant/components/pushbullet/notify.py | 30 ++---------- homeassistant/components/pushbullet/sensor.py | 49 ++----------------- .../components/pushbullet/strings.json | 6 --- .../components/pushbullet/test_config_flow.py | 13 ----- 5 files changed, 6 insertions(+), 97 deletions(-) diff --git a/homeassistant/components/pushbullet/config_flow.py b/homeassistant/components/pushbullet/config_flow.py index e6259fa8ce..1eca2bd890 100644 --- a/homeassistant/components/pushbullet/config_flow.py +++ b/homeassistant/components/pushbullet/config_flow.py @@ -24,11 +24,6 @@ CONFIG_SCHEMA = vol.Schema( class PushBulletConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for pushbullet integration.""" - async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: - """Handle import from config.""" - import_config[CONF_NAME] = import_config.get(CONF_NAME, DEFAULT_NAME) - return await self.async_step_user(import_config) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/pushbullet/notify.py b/homeassistant/components/pushbullet/notify.py index fcc9d00dc7..1cc851bdb9 100644 --- a/homeassistant/components/pushbullet/notify.py +++ b/homeassistant/components/pushbullet/notify.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging import mimetypes -from typing import Any +from typing import TYPE_CHECKING, Any from pushbullet import PushBullet, PushError from pushbullet.channel import Channel @@ -15,23 +15,16 @@ from homeassistant.components.notify import ( ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, - PLATFORM_SCHEMA, BaseNotificationService, ) -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ATTR_FILE, ATTR_FILE_URL, ATTR_URL, DOMAIN _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Required(CONF_API_KEY): cv.string}) - async def async_get_service( hass: HomeAssistant, @@ -39,25 +32,8 @@ async def async_get_service( discovery_info: DiscoveryInfoType | None = None, ) -> PushBulletNotificationService | None: """Get the Pushbullet notification service.""" - if discovery_info is None: - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2023.2.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - return None - + if TYPE_CHECKING: + assert discovery_info is not None pushbullet: PushBullet = hass.data[DOMAIN][discovery_info["entry_id"]].pushbullet return PushBulletNotificationService(hass, pushbullet) diff --git a/homeassistant/components/pushbullet/sensor.py b/homeassistant/components/pushbullet/sensor.py index aef97991c6..b61469f6b2 100644 --- a/homeassistant/components/pushbullet/sensor.py +++ b/homeassistant/components/pushbullet/sensor.py @@ -1,23 +1,14 @@ """Pushbullet platform for sensor component.""" from __future__ import annotations -import voluptuous as vol - -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, - SensorEntity, - SensorEntityDescription, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_MONITORED_CONDITIONS, CONF_NAME +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .api import PushBulletNotificationProvider from .const import DATA_UPDATED, DOMAIN @@ -75,40 +66,6 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS, default=["title", "body"]): vol.All( - cv.ensure_list, vol.Length(min=1), [vol.In(SENSOR_KEYS)] - ), - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Pushbullet Sensor platform.""" - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2023.2.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback diff --git a/homeassistant/components/pushbullet/strings.json b/homeassistant/components/pushbullet/strings.json index 92d22d117d..a6571ae7bf 100644 --- a/homeassistant/components/pushbullet/strings.json +++ b/homeassistant/components/pushbullet/strings.json @@ -15,11 +15,5 @@ } } } - }, - "issues": { - "deprecated_yaml": { - "title": "The Pushbullet YAML configuration is being removed", - "description": "Configuring Pushbullet using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Pushbullet YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/tests/components/pushbullet/test_config_flow.py b/tests/components/pushbullet/test_config_flow.py index a19c424c8b..f250c22c44 100644 --- a/tests/components/pushbullet/test_config_flow.py +++ b/tests/components/pushbullet/test_config_flow.py @@ -119,16 +119,3 @@ async def test_flow_conn_error(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" assert result["errors"] == {"base": "cannot_connect"} - - -async def test_import(hass: HomeAssistant, requests_mock_fixture) -> None: - """Test user initialized flow with unreachable server.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_CONFIG, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "pushbullet" - assert result["data"] == MOCK_CONFIG From e8f3b9c09a58972f3f2af83b0c85f8f001db6523 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 26 Mar 2023 09:42:38 +0200 Subject: [PATCH 1257/2810] Remove Volvooncall integration yaml import (#90288) Depr yaml import --- .../components/volvooncall/__init__.py | 73 +------------------ .../components/volvooncall/config_flow.py | 4 - .../components/volvooncall/strings.json | 6 -- .../volvooncall/test_config_flow.py | 39 ---------- 4 files changed, 1 insertion(+), 121 deletions(-) diff --git a/homeassistant/components/volvooncall/__init__.py b/homeassistant/components/volvooncall/__init__.py index b6d97dea21..ab4fa78111 100644 --- a/homeassistant/components/volvooncall/__init__.py +++ b/homeassistant/components/volvooncall/__init__.py @@ -4,28 +4,21 @@ import logging from aiohttp.client_exceptions import ClientResponseError import async_timeout -import voluptuous as vol from volvooncall import Connection from volvooncall.dashboard import Instrument -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONF_NAME, CONF_PASSWORD, CONF_REGION, - CONF_RESOURCES, - CONF_SCAN_INTERVAL, CONF_UNIT_SYSTEM, CONF_USERNAME, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -35,11 +28,9 @@ from homeassistant.helpers.update_coordinator import ( from .const import ( CONF_MUTABLE, CONF_SCANDINAVIAN_MILES, - CONF_SERVICE_URL, DEFAULT_UPDATE_INTERVAL, DOMAIN, PLATFORMS, - RESOURCES, UNIT_SYSTEM_IMPERIAL, UNIT_SYSTEM_METRIC, UNIT_SYSTEM_SCANDINAVIAN_MILES, @@ -49,68 +40,6 @@ from .errors import InvalidAuth _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_UPDATE_INTERVAL - ): vol.All(cv.time_period, vol.Clamp(min=DEFAULT_UPDATE_INTERVAL)), - vol.Optional(CONF_NAME, default={}): cv.schema_with_slug_keys( - cv.string - ), - vol.Optional(CONF_RESOURCES): vol.All( - cv.ensure_list, [vol.In(RESOURCES)] - ), - vol.Optional(CONF_REGION): cv.string, - vol.Optional(CONF_SERVICE_URL): cv.string, - vol.Optional(CONF_MUTABLE, default=True): cv.boolean, - vol.Optional(CONF_SCANDINAVIAN_MILES, default=False): cv.boolean, - } - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Migrate from YAML to ConfigEntry.""" - if DOMAIN not in config: - return True - - hass.data[DOMAIN] = {} - - if not hass.config_entries.async_entries(DOMAIN): - new_conf = {} - new_conf[CONF_USERNAME] = config[DOMAIN][CONF_USERNAME] - new_conf[CONF_PASSWORD] = config[DOMAIN][CONF_PASSWORD] - new_conf[CONF_REGION] = config[DOMAIN].get(CONF_REGION) - new_conf[CONF_SCANDINAVIAN_MILES] = config[DOMAIN][CONF_SCANDINAVIAN_MILES] - new_conf[CONF_MUTABLE] = config[DOMAIN][CONF_MUTABLE] - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=new_conf - ) - ) - - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version=None, - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - - return True - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the Volvo On Call component from a ConfigEntry.""" diff --git a/homeassistant/components/volvooncall/config_flow.py b/homeassistant/components/volvooncall/config_flow.py index c1b3ab3f66..d56d10ded5 100644 --- a/homeassistant/components/volvooncall/config_flow.py +++ b/homeassistant/components/volvooncall/config_flow.py @@ -106,10 +106,6 @@ class VolvoOnCallConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=user_schema, errors=errors ) - async def async_step_import(self, import_data) -> FlowResult: - """Import volvooncall config from configuration.yaml.""" - return await self.async_step_user(import_data) - async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" self._reauth_entry = self.hass.config_entries.async_get_entry( diff --git a/homeassistant/components/volvooncall/strings.json b/homeassistant/components/volvooncall/strings.json index 9e8471b04b..44b821b4b0 100644 --- a/homeassistant/components/volvooncall/strings.json +++ b/homeassistant/components/volvooncall/strings.json @@ -19,11 +19,5 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } - }, - "issues": { - "deprecated_yaml": { - "title": "The Volvo On Call YAML configuration is being removed", - "description": "Configuring the Volvo On Call platform using YAML is being removed in a future release of Home Assistant.\n\nYour existing configuration has been imported into the UI automatically. Remove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/tests/components/volvooncall/test_config_flow.py b/tests/components/volvooncall/test_config_flow.py index 549dc9d440..c8ed92d8ee 100644 --- a/tests/components/volvooncall/test_config_flow.py +++ b/tests/components/volvooncall/test_config_flow.py @@ -130,45 +130,6 @@ async def test_form_other_exception(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "unknown"} -async def test_import(hass: HomeAssistant) -> None: - """Test a YAML import.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT} - ) - assert result["type"] == FlowResultType.FORM - assert len(result["errors"]) == 0 - - with patch("volvooncall.Connection.get"), patch( - "homeassistant.components.volvooncall.async_setup", - return_value=True, - ), patch( - "homeassistant.components.volvooncall.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - "region": "na", - "unit_system": "metric", - "mutable": True, - }, - ) - await hass.async_block_till_done() - - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "test-username" - assert result2["data"] == { - "username": "test-username", - "password": "test-password", - "region": "na", - "unit_system": "metric", - "mutable": True, - } - assert len(mock_setup_entry.mock_calls) == 1 - - async def test_reauth(hass: HomeAssistant) -> None: """Test that we handle the reauth flow.""" From a0b6da33ab4c2c11d1832342828ae041eaf5e91b Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sun, 26 Mar 2023 09:57:13 +0200 Subject: [PATCH 1258/2810] Strict typing of UniFi integration (#90278) * Fix typing of UniFi controller * Strict typing of unifi.__init__ * Strict typing of UniFi config_flow * Strict typing of UniFi switch * Strict typing UniFi sensor * Strict typing UniFi device tracker * Strict typing of UniFi * Fix library issues related to typing --- .strict-typing | 2 +- homeassistant/components/unifi/__init__.py | 2 +- homeassistant/components/unifi/controller.py | 90 ++++++++++--------- .../components/unifi/device_tracker.py | 4 +- homeassistant/components/unifi/manifest.json | 2 +- homeassistant/components/unifi/switch.py | 5 +- mypy.ini | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 9 files changed, 57 insertions(+), 54 deletions(-) diff --git a/.strict-typing b/.strict-typing index 9db9500892..533d5239ca 100644 --- a/.strict-typing +++ b/.strict-typing @@ -311,7 +311,7 @@ homeassistant.components.trafikverket_train.* homeassistant.components.trafikverket_weatherstation.* homeassistant.components.tts.* homeassistant.components.twentemilieu.* -homeassistant.components.unifi.update +homeassistant.components.unifi.* homeassistant.components.unifiprotect.* homeassistant.components.upcloud.* homeassistant.components.update.* diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index d6405d1171..a7e8aede36 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -64,7 +64,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload a config entry.""" - controller = hass.data[UNIFI_DOMAIN].pop(config_entry.entry_id) + controller: UniFiController = hass.data[UNIFI_DOMAIN].pop(config_entry.entry_id) if not hass.data[UNIFI_DOMAIN]: async_unload_services(hass) diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index a5f3c4d772..60507d5a8c 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -10,7 +10,7 @@ from typing import Any from aiohttp import CookieJar import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent -from aiounifi.websocket import WebsocketSignal, WebsocketState +from aiounifi.websocket import WebsocketState import async_timeout from homeassistant.config_entries import ConfigEntry @@ -22,7 +22,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, Platform, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers import ( aiohttp_client, device_registry as dr, @@ -75,31 +75,32 @@ CHECK_HEARTBEAT_INTERVAL = timedelta(seconds=1) class UniFiController: """Manages a single UniFi Network instance.""" - def __init__(self, hass, config_entry, api): + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, api: aiounifi.Controller + ) -> None: """Initialize the system.""" self.hass = hass self.config_entry = config_entry self.api = api - api.callback = self.async_unifi_signalling_callback + api.ws_state_callback = self.async_unifi_ws_state_callback self.available = True self.wireless_clients = hass.data[UNIFI_WIRELESS_CLIENTS] self.site_id: str = "" - self._site_name = None - self._site_role = None + self._site_name: str | None = None + self._site_role: str | None = None - self._cancel_heartbeat_check = None - self._heartbeat_dispatch = {} - self._heartbeat_time = {} + self._cancel_heartbeat_check: CALLBACK_TYPE | None = None + self._heartbeat_time: dict[str, datetime] = {} self.load_config_entry_options() - self.entities = {} + self.entities: dict[str, str] = {} self.known_objects: set[tuple[str, str]] = set() - def load_config_entry_options(self): + def load_config_entry_options(self) -> None: """Store attributes to avoid property call overhead since they are called frequently.""" options = self.config_entry.options @@ -114,7 +115,7 @@ class UniFiController: CONF_TRACK_WIRED_CLIENTS, DEFAULT_TRACK_WIRED_CLIENTS ) # Config entry option to not track devices. - self.option_track_devices = options.get( + self.option_track_devices: bool = options.get( CONF_TRACK_DEVICES, DEFAULT_TRACK_DEVICES ) # Config entry option listing what SSIDs are being used to track clients. @@ -133,43 +134,45 @@ class UniFiController: # Config entry option with list of clients to control network access. self.option_block_clients = options.get(CONF_BLOCK_CLIENT, []) # Config entry option to control DPI restriction groups. - self.option_dpi_restrictions = options.get( + self.option_dpi_restrictions: bool = options.get( CONF_DPI_RESTRICTIONS, DEFAULT_DPI_RESTRICTIONS ) # Statistics sensor options # Config entry option to allow bandwidth sensors. - self.option_allow_bandwidth_sensors = options.get( + self.option_allow_bandwidth_sensors: bool = options.get( CONF_ALLOW_BANDWIDTH_SENSORS, DEFAULT_ALLOW_BANDWIDTH_SENSORS ) # Config entry option to allow uptime sensors. - self.option_allow_uptime_sensors = options.get( + self.option_allow_uptime_sensors: bool = options.get( CONF_ALLOW_UPTIME_SENSORS, DEFAULT_ALLOW_UPTIME_SENSORS ) @property - def host(self): + def host(self) -> str: """Return the host of this controller.""" - return self.config_entry.data[CONF_HOST] + host: str = self.config_entry.data[CONF_HOST] + return host @property - def site(self): + def site(self) -> str: """Return the site of this config entry.""" - return self.config_entry.data[CONF_SITE_ID] + site_id: str = self.config_entry.data[CONF_SITE_ID] + return site_id @property - def site_name(self): + def site_name(self) -> str | None: """Return the nice name of site.""" return self._site_name @property - def site_role(self): + def site_role(self) -> str | None: """Return the site user role of this controller.""" return self._site_role @property - def mac(self): + def mac(self) -> str | None: """Return the mac address of this controller.""" for client in self.api.clients.values(): if self.host == client.ip: @@ -227,22 +230,21 @@ class UniFiController: async_load_entities(description) @callback - def async_unifi_signalling_callback(self, signal, data): + def async_unifi_ws_state_callback(self, state: WebsocketState) -> None: """Handle messages back from UniFi library.""" - if signal == WebsocketSignal.CONNECTION_STATE: - if data == WebsocketState.DISCONNECTED and self.available: - LOGGER.warning("Lost connection to UniFi Network") + if state == WebsocketState.DISCONNECTED and self.available: + LOGGER.warning("Lost connection to UniFi Network") - if (data == WebsocketState.RUNNING and not self.available) or ( - data == WebsocketState.DISCONNECTED and self.available - ): - self.available = data == WebsocketState.RUNNING - async_dispatcher_send(self.hass, self.signal_reachable) + if (state == WebsocketState.RUNNING and not self.available) or ( + state == WebsocketState.DISCONNECTED and self.available + ): + self.available = state == WebsocketState.RUNNING + async_dispatcher_send(self.hass, self.signal_reachable) - if not self.available: - self.hass.loop.call_later(RETRY_TIMER, self.reconnect, True) - else: - LOGGER.info("Connected to UniFi Network") + if not self.available: + self.hass.loop.call_later(RETRY_TIMER, self.reconnect, True) + else: + LOGGER.info("Connected to UniFi Network") @property def signal_reachable(self) -> str: @@ -259,7 +261,7 @@ class UniFiController: """Event specific per UniFi device tracker to signal new heartbeat missed.""" return "unifi-heartbeat-missed" - async def initialize(self): + async def initialize(self) -> None: """Set up a UniFi Network instance.""" await self.api.initialize() @@ -291,7 +293,7 @@ class UniFiController: continue client = self.api.clients_all[mac] - self.api.clients.process_raw([client.raw]) + self.api.clients.process_raw([dict(client.raw)]) LOGGER.debug( "Restore disconnected client %s (%s)", entry.entity_id, @@ -319,7 +321,7 @@ class UniFiController: del self._heartbeat_time[unique_id] @callback - def _async_check_for_stale(self, *_) -> None: + def _async_check_for_stale(self, *_: datetime) -> None: """Check for any devices scheduled to be marked disconnected.""" now = dt_util.utcnow() @@ -365,7 +367,7 @@ class UniFiController: async_dispatcher_send(hass, controller.signal_options_update) @callback - def reconnect(self, log=False) -> None: + def reconnect(self, log: bool = False) -> None: """Prepare to reconnect UniFi session.""" if log: LOGGER.info("Will try to reconnect to UniFi Network") @@ -387,14 +389,14 @@ class UniFiController: self.hass.loop.call_later(RETRY_TIMER, self.reconnect) @callback - def shutdown(self, event) -> None: + def shutdown(self, event: Event) -> None: """Wrap the call to unifi.close. Used as an argument to EventBus.async_listen_once. """ self.api.stop_websocket() - async def async_reset(self): + async def async_reset(self) -> bool: """Reset this controller to default state. Will cancel any scheduled setup retry and will unload @@ -421,15 +423,15 @@ async def get_unifi_controller( config: MappingProxyType[str, Any], ) -> aiounifi.Controller: """Create a controller object and verify authentication.""" - ssl_context = False + ssl_context: ssl.SSLContext | bool = False - if verify_ssl := bool(config.get(CONF_VERIFY_SSL)): + if verify_ssl := config.get(CONF_VERIFY_SSL): session = aiohttp_client.async_get_clientsession(hass) if isinstance(verify_ssl, str): ssl_context = ssl.create_default_context(cafile=verify_ssl) else: session = aiohttp_client.async_create_clientsession( - hass, verify_ssl=verify_ssl, cookie_jar=CookieJar(unsafe=True) + hass, verify_ssl=False, cookie_jar=CookieJar(unsafe=True) ) controller = aiounifi.Controller( diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index f31176afe3..149f865e77 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -19,7 +19,7 @@ from aiounifi.models.event import Event, EventKey from homeassistant.components.device_tracker import ScannerEntity, SourceType from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event as core_Event, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util @@ -268,7 +268,7 @@ class UnifiScannerEntity(UnifiEntity[HandlerT, ApiItemT], ScannerEntity): return self._attr_unique_id @callback - def _make_disconnected(self, *_) -> None: + def _make_disconnected(self, *_: core_Event) -> None: """No heart beat by device.""" self._is_connected = False self.async_write_ha_state() diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 7fde8a2ad7..473c4ed21a 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["aiounifi"], "quality_scale": "platinum", - "requirements": ["aiounifi==45"], + "requirements": ["aiounifi==46"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index bd0166516d..87c9b9f4f4 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -247,8 +247,9 @@ async def async_setup_entry( for mac in controller.option_block_clients: if mac not in controller.api.clients and mac in controller.api.clients_all: - client = controller.api.clients_all[mac] - controller.api.clients.process_raw([client.raw]) + controller.api.clients.process_raw( + [dict(controller.api.clients_all[mac].raw)] + ) controller.register_platform_add_entities( UnifiSwitchEntity, ENTITY_DESCRIPTIONS, async_add_entities diff --git a/mypy.ini b/mypy.ini index 760c7f6811..b3a4cafba3 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2873,7 +2873,7 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true -[mypy-homeassistant.components.unifi.update] +[mypy-homeassistant.components.unifi.*] check_untyped_defs = true disallow_incomplete_defs = true disallow_subclassing_any = true diff --git a/requirements_all.txt b/requirements_all.txt index 66edf53480..daea2e6939 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -291,7 +291,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.5 # homeassistant.components.unifi -aiounifi==45 +aiounifi==46 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bf5998ed50..cde4c23f90 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -272,7 +272,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.5 # homeassistant.components.unifi -aiounifi==45 +aiounifi==46 # homeassistant.components.vlc_telnet aiovlc==0.1.0 From f8431278c8556e71c984859ad6cbe35cd2b87a4f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Mar 2023 23:05:21 -1000 Subject: [PATCH 1259/2810] Bump yalexs-ble to 2.1.6 (#90295) --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 3db09bdc34..d30d3a39fb 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.5"] + "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.6"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index ba314ecf2a..da9e0271e4 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.1.5"] + "requirements": ["yalexs-ble==2.1.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index daea2e6939..57e442108e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2668,7 +2668,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.5 +yalexs-ble==2.1.6 # homeassistant.components.august yalexs==1.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cde4c23f90..4366ec257d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1905,7 +1905,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.5 +yalexs-ble==2.1.6 # homeassistant.components.august yalexs==1.2.7 From e0ec3488d3f4ea485e16124ebe1d4325bc9e716a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 26 Mar 2023 14:20:05 +0200 Subject: [PATCH 1260/2810] Adjust IntFlag handling in syrupy (#90223) --- .core_files.yaml | 1 + .../components/elgato/snapshots/test_light.ambr | 6 +++--- tests/syrupy.py | 17 ++++++++++++++--- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.core_files.yaml b/.core_files.yaml index 7bf7a09b36..7933556b60 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -125,6 +125,7 @@ tests: &tests - tests/mock/** - tests/pylint/** - tests/scripts/** + - tests/syrupy.py - tests/test_util/** - tests/testing_config/** - tests/util/** diff --git a/tests/components/elgato/snapshots/test_light.ambr b/tests/components/elgato/snapshots/test_light.ambr index 5395851673..31f5dfba21 100644 --- a/tests/components/elgato/snapshots/test_light.ambr +++ b/tests/components/elgato/snapshots/test_light.ambr @@ -23,7 +23,7 @@ 'supported_color_modes': list([ , ]), - 'supported_features': 0, + 'supported_features': , 'xy_color': tuple( 0.465, 0.376, @@ -130,7 +130,7 @@ , , ]), - 'supported_features': 0, + 'supported_features': , 'xy_color': tuple( 0.465, 0.376, @@ -236,7 +236,7 @@ , , ]), - 'supported_features': 0, + 'supported_features': , 'xy_color': tuple( 0.34, 0.327, diff --git a/tests/syrupy.py b/tests/syrupy.py index 4f646a05eb..f18c11bf5d 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -111,10 +111,10 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): serializable_data = cls._serializable_config_entry(data) elif dataclasses.is_dataclass(data): serializable_data = dataclasses.asdict(data) - elif isinstance(data, IntFlag) and data == 0: + elif isinstance(data, IntFlag): # The repr of an enum.IntFlag has changed between Python 3.10 and 3.11 - # This only concerns the 0 case, which we normalize here - serializable_data = 0 + # so we normalize it here. + serializable_data = _IntFlagWrapper(data) else: serializable_data = data with suppress(TypeError): @@ -201,6 +201,17 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): ) +class _IntFlagWrapper: + def __init__(self, flag: IntFlag) -> None: + self._flag = flag + + def __repr__(self) -> str: + # 3.10: + # 3.11: + # Syrupy: + return f"<{self._flag.__class__.__name__}: {self._flag.value}>" + + class HomeAssistantSnapshotExtension(AmberSnapshotExtension): """Home Assistant extension for Syrupy.""" From 69a46d400253fad3d4457d5febd2c4e7042d16b6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 26 Mar 2023 15:21:19 +0200 Subject: [PATCH 1261/2810] Adjust pylint plugin for components fixtures (#90217) * Adjust pylint plugin for components fixtures * Adjust components * Use MagicMock * Adjust * Use None --- pylint/plugins/hass_enforce_type_hints.py | 1 + tests/components/airzone/test_sensor.py | 4 +--- tests/components/conftest.py | 12 ++++++------ tests/components/goalzero/test_sensor.py | 3 +-- tests/components/gree/test_switch.py | 11 ++++++----- tests/components/homekit_controller/test_sensor.py | 4 ++-- tests/components/kostal_plenticore/test_number.py | 11 +++++------ tests/components/litterrobot/test_binary_sensor.py | 4 ++-- tests/components/oralb/test_sensor.py | 7 +++++-- tests/components/powerwall/test_sensor.py | 4 +++- tests/components/qnap_qsw/test_binary_sensor.py | 4 +--- tests/components/qnap_qsw/test_sensor.py | 4 +--- tests/components/radarr/test_sensor.py | 3 +-- tests/components/sensibo/test_binary_sensor.py | 4 ++-- tests/components/sensibo/test_climate.py | 12 ++++++------ tests/components/sensibo/test_number.py | 6 +++--- tests/components/sensibo/test_sensor.py | 4 ++-- tests/components/sonarr/test_sensor.py | 4 ++-- tests/components/switchbot/test_sensor.py | 5 ++++- tests/components/unifi/test_sensor.py | 4 ++-- tests/components/unifiprotect/test_sensor.py | 7 +++---- 21 files changed, 59 insertions(+), 59 deletions(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index f25b8db84a..ba0a511c57 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -104,6 +104,7 @@ _TEST_FIXTURES: dict[str, list[str] | str] = { "enable_statistics": "bool", "enable_schema_validation": "bool", "entity_registry": "EntityRegistry", + "entity_registry_enabled_by_default": "None", "freezer": "FrozenDateTimeFactory", "hass_access_token": "str", "hass_admin_credential": "Credentials", diff --git a/tests/components/airzone/test_sensor.py b/tests/components/airzone/test_sensor.py index 151ee7c42f..1e7d335a46 100644 --- a/tests/components/airzone/test_sensor.py +++ b/tests/components/airzone/test_sensor.py @@ -1,14 +1,12 @@ """The sensor tests for the Airzone platform.""" -from unittest.mock import AsyncMock - from homeassistant.core import HomeAssistant from .util import async_init_integration async def test_airzone_create_sensors( - hass: HomeAssistant, entity_registry_enabled_by_default: AsyncMock + hass: HomeAssistant, entity_registry_enabled_by_default: None ) -> None: """Test creation of sensors.""" diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 6cad53aea7..d57ef9768a 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -1,12 +1,12 @@ """Fixtures for component testing.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import patch import pytest @pytest.fixture(scope="session", autouse=True) -def patch_zeroconf_multiple_catcher(): +def patch_zeroconf_multiple_catcher() -> Generator[None, None, None]: """Patch zeroconf wrapper that detects if multiple instances are used.""" with patch( "homeassistant.components.zeroconf.install_multiple_zeroconf_catcher", @@ -16,7 +16,7 @@ def patch_zeroconf_multiple_catcher(): @pytest.fixture(autouse=True) -def prevent_io(): +def prevent_io() -> Generator[None, None, None]: """Fixture to prevent certain I/O from happening.""" with patch( "homeassistant.components.http.ban.load_yaml_config_file", @@ -25,10 +25,10 @@ def prevent_io(): @pytest.fixture -def entity_registry_enabled_by_default() -> Generator[AsyncMock, None, None]: +def entity_registry_enabled_by_default() -> Generator[None, None, None]: """Test fixture that ensures all entities are enabled in the registry.""" with patch( "homeassistant.helpers.entity.Entity.entity_registry_enabled_default", return_value=True, - ) as mock_entity_registry_enabled_by_default: - yield mock_entity_registry_enabled_by_default + ): + yield diff --git a/tests/components/goalzero/test_sensor.py b/tests/components/goalzero/test_sensor.py index 5c979b7d84..47fbb29915 100644 --- a/tests/components/goalzero/test_sensor.py +++ b/tests/components/goalzero/test_sensor.py @@ -1,5 +1,4 @@ """Sensor tests for the Goalzero integration.""" -from unittest.mock import AsyncMock from homeassistant.components.goalzero.const import DEFAULT_NAME from homeassistant.components.sensor import ( @@ -29,7 +28,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_sensors( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, ) -> None: """Test we get sensor data.""" await async_init_integration(hass, aioclient_mock) diff --git a/tests/components/gree/test_switch.py b/tests/components/gree/test_switch.py index 85b9a41caf..58c740b859 100644 --- a/tests/components/gree/test_switch.py +++ b/tests/components/gree/test_switch.py @@ -1,4 +1,5 @@ """Tests for gree component.""" + from greeclimate.exceptions import DeviceTimeoutError import pytest @@ -54,7 +55,7 @@ async def test_health_mode_disabled_by_default(hass): ], ) async def test_send_switch_on( - hass: HomeAssistant, entity, entity_registry_enabled_by_default + hass: HomeAssistant, entity, entity_registry_enabled_by_default: None ) -> None: """Test for sending power on command to the device.""" await async_setup_gree(hass) @@ -82,7 +83,7 @@ async def test_send_switch_on( ], ) async def test_send_switch_on_device_timeout( - hass: HomeAssistant, device, entity, entity_registry_enabled_by_default + hass: HomeAssistant, device, entity, entity_registry_enabled_by_default: None ) -> None: """Test for sending power on command to the device with a device timeout.""" device().push_state_update.side_effect = DeviceTimeoutError @@ -112,7 +113,7 @@ async def test_send_switch_on_device_timeout( ], ) async def test_send_switch_off( - hass: HomeAssistant, entity, entity_registry_enabled_by_default + hass: HomeAssistant, entity, entity_registry_enabled_by_default: None ) -> None: """Test for sending power on command to the device.""" await async_setup_gree(hass) @@ -140,7 +141,7 @@ async def test_send_switch_off( ], ) async def test_send_switch_toggle( - hass: HomeAssistant, entity, entity_registry_enabled_by_default + hass: HomeAssistant, entity, entity_registry_enabled_by_default: None ) -> None: """Test for sending power on command to the device.""" await async_setup_gree(hass) @@ -193,7 +194,7 @@ async def test_send_switch_toggle( ], ) async def test_entity_name( - hass: HomeAssistant, entity, name, entity_registry_enabled_by_default + hass: HomeAssistant, entity, name, entity_registry_enabled_by_default: None ) -> None: """Test for name property.""" await async_setup_gree(hass) diff --git a/tests/components/homekit_controller/test_sensor.py b/tests/components/homekit_controller/test_sensor.py index c801ab91f7..6c9ad00870 100644 --- a/tests/components/homekit_controller/test_sensor.py +++ b/tests/components/homekit_controller/test_sensor.py @@ -364,7 +364,7 @@ def test_thread_status_to_str() -> None: async def test_rssi_sensor( hass: HomeAssistant, utcnow, - entity_registry_enabled_by_default, + entity_registry_enabled_by_default: None, enable_bluetooth: None, ) -> None: """Test an rssi sensor.""" @@ -389,7 +389,7 @@ async def test_rssi_sensor( async def test_migrate_rssi_sensor_unique_id( hass: HomeAssistant, utcnow, - entity_registry_enabled_by_default, + entity_registry_enabled_by_default: None, enable_bluetooth: None, ) -> None: """Test an rssi sensor unique id migration.""" diff --git a/tests/components/kostal_plenticore/test_number.py b/tests/components/kostal_plenticore/test_number.py index a1eb778a1c..beabd8fe66 100644 --- a/tests/components/kostal_plenticore/test_number.py +++ b/tests/components/kostal_plenticore/test_number.py @@ -1,5 +1,4 @@ """Test Kostal Plenticore number.""" - from collections.abc import Generator from datetime import timedelta from unittest.mock import patch @@ -90,7 +89,7 @@ async def test_setup_all_entries( mock_config_entry: MockConfigEntry, mock_plenticore_client: ApiClient, mock_get_setting_values: list, - entity_registry_enabled_by_default, + entity_registry_enabled_by_default: None, ) -> None: """Test if all available entries are setup.""" @@ -109,7 +108,7 @@ async def test_setup_no_entries( mock_config_entry: MockConfigEntry, mock_plenticore_client: ApiClient, mock_get_setting_values: list, - entity_registry_enabled_by_default, + entity_registry_enabled_by_default: None, ) -> None: """Test that no entries are setup if Plenticore does not provide data.""" @@ -130,7 +129,7 @@ async def test_number_has_value( mock_config_entry: MockConfigEntry, mock_plenticore_client: ApiClient, mock_get_setting_values: list, - entity_registry_enabled_by_default, + entity_registry_enabled_by_default: None, ) -> None: """Test if number has a value if data is provided on update.""" @@ -155,7 +154,7 @@ async def test_number_is_unavailable( mock_config_entry: MockConfigEntry, mock_plenticore_client: ApiClient, mock_get_setting_values: list, - entity_registry_enabled_by_default, + entity_registry_enabled_by_default: None, ) -> None: """Test if number is unavailable if no data is provided on update.""" @@ -176,7 +175,7 @@ async def test_set_value( mock_config_entry: MockConfigEntry, mock_plenticore_client: ApiClient, mock_get_setting_values: list, - entity_registry_enabled_by_default, + entity_registry_enabled_by_default: None, ) -> None: """Test if a new value could be set.""" diff --git a/tests/components/litterrobot/test_binary_sensor.py b/tests/components/litterrobot/test_binary_sensor.py index cbcdd44776..c6cfbff907 100644 --- a/tests/components/litterrobot/test_binary_sensor.py +++ b/tests/components/litterrobot/test_binary_sensor.py @@ -1,5 +1,5 @@ """Test the Litter-Robot binary sensor entity.""" -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import MagicMock import pytest @@ -17,7 +17,7 @@ from .conftest import setup_integration async def test_binary_sensors( hass: HomeAssistant, mock_account: MagicMock, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, ) -> None: """Tests binary sensors.""" await setup_integration(hass, mock_account, PLATFORM_DOMAIN) diff --git a/tests/components/oralb/test_sensor.py b/tests/components/oralb/test_sensor.py index d077230140..8c7bacce23 100644 --- a/tests/components/oralb/test_sensor.py +++ b/tests/components/oralb/test_sensor.py @@ -1,4 +1,5 @@ """Test the OralB sensors.""" + from homeassistant.components.oralb.const import DOMAIN from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import HomeAssistant @@ -16,7 +17,9 @@ from tests.components.bluetooth import ( ) -async def test_sensors(hass: HomeAssistant, entity_registry_enabled_by_default) -> None: +async def test_sensors( + hass: HomeAssistant, entity_registry_enabled_by_default: None +) -> None: """Test setting up creates the sensors.""" entry = MockConfigEntry( domain=DOMAIN, @@ -47,7 +50,7 @@ async def test_sensors(hass: HomeAssistant, entity_registry_enabled_by_default) async def test_sensors_io_series_4( - hass: HomeAssistant, entity_registry_enabled_by_default + hass: HomeAssistant, entity_registry_enabled_by_default: None ) -> None: """Test setting up creates the sensors with an io series 4.""" entry = MockConfigEntry( diff --git a/tests/components/powerwall/test_sensor.py b/tests/components/powerwall/test_sensor.py index c72a3ff6fe..a0d4d7f9e9 100644 --- a/tests/components/powerwall/test_sensor.py +++ b/tests/components/powerwall/test_sensor.py @@ -20,7 +20,9 @@ from .mocks import _mock_powerwall_with_fixtures from tests.common import MockConfigEntry -async def test_sensors(hass: HomeAssistant, entity_registry_enabled_by_default) -> None: +async def test_sensors( + hass: HomeAssistant, entity_registry_enabled_by_default: None +) -> None: """Test creation of the sensors.""" mock_powerwall = await _mock_powerwall_with_fixtures(hass) diff --git a/tests/components/qnap_qsw/test_binary_sensor.py b/tests/components/qnap_qsw/test_binary_sensor.py index f007f79934..47eb6a00ba 100644 --- a/tests/components/qnap_qsw/test_binary_sensor.py +++ b/tests/components/qnap_qsw/test_binary_sensor.py @@ -1,7 +1,5 @@ """The binary sensor tests for the QNAP QSW platform.""" -from unittest.mock import AsyncMock - from homeassistant.components.qnap_qsw.const import ATTR_MESSAGE from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -12,7 +10,7 @@ from .util import async_init_integration async def test_qnap_qsw_create_binary_sensors( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, entity_registry: er.EntityRegistry, ) -> None: """Test creation of binary sensors.""" diff --git a/tests/components/qnap_qsw/test_sensor.py b/tests/components/qnap_qsw/test_sensor.py index 902f65d925..673a607acd 100644 --- a/tests/components/qnap_qsw/test_sensor.py +++ b/tests/components/qnap_qsw/test_sensor.py @@ -1,7 +1,5 @@ """The sensor tests for the QNAP QSW platform.""" -from unittest.mock import AsyncMock - from homeassistant.components.qnap_qsw.const import ATTR_MAX from homeassistant.core import HomeAssistant @@ -10,7 +8,7 @@ from .util import async_init_integration async def test_qnap_qsw_create_sensors( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, ) -> None: """Test creation of sensors.""" diff --git a/tests/components/radarr/test_sensor.py b/tests/components/radarr/test_sensor.py index 5c4ae3ea34..d3dde74dcb 100644 --- a/tests/components/radarr/test_sensor.py +++ b/tests/components/radarr/test_sensor.py @@ -1,5 +1,4 @@ """The tests for Radarr sensor platform.""" -from unittest.mock import AsyncMock from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT @@ -13,7 +12,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_sensors( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, ) -> None: """Test for successfully setting up the Radarr platform.""" await setup_integration(hass, aioclient_mock) diff --git a/tests/components/sensibo/test_binary_sensor.py b/tests/components/sensibo/test_binary_sensor.py index 78d643eb74..d99dd2e871 100644 --- a/tests/components/sensibo/test_binary_sensor.py +++ b/tests/components/sensibo/test_binary_sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import AsyncMock, patch +from unittest.mock import patch from pysensibo.model import SensiboData import pytest @@ -16,7 +16,7 @@ from tests.common import async_fire_time_changed async def test_binary_sensor( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index 268abc5f89..be5b539fa0 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import datetime, timedelta -from unittest.mock import AsyncMock, patch +from unittest.mock import patch from pysensibo.model import SensiboData import pytest @@ -720,7 +720,7 @@ async def test_climate_no_fan_no_swing( async def test_climate_set_timer( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, @@ -824,7 +824,7 @@ async def test_climate_set_timer( async def test_climate_pure_boost( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, @@ -928,7 +928,7 @@ async def test_climate_pure_boost( async def test_climate_climate_react( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, @@ -1091,7 +1091,7 @@ async def test_climate_climate_react( async def test_climate_climate_react_fahrenheit( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, @@ -1234,7 +1234,7 @@ async def test_climate_climate_react_fahrenheit( async def test_climate_full_ac_state( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, diff --git a/tests/components/sensibo/test_number.py b/tests/components/sensibo/test_number.py index c7a6a18616..1f96835596 100644 --- a/tests/components/sensibo/test_number.py +++ b/tests/components/sensibo/test_number.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import AsyncMock, patch +from unittest.mock import patch from pysensibo.model import SensiboData import pytest @@ -23,7 +23,7 @@ from tests.common import async_fire_time_changed async def test_number( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, @@ -53,7 +53,7 @@ async def test_number( async def test_number_set_value( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, load_int: ConfigEntry, get_data: SensiboData, ) -> None: diff --git a/tests/components/sensibo/test_sensor.py b/tests/components/sensibo/test_sensor.py index f84c7cf900..8d3f705215 100644 --- a/tests/components/sensibo/test_sensor.py +++ b/tests/components/sensibo/test_sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import AsyncMock, patch +from unittest.mock import patch from pysensibo.model import SensiboData import pytest @@ -16,7 +16,7 @@ from tests.common import async_fire_time_changed async def test_sensor( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, load_int: ConfigEntry, monkeypatch: pytest.pytest.MonkeyPatch, get_data: SensiboData, diff --git a/tests/components/sonarr/test_sensor.py b/tests/components/sonarr/test_sensor.py index c00236c54e..9f27e59365 100644 --- a/tests/components/sonarr/test_sensor.py +++ b/tests/components/sonarr/test_sensor.py @@ -1,6 +1,6 @@ """Tests for the Sonarr sensor platform.""" from datetime import timedelta -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch from aiopyarr import ArrException import pytest @@ -25,7 +25,7 @@ async def test_sensors( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_sonarr: MagicMock, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, ) -> None: """Test the creation and values of the sensors.""" registry = er.async_get(hass) diff --git a/tests/components/switchbot/test_sensor.py b/tests/components/switchbot/test_sensor.py index e801faf257..80c85cdb60 100644 --- a/tests/components/switchbot/test_sensor.py +++ b/tests/components/switchbot/test_sensor.py @@ -1,4 +1,5 @@ """Test the switchbot sensors.""" + from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.components.switchbot.const import DOMAIN from homeassistant.const import ( @@ -18,7 +19,9 @@ from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info -async def test_sensors(hass: HomeAssistant, entity_registry_enabled_by_default) -> None: +async def test_sensors( + hass: HomeAssistant, entity_registry_enabled_by_default: None +) -> None: """Test setting up creates the sensors.""" await async_setup_component(hass, DOMAIN, {}) inject_bluetooth_service_info(hass, WOHAND_SERVICE_INFO) diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index b4b8216626..bf7ba4d53c 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -210,7 +210,7 @@ async def test_uptime_sensors( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket, - entity_registry_enabled_by_default, + entity_registry_enabled_by_default: None, initial_uptime, event_uptime, new_uptime, @@ -296,7 +296,7 @@ async def test_remove_sensors( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket, - entity_registry_enabled_by_default, + entity_registry_enabled_by_default: None, ) -> None: """Verify removing of clients work as expected.""" wired_client = { diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index 0d763e6f90..db7cdc801b 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -1,9 +1,8 @@ """Test the UniFi Protect sensor platform.""" - from __future__ import annotations from datetime import datetime, timedelta -from unittest.mock import AsyncMock, Mock +from unittest.mock import Mock from pyunifiprotect.data import ( NVR, @@ -398,7 +397,7 @@ async def test_sensor_setup_camera( async def test_sensor_setup_camera_with_last_trip_time( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, ufp: MockUFPFixture, doorbell: Camera, fixed_now: datetime, @@ -474,7 +473,7 @@ async def test_sensor_update_alarm( async def test_sensor_update_alarm_with_last_trip_time( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, ufp: MockUFPFixture, sensor_all: Sensor, fixed_now: datetime, From bec7bbeb9221e9f7c8c0e551ba6dacd6d41e1d97 Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Sun, 26 Mar 2023 16:57:19 +0200 Subject: [PATCH 1262/2810] Use SnapshotAssertion in bmw_connected_drive tests (#90128) --- .../diagnostics/diagnostics_config_entry.json | 803 ------ .../diagnostics/diagnostics_device.json | 801 ------ .../snapshots/test_diagnostics.ambr | 2373 +++++++++++++++++ .../bmw_connected_drive/test_diagnostics.py | 38 +- 4 files changed, 2389 insertions(+), 1626 deletions(-) delete mode 100644 tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_config_entry.json delete mode 100644 tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_device.json create mode 100644 tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr diff --git a/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_config_entry.json b/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_config_entry.json deleted file mode 100644 index 12e85bb852..0000000000 --- a/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_config_entry.json +++ /dev/null @@ -1,803 +0,0 @@ -{ - "info": { - "username": "**REDACTED**", - "password": "**REDACTED**", - "region": "rest_of_world", - "refresh_token": "**REDACTED**" - }, - "data": [ - { - "data": { - "appVehicleType": "CONNECTED", - "attributes": { - "a4aType": "USB_ONLY", - "bodyType": "I01", - "brand": "BMW_I", - "color": 4284110934, - "countryOfOrigin": "CZ", - "driveTrain": "ELECTRIC_WITH_RANGE_EXTENDER", - "driverGuideInfo": { - "androidAppScheme": "com.bmwgroup.driversguide.row", - "androidStoreUrl": "https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row", - "iosAppScheme": "bmwdriversguide:///open", - "iosStoreUrl": "https://apps.apple.com/de/app/id714042749?mt=8" - }, - "headUnitType": "NBT", - "hmiVersion": "ID4", - "lastFetched": "2022-07-10T09:25:53.104Z", - "model": "i3 (+ REX)", - "softwareVersionCurrent": { - "iStep": 510, - "puStep": { "month": 11, "year": 21 }, - "seriesCluster": "I001" - }, - "softwareVersionExFactory": { - "iStep": 502, - "puStep": { "month": 3, "year": 15 }, - "seriesCluster": "I001" - }, - "year": 2015 - }, - "mappingInfo": { - "isAssociated": false, - "isLmmEnabled": false, - "isPrimaryUser": true, - "mappingStatus": "CONFIRMED" - }, - "vin": "**REDACTED**", - "charging_settings": { - "chargeAndClimateSettings": { - "chargeAndClimateTimer": { "showDepartureTimers": false } - }, - "chargeAndClimateTimerDetail": { - "chargingMode": { - "chargingPreference": "CHARGING_WINDOW", - "endTimeSlot": "0001-01-01T01:30:00", - "startTimeSlot": "0001-01-01T18:01:00", - "type": "TIME_SLOT" - }, - "departureTimer": { - "type": "WEEKLY_DEPARTURE_TIMER", - "weeklyTimers": [ - { - "daysOfTheWeek": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY" - ], - "id": 1, - "time": "0001-01-01T07:35:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ], - "id": 2, - "time": "0001-01-01T18:00:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [], - "id": 3, - "time": "0001-01-01T07:00:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [], - "id": 4, - "time": "0001-01-01T00:00:00", - "timerAction": "DEACTIVATE" - } - ] - }, - "isPreconditionForDepartureActive": false - }, - "servicePack": "TCB1" - }, - "is_metric": true, - "fetched_at": "2022-07-10T11:00:00+00:00", - "capabilities": { - "climateFunction": "AIR_CONDITIONING", - "climateNow": true, - "climateTimerTrigger": "DEPARTURE_TIMER", - "horn": true, - "isBmwChargingSupported": true, - "isCarSharingSupported": false, - "isChargeNowForBusinessSupported": false, - "isChargingHistorySupported": true, - "isChargingHospitalityEnabled": false, - "isChargingLoudnessEnabled": false, - "isChargingPlanSupported": true, - "isChargingPowerLimitEnabled": false, - "isChargingSettingsEnabled": false, - "isChargingTargetSocEnabled": false, - "isClimateTimerSupported": true, - "isCustomerEsimSupported": false, - "isDCSContractManagementSupported": true, - "isDataPrivacyEnabled": false, - "isEasyChargeEnabled": false, - "isEvGoChargingSupported": false, - "isMiniChargingSupported": false, - "isNonLscFeatureEnabled": false, - "isRemoteEngineStartSupported": false, - "isRemoteHistoryDeletionSupported": false, - "isRemoteHistorySupported": true, - "isRemoteParkingSupported": false, - "isRemoteServicesActivationRequired": false, - "isRemoteServicesBookingRequired": false, - "isScanAndChargeSupported": false, - "isSustainabilitySupported": false, - "isWifiHotspotServiceSupported": false, - "lastStateCallState": "ACTIVATED", - "lights": true, - "lock": true, - "remoteChargingCommands": {}, - "sendPoi": true, - "specialThemeSupport": [], - "unlock": true, - "vehicleFinder": false, - "vehicleStateSource": "LAST_STATE_CALL" - }, - "state": { - "chargingProfile": { - "chargingControlType": "WEEKLY_PLANNER", - "chargingMode": "DELAYED_CHARGING", - "chargingPreference": "CHARGING_WINDOW", - "chargingSettings": { - "hospitality": "NO_ACTION", - "idcc": "NO_ACTION", - "targetSoc": 100 - }, - "climatisationOn": false, - "departureTimes": [ - { - "action": "DEACTIVATE", - "id": 1, - "timeStamp": { "hour": 7, "minute": 35 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY" - ] - }, - { - "action": "DEACTIVATE", - "id": 2, - "timeStamp": { "hour": 18, "minute": 0 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ] - }, - { - "action": "DEACTIVATE", - "id": 3, - "timeStamp": { "hour": 7, "minute": 0 }, - "timerWeekDays": [] - }, - { "action": "DEACTIVATE", "id": 4, "timerWeekDays": [] } - ], - "reductionOfChargeCurrent": { - "end": { "hour": 1, "minute": 30 }, - "start": { "hour": 18, "minute": 1 } - } - }, - "checkControlMessages": [], - "climateTimers": [ - { - "departureTime": { "hour": 6, "minute": 40 }, - "isWeeklyTimer": true, - "timerAction": "ACTIVATE", - "timerWeekDays": ["THURSDAY", "SUNDAY"] - }, - { - "departureTime": { "hour": 12, "minute": 50 }, - "isWeeklyTimer": false, - "timerAction": "ACTIVATE", - "timerWeekDays": ["MONDAY"] - }, - { - "departureTime": { "hour": 18, "minute": 59 }, - "isWeeklyTimer": true, - "timerAction": "DEACTIVATE", - "timerWeekDays": ["WEDNESDAY"] - } - ], - "combustionFuelLevel": { - "range": 105, - "remainingFuelLiters": 6, - "remainingFuelPercent": 65 - }, - "currentMileage": 137009, - "doorsState": { - "combinedSecurityState": "UNLOCKED", - "combinedState": "CLOSED", - "hood": "CLOSED", - "leftFront": "CLOSED", - "leftRear": "CLOSED", - "rightFront": "CLOSED", - "rightRear": "CLOSED", - "trunk": "CLOSED" - }, - "driverPreferences": { "lscPrivacyMode": "OFF" }, - "electricChargingState": { - "chargingConnectionType": "CONDUCTIVE", - "chargingLevelPercent": 82, - "chargingStatus": "WAITING_FOR_CHARGING", - "chargingTarget": 100, - "isChargerConnected": true, - "range": 174 - }, - "isLeftSteering": true, - "isLscSupported": true, - "lastFetched": "2022-06-22T14:24:23.982Z", - "lastUpdatedAt": "2022-06-22T13:58:52Z", - "range": 174, - "requiredServices": [ - { - "dateTime": "2022-10-01T00:00:00.000Z", - "description": "Next service due by the specified date.", - "status": "OK", - "type": "BRAKE_FLUID" - }, - { - "dateTime": "2023-05-01T00:00:00.000Z", - "description": "Next vehicle check due after the specified distance or date.", - "status": "OK", - "type": "VEHICLE_CHECK" - }, - { - "dateTime": "2023-05-01T00:00:00.000Z", - "description": "Next state inspection due by the specified date.", - "status": "OK", - "type": "VEHICLE_TUV" - } - ], - "roofState": { "roofState": "CLOSED", "roofStateType": "SUN_ROOF" }, - "windowsState": { - "combinedState": "CLOSED", - "leftFront": "CLOSED", - "rightFront": "CLOSED" - } - } - }, - "fuel_and_battery": { - "remaining_range_fuel": [105, "km"], - "remaining_range_electric": [174, "km"], - "remaining_range_total": [279, "km"], - "remaining_fuel": [6, "L"], - "remaining_fuel_percent": 65, - "remaining_battery_percent": 82, - "charging_status": "WAITING_FOR_CHARGING", - "charging_start_time_no_tz": "2022-07-10T18:01:00", - "charging_end_time": null, - "is_charger_connected": true, - "charging_target": 100, - "account_timezone": { - "_std_offset": "0:00:00", - "_dst_offset": "0:00:00", - "_dst_saved": "0:00:00", - "_hasdst": false, - "_tznames": ["UTC", "UTC"] - }, - "charging_start_time": "2022-07-10T18:01:00+00:00" - }, - "vehicle_location": { - "location": null, - "heading": null, - "vehicle_update_timestamp": "2022-07-10T09:25:53+00:00", - "account_region": "row", - "remote_service_position": null - }, - "doors_and_windows": { - "door_lock_state": "UNLOCKED", - "lids": [ - { "name": "hood", "state": "CLOSED", "is_closed": true }, - { "name": "leftFront", "state": "CLOSED", "is_closed": true }, - { "name": "leftRear", "state": "CLOSED", "is_closed": true }, - { "name": "rightFront", "state": "CLOSED", "is_closed": true }, - { "name": "rightRear", "state": "CLOSED", "is_closed": true }, - { "name": "trunk", "state": "CLOSED", "is_closed": true }, - { "name": "sunRoof", "state": "CLOSED", "is_closed": true } - ], - "windows": [ - { "name": "leftFront", "state": "CLOSED", "is_closed": true }, - { "name": "rightFront", "state": "CLOSED", "is_closed": true } - ], - "all_lids_closed": true, - "all_windows_closed": true, - "open_lids": [], - "open_windows": [] - }, - "condition_based_services": { - "messages": [ - { - "service_type": "BRAKE_FLUID", - "state": "OK", - "due_date": "2022-10-01T00:00:00+00:00", - "due_distance": [null, null] - }, - { - "service_type": "VEHICLE_CHECK", - "state": "OK", - "due_date": "2023-05-01T00:00:00+00:00", - "due_distance": [null, null] - }, - { - "service_type": "VEHICLE_TUV", - "state": "OK", - "due_date": "2023-05-01T00:00:00+00:00", - "due_distance": [null, null] - } - ], - "is_service_required": false - }, - "check_control_messages": { - "messages": [], - "has_check_control_messages": false - }, - "charging_profile": { - "is_pre_entry_climatization_enabled": false, - "timer_type": "WEEKLY_PLANNER", - "departure_times": [ - { - "_timer_dict": { - "action": "DEACTIVATE", - "id": 1, - "timeStamp": { "hour": 7, "minute": 35 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY" - ] - }, - "action": "DEACTIVATE", - "start_time": "07:35:00", - "timer_id": 1, - "weekdays": ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY"] - }, - { - "_timer_dict": { - "action": "DEACTIVATE", - "id": 2, - "timeStamp": { "hour": 18, "minute": 0 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ] - }, - "action": "DEACTIVATE", - "start_time": "18:00:00", - "timer_id": 2, - "weekdays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ] - }, - { - "_timer_dict": { - "action": "DEACTIVATE", - "id": 3, - "timeStamp": { "hour": 7, "minute": 0 }, - "timerWeekDays": [] - }, - "action": "DEACTIVATE", - "start_time": "07:00:00", - "timer_id": 3, - "weekdays": [] - }, - { - "_timer_dict": { - "action": "DEACTIVATE", - "id": 4, - "timerWeekDays": [] - }, - "action": "DEACTIVATE", - "start_time": null, - "timer_id": 4, - "weekdays": [] - } - ], - "preferred_charging_window": { - "_window_dict": { - "end": { "hour": 1, "minute": 30 }, - "start": { "hour": 18, "minute": 1 } - }, - "end_time": "01:30:00", - "start_time": "18:01:00" - }, - "charging_preferences": "CHARGING_WINDOW", - "charging_mode": "DELAYED_CHARGING", - "ac_current_limit": null, - "ac_available_limits": null, - "charging_preferences_service_pack": "TCB1" - }, - "available_attributes": [ - "gps_position", - "vin", - "remaining_range_total", - "mileage", - "charging_time_remaining", - "charging_start_time", - "charging_end_time", - "charging_time_label", - "charging_status", - "connection_status", - "remaining_battery_percent", - "remaining_range_electric", - "last_charging_end_result", - "ac_current_limit", - "charging_target", - "charging_mode", - "charging_preferences", - "is_pre_entry_climatization_enabled", - "remaining_fuel", - "remaining_range_fuel", - "remaining_fuel_percent", - "condition_based_services", - "check_control_messages", - "door_lock_state", - "timestamp", - "lids", - "windows" - ], - "brand": "bmw", - "drive_train": "ELECTRIC_WITH_RANGE_EXTENDER", - "drive_train_attributes": [ - "remaining_range_total", - "mileage", - "charging_time_remaining", - "charging_start_time", - "charging_end_time", - "charging_time_label", - "charging_status", - "connection_status", - "remaining_battery_percent", - "remaining_range_electric", - "last_charging_end_result", - "ac_current_limit", - "charging_target", - "charging_mode", - "charging_preferences", - "is_pre_entry_climatization_enabled", - "remaining_fuel", - "remaining_range_fuel", - "remaining_fuel_percent" - ], - "has_combustion_drivetrain": true, - "has_electric_drivetrain": true, - "is_charging_plan_supported": true, - "is_lsc_enabled": true, - "is_remote_charge_start_enabled": false, - "is_remote_charge_stop_enabled": false, - "is_remote_climate_start_enabled": true, - "is_remote_climate_stop_enabled": false, - "is_remote_horn_enabled": true, - "is_remote_lights_enabled": true, - "is_remote_lock_enabled": true, - "is_remote_sendpoi_enabled": true, - "is_remote_set_ac_limit_enabled": false, - "is_remote_set_target_soc_enabled": false, - "is_remote_unlock_enabled": true, - "is_vehicle_active": false, - "is_vehicle_tracking_enabled": false, - "lsc_type": "ACTIVATED", - "mileage": [137009, "km"], - "name": "i3 (+ REX)", - "timestamp": "2022-07-10T09:25:53+00:00", - "vin": "**REDACTED**" - } - ], - "fingerprint": [ - { - "filename": "bmw-eadrax-vcs_v4_vehicles.json", - "content": [ - { - "appVehicleType": "CONNECTED", - "attributes": { - "a4aType": "USB_ONLY", - "bodyType": "I01", - "brand": "BMW_I", - "color": 4284110934, - "countryOfOrigin": "CZ", - "driveTrain": "ELECTRIC_WITH_RANGE_EXTENDER", - "driverGuideInfo": { - "androidAppScheme": "com.bmwgroup.driversguide.row", - "androidStoreUrl": "https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row", - "iosAppScheme": "bmwdriversguide:///open", - "iosStoreUrl": "https://apps.apple.com/de/app/id714042749?mt=8" - }, - "headUnitType": "NBT", - "hmiVersion": "ID4", - "lastFetched": "2022-07-10T09:25:53.104Z", - "model": "i3 (+ REX)", - "softwareVersionCurrent": { - "iStep": 510, - "puStep": { "month": 11, "year": 21 }, - "seriesCluster": "I001" - }, - "softwareVersionExFactory": { - "iStep": 502, - "puStep": { "month": 3, "year": 15 }, - "seriesCluster": "I001" - }, - "year": 2015 - }, - "mappingInfo": { - "isAssociated": false, - "isLmmEnabled": false, - "isPrimaryUser": true, - "mappingStatus": "CONFIRMED" - }, - "vin": "**REDACTED**" - } - ] - }, - { "filename": "mini-eadrax-vcs_v4_vehicles.json", "content": [] }, - { - "filename": "bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT01.json", - "content": { - "capabilities": { - "climateFunction": "AIR_CONDITIONING", - "climateNow": true, - "climateTimerTrigger": "DEPARTURE_TIMER", - "horn": true, - "isBmwChargingSupported": true, - "isCarSharingSupported": false, - "isChargeNowForBusinessSupported": false, - "isChargingHistorySupported": true, - "isChargingHospitalityEnabled": false, - "isChargingLoudnessEnabled": false, - "isChargingPlanSupported": true, - "isChargingPowerLimitEnabled": false, - "isChargingSettingsEnabled": false, - "isChargingTargetSocEnabled": false, - "isClimateTimerSupported": true, - "isCustomerEsimSupported": false, - "isDCSContractManagementSupported": true, - "isDataPrivacyEnabled": false, - "isEasyChargeEnabled": false, - "isEvGoChargingSupported": false, - "isMiniChargingSupported": false, - "isNonLscFeatureEnabled": false, - "isRemoteEngineStartSupported": false, - "isRemoteHistoryDeletionSupported": false, - "isRemoteHistorySupported": true, - "isRemoteParkingSupported": false, - "isRemoteServicesActivationRequired": false, - "isRemoteServicesBookingRequired": false, - "isScanAndChargeSupported": false, - "isSustainabilitySupported": false, - "isWifiHotspotServiceSupported": false, - "lastStateCallState": "ACTIVATED", - "lights": true, - "lock": true, - "remoteChargingCommands": {}, - "sendPoi": true, - "specialThemeSupport": [], - "unlock": true, - "vehicleFinder": false, - "vehicleStateSource": "LAST_STATE_CALL" - }, - "state": { - "chargingProfile": { - "chargingControlType": "WEEKLY_PLANNER", - "chargingMode": "DELAYED_CHARGING", - "chargingPreference": "CHARGING_WINDOW", - "chargingSettings": { - "hospitality": "NO_ACTION", - "idcc": "NO_ACTION", - "targetSoc": 100 - }, - "climatisationOn": false, - "departureTimes": [ - { - "action": "DEACTIVATE", - "id": 1, - "timeStamp": { "hour": 7, "minute": 35 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY" - ] - }, - { - "action": "DEACTIVATE", - "id": 2, - "timeStamp": { "hour": 18, "minute": 0 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ] - }, - { - "action": "DEACTIVATE", - "id": 3, - "timeStamp": { "hour": 7, "minute": 0 }, - "timerWeekDays": [] - }, - { "action": "DEACTIVATE", "id": 4, "timerWeekDays": [] } - ], - "reductionOfChargeCurrent": { - "end": { "hour": 1, "minute": 30 }, - "start": { "hour": 18, "minute": 1 } - } - }, - "checkControlMessages": [], - "climateTimers": [ - { - "departureTime": { "hour": 6, "minute": 40 }, - "isWeeklyTimer": true, - "timerAction": "ACTIVATE", - "timerWeekDays": ["THURSDAY", "SUNDAY"] - }, - { - "departureTime": { "hour": 12, "minute": 50 }, - "isWeeklyTimer": false, - "timerAction": "ACTIVATE", - "timerWeekDays": ["MONDAY"] - }, - { - "departureTime": { "hour": 18, "minute": 59 }, - "isWeeklyTimer": true, - "timerAction": "DEACTIVATE", - "timerWeekDays": ["WEDNESDAY"] - } - ], - "combustionFuelLevel": { - "range": 105, - "remainingFuelLiters": 6, - "remainingFuelPercent": 65 - }, - "currentMileage": 137009, - "doorsState": { - "combinedSecurityState": "UNLOCKED", - "combinedState": "CLOSED", - "hood": "CLOSED", - "leftFront": "CLOSED", - "leftRear": "CLOSED", - "rightFront": "CLOSED", - "rightRear": "CLOSED", - "trunk": "CLOSED" - }, - "driverPreferences": { "lscPrivacyMode": "OFF" }, - "electricChargingState": { - "chargingConnectionType": "CONDUCTIVE", - "chargingLevelPercent": 82, - "chargingStatus": "WAITING_FOR_CHARGING", - "chargingTarget": 100, - "isChargerConnected": true, - "range": 174 - }, - "isLeftSteering": true, - "isLscSupported": true, - "lastFetched": "2022-06-22T14:24:23.982Z", - "lastUpdatedAt": "2022-06-22T13:58:52Z", - "range": 174, - "requiredServices": [ - { - "dateTime": "2022-10-01T00:00:00.000Z", - "description": "Next service due by the specified date.", - "status": "OK", - "type": "BRAKE_FLUID" - }, - { - "dateTime": "2023-05-01T00:00:00.000Z", - "description": "Next vehicle check due after the specified distance or date.", - "status": "OK", - "type": "VEHICLE_CHECK" - }, - { - "dateTime": "2023-05-01T00:00:00.000Z", - "description": "Next state inspection due by the specified date.", - "status": "OK", - "type": "VEHICLE_TUV" - } - ], - "roofState": { "roofState": "CLOSED", "roofStateType": "SUN_ROOF" }, - "windowsState": { - "combinedState": "CLOSED", - "leftFront": "CLOSED", - "rightFront": "CLOSED" - } - } - } - }, - { - "filename": "bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT01.json", - "content": { - "chargeAndClimateSettings": { - "chargeAndClimateTimer": { "showDepartureTimers": false } - }, - "chargeAndClimateTimerDetail": { - "chargingMode": { - "chargingPreference": "CHARGING_WINDOW", - "endTimeSlot": "0001-01-01T01:30:00", - "startTimeSlot": "0001-01-01T18:01:00", - "type": "TIME_SLOT" - }, - "departureTimer": { - "type": "WEEKLY_DEPARTURE_TIMER", - "weeklyTimers": [ - { - "daysOfTheWeek": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY" - ], - "id": 1, - "time": "0001-01-01T07:35:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ], - "id": 2, - "time": "0001-01-01T18:00:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [], - "id": 3, - "time": "0001-01-01T07:00:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [], - "id": 4, - "time": "0001-01-01T00:00:00", - "timerAction": "DEACTIVATE" - } - ] - }, - "isPreconditionForDepartureActive": false - }, - "servicePack": "TCB1" - } - } - ] -} diff --git a/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_device.json b/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_device.json deleted file mode 100644 index 8e1fe5019c..0000000000 --- a/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_device.json +++ /dev/null @@ -1,801 +0,0 @@ -{ - "info": { - "username": "**REDACTED**", - "password": "**REDACTED**", - "region": "rest_of_world", - "refresh_token": "**REDACTED**" - }, - "data": { - "data": { - "appVehicleType": "CONNECTED", - "attributes": { - "a4aType": "USB_ONLY", - "bodyType": "I01", - "brand": "BMW_I", - "color": 4284110934, - "countryOfOrigin": "CZ", - "driveTrain": "ELECTRIC_WITH_RANGE_EXTENDER", - "driverGuideInfo": { - "androidAppScheme": "com.bmwgroup.driversguide.row", - "androidStoreUrl": "https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row", - "iosAppScheme": "bmwdriversguide:///open", - "iosStoreUrl": "https://apps.apple.com/de/app/id714042749?mt=8" - }, - "headUnitType": "NBT", - "hmiVersion": "ID4", - "lastFetched": "2022-07-10T09:25:53.104Z", - "model": "i3 (+ REX)", - "softwareVersionCurrent": { - "iStep": 510, - "puStep": { "month": 11, "year": 21 }, - "seriesCluster": "I001" - }, - "softwareVersionExFactory": { - "iStep": 502, - "puStep": { "month": 3, "year": 15 }, - "seriesCluster": "I001" - }, - "year": 2015 - }, - "mappingInfo": { - "isAssociated": false, - "isLmmEnabled": false, - "isPrimaryUser": true, - "mappingStatus": "CONFIRMED" - }, - "vin": "**REDACTED**", - "charging_settings": { - "chargeAndClimateSettings": { - "chargeAndClimateTimer": { "showDepartureTimers": false } - }, - "chargeAndClimateTimerDetail": { - "chargingMode": { - "chargingPreference": "CHARGING_WINDOW", - "endTimeSlot": "0001-01-01T01:30:00", - "startTimeSlot": "0001-01-01T18:01:00", - "type": "TIME_SLOT" - }, - "departureTimer": { - "type": "WEEKLY_DEPARTURE_TIMER", - "weeklyTimers": [ - { - "daysOfTheWeek": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY" - ], - "id": 1, - "time": "0001-01-01T07:35:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ], - "id": 2, - "time": "0001-01-01T18:00:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [], - "id": 3, - "time": "0001-01-01T07:00:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [], - "id": 4, - "time": "0001-01-01T00:00:00", - "timerAction": "DEACTIVATE" - } - ] - }, - "isPreconditionForDepartureActive": false - }, - "servicePack": "TCB1" - }, - "is_metric": true, - "fetched_at": "2022-07-10T11:00:00+00:00", - "capabilities": { - "climateFunction": "AIR_CONDITIONING", - "climateNow": true, - "climateTimerTrigger": "DEPARTURE_TIMER", - "horn": true, - "isBmwChargingSupported": true, - "isCarSharingSupported": false, - "isChargeNowForBusinessSupported": false, - "isChargingHistorySupported": true, - "isChargingHospitalityEnabled": false, - "isChargingLoudnessEnabled": false, - "isChargingPlanSupported": true, - "isChargingPowerLimitEnabled": false, - "isChargingSettingsEnabled": false, - "isChargingTargetSocEnabled": false, - "isClimateTimerSupported": true, - "isCustomerEsimSupported": false, - "isDCSContractManagementSupported": true, - "isDataPrivacyEnabled": false, - "isEasyChargeEnabled": false, - "isEvGoChargingSupported": false, - "isMiniChargingSupported": false, - "isNonLscFeatureEnabled": false, - "isRemoteEngineStartSupported": false, - "isRemoteHistoryDeletionSupported": false, - "isRemoteHistorySupported": true, - "isRemoteParkingSupported": false, - "isRemoteServicesActivationRequired": false, - "isRemoteServicesBookingRequired": false, - "isScanAndChargeSupported": false, - "isSustainabilitySupported": false, - "isWifiHotspotServiceSupported": false, - "lastStateCallState": "ACTIVATED", - "lights": true, - "lock": true, - "remoteChargingCommands": {}, - "sendPoi": true, - "specialThemeSupport": [], - "unlock": true, - "vehicleFinder": false, - "vehicleStateSource": "LAST_STATE_CALL" - }, - "state": { - "chargingProfile": { - "chargingControlType": "WEEKLY_PLANNER", - "chargingMode": "DELAYED_CHARGING", - "chargingPreference": "CHARGING_WINDOW", - "chargingSettings": { - "hospitality": "NO_ACTION", - "idcc": "NO_ACTION", - "targetSoc": 100 - }, - "climatisationOn": false, - "departureTimes": [ - { - "action": "DEACTIVATE", - "id": 1, - "timeStamp": { "hour": 7, "minute": 35 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY" - ] - }, - { - "action": "DEACTIVATE", - "id": 2, - "timeStamp": { "hour": 18, "minute": 0 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ] - }, - { - "action": "DEACTIVATE", - "id": 3, - "timeStamp": { "hour": 7, "minute": 0 }, - "timerWeekDays": [] - }, - { "action": "DEACTIVATE", "id": 4, "timerWeekDays": [] } - ], - "reductionOfChargeCurrent": { - "end": { "hour": 1, "minute": 30 }, - "start": { "hour": 18, "minute": 1 } - } - }, - "checkControlMessages": [], - "climateTimers": [ - { - "departureTime": { "hour": 6, "minute": 40 }, - "isWeeklyTimer": true, - "timerAction": "ACTIVATE", - "timerWeekDays": ["THURSDAY", "SUNDAY"] - }, - { - "departureTime": { "hour": 12, "minute": 50 }, - "isWeeklyTimer": false, - "timerAction": "ACTIVATE", - "timerWeekDays": ["MONDAY"] - }, - { - "departureTime": { "hour": 18, "minute": 59 }, - "isWeeklyTimer": true, - "timerAction": "DEACTIVATE", - "timerWeekDays": ["WEDNESDAY"] - } - ], - "combustionFuelLevel": { - "range": 105, - "remainingFuelLiters": 6, - "remainingFuelPercent": 65 - }, - "currentMileage": 137009, - "doorsState": { - "combinedSecurityState": "UNLOCKED", - "combinedState": "CLOSED", - "hood": "CLOSED", - "leftFront": "CLOSED", - "leftRear": "CLOSED", - "rightFront": "CLOSED", - "rightRear": "CLOSED", - "trunk": "CLOSED" - }, - "driverPreferences": { "lscPrivacyMode": "OFF" }, - "electricChargingState": { - "chargingConnectionType": "CONDUCTIVE", - "chargingLevelPercent": 82, - "chargingStatus": "WAITING_FOR_CHARGING", - "chargingTarget": 100, - "isChargerConnected": true, - "range": 174 - }, - "isLeftSteering": true, - "isLscSupported": true, - "lastFetched": "2022-06-22T14:24:23.982Z", - "lastUpdatedAt": "2022-06-22T13:58:52Z", - "range": 174, - "requiredServices": [ - { - "dateTime": "2022-10-01T00:00:00.000Z", - "description": "Next service due by the specified date.", - "status": "OK", - "type": "BRAKE_FLUID" - }, - { - "dateTime": "2023-05-01T00:00:00.000Z", - "description": "Next vehicle check due after the specified distance or date.", - "status": "OK", - "type": "VEHICLE_CHECK" - }, - { - "dateTime": "2023-05-01T00:00:00.000Z", - "description": "Next state inspection due by the specified date.", - "status": "OK", - "type": "VEHICLE_TUV" - } - ], - "roofState": { "roofState": "CLOSED", "roofStateType": "SUN_ROOF" }, - "windowsState": { - "combinedState": "CLOSED", - "leftFront": "CLOSED", - "rightFront": "CLOSED" - } - } - }, - "fuel_and_battery": { - "remaining_range_fuel": [105, "km"], - "remaining_range_electric": [174, "km"], - "remaining_range_total": [279, "km"], - "remaining_fuel": [6, "L"], - "remaining_fuel_percent": 65, - "remaining_battery_percent": 82, - "charging_status": "WAITING_FOR_CHARGING", - "charging_start_time_no_tz": "2022-07-10T18:01:00", - "charging_end_time": null, - "is_charger_connected": true, - "charging_target": 100, - "account_timezone": { - "_std_offset": "0:00:00", - "_dst_offset": "0:00:00", - "_dst_saved": "0:00:00", - "_hasdst": false, - "_tznames": ["UTC", "UTC"] - }, - "charging_start_time": "2022-07-10T18:01:00+00:00" - }, - "vehicle_location": { - "location": null, - "heading": null, - "vehicle_update_timestamp": "2022-07-10T09:25:53+00:00", - "account_region": "row", - "remote_service_position": null - }, - "doors_and_windows": { - "door_lock_state": "UNLOCKED", - "lids": [ - { "name": "hood", "state": "CLOSED", "is_closed": true }, - { "name": "leftFront", "state": "CLOSED", "is_closed": true }, - { "name": "leftRear", "state": "CLOSED", "is_closed": true }, - { "name": "rightFront", "state": "CLOSED", "is_closed": true }, - { "name": "rightRear", "state": "CLOSED", "is_closed": true }, - { "name": "trunk", "state": "CLOSED", "is_closed": true }, - { "name": "sunRoof", "state": "CLOSED", "is_closed": true } - ], - "windows": [ - { "name": "leftFront", "state": "CLOSED", "is_closed": true }, - { "name": "rightFront", "state": "CLOSED", "is_closed": true } - ], - "all_lids_closed": true, - "all_windows_closed": true, - "open_lids": [], - "open_windows": [] - }, - "condition_based_services": { - "messages": [ - { - "service_type": "BRAKE_FLUID", - "state": "OK", - "due_date": "2022-10-01T00:00:00+00:00", - "due_distance": [null, null] - }, - { - "service_type": "VEHICLE_CHECK", - "state": "OK", - "due_date": "2023-05-01T00:00:00+00:00", - "due_distance": [null, null] - }, - { - "service_type": "VEHICLE_TUV", - "state": "OK", - "due_date": "2023-05-01T00:00:00+00:00", - "due_distance": [null, null] - } - ], - "is_service_required": false - }, - "check_control_messages": { - "messages": [], - "has_check_control_messages": false - }, - "charging_profile": { - "is_pre_entry_climatization_enabled": false, - "timer_type": "WEEKLY_PLANNER", - "departure_times": [ - { - "_timer_dict": { - "action": "DEACTIVATE", - "id": 1, - "timeStamp": { "hour": 7, "minute": 35 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY" - ] - }, - "action": "DEACTIVATE", - "start_time": "07:35:00", - "timer_id": 1, - "weekdays": ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY"] - }, - { - "_timer_dict": { - "action": "DEACTIVATE", - "id": 2, - "timeStamp": { "hour": 18, "minute": 0 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ] - }, - "action": "DEACTIVATE", - "start_time": "18:00:00", - "timer_id": 2, - "weekdays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ] - }, - { - "_timer_dict": { - "action": "DEACTIVATE", - "id": 3, - "timeStamp": { "hour": 7, "minute": 0 }, - "timerWeekDays": [] - }, - "action": "DEACTIVATE", - "start_time": "07:00:00", - "timer_id": 3, - "weekdays": [] - }, - { - "_timer_dict": { - "action": "DEACTIVATE", - "id": 4, - "timerWeekDays": [] - }, - "action": "DEACTIVATE", - "start_time": null, - "timer_id": 4, - "weekdays": [] - } - ], - "preferred_charging_window": { - "_window_dict": { - "end": { "hour": 1, "minute": 30 }, - "start": { "hour": 18, "minute": 1 } - }, - "end_time": "01:30:00", - "start_time": "18:01:00" - }, - "charging_preferences": "CHARGING_WINDOW", - "charging_mode": "DELAYED_CHARGING", - "ac_current_limit": null, - "ac_available_limits": null, - "charging_preferences_service_pack": "TCB1" - }, - "available_attributes": [ - "gps_position", - "vin", - "remaining_range_total", - "mileage", - "charging_time_remaining", - "charging_start_time", - "charging_end_time", - "charging_time_label", - "charging_status", - "connection_status", - "remaining_battery_percent", - "remaining_range_electric", - "last_charging_end_result", - "ac_current_limit", - "charging_target", - "charging_mode", - "charging_preferences", - "is_pre_entry_climatization_enabled", - "remaining_fuel", - "remaining_range_fuel", - "remaining_fuel_percent", - "condition_based_services", - "check_control_messages", - "door_lock_state", - "timestamp", - "lids", - "windows" - ], - "brand": "bmw", - "drive_train": "ELECTRIC_WITH_RANGE_EXTENDER", - "drive_train_attributes": [ - "remaining_range_total", - "mileage", - "charging_time_remaining", - "charging_start_time", - "charging_end_time", - "charging_time_label", - "charging_status", - "connection_status", - "remaining_battery_percent", - "remaining_range_electric", - "last_charging_end_result", - "ac_current_limit", - "charging_target", - "charging_mode", - "charging_preferences", - "is_pre_entry_climatization_enabled", - "remaining_fuel", - "remaining_range_fuel", - "remaining_fuel_percent" - ], - "has_combustion_drivetrain": true, - "has_electric_drivetrain": true, - "is_charging_plan_supported": true, - "is_lsc_enabled": true, - "is_remote_charge_start_enabled": false, - "is_remote_charge_stop_enabled": false, - "is_remote_climate_start_enabled": true, - "is_remote_climate_stop_enabled": false, - "is_remote_horn_enabled": true, - "is_remote_lights_enabled": true, - "is_remote_lock_enabled": true, - "is_remote_sendpoi_enabled": true, - "is_remote_set_ac_limit_enabled": false, - "is_remote_set_target_soc_enabled": false, - "is_remote_unlock_enabled": true, - "is_vehicle_active": false, - "is_vehicle_tracking_enabled": false, - "lsc_type": "ACTIVATED", - "mileage": [137009, "km"], - "name": "i3 (+ REX)", - "timestamp": "2022-07-10T09:25:53+00:00", - "vin": "**REDACTED**" - }, - "fingerprint": [ - { - "filename": "bmw-eadrax-vcs_v4_vehicles.json", - "content": [ - { - "appVehicleType": "CONNECTED", - "attributes": { - "a4aType": "USB_ONLY", - "bodyType": "I01", - "brand": "BMW_I", - "color": 4284110934, - "countryOfOrigin": "CZ", - "driveTrain": "ELECTRIC_WITH_RANGE_EXTENDER", - "driverGuideInfo": { - "androidAppScheme": "com.bmwgroup.driversguide.row", - "androidStoreUrl": "https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row", - "iosAppScheme": "bmwdriversguide:///open", - "iosStoreUrl": "https://apps.apple.com/de/app/id714042749?mt=8" - }, - "headUnitType": "NBT", - "hmiVersion": "ID4", - "lastFetched": "2022-07-10T09:25:53.104Z", - "model": "i3 (+ REX)", - "softwareVersionCurrent": { - "iStep": 510, - "puStep": { "month": 11, "year": 21 }, - "seriesCluster": "I001" - }, - "softwareVersionExFactory": { - "iStep": 502, - "puStep": { "month": 3, "year": 15 }, - "seriesCluster": "I001" - }, - "year": 2015 - }, - "mappingInfo": { - "isAssociated": false, - "isLmmEnabled": false, - "isPrimaryUser": true, - "mappingStatus": "CONFIRMED" - }, - "vin": "**REDACTED**" - } - ] - }, - { "filename": "mini-eadrax-vcs_v4_vehicles.json", "content": [] }, - { - "filename": "bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT01.json", - "content": { - "capabilities": { - "climateFunction": "AIR_CONDITIONING", - "climateNow": true, - "climateTimerTrigger": "DEPARTURE_TIMER", - "horn": true, - "isBmwChargingSupported": true, - "isCarSharingSupported": false, - "isChargeNowForBusinessSupported": false, - "isChargingHistorySupported": true, - "isChargingHospitalityEnabled": false, - "isChargingLoudnessEnabled": false, - "isChargingPlanSupported": true, - "isChargingPowerLimitEnabled": false, - "isChargingSettingsEnabled": false, - "isChargingTargetSocEnabled": false, - "isClimateTimerSupported": true, - "isCustomerEsimSupported": false, - "isDCSContractManagementSupported": true, - "isDataPrivacyEnabled": false, - "isEasyChargeEnabled": false, - "isEvGoChargingSupported": false, - "isMiniChargingSupported": false, - "isNonLscFeatureEnabled": false, - "isRemoteEngineStartSupported": false, - "isRemoteHistoryDeletionSupported": false, - "isRemoteHistorySupported": true, - "isRemoteParkingSupported": false, - "isRemoteServicesActivationRequired": false, - "isRemoteServicesBookingRequired": false, - "isScanAndChargeSupported": false, - "isSustainabilitySupported": false, - "isWifiHotspotServiceSupported": false, - "lastStateCallState": "ACTIVATED", - "lights": true, - "lock": true, - "remoteChargingCommands": {}, - "sendPoi": true, - "specialThemeSupport": [], - "unlock": true, - "vehicleFinder": false, - "vehicleStateSource": "LAST_STATE_CALL" - }, - "state": { - "chargingProfile": { - "chargingControlType": "WEEKLY_PLANNER", - "chargingMode": "DELAYED_CHARGING", - "chargingPreference": "CHARGING_WINDOW", - "chargingSettings": { - "hospitality": "NO_ACTION", - "idcc": "NO_ACTION", - "targetSoc": 100 - }, - "climatisationOn": false, - "departureTimes": [ - { - "action": "DEACTIVATE", - "id": 1, - "timeStamp": { "hour": 7, "minute": 35 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY" - ] - }, - { - "action": "DEACTIVATE", - "id": 2, - "timeStamp": { "hour": 18, "minute": 0 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ] - }, - { - "action": "DEACTIVATE", - "id": 3, - "timeStamp": { "hour": 7, "minute": 0 }, - "timerWeekDays": [] - }, - { "action": "DEACTIVATE", "id": 4, "timerWeekDays": [] } - ], - "reductionOfChargeCurrent": { - "end": { "hour": 1, "minute": 30 }, - "start": { "hour": 18, "minute": 1 } - } - }, - "checkControlMessages": [], - "climateTimers": [ - { - "departureTime": { "hour": 6, "minute": 40 }, - "isWeeklyTimer": true, - "timerAction": "ACTIVATE", - "timerWeekDays": ["THURSDAY", "SUNDAY"] - }, - { - "departureTime": { "hour": 12, "minute": 50 }, - "isWeeklyTimer": false, - "timerAction": "ACTIVATE", - "timerWeekDays": ["MONDAY"] - }, - { - "departureTime": { "hour": 18, "minute": 59 }, - "isWeeklyTimer": true, - "timerAction": "DEACTIVATE", - "timerWeekDays": ["WEDNESDAY"] - } - ], - "combustionFuelLevel": { - "range": 105, - "remainingFuelLiters": 6, - "remainingFuelPercent": 65 - }, - "currentMileage": 137009, - "doorsState": { - "combinedSecurityState": "UNLOCKED", - "combinedState": "CLOSED", - "hood": "CLOSED", - "leftFront": "CLOSED", - "leftRear": "CLOSED", - "rightFront": "CLOSED", - "rightRear": "CLOSED", - "trunk": "CLOSED" - }, - "driverPreferences": { "lscPrivacyMode": "OFF" }, - "electricChargingState": { - "chargingConnectionType": "CONDUCTIVE", - "chargingLevelPercent": 82, - "chargingStatus": "WAITING_FOR_CHARGING", - "chargingTarget": 100, - "isChargerConnected": true, - "range": 174 - }, - "isLeftSteering": true, - "isLscSupported": true, - "lastFetched": "2022-06-22T14:24:23.982Z", - "lastUpdatedAt": "2022-06-22T13:58:52Z", - "range": 174, - "requiredServices": [ - { - "dateTime": "2022-10-01T00:00:00.000Z", - "description": "Next service due by the specified date.", - "status": "OK", - "type": "BRAKE_FLUID" - }, - { - "dateTime": "2023-05-01T00:00:00.000Z", - "description": "Next vehicle check due after the specified distance or date.", - "status": "OK", - "type": "VEHICLE_CHECK" - }, - { - "dateTime": "2023-05-01T00:00:00.000Z", - "description": "Next state inspection due by the specified date.", - "status": "OK", - "type": "VEHICLE_TUV" - } - ], - "roofState": { "roofState": "CLOSED", "roofStateType": "SUN_ROOF" }, - "windowsState": { - "combinedState": "CLOSED", - "leftFront": "CLOSED", - "rightFront": "CLOSED" - } - } - } - }, - { - "filename": "bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT01.json", - "content": { - "chargeAndClimateSettings": { - "chargeAndClimateTimer": { "showDepartureTimers": false } - }, - "chargeAndClimateTimerDetail": { - "chargingMode": { - "chargingPreference": "CHARGING_WINDOW", - "endTimeSlot": "0001-01-01T01:30:00", - "startTimeSlot": "0001-01-01T18:01:00", - "type": "TIME_SLOT" - }, - "departureTimer": { - "type": "WEEKLY_DEPARTURE_TIMER", - "weeklyTimers": [ - { - "daysOfTheWeek": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY" - ], - "id": 1, - "time": "0001-01-01T07:35:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ], - "id": 2, - "time": "0001-01-01T18:00:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [], - "id": 3, - "time": "0001-01-01T07:00:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [], - "id": 4, - "time": "0001-01-01T00:00:00", - "timerAction": "DEACTIVATE" - } - ] - }, - "isPreconditionForDepartureActive": false - }, - "servicePack": "TCB1" - } - } - ] -} diff --git a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr new file mode 100644 index 0000000000..349706f593 --- /dev/null +++ b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr @@ -0,0 +1,2373 @@ +# serializer version: 1 +# name: test_config_entry_diagnostics + dict({ + 'data': list([ + dict({ + 'available_attributes': list([ + 'gps_position', + 'vin', + 'remaining_range_total', + 'mileage', + 'charging_time_remaining', + 'charging_start_time', + 'charging_end_time', + 'charging_time_label', + 'charging_status', + 'connection_status', + 'remaining_battery_percent', + 'remaining_range_electric', + 'last_charging_end_result', + 'ac_current_limit', + 'charging_target', + 'charging_mode', + 'charging_preferences', + 'is_pre_entry_climatization_enabled', + 'remaining_fuel', + 'remaining_range_fuel', + 'remaining_fuel_percent', + 'condition_based_services', + 'check_control_messages', + 'door_lock_state', + 'timestamp', + 'lids', + 'windows', + ]), + 'brand': 'bmw', + 'charging_profile': dict({ + 'ac_available_limits': None, + 'ac_current_limit': None, + 'charging_mode': 'DELAYED_CHARGING', + 'charging_preferences': 'CHARGING_WINDOW', + 'charging_preferences_service_pack': 'TCB1', + 'departure_times': list([ + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '07:35:00', + 'timer_id': 1, + 'weekdays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '18:00:00', + 'timer_id': 2, + 'weekdays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '07:00:00', + 'timer_id': 3, + 'weekdays': list([ + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timerWeekDays': list([ + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': None, + 'timer_id': 4, + 'weekdays': list([ + ]), + }), + ]), + 'is_pre_entry_climatization_enabled': False, + 'preferred_charging_window': dict({ + '_window_dict': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, + }), + }), + 'end_time': '01:30:00', + 'start_time': '18:01:00', + }), + 'timer_type': 'WEEKLY_PLANNER', + }), + 'check_control_messages': dict({ + 'has_check_control_messages': False, + 'messages': list([ + ]), + }), + 'condition_based_services': dict({ + 'is_service_required': False, + 'messages': list([ + dict({ + 'due_date': '2022-10-01T00:00:00+00:00', + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'BRAKE_FLUID', + 'state': 'OK', + }), + dict({ + 'due_date': '2023-05-01T00:00:00+00:00', + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'VEHICLE_CHECK', + 'state': 'OK', + }), + dict({ + 'due_date': '2023-05-01T00:00:00+00:00', + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'VEHICLE_TUV', + 'state': 'OK', + }), + ]), + }), + 'data': dict({ + 'appVehicleType': 'CONNECTED', + 'attributes': dict({ + 'a4aType': 'USB_ONLY', + 'bodyType': 'I01', + 'brand': 'BMW_I', + 'color': 4284110934, + 'countryOfOrigin': 'CZ', + 'driveTrain': 'ELECTRIC_WITH_RANGE_EXTENDER', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitType': 'NBT', + 'hmiVersion': 'ID4', + 'lastFetched': '2022-07-10T09:25:53.104Z', + 'model': 'i3 (+ REX)', + 'softwareVersionCurrent': dict({ + 'iStep': 510, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'I001', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 502, + 'puStep': dict({ + 'month': 3, + 'year': 15, + }), + 'seriesCluster': 'I001', + }), + 'year': 2015, + }), + 'capabilities': dict({ + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'climateTimerTrigger': 'DEPARTURE_TIMER', + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': False, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': False, + 'isChargingLoudnessEnabled': False, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': False, + 'isChargingSettingsEnabled': False, + 'isChargingTargetSocEnabled': False, + 'isClimateTimerSupported': True, + 'isCustomerEsimSupported': False, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': False, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remoteChargingCommands': dict({ + }), + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'unlock': True, + 'vehicleFinder': False, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'charging_settings': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'showDepartureTimers': False, + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'CHARGING_WINDOW', + 'endTimeSlot': '0001-01-01T01:30:00', + 'startTimeSlot': '0001-01-01T18:01:00', + 'type': 'TIME_SLOT', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + 'id': 1, + 'time': '0001-01-01T07:35:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + 'id': 2, + 'time': '0001-01-01T18:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T07:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'servicePack': 'TCB1', + }), + 'fetched_at': '2022-07-10T11:00:00+00:00', + 'is_metric': True, + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'mappingStatus': 'CONFIRMED', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'DELAYED_CHARGING', + 'chargingPreference': 'CHARGING_WINDOW', + 'chargingSettings': dict({ + 'hospitality': 'NO_ACTION', + 'idcc': 'NO_ACTION', + 'targetSoc': 100, + }), + 'climatisationOn': False, + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timerWeekDays': list([ + ]), + }), + ]), + 'reductionOfChargeCurrent': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, + }), + }), + }), + 'checkControlMessages': list([ + ]), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 6, + 'minute': 40, + }), + 'isWeeklyTimer': True, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'THURSDAY', + 'SUNDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 12, + 'minute': 50, + }), + 'isWeeklyTimer': False, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'MONDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 18, + 'minute': 59, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + 'WEDNESDAY', + ]), + }), + ]), + 'combustionFuelLevel': dict({ + 'range': 105, + 'remainingFuelLiters': 6, + 'remainingFuelPercent': 65, + }), + 'currentMileage': 137009, + 'doorsState': dict({ + 'combinedSecurityState': 'UNLOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'CONDUCTIVE', + 'chargingLevelPercent': 82, + 'chargingStatus': 'WAITING_FOR_CHARGING', + 'chargingTarget': 100, + 'isChargerConnected': True, + 'range': 174, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2022-06-22T14:24:23.982Z', + 'lastUpdatedAt': '2022-06-22T13:58:52Z', + 'range': 174, + 'requiredServices': list([ + dict({ + 'dateTime': '2022-10-01T00:00:00.000Z', + 'description': 'Next service due by the specified date.', + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next vehicle check due after the specified distance or date.', + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next state inspection due by the specified date.', + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + ]), + 'roofState': dict({ + 'roofState': 'CLOSED', + 'roofStateType': 'SUN_ROOF', + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'rightFront': 'CLOSED', + }), + }), + 'vin': '**REDACTED**', + }), + 'doors_and_windows': dict({ + 'all_lids_closed': True, + 'all_windows_closed': True, + 'door_lock_state': 'UNLOCKED', + 'lids': list([ + dict({ + 'is_closed': True, + 'name': 'hood', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'leftFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'leftRear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightRear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'trunk', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'sunRoof', + 'state': 'CLOSED', + }), + ]), + 'open_lids': list([ + ]), + 'open_windows': list([ + ]), + 'windows': list([ + dict({ + 'is_closed': True, + 'name': 'leftFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightFront', + 'state': 'CLOSED', + }), + ]), + }), + 'drive_train': 'ELECTRIC_WITH_RANGE_EXTENDER', + 'drive_train_attributes': list([ + 'remaining_range_total', + 'mileage', + 'charging_time_remaining', + 'charging_start_time', + 'charging_end_time', + 'charging_time_label', + 'charging_status', + 'connection_status', + 'remaining_battery_percent', + 'remaining_range_electric', + 'last_charging_end_result', + 'ac_current_limit', + 'charging_target', + 'charging_mode', + 'charging_preferences', + 'is_pre_entry_climatization_enabled', + 'remaining_fuel', + 'remaining_range_fuel', + 'remaining_fuel_percent', + ]), + 'fuel_and_battery': dict({ + 'account_timezone': dict({ + '_dst_offset': '0:00:00', + '_dst_saved': '0:00:00', + '_hasdst': False, + '_std_offset': '0:00:00', + '_tznames': list([ + 'UTC', + 'UTC', + ]), + }), + 'charging_end_time': None, + 'charging_start_time': '2022-07-10T18:01:00+00:00', + 'charging_start_time_no_tz': '2022-07-10T18:01:00', + 'charging_status': 'WAITING_FOR_CHARGING', + 'charging_target': 100, + 'is_charger_connected': True, + 'remaining_battery_percent': 82, + 'remaining_fuel': list([ + 6, + 'L', + ]), + 'remaining_fuel_percent': 65, + 'remaining_range_electric': list([ + 174, + 'km', + ]), + 'remaining_range_fuel': list([ + 105, + 'km', + ]), + 'remaining_range_total': list([ + 279, + 'km', + ]), + }), + 'has_combustion_drivetrain': True, + 'has_electric_drivetrain': True, + 'is_charging_plan_supported': True, + 'is_lsc_enabled': True, + 'is_remote_charge_start_enabled': False, + 'is_remote_charge_stop_enabled': False, + 'is_remote_climate_start_enabled': True, + 'is_remote_climate_stop_enabled': False, + 'is_remote_horn_enabled': True, + 'is_remote_lights_enabled': True, + 'is_remote_lock_enabled': True, + 'is_remote_sendpoi_enabled': True, + 'is_remote_set_ac_limit_enabled': False, + 'is_remote_set_target_soc_enabled': False, + 'is_remote_unlock_enabled': True, + 'is_vehicle_active': False, + 'is_vehicle_tracking_enabled': False, + 'lsc_type': 'ACTIVATED', + 'mileage': list([ + 137009, + 'km', + ]), + 'name': 'i3 (+ REX)', + 'timestamp': '2022-07-10T09:25:53+00:00', + 'vehicle_location': dict({ + 'account_region': 'row', + 'heading': None, + 'location': None, + 'remote_service_position': None, + 'vehicle_update_timestamp': '2022-07-10T09:25:53+00:00', + }), + 'vin': '**REDACTED**', + }), + ]), + 'fingerprint': list([ + dict({ + 'content': list([ + dict({ + 'appVehicleType': 'CONNECTED', + 'attributes': dict({ + 'a4aType': 'USB_ONLY', + 'bodyType': 'I01', + 'brand': 'BMW_I', + 'color': 4284110934, + 'countryOfOrigin': 'CZ', + 'driveTrain': 'ELECTRIC_WITH_RANGE_EXTENDER', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitType': 'NBT', + 'hmiVersion': 'ID4', + 'lastFetched': '2022-07-10T09:25:53.104Z', + 'model': 'i3 (+ REX)', + 'softwareVersionCurrent': dict({ + 'iStep': 510, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'I001', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 502, + 'puStep': dict({ + 'month': 3, + 'year': 15, + }), + 'seriesCluster': 'I001', + }), + 'year': 2015, + }), + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'mappingStatus': 'CONFIRMED', + }), + 'vin': '**REDACTED**', + }), + ]), + 'filename': 'bmw-eadrax-vcs_v4_vehicles.json', + }), + dict({ + 'content': list([ + ]), + 'filename': 'mini-eadrax-vcs_v4_vehicles.json', + }), + dict({ + 'content': dict({ + 'capabilities': dict({ + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'climateTimerTrigger': 'DEPARTURE_TIMER', + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': False, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': False, + 'isChargingLoudnessEnabled': False, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': False, + 'isChargingSettingsEnabled': False, + 'isChargingTargetSocEnabled': False, + 'isClimateTimerSupported': True, + 'isCustomerEsimSupported': False, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': False, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remoteChargingCommands': dict({ + }), + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'unlock': True, + 'vehicleFinder': False, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'DELAYED_CHARGING', + 'chargingPreference': 'CHARGING_WINDOW', + 'chargingSettings': dict({ + 'hospitality': 'NO_ACTION', + 'idcc': 'NO_ACTION', + 'targetSoc': 100, + }), + 'climatisationOn': False, + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timerWeekDays': list([ + ]), + }), + ]), + 'reductionOfChargeCurrent': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, + }), + }), + }), + 'checkControlMessages': list([ + ]), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 6, + 'minute': 40, + }), + 'isWeeklyTimer': True, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'THURSDAY', + 'SUNDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 12, + 'minute': 50, + }), + 'isWeeklyTimer': False, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'MONDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 18, + 'minute': 59, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + 'WEDNESDAY', + ]), + }), + ]), + 'combustionFuelLevel': dict({ + 'range': 105, + 'remainingFuelLiters': 6, + 'remainingFuelPercent': 65, + }), + 'currentMileage': 137009, + 'doorsState': dict({ + 'combinedSecurityState': 'UNLOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'CONDUCTIVE', + 'chargingLevelPercent': 82, + 'chargingStatus': 'WAITING_FOR_CHARGING', + 'chargingTarget': 100, + 'isChargerConnected': True, + 'range': 174, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2022-06-22T14:24:23.982Z', + 'lastUpdatedAt': '2022-06-22T13:58:52Z', + 'range': 174, + 'requiredServices': list([ + dict({ + 'dateTime': '2022-10-01T00:00:00.000Z', + 'description': 'Next service due by the specified date.', + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next vehicle check due after the specified distance or date.', + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next state inspection due by the specified date.', + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + ]), + 'roofState': dict({ + 'roofState': 'CLOSED', + 'roofStateType': 'SUN_ROOF', + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'rightFront': 'CLOSED', + }), + }), + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT01.json', + }), + dict({ + 'content': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'showDepartureTimers': False, + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'CHARGING_WINDOW', + 'endTimeSlot': '0001-01-01T01:30:00', + 'startTimeSlot': '0001-01-01T18:01:00', + 'type': 'TIME_SLOT', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + 'id': 1, + 'time': '0001-01-01T07:35:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + 'id': 2, + 'time': '0001-01-01T18:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T07:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'servicePack': 'TCB1', + }), + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT01.json', + }), + ]), + 'info': dict({ + 'password': '**REDACTED**', + 'refresh_token': '**REDACTED**', + 'region': 'rest_of_world', + 'username': '**REDACTED**', + }), + }) +# --- +# name: test_device_diagnostics + dict({ + 'data': dict({ + 'available_attributes': list([ + 'gps_position', + 'vin', + 'remaining_range_total', + 'mileage', + 'charging_time_remaining', + 'charging_start_time', + 'charging_end_time', + 'charging_time_label', + 'charging_status', + 'connection_status', + 'remaining_battery_percent', + 'remaining_range_electric', + 'last_charging_end_result', + 'ac_current_limit', + 'charging_target', + 'charging_mode', + 'charging_preferences', + 'is_pre_entry_climatization_enabled', + 'remaining_fuel', + 'remaining_range_fuel', + 'remaining_fuel_percent', + 'condition_based_services', + 'check_control_messages', + 'door_lock_state', + 'timestamp', + 'lids', + 'windows', + ]), + 'brand': 'bmw', + 'charging_profile': dict({ + 'ac_available_limits': None, + 'ac_current_limit': None, + 'charging_mode': 'DELAYED_CHARGING', + 'charging_preferences': 'CHARGING_WINDOW', + 'charging_preferences_service_pack': 'TCB1', + 'departure_times': list([ + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '07:35:00', + 'timer_id': 1, + 'weekdays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '18:00:00', + 'timer_id': 2, + 'weekdays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '07:00:00', + 'timer_id': 3, + 'weekdays': list([ + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timerWeekDays': list([ + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': None, + 'timer_id': 4, + 'weekdays': list([ + ]), + }), + ]), + 'is_pre_entry_climatization_enabled': False, + 'preferred_charging_window': dict({ + '_window_dict': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, + }), + }), + 'end_time': '01:30:00', + 'start_time': '18:01:00', + }), + 'timer_type': 'WEEKLY_PLANNER', + }), + 'check_control_messages': dict({ + 'has_check_control_messages': False, + 'messages': list([ + ]), + }), + 'condition_based_services': dict({ + 'is_service_required': False, + 'messages': list([ + dict({ + 'due_date': '2022-10-01T00:00:00+00:00', + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'BRAKE_FLUID', + 'state': 'OK', + }), + dict({ + 'due_date': '2023-05-01T00:00:00+00:00', + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'VEHICLE_CHECK', + 'state': 'OK', + }), + dict({ + 'due_date': '2023-05-01T00:00:00+00:00', + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'VEHICLE_TUV', + 'state': 'OK', + }), + ]), + }), + 'data': dict({ + 'appVehicleType': 'CONNECTED', + 'attributes': dict({ + 'a4aType': 'USB_ONLY', + 'bodyType': 'I01', + 'brand': 'BMW_I', + 'color': 4284110934, + 'countryOfOrigin': 'CZ', + 'driveTrain': 'ELECTRIC_WITH_RANGE_EXTENDER', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitType': 'NBT', + 'hmiVersion': 'ID4', + 'lastFetched': '2022-07-10T09:25:53.104Z', + 'model': 'i3 (+ REX)', + 'softwareVersionCurrent': dict({ + 'iStep': 510, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'I001', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 502, + 'puStep': dict({ + 'month': 3, + 'year': 15, + }), + 'seriesCluster': 'I001', + }), + 'year': 2015, + }), + 'capabilities': dict({ + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'climateTimerTrigger': 'DEPARTURE_TIMER', + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': False, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': False, + 'isChargingLoudnessEnabled': False, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': False, + 'isChargingSettingsEnabled': False, + 'isChargingTargetSocEnabled': False, + 'isClimateTimerSupported': True, + 'isCustomerEsimSupported': False, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': False, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remoteChargingCommands': dict({ + }), + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'unlock': True, + 'vehicleFinder': False, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'charging_settings': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'showDepartureTimers': False, + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'CHARGING_WINDOW', + 'endTimeSlot': '0001-01-01T01:30:00', + 'startTimeSlot': '0001-01-01T18:01:00', + 'type': 'TIME_SLOT', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + 'id': 1, + 'time': '0001-01-01T07:35:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + 'id': 2, + 'time': '0001-01-01T18:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T07:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'servicePack': 'TCB1', + }), + 'fetched_at': '2022-07-10T11:00:00+00:00', + 'is_metric': True, + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'mappingStatus': 'CONFIRMED', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'DELAYED_CHARGING', + 'chargingPreference': 'CHARGING_WINDOW', + 'chargingSettings': dict({ + 'hospitality': 'NO_ACTION', + 'idcc': 'NO_ACTION', + 'targetSoc': 100, + }), + 'climatisationOn': False, + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timerWeekDays': list([ + ]), + }), + ]), + 'reductionOfChargeCurrent': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, + }), + }), + }), + 'checkControlMessages': list([ + ]), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 6, + 'minute': 40, + }), + 'isWeeklyTimer': True, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'THURSDAY', + 'SUNDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 12, + 'minute': 50, + }), + 'isWeeklyTimer': False, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'MONDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 18, + 'minute': 59, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + 'WEDNESDAY', + ]), + }), + ]), + 'combustionFuelLevel': dict({ + 'range': 105, + 'remainingFuelLiters': 6, + 'remainingFuelPercent': 65, + }), + 'currentMileage': 137009, + 'doorsState': dict({ + 'combinedSecurityState': 'UNLOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'CONDUCTIVE', + 'chargingLevelPercent': 82, + 'chargingStatus': 'WAITING_FOR_CHARGING', + 'chargingTarget': 100, + 'isChargerConnected': True, + 'range': 174, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2022-06-22T14:24:23.982Z', + 'lastUpdatedAt': '2022-06-22T13:58:52Z', + 'range': 174, + 'requiredServices': list([ + dict({ + 'dateTime': '2022-10-01T00:00:00.000Z', + 'description': 'Next service due by the specified date.', + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next vehicle check due after the specified distance or date.', + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next state inspection due by the specified date.', + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + ]), + 'roofState': dict({ + 'roofState': 'CLOSED', + 'roofStateType': 'SUN_ROOF', + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'rightFront': 'CLOSED', + }), + }), + 'vin': '**REDACTED**', + }), + 'doors_and_windows': dict({ + 'all_lids_closed': True, + 'all_windows_closed': True, + 'door_lock_state': 'UNLOCKED', + 'lids': list([ + dict({ + 'is_closed': True, + 'name': 'hood', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'leftFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'leftRear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightRear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'trunk', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'sunRoof', + 'state': 'CLOSED', + }), + ]), + 'open_lids': list([ + ]), + 'open_windows': list([ + ]), + 'windows': list([ + dict({ + 'is_closed': True, + 'name': 'leftFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightFront', + 'state': 'CLOSED', + }), + ]), + }), + 'drive_train': 'ELECTRIC_WITH_RANGE_EXTENDER', + 'drive_train_attributes': list([ + 'remaining_range_total', + 'mileage', + 'charging_time_remaining', + 'charging_start_time', + 'charging_end_time', + 'charging_time_label', + 'charging_status', + 'connection_status', + 'remaining_battery_percent', + 'remaining_range_electric', + 'last_charging_end_result', + 'ac_current_limit', + 'charging_target', + 'charging_mode', + 'charging_preferences', + 'is_pre_entry_climatization_enabled', + 'remaining_fuel', + 'remaining_range_fuel', + 'remaining_fuel_percent', + ]), + 'fuel_and_battery': dict({ + 'account_timezone': dict({ + '_dst_offset': '0:00:00', + '_dst_saved': '0:00:00', + '_hasdst': False, + '_std_offset': '0:00:00', + '_tznames': list([ + 'UTC', + 'UTC', + ]), + }), + 'charging_end_time': None, + 'charging_start_time': '2022-07-10T18:01:00+00:00', + 'charging_start_time_no_tz': '2022-07-10T18:01:00', + 'charging_status': 'WAITING_FOR_CHARGING', + 'charging_target': 100, + 'is_charger_connected': True, + 'remaining_battery_percent': 82, + 'remaining_fuel': list([ + 6, + 'L', + ]), + 'remaining_fuel_percent': 65, + 'remaining_range_electric': list([ + 174, + 'km', + ]), + 'remaining_range_fuel': list([ + 105, + 'km', + ]), + 'remaining_range_total': list([ + 279, + 'km', + ]), + }), + 'has_combustion_drivetrain': True, + 'has_electric_drivetrain': True, + 'is_charging_plan_supported': True, + 'is_lsc_enabled': True, + 'is_remote_charge_start_enabled': False, + 'is_remote_charge_stop_enabled': False, + 'is_remote_climate_start_enabled': True, + 'is_remote_climate_stop_enabled': False, + 'is_remote_horn_enabled': True, + 'is_remote_lights_enabled': True, + 'is_remote_lock_enabled': True, + 'is_remote_sendpoi_enabled': True, + 'is_remote_set_ac_limit_enabled': False, + 'is_remote_set_target_soc_enabled': False, + 'is_remote_unlock_enabled': True, + 'is_vehicle_active': False, + 'is_vehicle_tracking_enabled': False, + 'lsc_type': 'ACTIVATED', + 'mileage': list([ + 137009, + 'km', + ]), + 'name': 'i3 (+ REX)', + 'timestamp': '2022-07-10T09:25:53+00:00', + 'vehicle_location': dict({ + 'account_region': 'row', + 'heading': None, + 'location': None, + 'remote_service_position': None, + 'vehicle_update_timestamp': '2022-07-10T09:25:53+00:00', + }), + 'vin': '**REDACTED**', + }), + 'fingerprint': list([ + dict({ + 'content': list([ + dict({ + 'appVehicleType': 'CONNECTED', + 'attributes': dict({ + 'a4aType': 'USB_ONLY', + 'bodyType': 'I01', + 'brand': 'BMW_I', + 'color': 4284110934, + 'countryOfOrigin': 'CZ', + 'driveTrain': 'ELECTRIC_WITH_RANGE_EXTENDER', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitType': 'NBT', + 'hmiVersion': 'ID4', + 'lastFetched': '2022-07-10T09:25:53.104Z', + 'model': 'i3 (+ REX)', + 'softwareVersionCurrent': dict({ + 'iStep': 510, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'I001', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 502, + 'puStep': dict({ + 'month': 3, + 'year': 15, + }), + 'seriesCluster': 'I001', + }), + 'year': 2015, + }), + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'mappingStatus': 'CONFIRMED', + }), + 'vin': '**REDACTED**', + }), + ]), + 'filename': 'bmw-eadrax-vcs_v4_vehicles.json', + }), + dict({ + 'content': list([ + ]), + 'filename': 'mini-eadrax-vcs_v4_vehicles.json', + }), + dict({ + 'content': dict({ + 'capabilities': dict({ + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'climateTimerTrigger': 'DEPARTURE_TIMER', + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': False, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': False, + 'isChargingLoudnessEnabled': False, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': False, + 'isChargingSettingsEnabled': False, + 'isChargingTargetSocEnabled': False, + 'isClimateTimerSupported': True, + 'isCustomerEsimSupported': False, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': False, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remoteChargingCommands': dict({ + }), + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'unlock': True, + 'vehicleFinder': False, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'DELAYED_CHARGING', + 'chargingPreference': 'CHARGING_WINDOW', + 'chargingSettings': dict({ + 'hospitality': 'NO_ACTION', + 'idcc': 'NO_ACTION', + 'targetSoc': 100, + }), + 'climatisationOn': False, + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timerWeekDays': list([ + ]), + }), + ]), + 'reductionOfChargeCurrent': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, + }), + }), + }), + 'checkControlMessages': list([ + ]), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 6, + 'minute': 40, + }), + 'isWeeklyTimer': True, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'THURSDAY', + 'SUNDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 12, + 'minute': 50, + }), + 'isWeeklyTimer': False, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'MONDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 18, + 'minute': 59, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + 'WEDNESDAY', + ]), + }), + ]), + 'combustionFuelLevel': dict({ + 'range': 105, + 'remainingFuelLiters': 6, + 'remainingFuelPercent': 65, + }), + 'currentMileage': 137009, + 'doorsState': dict({ + 'combinedSecurityState': 'UNLOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'CONDUCTIVE', + 'chargingLevelPercent': 82, + 'chargingStatus': 'WAITING_FOR_CHARGING', + 'chargingTarget': 100, + 'isChargerConnected': True, + 'range': 174, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2022-06-22T14:24:23.982Z', + 'lastUpdatedAt': '2022-06-22T13:58:52Z', + 'range': 174, + 'requiredServices': list([ + dict({ + 'dateTime': '2022-10-01T00:00:00.000Z', + 'description': 'Next service due by the specified date.', + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next vehicle check due after the specified distance or date.', + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next state inspection due by the specified date.', + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + ]), + 'roofState': dict({ + 'roofState': 'CLOSED', + 'roofStateType': 'SUN_ROOF', + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'rightFront': 'CLOSED', + }), + }), + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT01.json', + }), + dict({ + 'content': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'showDepartureTimers': False, + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'CHARGING_WINDOW', + 'endTimeSlot': '0001-01-01T01:30:00', + 'startTimeSlot': '0001-01-01T18:01:00', + 'type': 'TIME_SLOT', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + 'id': 1, + 'time': '0001-01-01T07:35:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + 'id': 2, + 'time': '0001-01-01T18:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T07:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'servicePack': 'TCB1', + }), + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT01.json', + }), + ]), + 'info': dict({ + 'password': '**REDACTED**', + 'refresh_token': '**REDACTED**', + 'region': 'rest_of_world', + 'username': '**REDACTED**', + }), + }) +# --- +# name: test_device_diagnostics_vehicle_not_found + dict({ + 'data': None, + 'fingerprint': list([ + dict({ + 'content': list([ + dict({ + 'appVehicleType': 'CONNECTED', + 'attributes': dict({ + 'a4aType': 'USB_ONLY', + 'bodyType': 'I01', + 'brand': 'BMW_I', + 'color': 4284110934, + 'countryOfOrigin': 'CZ', + 'driveTrain': 'ELECTRIC_WITH_RANGE_EXTENDER', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitType': 'NBT', + 'hmiVersion': 'ID4', + 'lastFetched': '2022-07-10T09:25:53.104Z', + 'model': 'i3 (+ REX)', + 'softwareVersionCurrent': dict({ + 'iStep': 510, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'I001', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 502, + 'puStep': dict({ + 'month': 3, + 'year': 15, + }), + 'seriesCluster': 'I001', + }), + 'year': 2015, + }), + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'mappingStatus': 'CONFIRMED', + }), + 'vin': '**REDACTED**', + }), + ]), + 'filename': 'bmw-eadrax-vcs_v4_vehicles.json', + }), + dict({ + 'content': list([ + ]), + 'filename': 'mini-eadrax-vcs_v4_vehicles.json', + }), + dict({ + 'content': dict({ + 'capabilities': dict({ + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'climateTimerTrigger': 'DEPARTURE_TIMER', + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': False, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': False, + 'isChargingLoudnessEnabled': False, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': False, + 'isChargingSettingsEnabled': False, + 'isChargingTargetSocEnabled': False, + 'isClimateTimerSupported': True, + 'isCustomerEsimSupported': False, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': False, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remoteChargingCommands': dict({ + }), + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'unlock': True, + 'vehicleFinder': False, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'DELAYED_CHARGING', + 'chargingPreference': 'CHARGING_WINDOW', + 'chargingSettings': dict({ + 'hospitality': 'NO_ACTION', + 'idcc': 'NO_ACTION', + 'targetSoc': 100, + }), + 'climatisationOn': False, + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timerWeekDays': list([ + ]), + }), + ]), + 'reductionOfChargeCurrent': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, + }), + }), + }), + 'checkControlMessages': list([ + ]), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 6, + 'minute': 40, + }), + 'isWeeklyTimer': True, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'THURSDAY', + 'SUNDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 12, + 'minute': 50, + }), + 'isWeeklyTimer': False, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'MONDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 18, + 'minute': 59, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + 'WEDNESDAY', + ]), + }), + ]), + 'combustionFuelLevel': dict({ + 'range': 105, + 'remainingFuelLiters': 6, + 'remainingFuelPercent': 65, + }), + 'currentMileage': 137009, + 'doorsState': dict({ + 'combinedSecurityState': 'UNLOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'CONDUCTIVE', + 'chargingLevelPercent': 82, + 'chargingStatus': 'WAITING_FOR_CHARGING', + 'chargingTarget': 100, + 'isChargerConnected': True, + 'range': 174, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2022-06-22T14:24:23.982Z', + 'lastUpdatedAt': '2022-06-22T13:58:52Z', + 'range': 174, + 'requiredServices': list([ + dict({ + 'dateTime': '2022-10-01T00:00:00.000Z', + 'description': 'Next service due by the specified date.', + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next vehicle check due after the specified distance or date.', + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next state inspection due by the specified date.', + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + ]), + 'roofState': dict({ + 'roofState': 'CLOSED', + 'roofStateType': 'SUN_ROOF', + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'rightFront': 'CLOSED', + }), + }), + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT01.json', + }), + dict({ + 'content': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'showDepartureTimers': False, + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'CHARGING_WINDOW', + 'endTimeSlot': '0001-01-01T01:30:00', + 'startTimeSlot': '0001-01-01T18:01:00', + 'type': 'TIME_SLOT', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + 'id': 1, + 'time': '0001-01-01T07:35:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + 'id': 2, + 'time': '0001-01-01T18:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T07:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'servicePack': 'TCB1', + }), + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT01.json', + }), + ]), + 'info': dict({ + 'password': '**REDACTED**', + 'refresh_token': '**REDACTED**', + 'region': 'rest_of_world', + 'username': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/bmw_connected_drive/test_diagnostics.py b/tests/components/bmw_connected_drive/test_diagnostics.py index a186a52bcd..0509409ad0 100644 --- a/tests/components/bmw_connected_drive/test_diagnostics.py +++ b/tests/components/bmw_connected_drive/test_diagnostics.py @@ -1,10 +1,10 @@ """Test BMW diagnostics.""" import datetime -import json import os import time import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.bmw_connected_drive.const import DOMAIN from homeassistant.core import HomeAssistant @@ -12,7 +12,6 @@ from homeassistant.helpers import device_registry as dr from . import setup_mocked_integration -from tests.common import load_fixture from tests.components.diagnostics import ( get_diagnostics_for_config_entry, get_diagnostics_for_device, @@ -22,7 +21,10 @@ from tests.typing import ClientSessionGenerator @pytest.mark.freeze_time(datetime.datetime(2022, 7, 10, 11)) async def test_config_entry_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator, bmw_fixture + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + bmw_fixture, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" @@ -36,16 +38,15 @@ async def test_config_entry_diagnostics( hass, hass_client, mock_config_entry ) - diagnostics_fixture = json.loads( - load_fixture("diagnostics/diagnostics_config_entry.json", DOMAIN) - ) - - assert diagnostics == diagnostics_fixture + assert diagnostics == snapshot @pytest.mark.freeze_time(datetime.datetime(2022, 7, 10, 11)) async def test_device_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator, bmw_fixture + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + bmw_fixture, + snapshot: SnapshotAssertion, ) -> None: """Test device diagnostics.""" @@ -65,16 +66,15 @@ async def test_device_diagnostics( hass, hass_client, mock_config_entry, reg_device ) - diagnostics_fixture = json.loads( - load_fixture("diagnostics/diagnostics_device.json", DOMAIN) - ) - - assert diagnostics == diagnostics_fixture + assert diagnostics == snapshot @pytest.mark.freeze_time(datetime.datetime(2022, 7, 10, 11)) async def test_device_diagnostics_vehicle_not_found( - hass: HomeAssistant, hass_client: ClientSessionGenerator, bmw_fixture + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + bmw_fixture, + snapshot: SnapshotAssertion, ) -> None: """Test device diagnostics when the vehicle cannot be found.""" @@ -99,10 +99,4 @@ async def test_device_diagnostics_vehicle_not_found( hass, hass_client, mock_config_entry, reg_device ) - diagnostics_fixture = json.loads( - load_fixture("diagnostics/diagnostics_device.json", DOMAIN) - ) - # Mock empty data if car is not found in account anymore - diagnostics_fixture["data"] = None - - assert diagnostics == diagnostics_fixture + assert diagnostics == snapshot From 6e92dac61ff095bcbb541a1639007c66d70b1f6b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 26 Mar 2023 18:37:26 +0200 Subject: [PATCH 1263/2810] Adjust pylint plugin for return type inheritance (#90046) --- pylint/plugins/hass_enforce_type_hints.py | 13 +++---------- tests/pylint/test_enforce_type_hints.py | 6 ++++++ 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index ba0a511c57..8dbb041fa9 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -42,7 +42,6 @@ class TypeHintMatch: """named_arg_types is for named or keyword arguments""" kwargs_type: str | None = None """kwargs_type is for the special case `**kwargs`""" - check_return_type_inheritance: bool = False has_async_counterpart: bool = False def need_to_check_function(self, node: nodes.FunctionDef) -> bool: @@ -398,7 +397,6 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "ConfigType", }, return_type=["DeviceScanner", None], - check_return_type_inheritance=True, has_async_counterpart=True, ), ], @@ -466,7 +464,6 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 2: "DiscoveryInfoType | None", }, return_type=["BaseNotificationService", None], - check_return_type_inheritance=True, has_async_counterpart=True, ), ], @@ -493,7 +490,6 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { 0: "ConfigEntry", }, return_type="OptionsFlow", - check_return_type_inheritance=True, ), TypeHintMatch( function_name="async_step_dhcp", @@ -681,7 +677,6 @@ _RESTORE_ENTITY_MATCH: list[TypeHintMatch] = [ TypeHintMatch( function_name="extra_restore_state_data", return_type=["ExtraStoredData", None], - check_return_type_inheritance=True, ), ] _TOGGLE_ENTITY_MATCH: list[TypeHintMatch] = [ @@ -2842,15 +2837,13 @@ def _is_valid_return_type(match: TypeHintMatch, node: nodes.NodeNG) -> bool: match, node.right ) - if ( - match.check_return_type_inheritance - and isinstance(match.return_type, (str, list)) - and isinstance(node, nodes.Name) - ): + if isinstance(match.return_type, (str, list)) and isinstance(node, nodes.Name): if isinstance(match.return_type, str): valid_types = {match.return_type} else: valid_types = {el for el in match.return_type if isinstance(el, str)} + if "Mapping[str, Any]" in valid_types: + valid_types.add("TypedDict") try: for infer_node in node.infer(): diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py index 365ccc111d..c580658b54 100644 --- a/tests/pylint/test_enforce_type_hints.py +++ b/tests/pylint/test_enforce_type_hints.py @@ -724,6 +724,7 @@ def test_invalid_mapping_return_type( "-> Mapping[str, bool | int]", "-> dict[str, Any]", "-> dict[str, str]", + "-> CustomTypedDict", ], ) def test_valid_mapping_return_type( @@ -737,6 +738,11 @@ def test_valid_mapping_return_type( class_node = astroid.extract_node( f""" + from typing import TypedDict + + class CustomTypedDict(TypedDict): + pass + class Entity(): pass From 1baadc1d09ed9e856dc6083545da805c0b5dba15 Mon Sep 17 00:00:00 2001 From: Niels Perfors Date: Sun, 26 Mar 2023 19:32:25 +0200 Subject: [PATCH 1264/2810] Update Verisure package to 2.6.1 (#89318) Co-authored-by: Franck Nijhof Co-authored-by: RobinBolder <33325401+RobinBolder@users.noreply.github.com> Co-authored-by: Tobias Lindaaker --- CODEOWNERS | 4 +- homeassistant/components/verisure/__init__.py | 10 +- .../verisure/alarm_control_panel.py | 34 +++-- .../components/verisure/binary_sensor.py | 4 +- homeassistant/components/verisure/camera.py | 19 ++- .../components/verisure/config_flow.py | 30 ++-- homeassistant/components/verisure/const.py | 3 + .../components/verisure/coordinator.py | 140 +++++++++++++----- .../components/verisure/diagnostics.py | 1 + homeassistant/components/verisure/lock.py | 54 ++++--- .../components/verisure/manifest.json | 4 +- homeassistant/components/verisure/sensor.py | 80 ++-------- homeassistant/components/verisure/switch.py | 33 +++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/verisure/conftest.py | 22 ++- tests/components/verisure/test_config_flow.py | 71 ++++----- 17 files changed, 294 insertions(+), 219 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 617fc46c27..ff31997ce6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1293,8 +1293,8 @@ build.json @home-assistant/supervisor /homeassistant/components/velux/ @Julius2342 /homeassistant/components/venstar/ @garbled1 /tests/components/venstar/ @garbled1 -/homeassistant/components/verisure/ @frenck -/tests/components/verisure/ @frenck +/homeassistant/components/verisure/ @frenck @niro1987 +/tests/components/verisure/ @frenck @niro1987 /homeassistant/components/versasense/ @flamm3blemuff1n /homeassistant/components/version/ @ludeeus /tests/components/version/ @ludeeus diff --git a/homeassistant/components/verisure/__init__.py b/homeassistant/components/verisure/__init__.py index 9ad8db08d5..94e8d667d7 100644 --- a/homeassistant/components/verisure/__init__.py +++ b/homeassistant/components/verisure/__init__.py @@ -6,9 +6,9 @@ import os from pathlib import Path from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EMAIL, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.const import CONF_EMAIL, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.storage import STORAGE_DIR @@ -34,11 +34,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = VerisureDataUpdateCoordinator(hass, entry=entry) if not await coordinator.async_login(): - raise ConfigEntryAuthFailed - - entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, coordinator.async_logout) - ) + raise ConfigEntryNotReady("Could not log in to verisure.") await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/verisure/alarm_control_panel.py b/homeassistant/components/verisure/alarm_control_panel.py index 5030e01c8b..0cfd6ebb81 100644 --- a/homeassistant/components/verisure/alarm_control_panel.py +++ b/homeassistant/components/verisure/alarm_control_panel.py @@ -55,33 +55,49 @@ class VerisureAlarm( """Return the unique ID for this entity.""" return self.coordinator.entry.data[CONF_GIID] - async def _async_set_arm_state(self, state: str, code: str | None = None) -> None: + async def _async_set_arm_state( + self, state: str, command_data: dict[str, str | dict[str, str]] + ) -> None: """Send set arm state command.""" arm_state = await self.hass.async_add_executor_job( - self.coordinator.verisure.set_arm_state, code, state + self.coordinator.verisure.request, command_data ) LOGGER.debug("Verisure set arm state %s", state) - transaction = {} - while "result" not in transaction: + result = None + while result is None: await asyncio.sleep(0.5) transaction = await self.hass.async_add_executor_job( - self.coordinator.verisure.get_arm_state_transaction, - arm_state["armStateChangeTransactionId"], + self.coordinator.verisure.request, + self.coordinator.verisure.poll_arm_state( + list(arm_state["data"].values())[0], state + ), + ) + result = ( + transaction.get("data", {}) + .get("installation", {}) + .get("armStateChangePollResult", {}) + .get("result") ) await self.coordinator.async_refresh() async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - await self._async_set_arm_state("DISARMED", code) + await self._async_set_arm_state( + "DISARMED", self.coordinator.verisure.disarm(code) + ) async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - await self._async_set_arm_state("ARMED_HOME", code) + await self._async_set_arm_state( + "ARMED_HOME", self.coordinator.verisure.arm_home(code) + ) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - await self._async_set_arm_state("ARMED_AWAY", code) + await self._async_set_arm_state( + "ARMED_AWAY", self.coordinator.verisure.arm_away(code) + ) @callback def _handle_coordinator_update(self) -> None: diff --git a/homeassistant/components/verisure/binary_sensor.py b/homeassistant/components/verisure/binary_sensor.py index 8283480a14..536b96ea2c 100644 --- a/homeassistant/components/verisure/binary_sensor.py +++ b/homeassistant/components/verisure/binary_sensor.py @@ -109,9 +109,9 @@ class VerisureEthernetStatus( @property def is_on(self) -> bool: """Return the state of the sensor.""" - return self.coordinator.data["ethernet"] + return self.coordinator.data["broadband"]["isBroadbandConnected"] @property def available(self) -> bool: """Return True if entity is available.""" - return super().available and self.coordinator.data["ethernet"] is not None + return super().available and self.coordinator.data["broadband"] is not None diff --git a/homeassistant/components/verisure/camera.py b/homeassistant/components/verisure/camera.py index 98ed41c5b9..1f890a22a6 100644 --- a/homeassistant/components/verisure/camera.py +++ b/homeassistant/components/verisure/camera.py @@ -63,12 +63,12 @@ class VerisureSmartcam(CoordinatorEntity[VerisureDataUpdateCoordinator], Camera) self.serial_number = serial_number self._directory_path = directory_path self._image: str | None = None - self._image_id = None + self._image_id: str | None = None @property def device_info(self) -> DeviceInfo: """Return device information about this entity.""" - area = self.coordinator.data["cameras"][self.serial_number]["area"] + area = self.coordinator.data["cameras"][self.serial_number]["device"]["area"] return DeviceInfo( name=area, suggested_area=area, @@ -95,16 +95,16 @@ class VerisureSmartcam(CoordinatorEntity[VerisureDataUpdateCoordinator], Camera) """Check the contents of the image list.""" self.coordinator.update_smartcam_imageseries() - images = self.coordinator.imageseries.get("imageSeries", []) - new_image_id = None - for image in images: + new_image = None + for image in self.coordinator.imageseries: if image["deviceLabel"] == self.serial_number: - new_image_id = image["image"][0]["imageId"] + new_image = image break - if not new_image_id: + if not new_image: return + new_image_id = new_image["mediaId"] if new_image_id in ("-1", self._image_id): LOGGER.debug("The image is the same, or loading image_id") return @@ -113,9 +113,8 @@ class VerisureSmartcam(CoordinatorEntity[VerisureDataUpdateCoordinator], Camera) new_image_path = os.path.join( self._directory_path, "{}{}".format(new_image_id, ".jpg") ) - self.coordinator.verisure.download_image( - self.serial_number, new_image_id, new_image_path - ) + new_image_url = new_image["contentUrl"] + self.coordinator.verisure.download_image(new_image_url, new_image_path) LOGGER.debug("Old image_id=%s", self._image_id) self.delete_image() diff --git a/homeassistant/components/verisure/config_flow.py b/homeassistant/components/verisure/config_flow.py index d53c7c9ed6..9392cdd9bc 100644 --- a/homeassistant/components/verisure/config_flow.py +++ b/homeassistant/components/verisure/config_flow.py @@ -56,7 +56,7 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): self.verisure = Verisure( username=self.email, password=self.password, - cookieFileName=self.hass.config.path( + cookie_file_name=self.hass.config.path( STORAGE_DIR, f"verisure_{user_input[CONF_EMAIL]}" ), ) @@ -66,7 +66,9 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): except VerisureLoginError as ex: if "Multifactor authentication enabled" in str(ex): try: - await self.hass.async_add_executor_job(self.verisure.login_mfa) + await self.hass.async_add_executor_job( + self.verisure.request_mfa + ) except ( VerisureLoginError, VerisureError, @@ -108,9 +110,8 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: try: await self.hass.async_add_executor_job( - self.verisure.mfa_validate, user_input[CONF_CODE], True + self.verisure.validate_mfa, user_input[CONF_CODE] ) - await self.hass.async_add_executor_job(self.verisure.login) except VerisureLoginError as ex: LOGGER.debug("Could not log in to Verisure, %s", ex) errors["base"] = "invalid_auth" @@ -136,9 +137,16 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Select Verisure installation to add.""" + installations_data = await self.hass.async_add_executor_job( + self.verisure.get_installations + ) installations = { - inst["giid"]: f"{inst['alias']} ({inst['street']})" - for inst in self.verisure.installations or [] + inst["giid"]: f"{inst['alias']} ({inst['address']['street']})" + for inst in ( + installations_data.get("data", {}) + .get("account", {}) + .get("installations", []) + ) } if user_input is None: @@ -184,8 +192,8 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): self.verisure = Verisure( username=self.email, password=self.password, - cookieFileName=self.hass.config.path( - STORAGE_DIR, f"verisure-{user_input[CONF_EMAIL]}" + cookie_file_name=self.hass.config.path( + STORAGE_DIR, f"verisure_{user_input[CONF_EMAIL]}" ), ) @@ -194,7 +202,9 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): except VerisureLoginError as ex: if "Multifactor authentication enabled" in str(ex): try: - await self.hass.async_add_executor_job(self.verisure.login_mfa) + await self.hass.async_add_executor_job( + self.verisure.request_mfa + ) except ( VerisureLoginError, VerisureError, @@ -248,7 +258,7 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: try: await self.hass.async_add_executor_job( - self.verisure.mfa_validate, user_input[CONF_CODE], True + self.verisure.validate_mfa, user_input[CONF_CODE] ) await self.hass.async_add_executor_job(self.verisure.login) except VerisureLoginError as ex: diff --git a/homeassistant/components/verisure/const.py b/homeassistant/components/verisure/const.py index e8720baa1d..ac30c58fde 100644 --- a/homeassistant/components/verisure/const.py +++ b/homeassistant/components/verisure/const.py @@ -36,6 +36,9 @@ DEVICE_TYPE_NAME = { "SMOKE3": "Smoke detector", "VOICEBOX1": "VoiceBox", "WATER1": "Water detector", + "SMOKE": "Smoke detector", + "SIREN": "Siren", + "VOICEBOX": "VoiceBox", } ALARM_STATE_TO_HA = { diff --git a/homeassistant/components/verisure/coordinator.py b/homeassistant/components/verisure/coordinator.py index 17cadb9598..47fbde3ef2 100644 --- a/homeassistant/components/verisure/coordinator.py +++ b/homeassistant/components/verisure/coordinator.py @@ -2,19 +2,21 @@ from __future__ import annotations from datetime import timedelta -from http import HTTPStatus +from time import sleep from verisure import ( Error as VerisureError, + LoginError as VerisureLoginError, ResponseError as VerisureResponseError, Session as Verisure, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.storage import STORAGE_DIR -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import Throttle from .const import CONF_GIID, DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER @@ -25,13 +27,14 @@ class VerisureDataUpdateCoordinator(DataUpdateCoordinator): def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize the Verisure hub.""" - self.imageseries: dict[str, list] = {} + self.imageseries: list[dict[str, str]] = [] self.entry = entry + self._overview: list[dict] = [] self.verisure = Verisure( username=entry.data[CONF_EMAIL], password=entry.data[CONF_PASSWORD], - cookieFileName=hass.config.path( + cookie_file_name=hass.config.path( STORAGE_DIR, f"verisure_{entry.data[CONF_EMAIL]}" ), ) @@ -43,8 +46,11 @@ class VerisureDataUpdateCoordinator(DataUpdateCoordinator): async def async_login(self) -> bool: """Login to Verisure.""" try: - await self.hass.async_add_executor_job(self.verisure.login) - except VerisureError as ex: + await self.hass.async_add_executor_job(self.verisure.login_cookie) + except VerisureLoginError as ex: + LOGGER.error("Could not log in to verisure, %s", ex) + raise ConfigEntryAuthFailed("Credentials expired for Verisure") from ex + except VerisureResponseError as ex: LOGGER.error("Could not log in to verisure, %s", ex) return False @@ -54,62 +60,116 @@ class VerisureDataUpdateCoordinator(DataUpdateCoordinator): return True - async def async_logout(self, _event: Event) -> None: - """Logout from Verisure.""" - try: - await self.hass.async_add_executor_job(self.verisure.logout) - except VerisureError as ex: - LOGGER.error("Could not log out from verisure, %s", ex) - async def _async_update_data(self) -> dict: """Fetch data from Verisure.""" try: - overview = await self.hass.async_add_executor_job( - self.verisure.get_overview - ) + await self.hass.async_add_executor_job(self.verisure.update_cookie) + except VerisureLoginError as ex: + LOGGER.error("Credentials expired for Verisure, %s", ex) + raise ConfigEntryAuthFailed("Credentials expired for Verisure") from ex except VerisureResponseError as ex: - LOGGER.error("Could not read overview, %s", ex) - if ex.status_code == HTTPStatus.SERVICE_UNAVAILABLE: - LOGGER.info("Trying to log in again") - await self.async_login() - return {} - raise + LOGGER.error("Could not log in to verisure, %s", ex) + raise ConfigEntryAuthFailed("Could not log in to verisure") from ex + try: + overview = await self.hass.async_add_executor_job( + self.verisure.request, + self.verisure.arm_state(), + self.verisure.broadband(), + self.verisure.cameras(), + self.verisure.climate(), + self.verisure.door_window(), + self.verisure.smart_lock(), + self.verisure.smartplugs(), + ) + except VerisureResponseError as err: + LOGGER.debug("Cookie expired or service unavailable, %s", err) + overview = self._overview + try: + await self.hass.async_add_executor_job(self.verisure.update_cookie) + except VerisureResponseError as ex: + raise ConfigEntryAuthFailed("Credentials for Verisure expired.") from ex + except VerisureError as err: + LOGGER.error("Could not read overview, %s", err) + raise UpdateFailed("Could not read overview") from err + + def unpack(overview: list, value: str) -> dict | list: + return next( + ( + item["data"]["installation"][value] + for item in overview + if value in item.get("data", {}).get("installation", {}) + ), + [], + ) # Store data in a way Home Assistant can easily consume it + self._overview = overview return { - "alarm": overview["armState"], - "ethernet": overview.get("ethernetConnectedNow"), + "alarm": unpack(overview, "armState"), + "broadband": unpack(overview, "broadband"), "cameras": { - device["deviceLabel"]: device - for device in overview["customerImageCameras"] + device["device"]["deviceLabel"]: device + for device in unpack(overview, "cameras") }, "climate": { - device["deviceLabel"]: device for device in overview["climateValues"] + device["device"]["deviceLabel"]: device + for device in unpack(overview, "climates") }, "door_window": { - device["deviceLabel"]: device - for device in overview["doorWindow"]["doorWindowDevice"] + device["device"]["deviceLabel"]: device + for device in unpack(overview, "doorWindows") }, "locks": { - device["deviceLabel"]: device - for device in overview["doorLockStatusList"] - }, - "mice": { - device["deviceLabel"]: device - for device in overview["eventCounts"] - if device["deviceType"] == "MOUSE1" + device["device"]["deviceLabel"]: device + for device in unpack(overview, "smartLocks") }, "smart_plugs": { - device["deviceLabel"]: device for device in overview["smartPlugs"] + device["device"]["deviceLabel"]: device + for device in unpack(overview, "smartplugs") }, } @Throttle(timedelta(seconds=60)) def update_smartcam_imageseries(self) -> None: """Update the image series.""" - self.imageseries = self.verisure.get_camera_imageseries() + image_data = self.verisure.request(self.verisure.cameras_image_series()) + self.imageseries = [ + content + for series in ( + image_data.get("data", {}) + .get("ContentProviderMediaSearch", {}) + .get("mediaSeriesList", []) + ) + for content in series.get("deviceMediaList", []) + if content.get("contentType") == "IMAGE_JPEG" + ] @Throttle(timedelta(seconds=30)) def smartcam_capture(self, device_id: str) -> None: """Capture a new image from a smartcam.""" - self.verisure.capture_image(device_id) + capture_request = self.verisure.request( + self.verisure.camera_get_request_id(device_id) + ) + request_id = ( + capture_request.get("data", {}) + .get("ContentProviderCaptureImageRequest", {}) + .get("requestId") + ) + capture_status = None + attempts = 0 + while capture_status != "AVAILABLE": + if attempts == 30: + break + if attempts > 1: + sleep(0.5) + attempts += 1 + capture_data = self.verisure.request( + self.verisure.camera_capture(device_id, request_id) + ) + capture_status = ( + capture_data.get("data", {}) + .get("installation", {}) + .get("cameraContentProvider", {}) + .get("captureImageRequestStatus", {}) + .get("mediaRequestStatus") + ) diff --git a/homeassistant/components/verisure/diagnostics.py b/homeassistant/components/verisure/diagnostics.py index 740aff0b90..8dbffe6eee 100644 --- a/homeassistant/components/verisure/diagnostics.py +++ b/homeassistant/components/verisure/diagnostics.py @@ -16,6 +16,7 @@ TO_REDACT = { "deviceArea", "name", "time", + "reportTime", "userString", } diff --git a/homeassistant/components/verisure/lock.py b/homeassistant/components/verisure/lock.py index 02cdad158c..d13005b265 100644 --- a/homeassistant/components/verisure/lock.py +++ b/homeassistant/components/verisure/lock.py @@ -77,7 +77,7 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt @property def device_info(self) -> DeviceInfo: """Return device information about this entity.""" - area = self.coordinator.data["locks"][self.serial_number]["area"] + area = self.coordinator.data["locks"][self.serial_number]["device"]["area"] return DeviceInfo( name=area, suggested_area=area, @@ -98,12 +98,16 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt @property def changed_by(self) -> str | None: """Last change triggered by.""" - return self.coordinator.data["locks"][self.serial_number].get("userString") + return ( + self.coordinator.data["locks"][self.serial_number] + .get("user", {}) + .get("name") + ) @property def changed_method(self) -> str: """Last change method.""" - return self.coordinator.data["locks"][self.serial_number]["method"] + return self.coordinator.data["locks"][self.serial_number]["lockMethod"] @property def code_format(self) -> str: @@ -114,8 +118,7 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt def is_locked(self) -> bool: """Return true if lock is locked.""" return ( - self.coordinator.data["locks"][self.serial_number]["lockedState"] - == "LOCKED" + self.coordinator.data["locks"][self.serial_number]["lockStatus"] == "LOCKED" ) @property @@ -147,28 +150,39 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt async def async_set_lock_state(self, code: str, state: str) -> None: """Send set lock state command.""" - target_state = "lock" if state == STATE_LOCKED else "unlock" - lock_state = await self.hass.async_add_executor_job( - self.coordinator.verisure.set_lock_state, - code, - self.serial_number, - target_state, + command = ( + self.coordinator.verisure.door_lock(self.serial_number, code) + if state == STATE_LOCKED + else self.coordinator.verisure.door_unlock(self.serial_number, code) + ) + lock_request = await self.hass.async_add_executor_job( + self.coordinator.verisure.request, + command, ) - LOGGER.debug("Verisure doorlock %s", state) - transaction = {} + transaction_id = lock_request.get("data", {}).get(command["operationName"]) + target_state = "LOCKED" if state == STATE_LOCKED else "UNLOCKED" + lock_status = None attempts = 0 - while "result" not in transaction: - transaction = await self.hass.async_add_executor_job( - self.coordinator.verisure.get_lock_state_transaction, - lock_state["doorLockStateChangeTransactionId"], - ) - attempts += 1 + while lock_status != "OK": if attempts == 30: break if attempts > 1: await asyncio.sleep(0.5) - if transaction["result"] == "OK": + attempts += 1 + poll_data = await self.hass.async_add_executor_job( + self.coordinator.verisure.request, + self.coordinator.verisure.poll_lock_state( + transaction_id, self.serial_number, target_state + ), + ) + lock_status = ( + poll_data.get("data", {}) + .get("installation", {}) + .get("doorLockStateChangePollResult", {}) + .get("result") + ) + if lock_status == "OK": self._state = state def disable_autolock(self) -> None: diff --git a/homeassistant/components/verisure/manifest.json b/homeassistant/components/verisure/manifest.json index 9e177a514a..66dccdc07d 100644 --- a/homeassistant/components/verisure/manifest.json +++ b/homeassistant/components/verisure/manifest.json @@ -1,7 +1,7 @@ { "domain": "verisure", "name": "Verisure", - "codeowners": ["@frenck"], + "codeowners": ["@frenck", "@niro1987"], "config_flow": true, "dhcp": [ { @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["verisure"], - "requirements": ["vsure==1.8.1"] + "requirements": ["vsure==2.6.1"] } diff --git a/homeassistant/components/verisure/sensor.py b/homeassistant/components/verisure/sensor.py index bbc1c15159..0b519b4726 100644 --- a/homeassistant/components/verisure/sensor.py +++ b/homeassistant/components/verisure/sensor.py @@ -28,18 +28,13 @@ async def async_setup_entry( sensors: list[Entity] = [ VerisureThermometer(coordinator, serial_number) for serial_number, values in coordinator.data["climate"].items() - if "temperature" in values + if "temperatureValue" in values ] sensors.extend( VerisureHygrometer(coordinator, serial_number) for serial_number, values in coordinator.data["climate"].items() - if "humidity" in values - ) - - sensors.extend( - VerisureMouseDetection(coordinator, serial_number) - for serial_number in coordinator.data["mice"] + if values.get("humidityEnabled") ) async_add_entities(sensors) @@ -67,10 +62,10 @@ class VerisureThermometer( @property def device_info(self) -> DeviceInfo: """Return device information about this entity.""" - device_type = self.coordinator.data["climate"][self.serial_number].get( - "deviceType" - ) - area = self.coordinator.data["climate"][self.serial_number]["deviceArea"] + device_type = self.coordinator.data["climate"][self.serial_number]["device"][ + "gui" + ]["label"] + area = self.coordinator.data["climate"][self.serial_number]["device"]["area"] return DeviceInfo( name=area, suggested_area=area, @@ -84,7 +79,7 @@ class VerisureThermometer( @property def native_value(self) -> str | None: """Return the state of the entity.""" - return self.coordinator.data["climate"][self.serial_number]["temperature"] + return self.coordinator.data["climate"][self.serial_number]["temperatureValue"] @property def available(self) -> bool: @@ -92,7 +87,8 @@ class VerisureThermometer( return ( super().available and self.serial_number in self.coordinator.data["climate"] - and "temperature" in self.coordinator.data["climate"][self.serial_number] + and "temperatureValue" + in self.coordinator.data["climate"][self.serial_number] ) @@ -118,10 +114,10 @@ class VerisureHygrometer( @property def device_info(self) -> DeviceInfo: """Return device information about this entity.""" - device_type = self.coordinator.data["climate"][self.serial_number].get( - "deviceType" - ) - area = self.coordinator.data["climate"][self.serial_number]["deviceArea"] + device_type = self.coordinator.data["climate"][self.serial_number]["device"][ + "gui" + ]["label"] + area = self.coordinator.data["climate"][self.serial_number]["device"]["area"] return DeviceInfo( name=area, suggested_area=area, @@ -135,7 +131,7 @@ class VerisureHygrometer( @property def native_value(self) -> str | None: """Return the state of the entity.""" - return self.coordinator.data["climate"][self.serial_number]["humidity"] + return self.coordinator.data["climate"][self.serial_number]["humidityValue"] @property def available(self) -> bool: @@ -143,51 +139,5 @@ class VerisureHygrometer( return ( super().available and self.serial_number in self.coordinator.data["climate"] - and "humidity" in self.coordinator.data["climate"][self.serial_number] - ) - - -class VerisureMouseDetection( - CoordinatorEntity[VerisureDataUpdateCoordinator], SensorEntity -): - """Representation of a Verisure mouse detector.""" - - _attr_name = "Mouse" - _attr_has_entity_name = True - _attr_native_unit_of_measurement = "Mice" - - def __init__( - self, coordinator: VerisureDataUpdateCoordinator, serial_number: str - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator) - self._attr_unique_id = f"{serial_number}_mice" - self.serial_number = serial_number - - @property - def device_info(self) -> DeviceInfo: - """Return device information about this entity.""" - area = self.coordinator.data["mice"][self.serial_number]["area"] - return DeviceInfo( - name=area, - suggested_area=area, - manufacturer="Verisure", - model="Mouse detector", - identifiers={(DOMAIN, self.serial_number)}, - via_device=(DOMAIN, self.coordinator.entry.data[CONF_GIID]), - configuration_url="https://mypages.verisure.com", - ) - - @property - def native_value(self) -> str | None: - """Return the state of the entity.""" - return self.coordinator.data["mice"][self.serial_number]["detections"] - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return ( - super().available - and self.serial_number in self.coordinator.data["mice"] - and "detections" in self.coordinator.data["mice"][self.serial_number] + and "humidityValue" in self.coordinator.data["climate"][self.serial_number] ) diff --git a/homeassistant/components/verisure/switch.py b/homeassistant/components/verisure/switch.py index ffb6e434fe..62e9bdf6cf 100644 --- a/homeassistant/components/verisure/switch.py +++ b/homeassistant/components/verisure/switch.py @@ -47,7 +47,9 @@ class VerisureSmartplug(CoordinatorEntity[VerisureDataUpdateCoordinator], Switch @property def device_info(self) -> DeviceInfo: """Return device information about this entity.""" - area = self.coordinator.data["smart_plugs"][self.serial_number]["area"] + area = self.coordinator.data["smart_plugs"][self.serial_number]["device"][ + "area" + ] return DeviceInfo( name=area, suggested_area=area, @@ -77,16 +79,23 @@ class VerisureSmartplug(CoordinatorEntity[VerisureDataUpdateCoordinator], Switch and self.serial_number in self.coordinator.data["smart_plugs"] ) - def turn_on(self, **kwargs: Any) -> None: - """Set smartplug status on.""" - self.coordinator.verisure.set_smartplug_state(self.serial_number, True) - self._state = True - self._change_timestamp = monotonic() - self.schedule_update_ha_state() + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the smartplug on.""" + await self.async_set_plug_state(True) - def turn_off(self, **kwargs: Any) -> None: - """Set smartplug status off.""" - self.coordinator.verisure.set_smartplug_state(self.serial_number, False) - self._state = False + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the smartplug off.""" + await self.async_set_plug_state(False) + + async def async_set_plug_state(self, state: bool) -> None: + """Set smartplug state.""" + command: dict[ + str, str | dict[str, str] + ] = self.coordinator.verisure.set_smartplug(self.serial_number, state) + await self.hass.async_add_executor_job( + self.coordinator.verisure.request, + command, + ) + self._state = state self._change_timestamp = monotonic() - self.schedule_update_ha_state() + await self.coordinator.async_request_refresh() diff --git a/requirements_all.txt b/requirements_all.txt index 57e442108e..997a206f58 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2589,7 +2589,7 @@ volkszaehler==0.4.0 volvooncall==0.10.2 # homeassistant.components.verisure -vsure==1.8.1 +vsure==2.6.1 # homeassistant.components.vasttrafik vtjp==0.1.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4366ec257d..e3c60c53ba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1847,7 +1847,7 @@ vilfo-api-client==0.3.2 volvooncall==0.10.2 # homeassistant.components.verisure -vsure==1.8.1 +vsure==2.6.1 # homeassistant.components.vulcan vulcan-api==2.3.0 diff --git a/tests/components/verisure/conftest.py b/tests/components/verisure/conftest.py index f91215866d..8ddc3a9981 100644 --- a/tests/components/verisure/conftest.py +++ b/tests/components/verisure/conftest.py @@ -43,8 +43,22 @@ def mock_verisure_config_flow() -> Generator[None, MagicMock, None]: ) as verisure_mock: verisure = verisure_mock.return_value verisure.login.return_value = True - verisure.installations = [ - {"giid": "12345", "alias": "ascending", "street": "12345th street"}, - {"giid": "54321", "alias": "descending", "street": "54321th street"}, - ] + verisure.get_installations.return_value = { + "data": { + "account": { + "installations": [ + { + "giid": "12345", + "alias": "ascending", + "address": {"street": "12345th street"}, + }, + { + "giid": "54321", + "alias": "descending", + "address": {"street": "54321th street"}, + }, + ] + } + } + } yield verisure diff --git a/tests/components/verisure/test_config_flow.py b/tests/components/verisure/test_config_flow.py index e330a341d8..b1e67766df 100644 --- a/tests/components/verisure/test_config_flow.py +++ b/tests/components/verisure/test_config_flow.py @@ -35,9 +35,10 @@ async def test_full_user_flow_single_installation( assert result.get("type") == FlowResultType.FORM assert result.get("errors") == {} - mock_verisure_config_flow.installations = [ - mock_verisure_config_flow.installations[0] - ] + mock_verisure_config_flow.get_installations.return_value = { + k1: {k2: {k3: [v3[0]] for k3, v3 in v2.items()} for k2, v2 in v1.items()} + for k1, v1 in mock_verisure_config_flow.get_installations.return_value.items() + } result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -133,9 +134,10 @@ async def test_full_user_flow_single_installation_with_mfa( assert result2.get("step_id") == "mfa" mock_verisure_config_flow.login.side_effect = None - mock_verisure_config_flow.installations = [ - mock_verisure_config_flow.installations[0] - ] + mock_verisure_config_flow.get_installations.return_value = { + k1: {k2: {k3: [v3[0]] for k3, v3 in v2.items()} for k2, v2 in v1.items()} + for k1, v1 in mock_verisure_config_flow.get_installations.return_value.items() + } result3 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -153,9 +155,9 @@ async def test_full_user_flow_single_installation_with_mfa( CONF_PASSWORD: "SuperS3cr3t!", } - assert len(mock_verisure_config_flow.login.mock_calls) == 2 - assert len(mock_verisure_config_flow.login_mfa.mock_calls) == 1 - assert len(mock_verisure_config_flow.mfa_validate.mock_calls) == 1 + assert len(mock_verisure_config_flow.login.mock_calls) == 1 + assert len(mock_verisure_config_flow.request_mfa.mock_calls) == 1 + assert len(mock_verisure_config_flow.validate_mfa.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -215,9 +217,9 @@ async def test_full_user_flow_multiple_installations_with_mfa( CONF_PASSWORD: "SuperS3cr3t!", } - assert len(mock_verisure_config_flow.login.mock_calls) == 2 - assert len(mock_verisure_config_flow.login_mfa.mock_calls) == 1 - assert len(mock_verisure_config_flow.mfa_validate.mock_calls) == 1 + assert len(mock_verisure_config_flow.login.mock_calls) == 1 + assert len(mock_verisure_config_flow.request_mfa.mock_calls) == 1 + assert len(mock_verisure_config_flow.validate_mfa.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -257,7 +259,7 @@ async def test_verisure_errors( mock_verisure_config_flow.login.side_effect = VerisureLoginError( "Multifactor authentication enabled, disable or create MFA cookie" ) - mock_verisure_config_flow.login_mfa.side_effect = side_effect + mock_verisure_config_flow.request_mfa.side_effect = side_effect result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], @@ -268,7 +270,7 @@ async def test_verisure_errors( ) await hass.async_block_till_done() - mock_verisure_config_flow.login_mfa.side_effect = None + mock_verisure_config_flow.request_mfa.side_effect = None assert result3.get("type") == FlowResultType.FORM assert result3.get("step_id") == "user" @@ -286,7 +288,7 @@ async def test_verisure_errors( assert result4.get("type") == FlowResultType.FORM assert result4.get("step_id") == "mfa" - mock_verisure_config_flow.mfa_validate.side_effect = side_effect + mock_verisure_config_flow.validate_mfa.side_effect = side_effect result5 = await hass.config_entries.flow.async_configure( result4["flow_id"], @@ -298,11 +300,11 @@ async def test_verisure_errors( assert result5.get("step_id") == "mfa" assert result5.get("errors") == {"base": error} - mock_verisure_config_flow.installations = [ - mock_verisure_config_flow.installations[0] - ] - - mock_verisure_config_flow.mfa_validate.side_effect = None + mock_verisure_config_flow.get_installations.return_value = { + k1: {k2: {k3: [v3[0]] for k3, v3 in v2.items()} for k2, v2 in v1.items()} + for k1, v1 in mock_verisure_config_flow.get_installations.return_value.items() + } + mock_verisure_config_flow.validate_mfa.side_effect = None mock_verisure_config_flow.login.side_effect = None result6 = await hass.config_entries.flow.async_configure( @@ -321,9 +323,9 @@ async def test_verisure_errors( CONF_PASSWORD: "SuperS3cr3t!", } - assert len(mock_verisure_config_flow.login.mock_calls) == 4 - assert len(mock_verisure_config_flow.login_mfa.mock_calls) == 2 - assert len(mock_verisure_config_flow.mfa_validate.mock_calls) == 2 + assert len(mock_verisure_config_flow.login.mock_calls) == 3 + assert len(mock_verisure_config_flow.request_mfa.mock_calls) == 2 + assert len(mock_verisure_config_flow.validate_mfa.mock_calls) == 2 assert len(mock_setup_entry.mock_calls) == 1 @@ -441,8 +443,8 @@ async def test_reauth_flow_with_mfa( } assert len(mock_verisure_config_flow.login.mock_calls) == 2 - assert len(mock_verisure_config_flow.login_mfa.mock_calls) == 1 - assert len(mock_verisure_config_flow.mfa_validate.mock_calls) == 1 + assert len(mock_verisure_config_flow.request_mfa.mock_calls) == 1 + assert len(mock_verisure_config_flow.validate_mfa.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -491,7 +493,7 @@ async def test_reauth_flow_errors( mock_verisure_config_flow.login.side_effect = VerisureLoginError( "Multifactor authentication enabled, disable or create MFA cookie" ) - mock_verisure_config_flow.login_mfa.side_effect = side_effect + mock_verisure_config_flow.request_mfa.side_effect = side_effect result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], @@ -506,7 +508,7 @@ async def test_reauth_flow_errors( assert result3.get("step_id") == "reauth_confirm" assert result3.get("errors") == {"base": "unknown_mfa"} - mock_verisure_config_flow.login_mfa.side_effect = None + mock_verisure_config_flow.request_mfa.side_effect = None result4 = await hass.config_entries.flow.async_configure( result3["flow_id"], @@ -520,7 +522,7 @@ async def test_reauth_flow_errors( assert result4.get("type") == FlowResultType.FORM assert result4.get("step_id") == "reauth_mfa" - mock_verisure_config_flow.mfa_validate.side_effect = side_effect + mock_verisure_config_flow.validate_mfa.side_effect = side_effect result5 = await hass.config_entries.flow.async_configure( result4["flow_id"], @@ -532,11 +534,12 @@ async def test_reauth_flow_errors( assert result5.get("step_id") == "reauth_mfa" assert result5.get("errors") == {"base": error} - mock_verisure_config_flow.mfa_validate.side_effect = None + mock_verisure_config_flow.validate_mfa.side_effect = None mock_verisure_config_flow.login.side_effect = None - mock_verisure_config_flow.installations = [ - mock_verisure_config_flow.installations[0] - ] + mock_verisure_config_flow.get_installations.return_value = { + k1: {k2: {k3: [v3[0]] for k3, v3 in v2.items()} for k2, v2 in v1.items()} + for k1, v1 in mock_verisure_config_flow.get_installations.return_value.items() + } await hass.config_entries.flow.async_configure( result5["flow_id"], @@ -553,8 +556,8 @@ async def test_reauth_flow_errors( } assert len(mock_verisure_config_flow.login.mock_calls) == 4 - assert len(mock_verisure_config_flow.login_mfa.mock_calls) == 2 - assert len(mock_verisure_config_flow.mfa_validate.mock_calls) == 2 + assert len(mock_verisure_config_flow.request_mfa.mock_calls) == 2 + assert len(mock_verisure_config_flow.validate_mfa.mock_calls) == 2 assert len(mock_setup_entry.mock_calls) == 1 From a036e31495b4ce4da4532a6eb90563e600c536cd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 26 Mar 2023 19:51:48 +0200 Subject: [PATCH 1265/2810] Use SnapshotAssertion in gree switch tests (#90222) --- .../gree/snapshots/test_switch.ambr | 206 ++++++++++++++++++ tests/components/gree/test_switch.py | 50 ++--- 2 files changed, 231 insertions(+), 25 deletions(-) create mode 100644 tests/components/gree/snapshots/test_switch.ambr diff --git a/tests/components/gree/snapshots/test_switch.ambr b/tests/components/gree/snapshots/test_switch.ambr new file mode 100644 index 0000000000..73056fcc46 --- /dev/null +++ b/tests/components/gree/snapshots/test_switch.ambr @@ -0,0 +1,206 @@ +# serializer version: 1 +# name: test_entity_state + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'fake-device-1 Panel Light', + 'icon': 'mdi:lightbulb', + }), + 'context': , + 'entity_id': 'switch.fake_device_1_panel_light', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'fake-device-1 Quiet', + }), + 'context': , + 'entity_id': 'switch.fake_device_1_quiet', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'fake-device-1 Fresh Air', + }), + 'context': , + 'entity_id': 'switch.fake_device_1_fresh_air', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'fake-device-1 XFan', + }), + 'context': , + 'entity_id': 'switch.fake_device_1_xfan', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'fake-device-1 Health mode', + 'icon': 'mdi:pine-tree', + }), + 'context': , + 'entity_id': 'switch.fake_device_1_health_mode', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + ]) +# --- +# name: test_registry_settings + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.fake_device_1_panel_light', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:lightbulb', + 'original_name': 'fake-device-1 Panel Light', + 'platform': 'gree', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbcc112233_Panel Light', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.fake_device_1_quiet', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'fake-device-1 Quiet', + 'platform': 'gree', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbcc112233_Quiet', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.fake_device_1_fresh_air', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'fake-device-1 Fresh Air', + 'platform': 'gree', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbcc112233_Fresh Air', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.fake_device_1_xfan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'fake-device-1 XFan', + 'platform': 'gree', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbcc112233_XFan', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.fake_device_1_health_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:pine-tree', + 'original_name': 'fake-device-1 Health mode', + 'platform': 'gree', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbcc112233_Health mode', + 'unit_of_measurement': None, + }), + ]) +# --- diff --git a/tests/components/gree/test_switch.py b/tests/components/gree/test_switch.py index 58c740b859..aee9c985e8 100644 --- a/tests/components/gree/test_switch.py +++ b/tests/components/gree/test_switch.py @@ -1,13 +1,14 @@ """Tests for gree component.""" +from unittest.mock import patch from greeclimate.exceptions import DeviceTimeoutError import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.gree.const import DOMAIN as GREE_DOMAIN from homeassistant.components.switch import DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, @@ -27,21 +28,26 @@ ENTITY_ID_FRESH_AIR = f"{DOMAIN}.fake_device_1_fresh_air" ENTITY_ID_XFAN = f"{DOMAIN}.fake_device_1_xfan" -async def async_setup_gree(hass): +async def async_setup_gree(hass: HomeAssistant) -> MockConfigEntry: """Set up the gree switch platform.""" - MockConfigEntry(domain=GREE_DOMAIN).add_to_hass(hass) + entry = MockConfigEntry(domain=GREE_DOMAIN) + entry.add_to_hass(hass) await async_setup_component(hass, GREE_DOMAIN, {GREE_DOMAIN: {DOMAIN: {}}}) await hass.async_block_till_done() + return entry -async def test_health_mode_disabled_by_default(hass): - """Test for making sure health mode is disabled on first load.""" - await async_setup_gree(hass) +@patch("homeassistant.components.gree.PLATFORMS", [DOMAIN]) +async def test_registry_settings( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test for entity registry settings (disabled_by, unique_id).""" + entry = await async_setup_gree(hass) - assert ( - er.async_get(hass).async_get(ENTITY_ID_HEALTH_MODE).disabled_by - == er.RegistryEntryDisabler.INTEGRATION - ) + state = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + assert state == snapshot @pytest.mark.parametrize( @@ -183,20 +189,14 @@ async def test_send_switch_toggle( assert state.state == STATE_ON -@pytest.mark.parametrize( - ("entity", "name"), - [ - (ENTITY_ID_LIGHT_PANEL, "Panel Light"), - (ENTITY_ID_HEALTH_MODE, "Health mode"), - (ENTITY_ID_QUIET, "Quiet"), - (ENTITY_ID_FRESH_AIR, "Fresh Air"), - (ENTITY_ID_XFAN, "XFan"), - ], -) -async def test_entity_name( - hass: HomeAssistant, entity, name, entity_registry_enabled_by_default: None +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_entity_state( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: - """Test for name property.""" + """Test for entity registry settings (disabled_by, unique_id).""" await async_setup_gree(hass) - state = hass.states.get(entity) - assert state.attributes[ATTR_FRIENDLY_NAME] == f"fake-device-1 {name}" + + state = hass.states.async_all(DOMAIN) + assert state == snapshot From 3058cc8d56eff58e56ceec3b066592bbd0877f0c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 26 Mar 2023 19:52:55 +0200 Subject: [PATCH 1266/2810] Adjust targets type hint in notify platform (#90062) --- homeassistant/components/notify/legacy.py | 4 ++-- pylint/plugins/hass_enforce_type_hints.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/notify/legacy.py b/homeassistant/components/notify/legacy.py index 2d91e1c065..110671864e 100644 --- a/homeassistant/components/notify/legacy.py +++ b/homeassistant/components/notify/legacy.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Coroutine +from collections.abc import Callable, Coroutine, Mapping from functools import partial from typing import Any, Protocol, cast @@ -221,7 +221,7 @@ class BaseNotificationService: registered_targets: dict[str, Any] @property - def targets(self) -> dict[str, Any] | None: + def targets(self) -> Mapping[str, Any] | None: """Return a dictionary of registered targets.""" return None diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 8dbb041fa9..7d11237fe5 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2035,7 +2035,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { matches=[ TypeHintMatch( function_name="targets", - return_type=["dict[str, Any]", None], + return_type=["Mapping[str, Any]", None], ), TypeHintMatch( function_name="send_message", From 3b83340f6e0f7a1119e5dd04fa879fe67cc027ed Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 26 Mar 2023 19:54:01 +0200 Subject: [PATCH 1267/2810] Improve get_browse_image type hints in media player (#90057) --- homeassistant/components/braviatv/media_player.py | 2 +- homeassistant/components/forked_daapd/media_player.py | 2 +- homeassistant/components/kodi/media_player.py | 2 +- homeassistant/components/media_player/__init__.py | 2 +- homeassistant/components/philips_js/media_player.py | 2 +- homeassistant/components/roku/media_player.py | 2 +- homeassistant/components/sonos/media_player.py | 2 +- homeassistant/components/squeezebox/media_player.py | 2 +- pylint/plugins/hass_enforce_type_hints.py | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/braviatv/media_player.py b/homeassistant/components/braviatv/media_player.py index c09df32aea..ff5691f9ae 100644 --- a/homeassistant/components/braviatv/media_player.py +++ b/homeassistant/components/braviatv/media_player.py @@ -231,7 +231,7 @@ class BraviaTVMediaPlayer(BraviaTVEntity, MediaPlayerEntity): async def async_get_browse_image( self, - media_content_type: str, + media_content_type: MediaType | str, media_content_id: str, media_image_id: str | None = None, ) -> tuple[bytes | None, str | None]: diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index d5f40c37b5..e1f1ece055 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -873,7 +873,7 @@ class ForkedDaapdMaster(MediaPlayerEntity): async def async_get_browse_image( self, - media_content_type: str, + media_content_type: MediaType | str, media_content_id: str, media_image_id: str | None = None, ) -> tuple[bytes | None, str | None]: diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index 63875236be..3272491a06 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -928,7 +928,7 @@ class KodiEntity(MediaPlayerEntity): async def async_get_browse_image( self, - media_content_type: str, + media_content_type: MediaType | str, media_content_id: str, media_image_id: str | None = None, ) -> tuple[bytes | None, str | None]: diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 8810ea165d..0f827d6073 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -1138,7 +1138,7 @@ class MediaPlayerImageView(HomeAssistantView): self, request: web.Request, entity_id: str, - media_content_type: str | None = None, + media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> web.Response: """Start a get request.""" diff --git a/homeassistant/components/philips_js/media_player.py b/homeassistant/components/philips_js/media_player.py index e8250dc8eb..c6ca70bdc8 100644 --- a/homeassistant/components/philips_js/media_player.py +++ b/homeassistant/components/philips_js/media_player.py @@ -415,7 +415,7 @@ class PhilipsTVMediaPlayer( async def async_get_browse_image( self, - media_content_type: str, + media_content_type: MediaType | str, media_content_id: str, media_image_id: str | None = None, ) -> tuple[bytes | None, str | None]: diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index cf6563519f..877e58233d 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -265,7 +265,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): async def async_get_browse_image( self, - media_content_type: str, + media_content_type: MediaType | str, media_content_id: str, media_image_id: str | None = None, ) -> tuple[bytes | None, str | None]: diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 1ef86429cb..cb18ec4388 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -691,7 +691,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): async def async_get_browse_image( self, - media_content_type: str, + media_content_type: MediaType | str, media_content_id: str, media_image_id: str | None = None, ) -> tuple[bytes | None, str | None]: diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 5c6f45c6ae..d3fae39bc4 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -634,7 +634,7 @@ class SqueezeBoxEntity(MediaPlayerEntity): async def async_get_browse_image( self, - media_content_type: str, + media_content_type: MediaType | str, media_content_id: str, media_image_id: str | None = None, ) -> tuple[bytes | None, str | None]: diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 7d11237fe5..9f4c806dc9 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -1776,7 +1776,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { TypeHintMatch( function_name="async_get_browse_image", arg_types={ - 1: "str", + 1: "MediaType | str", 2: "str", 3: "str | None", }, From c075dac9163dd36aca48417a0ecee9b2bb3d4c96 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 26 Mar 2023 19:54:21 +0200 Subject: [PATCH 1268/2810] Fix pylint plugin for tuple[float, float] returns (#90047) --- pylint/plugins/hass_enforce_type_hints.py | 2 +- tests/pylint/test_enforce_type_hints.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 9f4c806dc9..63cc5a1c6b 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2796,7 +2796,7 @@ def _is_valid_type( _is_valid_type(match.group(1), node.value) and isinstance(node.slice, nodes.Tuple) and all( - _is_valid_type(match.group(n + 2), node.slice.elts[n]) + _is_valid_type(match.group(n + 2), node.slice.elts[n], in_return) for n in range(len(node.slice.elts)) ) ) diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py index c580658b54..9e8df452b6 100644 --- a/tests/pylint/test_enforce_type_hints.py +++ b/tests/pylint/test_enforce_type_hints.py @@ -776,7 +776,7 @@ def test_valid_long_tuple( # Set ignore option type_hint_checker.config.ignore_missing_annotations = False - class_node, _, _ = astroid.extract_node( + class_node, _, _, _ = astroid.extract_node( """ class Entity(): pass @@ -790,6 +790,12 @@ def test_valid_long_tuple( class TestLight( #@ LightEntity ): + @property + def hs_color( #@ + self + ) -> tuple[int, int]: + pass + @property def rgbw_color( #@ self From 89355e087952417a6824507fd3b197f9d0520e19 Mon Sep 17 00:00:00 2001 From: Alexey Baturin Date: Sun, 26 Mar 2023 20:03:03 +0200 Subject: [PATCH 1269/2810] Add WLED IP as a sensor (#90241) Co-authored-by: Franck Nijhof --- homeassistant/components/wled/sensor.py | 7 +++++++ tests/components/wled/fixtures/rgb.json | 3 ++- tests/components/wled/fixtures/rgb_no_update.json | 3 ++- tests/components/wled/fixtures/rgb_single_segment.json | 3 ++- tests/components/wled/fixtures/rgb_websocket.json | 3 ++- tests/components/wled/fixtures/rgbw.json | 3 ++- tests/components/wled/snapshots/test_diagnostics.ambr | 2 +- tests/components/wled/test_sensor.py | 9 +++++++++ 8 files changed, 27 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/wled/sensor.py b/homeassistant/components/wled/sensor.py index 924414cadf..668b90159b 100644 --- a/homeassistant/components/wled/sensor.py +++ b/homeassistant/components/wled/sensor.py @@ -128,6 +128,13 @@ SENSORS: tuple[WLEDSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, value_fn=lambda device: device.info.wifi.bssid if device.info.wifi else None, ), + WLEDSensorEntityDescription( + key="ip", + name="IP", + icon="mdi:ip-network", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda device: device.info.ip, + ), ) diff --git a/tests/components/wled/fixtures/rgb.json b/tests/components/wled/fixtures/rgb.json index c66c07b339..21f9b005b7 100644 --- a/tests/components/wled/fixtures/rgb.json +++ b/tests/components/wled/fixtures/rgb.json @@ -86,7 +86,8 @@ "brand": "WLED", "product": "DIY light", "btype": "bin", - "mac": "aabbccddeeff" + "mac": "aabbccddeeff", + "ip": "127.0.0.1" }, "effects": [ "Solid", diff --git a/tests/components/wled/fixtures/rgb_no_update.json b/tests/components/wled/fixtures/rgb_no_update.json index a3c54dd118..c8aa902cc9 100644 --- a/tests/components/wled/fixtures/rgb_no_update.json +++ b/tests/components/wled/fixtures/rgb_no_update.json @@ -86,7 +86,8 @@ "brand": "WLED", "product": "DIY light", "btype": "bin", - "mac": "aabbccddeeff" + "mac": "aabbccddeeff", + "ip": "127.0.0.1" }, "effects": [ "Solid", diff --git a/tests/components/wled/fixtures/rgb_single_segment.json b/tests/components/wled/fixtures/rgb_single_segment.json index 08b1cb7bb6..aa0b79e98f 100644 --- a/tests/components/wled/fixtures/rgb_single_segment.json +++ b/tests/components/wled/fixtures/rgb_single_segment.json @@ -68,7 +68,8 @@ "brand": "WLED", "product": "DIY light", "btype": "bin", - "mac": "aabbccddeeff" + "mac": "aabbccddeeff", + "ip": "127.0.0.1" }, "effects": [ "Solid", diff --git a/tests/components/wled/fixtures/rgb_websocket.json b/tests/components/wled/fixtures/rgb_websocket.json index 36ca3e1f79..4a0ed7b1ee 100644 --- a/tests/components/wled/fixtures/rgb_websocket.json +++ b/tests/components/wled/fixtures/rgb_websocket.json @@ -94,7 +94,8 @@ "opt": 127, "brand": "WLED", "product": "FOSS", - "mac": "aabbccddeeff" + "mac": "aabbccddeeff", + "ip": "127.0.0.1" }, "effects": [ "Solid", diff --git a/tests/components/wled/fixtures/rgbw.json b/tests/components/wled/fixtures/rgbw.json index 7ffcaa36f9..100b393690 100644 --- a/tests/components/wled/fixtures/rgbw.json +++ b/tests/components/wled/fixtures/rgbw.json @@ -68,7 +68,8 @@ "brand": "WLED", "product": "DIY light", "btype": "bin", - "mac": "aabbccddee11" + "mac": "aabbccddee11", + "ip": "127.0.0.1" }, "effects": [ "Solid", diff --git a/tests/components/wled/snapshots/test_diagnostics.ambr b/tests/components/wled/snapshots/test_diagnostics.ambr index 25db6a3116..643e5fe4ad 100644 --- a/tests/components/wled/snapshots/test_diagnostics.ambr +++ b/tests/components/wled/snapshots/test_diagnostics.ambr @@ -92,7 +92,7 @@ 'effect_count': 81, 'filesystem': None, 'free_heap': 14600, - 'ip': 'Unknown', + 'ip': '127.0.0.1', 'leds': dict({ '__type': "", 'repr': 'Leds(cct=False, count=30, fps=None, light_capabilities=None, max_power=850, max_segments=10, power=470, rgbw=False, wv=True, segment_light_capabilities=None)', diff --git a/tests/components/wled/test_sensor.py b/tests/components/wled/test_sensor.py index f4016ce37a..d9168d7b69 100644 --- a/tests/components/wled/test_sensor.py +++ b/tests/components/wled/test_sensor.py @@ -110,6 +110,15 @@ async def test_sensors( assert entry.unique_id == "aabbccddeeff_wifi_bssid" assert entry.entity_category is EntityCategory.DIAGNOSTIC + assert (state := hass.states.get("sensor.wled_rgb_light_ip")) + assert state.attributes.get(ATTR_ICON) == "mdi:ip-network" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert state.state == "127.0.0.1" + + assert (entry := entity_registry.async_get("sensor.wled_rgb_light_ip")) + assert entry.unique_id == "aabbccddeeff_ip" + assert entry.entity_category is EntityCategory.DIAGNOSTIC + @pytest.mark.parametrize( "entity_id", From 542def7f82212b4734128a6945312610bc95821f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Mar 2023 09:10:35 -1000 Subject: [PATCH 1270/2810] Bump pySwitchbot to 0.37.5 (#90317) --- homeassistant/components/switchbot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index ada24bcee5..31ce20bea3 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -40,5 +40,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.37.4"] + "requirements": ["PySwitchbot==0.37.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 997a206f58..343660ab3d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -40,7 +40,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.37.4 +PySwitchbot==0.37.5 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e3c60c53ba..2cdff72fc8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -36,7 +36,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.37.4 +PySwitchbot==0.37.5 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 From 45262c61145f080673938078aa15a5b31877cfaa Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 26 Mar 2023 21:14:17 +0200 Subject: [PATCH 1271/2810] Implement config flow for nextcloud (#89396) * implement config flow * add tests * fix hassfest and requirements * abort import on connection error * add add_suggested_values_to_schema * mock async_setup_entry * revert code owner change * fix try connect in config flow * add device info * allow multiple instances * fix import in config flow * remove custom scan interval from coordinator * applay suggestions * apply suggestions * take over ownership from @meichthys * cleanup import data before passing to user step * apply suggestions to tests * add untested files to .coveragerc --- .coveragerc | 7 +- CODEOWNERS | 3 +- .../components/nextcloud/__init__.py | 73 ++++++--- .../components/nextcloud/binary_sensor.py | 21 +-- .../components/nextcloud/config_flow.py | 78 +++++++++ .../components/nextcloud/coordinator.py | 11 +- .../components/nextcloud/manifest.json | 3 +- homeassistant/components/nextcloud/sensor.py | 20 +-- .../components/nextcloud/strings.json | 28 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/nextcloud/__init__.py | 1 + tests/components/nextcloud/conftest.py | 25 +++ .../nextcloud/snapshots/test_config_flow.ambr | 15 ++ .../components/nextcloud/test_config_flow.py | 151 ++++++++++++++++++ 16 files changed, 383 insertions(+), 59 deletions(-) create mode 100644 homeassistant/components/nextcloud/config_flow.py create mode 100644 homeassistant/components/nextcloud/strings.json create mode 100644 tests/components/nextcloud/__init__.py create mode 100644 tests/components/nextcloud/conftest.py create mode 100644 tests/components/nextcloud/snapshots/test_config_flow.ambr create mode 100644 tests/components/nextcloud/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index f3dbc34791..17db4ef9cd 100644 --- a/.coveragerc +++ b/.coveragerc @@ -778,7 +778,12 @@ omit = homeassistant/components/nexia/climate.py homeassistant/components/nexia/entity.py homeassistant/components/nexia/switch.py - homeassistant/components/nextcloud/* + homeassistant/components/nextcloud/__init__.py + homeassistant/components/nextcloud/binary_sensor.py + homeassistant/components/nextcloud/const.py + homeassistant/components/nextcloud/coordinator.py + homeassistant/components/nextcloud/entity.py + homeassistant/components/nextcloud/sensor.py homeassistant/components/nfandroidtv/__init__.py homeassistant/components/nfandroidtv/notify.py homeassistant/components/nibe_heatpump/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index ff31997ce6..1acd5f6c9f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -785,7 +785,8 @@ build.json @home-assistant/supervisor /tests/components/nexia/ @bdraco /homeassistant/components/nextbus/ @vividboarder /tests/components/nextbus/ @vividboarder -/homeassistant/components/nextcloud/ @meichthys +/homeassistant/components/nextcloud/ @mib1185 +/tests/components/nextcloud/ @mib1185 /homeassistant/components/nextdns/ @bieniu /tests/components/nextdns/ @bieniu /homeassistant/components/nfandroidtv/ @tkdrob diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py index 5dffcbf9fb..d2ad3edf1c 100644 --- a/homeassistant/components/nextcloud/__init__.py +++ b/homeassistant/components/nextcloud/__init__.py @@ -4,6 +4,7 @@ import logging from nextcloudmonitor import NextcloudMonitor, NextcloudMonitorError import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_PASSWORD, CONF_SCAN_INTERVAL, @@ -12,42 +13,71 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from .const import DEFAULT_SCAN_INTERVAL, DOMAIN from .coordinator import NextcloudDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) - PLATFORMS = (Platform.SENSOR, Platform.BINARY_SENSOR) # Validate user configuration CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_URL): cv.url, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): cv.time_period, - } - ) - }, + vol.All( + cv.deprecated(DOMAIN), + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_URL): cv.url, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.time_period, + }, + ) + }, + ), extra=vol.ALLOW_EXTRA, ) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Nextcloud integration.""" - conf = config[DOMAIN] + if DOMAIN in config: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.6.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config[DOMAIN], + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up the Nextcloud integration.""" + + def _connect_nc(): + return NextcloudMonitor( + entry.data[CONF_URL], entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD] + ) try: - ncm = await hass.async_add_executor_job( - NextcloudMonitor, conf[CONF_URL], conf[CONF_USERNAME], conf[CONF_PASSWORD] - ) + ncm = await hass.async_add_executor_job(_connect_nc) except NextcloudMonitorError: _LOGGER.error("Nextcloud setup failed - Check configuration") return False @@ -55,13 +85,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: coordinator = NextcloudDataUpdateCoordinator( hass, ncm, - conf, + entry, ) - hass.data[DOMAIN] = coordinator + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await coordinator.async_config_entry_first_refresh() - for platform in PLATFORMS: - discovery.load_platform(hass, platform, DOMAIN, {}, config) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/nextcloud/binary_sensor.py b/homeassistant/components/nextcloud/binary_sensor.py index 52ddb66007..0d960bea8e 100644 --- a/homeassistant/components/nextcloud/binary_sensor.py +++ b/homeassistant/components/nextcloud/binary_sensor.py @@ -2,9 +2,9 @@ from __future__ import annotations from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN from .coordinator import NextcloudDataUpdateCoordinator @@ -18,24 +18,17 @@ BINARY_SENSORS = ( ) -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up the Nextcloud sensors.""" - if discovery_info is None: - return - coordinator: NextcloudDataUpdateCoordinator = hass.data[DOMAIN] - - add_entities( + """Set up the Nextcloud binary sensors.""" + coordinator: NextcloudDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( [ NextcloudBinarySensor(coordinator, name) for name in coordinator.data if name in BINARY_SENSORS - ], - True, + ] ) diff --git a/homeassistant/components/nextcloud/config_flow.py b/homeassistant/components/nextcloud/config_flow.py new file mode 100644 index 0000000000..e297a6893a --- /dev/null +++ b/homeassistant/components/nextcloud/config_flow.py @@ -0,0 +1,78 @@ +"""Config flow to configure the Nextcloud integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from nextcloudmonitor import NextcloudMonitor, NextcloudMonitorError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +DATA_SCHEMA_USER = vol.Schema( + { + vol.Required(CONF_URL): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) +_LOGGER = logging.getLogger(__name__) + + +class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a Nextcloud config flow.""" + + VERSION = 1 + + def _try_connect_nc(self, user_input: dict) -> NextcloudMonitor: + """Try to connect to nextcloud server.""" + return NextcloudMonitor( + user_input[CONF_URL], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: + """Handle a flow initiated by configuration file.""" + self._async_abort_entries_match({CONF_URL: user_input.get(CONF_URL)}) + try: + await self.hass.async_add_executor_job(self._try_connect_nc, user_input) + except NextcloudMonitorError: + _LOGGER.error( + "Connection error during import of yaml configuration, import aborted" + ) + return self.async_abort(reason="connection_error_during_import") + return await self.async_step_user( + { + CONF_URL: user_input[CONF_URL], + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_USERNAME: user_input[CONF_USERNAME], + } + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + errors = {} + + if user_input is not None: + self._async_abort_entries_match({CONF_URL: user_input.get(CONF_URL)}) + try: + await self.hass.async_add_executor_job(self._try_connect_nc, user_input) + except NextcloudMonitorError: + errors["base"] = "connection_error" + else: + return self.async_create_entry( + title=user_input[CONF_URL], + data=user_input, + ) + + data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA_USER, user_input) + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) diff --git a/homeassistant/components/nextcloud/coordinator.py b/homeassistant/components/nextcloud/coordinator.py index 07dc76d41d..73a07a77e2 100644 --- a/homeassistant/components/nextcloud/coordinator.py +++ b/homeassistant/components/nextcloud/coordinator.py @@ -5,9 +5,9 @@ from typing import Any from nextcloudmonitor import NextcloudMonitor, NextcloudMonitorError -from homeassistant.const import CONF_SCAN_INTERVAL, CONF_URL +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DEFAULT_SCAN_INTERVAL, DOMAIN @@ -19,18 +19,17 @@ class NextcloudDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Nextcloud data update coordinator.""" def __init__( - self, hass: HomeAssistant, ncm: NextcloudMonitor, config: ConfigType + self, hass: HomeAssistant, ncm: NextcloudMonitor, entry: ConfigEntry ) -> None: """Initialize the Nextcloud coordinator.""" - self.config = config self.ncm = ncm - self.url = config[CONF_URL] + self.url = entry.data[CONF_URL] super().__init__( hass, _LOGGER, name=self.url, - update_interval=config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL), + update_interval=DEFAULT_SCAN_INTERVAL, ) # Use recursion to create list of sensors & values based on nextcloud api data diff --git a/homeassistant/components/nextcloud/manifest.json b/homeassistant/components/nextcloud/manifest.json index 366c6eeb56..72e992277c 100644 --- a/homeassistant/components/nextcloud/manifest.json +++ b/homeassistant/components/nextcloud/manifest.json @@ -1,7 +1,8 @@ { "domain": "nextcloud", "name": "Nextcloud", - "codeowners": ["@meichthys"], + "codeowners": ["@mib1185"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nextcloud", "iot_class": "cloud_polling", "requirements": ["nextcloudmonitor==1.1.0"] diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py index 459f22d30e..eb6043e4bc 100644 --- a/homeassistant/components/nextcloud/sensor.py +++ b/homeassistant/components/nextcloud/sensor.py @@ -2,9 +2,10 @@ from __future__ import annotations from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from homeassistant.helpers.typing import StateType from .const import DOMAIN from .coordinator import NextcloudDataUpdateCoordinator @@ -57,24 +58,17 @@ SENSORS = ( ) -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Nextcloud sensors.""" - if discovery_info is None: - return - coordinator: NextcloudDataUpdateCoordinator = hass.data[DOMAIN] - - add_entities( + coordinator: NextcloudDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( [ NextcloudSensor(coordinator, name) for name in coordinator.data if name in SENSORS - ], - True, + ] ) diff --git a/homeassistant/components/nextcloud/strings.json b/homeassistant/components/nextcloud/strings.json new file mode 100644 index 0000000000..9ae7ed24a6 --- /dev/null +++ b/homeassistant/components/nextcloud/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "flow_title": "Nextcloud", + "step": { + "user": { + "description": "Enter your Nextcloud information.", + "data": { + "url": "[%key:common::config_flow::data::url%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "connection_error_during_import": "Connection error occured during yaml configuration import" + }, + "error": { + "connection_error": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The Netxcloud YAML configuration has been deprecated", + "description": "Configuring Netxcloud using YAML has been deprecated.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the `nextcloud` YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 6656972f8b..37480904f9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -282,6 +282,7 @@ FLOWS = { "netatmo", "netgear", "nexia", + "nextcloud", "nextdns", "nfandroidtv", "nibe_heatpump", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 843b8ed006..7340980f13 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3616,7 +3616,7 @@ "nextcloud": { "name": "Nextcloud", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "nextdns": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2cdff72fc8..6573d81e55 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -887,6 +887,9 @@ nettigo-air-monitor==2.1.0 # homeassistant.components.nexia nexia==2.0.6 +# homeassistant.components.nextcloud +nextcloudmonitor==1.1.0 + # homeassistant.components.discord nextcord==2.0.0a8 diff --git a/tests/components/nextcloud/__init__.py b/tests/components/nextcloud/__init__.py new file mode 100644 index 0000000000..e2102ed8c2 --- /dev/null +++ b/tests/components/nextcloud/__init__.py @@ -0,0 +1 @@ +"""Tests for the Nextcloud integration.""" diff --git a/tests/components/nextcloud/conftest.py b/tests/components/nextcloud/conftest.py new file mode 100644 index 0000000000..0ea281abb4 --- /dev/null +++ b/tests/components/nextcloud/conftest.py @@ -0,0 +1,25 @@ +"""Fixtrues for the Nextcloud integration tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, patch + +import pytest + + +@pytest.fixture +def mock_nextcloud_monitor() -> Mock: + """Mock of NextcloudMonitor.""" + ncm = Mock( + update=Mock(return_value=True), + ) + + return ncm + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.nextcloud.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/nextcloud/snapshots/test_config_flow.ambr b/tests/components/nextcloud/snapshots/test_config_flow.ambr new file mode 100644 index 0000000000..0c9df1238c --- /dev/null +++ b/tests/components/nextcloud/snapshots/test_config_flow.ambr @@ -0,0 +1,15 @@ +# serializer version: 1 +# name: test_import + dict({ + 'password': 'nc_pass', + 'url': 'nc_url', + 'username': 'nc_user', + }) +# --- +# name: test_user_create_entry + dict({ + 'password': 'nc_pass', + 'url': 'nc_url', + 'username': 'nc_user', + }) +# --- diff --git a/tests/components/nextcloud/test_config_flow.py b/tests/components/nextcloud/test_config_flow.py new file mode 100644 index 0000000000..118d8fef0d --- /dev/null +++ b/tests/components/nextcloud/test_config_flow.py @@ -0,0 +1,151 @@ +"""Tests for the Nextcloud config flow.""" +from unittest.mock import Mock, patch + +from nextcloudmonitor import NextcloudMonitorError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.nextcloud import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + +VALID_CONFIG = {CONF_URL: "nc_url", CONF_USERNAME: "nc_user", CONF_PASSWORD: "nc_pass"} + + +async def test_user_create_entry( + hass: HomeAssistant, mock_nextcloud_monitor: Mock, snapshot: SnapshotAssertion +) -> None: + """Test that the user step works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + side_effect=NextcloudMonitorError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "connection_error"} + + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + return_value=mock_nextcloud_monitor, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "nc_url" + assert result["data"] == snapshot + + +async def test_user_already_configured( + hass: HomeAssistant, mock_nextcloud_monitor: Mock +) -> None: + """Test that errors are shown when duplicates are added.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="nc_url", + unique_id="nc_url", + data=VALID_CONFIG, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + return_value=mock_nextcloud_monitor, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_import( + hass: HomeAssistant, mock_nextcloud_monitor: Mock, snapshot: SnapshotAssertion +) -> None: + """Test that the import step works.""" + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + return_value=mock_nextcloud_monitor, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=VALID_CONFIG, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "nc_url" + assert result["data"] == snapshot + + +async def test_import_already_configured( + hass: HomeAssistant, mock_nextcloud_monitor: Mock +) -> None: + """Test that import step is aborted when duplicates are added.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="nc_url", + unique_id="nc_url", + data=VALID_CONFIG, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + return_value=mock_nextcloud_monitor, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=VALID_CONFIG, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_import_connection_error(hass: HomeAssistant) -> None: + """Test that import step is aborted on connection error.""" + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + side_effect=NextcloudMonitorError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=VALID_CONFIG, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "connection_error_during_import" From 94cc188885503d0e326f8a60d974676dea2c3d74 Mon Sep 17 00:00:00 2001 From: Igor Santos <532299+igorsantos07@users.noreply.github.com> Date: Sun, 26 Mar 2023 16:37:24 -0300 Subject: [PATCH 1272/2810] [Issue template] Point to health page for version info (#80708) --- .github/ISSUE_TEMPLATE/bug_report.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 5bb755750e..237fc2888a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -31,9 +31,9 @@ body: label: What version of Home Assistant Core has the issue? placeholder: core- description: > - Can be found in: [Settings -> About](https://my.home-assistant.io/redirect/info/). + Can be found in: [Settings ⇒ System ⇒ Repairs ⇒ Three Dots in Upper Right ⇒ System information](https://my.home-assistant.io/redirect/system_health/). - [![Open your Home Assistant instance and show your Home Assistant version information.](https://my.home-assistant.io/badges/info.svg)](https://my.home-assistant.io/redirect/info/) + [![Open your Home Assistant instance and show the system information.](https://my.home-assistant.io/badges/system_health.svg)](https://my.home-assistant.io/redirect/system_health/) - type: input attributes: label: What was the last working version of Home Assistant Core? @@ -46,9 +46,9 @@ body: attributes: label: What type of installation are you running? description: > - Can be found in: [Settings -> System-> Repairs -> Three Dots in Upper Right -> System information](https://my.home-assistant.io/redirect/system_health/). + Can be found in: [Settings ⇒ System ⇒ Repairs ⇒ Three Dots in Upper Right ⇒ System information](https://my.home-assistant.io/redirect/system_health/). - [![Open your Home Assistant instance and show health information about your system.](https://my.home-assistant.io/badges/system_health.svg)](https://my.home-assistant.io/redirect/system_health/) + [![Open your Home Assistant instance and show the system information.](https://my.home-assistant.io/badges/system_health.svg)](https://my.home-assistant.io/redirect/system_health/) options: - Home Assistant OS - Home Assistant Container From 916b274ec89dc7648479cebaf1a0432e0b81a7ad Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 26 Mar 2023 21:47:47 +0200 Subject: [PATCH 1273/2810] Update pipdeptree to 2.7.0 (#90312) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index fa16881520..caf29fc558 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -17,7 +17,7 @@ pre-commit==3.1.0 pydantic==1.10.7 pylint==2.17.0 pylint-per-file-ignores==1.1.0 -pipdeptree==2.5.0 +pipdeptree==2.7.0 pytest-asyncio==0.20.3 pytest-aiohttp==1.0.4 pytest-cov==3.0.0 From 8c9966aa0533c2cfd23461ecaa819e90380884b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Mar 2023 10:06:14 -1000 Subject: [PATCH 1274/2810] Ensure esphome subscribes to bluetooth connection free before accepting connect requests (#90319) --- .../components/esphome/bluetooth/__init__.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/esphome/bluetooth/__init__.py b/homeassistant/components/esphome/bluetooth/__init__.py index 4a70b906b1..e62b54655c 100644 --- a/homeassistant/components/esphome/bluetooth/__init__.py +++ b/homeassistant/components/esphome/bluetooth/__init__.py @@ -78,15 +78,18 @@ async def async_connect_scanner( scanner = ESPHomeScanner( hass, source, entry.title, new_info_callback, connector, connectable ) + if connectable: + # If its connectable be sure not to register the scanner + # until we know the connection is fully setup since otherwise + # there is a race condition where the connection can fail + await cli.subscribe_bluetooth_connections_free( + entry_data.async_update_ble_connection_limits + ) unload_callbacks = [ async_register_scanner(hass, scanner, connectable), scanner.async_setup(), ] await cli.subscribe_bluetooth_le_advertisements(scanner.async_on_advertisement) - if connectable: - await cli.subscribe_bluetooth_connections_free( - entry_data.async_update_ble_connection_limits - ) @hass_callback def _async_unload() -> None: From 745df277a0ae0875228fa311d79c887cc9609ea2 Mon Sep 17 00:00:00 2001 From: Aaron Godfrey Date: Sun, 26 Mar 2023 13:08:36 -0700 Subject: [PATCH 1275/2810] Fix Todoist end date for all day event (#89837) --- homeassistant/components/todoist/calendar.py | 2 +- tests/components/todoist/test_calendar.py | 56 +++++++++++++++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 8fdafee6cf..02459b429c 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -612,7 +612,7 @@ class TodoistProjectData: event = CalendarEvent( summary=task.content, start=due_date_value, - end=due_date_value, + end=due_date_value + timedelta(days=1), ) events.append(event) return events diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index 82eff0d755..adf0f8a14b 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -1,6 +1,8 @@ """Unit tests for the Todoist calendar platform.""" -from datetime import datetime +from datetime import datetime, timedelta +from http import HTTPStatus from unittest.mock import AsyncMock, patch +import urllib import pytest from todoist_api_python.models import Due, Label, Project, Task @@ -11,6 +13,9 @@ from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity +from homeassistant.util import dt + +from tests.typing import ClientSessionGenerator @pytest.fixture(name="task") @@ -68,6 +73,11 @@ def mock_api(task) -> AsyncMock: return api +def get_events_url(entity: str, start: str, end: str) -> str: + """Create a url to get events during the specified time range.""" + return f"/api/calendars/{entity}?start={urllib.parse.quote(start)}&end={urllib.parse.quote(end)}" + + @patch("homeassistant.components.todoist.calendar.TodoistAPIAsync") async def test_calendar_entity_unique_id( todoist_api, hass: HomeAssistant, api, entity_registry: er.EntityRegistry @@ -139,3 +149,47 @@ async def test_calendar_custom_project_unique_id( state = hass.states.get("calendar.all_projects") assert state.state == "off" + + +@patch("homeassistant.components.todoist.calendar.TodoistAPIAsync") +async def test_all_day_event( + todoist_api, hass: HomeAssistant, hass_client: ClientSessionGenerator, api +) -> None: + """Test for an all day calendar event.""" + todoist_api.return_value = api + assert await setup.async_setup_component( + hass, + "calendar", + { + "calendar": { + "platform": DOMAIN, + CONF_TOKEN: "token", + "custom_projects": [{"name": "All projects", "labels": ["Label1"]}], + } + }, + ) + await hass.async_block_till_done() + + await async_update_entity(hass, "calendar.all_projects") + client = await hass_client() + start = dt.now() - timedelta(days=1) + end = dt.now() + timedelta(days=1) + response = await client.get( + get_events_url("calendar.all_projects", start.isoformat(), end.isoformat()) + ) + assert response.status == HTTPStatus.OK + events = await response.json() + + expected = [ + { + "start": {"date": dt.now().strftime("%Y-%m-%d")}, + "end": {"date": (dt.now() + timedelta(days=1)).strftime("%Y-%m-%d")}, + "summary": "A task", + "description": None, + "location": None, + "uid": None, + "recurrence_id": None, + "rrule": None, + } + ] + assert events == expected From fa35867765cb38a39c0e07c6c34ad771a5dec112 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 26 Mar 2023 14:00:45 -0700 Subject: [PATCH 1276/2810] Bump ical to 4.5.1 and set PRODID for home assistant in local calendar ics (#90291) --- homeassistant/components/local_calendar/calendar.py | 3 +++ homeassistant/components/local_calendar/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/local_calendar/snapshots/test_diagnostics.ambr | 2 +- 5 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index 2905e98caa..718c65ffce 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -33,6 +33,8 @@ from .store import LocalCalendarStore _LOGGER = logging.getLogger(__name__) +PRODID = "-//homeassistant.io//local_calendar 1.0//EN" + async def async_setup_entry( hass: HomeAssistant, @@ -43,6 +45,7 @@ async def async_setup_entry( store = hass.data[DOMAIN][config_entry.entry_id] ics = await store.async_load() calendar = IcsCalendarStream.calendar_from_ics(ics) + calendar.prodid = PRODID name = config_entry.data[CONF_CALENDAR_NAME] entity = LocalCalendarEntity(store, calendar, name, unique_id=config_entry.entry_id) diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index 42cd7fcf5a..049f9de03e 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==4.5.0"] + "requirements": ["ical==4.5.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 343660ab3d..b7f2f6a181 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -952,7 +952,7 @@ ibm-watson==5.2.2 ibmiotf==0.3.4 # homeassistant.components.local_calendar -ical==4.5.0 +ical==4.5.1 # homeassistant.components.ping icmplib==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6573d81e55..0c4581d87e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -723,7 +723,7 @@ iaqualink==0.5.0 ibeacon_ble==1.0.1 # homeassistant.components.local_calendar -ical==4.5.0 +ical==4.5.1 # homeassistant.components.ping icmplib==3.0 diff --git a/tests/components/local_calendar/snapshots/test_diagnostics.ambr b/tests/components/local_calendar/snapshots/test_diagnostics.ambr index e61b9da7a9..a70b9d7438 100644 --- a/tests/components/local_calendar/snapshots/test_diagnostics.ambr +++ b/tests/components/local_calendar/snapshots/test_diagnostics.ambr @@ -3,7 +3,7 @@ dict({ 'ics': ''' BEGIN:VCALENDAR - PRODID:-//github.com/allenporter/ical//4.5.0//EN + PRODID:-//homeassistant.io//local_calendar 1.0//EN VERSION:*** BEGIN:VEVENT DTSTAMP:20230313T190500 From 0393797ade9b25054928fd50305e43646381aa14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20=C3=98stergaard=20Nielsen?= Date: Sun, 26 Mar 2023 23:32:06 +0200 Subject: [PATCH 1277/2810] Bump ihcsdk to 2.8.5 (#90266) --- homeassistant/components/ihc/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ihc/manifest.json b/homeassistant/components/ihc/manifest.json index 13bf8bb6d8..2400206c3a 100644 --- a/homeassistant/components/ihc/manifest.json +++ b/homeassistant/components/ihc/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/ihc", "iot_class": "local_push", "loggers": ["ihcsdk"], - "requirements": ["defusedxml==0.7.1", "ihcsdk==2.7.6"] + "requirements": ["defusedxml==0.7.1", "ihcsdk==2.8.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index b7f2f6a181..d06ee4e2bf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -964,7 +964,7 @@ ifaddr==0.1.7 iglo==1.2.7 # homeassistant.components.ihc -ihcsdk==2.7.6 +ihcsdk==2.8.5 # homeassistant.components.incomfort incomfort-client==0.5.0 From 2642d375052da2da43f5856e638fce8ada292625 Mon Sep 17 00:00:00 2001 From: Chris Xiao <30990835+chrisx8@users.noreply.github.com> Date: Sun, 26 Mar 2023 17:40:59 -0400 Subject: [PATCH 1278/2810] Set qbittorrent integration_type to service (#90236) --- homeassistant/components/qbittorrent/manifest.json | 1 + homeassistant/generated/integrations.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/qbittorrent/manifest.json b/homeassistant/components/qbittorrent/manifest.json index 2c1a7be74f..47090ab8b9 100644 --- a/homeassistant/components/qbittorrent/manifest.json +++ b/homeassistant/components/qbittorrent/manifest.json @@ -3,6 +3,7 @@ "name": "qBittorrent", "codeowners": ["@geoffreylagaisse"], "documentation": "https://www.home-assistant.io/integrations/qbittorrent", + "integration_type": "service", "iot_class": "local_polling", "loggers": ["qbittorrent"], "requirements": ["python-qbittorrent==0.4.2"] diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 7340980f13..4001adbd20 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4309,7 +4309,7 @@ }, "qbittorrent": { "name": "qBittorrent", - "integration_type": "hub", + "integration_type": "service", "config_flow": false, "iot_class": "local_polling" }, From c06ec1f78fb1fafa4d3da0737f7cc779514c8b57 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 26 Mar 2023 23:46:52 +0200 Subject: [PATCH 1279/2810] Improve onewire test coverage (#90184) --- tests/components/onewire/test_init.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/components/onewire/test_init.py b/tests/components/onewire/test_init.py index 5a69fb95e1..01c1841d17 100644 --- a/tests/components/onewire/test_init.py +++ b/tests/components/onewire/test_init.py @@ -1,4 +1,5 @@ """Tests for 1-Wire config flow.""" +from copy import deepcopy from unittest.mock import MagicMock, patch import aiohttp @@ -74,6 +75,27 @@ async def test_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> N assert not hass.data.get(DOMAIN) +async def test_update_options( + hass: HomeAssistant, config_entry: ConfigEntry, owproxy: MagicMock +) -> None: + """Test update options triggers reload.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state is ConfigEntryState.LOADED + assert owproxy.call_count == 1 + + new_options = deepcopy(dict(config_entry.options)) + new_options["device_options"].clear() + hass.config_entries.async_update_entry(config_entry, options=new_options) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state is ConfigEntryState.LOADED + assert owproxy.call_count == 2 + + @patch("homeassistant.components.onewire.PLATFORMS", [Platform.SENSOR]) async def test_registry_cleanup( hass: HomeAssistant, From 16028dc9bc1beaa9fa2e50fb901eb28c8a64ae3f Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 26 Mar 2023 17:52:01 -0400 Subject: [PATCH 1280/2810] Add milliseconds as valid duration sensor unit (#90018) --- homeassistant/components/sensor/const.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 356eb68b4d..892bc611b3 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -485,6 +485,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { UnitOfTime.HOURS, UnitOfTime.MINUTES, UnitOfTime.SECONDS, + UnitOfTime.MILLISECONDS, }, SensorDeviceClass.ENERGY: set(UnitOfEnergy), SensorDeviceClass.ENERGY_STORAGE: set(UnitOfEnergy), From 00ce7570510f7cc5a215c2f5ef4cbf8d0e124b5a Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 27 Mar 2023 00:10:57 +0200 Subject: [PATCH 1281/2810] Apply late review comments from #89396 in Nextcloud (#90327) --- .coveragerc | 1 - homeassistant/components/nextcloud/__init__.py | 9 +++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.coveragerc b/.coveragerc index 17db4ef9cd..da7cc42ba1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -780,7 +780,6 @@ omit = homeassistant/components/nexia/switch.py homeassistant/components/nextcloud/__init__.py homeassistant/components/nextcloud/binary_sensor.py - homeassistant/components/nextcloud/const.py homeassistant/components/nextcloud/coordinator.py homeassistant/components/nextcloud/entity.py homeassistant/components/nextcloud/sensor.py diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py index d2ad3edf1c..d2514b9091 100644 --- a/homeassistant/components/nextcloud/__init__.py +++ b/homeassistant/components/nextcloud/__init__.py @@ -94,3 +94,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Nextcloud integration.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) + return unload_ok From bdd095423b4dfdc6a4900a567cfdd2cc12f874f8 Mon Sep 17 00:00:00 2001 From: skrynklarn <20681457+skrynklarn@users.noreply.github.com> Date: Mon, 27 Mar 2023 00:17:12 +0200 Subject: [PATCH 1282/2810] Add last trip time attribute to Verisure binary sensors (#89944) --- homeassistant/components/verisure/binary_sensor.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/verisure/binary_sensor.py b/homeassistant/components/verisure/binary_sensor.py index 536b96ea2c..a960107c71 100644 --- a/homeassistant/components/verisure/binary_sensor.py +++ b/homeassistant/components/verisure/binary_sensor.py @@ -6,11 +6,12 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory +from homeassistant.const import ATTR_LAST_TRIP_TIME, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util from .const import CONF_GIID, DOMAIN from .coordinator import VerisureDataUpdateCoordinator @@ -79,6 +80,15 @@ class VerisureDoorWindowSensor( and self.serial_number in self.coordinator.data["door_window"] ) + @property + def extra_state_attributes(self): + """Return the state attributes of the sensor.""" + return { + ATTR_LAST_TRIP_TIME: dt_util.parse_datetime( + self.coordinator.data["door_window"][self.serial_number]["reportTime"] + ) + } + class VerisureEthernetStatus( CoordinatorEntity[VerisureDataUpdateCoordinator], BinarySensorEntity From a733ca96a2108ab35b7c5db212fc6b4aec1fda27 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Mar 2023 12:17:32 -1000 Subject: [PATCH 1283/2810] Bump yalexs-ble to 2.1.9 (#90320) --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index d30d3a39fb..7884ba6a4b 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.6"] + "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.9"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index da9e0271e4..5c7adf09e3 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.1.6"] + "requirements": ["yalexs-ble==2.1.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index d06ee4e2bf..68aece1846 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2668,7 +2668,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.6 +yalexs-ble==2.1.9 # homeassistant.components.august yalexs==1.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0c4581d87e..5b63fb59b6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1908,7 +1908,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.6 +yalexs-ble==2.1.9 # homeassistant.components.august yalexs==1.2.7 From a7c796a2f7ccc7d609363824a5f2b720d72db546 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Mar 2023 12:30:00 -1000 Subject: [PATCH 1284/2810] Ensure esphome connected future is awaited when connecting is canceled (#90329) --- .../components/esphome/bluetooth/client.py | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index 71d081ff6a..c332fc3441 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -322,15 +322,24 @@ class ESPHomeClient(BaseBleakClient): address_type=self._address_type, ) ) + except asyncio.CancelledError: + if connected_future.done(): + with contextlib.suppress(BleakError): + # If we are cancelled while connecting, + # we need to make sure we await the future + # to avoid a warning about an un-retrieved + # exception. + await connected_future + raise except Exception: - with contextlib.suppress(BleakError): - # If the connect call throws an exception, - # we need to make sure we await the future - # to avoid a warning about an un-retrieved - # exception since we prefer to raise the - # exception from the connect call as it - # will be more descriptive. - if connected_future.done(): + if connected_future.done(): + with contextlib.suppress(BleakError): + # If the connect call throws an exception, + # we need to make sure we await the future + # to avoid a warning about an un-retrieved + # exception since we prefer to raise the + # exception from the connect call as it + # will be more descriptive. await connected_future connected_future.cancel() raise From ce9099a38664524cbba8801130cd0107c19aa6c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Mar 2023 12:30:17 -1000 Subject: [PATCH 1285/2810] Bump cryptography to 40.0.1 (#90326) --- homeassistant/package_constraints.txt | 10 +++++----- pyproject.toml | 6 +++--- requirements.txt | 4 ++-- script/gen_requirements_all.py | 6 +++--- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c91ab060b5..cd568947cf 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -17,7 +17,7 @@ bluetooth-auto-recovery==1.0.3 bluetooth-data-tools==0.3.1 certifi>=2021.5.30 ciso8601==2.3.0 -cryptography==39.0.1 +cryptography==40.0.1 dbus-fast==1.84.2 fnvhash==0.1.0 hass-nabucasa==0.62.0 @@ -35,7 +35,7 @@ paho-mqtt==1.6.1 pillow==9.4.0 pip>=21.0,<23.1 psutil-home-assistant==0.0.1 -pyOpenSSL==23.0.0 +pyOpenSSL==23.1.0 pyserial==3.5 python-slugify==4.0.1 pyudev==0.23.2 @@ -144,9 +144,9 @@ pandas==1.4.3;python_version<'3.11' # We need at least >=2.1.0 (tensorflow integration -> pycocotools) matplotlib==3.6.1 -# pyOpenSSL 23.0.0 or later required to avoid import errors when -# cryptography 39.0.0 is installed with botocore -pyOpenSSL>=23.0.0 +# pyOpenSSL 23.1.0 or later required to avoid import errors when +# cryptography 40.0.1 is installed with botocore +pyOpenSSL>=23.1.0 # uamqp newer versions we currently can't build for armv7/armhf # Limit this to Python 3.10, to not block Python 3.11 dev for now diff --git a/pyproject.toml b/pyproject.toml index 5d39a99c03..577ba18140 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,9 +41,9 @@ dependencies = [ "lru-dict==1.1.8", "PyJWT==2.6.0", # PyJWT has loose dependency. We want the latest one. - "cryptography==39.0.1", - # pyOpenSSL 23.0.0 is required to work with cryptography 39+ - "pyOpenSSL==23.0.0", + "cryptography==40.0.1", + # pyOpenSSL 23.1.0 is required to work with cryptography 39+ + "pyOpenSSL==23.1.0", "orjson==3.8.7", "pip>=21.0,<23.1", "python-slugify==4.0.1", diff --git a/requirements.txt b/requirements.txt index 2386015c84..84726cb49d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,8 +16,8 @@ ifaddr==0.1.7 jinja2==3.1.2 lru-dict==1.1.8 PyJWT==2.6.0 -cryptography==39.0.1 -pyOpenSSL==23.0.0 +cryptography==40.0.1 +pyOpenSSL==23.1.0 orjson==3.8.7 pip>=21.0,<23.1 python-slugify==4.0.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index cd53635d96..564d0e2eb0 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -151,9 +151,9 @@ pandas==1.4.3;python_version<'3.11' # We need at least >=2.1.0 (tensorflow integration -> pycocotools) matplotlib==3.6.1 -# pyOpenSSL 23.0.0 or later required to avoid import errors when -# cryptography 39.0.0 is installed with botocore -pyOpenSSL>=23.0.0 +# pyOpenSSL 23.1.0 or later required to avoid import errors when +# cryptography 40.0.1 is installed with botocore +pyOpenSSL>=23.1.0 # uamqp newer versions we currently can't build for armv7/armhf # Limit this to Python 3.10, to not block Python 3.11 dev for now From 65e46e326171e4ca24ab7411ad6f7b7df3725039 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Mar 2023 12:58:07 -1000 Subject: [PATCH 1286/2810] Bump aioesphomeapi to 13.6.0 (#90330) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 95b6c091d5..ac98592da9 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -14,6 +14,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], - "requirements": ["aioesphomeapi==13.5.1", "esphome-dashboard-api==1.2.3"], + "requirements": ["aioesphomeapi==13.6.0", "esphome-dashboard-api==1.2.3"], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 68aece1846..7500baa41a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -156,7 +156,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.5.1 +aioesphomeapi==13.6.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5b63fb59b6..3be11d6cd6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -146,7 +146,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.5.1 +aioesphomeapi==13.6.0 # homeassistant.components.flo aioflo==2021.11.0 From 75e28826e06eb709c3102fdcb816986d8122d22f Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Mon, 27 Mar 2023 01:22:20 +0200 Subject: [PATCH 1287/2810] Upgrade netgear_lte third-party library to v0.0.15 (#90324) * Upgrade netgear_lte third-party library to 0.0.15 * Create explicit tasks for asyncio.wait() --- homeassistant/components/netgear_lte/__init__.py | 5 ++++- homeassistant/components/netgear_lte/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py index fd2c399fb3..0ab3dd07ed 100644 --- a/homeassistant/components/netgear_lte/__init__.py +++ b/homeassistant/components/netgear_lte/__init__.py @@ -221,7 +221,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: netgear_lte_config = config[DOMAIN] # Set up each modem - tasks = [_setup_lte(hass, lte_conf) for lte_conf in netgear_lte_config] + tasks = [ + hass.async_create_task(_setup_lte(hass, lte_conf)) + for lte_conf in netgear_lte_config + ] await asyncio.wait(tasks) # Load platforms for each modem diff --git a/homeassistant/components/netgear_lte/manifest.json b/homeassistant/components/netgear_lte/manifest.json index ae580dbb99..427aa9633c 100644 --- a/homeassistant/components/netgear_lte/manifest.json +++ b/homeassistant/components/netgear_lte/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/netgear_lte", "iot_class": "local_polling", "loggers": ["eternalegypt"], - "requirements": ["eternalegypt==0.0.12"] + "requirements": ["eternalegypt==0.0.15"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7500baa41a..31e78c40c2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -679,7 +679,7 @@ epsonprinter==0.0.9 esphome-dashboard-api==1.2.3 # homeassistant.components.netgear_lte -eternalegypt==0.0.12 +eternalegypt==0.0.15 # homeassistant.components.eufylife_ble eufylife_ble_client==0.1.7 From 7098debe098a4c019c85222cfc73441ae6eda1f6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Mar 2023 15:02:24 -1000 Subject: [PATCH 1288/2810] Fix sql doing I/O in the event loop at startup (#90335) * Fix sql doing I/O in the event loop * Fix sql doing I/O in the event loop * no test query on main db * fix mocking because it was targeting the recorder --- homeassistant/components/sql/sensor.py | 53 +++++++++++++++++--------- tests/components/sql/test_sensor.py | 16 +++++--- 2 files changed, 46 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 95227bac65..57818ef27e 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -136,24 +136,17 @@ async def async_setup_sensor( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the SQL sensor.""" - try: - engine = sqlalchemy.create_engine(db_url, future=True) - sessmaker = scoped_session(sessionmaker(bind=engine, future=True)) - - # Run a dummy query just to test the db_url - sess: Session = sessmaker() - sess.execute(sqlalchemy.text("SELECT 1;")) - - except SQLAlchemyError as err: - _LOGGER.error( - "Couldn't connect using %s DB_URL: %s", - redact_credentials(db_url), - redact_credentials(str(err)), + instance = get_instance(hass) + sessmaker: scoped_session | None + if use_database_executor := (db_url == instance.db_url): + assert instance.engine is not None + sessmaker = scoped_session(sessionmaker(bind=instance.engine, future=True)) + elif not ( + sessmaker := await hass.async_add_executor_job( + _validate_and_get_session_maker_for_db_url, db_url ) + ): return - finally: - if sess: - sess.close() # MSSQL uses TOP and not LIMIT if not ("LIMIT" in query_str.upper() or "SELECT TOP" in query_str.upper()): @@ -162,8 +155,6 @@ async def async_setup_sensor( else: query_str = query_str.replace(";", "") + " LIMIT 1;" - use_database_executor = db_url == get_instance(hass).db_url - async_add_entities( [ SQLSensor( @@ -184,6 +175,32 @@ async def async_setup_sensor( ) +def _validate_and_get_session_maker_for_db_url(db_url: str) -> scoped_session | None: + """Validate the db_url and return a session maker. + + This does I/O and should be run in the executor. + """ + try: + engine = sqlalchemy.create_engine(db_url, future=True) + sessmaker = scoped_session(sessionmaker(bind=engine, future=True)) + # Run a dummy query just to test the db_url + sess: Session = sessmaker() + sess.execute(sqlalchemy.text("SELECT 1;")) + + except SQLAlchemyError as err: + _LOGGER.error( + "Couldn't connect using %s DB_URL: %s", + redact_credentials(db_url), + redact_credentials(str(err)), + ) + return None + else: + return sessmaker + finally: + if sess: + sess.close() + + class SQLSensor(SensorEntity): """Representation of an SQL sensor.""" diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index 400e3056d5..426dd9e196 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import timedelta +from typing import Any from unittest.mock import patch import pytest @@ -193,14 +194,19 @@ async def test_invalid_url_on_update( "column": "value", "name": "count_tables", } - await init_integration(hass, config) + + class MockSession: + """Mock session.""" + + def execute(self, query: Any) -> None: + """Execute the query.""" + raise SQLAlchemyError("sqlite://homeassistant:hunter2@homeassistant.local") with patch( - "homeassistant.components.sql.sensor.sqlalchemy.engine.cursor.CursorResult", - side_effect=SQLAlchemyError( - "sqlite://homeassistant:hunter2@homeassistant.local" - ), + "homeassistant.components.sql.sensor.scoped_session", + return_value=MockSession, ): + await init_integration(hass, config) async_fire_time_changed( hass, dt.utcnow() + timedelta(minutes=1), From c3717f8182d0eb7e176efc08ccb5be61d91b9a27 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 26 Mar 2023 22:41:17 -0400 Subject: [PATCH 1289/2810] Clean up voice assistant integration (#90239) * Clean up voice assistant * Reinstate auto-removed imports * Resample STT audio from 44.1Khz to 16Khz * Energy based VAD for prototyping --------- Co-authored-by: Michael Hansen --- homeassistant/components/cloud/stt.py | 6 +- .../components/voice_assistant/pipeline.py | 150 ++++++------ .../voice_assistant/websocket_api.py | 62 ++++- .../snapshots/test_websocket.ambr | 210 +++++++++++++++++ .../voice_assistant/test_websocket.py | 216 +++++------------- 5 files changed, 407 insertions(+), 237 deletions(-) create mode 100644 tests/components/voice_assistant/snapshots/test_websocket.ambr diff --git a/homeassistant/components/cloud/stt.py b/homeassistant/components/cloud/stt.py index bdce055c3c..13062db57d 100644 --- a/homeassistant/components/cloud/stt.py +++ b/homeassistant/components/cloud/stt.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import AsyncIterable +import logging from hass_nabucasa import Cloud from hass_nabucasa.voice import VoiceError @@ -20,6 +21,8 @@ from homeassistant.components.stt import ( from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + SUPPORT_LANGUAGES = [ "da-DK", "de-DE", @@ -102,7 +105,8 @@ class CloudProvider(Provider): result = await self.cloud.voice.process_stt( stream, content, metadata.language ) - except VoiceError: + except VoiceError as err: + _LOGGER.debug("Voice error: %s", err) return SpeechResult(None, SpeechResultState.ERROR) # Return Speech as Text diff --git a/homeassistant/components/voice_assistant/pipeline.py b/homeassistant/components/voice_assistant/pipeline.py index 0070154bd4..806a603f5e 100644 --- a/homeassistant/components/voice_assistant/pipeline.py +++ b/homeassistant/components/voice_assistant/pipeline.py @@ -150,6 +150,7 @@ class PipelineRun: end_stage: PipelineStage event_callback: Callable[[PipelineEvent], None] language: str = None # type: ignore[assignment] + runner_data: Any | None = None def __post_init__(self): """Set language for pipeline.""" @@ -163,15 +164,14 @@ class PipelineRun: def start(self): """Emit run start event.""" - self.event_callback( - PipelineEvent( - PipelineEventType.RUN_START, - { - "pipeline": self.pipeline.name, - "language": self.language, - }, - ) - ) + data = { + "pipeline": self.pipeline.name, + "language": self.language, + } + if self.runner_data is not None: + data["runner_data"] = self.runner_data + + self.event_callback(PipelineEvent(PipelineEventType.RUN_START, data)) def end(self): """Emit run end event.""" @@ -200,41 +200,45 @@ class PipelineRun: try: # Load provider - stt_provider = stt.async_get_provider(self.hass, self.pipeline.stt_engine) + stt_provider: stt.Provider = stt.async_get_provider( + self.hass, self.pipeline.stt_engine + ) assert stt_provider is not None except Exception as src_error: - stt_error = SpeechToTextError( + _LOGGER.exception("No speech to text provider for %s", engine) + raise SpeechToTextError( code="stt-provider-missing", message=f"No speech to text provider for: {engine}", + ) from src_error + + if not stt_provider.check_metadata(metadata): + raise SpeechToTextError( + code="stt-provider-unsupported-metadata", + message=f"Provider {engine} does not support input speech to text metadata", ) - _LOGGER.exception(stt_error.message) - self.event_callback( - PipelineEvent( - PipelineEventType.ERROR, - {"code": stt_error.code, "message": stt_error.message}, - ) - ) - raise stt_error from src_error try: # Transcribe audio stream result = await stt_provider.async_process_audio_stream(metadata, stream) - assert (result.text is not None) and ( - result.result == stt.SpeechResultState.SUCCESS - ) except Exception as src_error: - stt_error = SpeechToTextError( + _LOGGER.exception("Unexpected error during speech to text") + raise SpeechToTextError( code="stt-stream-failed", message="Unexpected error during speech to text", + ) from src_error + + _LOGGER.debug("speech-to-text result %s", result) + + if result.result != stt.SpeechResultState.SUCCESS: + raise SpeechToTextError( + code="stt-stream-failed", + message="Speech to text failed", ) - _LOGGER.exception(stt_error.message) - self.event_callback( - PipelineEvent( - PipelineEventType.ERROR, - {"code": stt_error.code, "message": stt_error.message}, - ) + + if not result.text: + raise SpeechToTextError( + code="stt-no-text-recognized", message="No text recognized" ) - raise stt_error from src_error self.event_callback( PipelineEvent( @@ -273,18 +277,13 @@ class PipelineRun: agent_id=self.pipeline.conversation_engine, ) except Exception as src_error: - intent_error = IntentRecognitionError( + _LOGGER.exception("Unexpected error during intent recognition") + raise IntentRecognitionError( code="intent-failed", message="Unexpected error during intent recognition", - ) - _LOGGER.exception(intent_error.message) - self.event_callback( - PipelineEvent( - PipelineEventType.ERROR, - {"code": intent_error.code, "message": intent_error.message}, - ) - ) - raise intent_error from src_error + ) from src_error + + _LOGGER.debug("conversation result %s", conversation_result) self.event_callback( PipelineEvent( @@ -320,18 +319,13 @@ class PipelineRun: ), ) except Exception as src_error: - tts_error = TextToSpeechError( + _LOGGER.exception("Unexpected error during text to speech") + raise TextToSpeechError( code="tts-failed", message="Unexpected error during text to speech", - ) - _LOGGER.exception(tts_error.message) - self.event_callback( - PipelineEvent( - PipelineEventType.ERROR, - {"code": tts_error.code, "message": tts_error.message}, - ) - ) - raise tts_error from src_error + ) from src_error + + _LOGGER.debug("TTS result %s", tts_media) self.event_callback( PipelineEvent( @@ -377,31 +371,41 @@ class PipelineInput: run.start() current_stage = run.start_stage - # Speech to text - intent_input = self.intent_input - if current_stage == PipelineStage.STT: - assert self.stt_metadata is not None - assert self.stt_stream is not None - intent_input = await run.speech_to_text( - self.stt_metadata, - self.stt_stream, - ) - current_stage = PipelineStage.INTENT - - if run.end_stage != PipelineStage.STT: - tts_input = self.tts_input - - if current_stage == PipelineStage.INTENT: - assert intent_input is not None - tts_input = await run.recognize_intent( - intent_input, self.conversation_id + try: + # Speech to text + intent_input = self.intent_input + if current_stage == PipelineStage.STT: + assert self.stt_metadata is not None + assert self.stt_stream is not None + intent_input = await run.speech_to_text( + self.stt_metadata, + self.stt_stream, ) - current_stage = PipelineStage.TTS + current_stage = PipelineStage.INTENT - if run.end_stage != PipelineStage.INTENT: - if current_stage == PipelineStage.TTS: - assert tts_input is not None - await run.text_to_speech(tts_input) + if run.end_stage != PipelineStage.STT: + tts_input = self.tts_input + + if current_stage == PipelineStage.INTENT: + assert intent_input is not None + tts_input = await run.recognize_intent( + intent_input, self.conversation_id + ) + current_stage = PipelineStage.TTS + + if run.end_stage != PipelineStage.INTENT: + if current_stage == PipelineStage.TTS: + assert tts_input is not None + await run.text_to_speech(tts_input) + + except PipelineError as err: + run.event_callback( + PipelineEvent( + PipelineEventType.ERROR, + {"code": err.code, "message": err.message}, + ) + ) + return run.end() diff --git a/homeassistant/components/voice_assistant/websocket_api.py b/homeassistant/components/voice_assistant/websocket_api.py index cc4799f13e..28cafb7a35 100644 --- a/homeassistant/components/voice_assistant/websocket_api.py +++ b/homeassistant/components/voice_assistant/websocket_api.py @@ -1,5 +1,6 @@ """Voice Assistant Websocket API.""" import asyncio +import audioop from collections.abc import Callable import logging from typing import Any @@ -12,6 +13,8 @@ from homeassistant.core import HomeAssistant, callback from .pipeline import ( DEFAULT_TIMEOUT, PipelineError, + PipelineEvent, + PipelineEventType, PipelineInput, PipelineRun, PipelineStage, @@ -20,6 +23,10 @@ from .pipeline import ( _LOGGER = logging.getLogger(__name__) +_VAD_ENERGY_THRESHOLD = 1000 +_VAD_SPEECH_FRAMES = 25 +_VAD_SILENCE_FRAMES = 25 + @callback def async_register_websocket_api(hass: HomeAssistant) -> None: @@ -27,6 +34,17 @@ def async_register_websocket_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_run) +def _get_debiased_energy(audio_data: bytes, width: int = 2) -> float: + """Compute RMS of debiased audio.""" + energy = -audioop.rms(audio_data, width) + energy_bytes = bytes([energy & 0xFF, (energy >> 8) & 0xFF]) + debiased_energy = audioop.rms( + audioop.add(audio_data, energy_bytes * (len(audio_data) // width), width), width + ) + + return debiased_energy + + @websocket_api.websocket_command( { vol.Required("type"): "voice_assistant/run", @@ -49,6 +67,11 @@ async def websocket_run( ) -> None: """Run a pipeline.""" language = msg.get("language", hass.config.language) + + # Temporary workaround for language codes + if language == "en": + language = "en-US" + pipeline_id = msg.get("pipeline") pipeline = async_get_pipeline( hass, @@ -79,8 +102,32 @@ async def websocket_run( audio_queue: "asyncio.Queue[bytes]" = asyncio.Queue() async def stt_stream(): + state = None + speech_count = 0 + in_voice_command = False + # Yield until we receive an empty chunk while chunk := await audio_queue.get(): + chunk, state = audioop.ratecv(chunk, 2, 1, 44100, 16000, state) + is_speech = _get_debiased_energy(chunk) > _VAD_ENERGY_THRESHOLD + + if in_voice_command: + if is_speech: + speech_count += 1 + else: + speech_count -= 1 + + if speech_count <= -_VAD_SILENCE_FRAMES: + _LOGGER.info("Voice command stopped") + break + else: + if is_speech: + speech_count += 1 + + if speech_count >= _VAD_SPEECH_FRAMES: + in_voice_command = True + _LOGGER.info("Voice command started") + yield chunk def handle_binary(_hass, _connection, data: bytes): @@ -119,6 +166,9 @@ async def websocket_run( event_callback=lambda event: connection.send_event( msg["id"], event.as_dict() ), + runner_data={ + "stt_binary_handler_id": handler_id, + }, ), timeout=timeout, ) @@ -130,16 +180,20 @@ async def websocket_run( # Confirm subscription connection.send_result(msg["id"]) - if handler_id is not None: - # Send handler id to client - connection.send_event(msg["id"], {"handler_id": handler_id}) - try: # Task contains a timeout await run_task except PipelineError as error: # Report more specific error when possible connection.send_error(msg["id"], error.code, error.message) + except asyncio.TimeoutError: + connection.send_event( + msg["id"], + PipelineEvent( + PipelineEventType.ERROR, + {"code": "timeout", "message": "Timeout running pipeline"}, + ), + ) finally: if unregister_handler is not None: # Unregister binary handler diff --git a/tests/components/voice_assistant/snapshots/test_websocket.ambr b/tests/components/voice_assistant/snapshots/test_websocket.ambr new file mode 100644 index 0000000000..07934df6c4 --- /dev/null +++ b/tests/components/voice_assistant/snapshots/test_websocket.ambr @@ -0,0 +1,210 @@ +# serializer version: 1 +# name: test_audio_pipeline + dict({ + 'language': 'en-US', + 'pipeline': 'en-US', + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + }), + }) +# --- +# name: test_audio_pipeline.1 + dict({ + 'engine': 'default', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'language': 'en-US', + 'sample_rate': 16000, + }), + }) +# --- +# name: test_audio_pipeline.2 + dict({ + 'stt_output': dict({ + 'text': 'test transcript', + }), + }) +# --- +# name: test_audio_pipeline.3 + dict({ + 'engine': 'default', + 'intent_input': 'test transcript', + }) +# --- +# name: test_audio_pipeline.4 + dict({ + 'intent_output': dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_intent_match', + }), + 'language': 'en-US', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': "Sorry, I couldn't understand that", + }), + }), + }), + }), + }) +# --- +# name: test_audio_pipeline.5 + dict({ + 'engine': 'default', + 'tts_input': "Sorry, I couldn't understand that", + }) +# --- +# name: test_audio_pipeline.6 + dict({ + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en_-_demo.mp3', + }), + }) +# --- +# name: test_intent_failed + dict({ + 'language': 'en-US', + 'pipeline': 'en-US', + 'runner_data': dict({ + 'stt_binary_handler_id': None, + }), + }) +# --- +# name: test_intent_failed.1 + dict({ + 'engine': 'default', + 'intent_input': 'Are the lights on?', + }) +# --- +# name: test_intent_timeout + dict({ + 'language': 'en-US', + 'pipeline': 'en-US', + 'runner_data': dict({ + 'stt_binary_handler_id': None, + }), + }) +# --- +# name: test_intent_timeout.1 + dict({ + 'engine': 'default', + 'intent_input': 'Are the lights on?', + }) +# --- +# name: test_intent_timeout.2 + dict({ + 'code': 'timeout', + 'message': 'Timeout running pipeline', + }) +# --- +# name: test_stt_provider_missing + dict({ + 'language': 'en-US', + 'pipeline': 'en-US', + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + }), + }) +# --- +# name: test_stt_provider_missing.1 + dict({ + 'engine': 'default', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'language': 'en-US', + 'sample_rate': 16000, + }), + }) +# --- +# name: test_stt_stream_failed + dict({ + 'language': 'en-US', + 'pipeline': 'en-US', + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + }), + }) +# --- +# name: test_stt_stream_failed.1 + dict({ + 'engine': 'default', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'language': 'en-US', + 'sample_rate': 16000, + }), + }) +# --- +# name: test_text_only_pipeline + dict({ + 'language': 'en-US', + 'pipeline': 'en-US', + 'runner_data': dict({ + 'stt_binary_handler_id': None, + }), + }) +# --- +# name: test_text_only_pipeline.1 + dict({ + 'engine': 'default', + 'intent_input': 'Are the lights on?', + }) +# --- +# name: test_text_only_pipeline.2 + dict({ + 'intent_output': dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_intent_match', + }), + 'language': 'en-US', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': "Sorry, I couldn't understand that", + }), + }), + }), + }), + }) +# --- +# name: test_text_pipeline_timeout + dict({ + 'code': 'timeout', + 'message': 'Timeout running pipeline', + }) +# --- +# name: test_tts_failed + dict({ + 'language': 'en-US', + 'pipeline': 'en-US', + 'runner_data': dict({ + 'stt_binary_handler_id': None, + }), + }) +# --- +# name: test_tts_failed.1 + dict({ + 'engine': 'default', + 'tts_input': 'Lights are on.', + }) +# --- diff --git a/tests/components/voice_assistant/test_websocket.py b/tests/components/voice_assistant/test_websocket.py index a1ba8b5f7c..f02122a3e7 100644 --- a/tests/components/voice_assistant/test_websocket.py +++ b/tests/components/voice_assistant/test_websocket.py @@ -4,6 +4,7 @@ from collections.abc import AsyncIterable from unittest.mock import MagicMock, patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components import stt from homeassistant.core import HomeAssistant @@ -29,7 +30,7 @@ class MockSttProvider(stt.Provider): @property def supported_languages(self) -> list[str]: """Return a list of supported languages.""" - return [self.hass.config.language] + return ["en-US"] @property def supported_formats(self) -> list[stt.AudioFormats]: @@ -64,7 +65,11 @@ class MockSttProvider(stt.Provider): @pytest.fixture(autouse=True) -async def init_components(hass): +async def init_components( + hass: HomeAssistant, + mock_get_cache_files, # noqa: F811 + mock_init_cache_dir, # noqa: F811 +): """Initialize relevant components with empty configs.""" assert await async_setup_component(hass, "media_source", {}) assert await async_setup_component( @@ -93,6 +98,7 @@ async def init_components(hass): async def test_text_only_pipeline( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test events from a pipeline run with text input (no STT/TTS).""" client = await hass_ws_client(hass) @@ -114,38 +120,16 @@ async def test_text_only_pipeline( # run start msg = await client.receive_json() assert msg["event"]["type"] == "run-start" - assert msg["event"]["data"] == { - "pipeline": hass.config.language, - "language": hass.config.language, - } + assert msg["event"]["data"] == snapshot # intent msg = await client.receive_json() assert msg["event"]["type"] == "intent-start" - assert msg["event"]["data"] == { - "engine": "default", - "intent_input": "Are the lights on?", - } + assert msg["event"]["data"] == snapshot msg = await client.receive_json() assert msg["event"]["type"] == "intent-end" - assert msg["event"]["data"] == { - "intent_output": { - "response": { - "speech": { - "plain": { - "speech": "Sorry, I couldn't understand that", - "extra_data": None, - } - }, - "card": {}, - "language": "en", - "response_type": "error", - "data": {"code": "no_intent_match"}, - }, - "conversation_id": None, - } - } + assert msg["event"]["data"] == snapshot # run end msg = await client.receive_json() @@ -154,8 +138,7 @@ async def test_text_only_pipeline( async def test_audio_pipeline( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, snapshot: SnapshotAssertion ) -> None: """Test events from a pipeline run with audio input/output.""" client = await hass_ws_client(hass) @@ -173,86 +156,40 @@ async def test_audio_pipeline( msg = await client.receive_json() assert msg["success"] - # handler id - msg = await client.receive_json() - assert msg["event"]["handler_id"] == 1 - # run start msg = await client.receive_json() assert msg["event"]["type"] == "run-start" - assert msg["event"]["data"] == { - "pipeline": hass.config.language, - "language": hass.config.language, - } + assert msg["event"]["data"] == snapshot # stt msg = await client.receive_json() assert msg["event"]["type"] == "stt-start" - assert msg["event"]["data"] == { - "engine": "default", - "metadata": { - "bit_rate": 16, - "channel": 1, - "codec": "pcm", - "format": "wav", - "language": "en", - "sample_rate": 16000, - }, - } + assert msg["event"]["data"] == snapshot # End of audio stream (handler id + empty payload) await client.send_bytes(b"1") msg = await client.receive_json() assert msg["event"]["type"] == "stt-end" - assert msg["event"]["data"] == { - "stt_output": {"text": _TRANSCRIPT}, - } + assert msg["event"]["data"] == snapshot # intent msg = await client.receive_json() assert msg["event"]["type"] == "intent-start" - assert msg["event"]["data"] == { - "engine": "default", - "intent_input": _TRANSCRIPT, - } + assert msg["event"]["data"] == snapshot msg = await client.receive_json() assert msg["event"]["type"] == "intent-end" - assert msg["event"]["data"] == { - "intent_output": { - "response": { - "speech": { - "plain": { - "speech": "Sorry, I couldn't understand that", - "extra_data": None, - } - }, - "card": {}, - "language": "en", - "response_type": "error", - "data": {"code": "no_intent_match"}, - }, - "conversation_id": None, - } - } + assert msg["event"]["data"] == snapshot # text to speech msg = await client.receive_json() assert msg["event"]["type"] == "tts-start" - assert msg["event"]["data"] == { - "engine": "default", - "tts_input": "Sorry, I couldn't understand that", - } + assert msg["event"]["data"] == snapshot msg = await client.receive_json() assert msg["event"]["type"] == "tts-end" - assert msg["event"]["data"] == { - "tts_output": { - "url": f"/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_{hass.config.language}_-_demo.mp3", - "mime_type": "audio/mpeg", - }, - } + assert msg["event"]["data"] == snapshot # run end msg = await client.receive_json() @@ -261,7 +198,10 @@ async def test_audio_pipeline( async def test_intent_timeout( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, ) -> None: """Test partial pipeline run with conversation agent timeout.""" client = await hass_ws_client(hass) @@ -291,27 +231,24 @@ async def test_intent_timeout( # run start msg = await client.receive_json() assert msg["event"]["type"] == "run-start" - assert msg["event"]["data"] == { - "pipeline": hass.config.language, - "language": hass.config.language, - } + assert msg["event"]["data"] == snapshot # intent msg = await client.receive_json() assert msg["event"]["type"] == "intent-start" - assert msg["event"]["data"] == { - "engine": "default", - "intent_input": "Are the lights on?", - } + assert msg["event"]["data"] == snapshot # timeout error msg = await client.receive_json() - assert not msg["success"] - assert msg["error"]["code"] == "timeout" + assert msg["event"]["type"] == "error" + assert msg["event"]["data"] == snapshot async def test_text_pipeline_timeout( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, ) -> None: """Test text-only pipeline run with immediate timeout.""" client = await hass_ws_client(hass) @@ -340,12 +277,15 @@ async def test_text_pipeline_timeout( # timeout error msg = await client.receive_json() - assert not msg["success"] - assert msg["error"]["code"] == "timeout" + assert msg["event"]["type"] == "error" + assert msg["event"]["data"] == snapshot async def test_intent_failed( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, ) -> None: """Test text-only pipeline run with conversation agent error.""" client = await hass_ws_client(hass) @@ -371,18 +311,12 @@ async def test_intent_failed( # run start msg = await client.receive_json() assert msg["event"]["type"] == "run-start" - assert msg["event"]["data"] == { - "pipeline": hass.config.language, - "language": hass.config.language, - } + assert msg["event"]["data"] == snapshot # intent start msg = await client.receive_json() assert msg["event"]["type"] == "intent-start" - assert msg["event"]["data"] == { - "engine": "default", - "intent_input": "Are the lights on?", - } + assert msg["event"]["data"] == snapshot # intent error msg = await client.receive_json() @@ -391,7 +325,10 @@ async def test_intent_failed( async def test_audio_pipeline_timeout( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, ) -> None: """Test audio pipeline run with immediate timeout.""" client = await hass_ws_client(hass) @@ -417,19 +354,16 @@ async def test_audio_pipeline_timeout( msg = await client.receive_json() assert msg["success"] - # handler id - msg = await client.receive_json() - assert msg["event"]["handler_id"] == 1 - # timeout error msg = await client.receive_json() - assert not msg["success"] - assert msg["error"]["code"] == "timeout" + assert msg["event"]["type"] == "error" + assert msg["event"]["data"]["code"] == "timeout" async def test_stt_provider_missing( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test events from a pipeline run with a non-existent STT provider.""" with patch( @@ -451,32 +385,15 @@ async def test_stt_provider_missing( msg = await client.receive_json() assert msg["success"] - # handler id - msg = await client.receive_json() - assert msg["event"]["handler_id"] == 1 - # run start msg = await client.receive_json() assert msg["event"]["type"] == "run-start" - assert msg["event"]["data"] == { - "pipeline": hass.config.language, - "language": hass.config.language, - } + assert msg["event"]["data"] == snapshot # stt msg = await client.receive_json() assert msg["event"]["type"] == "stt-start" - assert msg["event"]["data"] == { - "engine": "default", - "metadata": { - "bit_rate": 16, - "channel": 1, - "codec": "pcm", - "format": "wav", - "language": "en", - "sample_rate": 16000, - }, - } + assert msg["event"]["data"] == snapshot # End of audio stream (handler id + empty payload) await client.send_bytes(b"1") @@ -490,6 +407,7 @@ async def test_stt_provider_missing( async def test_stt_stream_failed( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test events from a pipeline run with a non-existent STT provider.""" with patch( @@ -511,32 +429,15 @@ async def test_stt_stream_failed( msg = await client.receive_json() assert msg["success"] - # handler id - msg = await client.receive_json() - assert msg["event"]["handler_id"] == 1 - # run start msg = await client.receive_json() assert msg["event"]["type"] == "run-start" - assert msg["event"]["data"] == { - "pipeline": hass.config.language, - "language": hass.config.language, - } + assert msg["event"]["data"] == snapshot # stt msg = await client.receive_json() assert msg["event"]["type"] == "stt-start" - assert msg["event"]["data"] == { - "engine": "default", - "metadata": { - "bit_rate": 16, - "channel": 1, - "codec": "pcm", - "format": "wav", - "language": "en", - "sample_rate": 16000, - }, - } + assert msg["event"]["data"] == snapshot # End of audio stream (handler id + empty payload) await client.send_bytes(b"1") @@ -548,7 +449,10 @@ async def test_stt_stream_failed( async def test_tts_failed( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, ) -> None: """Test pipeline run with text to speech error.""" client = await hass_ws_client(hass) @@ -574,18 +478,12 @@ async def test_tts_failed( # run start msg = await client.receive_json() assert msg["event"]["type"] == "run-start" - assert msg["event"]["data"] == { - "pipeline": hass.config.language, - "language": hass.config.language, - } + assert msg["event"]["data"] == snapshot # tts start msg = await client.receive_json() assert msg["event"]["type"] == "tts-start" - assert msg["event"]["data"] == { - "engine": "default", - "tts_input": "Lights are on.", - } + assert msg["event"]["data"] == snapshot # tts error msg = await client.receive_json() From 624860da0e9a49381571ef91788b863181387d0b Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 27 Mar 2023 08:05:30 +0200 Subject: [PATCH 1290/2810] Remove deprecated platform yaml in Scrape (#90272) * Deprecate platform yaml * typing * DiscoveryInfoType --- homeassistant/components/scrape/sensor.py | 89 ++------------------ homeassistant/components/scrape/strings.json | 6 -- tests/components/scrape/__init__.py | 54 +----------- tests/components/scrape/test_sensor.py | 67 +-------------- 4 files changed, 8 insertions(+), 208 deletions(-) diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index 22184a17b8..5ddd6c48e4 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -1,44 +1,23 @@ """Support for getting data from websites with scraping.""" from __future__ import annotations -from datetime import timedelta import logging -from typing import Any +from typing import Any, cast import voluptuous as vol -from homeassistant.components.rest import RESOURCE_SCHEMA, create_rest_data_from_config -from homeassistant.components.sensor import ( - CONF_STATE_CLASS, - DEVICE_CLASSES_SCHEMA, - PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, - STATE_CLASSES_SCHEMA, - SensorDeviceClass, -) +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ATTRIBUTE, - CONF_AUTHENTICATION, - CONF_DEVICE_CLASS, - CONF_HEADERS, CONF_NAME, - CONF_PASSWORD, - CONF_RESOURCE, - CONF_SCAN_INTERVAL, CONF_UNIQUE_ID, - CONF_UNIT_OF_MEASUREMENT, - CONF_USERNAME, CONF_VALUE_TEMPLATE, - CONF_VERIFY_SSL, - HTTP_BASIC_AUTHENTICATION, - HTTP_DIGEST_AUTHENTICATION, ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template from homeassistant.helpers.template_entity import ( TEMPLATE_SENSOR_BASE_SCHEMA, @@ -47,43 +26,11 @@ from homeassistant.helpers.template_entity import ( from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - CONF_INDEX, - CONF_SELECT, - DEFAULT_NAME, - DEFAULT_SCAN_INTERVAL, - DEFAULT_VERIFY_SSL, - DOMAIN, -) +from .const import CONF_INDEX, CONF_SELECT, DOMAIN from .coordinator import ScrapeCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( - { - # Linked to the loading of the page (can be linked to RestData) - vol.Optional(CONF_AUTHENTICATION): vol.In( - [HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION] - ), - vol.Optional(CONF_HEADERS): vol.Schema({cv.string: cv.string}), - vol.Optional(CONF_PASSWORD): cv.string, - vol.Required(CONF_RESOURCE): cv.string, - vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, - # Linked to the parsing of the page (specific to scrape) - vol.Optional(CONF_ATTRIBUTE): cv.string, - vol.Optional(CONF_INDEX, default=0): cv.positive_int, - vol.Required(CONF_SELECT): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - # Linked to the sensor definition (can be linked to TemplateSensor) - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - } -) - async def async_setup_platform( hass: HomeAssistant, @@ -92,33 +39,9 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Web scrape sensor.""" - coordinator: ScrapeCoordinator - sensors_config: list[ConfigType] - if discovery_info is None: - async_create_issue( - hass, - DOMAIN, - "moved_yaml", - breaks_in_ha_version="2022.12.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="moved_yaml", - ) - resource_config = vol.Schema(RESOURCE_SCHEMA, extra=vol.REMOVE_EXTRA)(config) - rest = create_rest_data_from_config(hass, resource_config) - - scan_interval: timedelta = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - coordinator = ScrapeCoordinator(hass, rest, scan_interval) - - sensors_config = [ - vol.Schema(TEMPLATE_SENSOR_BASE_SCHEMA.schema, extra=vol.ALLOW_EXTRA)( - config - ) - ] - - else: - coordinator = discovery_info["coordinator"] - sensors_config = discovery_info["configs"] + discovery_info = cast(DiscoveryInfoType, discovery_info) + coordinator: ScrapeCoordinator = discovery_info["coordinator"] + sensors_config: list[ConfigType] = discovery_info["configs"] await coordinator.async_refresh() if coordinator.data is None: diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index 907aa2a9df..061518cb1d 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -121,11 +121,5 @@ } } } - }, - "issues": { - "moved_yaml": { - "title": "The Scrape YAML configuration has been moved", - "description": "Configuring Scrape using YAML has been moved to integration key.\n\nYour existing YAML configuration will be working for 2 more versions.\n\nMigrate your YAML configuration to the integration key according to the documentation." - } } } diff --git a/tests/components/scrape/__init__.py b/tests/components/scrape/__init__.py index 1bf3040513..3d57970a52 100644 --- a/tests/components/scrape/__init__.py +++ b/tests/components/scrape/__init__.py @@ -29,65 +29,13 @@ def return_integration_config( return config -def return_config( - select, - name, - *, - attribute=None, - index=None, - template=None, - uom=None, - device_class=None, - state_class=None, - authentication=None, - username=None, - password=None, - headers=None, - unique_id=None, - remove_platform=False, -) -> dict[str, dict[str, Any]]: - """Return config.""" - config = { - "platform": "scrape", - "resource": "https://www.home-assistant.io", - "select": select, - "name": name, - "index": 0, - "verify_ssl": True, - } - if remove_platform: - config.pop("platform") - if attribute: - config["attribute"] = attribute - if index: - config["index"] = index - if template: - config["value_template"] = template - if uom: - config["unit_of_measurement"] = uom - if device_class: - config["device_class"] = device_class - if state_class: - config["state_class"] = state_class - if authentication: - config["authentication"] = authentication - if username: - config["username"] = username - config["password"] = password - if headers: - config["headers"] = headers - if unique_id: - config["unique_id"] = unique_id - return config - - class MockRestData: """Mock RestData.""" def __init__( self, payload, - ): + ) -> None: """Init RestDataMock.""" self.data: str | None = None self.payload = payload diff --git a/tests/components/scrape/test_sensor.py b/tests/components/scrape/test_sensor.py index bc13e01d25..44c264520d 100644 --- a/tests/components/scrape/test_sensor.py +++ b/tests/components/scrape/test_sensor.py @@ -9,7 +9,6 @@ import pytest from homeassistant.components.scrape.const import DEFAULT_SCAN_INTERVAL from homeassistant.components.sensor import ( CONF_STATE_CLASS, - DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorStateClass, ) @@ -24,7 +23,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from . import MockRestData, return_config, return_integration_config +from . import MockRestData, return_integration_config from tests.common import MockConfigEntry, async_fire_time_changed @@ -53,70 +52,6 @@ async def test_scrape_sensor(hass: HomeAssistant) -> None: assert state.state == "Current Version: 2021.12.10" -async def test_scrape_sensor_platform_yaml(hass: HomeAssistant) -> None: - """Test Scrape sensor load from sensor platform.""" - config = { - SENSOR_DOMAIN: [ - return_config( - select=".return", - name="Auth page", - username="user@secret.com", - password="12345678", - authentication="digest", - ), - return_config( - select=".return", - name="Auth page2", - username="user@secret.com", - password="12345678", - template="{{value}}", - ), - ] - } - - mocker = MockRestData("test_scrape_sensor_authentication") - with patch( - "homeassistant.components.rest.RestData", - return_value=mocker, - ): - assert await async_setup_component(hass, SENSOR_DOMAIN, config) - await hass.async_block_till_done() - - state = hass.states.get("sensor.auth_page") - assert state.state == "secret text" - state2 = hass.states.get("sensor.auth_page2") - assert state2.state == "secret text" - - -async def test_scrape_sensor_platform_yaml_no_data( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test Scrape sensor load from sensor platform fetching no data.""" - config = { - SENSOR_DOMAIN: [ - return_config( - select=".return", - name="Auth page", - username="user@secret.com", - password="12345678", - authentication="digest", - ), - ] - } - - mocker = MockRestData("test_scrape_sensor_no_data") - with patch( - "homeassistant.components.rest.RestData", - return_value=mocker, - ): - assert await async_setup_component(hass, SENSOR_DOMAIN, config) - await hass.async_block_till_done() - - state = hass.states.get("sensor.auth_page") - assert not state - assert "Platform scrape not ready yet: None; Retrying in background" in caplog.text - - async def test_scrape_sensor_value_template(hass: HomeAssistant) -> None: """Test Scrape sensor with value template.""" config = { From a773c37190cba72e4fc772fc7611225f291f69d2 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 27 Mar 2023 08:33:46 +0200 Subject: [PATCH 1291/2810] Cleanup name assignment imap sensor (#90306) --- homeassistant/components/imap/sensor.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/imap/sensor.py b/homeassistant/components/imap/sensor.py index 4dc0c0fffb..776abc174a 100644 --- a/homeassistant/components/imap/sensor.py +++ b/homeassistant/components/imap/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, CONF_USERNAME +from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo @@ -41,10 +41,6 @@ class ImapSensor( ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - # To be removed when YAML import is removed - if CONF_NAME in coordinator.config_entry.data: - self._attr_name = coordinator.config_entry.data[CONF_NAME] - self._attr_has_entity_name = False self._attr_unique_id = f"{coordinator.config_entry.entry_id}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, From 6a5c05e7d2290ebc5d41d0abbe4be0ece1522e3d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Mar 2023 21:34:28 -1000 Subject: [PATCH 1292/2810] Add support for clearing the on device GATT cache to esphome (#90318) --- .../components/esphome/bluetooth/client.py | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index c332fc3441..914021b467 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -44,6 +44,7 @@ CCCD_INDICATE_BYTES = b"\x02\x00" MIN_BLUETOOTH_PROXY_VERSION_HAS_CACHE = 3 MIN_BLUETOOTH_PROXY_HAS_PAIRING = 4 +MIN_BLUETOOTH_PROXY_HAS_CLEAR_CACHE = 5 DEFAULT_MAX_WRITE_WITHOUT_RESPONSE = DEFAULT_MTU - GATT_HEADER_SIZE _LOGGER = logging.getLogger(__name__) @@ -518,10 +519,28 @@ class ESPHomeClient(BaseBleakClient): raise BleakError(f"Characteristic {char_specifier} was not found!") return characteristic - async def clear_cache(self) -> None: + @api_error_as_bleak_error + async def clear_cache(self) -> bool: """Clear the GATT cache.""" self.domain_data.clear_gatt_services_cache(self._address_as_int) self.domain_data.clear_gatt_mtu_cache(self._address_as_int) + if self._connection_version < MIN_BLUETOOTH_PROXY_HAS_CLEAR_CACHE: + _LOGGER.warning( + "On device cache clear is not available with ESPHome Bluetooth version %s, " + "version %s is needed; Only memory cache will be cleared", + self._connection_version, + MIN_BLUETOOTH_PROXY_HAS_CLEAR_CACHE, + ) + return True + response = await self._client.bluetooth_device_clear_cache(self._address_as_int) + if response.success: + return True + _LOGGER.error( + "Clear cache failed with %s failed due to error: %s", + self.address, + response.error, + ) + return False @verify_connected @api_error_as_bleak_error From 164482dc089fd649d2862e57d425922fb16ea7eb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Mar 2023 10:00:41 +0200 Subject: [PATCH 1293/2810] Use lambda in gree switch (#90316) --- homeassistant/components/gree/switch.py | 77 +++++++++++++++++++++---- 1 file changed, 67 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/gree/switch.py b/homeassistant/components/gree/switch.py index 0ac7406713..01f98b996d 100644 --- a/homeassistant/components/gree/switch.py +++ b/homeassistant/components/gree/switch.py @@ -1,8 +1,12 @@ """Support for interface with a Gree climate systems.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from typing import Any, cast +from greeclimate.device import Device + from homeassistant.components.switch import ( SwitchDeviceClass, SwitchEntity, @@ -16,25 +20,77 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DISPATCHERS, DOMAIN from .entity import GreeEntity -GREE_SWITCHES: tuple[SwitchEntityDescription, ...] = ( - SwitchEntityDescription( + +@dataclass +class GreeRequiredKeysMixin: + """Mixin for required keys.""" + + get_value_fn: Callable[[Device], bool] + set_value_fn: Callable[[Device, bool], None] + + +@dataclass +class GreeSwitchEntityDescription(SwitchEntityDescription, GreeRequiredKeysMixin): + """Describes Gree switch entity.""" + + +def _set_light(device: Device, value: bool) -> None: + """Typed helper to set device light property.""" + device.light = value + + +def _set_quiet(device: Device, value: bool) -> None: + """Typed helper to set device quiet property.""" + device.quiet = value + + +def _set_fresh_air(device: Device, value: bool) -> None: + """Typed helper to set device fresh_air property.""" + device.fresh_air = value + + +def _set_xfan(device: Device, value: bool) -> None: + """Typed helper to set device xfan property.""" + device.xfan = value + + +def _set_anion(device: Device, value: bool) -> None: + """Typed helper to set device anion property.""" + device.anion = value + + +GREE_SWITCHES: tuple[GreeSwitchEntityDescription, ...] = ( + GreeSwitchEntityDescription( icon="mdi:lightbulb", name="Panel Light", key="light", + get_value_fn=lambda d: d.light, + set_value_fn=_set_light, ), - SwitchEntityDescription( + GreeSwitchEntityDescription( name="Quiet", key="quiet", + get_value_fn=lambda d: d.quiet, + set_value_fn=_set_quiet, ), - SwitchEntityDescription( + GreeSwitchEntityDescription( name="Fresh Air", key="fresh_air", + get_value_fn=lambda d: d.fresh_air, + set_value_fn=_set_fresh_air, ), - SwitchEntityDescription(name="XFan", key="xfan"), - SwitchEntityDescription( + GreeSwitchEntityDescription( + name="XFan", + key="xfan", + get_value_fn=lambda d: d.xfan, + set_value_fn=_set_xfan, + ), + GreeSwitchEntityDescription( icon="mdi:pine-tree", name="Health mode", key="anion", + get_value_fn=lambda d: d.anion, + set_value_fn=_set_anion, entity_registry_enabled_default=False, ), ) @@ -68,8 +124,9 @@ class GreeSwitch(GreeEntity, SwitchEntity): """Generic Gree switch entity.""" _attr_device_class = SwitchDeviceClass.SWITCH + entity_description: GreeSwitchEntityDescription - def __init__(self, coordinator, description: SwitchEntityDescription) -> None: + def __init__(self, coordinator, description: GreeSwitchEntityDescription) -> None: """Initialize the Gree device.""" self.entity_description = description @@ -78,16 +135,16 @@ class GreeSwitch(GreeEntity, SwitchEntity): @property def is_on(self) -> bool: """Return if the state is turned on.""" - return getattr(self.coordinator.device, self.entity_description.key) + return self.entity_description.get_value_fn(self.coordinator.device) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - setattr(self.coordinator.device, self.entity_description.key, True) + self.entity_description.set_value_fn(self.coordinator.device, True) await self.coordinator.push_state_update() self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - setattr(self.coordinator.device, self.entity_description.key, False) + self.entity_description.set_value_fn(self.coordinator.device, False) await self.coordinator.push_state_update() self.async_write_ha_state() From 8c519e1abb1468256c028a04c0c68f84d5cee68a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Mar 2023 10:01:39 +0200 Subject: [PATCH 1294/2810] Use SnapshotAssertion in gree climate tests (#90339) --- tests/components/gree/common.py | 7 +- .../gree/snapshots/test_climate.ambr | 118 ++++++++++++++++++ tests/components/gree/test_climate.py | 37 +++--- 3 files changed, 140 insertions(+), 22 deletions(-) create mode 100644 tests/components/gree/snapshots/test_climate.ambr diff --git a/tests/components/gree/common.py b/tests/components/gree/common.py index cd8a2d6ee2..aa88688486 100644 --- a/tests/components/gree/common.py +++ b/tests/components/gree/common.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, Mock from greeclimate.discovery import Listener from homeassistant.components.gree.const import DISCOVERY_TIMEOUT, DOMAIN as GREE_DOMAIN +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -90,8 +91,10 @@ def build_device_mock(name="fake-device-1", ipAddress="1.1.1.1", mac="aabbcc1122 return mock -async def async_setup_gree(hass): +async def async_setup_gree(hass: HomeAssistant) -> MockConfigEntry: """Set up the gree platform.""" - MockConfigEntry(domain=GREE_DOMAIN).add_to_hass(hass) + entry = MockConfigEntry(domain=GREE_DOMAIN) + entry.add_to_hass(hass) await async_setup_component(hass, GREE_DOMAIN, {GREE_DOMAIN: {"climate": {}}}) await hass.async_block_till_done() + return entry diff --git a/tests/components/gree/snapshots/test_climate.ambr b/tests/components/gree/snapshots/test_climate.ambr new file mode 100644 index 0000000000..f1479cad3d --- /dev/null +++ b/tests/components/gree/snapshots/test_climate.ambr @@ -0,0 +1,118 @@ +# serializer version: 1 +# name: test_entity_states + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 25, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'auto', + 'low', + 'medium low', + 'medium', + 'medium high', + 'high', + ]), + 'friendly_name': 'fake-device-1', + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 8, + 'preset_mode': 'none', + 'preset_modes': list([ + 'eco', + 'away', + 'boost', + 'none', + 'sleep', + ]), + 'supported_features': , + 'swing_mode': 'off', + 'swing_modes': list([ + 'off', + 'vertical', + 'horizontal', + 'both', + ]), + 'target_temp_step': 1, + 'temperature': 25, + }), + 'context': , + 'entity_id': 'climate.fake_device_1', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + ]) +# --- +# name: test_registry_settings + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium low', + 'medium', + 'medium high', + 'high', + ]), + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 8, + 'preset_modes': list([ + 'eco', + 'away', + 'boost', + 'none', + 'sleep', + ]), + 'swing_modes': list([ + 'off', + 'vertical', + 'horizontal', + 'both', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.fake_device_1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'fake-device-1', + 'platform': 'gree', + 'supported_features': , + 'translation_key': None, + 'unique_id': 'aabbcc112233', + 'unit_of_measurement': None, + }), + ]) +# --- diff --git a/tests/components/gree/test_climate.py b/tests/components/gree/test_climate.py index 16b6d0cf3a..afed01c1a0 100644 --- a/tests/components/gree/test_climate.py +++ b/tests/components/gree/test_climate.py @@ -5,6 +5,7 @@ from unittest.mock import DEFAULT as DEFAULT_MOCK, AsyncMock, patch from greeclimate.device import HorizontalSwing, VerticalSwing from greeclimate.exceptions import DeviceNotBoundError, DeviceTimeoutError import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE, @@ -31,15 +32,12 @@ from homeassistant.components.climate import ( SWING_HORIZONTAL, SWING_OFF, SWING_VERTICAL, - ClimateEntityFeature, HVACMode, ) from homeassistant.components.gree.climate import FAN_MODES_REVERSE, HVAC_MODES_REVERSE from homeassistant.components.gree.const import FAN_MEDIUM_HIGH, FAN_MEDIUM_LOW from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, - ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, SERVICE_TURN_OFF, SERVICE_TURN_ON, @@ -47,6 +45,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util from .common import async_setup_gree, build_device_mock @@ -797,22 +796,20 @@ async def test_update_swing_mode( assert state.attributes.get(ATTR_SWING_MODE) == swing_mode -async def test_name(hass: HomeAssistant, discovery, device) -> None: - """Test for name property.""" - await async_setup_gree(hass) - state = hass.states.get(ENTITY_ID) - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake-device-1" - - -async def test_supported_features_with_turnon( - hass: HomeAssistant, discovery, device +@patch("homeassistant.components.gree.PLATFORMS", [DOMAIN]) +async def test_registry_settings( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: - """Test for supported_features property.""" + """Test for entity registry settings (unique_id).""" + entry = await async_setup_gree(hass) + + entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + assert entries == snapshot + + +@patch("homeassistant.components.gree.PLATFORMS", [DOMAIN]) +async def test_entity_states(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: + """Test for entity registry settings (unique_id).""" await async_setup_gree(hass) - state = hass.states.get(ENTITY_ID) - assert state.attributes[ATTR_SUPPORTED_FEATURES] == ( - ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.PRESET_MODE - | ClimateEntityFeature.SWING_MODE - ) + states = hass.states.async_all() + assert states == snapshot From 94a52d5ccad9dcf1151b539481dd78abb7983e12 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Mar 2023 11:00:23 +0200 Subject: [PATCH 1295/2810] Adjust tts default_options type hints (#90053) * Adjust tts default_options type hints * Improve other components * Adjust * Revert component changes * Adjust get_tts_audio in amazon_polly --- homeassistant/components/amazon_polly/tts.py | 6 +++--- homeassistant/components/tts/__init__.py | 12 +++++++----- pylint/plugins/hass_enforce_type_hints.py | 2 +- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/amazon_polly/tts.py b/homeassistant/components/amazon_polly/tts.py index 7e21b9ac60..97e0af7f18 100644 --- a/homeassistant/components/amazon_polly/tts.py +++ b/homeassistant/components/amazon_polly/tts.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Final +from typing import Any, Final import boto3 import botocore @@ -166,8 +166,8 @@ class AmazonPollyProvider(Provider): def get_tts_audio( self, message: str, - language: str | None = None, - options: dict[str, str] | None = None, + language: str, + options: dict[str, Any] | None = None, ) -> TtsAudioType: """Request TTS file from Polly.""" if options is None or language is None: diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 39aedfe8cb..aa8864ad23 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping import functools as ft import hashlib from http import HTTPStatus @@ -380,11 +381,12 @@ class SpeechManager: raise HomeAssistantError(f"Not supported language {language}") # Options - if provider.default_options and options: - merged_options = provider.default_options.copy() + if (default_options := provider.default_options) and options: + merged_options = dict(default_options) merged_options.update(options) options = merged_options - options = options or provider.default_options + if not options: + options = None if default_options is None else dict(default_options) if options is not None: supported_options = provider.supported_options or [] @@ -665,8 +667,8 @@ class Provider: return None @property - def default_options(self) -> dict[str, Any] | None: - """Return a dict include default options.""" + def default_options(self) -> Mapping[str, Any] | None: + """Return a mapping with the default options.""" return None def get_tts_audio( diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 63cc5a1c6b..58bfd3b69b 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2366,7 +2366,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { ), TypeHintMatch( function_name="default_options", - return_type=["dict[str, Any]", None], + return_type=["Mapping[str, Any]", None], ), TypeHintMatch( function_name="get_tts_audio", From 56293ad876884fb3c49ecf4d2e18cdcc82e95452 Mon Sep 17 00:00:00 2001 From: PatrickGlesner <34370149+PatrickGlesner@users.noreply.github.com> Date: Mon, 27 Mar 2023 11:00:52 +0200 Subject: [PATCH 1296/2810] Revert "Fix NMBS IndexError" (#90346) --- homeassistant/components/nmbs/sensor.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index c3bcdb3553..b9a216875f 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -162,11 +162,7 @@ class NMBSLiveBoard(SensorEntity): """Set the state equal to the next departure.""" liveboard = self._api_client.get_liveboard(self._station) - if ( - liveboard is None - or not liveboard.get("departures") - or liveboard.get("number") == "0" - ): + if liveboard is None or not liveboard.get("departures"): return next_departure = liveboard["departures"]["departure"][0] From 97f8a3fdcd126814ede5594e586748823ec0c800 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 27 Mar 2023 11:04:03 +0200 Subject: [PATCH 1297/2810] Reolink add auto tracking entities (#90063) --- homeassistant/components/reolink/number.py | 54 +++++++++++++++++++ homeassistant/components/reolink/select.py | 13 ++++- homeassistant/components/reolink/strings.json | 7 +++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 4a221e2ca9..bb19974114 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -188,6 +188,60 @@ NUMBER_ENTITIES = ( value=lambda api, ch: api.quick_reply_time(ch), method=lambda api, ch, value: api.set_quick_reply(ch, time=int(value)), ), + ReolinkNumberEntityDescription( + key="auto_track_limit_left", + name="Auto track limit left", + icon="mdi:angle-acute", + mode=NumberMode.SLIDER, + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=-1, + native_max_value=2700, + supported=lambda api, ch: api.supported(ch, "auto_track_limit"), + value=lambda api, ch: api.auto_track_limit_left(ch), + method=lambda api, ch, value: api.set_auto_track_limit(ch, left=int(value)), + ), + ReolinkNumberEntityDescription( + key="auto_track_limit_right", + name="Auto track limit right", + icon="mdi:angle-acute", + mode=NumberMode.SLIDER, + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=-1, + native_max_value=2700, + supported=lambda api, ch: api.supported(ch, "auto_track_limit"), + value=lambda api, ch: api.auto_track_limit_right(ch), + method=lambda api, ch, value: api.set_auto_track_limit(ch, right=int(value)), + ), + ReolinkNumberEntityDescription( + key="auto_track_disappear_time", + name="Auto track disappear time", + icon="mdi:target-account", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=1, + native_max_value=60, + supported=lambda api, ch: api.supported(ch, "auto_track_disappear_time"), + value=lambda api, ch: api.auto_track_disappear_time(ch), + method=lambda api, ch, value: api.set_auto_tracking( + ch, disappear_time=int(value) + ), + ), + ReolinkNumberEntityDescription( + key="auto_track_stop_time", + name="Auto track stop time", + icon="mdi:target-account", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=1, + native_max_value=60, + supported=lambda api, ch: api.supported(ch, "auto_track_stop_time"), + value=lambda api, ch: api.auto_track_stop_time(ch), + method=lambda api, ch, value: api.set_auto_tracking(ch, stop_time=int(value)), + ), ) diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index e18961c97d..a994b7d353 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -5,7 +5,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Any -from reolink_aio.api import DayNightEnum, Host, SpotlightModeEnum +from reolink_aio.api import DayNightEnum, Host, SpotlightModeEnum, TrackMethodEnum from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry @@ -79,6 +79,17 @@ SELECT_ENTITIES = ( ch, file_id=[k for k, v in api.quick_reply_dict(ch).items() if v == mess][0] ), ), + ReolinkSelectEntityDescription( + key="auto_track_method", + name="Auto track method", + icon="mdi:target-account", + translation_key="auto_track_method", + entity_category=EntityCategory.CONFIG, + get_options=[method.name for method in TrackMethodEnum], + supported=lambda api, ch: api.supported(ch, "auto_track_method"), + value=lambda api, ch: TrackMethodEnum(api.auto_track_method(ch)).name, + method=lambda api, ch, name: api.set_auto_tracking(ch, method=name), + ), ) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 06b588a119..74759c12f9 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -72,6 +72,13 @@ "state": { "off": "Off" } + }, + "auto_track_method": { + "state": { + "digital": "Digital", + "digitalfirst": "Digital first", + "pantiltfirst": "Pan/tilt first" + } } } } From 0d5864682390e7f10d131d58697b3ef38cd4564a Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 27 Mar 2023 11:11:38 +0200 Subject: [PATCH 1298/2810] Bump reolink-aio to 0.5.7 (#90344) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 7050ed61d5..95b180fc16 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.5.6"] + "requirements": ["reolink-aio==0.5.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 31e78c40c2..fafe16fb78 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2234,7 +2234,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.6 +reolink-aio==0.5.7 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3be11d6cd6..93bd067383 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1597,7 +1597,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.6 +reolink-aio==0.5.7 # homeassistant.components.python_script restrictedpython==6.0 From 5b3c57ff1e954273b7e24d600697bcbd98321349 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 27 Mar 2023 11:47:22 +0200 Subject: [PATCH 1299/2810] Add option flow for imap integration (#89914) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/imap/config_flow.py | 74 ++++++++++++- homeassistant/components/imap/strings.json | 18 +++ tests/components/imap/conftest.py | 14 +++ tests/components/imap/test_config_flow.py | 110 ++++++++++++++++--- 4 files changed, 197 insertions(+), 19 deletions(-) create mode 100644 tests/components/imap/conftest.py diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index de1ac1e5d6..c855d099b4 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv @@ -36,6 +37,13 @@ STEP_USER_DATA_SCHEMA = vol.Schema( } ) +OPTIONS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_FOLDER, default="INBOX"): str, + vol.Optional(CONF_SEARCH, default="UnSeen UnDeleted"): str, + } +) + async def validate_input(user_input: dict[str, Any]) -> dict[str, str]: """Validate user input.""" @@ -80,9 +88,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._async_abort_entries_match( { - CONF_USERNAME: user_input[CONF_USERNAME], - CONF_FOLDER: user_input[CONF_FOLDER], - CONF_SEARCH: user_input[CONF_SEARCH], + key: user_input[key] + for key in (CONF_USERNAME, CONF_SERVER, CONF_FOLDER, CONF_SEARCH) } ) @@ -128,3 +135,64 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlow: + """Get the options flow for this handler.""" + return OptionsFlow(config_entry) + + +class OptionsFlow(config_entries.OptionsFlowWithConfigEntry): + """Option flow handler.""" + + def _async_abort_entries_match( + self, match_dict: dict[str, Any] | None + ) -> dict[str, str]: + """Validate the user input against other config entries.""" + if match_dict is None: + return {} + + errors: dict[str, str] = {} + for entry in [ + entry + for entry in self.hass.config_entries.async_entries(DOMAIN) + if entry is not self.config_entry + ]: + if all(item in entry.data.items() for item in match_dict.items()): + errors["base"] = "already_configured" + break + return errors + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + errors: dict[str, str] = self._async_abort_entries_match( + { + CONF_SERVER: self._config_entry.data[CONF_SERVER], + CONF_USERNAME: self._config_entry.data[CONF_USERNAME], + CONF_FOLDER: user_input[CONF_FOLDER], + CONF_SEARCH: user_input[CONF_SEARCH], + } + if user_input + else None + ) + entry_data: dict[str, Any] = dict(self._config_entry.data) + if not errors and user_input is not None: + entry_data.update(user_input) + errors = await validate_input(entry_data) + if not errors: + self.hass.config_entries.async_update_entry( + self.config_entry, data=entry_data + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.config_entry.entry_id) + ) + return self.async_create_entry(data={}) + + schema = self.add_suggested_values_to_schema(OPTIONS_SCHEMA, entry_data) + + return self.async_show_form(step_id="init", data_schema=schema, errors=errors) diff --git a/homeassistant/components/imap/strings.json b/homeassistant/components/imap/strings.json index bb03f82bb7..d104f591c6 100644 --- a/homeassistant/components/imap/strings.json +++ b/homeassistant/components/imap/strings.json @@ -31,5 +31,23 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "options": { + "step": { + "init": { + "data": { + "folder": "[%key:component::imap::config::step::user::data::folder%]", + "search": "[%key:component::imap::config::step::user::data::search%]" + } + } + }, + "error": { + "already_configured": "An entry with these folder and search options already exists", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_charset": "[%key:component::imap::config::error::invalid_charset%]", + "invalid_folder": "[%key:component::imap::config::error::invalid_folder%]", + "invalid_search": "[%key:component::imap::config::error::invalid_search%]" + } } } diff --git a/tests/components/imap/conftest.py b/tests/components/imap/conftest.py new file mode 100644 index 0000000000..bc82cf57d8 --- /dev/null +++ b/tests/components/imap/conftest.py @@ -0,0 +1,14 @@ +"""Test the iamp config flow.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.imap.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/imap/test_config_flow.py b/tests/components/imap/test_config_flow.py index 663637ff0b..20c9ddf893 100644 --- a/tests/components/imap/test_config_flow.py +++ b/tests/components/imap/test_config_flow.py @@ -1,11 +1,11 @@ """Test the imap config flow.""" import asyncio -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from aioimaplib import AioImapException import pytest -from homeassistant import config_entries +from homeassistant import config_entries, data_entry_flow from homeassistant.components.imap.const import ( CONF_CHARSET, CONF_FOLDER, @@ -29,8 +29,15 @@ MOCK_CONFIG = { "search": "UnSeen UnDeleted", } +MOCK_OPTIONS = { + "folder": "INBOX", + "search": "UnSeen UnDeleted", +} -async def test_form(hass: HomeAssistant) -> None: +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -40,10 +47,7 @@ async def test_form(hass: HomeAssistant) -> None: with patch( "homeassistant.components.imap.config_flow.connect_to_server" - ) as mock_client, patch( - "homeassistant.components.imap.async_setup_entry", - return_value=True, - ) as mock_setup_entry: + ) as mock_client: mock_client.return_value.search.return_value = ( "OK", [b""], @@ -184,10 +188,7 @@ async def test_form_invalid_search(hass: HomeAssistant) -> None: with patch( "homeassistant.components.imap.config_flow.connect_to_server" ) as mock_client: - mock_client.return_value.search.return_value = ( - "BAD", - [b"Invalid search"], - ) + mock_client.return_value.search.return_value = ("BAD", [b"Invalid search"]) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], MOCK_CONFIG ) @@ -196,7 +197,7 @@ async def test_form_invalid_search(hass: HomeAssistant) -> None: assert result2["errors"] == {CONF_SEARCH: "invalid_search"} -async def test_reauth_success(hass: HomeAssistant) -> None: +async def test_reauth_success(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we can reauth.""" entry = MockConfigEntry( domain=DOMAIN, @@ -219,10 +220,7 @@ async def test_reauth_success(hass: HomeAssistant) -> None: with patch( "homeassistant.components.imap.config_flow.connect_to_server" - ) as mock_client, patch( - "homeassistant.components.imap.async_setup_entry", - return_value=True, - ) as mock_setup_entry: + ) as mock_client: mock_client.return_value.search.return_value = ( "OK", [b""], @@ -310,3 +308,83 @@ async def test_reauth_failed_conn_error(hass: HomeAssistant) -> None: assert result2["type"] == FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_options_form(hass: HomeAssistant) -> None: + """Test we show the options form.""" + + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + + new_config = MOCK_OPTIONS.copy() + new_config["folder"] = "INBOX.Notifications" + new_config["search"] = "UnSeen UnDeleted!!INVALID" + + # simulate initial search setup error + with patch( + "homeassistant.components.imap.config_flow.connect_to_server" + ) as mock_client: + mock_client.return_value.search.return_value = ("BAD", [b"Invalid search"]) + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], new_config + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {CONF_SEARCH: "invalid_search"} + + new_config["search"] = "UnSeen UnDeleted" + + with patch( + "homeassistant.components.imap.config_flow.connect_to_server" + ) as mock_client: + mock_client.return_value.search.return_value = ("OK", [b""]) + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], + new_config, + ) + await hass.async_block_till_done() + assert result3["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result3["data"] == {} + for key, value in new_config.items(): + assert entry.data[key] == value + + +async def test_key_options_in_options_form(hass: HomeAssistant) -> None: + """Test we cannot change options if that would cause duplicates.""" + + entry1 = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + entry1.add_to_hass(hass) + await hass.config_entries.async_setup(entry1.entry_id) + + config2 = MOCK_CONFIG.copy() + config2["folder"] = "INBOX.Notifications" + entry2 = MockConfigEntry(domain=DOMAIN, data=config2) + entry2.add_to_hass(hass) + await hass.config_entries.async_setup(entry2.entry_id) + + # Now try to set back the folder option of entry2 + # so that it conflicts with that of entry1 + result = await hass.config_entries.options.async_init(entry2.entry_id) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + + new_config = MOCK_OPTIONS.copy() + + with patch( + "homeassistant.components.imap.config_flow.connect_to_server" + ) as mock_client: + mock_client.return_value.search.return_value = ("OK", [b""]) + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + new_config, + ) + await hass.async_block_till_done() + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["errors"] == {"base": "already_configured"} From d9471fd01a3abdedc230c650f97e18e76f9c4a79 Mon Sep 17 00:00:00 2001 From: Joel Goguen Date: Mon, 27 Mar 2023 06:14:16 -0400 Subject: [PATCH 1300/2810] Bump python-holidays to 0.21.13 (#89724) Update Python holidays module to 0.21.13 python-holidays 0.19-0.21.13 adds support for new countries and enhances support for many currently supported countries. --- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 4c10141406..c9299b21ce 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -11,5 +11,5 @@ "korean_lunar_calendar" ], "quality_scale": "internal", - "requirements": ["holidays==0.18.0"] + "requirements": ["holidays==0.21.13"] } diff --git a/requirements_all.txt b/requirements_all.txt index fafe16fb78..134bc6f459 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -904,7 +904,7 @@ hlk-sw16==0.0.9 hole==0.8.0 # homeassistant.components.workday -holidays==0.18.0 +holidays==0.21.13 # homeassistant.components.frontend home-assistant-frontend==20230309.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 93bd067383..977c2896c0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -690,7 +690,7 @@ hlk-sw16==0.0.9 hole==0.8.0 # homeassistant.components.workday -holidays==0.18.0 +holidays==0.21.13 # homeassistant.components.frontend home-assistant-frontend==20230309.1 From c11a3881af58d681a27e1402d54de4e7963cd2e8 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 27 Mar 2023 06:25:04 -0400 Subject: [PATCH 1301/2810] Bump zwave-js-server-python to 0.47.0 (#90212) --- .../components/zwave_js/diagnostics.py | 4 ++-- .../components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zwave_js/test_api.py | 1 + tests/components/zwave_js/test_diagnostics.py | 11 +--------- tests/components/zwave_js/test_trigger.py | 20 +++++++++++++------ 7 files changed, 21 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/zwave_js/diagnostics.py b/homeassistant/components/zwave_js/diagnostics.py index 50130fc263..acb87a239a 100644 --- a/homeassistant/components/zwave_js/diagnostics.py +++ b/homeassistant/components/zwave_js/diagnostics.py @@ -117,7 +117,8 @@ async def async_get_config_entry_diagnostics( handshake_msgs = msgs[:-1] network_state = msgs[-1] network_state["result"]["state"]["nodes"] = [ - redact_node_state(node) for node in network_state["result"]["state"]["nodes"] + redact_node_state(async_redact_data(node, KEYS_TO_REDACT)) + for node in network_state["result"]["state"]["nodes"] ] return {"messages": [*handshake_msgs, network_state]} @@ -136,7 +137,6 @@ async def async_get_device_diagnostics( entities = get_device_entities(hass, node, device) assert client.version node_state = redact_node_state(async_redact_data(node.data, KEYS_TO_REDACT)) - node_state["statistics"] = node.statistics.data return { "versionInfo": { "driverVersion": client.version.driver_version, diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index a21f7a6f30..0ad934103d 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -8,7 +8,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.46.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.47.0"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index 134bc6f459..cf3fbc10f4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2728,7 +2728,7 @@ zigpy==0.53.2 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.46.0 +zwave-js-server-python==0.47.0 # homeassistant.components.zwave_me zwave_me_ws==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 977c2896c0..f1a499c45d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1950,7 +1950,7 @@ zigpy-znp==0.9.3 zigpy==0.53.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.46.0 +zwave-js-server-python==0.47.0 # homeassistant.components.zwave_me zwave_me_ws==0.3.1 diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 43489be4cc..f8a7a68f13 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -3201,6 +3201,7 @@ async def test_subscribe_log_updates( "multiline": False, "timestamp": "time", "label": "label", + "context": {"source": "config"}, }, ) client.driver.receive_event(event) diff --git a/tests/components/zwave_js/test_diagnostics.py b/tests/components/zwave_js/test_diagnostics.py index e3c144c4ac..773b799cd6 100644 --- a/tests/components/zwave_js/test_diagnostics.py +++ b/tests/components/zwave_js/test_diagnostics.py @@ -92,16 +92,7 @@ async def test_device_diagnostics( assert len(diagnostics_data["entities"]) == len( list(async_discover_node_values(multisensor_6, device, {device.id: set()})) ) - assert diagnostics_data["state"] == { - **multisensor_6.data, - "statistics": { - "commandsDroppedRX": 0, - "commandsDroppedTX": 0, - "commandsRX": 0, - "commandsTX": 0, - "timeoutResponse": 0, - }, - } + assert diagnostics_data["state"] == multisensor_6.data async def test_device_diagnostics_error(hass: HomeAssistant, integration) -> None: diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py index 9ba0080667..cbf68a55f5 100644 --- a/tests/components/zwave_js/test_trigger.py +++ b/tests/components/zwave_js/test_trigger.py @@ -601,7 +601,8 @@ async def test_zwave_js_event( }, ) - # Test that `node no event data filter` is triggered and `node event data filter` is not + # Test that `node no event data filter` is triggered and `node event data + # filter` is not event = Event( type="interview stage completed", data={ @@ -649,7 +650,8 @@ async def test_zwave_js_event( clear_events() - # Test that `controller no event data filter` is triggered and `controller event data filter` is not + # Test that `controller no event data filter` is triggered and `controller event + # data filter` is not event = Event( type="inclusion started", data={ @@ -672,7 +674,8 @@ async def test_zwave_js_event( clear_events() - # Test that both `controller no event data filter` and `controller event data filter` are triggered + # Test that both `controller no event data filter` and `controller event data + # filter`` are triggered event = Event( type="inclusion started", data={ @@ -695,7 +698,8 @@ async def test_zwave_js_event( clear_events() - # Test that `driver no event data filter` is triggered and `driver event data filter` is not + # Test that `driver no event data filter` is triggered and `driver event data + # filter` is not event = Event( type="logging", data={ @@ -711,6 +715,7 @@ async def test_zwave_js_event( "multiline": False, "timestamp": "time", "label": "label", + "context": {"source": "config"}, }, ) client.driver.receive_event(event) @@ -727,7 +732,8 @@ async def test_zwave_js_event( clear_events() - # Test that both `driver no event data filter` and `driver event data filter` are triggered + # Test that both `driver no event data filter` and `driver event data filter` + # are triggered event = Event( type="logging", data={ @@ -743,6 +749,7 @@ async def test_zwave_js_event( "multiline": False, "timestamp": "time", "label": "label", + "context": {"source": "config"}, }, ) client.driver.receive_event(event) @@ -862,7 +869,8 @@ async def test_zwave_js_event_bypass_dynamic_validation( }, ) - # Test that `node no event data filter` is triggered and `node event data filter` is not + # Test that `node no event data filter` is triggered and `node event data filter` + # is not event = Event( type="interview stage completed", data={ From 53de9dcdbc3c2a2119a84f72bd76f7e090c351d5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Mar 2023 13:09:42 +0200 Subject: [PATCH 1302/2810] Fix pylint plugin for binary websocket (#90351) --- pylint/plugins/hass_enforce_type_hints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 58bfd3b69b..0581569c2d 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2323,7 +2323,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { ), TypeHintMatch( function_name="async_process_audio_stream", - arg_types={1: "SpeechMetadata", 2: "StreamReader"}, + arg_types={1: "SpeechMetadata", 2: "AsyncIterable[bytes]"}, return_type="SpeechResult", ), ], From c193402ba72e84fcca851c26dc0a764a6c8efa1a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Mar 2023 13:53:14 +0200 Subject: [PATCH 1303/2810] Remove incorrect ignore in pylint plugin (#90024) * Remove incorrect ignore_missing_annotations * Allow tuple[int, int] in hs_color * Adjust notify targets * Always check for return type inheritance * Adjust tests * Revert "Always check for return type inheritance" This reverts commit 3528742adf98edc6481f2c954c032ace881e1d6e. * Revert "Allow tuple[int, int] in hs_color" This reverts commit d51c1731eff3d59b9e94e7a7e914933ceaf8e34f. * Revert "Adjust notify targets" This reverts commit 4cba77a7309dc89980e29d6d5b9107d9e55f7070. --- pylint/plugins/hass_enforce_type_hints.py | 4 +--- tests/pylint/test_enforce_type_hints.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 0581569c2d..4fb471e214 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2974,9 +2974,7 @@ class HassTypeHintChecker(BaseChecker): # type: ignore[misc] if class_matches := _CLASS_MATCH.get(module_platform): self._class_matchers.extend(class_matches) - if not self.linter.config.ignore_missing_annotations and ( - property_matches := _INHERITANCE_MATCH.get(module_platform) - ): + if property_matches := _INHERITANCE_MATCH.get(module_platform): self._class_matchers.extend(property_matches) self._class_matchers.reverse() diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py index 9e8df452b6..b80d8f0144 100644 --- a/tests/pylint/test_enforce_type_hints.py +++ b/tests/pylint/test_enforce_type_hints.py @@ -574,7 +574,7 @@ def test_ignore_invalid_entity_properties( async def async_lock( self, **kwargs - ) -> bool: + ): pass """, "homeassistant.components.pylint_test.lock", From a91aef9d52bf64d15776ea8ce0f657e83f7b998e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Mar 2023 14:01:17 +0200 Subject: [PATCH 1304/2810] Rewrite tts tests (#90355) --- tests/components/tts/test_init.py | 442 ++++++++++++++++++------------ 1 file changed, 263 insertions(+), 179 deletions(-) diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 8c21336b59..e7b3a818f6 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -1,12 +1,11 @@ """The tests for the TTS component.""" from http import HTTPStatus -from unittest.mock import PropertyMock, patch +from typing import Any import pytest import voluptuous as vol from homeassistant.components import media_source, tts -from homeassistant.components.demo.tts import DemoProvider from homeassistant.components.media_player import ( ATTR_MEDIA_ANNOUNCE, ATTR_MEDIA_CONTENT_ID, @@ -18,10 +17,17 @@ from homeassistant.components.media_player import ( from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_setup_component from homeassistant.util.network import normalize_url -from tests.common import assert_setup_component, async_mock_service +from tests.common import ( + MockModule, + assert_setup_component, + async_mock_service, + mock_integration, + mock_platform, +) from tests.typing import ClientSessionGenerator ORIG_WRITE_TAGS = tts.SpeechManager.write_tags @@ -36,10 +42,68 @@ async def get_media_source_url(hass, media_content_id): return resolved.url +SUPPORT_LANGUAGES = ["de", "en", "en_US"] + +DEFAULT_LANG = "en" + + +class MockProvider(tts.Provider): + """Test speech API provider.""" + + def __init__(self, lang: str) -> None: + """Initialize test provider.""" + self._lang = lang + self.name = "Test" + + @property + def default_language(self) -> str: + """Return the default language.""" + return self._lang + + @property + def supported_languages(self) -> list[str]: + """Return list of supported languages.""" + return SUPPORT_LANGUAGES + + @property + def supported_options(self) -> list[str]: + """Return list of supported options like voice, emotions.""" + return ["voice", "age"] + + def get_tts_audio( + self, message: str, language: str, options: dict[str, Any] | None = None + ) -> tts.TtsAudioType: + """Load TTS dat.""" + return ("mp3", b"") + + +class MockTTS: + """A mock TTS platform.""" + + PLATFORM_SCHEMA = tts.PLATFORM_SCHEMA.extend( + {vol.Optional(tts.CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORT_LANGUAGES)} + ) + + def __init__(self, provider=None) -> None: + """Initialize.""" + if provider is None: + provider = MockProvider + self._provider = provider + + async def async_get_engine( + self, + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, + ) -> tts.Provider: + """Set up a mock speech component.""" + return self._provider(config.get(tts.CONF_LANG, DEFAULT_LANG)) + + @pytest.fixture -def demo_provider(): - """Demo TTS provider.""" - return DemoProvider("en") +def test_provider(): + """Test TTS provider.""" + return MockProvider("en") @pytest.fixture(autouse=True) @@ -52,49 +116,52 @@ async def internal_url_mock(hass): @pytest.fixture -async def setup_tts(hass): +async def mock_tts(hass): """Mock TTS.""" - with patch("homeassistant.components.demo.async_setup", return_value=True): - assert await async_setup_component( - hass, tts.DOMAIN, {"tts": {"platform": "demo"}} - ) - await hass.async_block_till_done() + mock_integration(hass, MockModule(domain="test")) + mock_platform(hass, "test.tts", MockTTS()) -async def test_setup_component_demo(hass: HomeAssistant, setup_tts) -> None: - """Set up the demo platform with defaults.""" - assert hass.services.has_service(tts.DOMAIN, "demo_say") +@pytest.fixture +async def setup_tts(hass, mock_tts): + """Mock TTS.""" + assert await async_setup_component(hass, tts.DOMAIN, {"tts": {"platform": "test"}}) + + +async def test_setup_component(hass: HomeAssistant, setup_tts) -> None: + """Set up a TTS platform with defaults.""" + assert hass.services.has_service(tts.DOMAIN, "test_say") assert hass.services.has_service(tts.DOMAIN, "clear_cache") - assert f"{tts.DOMAIN}.demo" in hass.config.components + assert f"{tts.DOMAIN}.test" in hass.config.components -async def test_setup_component_demo_no_access_cache_folder( - hass: HomeAssistant, mock_init_cache_dir +async def test_setup_component_no_access_cache_folder( + hass: HomeAssistant, mock_init_cache_dir, mock_tts ) -> None: - """Set up the demo platform with defaults.""" - config = {tts.DOMAIN: {"platform": "demo"}} + """Set up a TTS platform with defaults.""" + config = {tts.DOMAIN: {"platform": "test"}} mock_init_cache_dir.side_effect = OSError(2, "No access") assert not await async_setup_component(hass, tts.DOMAIN, config) - assert not hass.services.has_service(tts.DOMAIN, "demo_say") + assert not hass.services.has_service(tts.DOMAIN, "test_say") assert not hass.services.has_service(tts.DOMAIN, "clear_cache") async def test_setup_component_and_test_service( - hass: HomeAssistant, empty_cache_dir + hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: - """Set up the demo platform and call service.""" + """Set up a TTS platform and call service.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo"}} + config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -107,28 +174,28 @@ async def test_setup_component_and_test_service( assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC assert ( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" ) await hass.async_block_till_done() assert ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" ).is_file() async def test_setup_component_and_test_service_with_config_language( - hass: HomeAssistant, empty_cache_dir + hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: - """Set up the demo platform and call service.""" + """Set up a TTS platform and call service.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo", "language": "de"}} + config = {tts.DOMAIN: {"platform": "test", "language": "de"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -139,31 +206,28 @@ async def test_setup_component_and_test_service_with_config_language( assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC assert ( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3" + == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_test.mp3" ) await hass.async_block_till_done() assert ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_test.mp3" ).is_file() async def test_setup_component_and_test_service_with_config_language_special( - hass: HomeAssistant, empty_cache_dir + hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: - """Set up the demo platform and call service with extend language.""" - import homeassistant.components.demo.tts as demo_tts - - demo_tts.SUPPORT_LANGUAGES.append("en_US") + """Set up a TTS platform and call service with extend language.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo", "language": "en_US"}} + config = {tts.DOMAIN: {"platform": "test", "language": "en_US"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -174,38 +238,38 @@ async def test_setup_component_and_test_service_with_config_language_special( assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC assert ( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_demo.mp3" + == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" ) await hass.async_block_till_done() assert ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_demo.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" ).is_file() async def test_setup_component_and_test_service_with_wrong_conf_language( - hass: HomeAssistant, + hass: HomeAssistant, mock_tts ) -> None: - """Set up the demo platform and call service with wrong config.""" - config = {tts.DOMAIN: {"platform": "demo", "language": "ru"}} + """Set up a TTS platform and call service with wrong config.""" + config = {tts.DOMAIN: {"platform": "test", "language": "ru"}} with assert_setup_component(0, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) async def test_setup_component_and_test_service_with_service_language( - hass: HomeAssistant, empty_cache_dir + hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: - """Set up the demo platform and call service.""" + """Set up a TTS platform and call service.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo"}} + config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -217,21 +281,21 @@ async def test_setup_component_and_test_service_with_service_language( assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC assert ( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3" + == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_test.mp3" ) await hass.async_block_till_done() assert ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_test.mp3" ).is_file() async def test_setup_component_test_service_with_wrong_service_language( - hass: HomeAssistant, empty_cache_dir + hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: - """Set up the demo platform and call service.""" + """Set up a TTS platform and call service.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo"}} + config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) @@ -239,7 +303,7 @@ async def test_setup_component_test_service_with_wrong_service_language( with pytest.raises(HomeAssistantError): await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -249,24 +313,24 @@ async def test_setup_component_test_service_with_wrong_service_language( ) assert len(calls) == 0 assert not ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_lang_-_demo.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_lang_-_test.mp3" ).is_file() async def test_setup_component_and_test_service_with_service_options( - hass: HomeAssistant, empty_cache_dir + hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: - """Set up the demo platform and call service with options.""" + """Set up a TTS platform and call service with options.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo"}} + config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -281,32 +345,37 @@ async def test_setup_component_and_test_service_with_service_options( assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC assert ( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == f"/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_demo.mp3" + == f"/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_test.mp3" ) await hass.async_block_till_done() assert ( empty_cache_dir - / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_demo.mp3" + / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_test.mp3" ).is_file() async def test_setup_component_and_test_with_service_options_def( hass: HomeAssistant, empty_cache_dir ) -> None: - """Set up the demo platform and call service with default options.""" + """Set up a TTS platform and call service with default options.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo"}} + config = {tts.DOMAIN: {"platform": "test"}} - with assert_setup_component(1, tts.DOMAIN), patch( - "homeassistant.components.demo.tts.DemoProvider.default_options", - new_callable=PropertyMock(return_value={"voice": "alex"}), - ): + class MockProviderWithDefaults(MockProvider): + @property + def default_options(self): + return {"voice": "alex"} + + mock_integration(hass, MockModule(domain="test")) + mock_platform(hass, "test.tts", MockTTS(MockProviderWithDefaults)) + + with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -320,22 +389,22 @@ async def test_setup_component_and_test_with_service_options_def( assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC assert ( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == f"/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_demo.mp3" + == f"/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_test.mp3" ) await hass.async_block_till_done() assert ( empty_cache_dir - / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_demo.mp3" + / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_test.mp3" ).is_file() async def test_setup_component_and_test_service_with_service_options_wrong( - hass: HomeAssistant, empty_cache_dir + hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: - """Set up the demo platform and call service with wrong options.""" + """Set up a TTS platform and call service with wrong options.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo"}} + config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) @@ -343,7 +412,7 @@ async def test_setup_component_and_test_service_with_service_options_wrong( with pytest.raises(HomeAssistantError): await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -358,24 +427,24 @@ async def test_setup_component_and_test_service_with_service_options_wrong( await hass.async_block_till_done() assert not ( empty_cache_dir - / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_demo.mp3" + / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_test.mp3" ).is_file() async def test_setup_component_and_test_service_with_base_url_set( - hass: HomeAssistant, + hass: HomeAssistant, mock_tts ) -> None: - """Set up the demo platform with ``base_url`` set and call service.""" + """Set up a TTS platform with ``base_url`` set and call service.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo", "base_url": "http://fnord"}} + config = {tts.DOMAIN: {"platform": "test", "base_url": "http://fnord"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -388,24 +457,24 @@ async def test_setup_component_and_test_service_with_base_url_set( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == "http://fnord" "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" - "_en_-_demo.mp3" + "_en_-_test.mp3" ) async def test_setup_component_and_test_service_clear_cache( - hass: HomeAssistant, empty_cache_dir + hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: - """Set up the demo platform and call service clear cache.""" + """Set up a TTS platform and call service clear cache.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo"}} + config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -417,7 +486,7 @@ async def test_setup_component_and_test_service_clear_cache( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) await hass.async_block_till_done() assert ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" ).is_file() await hass.services.async_call( @@ -425,17 +494,17 @@ async def test_setup_component_and_test_service_clear_cache( ) assert not ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" ).is_file() async def test_setup_component_and_test_service_with_receive_voice( - hass: HomeAssistant, demo_provider, hass_client: ClientSessionGenerator + hass: HomeAssistant, test_provider, hass_client: ClientSessionGenerator, mock_tts ) -> None: - """Set up the demo platform and call service and receive voice.""" + """Set up a TTS platform and call service and receive voice.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo"}} + config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) @@ -444,7 +513,7 @@ async def test_setup_component_and_test_service_with_receive_voice( await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: message, @@ -456,39 +525,39 @@ async def test_setup_component_and_test_service_with_receive_voice( url = await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) client = await hass_client() req = await client.get(url) - _, demo_data = demo_provider.get_tts_audio("bla", "en") - demo_data = tts.SpeechManager.write_tags( - "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3", - demo_data, - demo_provider, + _, tts_data = test_provider.get_tts_audio("bla", "en") + tts_data = tts.SpeechManager.write_tags( + "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3", + tts_data, + test_provider, message, "en", None, ) assert req.status == HTTPStatus.OK - assert await req.read() == demo_data + assert await req.read() == tts_data extension, data = await tts.async_get_media_source_audio( hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] ) assert extension == "mp3" - assert demo_data == data + assert tts_data == data async def test_setup_component_and_test_service_with_receive_voice_german( - hass: HomeAssistant, demo_provider, hass_client: ClientSessionGenerator + hass: HomeAssistant, test_provider, hass_client: ClientSessionGenerator, mock_tts ) -> None: - """Set up the demo platform and call service and receive voice.""" + """Set up a TTS platform and call service and receive voice.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo", "language": "de"}} + config = {tts.DOMAIN: {"platform": "test", "language": "de"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -499,67 +568,67 @@ async def test_setup_component_and_test_service_with_receive_voice_german( url = await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) client = await hass_client() req = await client.get(url) - _, demo_data = demo_provider.get_tts_audio("bla", "de") - demo_data = tts.SpeechManager.write_tags( - "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3", - demo_data, - demo_provider, + _, tts_data = test_provider.get_tts_audio("bla", "de") + tts_data = tts.SpeechManager.write_tags( + "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_test.mp3", + tts_data, + test_provider, "There is someone at the door.", "de", None, ) assert req.status == HTTPStatus.OK - assert await req.read() == demo_data + assert await req.read() == tts_data async def test_setup_component_and_web_view_wrong_file( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts ) -> None: - """Set up the demo platform and receive wrong file from web.""" - config = {tts.DOMAIN: {"platform": "demo"}} + """Set up a TTS platform and receive wrong file from web.""" + config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) client = await hass_client() - url = "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + url = "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" req = await client.get(url) assert req.status == HTTPStatus.NOT_FOUND async def test_setup_component_and_web_view_wrong_filename( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts ) -> None: - """Set up the demo platform and receive wrong filename from web.""" - config = {tts.DOMAIN: {"platform": "demo"}} + """Set up a TTS platform and receive wrong filename from web.""" + config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) client = await hass_client() - url = "/api/tts_proxy/265944dsk32c1b2a621be5930510bb2cd_en_-_demo.mp3" + url = "/api/tts_proxy/265944dsk32c1b2a621be5930510bb2cd_en_-_test.mp3" req = await client.get(url) assert req.status == HTTPStatus.NOT_FOUND async def test_setup_component_test_without_cache( - hass: HomeAssistant, empty_cache_dir + hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: - """Set up demo platform without cache.""" + """Set up a TTS platform without cache.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo", "cache": False}} + config = {tts.DOMAIN: {"platform": "test", "cache": False}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -569,24 +638,24 @@ async def test_setup_component_test_without_cache( assert len(calls) == 1 await hass.async_block_till_done() assert not ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" ).is_file() async def test_setup_component_test_with_cache_call_service_without_cache( - hass: HomeAssistant, empty_cache_dir + hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: - """Set up demo platform with cache and call service without cache.""" + """Set up a TTS platform with cache and call service without cache.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo", "cache": True}} + config = {tts.DOMAIN: {"platform": "test", "cache": True}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -597,116 +666,131 @@ async def test_setup_component_test_with_cache_call_service_without_cache( assert len(calls) == 1 await hass.async_block_till_done() assert not ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" ).is_file() async def test_setup_component_test_with_cache_dir( - hass: HomeAssistant, empty_cache_dir, demo_provider + hass: HomeAssistant, empty_cache_dir, test_provider ) -> None: - """Set up demo platform with cache and call service without cache.""" + """Set up a TTS platform with cache and call service without cache.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - _, demo_data = demo_provider.get_tts_audio("bla", "en") + _, tts_data = test_provider.get_tts_audio("bla", "en") cache_file = ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" ) with open(cache_file, "wb") as voice_file: - voice_file.write(demo_data) + voice_file.write(tts_data) - config = {tts.DOMAIN: {"platform": "demo", "cache": True}} + config = {tts.DOMAIN: {"platform": "test", "cache": True}} + + class MockProviderBoom(MockProvider): + def get_tts_audio( + self, message: str, language: str, options: dict[str, Any] | None = None + ) -> tts.TtsAudioType: + """Load TTS dat.""" + # This should not be called, data should be fetched from cache + raise Exception("Boom!") + + mock_integration(hass, MockModule(domain="test")) + mock_platform(hass, "test.tts", MockTTS(MockProviderBoom)) with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) - with patch( - "homeassistant.components.demo.tts.DemoProvider.get_tts_audio", - return_value=(None, None), - ): - await hass.services.async_call( - tts.DOMAIN, - "demo_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is someone at the door.", - }, - blocking=True, - ) + await hass.services.async_call( + tts.DOMAIN, + "test_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + }, + blocking=True, + ) assert len(calls) == 1 assert ( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" ) async def test_setup_component_test_with_error_on_get_tts(hass: HomeAssistant) -> None: - """Set up demo platform with wrong get_tts_audio.""" - config = {tts.DOMAIN: {"platform": "demo"}} + """Set up a TTS platform with wrong get_tts_audio.""" + config = {tts.DOMAIN: {"platform": "test"}} - with assert_setup_component(1, tts.DOMAIN), patch( - "homeassistant.components.demo.tts.DemoProvider.get_tts_audio", - return_value=(None, None), - ): + class MockProviderEmpty(MockProvider): + def get_tts_audio( + self, message: str, language: str, options: dict[str, Any] | None = None + ) -> tts.TtsAudioType: + """Load TTS dat.""" + return (None, None) + + mock_integration(hass, MockModule(domain="test")) + mock_platform(hass, "test.tts", MockTTS(MockProviderEmpty)) + + with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) async def test_setup_component_load_cache_retrieve_without_mem_cache( hass: HomeAssistant, - demo_provider, + test_provider, empty_cache_dir, hass_client: ClientSessionGenerator, + mock_tts, ) -> None: """Set up component and load cache and get without mem cache.""" - _, demo_data = demo_provider.get_tts_audio("bla", "en") + _, tts_data = test_provider.get_tts_audio("bla", "en") cache_file = ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" ) with open(cache_file, "wb") as voice_file: - voice_file.write(demo_data) + voice_file.write(tts_data) - config = {tts.DOMAIN: {"platform": "demo", "cache": True}} + config = {tts.DOMAIN: {"platform": "test", "cache": True}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) client = await hass_client() - url = "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + url = "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" req = await client.get(url) assert req.status == HTTPStatus.OK - assert await req.read() == demo_data + assert await req.read() == tts_data async def test_setup_component_and_web_get_url( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts ) -> None: - """Set up the demo platform and receive file from web.""" - config = {tts.DOMAIN: {"platform": "demo"}} + """Set up a TTS platform and receive file from web.""" + config = {tts.DOMAIN: {"platform": "test"}} await async_setup_component(hass, tts.DOMAIN, config) client = await hass_client() url = "/api/tts_get_url" - data = {"platform": "demo", "message": "There is someone at the door."} + data = {"platform": "test", "message": "There is someone at the door."} req = await client.post(url, json=data) assert req.status == HTTPStatus.OK response = await req.json() assert response == { - "url": "http://example.local:8123/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3", - "path": "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3", + "url": "http://example.local:8123/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3", + "path": "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3", } async def test_setup_component_and_web_get_url_bad_config( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts ) -> None: - """Set up the demo platform and receive wrong file from web.""" - config = {tts.DOMAIN: {"platform": "demo"}} + """Set up a TTS platform and receive wrong file from web.""" + config = {tts.DOMAIN: {"platform": "test"}} await async_setup_component(hass, tts.DOMAIN, config) @@ -719,25 +803,25 @@ async def test_setup_component_and_web_get_url_bad_config( assert req.status == HTTPStatus.BAD_REQUEST -async def test_tags_with_wave(hass: HomeAssistant, demo_provider) -> None: - """Set up the demo platform and call service and receive voice.""" +async def test_tags_with_wave(hass: HomeAssistant, test_provider) -> None: + """Set up a TTS platform and call service and receive voice.""" # below data represents an empty wav file - demo_data = bytes.fromhex( + tts_data = bytes.fromhex( "52 49 46 46 24 00 00 00 57 41 56 45 66 6d 74 20 10 00 00 00 01 00 02 00" + "22 56 00 00 88 58 01 00 04 00 10 00 64 61 74 61 00 00 00 00" ) tagged_data = ORIG_WRITE_TAGS( - "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.wav", - demo_data, - demo_provider, + "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.wav", + tts_data, + test_provider, "AI person is in front of your door.", "en", None, ) - assert tagged_data != demo_data + assert tagged_data != tts_data @pytest.mark.parametrize( @@ -781,10 +865,10 @@ def test_invalid_base_url(value) -> None: @pytest.mark.parametrize( ("engine", "language", "options", "cache", "result_engine", "result_query"), ( - (None, None, None, None, "demo", ""), - (None, "de", None, None, "demo", "language=de"), - (None, "de", {"voice": "henk"}, None, "demo", "language=de&voice=henk"), - (None, "de", None, True, "demo", "cache=true&language=de"), + (None, None, None, None, "test", ""), + (None, "de", None, None, "test", "language=de"), + (None, "de", {"voice": "henk"}, None, "test", "language=de&voice=henk"), + (None, "de", None, True, "test", "cache=true&language=de"), ), ) async def test_generate_media_source_id( From a32c78238eccee99ac9558b08be2c8e3c1a145ca Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Mon, 27 Mar 2023 23:06:30 +1100 Subject: [PATCH 1305/2810] Bump lifx dependencies (#90345) --- homeassistant/components/lifx/manifest.json | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index 0019f68ab9..65f4e7ecef 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -42,7 +42,7 @@ "quality_scale": "platinum", "requirements": [ "aiolifx==0.8.9", - "aiolifx_effects==0.3.1", - "aiolifx_themes==0.4.0" + "aiolifx_effects==0.3.2", + "aiolifx_themes==0.4.5" ] } diff --git a/requirements_all.txt b/requirements_all.txt index cf3fbc10f4..91238f06f2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -196,10 +196,10 @@ aiokef==0.2.16 aiolifx==0.8.9 # homeassistant.components.lifx -aiolifx_effects==0.3.1 +aiolifx_effects==0.3.2 # homeassistant.components.lifx -aiolifx_themes==0.4.0 +aiolifx_themes==0.4.5 # homeassistant.components.livisi aiolivisi==0.0.19 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f1a499c45d..f16cbf7c37 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -180,10 +180,10 @@ aiokafka==0.7.2 aiolifx==0.8.9 # homeassistant.components.lifx -aiolifx_effects==0.3.1 +aiolifx_effects==0.3.2 # homeassistant.components.lifx -aiolifx_themes==0.4.0 +aiolifx_themes==0.4.5 # homeassistant.components.livisi aiolivisi==0.0.19 From 2ce3c014ff8a1663a74554d4f9e553ea69041dbf Mon Sep 17 00:00:00 2001 From: dougiteixeira <31328123+dougiteixeira@users.noreply.github.com> Date: Mon, 27 Mar 2023 10:27:55 -0300 Subject: [PATCH 1306/2810] Move Proxmox VE constants (#90357) * Move constants to const.py * Update homeassistant/components/proxmoxve/const.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/proxmoxve/const.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/proxmoxve/__init__.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/proxmoxve/__init__.py | 38 +++++++++---------- homeassistant/components/proxmoxve/const.py | 22 +++++++++++ 2 files changed, 40 insertions(+), 20 deletions(-) create mode 100644 homeassistant/components/proxmoxve/const.py diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index 8e8842abf4..f8e350f2b1 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import timedelta -import logging from proxmoxer import ProxmoxAPI from proxmoxer.backends.https import AuthenticationError @@ -28,26 +27,25 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) +from .const import ( + _LOGGER, + CONF_CONTAINERS, + CONF_NODE, + CONF_NODES, + CONF_REALM, + CONF_VMS, + COORDINATORS, + DEFAULT_PORT, + DEFAULT_REALM, + DEFAULT_VERIFY_SSL, + DOMAIN, + PROXMOX_CLIENTS, + TYPE_CONTAINER, + TYPE_VM, + UPDATE_INTERVAL, +) + PLATFORMS = [Platform.BINARY_SENSOR] -DOMAIN = "proxmoxve" -PROXMOX_CLIENTS = "proxmox_clients" -CONF_REALM = "realm" -CONF_NODE = "node" -CONF_NODES = "nodes" -CONF_VMS = "vms" -CONF_CONTAINERS = "containers" - -COORDINATORS = "coordinators" -API_DATA = "api_data" - -DEFAULT_PORT = 8006 -DEFAULT_REALM = "pam" -DEFAULT_VERIFY_SSL = True -TYPE_VM = 0 -TYPE_CONTAINER = 1 -UPDATE_INTERVAL = 60 - -_LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema( { diff --git a/homeassistant/components/proxmoxve/const.py b/homeassistant/components/proxmoxve/const.py new file mode 100644 index 0000000000..6477c08146 --- /dev/null +++ b/homeassistant/components/proxmoxve/const.py @@ -0,0 +1,22 @@ +"""Constants for ProxmoxVE.""" + +import logging + +DOMAIN = "proxmoxve" +PROXMOX_CLIENTS = "proxmox_clients" +CONF_REALM = "realm" +CONF_NODE = "node" +CONF_NODES = "nodes" +CONF_VMS = "vms" +CONF_CONTAINERS = "containers" + +COORDINATORS = "coordinators" + +DEFAULT_PORT = 8006 +DEFAULT_REALM = "pam" +DEFAULT_VERIFY_SSL = True +TYPE_VM = 0 +TYPE_CONTAINER = 1 +UPDATE_INTERVAL = 60 + +_LOGGER = logging.getLogger(__package__) From fd3280260d79ba5a774292624321639620531869 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 27 Mar 2023 16:39:51 +0200 Subject: [PATCH 1307/2810] Remove unreachable continue statement in imap push coordinator (#90361) --- homeassistant/components/imap/coordinator.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index e9bbb62301..69f291df6e 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -199,7 +199,6 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator): self.async_set_update_error(UpdateFailed("Lost connection")) await self._cleanup() await asyncio.sleep(BACKOFF_TIME) - continue async def shutdown(self, *_) -> None: """Close resources.""" From 89f89cab2ca8778de717bb2564fcb46830ce7101 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 27 Mar 2023 16:55:10 +0200 Subject: [PATCH 1308/2810] Use entity name translations in Verisure (#90362) --- .../components/verisure/binary_sensor.py | 2 +- homeassistant/components/verisure/sensor.py | 4 ++-- homeassistant/components/verisure/strings.json | 15 +++++++++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/verisure/binary_sensor.py b/homeassistant/components/verisure/binary_sensor.py index a960107c71..68d549eaa5 100644 --- a/homeassistant/components/verisure/binary_sensor.py +++ b/homeassistant/components/verisure/binary_sensor.py @@ -98,7 +98,7 @@ class VerisureEthernetStatus( _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_has_entity_name = True - _attr_name = "Ethernet status" + _attr_translation_key = "ethernet" @property def unique_id(self) -> str: diff --git a/homeassistant/components/verisure/sensor.py b/homeassistant/components/verisure/sensor.py index 0b519b4726..7c9639b654 100644 --- a/homeassistant/components/verisure/sensor.py +++ b/homeassistant/components/verisure/sensor.py @@ -47,7 +47,7 @@ class VerisureThermometer( _attr_device_class = SensorDeviceClass.TEMPERATURE _attr_has_entity_name = True - _attr_name = "Temperature" + _attr_translation_key = "temperature" _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS _attr_state_class = SensorStateClass.MEASUREMENT @@ -99,7 +99,7 @@ class VerisureHygrometer( _attr_device_class = SensorDeviceClass.HUMIDITY _attr_has_entity_name = True - _attr_name = "Humidity" + _attr_translation_key = "humidity" _attr_native_unit_of_measurement = PERCENTAGE _attr_state_class = SensorStateClass.MEASUREMENT diff --git a/homeassistant/components/verisure/strings.json b/homeassistant/components/verisure/strings.json index c8326d7375..17feb4a7fe 100644 --- a/homeassistant/components/verisure/strings.json +++ b/homeassistant/components/verisure/strings.json @@ -56,5 +56,20 @@ "error": { "code_format_mismatch": "The default PIN code does not match the required number of digits" } + }, + "entity": { + "binary_sensor": { + "ethernet": { + "name": "Ethernet status" + } + }, + "sensor": { + "humidity": { + "name": "[%key:component::sensor::entity_component::humidity::name%]" + }, + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + } + } } } From f4fda55405764446c571701a23860ce0fe0b1254 Mon Sep 17 00:00:00 2001 From: javicalle <31999997+javicalle@users.noreply.github.com> Date: Mon, 27 Mar 2023 17:57:40 +0200 Subject: [PATCH 1309/2810] Fix `quirk_class_validator` in ZHA unit tests (#90140) * Fix `quirk_class_validator` Fix the `quirk_class_validator` for quirks with more than 1 module level * fix black * Shorten `quirk_cls` in `clss` Co-authored-by: TheJulianJES * Update comment --------- Co-authored-by: TheJulianJES --- tests/components/zha/test_registries.py | 26 +++++++------------------ 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/tests/components/zha/test_registries.py b/tests/components/zha/test_registries.py index 6a6bf758ce..80b1f10f56 100644 --- a/tests/components/zha/test_registries.py +++ b/tests/components/zha/test_registries.py @@ -1,4 +1,5 @@ """Test ZHA registries.""" +import importlib import inspect from unittest import mock @@ -440,24 +441,11 @@ def test_quirk_classes() -> None: def find_quirk_class(base_obj, quirk_mod, quirk_cls): """Find a specific quirk class.""" - mods = dict(inspect.getmembers(base_obj, inspect.ismodule)) - # Check if we have found the right module - if quirk_mod in mods: - # If so, look for the class - clss = dict(inspect.getmembers(mods[quirk_mod], inspect.isclass)) - if quirk_cls in clss: - # Quirk class found - return True - - else: - # Recurse into other modules - for mod in mods: - if not mods[mod].__name__.startswith("zhaquirks."): - continue - if find_quirk_class(mods[mod], quirk_mod, quirk_cls): - return True - return False + module = importlib.import_module(quirk_mod) + clss = dict(inspect.getmembers(module, inspect.isclass)) + # Check quirk_cls in module classes + return quirk_cls in clss def quirk_class_validator(value): """Validate quirk classes during self test.""" @@ -471,9 +459,9 @@ def test_quirk_classes() -> None: quirk_class_validator(v) return - quirk_tok = value.split(".") + quirk_tok = value.rsplit(".", 1) if len(quirk_tok) != 2: - # quirk_class is always __module__.__class__ + # quirk_class is at least __module__.__class__ raise ValueError(f"Invalid quirk class : '{value}'") if not find_quirk_class(zhaquirks, quirk_tok[0], quirk_tok[1]): From b033232b06e14759bd886204e2efd3347a8384be Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 27 Mar 2023 19:49:40 +0200 Subject: [PATCH 1310/2810] Filter out ASCII tab or newline from input URLs (#90348) --- .../components/http/security_filter.py | 18 +++++++ tests/components/http/test_security_filter.py | 51 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/homeassistant/components/http/security_filter.py b/homeassistant/components/http/security_filter.py index a9b32bd7f4..e8e3aa4699 100644 --- a/homeassistant/components/http/security_filter.py +++ b/homeassistant/components/http/security_filter.py @@ -35,6 +35,9 @@ FILTERS: Final = re.compile( ) # fmt: on +# Unsafe bytes to be removed per WHATWG spec +UNSAFE_URL_BYTES = ["\t", "\r", "\n"] + @callback def setup_security_filter(app: Application) -> None: @@ -51,6 +54,21 @@ def setup_security_filter(app: Application) -> None: request: Request, handler: Callable[[Request], Awaitable[StreamResponse]] ) -> StreamResponse: """Process request and block commonly known exploit attempts.""" + for unsafe_byte in UNSAFE_URL_BYTES: + if unsafe_byte in request.path: + _LOGGER.warning( + "Filtered a request with an unsafe byte in path: %s", + request.raw_path, + ) + raise HTTPBadRequest + + if unsafe_byte in request.query_string: + _LOGGER.warning( + "Filtered a request with unsafe byte query string: %s", + request.raw_path, + ) + raise HTTPBadRequest + if FILTERS.search(_recursive_unquote(request.path)): _LOGGER.warning( "Filtered a potential harmful request to: %s", request.raw_path diff --git a/tests/components/http/test_security_filter.py b/tests/components/http/test_security_filter.py index 1c139a5916..5469b7ebfa 100644 --- a/tests/components/http/test_security_filter.py +++ b/tests/components/http/test_security_filter.py @@ -107,3 +107,54 @@ async def test_bad_requests( if fail_on_query_string: message = "Filtered a request with a potential harmful query string:" assert message in caplog.text + + +@pytest.mark.parametrize( + ("request_path", "request_params", "fail_on_query_string"), + [ + ("/some\thing", {}, False), + ("/new\nline/cinema", {}, False), + ("/return\r/to/sender", {}, False), + ("/", {"some": "\thing"}, True), + ("/", {"\newline": "cinema"}, True), + ("/", {"return": "t\rue"}, True), + ], +) +async def test_bad_requests_with_unsafe_bytes( + request_path, + request_params, + fail_on_query_string, + aiohttp_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + loop, +) -> None: + """Test request with unsafe bytes in their URLs.""" + app = web.Application() + app.router.add_get("/{all:.*}", mock_handler) + + setup_security_filter(app) + + mock_api_client = await aiohttp_client(app) + + # Manual params handling + if request_params: + raw_params = "&".join(f"{val}={key}" for val, key in request_params.items()) + man_params = f"?{raw_params}" + else: + man_params = "" + + http = urllib3.PoolManager() + resp = await loop.run_in_executor( + None, + http.request, + "GET", + f"http://{mock_api_client.host}:{mock_api_client.port}{request_path}{man_params}", + request_params, + ) + + assert resp.status == HTTPStatus.BAD_REQUEST + + message = "Filtered a request with an unsafe byte in path:" + if fail_on_query_string: + message = "Filtered a request with unsafe byte query string:" + assert message in caplog.text From d59e2b13496e51f30ed05f3c201423fc09e88a95 Mon Sep 17 00:00:00 2001 From: Trevor Bernard Date: Mon, 27 Mar 2023 12:57:56 -0500 Subject: [PATCH 1311/2810] Add "stream" to default_config (#90153) * add stream to manifest * Update __init__.py remove av check from init * Update homeassistant/components/default_config/__init__.py * Update requirements --------- Co-authored-by: Paulus Schoutsen Co-authored-by: Paulus Schoutsen --- homeassistant/components/default_config/__init__.py | 11 +---------- homeassistant/components/default_config/manifest.json | 1 + homeassistant/package_constraints.txt | 2 ++ 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/default_config/__init__.py b/homeassistant/components/default_config/__init__.py index 574d97c6d2..d91d06949e 100644 --- a/homeassistant/components/default_config/__init__.py +++ b/homeassistant/components/default_config/__init__.py @@ -1,10 +1,4 @@ """Component providing default configuration for new users.""" - -try: - import av -except ImportError: - av = None - from homeassistant.components.hassio import is_hassio from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType @@ -18,7 +12,4 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if not is_hassio(hass): await async_setup_component(hass, "backup", config) - if av is None: - return True - - return await async_setup_component(hass, "stream", config) + return True diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json index d4faaddaa5..a1add47594 100644 --- a/homeassistant/components/default_config/manifest.json +++ b/homeassistant/components/default_config/manifest.json @@ -33,6 +33,7 @@ "schedule", "script", "ssdp", + "stream", "sun", "system_health", "tag", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cd568947cf..ae190115b2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,5 +1,6 @@ PyJWT==2.6.0 PyNaCl==1.5.0 +PyTurboJPEG==1.6.7 aiodiscover==1.4.14 aiohttp==3.8.4 aiohttp_cors==0.7.0 @@ -20,6 +21,7 @@ ciso8601==2.3.0 cryptography==40.0.1 dbus-fast==1.84.2 fnvhash==0.1.0 +ha-av==10.0.0 hass-nabucasa==0.62.0 hassil==1.0.6 home-assistant-bluetooth==1.9.3 From a4051121421bab0ec3784e7019882674a5b0f879 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 27 Mar 2023 19:59:57 +0200 Subject: [PATCH 1312/2810] Add state translations for Script entities (#90354) --- homeassistant/components/script/strings.json | 23 ++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/homeassistant/components/script/strings.json b/homeassistant/components/script/strings.json index c78e4265cb..b9624f16a3 100644 --- a/homeassistant/components/script/strings.json +++ b/homeassistant/components/script/strings.json @@ -6,6 +6,29 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" + }, + "state_attributes": { + "current": { + "name": "[%key:component::automation::entity_component::_::state_attributes::current::name%]" + }, + "last_action": { + "name": "Last action" + }, + "last_triggered": { + "name": "[%key:component::automation::entity_component::_::state_attributes::last_triggered::name%]" + }, + "max": { + "name": "Max running scripts" + }, + "mode": { + "name": "[%key:component::automation::entity_component::_::state_attributes::mode::name%]", + "state": { + "parallel": "[%key:component::automation::entity_component::_::state_attributes::mode::state::parallel%]", + "queued": "[%key:component::automation::entity_component::_::state_attributes::mode::state::queued%]", + "restart": "[%key:component::automation::entity_component::_::state_attributes::mode::state::restart%]", + "single": "[%key:component::automation::entity_component::_::state_attributes::mode::state::single%]" + } + } } } } From f84651b14e1d51999e5919f745a553a94a87eada Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Mar 2023 20:00:54 +0200 Subject: [PATCH 1313/2810] Improve tts test coverage (#90370) --- tests/components/tts/test_init.py | 64 +++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index e7b3a818f6..251ed9b30c 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -14,6 +14,7 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, MediaType, ) +from homeassistant.components.media_source import Unresolvable from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -398,6 +399,54 @@ async def test_setup_component_and_test_with_service_options_def( ).is_file() +async def test_setup_component_and_test_with_service_options_def_2( + hass: HomeAssistant, empty_cache_dir +) -> None: + """Set up a TTS platform and call service with default options. + + This tests merging default and user provided options. + """ + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + config = {tts.DOMAIN: {"platform": "test"}} + + class MockProviderWithDefaults(MockProvider): + @property + def default_options(self): + return {"voice": "alex"} + + mock_integration(hass, MockModule(domain="test")) + mock_platform(hass, "test.tts", MockTTS(MockProviderWithDefaults)) + + with assert_setup_component(1, tts.DOMAIN): + assert await async_setup_component(hass, tts.DOMAIN, config) + + await hass.services.async_call( + tts.DOMAIN, + "test_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + tts.ATTR_LANGUAGE: "de", + tts.ATTR_OPTIONS: {"age": 5}, + }, + blocking=True, + ) + opt_hash = tts._hash_options({"voice": "alex", "age": 5}) + + assert len(calls) == 1 + assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC + assert ( + await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == f"/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_test.mp3" + ) + await hass.async_block_till_done() + assert ( + empty_cache_dir + / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_test.mp3" + ).is_file() + + async def test_setup_component_and_test_service_with_service_options_wrong( hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: @@ -718,6 +767,8 @@ async def test_setup_component_test_with_cache_dir( async def test_setup_component_test_with_error_on_get_tts(hass: HomeAssistant) -> None: """Set up a TTS platform with wrong get_tts_audio.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + config = {tts.DOMAIN: {"platform": "test"}} class MockProviderEmpty(MockProvider): @@ -733,6 +784,19 @@ async def test_setup_component_test_with_error_on_get_tts(hass: HomeAssistant) - with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) + await hass.services.async_call( + tts.DOMAIN, + "test_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + }, + blocking=True, + ) + assert len(calls) == 1 + with pytest.raises(Unresolvable): + await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + async def test_setup_component_load_cache_retrieve_without_mem_cache( hass: HomeAssistant, From 18933df95c0619db7500edab1c2162b80f70638e Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 27 Mar 2023 20:06:59 +0200 Subject: [PATCH 1314/2810] Clean dead code from matter (#90369) --- homeassistant/components/matter/models.py | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py index 2575b16e8b..eaa9ccf9a0 100644 --- a/homeassistant/components/matter/models.py +++ b/homeassistant/components/matter/models.py @@ -2,8 +2,8 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import asdict, dataclass -from typing import TYPE_CHECKING, Any +from dataclasses import dataclass +from typing import Any from chip.clusters import Objects as clusters from chip.clusters.Objects import ClusterAttributeDescriptor @@ -13,19 +13,6 @@ from matter_server.client.models.node import MatterEndpoint from homeassistant.const import Platform from homeassistant.helpers.entity import EntityDescription -if TYPE_CHECKING: - from _typeshed import DataclassInstance - - -class DataclassMustHaveAtLeastOne: - """A dataclass that must have at least one input parameter that is not None.""" - - def __post_init__(self: DataclassInstance) -> None: - """Post dataclass initialization.""" - if all(val is None for val in asdict(self).values()): - raise ValueError("At least one input parameter must not be None") - - SensorValueTypes = type[ clusters.uint | int | clusters.Nullable | clusters.float32 | float ] From 1937d803c5f9fba91b4f9c810fa376d1dd8a13ff Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Mar 2023 20:08:20 +0200 Subject: [PATCH 1315/2810] Add RestoreEntity pylint checks to all platforms (#90020) --- pylint/plugins/hass_enforce_type_hints.py | 84 +++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 4fb471e214..a84d578cf5 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -710,6 +710,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="AlarmControlPanelEntity", matches=[ @@ -793,6 +797,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="BinarySensorEntity", matches=[ @@ -836,6 +844,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="CalendarEntity", matches=[ @@ -860,6 +872,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="Camera", matches=[ @@ -974,6 +990,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="ClimateEntity", matches=[ @@ -1147,6 +1167,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="CoverEntity", matches=[ @@ -1246,6 +1270,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="BaseTrackerEntity", matches=[ @@ -1319,6 +1347,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="ToggleEntity", matches=_TOGGLE_ENTITY_MATCH, @@ -1400,6 +1432,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="GeolocationEvent", matches=[ @@ -1427,6 +1463,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="ImageProcessingEntity", matches=[ @@ -1470,6 +1510,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="ToggleEntity", matches=_TOGGLE_ENTITY_MATCH, @@ -1525,6 +1569,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="ToggleEntity", matches=_TOGGLE_ENTITY_MATCH, @@ -1624,6 +1672,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="LockEntity", matches=[ @@ -1714,6 +1766,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="MediaPlayerEntity", matches=[ @@ -2118,6 +2174,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="ToggleEntity", matches=_TOGGLE_ENTITY_MATCH, @@ -2185,6 +2245,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="SelectEntity", matches=[ @@ -2275,6 +2339,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="ToggleEntity", matches=_TOGGLE_ENTITY_MATCH, @@ -2334,6 +2402,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="ToggleEntity", matches=_TOGGLE_ENTITY_MATCH, @@ -2445,6 +2517,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="ToggleEntity", matches=_TOGGLE_ENTITY_MATCH, @@ -2580,6 +2656,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="WaterHeaterEntity", matches=[ @@ -2661,6 +2741,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="WeatherEntity", matches=[ From 506a916a136249898e95f5e6b989d9febf9c4c06 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Mar 2023 20:37:31 +0200 Subject: [PATCH 1316/2810] Add reauth flow to dormakaba dkey (#90225) --- .../components/dormakaba_dkey/__init__.py | 6 +- .../components/dormakaba_dkey/config_flow.py | 50 +++++++++++++-- .../components/dormakaba_dkey/strings.json | 5 ++ .../dormakaba_dkey/test_config_flow.py | 63 +++++++++++++++++++ 4 files changed, 117 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/dormakaba_dkey/__init__.py b/homeassistant/components/dormakaba_dkey/__init__.py index 2f57d9802b..4903e46b8d 100644 --- a/homeassistant/components/dormakaba_dkey/__init__.py +++ b/homeassistant/components/dormakaba_dkey/__init__.py @@ -5,7 +5,7 @@ from datetime import timedelta import logging from py_dormakaba_dkey import DKEYLock -from py_dormakaba_dkey.errors import DKEY_EXCEPTIONS +from py_dormakaba_dkey.errors import DKEY_EXCEPTIONS, NotAssociated from py_dormakaba_dkey.models import AssociationData from homeassistant.components import bluetooth @@ -13,7 +13,7 @@ from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackM from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_ASSOCIATION_DATA, DOMAIN, UPDATE_SECONDS @@ -60,6 +60,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await lock.update() await lock.disconnect() + except NotAssociated as ex: + raise ConfigEntryAuthFailed("Not associated") from ex except DKEY_EXCEPTIONS as ex: raise UpdateFailed(str(ex)) from ex diff --git a/homeassistant/components/dormakaba_dkey/config_flow.py b/homeassistant/components/dormakaba_dkey/config_flow.py index 3da1fd841f..f03861d015 100644 --- a/homeassistant/components/dormakaba_dkey/config_flow.py +++ b/homeassistant/components/dormakaba_dkey/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Dormakaba dKey integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -12,6 +13,7 @@ from homeassistant import config_entries from homeassistant.components.bluetooth import ( BluetoothServiceInfoBleak, async_discovered_service_info, + async_last_service_info, ) from homeassistant.const import CONF_ADDRESS from homeassistant.data_entry_flow import FlowResult @@ -32,12 +34,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 + _reauth_entry: config_entries.ConfigEntry | None = None + def __init__(self) -> None: """Initialize the config flow.""" self._lock: DKEYLock | None = None # Populated by user step self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {} - # Populated by bluetooth and user steps + # Populated by bluetooth, reauth_confirm and user steps self._discovery_info: BluetoothServiceInfoBleak | None = None async def async_step_user( @@ -113,6 +117,36 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_associate() + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle reauthorization request.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle reauthorization flow.""" + errors = {} + reauth_entry = self._reauth_entry + assert reauth_entry is not None + + if user_input is not None: + if ( + discovery_info := async_last_service_info( + self.hass, reauth_entry.data[CONF_ADDRESS], True + ) + ) is None: + errors = {"base": "no_longer_in_range"} + else: + self._discovery_info = discovery_info + return await self.async_step_associate() + + return self.async_show_form( + step_id="reauth_confirm", data_schema=vol.Schema({}), errors=errors + ) + async def async_step_associate( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -143,14 +177,20 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") else: + data = { + CONF_ADDRESS: self._discovery_info.device.address, + CONF_ASSOCIATION_DATA: association_data.to_json(), + } + if reauth_entry := self._reauth_entry: + self.hass.config_entries.async_update_entry(reauth_entry, data=data) + await self.hass.config_entries.async_reload(reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + return self.async_create_entry( title=lock.device_info.device_name or lock.device_info.device_id or lock.name, - data={ - CONF_ADDRESS: self._discovery_info.device.address, - CONF_ASSOCIATION_DATA: association_data.to_json(), - }, + data=data, ) return self.async_show_form( diff --git a/homeassistant/components/dormakaba_dkey/strings.json b/homeassistant/components/dormakaba_dkey/strings.json index d07deaca82..efe9d3acb5 100644 --- a/homeassistant/components/dormakaba_dkey/strings.json +++ b/homeassistant/components/dormakaba_dkey/strings.json @@ -11,6 +11,9 @@ "bluetooth_confirm": { "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" }, + "reauth_confirm": { + "description": "The activation code is no longer valid, a new unused activation code is needed.\n\n" + }, "associate": { "description": "Provide an unused activation code.\n\nTo create an activation code, create a new key in the dKey admin app, then choose to share the key and share an activation code.\n\nMake sure to close the dKey admin app before proceeding.", "data": { @@ -19,6 +22,7 @@ } }, "error": { + "no_longer_in_range": "The lock is no longer in Bluetooth range. Move the lock or adapter and try again.", "invalid_code": "Invalid activation code. An activation code consist of 8 characters, separated by a dash, e.g. GBZT-HXC0.", "wrong_code": "Wrong activation code. Note that an activation code can only be used once." }, @@ -26,6 +30,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" } } diff --git a/tests/components/dormakaba_dkey/test_config_flow.py b/tests/components/dormakaba_dkey/test_config_flow.py index 70c86524be..8c0156e221 100644 --- a/tests/components/dormakaba_dkey/test_config_flow.py +++ b/tests/components/dormakaba_dkey/test_config_flow.py @@ -296,3 +296,66 @@ async def test_bluetooth_step_cannot_associate(hass: HomeAssistant, exc, error) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "associate" assert result["errors"] == {"base": error} + + +async def test_reauth(hass: HomeAssistant) -> None: + """Test reauthentication.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=DKEY_DISCOVERY_INFO.address, + data={"address": DKEY_DISCOVERY_INFO.address}, + ) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_REAUTH, "entry_id": entry.entry_id}, + data=entry.data, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.dormakaba_dkey.config_flow.async_last_service_info", + return_value=None, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "no_longer_in_range"} + + with patch( + "homeassistant.components.dormakaba_dkey.config_flow.async_last_service_info", + return_value=DKEY_DISCOVERY_INFO, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "associate" + assert result["errors"] is None + + with patch( + "homeassistant.components.dormakaba_dkey.config_flow.DKEYLock.associate", + return_value=AssociationData(b"1234", b"AABBCCDD"), + ) as mock_associate, patch( + "homeassistant.components.dormakaba_dkey.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"activation_code": "1234-1234"} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert entry.data == { + CONF_ADDRESS: DKEY_DISCOVERY_INFO.address, + "association_data": {"key_holder_id": "31323334", "secret": "4141424243434444"}, + } + mock_associate.assert_awaited_once_with("1234-1234") From 2ceb24e5d0569ad5ca0a9629da2f913221980623 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 27 Mar 2023 20:49:49 +0200 Subject: [PATCH 1317/2810] Fail CI if codecov upload fails (#90363) --- .github/workflows/ci.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f4e04059d1..e4fd319e71 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1100,7 +1100,10 @@ jobs: if: needs.info.outputs.test_full_suite == 'true' uses: codecov/codecov-action@v3.1.1 with: + fail_ci_if_error: true flags: full-suite - name: Upload coverage to Codecov (partial coverage) if: needs.info.outputs.test_full_suite == 'false' uses: codecov/codecov-action@v3.1.1 + with: + fail_ci_if_error: true From 96698813efe605056bc2e6005b2529a51901660d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 27 Mar 2023 21:19:09 +0200 Subject: [PATCH 1318/2810] Cleanup command_line (#90268) * Cleanup command_line * Fix ipv6 resolver * Fix fix * Fix tests * Align states --- .../components/command_line/__init__.py | 61 ------------------ .../components/command_line/binary_sensor.py | 28 ++------- .../components/command_line/cover.py | 33 +++------- .../components/command_line/sensor.py | 38 +++--------- .../components/command_line/switch.py | 49 +++++++++------ .../components/command_line/utils.py | 62 +++++++++++++++++++ tests/components/command_line/test_cover.py | 6 +- tests/components/command_line/test_sensor.py | 2 +- tests/components/command_line/test_switch.py | 4 +- 9 files changed, 122 insertions(+), 161 deletions(-) create mode 100644 homeassistant/components/command_line/utils.py diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index c0713d0780..fe0640d3ef 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -1,62 +1 @@ """The command_line component.""" -from __future__ import annotations - -import logging -import subprocess - -_LOGGER = logging.getLogger(__name__) - - -def call_shell_with_timeout( - command: str, timeout: int, *, log_return_code: bool = True -) -> int: - """Run a shell command with a timeout. - - If log_return_code is set to False, it will not print an error if a non-zero - return code is returned. - """ - try: - _LOGGER.debug("Running command: %s", command) - subprocess.check_output( - command, - shell=True, # nosec # shell by design - timeout=timeout, - close_fds=False, # required for posix_spawn - ) - return 0 - except subprocess.CalledProcessError as proc_exception: - if log_return_code: - _LOGGER.error( - "Command failed (with return code %s): %s", - proc_exception.returncode, - command, - ) - return proc_exception.returncode - except subprocess.TimeoutExpired: - _LOGGER.error("Timeout for command: %s", command) - return -1 - except subprocess.SubprocessError: - _LOGGER.error("Error trying to exec command: %s", command) - return -1 - - -def check_output_or_log(command: str, timeout: int) -> str | None: - """Run a shell command with a timeout and return the output.""" - try: - return_value = subprocess.check_output( - command, - shell=True, # nosec # shell by design - timeout=timeout, - close_fds=False, # required for posix_spawn - ) - return return_value.strip().decode("utf-8") - except subprocess.CalledProcessError as err: - _LOGGER.error( - "Command failed (with return code %s): %s", err.returncode, command - ) - except subprocess.TimeoutExpired: - _LOGGER.error("Timeout for command: %s", command) - except subprocess.SubprocessError: - _LOGGER.error("Error trying to exec command: %s", command) - - return None diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index 2e1ddb7a96..0c2edb8f19 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -25,10 +25,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.template import Template -from homeassistant.helpers.template_entity import ( - TEMPLATE_ENTITY_BASE_SCHEMA, - TemplateEntity, -) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS @@ -65,10 +61,6 @@ async def async_setup_platform( await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - binary_sensor_config = vol.Schema( - TEMPLATE_ENTITY_BASE_SCHEMA.schema, extra=vol.REMOVE_EXTRA - )(config) - name: str = config.get(CONF_NAME, DEFAULT_NAME) command: str = config[CONF_COMMAND] payload_off: str = config[CONF_PAYLOAD_OFF] @@ -84,8 +76,6 @@ async def async_setup_platform( async_add_entities( [ CommandBinarySensor( - hass, - binary_sensor_config, data, name, device_class, @@ -99,13 +89,11 @@ async def async_setup_platform( ) -class CommandBinarySensor(TemplateEntity, BinarySensorEntity): +class CommandBinarySensor(BinarySensorEntity): """Representation of a command line binary sensor.""" def __init__( self, - hass: HomeAssistant, - config: ConfigType, data: CommandSensorData, name: str, device_class: BinarySensorDeviceClass | None, @@ -115,19 +103,14 @@ class CommandBinarySensor(TemplateEntity, BinarySensorEntity): unique_id: str | None, ) -> None: """Initialize the Command line binary sensor.""" - TemplateEntity.__init__( - self, - hass, - config=config, - fallback_name=name, - unique_id=unique_id, - ) self.data = data + self._attr_name = name self._attr_device_class = device_class self._attr_is_on = None self._payload_on = payload_on self._payload_off = payload_off self._value_template = value_template + self._attr_unique_id = unique_id async def async_update(self) -> None: """Get the latest data and updates the state.""" @@ -135,9 +118,10 @@ class CommandBinarySensor(TemplateEntity, BinarySensorEntity): value = self.data.value if self._value_template is not None: - value = await self.hass.async_add_executor_job( - self._value_template.render_with_possible_json_value, value, False + value = self._value_template.async_render_with_possible_json_value( + value, None ) + self._attr_is_on = None if value == self._payload_on: self._attr_is_on = True elif value == self._payload_off: diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index 53773ae4e9..e477affc85 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -22,14 +22,10 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.template import Template -from homeassistant.helpers.template_entity import ( - TEMPLATE_ENTITY_BASE_SCHEMA, - TemplateEntity, -) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import call_shell_with_timeout, check_output_or_log from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS +from .utils import call_shell_with_timeout, check_output_or_log _LOGGER = logging.getLogger(__name__) @@ -69,14 +65,8 @@ async def async_setup_platform( if value_template is not None: value_template.hass = hass - cover_config = vol.Schema( - TEMPLATE_ENTITY_BASE_SCHEMA.schema, extra=vol.REMOVE_EXTRA - )(device_config) - covers.append( CommandCover( - hass, - cover_config, device_config.get(CONF_FRIENDLY_NAME, device_name), device_config[CONF_COMMAND_OPEN], device_config[CONF_COMMAND_CLOSE], @@ -95,13 +85,11 @@ async def async_setup_platform( async_add_entities(covers) -class CommandCover(TemplateEntity, CoverEntity): +class CommandCover(CoverEntity): """Representation a command line cover.""" def __init__( self, - hass: HomeAssistant, - config: ConfigType, name: str, command_open: str, command_close: str, @@ -112,13 +100,7 @@ class CommandCover(TemplateEntity, CoverEntity): unique_id: str | None, ) -> None: """Initialize the cover.""" - TemplateEntity.__init__( - self, - hass, - config=config, - fallback_name=name, - unique_id=unique_id, - ) + self._attr_name = name self._state: int | None = None self._command_open = command_open self._command_close = command_close @@ -126,6 +108,7 @@ class CommandCover(TemplateEntity, CoverEntity): self._command_state = command_state self._value_template = value_template self._timeout = timeout + self._attr_unique_id = unique_id self._attr_should_poll = bool(command_state) def _move_cover(self, command: str) -> bool: @@ -170,10 +153,12 @@ class CommandCover(TemplateEntity, CoverEntity): if self._command_state: payload = str(await self.hass.async_add_executor_job(self._query_state)) if self._value_template: - payload = await self.hass.async_add_executor_job( - self._value_template.render_with_possible_json_value, payload + payload = self._value_template.async_render_with_possible_json_value( + payload, None ) - self._state = int(payload) + self._state = None + if payload: + self._state = int(payload) def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index 24224c12ca..f459e41566 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -22,7 +22,6 @@ from homeassistant.const import ( CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, - STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError @@ -30,14 +29,10 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.template import Template -from homeassistant.helpers.template_entity import ( - TEMPLATE_SENSOR_BASE_SCHEMA, - TemplateSensor, -) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import check_output_or_log from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS +from .utils import check_output_or_log _LOGGER = logging.getLogger(__name__) @@ -72,10 +67,6 @@ async def async_setup_platform( await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - sensor_config = vol.Schema( - TEMPLATE_SENSOR_BASE_SCHEMA.schema, extra=vol.REMOVE_EXTRA - )(config) - name: str = config[CONF_NAME] command: str = config[CONF_COMMAND] unit: str | None = config.get(CONF_UNIT_OF_MEASUREMENT) @@ -90,8 +81,6 @@ async def async_setup_platform( async_add_entities( [ CommandSensor( - hass, - sensor_config, data, name, unit, @@ -104,13 +93,11 @@ async def async_setup_platform( ) -class CommandSensor(TemplateSensor, SensorEntity): +class CommandSensor(SensorEntity): """Representation of a sensor that is using shell commands.""" def __init__( self, - hass: HomeAssistant, - config: ConfigType, data: CommandSensorData, name: str, unit_of_measurement: str | None, @@ -119,18 +106,14 @@ class CommandSensor(TemplateSensor, SensorEntity): unique_id: str | None, ) -> None: """Initialize the sensor.""" - TemplateSensor.__init__( - self, - hass, - config=config, - fallback_name=name, - unique_id=unique_id, - ) + self._attr_name = name self.data = data self._attr_extra_state_attributes = {} self._json_attributes = json_attributes self._attr_native_value = None self._value_template = value_template + self._attr_native_unit_of_measurement = unit_of_measurement + self._attr_unique_id = unique_id async def async_update(self) -> None: """Get the latest data and updates the state.""" @@ -155,13 +138,12 @@ class CommandSensor(TemplateSensor, SensorEntity): else: _LOGGER.warning("Empty reply found when expecting JSON data") - if value is None: - value = STATE_UNKNOWN elif self._value_template is not None: - self._attr_native_value = await self.hass.async_add_executor_job( - self._value_template.render_with_possible_json_value, - value, - STATE_UNKNOWN, + self._attr_native_value = ( + self._value_template.async_render_with_possible_json_value( + value, + None, + ) ) else: self._attr_native_value = value diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index 7142f14e82..3c344891fb 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -24,12 +24,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.reload import setup_reload_service +from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import call_shell_with_timeout, check_output_or_log from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS +from .utils import call_shell_with_timeout, check_output_or_log _LOGGER = logging.getLogger(__name__) @@ -51,15 +51,15 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Find and return switches controlled by shell commands.""" - setup_reload_service(hass, DOMAIN, PLATFORMS) + await async_setup_reload_service(hass, DOMAIN, PLATFORMS) devices: dict[str, Any] = config.get(CONF_SWITCHES, {}) switches = [] @@ -92,7 +92,7 @@ def setup_platform( _LOGGER.error("No switches added") return - add_entities(switches) + async_add_entities(switches) class CommandSwitch(SwitchEntity): @@ -123,11 +123,16 @@ class CommandSwitch(SwitchEntity): self._attr_unique_id = unique_id self._attr_should_poll = bool(command_state) - def _switch(self, command: str) -> bool: + async def _switch(self, command: str) -> bool: """Execute the actual commands.""" _LOGGER.info("Running command: %s", command) - success = call_shell_with_timeout(command, self._timeout) == 0 + success = ( + await self.hass.async_add_executor_job( + call_shell_with_timeout, command, self._timeout + ) + == 0 + ) if not success: _LOGGER.error("Command failed: %s", command) @@ -160,26 +165,30 @@ class CommandSwitch(SwitchEntity): if TYPE_CHECKING: return None - def update(self) -> None: + async def async_update(self) -> None: """Update device state.""" if self._command_state: - payload = str(self._query_state()) + payload = str(await self.hass.async_add_executor_job(self._query_state)) if self._icon_template: - self._attr_icon = self._icon_template.render_with_possible_json_value( - payload + self._attr_icon = ( + self._icon_template.async_render_with_possible_json_value(payload) ) if self._value_template: - payload = self._value_template.render_with_possible_json_value(payload) - self._attr_is_on = payload.lower() == "true" + payload = self._value_template.async_render_with_possible_json_value( + payload, None + ) + self._attr_is_on = None + if payload: + self._attr_is_on = payload.lower() == "true" - def turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - if self._switch(self._command_on) and not self._command_state: + if await self._switch(self._command_on) and not self._command_state: self._attr_is_on = True - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - if self._switch(self._command_off) and not self._command_state: + if await self._switch(self._command_off) and not self._command_state: self._attr_is_on = False - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/command_line/utils.py b/homeassistant/components/command_line/utils.py new file mode 100644 index 0000000000..2d42732190 --- /dev/null +++ b/homeassistant/components/command_line/utils.py @@ -0,0 +1,62 @@ +"""The command_line component utils.""" +from __future__ import annotations + +import logging +import subprocess + +_LOGGER = logging.getLogger(__name__) + + +def call_shell_with_timeout( + command: str, timeout: int, *, log_return_code: bool = True +) -> int: + """Run a shell command with a timeout. + + If log_return_code is set to False, it will not print an error if a non-zero + return code is returned. + """ + try: + _LOGGER.debug("Running command: %s", command) + subprocess.check_output( + command, + shell=True, # nosec # shell by design + timeout=timeout, + close_fds=False, # required for posix_spawn + ) + return 0 + except subprocess.CalledProcessError as proc_exception: + if log_return_code: + _LOGGER.error( + "Command failed (with return code %s): %s", + proc_exception.returncode, + command, + ) + return proc_exception.returncode + except subprocess.TimeoutExpired: + _LOGGER.error("Timeout for command: %s", command) + return -1 + except subprocess.SubprocessError: + _LOGGER.error("Error trying to exec command: %s", command) + return -1 + + +def check_output_or_log(command: str, timeout: int) -> str | None: + """Run a shell command with a timeout and return the output.""" + try: + return_value = subprocess.check_output( + command, + shell=True, # nosec # shell by design + timeout=timeout, + close_fds=False, # required for posix_spawn + ) + return return_value.strip().decode("utf-8") + except subprocess.CalledProcessError as err: + _LOGGER.error( + "Command failed (with return code %s): %s", err.returncode, command + ) + except subprocess.TimeoutExpired: + _LOGGER.error("Timeout for command: %s", command) + except subprocess.SubprocessError: + _LOGGER.error("Error trying to exec command: %s", command) + + return None diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py index bfb74832f9..a650bd6c4f 100644 --- a/tests/components/command_line/test_cover.py +++ b/tests/components/command_line/test_cover.py @@ -42,7 +42,7 @@ async def test_no_covers(caplog: pytest.LogCaptureFixture, hass: HomeAssistant) """Test that the cover does not polls when there's no state command.""" with patch( - "homeassistant.components.command_line.subprocess.check_output", + "homeassistant.components.command_line.utils.subprocess.check_output", return_value=b"50\n", ): await setup_test_entity(hass, {}) @@ -53,7 +53,7 @@ async def test_no_poll_when_cover_has_no_command_state(hass: HomeAssistant) -> N """Test that the cover does not polls when there's no state command.""" with patch( - "homeassistant.components.command_line.subprocess.check_output", + "homeassistant.components.command_line.utils.subprocess.check_output", return_value=b"50\n", ) as check_output: await setup_test_entity(hass, {"test": {}}) @@ -66,7 +66,7 @@ async def test_poll_when_cover_has_command_state(hass: HomeAssistant) -> None: """Test that the cover polls when there's a state command.""" with patch( - "homeassistant.components.command_line.subprocess.check_output", + "homeassistant.components.command_line.utils.subprocess.check_output", return_value=b"50\n", ) as check_output: await setup_test_entity(hass, {"test": {"command_state": "echo state"}}) diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index 5aab14225f..4643891691 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -88,7 +88,7 @@ async def test_template_render_with_quote(hass: HomeAssistant) -> None: """Ensure command with templates and quotes get rendered properly.""" with patch( - "homeassistant.components.command_line.subprocess.check_output", + "homeassistant.components.command_line.utils.subprocess.check_output", return_value=b"Works\n", ) as check_output: await setup_test_entities( diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py index ac1ae35712..bc8eadcb22 100644 --- a/tests/components/command_line/test_switch.py +++ b/tests/components/command_line/test_switch.py @@ -323,7 +323,7 @@ async def test_switch_command_state_code_exceptions( """Test that switch state code exceptions are handled correctly.""" with patch( - "homeassistant.components.command_line.subprocess.check_output", + "homeassistant.components.command_line.utils.subprocess.check_output", side_effect=[ subprocess.TimeoutExpired("cmd", 10), subprocess.SubprocessError(), @@ -356,7 +356,7 @@ async def test_switch_command_state_value_exceptions( """Test that switch state value exceptions are handled correctly.""" with patch( - "homeassistant.components.command_line.subprocess.check_output", + "homeassistant.components.command_line.utils.subprocess.check_output", side_effect=[ subprocess.TimeoutExpired("cmd", 10), subprocess.SubprocessError(), From 71b5ccee233840248b7a78d8c8cb8092d53ad6fd Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Mon, 27 Mar 2023 22:22:36 +0300 Subject: [PATCH 1319/2810] Fix generic_hygrostat error at startup (#88764) --- .../components/generic_hygrostat/humidifier.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index dfd6be14e6..73d876b354 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -22,6 +22,8 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, ) from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant, callback from homeassistant.helpers import condition @@ -175,6 +177,15 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): async def _async_startup(event): """Init on startup.""" sensor_state = self.hass.states.get(self._sensor_entity_id) + if sensor_state is None or sensor_state.state in ( + STATE_UNKNOWN, + STATE_UNAVAILABLE, + ): + _LOGGER.debug( + "The sensor state is %s, initialization is delayed", + sensor_state.state if sensor_state is not None else "None", + ) + return await self._async_sensor_changed(self._sensor_entity_id, None, sensor_state) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_startup) From 9f04c234146b076e9e89c36f1eaab849c05c9adb Mon Sep 17 00:00:00 2001 From: Jonas Bergler Date: Tue, 28 Mar 2023 08:40:58 +1300 Subject: [PATCH 1320/2810] Support toggling debug logging for custom components (#90340) Co-authored-by: J. Nick Koston --- homeassistant/components/logger/helpers.py | 11 ++-- tests/components/logger/test_websocket_api.py | 51 +++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/logger/helpers.py b/homeassistant/components/logger/helpers.py index d85486a41e..df275eaae9 100644 --- a/homeassistant/components/logger/helpers.py +++ b/homeassistant/components/logger/helpers.py @@ -66,13 +66,14 @@ def _chattiest_log_level(level1: int, level2: int) -> int: return min(level1, level2) -async def get_integration_loggers(hass: HomeAssistant, domain: str) -> list[str]: +async def get_integration_loggers(hass: HomeAssistant, domain: str) -> set[str]: """Get loggers for an integration.""" - loggers = [f"homeassistant.components.{domain}"] + loggers: set[str] = {f"homeassistant.components.{domain}"} with contextlib.suppress(IntegrationNotFound): integration = await async_get_integration(hass, domain) + loggers.add(integration.pkg_path) if integration.loggers: - loggers.extend(integration.loggers) + loggers.update(integration.loggers) return loggers @@ -188,7 +189,7 @@ class LoggerSettings: if settings.type == LogSettingsType.INTEGRATION: loggers = await get_integration_loggers(hass, domain) else: - loggers = [domain] + loggers = {domain} combined_logs = {logger: LOGSEVERITY[settings.level] for logger in loggers} # Don't override the log levels with the ones from YAML @@ -203,7 +204,7 @@ class LoggerSettings: if settings.type == LogSettingsType.INTEGRATION: loggers = await get_integration_loggers(hass, domain) else: - loggers = [domain] + loggers = {domain} for logger in loggers: combined_logs[logger] = LOGSEVERITY[settings.level] diff --git a/tests/components/logger/test_websocket_api.py b/tests/components/logger/test_websocket_api.py index 1252734df9..10c1ceb2f2 100644 --- a/tests/components/logger/test_websocket_api.py +++ b/tests/components/logger/test_websocket_api.py @@ -1,6 +1,8 @@ """Tests for Logger Websocket API commands.""" import logging +from unittest.mock import patch +from homeassistant import loader from homeassistant.components.logger.helpers import async_get_domain_config from homeassistant.components.websocket_api import const from homeassistant.core import HomeAssistant @@ -79,6 +81,55 @@ async def test_integration_log_level( } +async def test_custom_integration_log_level( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_admin_user: MockUser +) -> None: + """Test setting integration log level.""" + websocket_client = await hass_ws_client() + assert await async_setup_component(hass, "logger", {}) + + integration = loader.Integration( + hass, + "custom_components.hue", + None, + { + "name": "Hue", + "dependencies": [], + "requirements": [], + "domain": "hue", + "loggers": ["some_other_logger"], + }, + ) + + with patch( + "homeassistant.components.logger.helpers.async_get_integration", + return_value=integration, + ), patch( + "homeassistant.components.logger.websocket_api.async_get_integration", + return_value=integration, + ): + await websocket_client.send_json( + { + "id": 7, + "type": "logger/integration_log_level", + "integration": "hue", + "level": "DEBUG", + "persistence": "none", + } + ) + + msg = await websocket_client.receive_json() + assert msg["id"] == 7 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + + assert async_get_domain_config(hass).overrides == { + "homeassistant.components.hue": logging.DEBUG, + "custom_components.hue": logging.DEBUG, + "some_other_logger": logging.DEBUG, + } + + async def test_integration_log_level_unknown_integration( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_admin_user: MockUser ) -> None: From fb4b35709dda05920488812551e3723a63dca5f2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 27 Mar 2023 22:19:25 +0200 Subject: [PATCH 1321/2810] Add state translations for helpers (#90356) * Add state translations for helpers * Managed via the UI --- homeassistant/components/counter/strings.json | 2 +- homeassistant/components/group/strings.json | 5 +++ .../components/input_boolean/strings.json | 9 +++++ .../components/input_button/strings.json | 17 ++++++++ .../components/input_datetime/strings.json | 39 ++++++++++++++++++- .../components/input_number/strings.json | 38 +++++++++++++++++- .../components/input_select/strings.json | 21 +++++++++- .../components/input_text/strings.json | 34 +++++++++++++++- .../components/schedule/strings.json | 12 ++++++ homeassistant/components/timer/strings.json | 21 ++++++++++ homeassistant/generated/integrations.json | 2 +- homeassistant/strings.json | 3 +- 12 files changed, 196 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/input_button/strings.json diff --git a/homeassistant/components/counter/strings.json b/homeassistant/components/counter/strings.json index fb7d34edf4..548d155408 100644 --- a/homeassistant/components/counter/strings.json +++ b/homeassistant/components/counter/strings.json @@ -5,7 +5,7 @@ "name": "[%key:component::counter::title%]", "state_attributes": { "editable": { - "name": "UI-managed", + "name": "[%key:common::generic::ui_managed%]", "state": { "true": "[%key:common::state::yes%]", "false": "[%key:common::state::no%]" diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index e78fe982d5..9f50545468 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -169,6 +169,11 @@ "unlocked": "[%key:common::state::unlocked%]", "ok": "[%key:component::binary_sensor::entity_component::problem::state::off%]", "problem": "[%key:component::binary_sensor::entity_component::problem::state::on%]" + }, + "state_attributes": { + "entity_id": { + "name": "Members" + } } } } diff --git a/homeassistant/components/input_boolean/strings.json b/homeassistant/components/input_boolean/strings.json index 8294d72875..d8e1e133f5 100644 --- a/homeassistant/components/input_boolean/strings.json +++ b/homeassistant/components/input_boolean/strings.json @@ -6,6 +6,15 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" + }, + "state_attributes": { + "editable": { + "name": "[%key:common::generic::ui_managed%]", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + } } } } diff --git a/homeassistant/components/input_button/strings.json b/homeassistant/components/input_button/strings.json new file mode 100644 index 0000000000..cfd616fd5e --- /dev/null +++ b/homeassistant/components/input_button/strings.json @@ -0,0 +1,17 @@ +{ + "title": "Input button", + "entity_component": { + "_": { + "name": "[%key:component::input_button::title%]", + "state_attributes": { + "editable": { + "name": "[%key:common::generic::ui_managed%]", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + } + } + } + } +} diff --git a/homeassistant/components/input_datetime/strings.json b/homeassistant/components/input_datetime/strings.json index 8d51025070..0c3a4b0b0d 100644 --- a/homeassistant/components/input_datetime/strings.json +++ b/homeassistant/components/input_datetime/strings.json @@ -1 +1,38 @@ -{ "title": "Input datetime" } +{ + "title": "Input datetime", + "entity_component": { + "_": { + "name": "[%key:component::input_datetime::title%]", + "state_attributes": { + "day": { + "name": "Day" + }, + "editable": { + "name": "[%key:common::generic::ui_managed%]", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "hour": { + "name": "Hour" + }, + "minute": { + "name": "Minute" + }, + "month": { + "name": "Month" + }, + "second": { + "name": "Second" + }, + "timestamp": { + "name": "Timestamp" + }, + "year": { + "name": "Year" + } + } + } + } +} diff --git a/homeassistant/components/input_number/strings.json b/homeassistant/components/input_number/strings.json index 35bbbebbdd..11ed2f8bf1 100644 --- a/homeassistant/components/input_number/strings.json +++ b/homeassistant/components/input_number/strings.json @@ -1 +1,37 @@ -{ "title": "Input number" } +{ + "title": "Input number", + "entity_component": { + "_": { + "name": "[%key:component::input_number::title%]", + "state_attributes": { + "editable": { + "name": "[%key:common::generic::ui_managed%]", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "initial": { + "name": "Initial value" + }, + "max": { + "name": "[%key:component::number::entity_component::_::state_attributes::max::name%]" + }, + "min": { + "name": "[%key:component::number::entity_component::_::state_attributes::min::name%]" + }, + "mode": { + "name": "[%key:component::number::entity_component::_::state_attributes::mode::name%]", + "state": { + "auto": "[%key:component::number::entity_component::_::state_attributes::mode::state::auto%]", + "box": "[%key:component::number::entity_component::_::state_attributes::mode::state::box%]", + "slider": "[%key:component::number::entity_component::_::state_attributes::mode::state::slider%]" + } + }, + "step": { + "name": "[%key:component::number::entity_component::_::state_attributes::step::name%]" + } + } + } + } +} diff --git a/homeassistant/components/input_select/strings.json b/homeassistant/components/input_select/strings.json index c3cd5c0c71..f0dead7a1d 100644 --- a/homeassistant/components/input_select/strings.json +++ b/homeassistant/components/input_select/strings.json @@ -1 +1,20 @@ -{ "title": "Input select" } +{ + "title": "Input select", + "entity_component": { + "_": { + "name": "[%key:component::input_select::title%]", + "state_attributes": { + "editable": { + "name": "[%key:common::generic::ui_managed%]", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "options": { + "name": "[%key:component::select::entity_component::_::state_attributes::options::name%]" + } + } + } + } +} diff --git a/homeassistant/components/input_text/strings.json b/homeassistant/components/input_text/strings.json index dac5995aca..d713c395b6 100644 --- a/homeassistant/components/input_text/strings.json +++ b/homeassistant/components/input_text/strings.json @@ -1 +1,33 @@ -{ "title": "Input text" } +{ + "title": "Input text", + "entity_component": { + "_": { + "name": "[%key:component::input_text::title%]", + "state_attributes": { + "editable": { + "name": "[%key:common::generic::ui_managed%]", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "max": { + "name": "[%key:component::text::entity_component::_::state_attributes::max::name%]" + }, + "min": { + "name": "[%key:component::text::entity_component::_::state_attributes::min::name%]" + }, + "mode": { + "name": "[%key:component::text::entity_component::_::state_attributes::mode::name%]", + "state": { + "text": "[%key:component::text::entity_component::_::state_attributes::mode::state::text%]", + "password": "[%key:component::text::entity_component::_::state_attributes::mode::state::password%]" + } + }, + "pattern": { + "name": "[%key:component::text::entity_component::_::state_attributes::pattern::name%]" + } + } + } + } +} diff --git a/homeassistant/components/schedule/strings.json b/homeassistant/components/schedule/strings.json index f8da366887..4c22e5ecea 100644 --- a/homeassistant/components/schedule/strings.json +++ b/homeassistant/components/schedule/strings.json @@ -6,6 +6,18 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" + }, + "state_attributes": { + "editable": { + "name": "[%key:common::generic::ui_managed%]", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "next_event": { + "name": "Next event" + } } } } diff --git a/homeassistant/components/timer/strings.json b/homeassistant/components/timer/strings.json index b6dd2418ad..217de09a53 100644 --- a/homeassistant/components/timer/strings.json +++ b/homeassistant/components/timer/strings.json @@ -6,6 +6,27 @@ "active": "[%key:common::state::active%]", "idle": "[%key:common::state::idle%]", "paused": "[%key:common::state::paused%]" + }, + "state_attributes": { + "duration": { + "name": "Duration" + }, + "editable": { + "name": "[%key:common::generic::ui_managed%]", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "finishes_at": { + "name": "Finishes at" + }, + "remaining": { + "name": "Remaining" + }, + "restore": { + "name": "Restore" + } } } } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 4001adbd20..1fb801be12 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6502,7 +6502,6 @@ "config_flow": false }, "input_button": { - "name": "Input Button", "integration_type": "helper", "config_flow": false }, @@ -6578,6 +6577,7 @@ "growatt_server", "homekit_controller", "input_boolean", + "input_button", "input_datetime", "input_number", "input_select", diff --git a/homeassistant/strings.json b/homeassistant/strings.json index ad18b675e0..c4cf0593aa 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -1,7 +1,8 @@ { "common": { "generic": { - "model": "Model" + "model": "Model", + "ui_managed": "Managed via UI" }, "state": { "off": "Off", From 182af87f972dc624784a84e24ffd47b582806873 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 27 Mar 2023 22:21:56 +0200 Subject: [PATCH 1322/2810] Refactor matter device entity value conversion (#90368) --- .../components/matter/binary_sensor.py | 36 ++++++++++++------ homeassistant/components/matter/discovery.py | 3 +- homeassistant/components/matter/entity.py | 11 +++++- homeassistant/components/matter/models.py | 11 +----- homeassistant/components/matter/sensor.py | 38 +++++++++++-------- 5 files changed, 60 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index b4d1b867e7..a82614cbcc 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -1,6 +1,8 @@ """Matter binary sensors.""" from __future__ import annotations +from dataclasses import dataclass + from chip.clusters import Objects as clusters from chip.clusters.Objects import uint from chip.clusters.Types import Nullable, NullValue @@ -15,7 +17,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import MatterEntity +from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema @@ -30,9 +32,18 @@ async def async_setup_entry( matter.register_platform_handler(Platform.BINARY_SENSOR, async_add_entities) +@dataclass +class MatterBinarySensorEntityDescription( + BinarySensorEntityDescription, MatterEntityDescription +): + """Describe Matter binary sensor entities.""" + + class MatterBinarySensor(MatterEntity, BinarySensorEntity): """Representation of a Matter binary sensor.""" + entity_description: MatterBinarySensorEntityDescription + @callback def _update_from_device(self) -> None: """Update from device.""" @@ -40,7 +51,7 @@ class MatterBinarySensor(MatterEntity, BinarySensorEntity): value = self.get_matter_attribute_value(self._entity_info.primary_attribute) if value in (None, NullValue): value = None - elif value_convert := self._entity_info.measurement_to_ha: + elif value_convert := self.entity_description.measurement_to_ha: value = value_convert(value) self._attr_is_on = value @@ -51,52 +62,53 @@ DISCOVERY_SCHEMAS = [ # instead of generic occupancy sensor MatterDiscoverySchema( platform=Platform.BINARY_SENSOR, - entity_description=BinarySensorEntityDescription( + entity_description=MatterBinarySensorEntityDescription( key="HueMotionSensor", device_class=BinarySensorDeviceClass.MOTION, name="Motion", + measurement_to_ha=lambda x: (x & 1 == 1) if x is not None else None, ), entity_class=MatterBinarySensor, required_attributes=(clusters.OccupancySensing.Attributes.Occupancy,), vendor_id=(4107,), product_name=("Hue motion sensor",), - measurement_to_ha=lambda x: (x & 1 == 1) if x is not None else None, ), MatterDiscoverySchema( platform=Platform.BINARY_SENSOR, - entity_description=BinarySensorEntityDescription( + entity_description=MatterBinarySensorEntityDescription( key="ContactSensor", device_class=BinarySensorDeviceClass.DOOR, name="Contact", + # value is inverted on matter to what we expect + measurement_to_ha=lambda x: not x, ), entity_class=MatterBinarySensor, required_attributes=(clusters.BooleanState.Attributes.StateValue,), - # value is inverted on matter to what we expect - measurement_to_ha=lambda x: not x, ), MatterDiscoverySchema( platform=Platform.BINARY_SENSOR, - entity_description=BinarySensorEntityDescription( + entity_description=MatterBinarySensorEntityDescription( key="OccupancySensor", device_class=BinarySensorDeviceClass.OCCUPANCY, name="Occupancy", + # The first bit = if occupied + measurement_to_ha=lambda x: (x & 1 == 1) if x is not None else None, ), entity_class=MatterBinarySensor, required_attributes=(clusters.OccupancySensing.Attributes.Occupancy,), - # The first bit = if occupied - measurement_to_ha=lambda x: (x & 1 == 1) if x is not None else None, ), MatterDiscoverySchema( platform=Platform.BINARY_SENSOR, - entity_description=BinarySensorEntityDescription( + entity_description=MatterBinarySensorEntityDescription( key="BatteryChargeLevel", device_class=BinarySensorDeviceClass.BATTERY, name="Battery Status", + measurement_to_ha=lambda x: x + != clusters.PowerSource.Enums.BatChargeLevel.kOk, ), entity_class=MatterBinarySensor, required_attributes=(clusters.PowerSource.Attributes.BatChargeLevel,), # only add binary battery sensor if a regular percentage based is not available absent_attributes=(clusters.PowerSource.Attributes.BatPercentRemaining,), - measurement_to_ha=lambda x: x != clusters.PowerSource.Enums.BatChargeLevel.kOk, ), ] diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index 36f415dacc..9df4484e00 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -23,7 +23,7 @@ DISCOVERY_SCHEMAS: dict[Platform, list[MatterDiscoverySchema]] = { Platform.SENSOR: SENSOR_SCHEMAS, Platform.SWITCH: SWITCH_SCHEMAS, } -SUPPORTED_PLATFORMS = tuple(DISCOVERY_SCHEMAS.keys()) +SUPPORTED_PLATFORMS = tuple(DISCOVERY_SCHEMAS) @callback @@ -109,7 +109,6 @@ def async_discover_entities( attributes_to_watch=attributes_to_watch, entity_description=schema.entity_description, entity_class=schema.entity_class, - measurement_to_ha=schema.measurement_to_ha, ) # prevent re-discovery of the same attributes diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index a1d67158ab..bf0a74ef84 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -3,6 +3,7 @@ from __future__ import annotations from abc import abstractmethod from collections.abc import Callable +from dataclasses import dataclass import logging from typing import TYPE_CHECKING, Any, cast @@ -11,7 +12,7 @@ from matter_server.common.helpers.util import create_attribute_path from matter_server.common.models import EventType, ServerInfoMessage from homeassistant.core import callback -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription from .const import DOMAIN, ID_TYPE_DEVICE_ID from .helpers import get_device_id @@ -25,6 +26,14 @@ if TYPE_CHECKING: LOGGER = logging.getLogger(__name__) +@dataclass +class MatterEntityDescription(EntityDescription): + """Describe the Matter entity.""" + + # convert the value from the primary attribute to the value used by HA + measurement_to_ha: Callable[[Any], Any] | None = None + + class MatterEntity(Entity): """Entity class for Matter devices.""" diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py index eaa9ccf9a0..3ac7f66b83 100644 --- a/homeassistant/components/matter/models.py +++ b/homeassistant/components/matter/models.py @@ -1,9 +1,7 @@ """Models used for the Matter integration.""" from __future__ import annotations -from collections.abc import Callable from dataclasses import dataclass -from typing import Any from chip.clusters import Objects as clusters from chip.clusters.Objects import ClusterAttributeDescriptor @@ -37,9 +35,6 @@ class MatterEntityInfo: # entity class to use to instantiate the entity entity_class: type - # [optional] function to call to convert the value from the primary attribute - measurement_to_ha: Callable[[SensorValueTypes], SensorValueTypes] | None = None - @property def primary_attribute(self) -> type[ClusterAttributeDescriptor]: """Return Primary Attribute belonging to the entity.""" @@ -50,7 +45,8 @@ class MatterEntityInfo: class MatterDiscoverySchema: """Matter discovery schema. - The Matter endpoint and it's (primary) Attribute for an entity must match these conditions. + The Matter endpoint and its (primary) Attribute + for an entity must match these conditions. """ # specify the hass platform for which this scheme applies (e.g. light, sensor) @@ -95,6 +91,3 @@ class MatterDiscoverySchema: # [optional] bool to specify if this primary value may be discovered # by multiple platforms allow_multi: bool = False - - # [optional] function to call to convert the value from the primary attribute - measurement_to_ha: Callable[[Any], Any] | None = None diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 34760fbbf1..84e68695d6 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -1,6 +1,8 @@ """Matter sensors.""" from __future__ import annotations +from dataclasses import dataclass + from chip.clusters import Objects as clusters from chip.clusters.Types import Nullable, NullValue @@ -22,7 +24,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import MatterEntity +from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema @@ -37,10 +39,16 @@ async def async_setup_entry( matter.register_platform_handler(Platform.SENSOR, async_add_entities) +@dataclass +class MatterSensorEntityDescription(SensorEntityDescription, MatterEntityDescription): + """Describe Matter sensor entities.""" + + class MatterSensor(MatterEntity, SensorEntity): """Representation of a Matter sensor.""" _attr_state_class = SensorStateClass.MEASUREMENT + entity_description: MatterSensorEntityDescription @callback def _update_from_device(self) -> None: @@ -49,7 +57,7 @@ class MatterSensor(MatterEntity, SensorEntity): value = self.get_matter_attribute_value(self._entity_info.primary_attribute) if value in (None, NullValue): value = None - elif value_convert := self._entity_info.measurement_to_ha: + elif value_convert := self.entity_description.measurement_to_ha: value = value_convert(value) self._attr_native_value = value @@ -58,77 +66,77 @@ class MatterSensor(MatterEntity, SensorEntity): DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.SENSOR, - entity_description=SensorEntityDescription( + entity_description=MatterSensorEntityDescription( key="TemperatureSensor", name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + measurement_to_ha=lambda x: x / 100, ), entity_class=MatterSensor, required_attributes=(clusters.TemperatureMeasurement.Attributes.MeasuredValue,), - measurement_to_ha=lambda x: x / 100, ), MatterDiscoverySchema( platform=Platform.SENSOR, - entity_description=SensorEntityDescription( + entity_description=MatterSensorEntityDescription( key="PressureSensor", name="Pressure", native_unit_of_measurement=UnitOfPressure.KPA, device_class=SensorDeviceClass.PRESSURE, + measurement_to_ha=lambda x: x / 10, ), entity_class=MatterSensor, required_attributes=(clusters.PressureMeasurement.Attributes.MeasuredValue,), - measurement_to_ha=lambda x: x / 10, ), MatterDiscoverySchema( platform=Platform.SENSOR, - entity_description=SensorEntityDescription( + entity_description=MatterSensorEntityDescription( key="FlowSensor", name="Flow", native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, device_class=SensorDeviceClass.WATER, # what is the device class here ? + measurement_to_ha=lambda x: x / 10, ), entity_class=MatterSensor, required_attributes=(clusters.FlowMeasurement.Attributes.MeasuredValue,), - measurement_to_ha=lambda x: x / 10, ), MatterDiscoverySchema( platform=Platform.SENSOR, - entity_description=SensorEntityDescription( + entity_description=MatterSensorEntityDescription( key="HumiditySensor", name="Humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, + measurement_to_ha=lambda x: x / 100, ), entity_class=MatterSensor, required_attributes=( clusters.RelativeHumidityMeasurement.Attributes.MeasuredValue, ), - measurement_to_ha=lambda x: x / 100, ), MatterDiscoverySchema( platform=Platform.SENSOR, - entity_description=SensorEntityDescription( + entity_description=MatterSensorEntityDescription( key="LightSensor", name="Illuminance", native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, + measurement_to_ha=lambda x: round(pow(10, ((x - 1) / 10000)), 1), ), entity_class=MatterSensor, required_attributes=(clusters.IlluminanceMeasurement.Attributes.MeasuredValue,), - measurement_to_ha=lambda x: round(pow(10, ((x - 1) / 10000)), 1), ), MatterDiscoverySchema( platform=Platform.SENSOR, - entity_description=SensorEntityDescription( + entity_description=MatterSensorEntityDescription( key="PowerSource", name="Battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, + # value has double precision + measurement_to_ha=lambda x: int(x / 2), ), entity_class=MatterSensor, required_attributes=(clusters.PowerSource.Attributes.BatPercentRemaining,), - # value has double precision - measurement_to_ha=lambda x: int(x / 2), ), ] From cb6d384dbafe69b623bedc17940f0932805b0753 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 27 Mar 2023 23:11:49 +0200 Subject: [PATCH 1323/2810] Workday cleanup (#90267) * clean binary sensor * fix const * clean sensor * Fix tests * Clean up --------- Co-authored-by: Martin Hjelmare --- .../components/workday/binary_sensor.py | 66 +-- homeassistant/components/workday/const.py | 2 +- tests/components/workday/__init__.py | 158 ++++++ .../components/workday/test_binary_sensor.py | 460 ++++++------------ 4 files changed, 332 insertions(+), 354 deletions(-) diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index cfd04dd30d..a2e7f1e589 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -92,44 +92,38 @@ def setup_platform( sensor_name: str = config[CONF_NAME] workdays: list[str] = config[CONF_WORKDAYS] - year: int = (get_date(dt.now()) + timedelta(days=days_offset)).year + year: int = (dt.now() + timedelta(days=days_offset)).year obj_holidays: HolidayBase = getattr(holidays, country)(years=year) if province: - if ( - hasattr(obj_holidays, "subdivisions") - and province in obj_holidays.subdivisions - ): + try: obj_holidays = getattr(holidays, country)(subdiv=province, years=year) - else: + except NotImplementedError: LOGGER.error("There is no subdivision %s in country %s", province, country) return # Add custom holidays try: obj_holidays.append(add_holidays) - except TypeError: - LOGGER.debug("No custom holidays or invalid holidays") + except ValueError as error: + LOGGER.error("Could not add custom holidays: %s", error) # Remove holidays - try: - for remove_holiday in remove_holidays: - try: - # is this formatted as a date? - if dt.parse_date(remove_holiday): - # remove holiday by date - removed = obj_holidays.pop(remove_holiday) - LOGGER.debug("Removed %s", remove_holiday) - else: - # remove holiday by name - LOGGER.debug("Treating '%s' as named holiday", remove_holiday) - removed = obj_holidays.pop_named(remove_holiday) - for holiday in removed: - LOGGER.debug("Removed %s by name '%s'", holiday, remove_holiday) - except KeyError as unmatched: - LOGGER.warning("No holiday found matching %s", unmatched) - except TypeError: - LOGGER.debug("No holidays to remove or invalid holidays") + for remove_holiday in remove_holidays: + try: + # is this formatted as a date? + if dt.parse_date(remove_holiday): + # remove holiday by date + removed = obj_holidays.pop(remove_holiday) + LOGGER.debug("Removed %s", remove_holiday) + else: + # remove holiday by name + LOGGER.debug("Treating '%s' as named holiday", remove_holiday) + removed = obj_holidays.pop_named(remove_holiday) + for holiday in removed: + LOGGER.debug("Removed %s by name '%s'", holiday, remove_holiday) + except KeyError as unmatched: + LOGGER.warning("No holiday found matching %s", unmatched) LOGGER.debug("Found the following holidays for your configuration:") for holiday_date, name in sorted(obj_holidays.items()): @@ -143,19 +137,6 @@ def setup_platform( ) -def day_to_string(day: int) -> str | None: - """Convert day index 0 - 7 to string.""" - try: - return ALLOWED_DAYS[day] - except IndexError: - return None - - -def get_date(input_date: date) -> date: - """Return date. Needed for testing.""" - return input_date - - class IsWorkdaySensor(BinarySensorEntity): """Implementation of a Workday sensor.""" @@ -203,12 +184,9 @@ class IsWorkdaySensor(BinarySensorEntity): self._attr_is_on = False # Get ISO day of the week (1 = Monday, 7 = Sunday) - adjusted_date = get_date(dt.now()) + timedelta(days=self._days_offset) + adjusted_date = dt.now() + timedelta(days=self._days_offset) day = adjusted_date.isoweekday() - 1 - day_of_week = day_to_string(day) - - if day_of_week is None: - return + day_of_week = ALLOWED_DAYS[day] if self.is_include(day_of_week, adjusted_date): self._attr_is_on = True diff --git a/homeassistant/components/workday/const.py b/homeassistant/components/workday/const.py index 9ebf85f1c2..810e1de393 100644 --- a/homeassistant/components/workday/const.py +++ b/homeassistant/components/workday/const.py @@ -5,7 +5,7 @@ import logging from homeassistant.const import WEEKDAYS -LOGGER = logging.getLogger(__name__) +LOGGER = logging.getLogger(__package__) ALLOWED_DAYS = WEEKDAYS + ["holiday"] diff --git a/tests/components/workday/__init__.py b/tests/components/workday/__init__.py index 57f437e438..80c8f8d584 100644 --- a/tests/components/workday/__init__.py +++ b/tests/components/workday/__init__.py @@ -1 +1,159 @@ """Tests the Home Assistant workday binary sensor.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.workday.const import ( + DEFAULT_EXCLUDES, + DEFAULT_NAME, + DEFAULT_OFFSET, + DEFAULT_WORKDAYS, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + + +async def init_integration( + hass: HomeAssistant, + config: dict[str, Any], +) -> None: + """Set up the Workday integration in Home Assistant.""" + + await async_setup_component( + hass, "binary_sensor", {"binary_sensor": {"platform": "workday", **config}} + ) + await hass.async_block_till_done() + + +TEST_CONFIG_WITH_PROVINCE = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], +} +TEST_CONFIG_INCORRECT_PROVINCE = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "ZZ", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], +} +TEST_CONFIG_NO_PROVINCE = { + "name": DEFAULT_NAME, + "country": "DE", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], +} +TEST_CONFIG_WITH_STATE = { + "name": DEFAULT_NAME, + "country": "US", + "province": "CA", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], +} +TEST_CONFIG_NO_STATE = { + "name": DEFAULT_NAME, + "country": "US", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], +} +TEST_CONFIG_INCLUDE_HOLIDAY = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": ["sat", "sun"], + "days_offset": DEFAULT_OFFSET, + "workdays": ["holiday"], + "add_holidays": [], + "remove_holidays": [], +} +TEST_CONFIG_EXAMPLE_1 = { + "name": DEFAULT_NAME, + "country": "US", + "excludes": ["sat", "sun"], + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], +} +TEST_CONFIG_EXAMPLE_2 = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": ["mon", "wed", "fri"], + "add_holidays": ["2020-02-24"], + "remove_holidays": [], +} +TEST_CONFIG_REMOVE_HOLIDAY = { + "name": DEFAULT_NAME, + "country": "US", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": ["2020-12-25", "2020-11-26"], +} +TEST_CONFIG_REMOVE_NAMED = { + "name": DEFAULT_NAME, + "country": "US", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": ["Not a Holiday", "Christmas", "Thanksgiving"], +} +TEST_CONFIG_TOMORROW = { + "name": DEFAULT_NAME, + "country": "DE", + "excludes": DEFAULT_EXCLUDES, + "days_offset": 1, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], +} +TEST_CONFIG_DAY_AFTER_TOMORROW = { + "name": DEFAULT_NAME, + "country": "DE", + "excludes": DEFAULT_EXCLUDES, + "days_offset": 2, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], +} +TEST_CONFIG_YESTERDAY = { + "name": DEFAULT_NAME, + "country": "DE", + "excludes": DEFAULT_EXCLUDES, + "days_offset": -1, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], +} +TEST_CONFIG_INCORRECT_ADD_REMOVE = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": ["2023-12-32"], + "remove_holidays": ["2023-12-32"], +} diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py index f0d2d6b068..89c98a0c67 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -1,350 +1,192 @@ """Tests the Home Assistant workday binary sensor.""" -from datetime import date -from unittest.mock import patch +from datetime import datetime +from typing import Any +from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol -import homeassistant.components.workday.binary_sensor as binary_sensor -from homeassistant.setup import setup_component +from homeassistant.components.workday import binary_sensor +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util.dt import UTC -from tests.common import assert_setup_component, get_test_home_assistant - -FUNCTION_PATH = "homeassistant.components.workday.binary_sensor.get_date" +from . import ( + TEST_CONFIG_DAY_AFTER_TOMORROW, + TEST_CONFIG_EXAMPLE_1, + TEST_CONFIG_EXAMPLE_2, + TEST_CONFIG_INCLUDE_HOLIDAY, + TEST_CONFIG_INCORRECT_ADD_REMOVE, + TEST_CONFIG_INCORRECT_PROVINCE, + TEST_CONFIG_NO_PROVINCE, + TEST_CONFIG_NO_STATE, + TEST_CONFIG_REMOVE_HOLIDAY, + TEST_CONFIG_REMOVE_NAMED, + TEST_CONFIG_TOMORROW, + TEST_CONFIG_WITH_PROVINCE, + TEST_CONFIG_WITH_STATE, + TEST_CONFIG_YESTERDAY, + init_integration, +) -class TestWorkdaySetup: - """Test class for workday sensor.""" +async def test_valid_country_yaml() -> None: + """Test valid country from yaml.""" + # Invalid UTF-8, must not contain U+D800 to U+DFFF + with pytest.raises(vol.Invalid): + binary_sensor.valid_country("\ud800") + with pytest.raises(vol.Invalid): + binary_sensor.valid_country("\udfff") + # Country MUST NOT be empty + with pytest.raises(vol.Invalid): + binary_sensor.valid_country("") + # Country must be supported by holidays + with pytest.raises(vol.Invalid): + binary_sensor.valid_country("HomeAssistantLand") - def setup_method(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - # Set valid default config for test - self.config_province = { - "binary_sensor": {"platform": "workday", "country": "DE", "province": "BW"} - } +@pytest.mark.parametrize( + ("config", "expected_state"), + [ + (TEST_CONFIG_WITH_PROVINCE, "off"), + (TEST_CONFIG_NO_PROVINCE, "off"), + (TEST_CONFIG_WITH_STATE, "on"), + (TEST_CONFIG_NO_STATE, "on"), + (TEST_CONFIG_EXAMPLE_1, "on"), + (TEST_CONFIG_EXAMPLE_2, "off"), + (TEST_CONFIG_TOMORROW, "off"), + (TEST_CONFIG_DAY_AFTER_TOMORROW, "off"), + (TEST_CONFIG_YESTERDAY, "on"), + ], +) +async def test_setup( + hass: HomeAssistant, + config: dict[str, Any], + expected_state: str, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setup from various configs.""" + freezer.move_to(datetime(2022, 4, 15, 12, tzinfo=UTC)) # Monday + await init_integration(hass, config) - self.config_noprovince = { - "binary_sensor": {"platform": "workday", "country": "DE"} - } + state = hass.states.get("binary_sensor.workday_sensor") + assert state.state == expected_state + assert state.attributes == { + "friendly_name": "Workday Sensor", + "workdays": config["workdays"], + "excludes": config["excludes"], + "days_offset": config["days_offset"], + } - self.config_invalidprovince = { + +async def test_setup_with_invalid_province_from_yaml(hass: HomeAssistant) -> None: + """Test setup invalid province with import.""" + + await async_setup_component( + hass, + "binary_sensor", + { "binary_sensor": { "platform": "workday", "country": "DE", "province": "invalid", } - } + }, + ) + await hass.async_block_till_done() - self.config_state = { - "binary_sensor": {"platform": "workday", "country": "US", "province": "CA"} - } + state = hass.states.get("binary_sensor.workday_sensor") + assert state is None - self.config_nostate = { - "binary_sensor": {"platform": "workday", "country": "US"} - } - self.config_includeholiday = { - "binary_sensor": { - "platform": "workday", - "country": "DE", - "province": "BW", - "workdays": ["holiday"], - "excludes": ["sat", "sun"], - } - } +async def test_setup_with_working_holiday( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setup from various configs.""" + freezer.move_to(datetime(2017, 1, 6, 12, tzinfo=UTC)) # Friday + await init_integration(hass, TEST_CONFIG_INCLUDE_HOLIDAY) - self.config_example1 = { - "binary_sensor": { - "platform": "workday", - "country": "US", - "workdays": ["mon", "tue", "wed", "thu", "fri"], - "excludes": ["sat", "sun"], - } - } + state = hass.states.get("binary_sensor.workday_sensor") + assert state.state == "on" - self.config_example2 = { - "binary_sensor": { - "platform": "workday", - "country": "DE", - "province": "BW", - "workdays": ["mon", "wed", "fri"], - "excludes": ["sat", "sun", "holiday"], - "add_holidays": ["2020-02-24"], - } - } - self.config_remove_holidays = { - "binary_sensor": { - "platform": "workday", - "country": "US", - "workdays": ["mon", "tue", "wed", "thu", "fri"], - "excludes": ["sat", "sun", "holiday"], - "remove_holidays": ["2020-12-25", "2020-11-26"], - } - } +async def test_setup_add_holiday( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setup from various configs.""" + freezer.move_to(datetime(2020, 2, 24, 12, tzinfo=UTC)) # Monday + await init_integration(hass, TEST_CONFIG_EXAMPLE_2) - self.config_remove_named_holidays = { - "binary_sensor": { - "platform": "workday", - "country": "US", - "workdays": ["mon", "tue", "wed", "thu", "fri"], - "excludes": ["sat", "sun", "holiday"], - "remove_holidays": ["Not a Holiday", "Christmas", "Thanksgiving"], - } - } + state = hass.states.get("binary_sensor.workday_sensor") + assert state.state == "off" - self.config_tomorrow = { - "binary_sensor": {"platform": "workday", "country": "DE", "days_offset": 1} - } - self.config_day_after_tomorrow = { - "binary_sensor": {"platform": "workday", "country": "DE", "days_offset": 2} - } +async def test_setup_remove_holiday( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setup from various configs.""" + freezer.move_to(datetime(2020, 12, 25, 12, tzinfo=UTC)) # Friday + await init_integration(hass, TEST_CONFIG_REMOVE_HOLIDAY) - self.config_yesterday = { - "binary_sensor": {"platform": "workday", "country": "DE", "days_offset": -1} - } + state = hass.states.get("binary_sensor.workday_sensor") + assert state.state == "on" - def teardown_method(self): - """Stop everything that was started.""" - self.hass.stop() - def test_valid_country(self): - """Test topic name/filter validation.""" - # Invalid UTF-8, must not contain U+D800 to U+DFFF - with pytest.raises(vol.Invalid): - binary_sensor.valid_country("\ud800") - with pytest.raises(vol.Invalid): - binary_sensor.valid_country("\udfff") - # Country MUST NOT be empty - with pytest.raises(vol.Invalid): - binary_sensor.valid_country("") - # Country must be supported by holidays - with pytest.raises(vol.Invalid): - binary_sensor.valid_country("HomeAssistantLand") +async def test_setup_remove_holiday_named( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setup from various configs.""" + freezer.move_to(datetime(2020, 12, 25, 12, tzinfo=UTC)) # Friday + await init_integration(hass, TEST_CONFIG_REMOVE_NAMED) - # Valid country code validation must not raise an exception - for country in ("IM", "LI", "US"): - assert binary_sensor.valid_country(country) == country + state = hass.states.get("binary_sensor.workday_sensor") + assert state.state == "on" - def test_setup_component_province(self): - """Set up workday component.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_province) - self.hass.block_till_done() - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity is not None +async def test_setup_day_after_tomorrow( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setup from various configs.""" + freezer.move_to(datetime(2022, 5, 27, 12, tzinfo=UTC)) # Friday + await init_integration(hass, TEST_CONFIG_DAY_AFTER_TOMORROW) - # Freeze time to a workday - Mar 15th, 2017 - @patch(FUNCTION_PATH, return_value=date(2017, 3, 15)) - def test_workday_province(self, mock_date): - """Test if workdays are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_province) - self.hass.block_till_done() + state = hass.states.get("binary_sensor.workday_sensor") + assert state.state == "off" - self.hass.start() - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "on" +async def test_setup_faulty_province( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setup with faulty province.""" + freezer.move_to(datetime(2017, 1, 6, 12, tzinfo=UTC)) # Friday + await init_integration(hass, TEST_CONFIG_INCORRECT_PROVINCE) - # Freeze time to a weekend - Mar 12th, 2017 - @patch(FUNCTION_PATH, return_value=date(2017, 3, 12)) - def test_weekend_province(self, mock_date): - """Test if weekends are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_province) - self.hass.block_till_done() + state = hass.states.get("binary_sensor.workday_sensor") + assert state is None - self.hass.start() + assert "There is no subdivision" in caplog.text - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "off" - # Freeze time to a public holiday in province BW - Jan 6th, 2017 - @patch(FUNCTION_PATH, return_value=date(2017, 1, 6)) - def test_public_holiday_province(self, mock_date): - """Test if public holidays are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_province) - self.hass.block_till_done() +async def test_setup_incorrect_add_remove( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setup with incorrect add/remove custom holiday.""" + freezer.move_to(datetime(2017, 1, 6, 12, tzinfo=UTC)) # Friday + await init_integration(hass, TEST_CONFIG_INCORRECT_ADD_REMOVE) - self.hass.start() + hass.states.get("binary_sensor.workday_sensor") - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "off" - - def test_setup_component_noprovince(self): - """Set up workday component.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_noprovince) - self.hass.block_till_done() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity is not None - - # Freeze time to a public holiday in province BW - Jan 6th, 2017 - @patch(FUNCTION_PATH, return_value=date(2017, 1, 6)) - def test_public_holiday_noprovince(self, mock_date): - """Test if public holidays are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_noprovince) - self.hass.block_till_done() - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "on" - - # Freeze time to a public holiday in state CA - Mar 31st, 2017 - @patch(FUNCTION_PATH, return_value=date(2017, 3, 31)) - def test_public_holiday_state(self, mock_date): - """Test if public holidays are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_state) - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "off" - - # Freeze time to a public holiday in state CA - Mar 31st, 2017 - @patch(FUNCTION_PATH, return_value=date(2017, 3, 31)) - def test_public_holiday_nostate(self, mock_date): - """Test if public holidays are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_nostate) - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "on" - - def test_setup_component_invalidprovince(self): - """Set up workday component.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_invalidprovince) - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity is None - - # Freeze time to a public holiday in province BW - Jan 6th, 2017 - @patch(FUNCTION_PATH, return_value=date(2017, 1, 6)) - def test_public_holiday_includeholiday(self, mock_date): - """Test if public holidays are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_includeholiday) - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "on" - - # Freeze time to a saturday to test offset - Aug 5th, 2017 - @patch(FUNCTION_PATH, return_value=date(2017, 8, 5)) - def test_tomorrow(self, mock_date): - """Test if tomorrow are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_tomorrow) - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "off" - - # Freeze time to a saturday to test offset - Aug 5th, 2017 - @patch(FUNCTION_PATH, return_value=date(2017, 8, 5)) - def test_day_after_tomorrow(self, mock_date): - """Test if the day after tomorrow are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_day_after_tomorrow) - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "on" - - # Freeze time to a saturday to test offset - Aug 5th, 2017 - @patch(FUNCTION_PATH, return_value=date(2017, 8, 5)) - def test_yesterday(self, mock_date): - """Test if yesterday are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_yesterday) - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "on" - - # Freeze time to a Presidents day to test Holiday on a Work day - Jan 20th, 2020 - # Presidents day Feb 17th 2020 is mon. - @patch(FUNCTION_PATH, return_value=date(2020, 2, 17)) - def test_config_example1_holiday(self, mock_date): - """Test if public holidays are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_example1) - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "on" - - # Freeze time to test tue - Feb 18th, 2020 - @patch(FUNCTION_PATH, return_value=date(2020, 2, 18)) - def test_config_example2_tue(self, mock_date): - """Test if public holidays are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_example2) - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "off" - - # Freeze time to test mon, but added as holiday - Feb 24th, 2020 - @patch(FUNCTION_PATH, return_value=date(2020, 2, 24)) - def test_config_example2_add_holiday(self, mock_date): - """Test if public holidays are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_example2) - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "off" - - def test_day_to_string(self): - """Test if day_to_string is behaving correctly.""" - assert binary_sensor.day_to_string(0) == "mon" - assert binary_sensor.day_to_string(1) == "tue" - assert binary_sensor.day_to_string(7) == "holiday" - assert binary_sensor.day_to_string(8) is None - - # Freeze time to test Fri, but remove holiday - December 25, 2020 - @patch(FUNCTION_PATH, return_value=date(2020, 12, 25)) - def test_config_remove_holidays_xmas(self, mock_date): - """Test if removed holidays are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_remove_holidays) - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "on" - - # Freeze time to test Fri, but remove holiday by name - Christmas - @patch(FUNCTION_PATH, return_value=date(2020, 12, 25)) - def test_config_remove_named_holidays_xmas(self, mock_date): - """Test if removed by name holidays are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component( - self.hass, "binary_sensor", self.config_remove_named_holidays - ) - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "on" + assert ( + "Could not add custom holidays: Cannot parse date from string '2023-12-32'" + in caplog.text + ) + assert "No holiday found matching '2023-12-32'" in caplog.text From 058a2c9d83870f81af88632403aa93f9abab676a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Mar 2023 12:41:51 -1000 Subject: [PATCH 1324/2810] Bump yalexs-ble to 2.1.12 (#90381) --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 7884ba6a4b..5528b79353 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.9"] + "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.12"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 5c7adf09e3..6cff0dd8c6 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.1.9"] + "requirements": ["yalexs-ble==2.1.12"] } diff --git a/requirements_all.txt b/requirements_all.txt index 91238f06f2..76444267b4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2668,7 +2668,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.9 +yalexs-ble==2.1.12 # homeassistant.components.august yalexs==1.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f16cbf7c37..451b2353a6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1908,7 +1908,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.9 +yalexs-ble==2.1.12 # homeassistant.components.august yalexs==1.2.7 From 1cd2fe9d28f1cf60c2f7987dce5fbf7344153571 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Mar 2023 12:44:34 -1000 Subject: [PATCH 1325/2810] Bump aiodiscover to 1.4.15 (#90383) --- homeassistant/components/dhcp/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index a5ee449dda..2e1d758746 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["aiodiscover", "dnspython", "pyroute2", "scapy"], "quality_scale": "internal", - "requirements": ["scapy==2.5.0", "aiodiscover==1.4.14"] + "requirements": ["scapy==2.5.0", "aiodiscover==1.4.15"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ae190115b2..518bea69fb 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,7 +1,7 @@ PyJWT==2.6.0 PyNaCl==1.5.0 PyTurboJPEG==1.6.7 -aiodiscover==1.4.14 +aiodiscover==1.4.15 aiohttp==3.8.4 aiohttp_cors==0.7.0 astral==2.2 diff --git a/requirements_all.txt b/requirements_all.txt index 76444267b4..77974c3ad7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -137,7 +137,7 @@ aiobafi6==0.8.0 aiobotocore==2.1.0 # homeassistant.components.dhcp -aiodiscover==1.4.14 +aiodiscover==1.4.15 # homeassistant.components.dnsip # homeassistant.components.minecraft_server diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 451b2353a6..cfb677ed57 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -127,7 +127,7 @@ aiobafi6==0.8.0 aiobotocore==2.1.0 # homeassistant.components.dhcp -aiodiscover==1.4.14 +aiodiscover==1.4.15 # homeassistant.components.dnsip # homeassistant.components.minecraft_server From a361fba8f5859be3e60943c4011a8bdbde26db1b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 28 Mar 2023 00:48:14 +0200 Subject: [PATCH 1326/2810] Bump nextcloudmonitor to 1.4.0 (#90372) --- homeassistant/components/nextcloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nextcloud/manifest.json b/homeassistant/components/nextcloud/manifest.json index 72e992277c..fe4366c334 100644 --- a/homeassistant/components/nextcloud/manifest.json +++ b/homeassistant/components/nextcloud/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nextcloud", "iot_class": "cloud_polling", - "requirements": ["nextcloudmonitor==1.1.0"] + "requirements": ["nextcloudmonitor==1.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 77974c3ad7..c2838240e2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1189,7 +1189,7 @@ neurio==0.3.1 nexia==2.0.6 # homeassistant.components.nextcloud -nextcloudmonitor==1.1.0 +nextcloudmonitor==1.4.0 # homeassistant.components.discord nextcord==2.0.0a8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cfb677ed57..2759d786e8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -888,7 +888,7 @@ nettigo-air-monitor==2.1.0 nexia==2.0.6 # homeassistant.components.nextcloud -nextcloudmonitor==1.1.0 +nextcloudmonitor==1.4.0 # homeassistant.components.discord nextcord==2.0.0a8 From 59113a3e4c368d01a0f047de9fa17d617a7d58b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Mar 2023 12:50:11 -1000 Subject: [PATCH 1327/2810] Bump flux_led to 0.28.36 (#90380) --- homeassistant/components/flux_led/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/flux_led/manifest.json b/homeassistant/components/flux_led/manifest.json index 5bb47fbe79..a9b1ef61db 100644 --- a/homeassistant/components/flux_led/manifest.json +++ b/homeassistant/components/flux_led/manifest.json @@ -51,5 +51,5 @@ "iot_class": "local_push", "loggers": ["flux_led"], "quality_scale": "platinum", - "requirements": ["flux_led==0.28.35"] + "requirements": ["flux_led==0.28.36"] } diff --git a/requirements_all.txt b/requirements_all.txt index c2838240e2..51d948a2de 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -725,7 +725,7 @@ fjaraskupan==2.2.0 flipr-api==1.5.0 # homeassistant.components.flux_led -flux_led==0.28.35 +flux_led==0.28.36 # homeassistant.components.homekit # homeassistant.components.recorder diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2759d786e8..ceafe5d173 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -553,7 +553,7 @@ fjaraskupan==2.2.0 flipr-api==1.5.0 # homeassistant.components.flux_led -flux_led==0.28.35 +flux_led==0.28.36 # homeassistant.components.homekit # homeassistant.components.recorder From 5b4663d2ca729545635db0e4fc29b68ec961b2b7 Mon Sep 17 00:00:00 2001 From: dougiteixeira <31328123+dougiteixeira@users.noreply.github.com> Date: Mon, 27 Mar 2023 20:27:54 -0300 Subject: [PATCH 1328/2810] Bump proxmoxer to 2.0.1 in Proxmox VE (#90378) --- homeassistant/components/proxmoxve/__init__.py | 3 +-- homeassistant/components/proxmoxve/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index f8e350f2b1..7ea4cac58d 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -3,8 +3,7 @@ from __future__ import annotations from datetime import timedelta -from proxmoxer import ProxmoxAPI -from proxmoxer.backends.https import AuthenticationError +from proxmoxer import AuthenticationError, ProxmoxAPI from proxmoxer.core import ResourceException import requests.exceptions from requests.exceptions import ConnectTimeout, SSLError diff --git a/homeassistant/components/proxmoxve/manifest.json b/homeassistant/components/proxmoxve/manifest.json index 1c68069579..8cf3bc7932 100644 --- a/homeassistant/components/proxmoxve/manifest.json +++ b/homeassistant/components/proxmoxve/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/proxmoxve", "iot_class": "local_polling", "loggers": ["proxmoxer"], - "requirements": ["proxmoxer==1.3.1"] + "requirements": ["proxmoxer==2.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 51d948a2de..e0df5121a0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1397,7 +1397,7 @@ proliphix==0.4.1 prometheus_client==0.7.1 # homeassistant.components.proxmoxve -proxmoxer==1.3.1 +proxmoxer==2.0.1 # homeassistant.components.hardware psutil-home-assistant==0.0.1 From e27d3c952396b52c5afe714f8379df2bf065d052 Mon Sep 17 00:00:00 2001 From: Renat Sibgatulin Date: Tue, 28 Mar 2023 06:18:47 +0000 Subject: [PATCH 1329/2810] Improve airq handling of DeviceInfo (#90232) * Reduce data sharing between ConfigFlow and DataUpdateCoordinator Instead of fetching device information from the device once in `ConfigFlow` and then piping it through in `ConfigEntry.data`, only use as much as needed in `ConfigFlow.async_step_user`, then fetch again in `AirQCoordinator._async_update_data` if a key is missing. Additionally, factor `AirQCoordinator` out into a sumbodule. Add a simple test for `AirQCoordinator.device_info` update. Positive side effect: `AirQCoordinator.device_info` is updated explicitly, instead of dumping the entire content of (a fully compatible) `TypedDict`, retrieved from `aioairq`. * Remove tests ill-suited to this PR `test_config_flow.test_duplicate_error` slipped through by mistake, while `test_coordinator.test_fetch_device_info_on_first_update` may need a more thoroughly suite of accompanying tests * Ignore airq/coordinator.py ...newly separated from airq/__init__.py, that's already in this list * Reorder files alphabetically --- .coveragerc | 1 + homeassistant/components/airq/__init__.py | 48 +-------------- homeassistant/components/airq/config_flow.py | 5 +- homeassistant/components/airq/coordinator.py | 61 ++++++++++++++++++++ tests/components/airq/test_config_flow.py | 5 +- 5 files changed, 68 insertions(+), 52 deletions(-) create mode 100644 homeassistant/components/airq/coordinator.py diff --git a/.coveragerc b/.coveragerc index da7cc42ba1..520b87b08b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -36,6 +36,7 @@ omit = homeassistant/components/airnow/__init__.py homeassistant/components/airnow/sensor.py homeassistant/components/airq/__init__.py + homeassistant/components/airq/coordinator.py homeassistant/components/airq/sensor.py homeassistant/components/airthings/__init__.py homeassistant/components/airthings/sensor.py diff --git a/homeassistant/components/airq/__init__.py b/homeassistant/components/airq/__init__.py index 4bc64e1e82..06d7ba3074 100644 --- a/homeassistant/components/airq/__init__.py +++ b/homeassistant/components/airq/__init__.py @@ -1,58 +1,16 @@ """The air-Q integration.""" from __future__ import annotations -from datetime import timedelta -import logging - -from aioairq import AirQ - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN, MANUFACTURER, TARGET_ROUTE, UPDATE_INTERVAL - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN +from .coordinator import AirQCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -class AirQCoordinator(DataUpdateCoordinator): - """Coordinator is responsible for querying the device at a specified route.""" - - def __init__( - self, - hass: HomeAssistant, - entry: ConfigEntry, - ) -> None: - """Initialise a custom coordinator.""" - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=timedelta(seconds=UPDATE_INTERVAL), - ) - session = async_get_clientsession(hass) - self.airq = AirQ( - entry.data[CONF_IP_ADDRESS], entry.data[CONF_PASSWORD], session - ) - self.device_id = entry.unique_id - assert self.device_id is not None - self.device_info = DeviceInfo( - manufacturer=MANUFACTURER, - identifiers={(DOMAIN, self.device_id)}, - ) - self.device_info.update(entry.data["device_info"]) - - async def _async_update_data(self) -> dict: - """Fetch the data from the device.""" - data = await self.airq.get(TARGET_ROUTE) - return self.airq.drop_uncertainties_from_data(data) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up air-Q from a config entry.""" diff --git a/homeassistant/components/airq/config_flow.py b/homeassistant/components/airq/config_flow.py index 90a6b9e055..41eda912e9 100644 --- a/homeassistant/components/airq/config_flow.py +++ b/homeassistant/components/airq/config_flow.py @@ -74,12 +74,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) device_info = await airq.fetch_device_info() - await self.async_set_unique_id(device_info.pop("id")) + await self.async_set_unique_id(device_info["id"]) self._abort_if_unique_id_configured() return self.async_create_entry( - title=device_info["name"], - data=user_input | {"device_info": device_info}, + title=device_info["name"], data=user_input ) return self.async_show_form( diff --git a/homeassistant/components/airq/coordinator.py b/homeassistant/components/airq/coordinator.py new file mode 100644 index 0000000000..78e9580c63 --- /dev/null +++ b/homeassistant/components/airq/coordinator.py @@ -0,0 +1,61 @@ +"""The air-Q integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from aioairq import AirQ + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, MANUFACTURER, TARGET_ROUTE, UPDATE_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class AirQCoordinator(DataUpdateCoordinator): + """Coordinator is responsible for querying the device at a specified route.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + ) -> None: + """Initialise a custom coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=UPDATE_INTERVAL), + ) + session = async_get_clientsession(hass) + self.airq = AirQ( + entry.data[CONF_IP_ADDRESS], entry.data[CONF_PASSWORD], session + ) + self.device_id = entry.unique_id + assert self.device_id is not None + self.device_info = DeviceInfo( + manufacturer=MANUFACTURER, + identifiers={(DOMAIN, self.device_id)}, + ) + + async def _async_update_data(self) -> dict: + """Fetch the data from the device.""" + if "name" not in self.device_info: + info = await self.airq.fetch_device_info() + self.device_info.update( + DeviceInfo( + name=info["name"], + model=info["model"], + sw_version=info["sw_version"], + hw_version=info["hw_version"], + ) + ) + + data = await self.airq.get(TARGET_ROUTE) + return self.airq.drop_uncertainties_from_data(data) diff --git a/tests/components/airq/test_config_flow.py b/tests/components/airq/test_config_flow.py index 52bd5cd37f..af71dc813e 100644 --- a/tests/components/airq/test_config_flow.py +++ b/tests/components/airq/test_config_flow.py @@ -24,9 +24,6 @@ TEST_DEVICE_INFO = DeviceInfo( sw_version="sw", hw_version="hw", ) -TEST_DATA_OUT = TEST_USER_DATA | { - "device_info": {k: v for k, v in TEST_DEVICE_INFO.items() if k != "id"} -} async def test_form(hass: HomeAssistant) -> None: @@ -48,7 +45,7 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["type"] == FlowResultType.CREATE_ENTRY assert result2["title"] == TEST_DEVICE_INFO["name"] - assert result2["data"] == TEST_DATA_OUT + assert result2["data"] == TEST_USER_DATA async def test_form_invalid_auth(hass: HomeAssistant) -> None: From 3dd3cb195fe64ecda642d6a811ce8a9d5a832108 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Mon, 27 Mar 2023 19:23:53 -1100 Subject: [PATCH 1330/2810] Set default value for some Fronius entities (#89475) --- .../components/fronius/coordinator.py | 4 +- homeassistant/components/fronius/sensor.py | 199 ++++++++++-------- tests/components/fronius/test_sensor.py | 26 ++- 3 files changed, 137 insertions(+), 92 deletions(-) diff --git a/homeassistant/components/fronius/coordinator.py b/homeassistant/components/fronius/coordinator.py index 16e55f1272..94fd5f256a 100644 --- a/homeassistant/components/fronius/coordinator.py +++ b/homeassistant/components/fronius/coordinator.py @@ -7,7 +7,6 @@ from typing import TYPE_CHECKING, Any, TypeVar from pyfronius import BadStatusError, FroniusError -from homeassistant.components.sensor import SensorEntityDescription from homeassistant.core import callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -25,6 +24,7 @@ from .sensor import ( OHMPILOT_ENTITY_DESCRIPTIONS, POWER_FLOW_ENTITY_DESCRIPTIONS, STORAGE_ENTITY_DESCRIPTIONS, + FroniusSensorEntityDescription, ) if TYPE_CHECKING: @@ -41,7 +41,7 @@ class FroniusCoordinatorBase( default_interval: timedelta error_interval: timedelta - valid_descriptions: list[SensorEntityDescription] + valid_descriptions: list[FroniusSensorEntityDescription] MAX_FAILED_UPDATES = 3 diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index 8c7055db8b..e7f9389537 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -1,6 +1,7 @@ """Support for Fronius devices.""" from __future__ import annotations +from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Final from homeassistant.components.sensor import ( @@ -25,6 +26,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -77,113 +79,128 @@ async def async_setup_entry( ) -INVERTER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ - SensorEntityDescription( +@dataclass +class FroniusSensorEntityDescription(SensorEntityDescription): + """Describes Fronius sensor entity.""" + + default_value: StateType | None = None + + +INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ + FroniusSensorEntityDescription( key="energy_day", name="Energy day", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_year", name="Energy year", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_total", name="Energy total", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="frequency_ac", name="Frequency AC", + default_value=0, native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="current_ac", name="Current AC", + default_value=0, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="current_dc", name="Current DC", + default_value=0, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, icon="mdi:current-dc", ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="current_dc_2", name="Current DC 2", + default_value=0, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, icon="mdi:current-dc", ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_ac", name="Power AC", + default_value=0, native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_ac", name="Voltage AC", + default_value=0, native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_dc", name="Voltage DC", + default_value=0, native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:current-dc", ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_dc_2", name="Voltage DC 2", + default_value=0, native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:current-dc", ), # device status entities - SensorEntityDescription( + FroniusSensorEntityDescription( key="inverter_state", name="Inverter state", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="error_code", name="Error code", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="status_code", name="Status code", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="led_state", name="LED state", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="led_color", name="LED color", entity_category=EntityCategory.DIAGNOSTIC, @@ -191,20 +208,20 @@ INVERTER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ ), ] -LOGGER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ - SensorEntityDescription( +LOGGER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ + FroniusSensorEntityDescription( key="co2_factor", name="CO₂ factor", state_class=SensorStateClass.MEASUREMENT, icon="mdi:molecule-co2", ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="cash_factor", name="Grid export tariff", state_class=SensorStateClass.MEASUREMENT, icon="mdi:cash-plus", ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="delivery_factor", name="Grid import tariff", state_class=SensorStateClass.MEASUREMENT, @@ -212,8 +229,8 @@ LOGGER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ ), ] -METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ - SensorEntityDescription( +METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ + FroniusSensorEntityDescription( key="current_ac_phase_1", name="Current AC phase 1", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -221,7 +238,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="current_ac_phase_2", name="Current AC phase 2", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -229,7 +246,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="current_ac_phase_3", name="Current AC phase 3", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -237,7 +254,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_reactive_ac_consumed", name="Energy reactive AC consumed", native_unit_of_measurement=ENERGY_VOLT_AMPERE_REACTIVE_HOUR, @@ -245,7 +262,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:lightning-bolt-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_reactive_ac_produced", name="Energy reactive AC produced", native_unit_of_measurement=ENERGY_VOLT_AMPERE_REACTIVE_HOUR, @@ -253,7 +270,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:lightning-bolt-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_real_ac_minus", name="Energy real AC minus", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -261,7 +278,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_real_ac_plus", name="Energy real AC plus", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -269,33 +286,33 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_real_consumed", name="Energy real consumed", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_real_produced", name="Energy real produced", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="frequency_phase_average", name="Frequency phase average", native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="meter_location", name="Meter location", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_apparent_phase_1", name="Power apparent phase 1", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, @@ -304,7 +321,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:flash-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_apparent_phase_2", name="Power apparent phase 2", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, @@ -313,7 +330,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:flash-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_apparent_phase_3", name="Power apparent phase 3", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, @@ -322,7 +339,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:flash-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_apparent", name="Power apparent", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, @@ -331,34 +348,34 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:flash-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_factor_phase_1", name="Power factor phase 1", device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_factor_phase_2", name="Power factor phase 2", device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_factor_phase_3", name="Power factor phase 3", device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_factor", name="Power factor", device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_reactive_phase_1", name="Power reactive phase 1", native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, @@ -367,7 +384,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:flash-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_reactive_phase_2", name="Power reactive phase 2", native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, @@ -376,7 +393,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:flash-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_reactive_phase_3", name="Power reactive phase 3", native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, @@ -385,7 +402,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:flash-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_reactive", name="Power reactive", native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, @@ -394,7 +411,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:flash-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_real_phase_1", name="Power real phase 1", native_unit_of_measurement=UnitOfPower.WATT, @@ -402,7 +419,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_real_phase_2", name="Power real phase 2", native_unit_of_measurement=UnitOfPower.WATT, @@ -410,7 +427,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_real_phase_3", name="Power real phase 3", native_unit_of_measurement=UnitOfPower.WATT, @@ -418,14 +435,14 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_real", name="Power real", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_ac_phase_1", name="Voltage AC phase 1", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -433,7 +450,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_ac_phase_2", name="Voltage AC phase 2", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -441,7 +458,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_ac_phase_3", name="Voltage AC phase 3", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -449,7 +466,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_ac_phase_to_phase_12", name="Voltage AC phase 1-2", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -457,7 +474,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_ac_phase_to_phase_23", name="Voltage AC phase 2-3", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -465,7 +482,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_ac_phase_to_phase_31", name="Voltage AC phase 3-1", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -475,47 +492,47 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ ), ] -OHMPILOT_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ - SensorEntityDescription( +OHMPILOT_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ + FroniusSensorEntityDescription( key="energy_real_ac_consumed", name="Energy consumed", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_real_ac", name="Power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="temperature_channel_1", name="Temperature channel 1", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="error_code", name="Error code", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="state_code", name="State code", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="state_message", name="State message", entity_category=EntityCategory.DIAGNOSTIC, ), ] -POWER_FLOW_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ - SensorEntityDescription( +POWER_FLOW_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ + FroniusSensorEntityDescription( key="energy_day", name="Energy day", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -523,7 +540,7 @@ POWER_FLOW_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_year", name="Energy year", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -531,7 +548,7 @@ POWER_FLOW_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_total", name="Energy total", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -539,69 +556,75 @@ POWER_FLOW_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="meter_mode", name="Meter mode", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_battery", name="Power battery", + default_value=0, native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_grid", name="Power grid", + default_value=0, native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_load", name="Power load", + default_value=0, native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_photovoltaics", name="Power photovoltaics", + default_value=0, native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="relative_autonomy", name="Relative autonomy", + default_value=0, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:home-circle-outline", ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="relative_self_consumption", name="Relative self consumption", + default_value=0, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:solar-power", ), ] -STORAGE_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ - SensorEntityDescription( +STORAGE_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ + FroniusSensorEntityDescription( key="capacity_maximum", name="Capacity maximum", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="capacity_designed", name="Capacity designed", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="current_dc", name="Current DC", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -609,7 +632,7 @@ STORAGE_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, icon="mdi:current-dc", ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_dc", name="Voltage DC", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -617,7 +640,7 @@ STORAGE_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, icon="mdi:current-dc", ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_dc_maximum_cell", name="Voltage DC maximum cell", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -626,7 +649,7 @@ STORAGE_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:current-dc", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_dc_minimum_cell", name="Voltage DC minimum cell", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -635,14 +658,14 @@ STORAGE_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:current-dc", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="state_of_charge", name="State of charge", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="temperature_cell", name="Temperature cell", native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -655,7 +678,8 @@ STORAGE_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEntity): """Defines a Fronius coordinator entity.""" - entity_descriptions: list[SensorEntityDescription] + entity_description: FroniusSensorEntityDescription + entity_descriptions: list[FroniusSensorEntityDescription] _attr_has_entity_name = True @@ -682,7 +706,11 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn new_value = self.coordinator.data[self.solar_net_id][ self.entity_description.key ]["value"] - return round(new_value, 4) if isinstance(new_value, float) else new_value + if new_value is None: + return self.entity_description.default_value + if isinstance(new_value, float): + return round(new_value, 4) + return new_value @callback def _handle_coordinator_update(self) -> None: @@ -690,7 +718,8 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn try: self._attr_native_value = self._get_entity_value() except KeyError: - return + # sets state to `None` if no default_value is defined in entity description + self._attr_native_value = self.entity_description.default_value self.async_write_ha_state() diff --git a/tests/components/fronius/test_sensor.py b/tests/components/fronius/test_sensor.py index 6f7b779388..ef881b552f 100644 --- a/tests/components/fronius/test_sensor.py +++ b/tests/components/fronius/test_sensor.py @@ -62,16 +62,16 @@ async def test_symo_inverter( assert_state("sensor.symo_20_power_ac", 1190) assert_state("sensor.symo_20_voltage_ac", 227.90) - # Third test at nighttime - additional AC entities aren't changed + # Third test at nighttime - additional AC entities default to 0 mock_responses(aioclient_mock, night=True) async_fire_time_changed( hass, dt.utcnow() + FroniusInverterUpdateCoordinator.default_interval ) await hass.async_block_till_done() - assert_state("sensor.symo_20_current_ac", 5.19) - assert_state("sensor.symo_20_frequency_ac", 49.94) - assert_state("sensor.symo_20_power_ac", 1190) - assert_state("sensor.symo_20_voltage_ac", 227.90) + assert_state("sensor.symo_20_current_ac", 0) + assert_state("sensor.symo_20_frequency_ac", 0) + assert_state("sensor.symo_20_power_ac", 0) + assert_state("sensor.symo_20_voltage_ac", 0) async def test_symo_logger( @@ -190,6 +190,22 @@ async def test_symo_power_flow( assert_state("sensor.solarnet_relative_autonomy", 39.4708) assert_state("sensor.solarnet_relative_self_consumption", 100) + # Third test at nighttime - default values are used + mock_responses(aioclient_mock, night=True) + async_fire_time_changed( + hass, dt.utcnow() + FroniusPowerFlowUpdateCoordinator.default_interval + ) + await hass.async_block_till_done() + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 54 + assert_state("sensor.solarnet_energy_day", 10828) + assert_state("sensor.solarnet_energy_total", 44186900) + assert_state("sensor.solarnet_energy_year", 25507686) + assert_state("sensor.solarnet_power_grid", 975.31) + assert_state("sensor.solarnet_power_load", -975.31) + assert_state("sensor.solarnet_power_photovoltaics", 0) + assert_state("sensor.solarnet_relative_autonomy", 0) + assert_state("sensor.solarnet_relative_self_consumption", 0) + async def test_gen24(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: """Test Fronius Gen24 inverter entities.""" From dc37d921972e444a7fcb88441dabd0d7947a19b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Mar 2023 20:34:56 -1000 Subject: [PATCH 1331/2810] Add lru stats to the profiler integration (#90388) --- homeassistant/components/profiler/__init__.py | 72 ++++++++++++++++++- .../components/profiler/services.yaml | 3 + tests/components/profiler/test_init.py | 32 +++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index fab6932edd..b838f67d02 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -1,6 +1,8 @@ """The profiler integration.""" import asyncio +from contextlib import suppress from datetime import timedelta +from functools import _lru_cache_wrapper import logging import reprlib import sys @@ -9,6 +11,7 @@ import time import traceback from typing import Any, cast +from lru import LRU # pylint: disable=no-name-in-module import voluptuous as vol from homeassistant.components import persistent_notification @@ -27,9 +30,21 @@ SERVICE_MEMORY = "memory" SERVICE_START_LOG_OBJECTS = "start_log_objects" SERVICE_STOP_LOG_OBJECTS = "stop_log_objects" SERVICE_DUMP_LOG_OBJECTS = "dump_log_objects" +SERVICE_LRU_STATS = "lru_stats" SERVICE_LOG_THREAD_FRAMES = "log_thread_frames" SERVICE_LOG_EVENT_LOOP_SCHEDULED = "log_event_loop_scheduled" +_LRU_CACHE_WRAPPER_OBJECT = _lru_cache_wrapper.__name__ + +_KNOWN_LRU_CLASSES = ( + "EventDataManager", + "EventTypeManager", + "StatesMetaManager", + "StateAttributesManager", + "StatisticsMetaManager", + "DomainData", + "IntegrationMatcher", +) SERVICES = ( SERVICE_START, @@ -37,6 +52,7 @@ SERVICES = ( SERVICE_START_LOG_OBJECTS, SERVICE_STOP_LOG_OBJECTS, SERVICE_DUMP_LOG_OBJECTS, + SERVICE_LRU_STATS, SERVICE_LOG_THREAD_FRAMES, SERVICE_LOG_EVENT_LOOP_SCHEDULED, ) @@ -47,6 +63,7 @@ CONF_SECONDS = "seconds" LOG_INTERVAL_SUB = "log_interval_subscription" + _LOGGER = logging.getLogger(__name__) @@ -123,6 +140,52 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: notification_id="profile_object_dump", ) + def _get_function_absfile(func: Any) -> str: + """Get the absolute file path of a function.""" + import inspect # pylint: disable=import-outside-toplevel + + abs_file = "unknown" + with suppress(Exception): + abs_file = inspect.getabsfile(func) + return abs_file + + def _lru_stats(call: ServiceCall) -> None: + """Log the stats of all lru caches.""" + # Imports deferred to avoid loading modules + # in memory since usually only one part of this + # integration is used at a time + import objgraph # pylint: disable=import-outside-toplevel + + for lru in objgraph.by_type(_LRU_CACHE_WRAPPER_OBJECT): + lru = cast(_lru_cache_wrapper, lru) + _LOGGER.critical( + "Cache stats for lru_cache %s at %s: %s", + lru.__wrapped__, + _get_function_absfile(lru.__wrapped__), + lru.cache_info(), + ) + + for _class in _KNOWN_LRU_CLASSES: + for class_with_lru_attr in objgraph.by_type(_class): + for maybe_lru in class_with_lru_attr.__dict__.values(): + if isinstance(maybe_lru, LRU): + _LOGGER.critical( + "Cache stats for LRU %s at %s: %s", + type(class_with_lru_attr), + _get_function_absfile(class_with_lru_attr), + maybe_lru.get_stats(), + ) + + persistent_notification.create( + hass, + ( + "LRU cache states have been dumped to the log. See [the" + " logs](/config/logs) to review the stats." + ), + title="LRU stats completed", + notification_id="profile_lru_stats", + ) + async def _async_dump_thread_frames(call: ServiceCall) -> None: """Log all thread frames.""" frames = sys._current_frames() # pylint: disable=protected-access @@ -202,6 +265,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: schema=vol.Schema({vol.Required(CONF_TYPE): str}), ) + async_register_admin_service( + hass, + DOMAIN, + SERVICE_LRU_STATS, + _lru_stats, + ) + async_register_admin_service( hass, DOMAIN, @@ -323,4 +393,4 @@ def _log_objects(*_): # integration is used at a time import objgraph # pylint: disable=import-outside-toplevel - _LOGGER.critical("Memory Growth: %s", objgraph.growth(limit=100)) + _LOGGER.critical("Memory Growth: %s", objgraph.growth(limit=1000)) diff --git a/homeassistant/components/profiler/services.yaml b/homeassistant/components/profiler/services.yaml index 8d9ae35ed1..1105842891 100644 --- a/homeassistant/components/profiler/services.yaml +++ b/homeassistant/components/profiler/services.yaml @@ -51,6 +51,9 @@ dump_log_objects: example: State selector: text: +lru_stats: + name: Log LRU stats + description: Log the stats of all lru caches. log_thread_frames: name: Log thread frames description: Log the current frames for all threads. diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index 0f46f306fe..2c283463b6 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -1,9 +1,11 @@ """Test the Profiler config flow.""" from datetime import timedelta +from functools import lru_cache import os import sys from unittest.mock import patch +from lru import LRU # pylint: disable=no-name-in-module import py import pytest @@ -12,6 +14,7 @@ from homeassistant.components.profiler import ( SERVICE_DUMP_LOG_OBJECTS, SERVICE_LOG_EVENT_LOOP_SCHEDULED, SERVICE_LOG_THREAD_FRAMES, + SERVICE_LRU_STATS, SERVICE_MEMORY, SERVICE_START, SERVICE_START_LOG_OBJECTS, @@ -228,3 +231,32 @@ async def test_log_scheduled( assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +async def test_lru_stats(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: + """Test logging lru stats.""" + + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + @lru_cache(maxsize=1) + def _dummy_test_lru_stats(): + return 1 + + class DomainData: + def __init__(self): + self._data = LRU(1) + + domain_data = DomainData() + assert hass.services.has_service(DOMAIN, SERVICE_LRU_STATS) + + await hass.services.async_call(DOMAIN, SERVICE_LRU_STATS, blocking=True) + + assert "DomainData" in caplog.text + assert "(0, 0)" in caplog.text + assert "_dummy_test_lru_stats" in caplog.text + assert "CacheInfo" in caplog.text + del domain_data From 33fef5592fba45117bf6555385a20d5ed58cc56b Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 28 Mar 2023 08:36:42 +0200 Subject: [PATCH 1332/2810] Refactor GIOS sensor platform (#89389) --- homeassistant/components/gios/const.py | 3 - homeassistant/components/gios/sensor.py | 143 ++++++---- homeassistant/components/gios/strings.json | 50 ++++ tests/components/gios/test_sensor.py | 314 ++++++++++----------- 4 files changed, 300 insertions(+), 210 deletions(-) diff --git a/homeassistant/components/gios/const.py b/homeassistant/components/gios/const.py index 895775495f..33ddfae6fe 100644 --- a/homeassistant/components/gios/const.py +++ b/homeassistant/components/gios/const.py @@ -16,9 +16,6 @@ URL = "http://powietrze.gios.gov.pl/pjp/current/station_details/info/{station_id API_TIMEOUT: Final = 30 -ATTR_INDEX: Final = "index" -ATTR_STATION: Final = "station" - ATTR_C6H6: Final = "c6h6" ATTR_CO: Final = "co" ATTR_NO2: Final = "no2" diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index 9c73b35889..7cf4b7e7c6 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -4,7 +4,8 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Any, cast + +from gios.model import GiosSensors from homeassistant.components.sensor import ( DOMAIN as PLATFORM, @@ -14,11 +15,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_NAME, - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - CONF_NAME, -) +from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType @@ -32,13 +29,11 @@ from .const import ( ATTR_AQI, ATTR_C6H6, ATTR_CO, - ATTR_INDEX, ATTR_NO2, ATTR_O3, ATTR_PM10, ATTR_PM25, ATTR_SO2, - ATTR_STATION, ATTRIBUTION, DOMAIN, MANUFACTURER, @@ -49,17 +44,24 @@ _LOGGER = logging.getLogger(__name__) @dataclass -class GiosSensorEntityDescription(SensorEntityDescription): +class GiosSensorRequiredKeysMixin: + """Class for GIOS entity required keys.""" + + value: Callable[[GiosSensors], StateType] + + +@dataclass +class GiosSensorEntityDescription(SensorEntityDescription, GiosSensorRequiredKeysMixin): """Class describing GIOS sensor entities.""" - value: Callable | None = round + subkey: str | None = None SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( GiosSensorEntityDescription( key=ATTR_AQI, name="AQI", - value=None, + value=lambda sensors: sensors.aqi.value if sensors.aqi else None, icon="mdi:air-filter", device_class=SensorDeviceClass.ENUM, options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], @@ -68,6 +70,8 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( GiosSensorEntityDescription( key=ATTR_C6H6, name="C6H6", + value=lambda sensors: sensors.c6h6.value if sensors.c6h6 else None, + suggested_display_precision=0, icon="mdi:molecule", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -75,44 +79,107 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( GiosSensorEntityDescription( key=ATTR_CO, name="CO", + value=lambda sensors: sensors.co.value if sensors.co else None, + suggested_display_precision=0, + icon="mdi:molecule", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ), GiosSensorEntityDescription( key=ATTR_NO2, name="NO2", + value=lambda sensors: sensors.no2.value if sensors.no2 else None, + suggested_display_precision=0, device_class=SensorDeviceClass.NITROGEN_DIOXIDE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ), + GiosSensorEntityDescription( + key=ATTR_NO2, + subkey="index", + name="NO2 index", + value=lambda sensors: sensors.no2.index if sensors.no2 else None, + icon="mdi:molecule", + device_class=SensorDeviceClass.ENUM, + options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], + translation_key="no2_index", + ), GiosSensorEntityDescription( key=ATTR_O3, name="O3", + value=lambda sensors: sensors.o3.value if sensors.o3 else None, + suggested_display_precision=0, device_class=SensorDeviceClass.OZONE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ), + GiosSensorEntityDescription( + key=ATTR_O3, + subkey="index", + name="O3 index", + value=lambda sensors: sensors.o3.index if sensors.o3 else None, + icon="mdi:molecule", + device_class=SensorDeviceClass.ENUM, + options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], + translation_key="o3_index", + ), GiosSensorEntityDescription( key=ATTR_PM10, name="PM10", + value=lambda sensors: sensors.pm10.value if sensors.pm10 else None, + suggested_display_precision=0, device_class=SensorDeviceClass.PM10, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ), + GiosSensorEntityDescription( + key=ATTR_PM10, + subkey="index", + name="PM10 index", + value=lambda sensors: sensors.pm10.index if sensors.pm10 else None, + icon="mdi:molecule", + device_class=SensorDeviceClass.ENUM, + options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], + translation_key="pm10_index", + ), GiosSensorEntityDescription( key=ATTR_PM25, name="PM2.5", + value=lambda sensors: sensors.pm25.value if sensors.pm25 else None, + suggested_display_precision=0, device_class=SensorDeviceClass.PM25, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ), + GiosSensorEntityDescription( + key=ATTR_PM25, + subkey="index", + name="PM2.5 index", + value=lambda sensors: sensors.pm25.index if sensors.pm25 else None, + icon="mdi:molecule", + device_class=SensorDeviceClass.ENUM, + options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], + translation_key="pm25_index", + ), GiosSensorEntityDescription( key=ATTR_SO2, name="SO2", + value=lambda sensors: sensors.so2.value if sensors.so2 else None, + suggested_display_precision=0, device_class=SensorDeviceClass.SULPHUR_DIOXIDE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ), + GiosSensorEntityDescription( + key=ATTR_SO2, + subkey="index", + name="SO2 index", + value=lambda sensors: sensors.so2.index if sensors.so2 else None, + icon="mdi:molecule", + device_class=SensorDeviceClass.ENUM, + options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], + translation_key="so2_index", + ), ) @@ -140,15 +207,13 @@ async def async_setup_entry( ) entity_registry.async_update_entity(entity_id, new_unique_id=new_unique_id) - sensors: list[GiosSensor | GiosAqiSensor] = [] + sensors: list[GiosSensor] = [] for description in SENSOR_TYPES: if getattr(coordinator.data, description.key) is None: continue - if description.key == ATTR_AQI: - sensors.append(GiosAqiSensor(name, coordinator, description)) - else: - sensors.append(GiosSensor(name, coordinator, description)) + sensors.append(GiosSensor(name, coordinator, description)) + async_add_entities(sensors) @@ -174,45 +239,27 @@ class GiosSensor(CoordinatorEntity[GiosDataUpdateCoordinator], SensorEntity): name=name, configuration_url=URL.format(station_id=coordinator.gios.station_id), ) - self._attr_unique_id = f"{coordinator.gios.station_id}-{description.key}" - self._attrs: dict[str, Any] = { - ATTR_STATION: self.coordinator.gios.station_name, - } + if description.subkey: + self._attr_unique_id = ( + f"{coordinator.gios.station_id}-{description.key}-{description.subkey}" + ) + else: + self._attr_unique_id = f"{coordinator.gios.station_id}-{description.key}" self.entity_description = description - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the state attributes.""" - self._attrs[ATTR_NAME] = getattr( - self.coordinator.data, self.entity_description.key - ).name - self._attrs[ATTR_INDEX] = getattr( - self.coordinator.data, self.entity_description.key - ).index - return self._attrs - @property def native_value(self) -> StateType: """Return the state.""" - state = getattr(self.coordinator.data, self.entity_description.key).value - assert self.entity_description.value is not None - return cast(StateType, self.entity_description.value(state)) - - -class GiosAqiSensor(GiosSensor): - """Define an GIOS AQI sensor.""" - - @property - def native_value(self) -> StateType: - """Return the state.""" - return cast( - StateType, getattr(self.coordinator.data, self.entity_description.key).value - ) + return self.entity_description.value(self.coordinator.data) @property def available(self) -> bool: """Return if entity is available.""" available = super().available - return available and bool( - getattr(self.coordinator.data, self.entity_description.key) - ) + sensor_data = getattr(self.coordinator.data, self.entity_description.key) + + # Sometimes the API returns sensor data without indexes + if self.entity_description.subkey: + return available and bool(sensor_data.index) + + return available and bool(sensor_data) diff --git a/homeassistant/components/gios/strings.json b/homeassistant/components/gios/strings.json index a76bd3f612..53e7dd78a8 100644 --- a/homeassistant/components/gios/strings.json +++ b/homeassistant/components/gios/strings.json @@ -34,6 +34,56 @@ "good": "Good", "very_good": "Very good" } + }, + "no2_index": { + "state": { + "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", + "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", + "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", + "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", + "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", + "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + } + }, + "o3_index": { + "state": { + "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", + "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", + "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", + "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", + "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", + "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + } + }, + "pm10_index": { + "state": { + "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", + "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", + "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", + "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", + "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", + "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + } + }, + "pm25_index": { + "state": { + "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", + "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", + "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", + "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", + "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", + "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + } + }, + "so2_index": { + "state": { + "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", + "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", + "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", + "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", + "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", + "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + } } } } diff --git a/tests/components/gios/test_sensor.py b/tests/components/gios/test_sensor.py index c5b19502a0..48f0e23840 100644 --- a/tests/components/gios/test_sensor.py +++ b/tests/components/gios/test_sensor.py @@ -5,12 +5,7 @@ from unittest.mock import patch from gios import ApiError -from homeassistant.components.gios.const import ( - ATTR_INDEX, - ATTR_STATION, - ATTRIBUTION, - DOMAIN, -) +from homeassistant.components.gios.const import ATTRIBUTION, DOMAIN from homeassistant.components.sensor import ( ATTR_OPTIONS, ATTR_STATE_CLASS, @@ -42,16 +37,14 @@ async def test_sensor(hass: HomeAssistant) -> None: state = hass.states.get("sensor.home_c6h6") assert state - assert state.state == "0" + assert state.state == "0.23789" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) assert state.attributes.get(ATTR_ICON) == "mdi:molecule" - assert state.attributes.get(ATTR_INDEX) == "very_good" entry = registry.async_get("sensor.home_c6h6") assert entry @@ -59,16 +52,14 @@ async def test_sensor(hass: HomeAssistant) -> None: state = hass.states.get("sensor.home_co") assert state - assert state.state == "252" + assert state.state == "251.874" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" assert state.attributes.get(ATTR_DEVICE_CLASS) is None assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_INDEX) == "good" entry = registry.async_get("sensor.home_co") assert entry @@ -76,94 +67,173 @@ async def test_sensor(hass: HomeAssistant) -> None: state = hass.states.get("sensor.home_no2") assert state - assert state.state == "7" + assert state.state == "7.13411" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.NITROGEN_DIOXIDE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_INDEX) == "good" entry = registry.async_get("sensor.home_no2") assert entry assert entry.unique_id == "123-no2" + state = hass.states.get("sensor.home_no2_index") + assert state + assert state.state == "good" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert state.attributes.get(ATTR_OPTIONS) == [ + "very_bad", + "bad", + "sufficient", + "moderate", + "good", + "very_good", + ] + + entry = registry.async_get("sensor.home_no2_index") + assert entry + assert entry.unique_id == "123-no2-index" + state = hass.states.get("sensor.home_o3") assert state - assert state.state == "96" + assert state.state == "95.7768" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.OZONE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_INDEX) == "good" entry = registry.async_get("sensor.home_o3") assert entry assert entry.unique_id == "123-o3" + state = hass.states.get("sensor.home_o3_index") + assert state + assert state.state == "good" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert state.attributes.get(ATTR_OPTIONS) == [ + "very_bad", + "bad", + "sufficient", + "moderate", + "good", + "very_good", + ] + + entry = registry.async_get("sensor.home_o3_index") + assert entry + assert entry.unique_id == "123-o3-index" + state = hass.states.get("sensor.home_pm10") assert state - assert state.state == "17" + assert state.state == "16.8344" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM10 assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_INDEX) == "good" entry = registry.async_get("sensor.home_pm10") assert entry assert entry.unique_id == "123-pm10" + state = hass.states.get("sensor.home_pm10_index") + assert state + assert state.state == "good" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert state.attributes.get(ATTR_OPTIONS) == [ + "very_bad", + "bad", + "sufficient", + "moderate", + "good", + "very_good", + ] + + entry = registry.async_get("sensor.home_pm10_index") + assert entry + assert entry.unique_id == "123-pm10-index" + state = hass.states.get("sensor.home_pm2_5") assert state assert state.state == "4" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM25 assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_INDEX) == "good" entry = registry.async_get("sensor.home_pm2_5") assert entry assert entry.unique_id == "123-pm25" + state = hass.states.get("sensor.home_pm2_5_index") + assert state + assert state.state == "good" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert state.attributes.get(ATTR_OPTIONS) == [ + "very_bad", + "bad", + "sufficient", + "moderate", + "good", + "very_good", + ] + + entry = registry.async_get("sensor.home_pm2_5_index") + assert entry + assert entry.unique_id == "123-pm25-index" + state = hass.states.get("sensor.home_so2") assert state - assert state.state == "4" + assert state.state == "4.35478" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.SULPHUR_DIOXIDE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_INDEX) == "very_good" entry = registry.async_get("sensor.home_so2") assert entry assert entry.unique_id == "123-so2" + state = hass.states.get("sensor.home_so2_index") + assert state + assert state.state == "very_good" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert state.attributes.get(ATTR_OPTIONS) == [ + "very_bad", + "bad", + "sufficient", + "moderate", + "good", + "very_good", + ] + + entry = registry.async_get("sensor.home_so2_index") + assert entry + assert entry.unique_id == "123-so2-index" + state = hass.states.get("sensor.home_aqi") assert state assert state.state == "good" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" assert state.attributes.get(ATTR_STATE_CLASS) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None assert state.attributes.get(ATTR_OPTIONS) == [ @@ -182,13 +252,23 @@ async def test_sensor(hass: HomeAssistant) -> None: async def test_availability(hass: HomeAssistant) -> None: """Ensure that we mark the entities unavailable correctly when service causes an error.""" + indexes = json.loads(load_fixture("gios/indexes.json")) + sensors = json.loads(load_fixture("gios/sensors.json")) + await init_integration(hass) state = hass.states.get("sensor.home_pm2_5") assert state - assert state.state != STATE_UNAVAILABLE assert state.state == "4" + state = hass.states.get("sensor.home_pm2_5_index") + assert state + assert state.state == "good" + + state = hass.states.get("sensor.home_aqi") + assert state + assert state.state == "good" + future = utcnow() + timedelta(minutes=60) with patch( "homeassistant.components.gios.Gios._get_all_sensors", @@ -201,10 +281,18 @@ async def test_availability(hass: HomeAssistant) -> None: assert state assert state.state == STATE_UNAVAILABLE + state = hass.states.get("sensor.home_pm2_5_index") + assert state + assert state.state == STATE_UNAVAILABLE + + state = hass.states.get("sensor.home_aqi") + assert state + assert state.state == STATE_UNAVAILABLE + future = utcnow() + timedelta(minutes=120) with patch( "homeassistant.components.gios.Gios._get_all_sensors", - return_value=json.loads(load_fixture("gios/sensors.json")), + return_value=sensors, ), patch( "homeassistant.components.gios.Gios._get_indexes", return_value={}, @@ -214,161 +302,69 @@ async def test_availability(hass: HomeAssistant) -> None: state = hass.states.get("sensor.home_pm2_5") assert state - assert state.state != STATE_UNAVAILABLE assert state.state == "4" + # Indexes are empty so the state should be unavailable state = hass.states.get("sensor.home_aqi") assert state assert state.state == STATE_UNAVAILABLE + # Indexes are empty so the state should be unavailable + state = hass.states.get("sensor.home_pm2_5_index") + assert state + assert state.state == STATE_UNAVAILABLE + + future = utcnow() + timedelta(minutes=180) + with patch( + "homeassistant.components.gios.Gios._get_all_sensors", return_value=sensors + ), patch( + "homeassistant.components.gios.Gios._get_indexes", + return_value=indexes, + ): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get("sensor.home_pm2_5") + assert state + assert state.state == "4" + + state = hass.states.get("sensor.home_pm2_5_index") + assert state + assert state.state == "good" + + state = hass.states.get("sensor.home_aqi") + assert state + assert state.state == "good" + async def test_invalid_indexes(hass: HomeAssistant) -> None: """Test states of the sensor when API returns invalid indexes.""" await init_integration(hass, invalid_indexes=True) - registry = er.async_get(hass) - state = hass.states.get("sensor.home_c6h6") + state = hass.states.get("sensor.home_no2_index") assert state - assert state.state == "0" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_ICON) == "mdi:molecule" - assert state.attributes.get(ATTR_INDEX) is None + assert state.state == STATE_UNAVAILABLE - entry = registry.async_get("sensor.home_c6h6") - assert entry - assert entry.unique_id == "123-c6h6" - - state = hass.states.get("sensor.home_co") + state = hass.states.get("sensor.home_o3_index") assert state - assert state.state == "252" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_INDEX) is None + assert state.state == STATE_UNAVAILABLE - entry = registry.async_get("sensor.home_co") - assert entry - assert entry.unique_id == "123-co" - - state = hass.states.get("sensor.home_no2") + state = hass.states.get("sensor.home_pm10_index") assert state - assert state.state == "7" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_INDEX) is None + assert state.state == STATE_UNAVAILABLE - entry = registry.async_get("sensor.home_no2") - assert entry - assert entry.unique_id == "123-no2" - - state = hass.states.get("sensor.home_o3") + state = hass.states.get("sensor.home_pm2_5_index") assert state - assert state.state == "96" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_INDEX) is None + assert state.state == STATE_UNAVAILABLE - entry = registry.async_get("sensor.home_o3") - assert entry - assert entry.unique_id == "123-o3" - - state = hass.states.get("sensor.home_pm10") + state = hass.states.get("sensor.home_so2_index") assert state - assert state.state == "17" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_INDEX) is None - - entry = registry.async_get("sensor.home_pm10") - assert entry - assert entry.unique_id == "123-pm10" - - state = hass.states.get("sensor.home_pm2_5") - assert state - assert state.state == "4" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_INDEX) is None - - entry = registry.async_get("sensor.home_pm2_5") - assert entry - assert entry.unique_id == "123-pm25" - - state = hass.states.get("sensor.home_so2") - assert state - assert state.state == "4" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_INDEX) is None - - entry = registry.async_get("sensor.home_so2") - assert entry - assert entry.unique_id == "123-so2" + assert state.state == STATE_UNAVAILABLE state = hass.states.get("sensor.home_aqi") assert state is None -async def test_aqi_sensor_availability(hass: HomeAssistant) -> None: - """Ensure that we mark the AQI sensor unavailable correctly when indexes are invalid.""" - await init_integration(hass) - - state = hass.states.get("sensor.home_aqi") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "good" - - future = utcnow() + timedelta(minutes=60) - with patch( - "homeassistant.components.gios.Gios._get_all_sensors", - return_value=json.loads(load_fixture("gios/sensors.json")), - ), patch( - "homeassistant.components.gios.Gios._get_indexes", - return_value={}, - ): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("sensor.home_aqi") - assert state - assert state.state == STATE_UNAVAILABLE - - async def test_unique_id_migration(hass: HomeAssistant) -> None: """Test states of the unique_id migration.""" registry = er.async_get(hass) From c0387a655c888175dd09225ade78f0b13db98f3d Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 28 Mar 2023 08:39:34 +0200 Subject: [PATCH 1333/2810] Turn AVM FRITZ!Box Tools binary sensors into coordinator entities (#89955) make binary sensors coordinator entities --- .../components/fritz/binary_sensor.py | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/fritz/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py index 918a114fdf..228b7d5935 100644 --- a/homeassistant/components/fritz/binary_sensor.py +++ b/homeassistant/components/fritz/binary_sensor.py @@ -15,14 +15,21 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import AvmWrapper, ConnectionInfo, FritzBoxBaseEntity +from .common import ( + AvmWrapper, + ConnectionInfo, + FritzBoxBaseCoordinatorEntity, + FritzEntityDescription, +) from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @dataclass -class FritzBinarySensorEntityDescription(BinarySensorEntityDescription): +class FritzBinarySensorEntityDescription( + BinarySensorEntityDescription, FritzEntityDescription +): """Describes Fritz sensor entity.""" is_suitable: Callable[[ConnectionInfo], bool] = lambda info: info.wan_enabled @@ -34,12 +41,14 @@ SENSOR_TYPES: tuple[FritzBinarySensorEntityDescription, ...] = ( name="Connection", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda status, _: bool(status.is_connected), ), FritzBinarySensorEntityDescription( key="is_linked", name="Link", device_class=BinarySensorDeviceClass.PLUG, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda status, _: bool(status.is_linked), ), ) @@ -62,25 +71,16 @@ async def async_setup_entry( async_add_entities(entities, True) -class FritzBoxBinarySensor(FritzBoxBaseEntity, BinarySensorEntity): +class FritzBoxBinarySensor(FritzBoxBaseCoordinatorEntity, BinarySensorEntity): """Define FRITZ!Box connectivity class.""" - def __init__( - self, - avm_wrapper: AvmWrapper, - device_friendly_name: str, - description: BinarySensorEntityDescription, - ) -> None: - """Init FRITZ!Box connectivity class.""" - self.entity_description = description - self._attr_name = f"{device_friendly_name} {description.name}" - self._attr_unique_id = f"{avm_wrapper.unique_id}-{description.key}" - super().__init__(avm_wrapper, device_friendly_name) + entity_description: FritzBinarySensorEntityDescription - def update(self) -> None: - """Update data.""" - _LOGGER.debug("Updating FRITZ!Box binary sensors") - if self.entity_description.key == "is_connected": - self._attr_is_on = bool(self._avm_wrapper.fritz_status.is_connected) - elif self.entity_description.key == "is_linked": - self._attr_is_on = bool(self._avm_wrapper.fritz_status.is_linked) + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + if isinstance( + state := self.coordinator.data.get(self.entity_description.key), bool + ): + return state + return None From b399e5c8b7dad57beb2e4faf8676e61dd19ab1fe Mon Sep 17 00:00:00 2001 From: mkmer Date: Tue, 28 Mar 2023 02:45:10 -0400 Subject: [PATCH 1334/2810] Handle uncaught exceptions during update in Aladdin_connect (#89889) * Handle uncaught errors during update * Remove unnecssary patch * Update tests/components/aladdin_connect/test_cover.py Co-authored-by: Franck Nijhof * Update tests/components/aladdin_connect/test_cover.py Co-authored-by: Franck Nijhof * Remove unasserted statement * Blocking is True - one more --------- Co-authored-by: Franck Nijhof --- .../components/aladdin_connect/cover.py | 17 ++- .../components/aladdin_connect/test_cover.py | 111 ++++++++++-------- 2 files changed, 74 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index 5837920560..2cf526e562 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta from typing import Any -from AIOAladdinConnect import AladdinConnectClient +from AIOAladdinConnect import AladdinConnectClient, session_manager from homeassistant.components.cover import CoverDeviceClass, CoverEntity from homeassistant.config_entries import ConfigEntry @@ -46,7 +46,7 @@ class AladdinDevice(CoverEntity): ) -> None: """Initialize the Aladdin Connect cover.""" self._acc = acc - + self._entry_id = entry.entry_id self._device_id = device["device_id"] self._number = device["door_number"] self._name = device["name"] @@ -85,7 +85,18 @@ class AladdinDevice(CoverEntity): async def async_update(self) -> None: """Update status of cover.""" - await self._acc.get_doors(self._serial) + try: + await self._acc.get_doors(self._serial) + self._attr_available = True + + except session_manager.ConnectionError: + self._attr_available = False + + except session_manager.InvalidPasswordError: + self._attr_available = False + await self.hass.async_create_task( + self.hass.config_entries.async_reload(self._entry_id) + ) @property def is_closed(self) -> bool | None: diff --git a/tests/components/aladdin_connect/test_cover.py b/tests/components/aladdin_connect/test_cover.py index e63b50607c..eb617b959a 100644 --- a/tests/components/aladdin_connect/test_cover.py +++ b/tests/components/aladdin_connect/test_cover.py @@ -1,6 +1,8 @@ """Test the Aladdin Connect Cover.""" from unittest.mock import AsyncMock, MagicMock, patch +from AIOAladdinConnect import session_manager + from homeassistant.components.aladdin_connect.const import DOMAIN from homeassistant.components.aladdin_connect.cover import SCAN_INTERVAL from homeassistant.components.cover import DOMAIN as COVER_DOMAIN @@ -13,6 +15,7 @@ from homeassistant.const import ( STATE_CLOSING, STATE_OPEN, STATE_OPENING, + STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant @@ -97,8 +100,10 @@ async def test_cover_operation( assert await async_setup_component(hass, "homeassistant", {}) await hass.async_block_till_done() + mock_aladdinconnect_api.async_get_door_status = AsyncMock(return_value=STATE_OPEN) mock_aladdinconnect_api.get_door_status.return_value = STATE_OPEN + with patch( "homeassistant.components.aladdin_connect.AladdinConnectClient", return_value=mock_aladdinconnect_api, @@ -116,27 +121,22 @@ async def test_cover_operation( {ATTR_ENTITY_ID: "cover.home"}, blocking=True, ) - await hass.async_block_till_done() assert hass.states.get("cover.home").state == STATE_OPEN mock_aladdinconnect_api.async_get_door_status = AsyncMock(return_value=STATE_CLOSED) mock_aladdinconnect_api.get_door_status.return_value = STATE_CLOSED - with patch( - "homeassistant.components.aladdin_connect.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: "cover.home"}, - blocking=True, - ) - await hass.async_block_till_done() - async_fire_time_changed( - hass, - utcnow() + SCAN_INTERVAL, - ) - await hass.async_block_till_done() + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.home"}, + blocking=True, + ) + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() assert hass.states.get("cover.home").state == STATE_CLOSED @@ -145,15 +145,11 @@ async def test_cover_operation( ) mock_aladdinconnect_api.get_door_status.return_value = STATE_CLOSING - with patch( - "homeassistant.components.aladdin_connect.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - async_fire_time_changed( - hass, - utcnow() + SCAN_INTERVAL, - ) - await hass.async_block_till_done() + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() assert hass.states.get("cover.home").state == STATE_CLOSING mock_aladdinconnect_api.async_get_door_status = AsyncMock( @@ -161,34 +157,47 @@ async def test_cover_operation( ) mock_aladdinconnect_api.get_door_status.return_value = STATE_OPENING - with patch( - "homeassistant.components.aladdin_connect.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - async_fire_time_changed( - hass, - utcnow() + SCAN_INTERVAL, - ) - await hass.async_block_till_done() + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() assert hass.states.get("cover.home").state == STATE_OPENING mock_aladdinconnect_api.async_get_door_status = AsyncMock(return_value=None) mock_aladdinconnect_api.get_door_status.return_value = None - with patch( - "homeassistant.components.aladdin_connect.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: "cover.home"}, - blocking=True, - ) - await hass.async_block_till_done() - async_fire_time_changed( - hass, - utcnow() + SCAN_INTERVAL, - ) - await hass.async_block_till_done() + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.home"}, + blocking=True, + ) + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() assert hass.states.get("cover.home").state == STATE_UNKNOWN + + mock_aladdinconnect_api.get_doors.side_effect = session_manager.ConnectionError + + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + + assert hass.states.get("cover.home").state == STATE_UNAVAILABLE + + mock_aladdinconnect_api.get_doors.side_effect = session_manager.InvalidPasswordError + mock_aladdinconnect_api.login.return_value = False + mock_aladdinconnect_api.login.side_effect = session_manager.InvalidPasswordError + + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + assert hass.states.get("cover.home").state == STATE_UNAVAILABLE From 8807878529660a0dad04c20f97007182c63e3666 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cosmin=20Lu=C8=9B=C4=83?= Date: Tue, 28 Mar 2023 09:46:16 +0300 Subject: [PATCH 1335/2810] Add Mikrotik WifiWave2 (#89711) * Add support for wifiwave2 * Add test for wifiwave2 --- homeassistant/components/mikrotik/const.py | 5 +++ homeassistant/components/mikrotik/hub.py | 7 ++++ tests/components/mikrotik/__init__.py | 32 +++++++++++++++++++ .../mikrotik/test_device_tracker.py | 23 +++++++++++++ 4 files changed, 67 insertions(+) diff --git a/homeassistant/components/mikrotik/const.py b/homeassistant/components/mikrotik/const.py index 911d348365..4354b9b06b 100644 --- a/homeassistant/components/mikrotik/const.py +++ b/homeassistant/components/mikrotik/const.py @@ -24,8 +24,11 @@ ARP: Final = "arp" CAPSMAN: Final = "capsman" DHCP: Final = "dhcp" WIRELESS: Final = "wireless" +WIFIWAVE2: Final = "wifiwave2" IS_WIRELESS: Final = "is_wireless" IS_CAPSMAN: Final = "is_capsman" +IS_WIFIWAVE2: Final = "is_wifiwave2" + MIKROTIK_SERVICES: Final = { ARP: "/ip/arp/getall", @@ -34,8 +37,10 @@ MIKROTIK_SERVICES: Final = { IDENTITY: "/system/identity/getall", INFO: "/system/routerboard/getall", WIRELESS: "/interface/wireless/registration-table/getall", + WIFIWAVE2: "/interface/wifiwave2/registration-table/print", IS_WIRELESS: "/interface/wireless/print", IS_CAPSMAN: "/caps-man/interface/print", + IS_WIFIWAVE2: "/interface/wifiwave2/print", } diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py index 26a5894862..9e0a610c77 100644 --- a/homeassistant/components/mikrotik/hub.py +++ b/homeassistant/components/mikrotik/hub.py @@ -31,9 +31,11 @@ from .const import ( IDENTITY, INFO, IS_CAPSMAN, + IS_WIFIWAVE2, IS_WIRELESS, MIKROTIK_SERVICES, NAME, + WIFIWAVE2, WIRELESS, ) from .device import Device @@ -57,6 +59,7 @@ class MikrotikData: self.devices: dict[str, Device] = {} self.support_capsman: bool = False self.support_wireless: bool = False + self.support_wifiwave2: bool = False self.hostname: str = "" self.model: str = "" self.firmware: str = "" @@ -97,6 +100,7 @@ class MikrotikData: self.serial_number = self.get_info(ATTR_SERIAL_NUMBER) self.support_capsman = bool(self.command(MIKROTIK_SERVICES[IS_CAPSMAN])) self.support_wireless = bool(self.command(MIKROTIK_SERVICES[IS_WIRELESS])) + self.support_wifiwave2 = bool(self.command(MIKROTIK_SERVICES[IS_WIFIWAVE2])) def get_list_from_interface(self, interface: str) -> dict[str, dict[str, Any]]: """Get devices from interface.""" @@ -121,6 +125,9 @@ class MikrotikData: elif self.support_wireless: _LOGGER.debug("Hub supports wireless Interface") device_list = wireless_devices = self.get_list_from_interface(WIRELESS) + elif self.support_wifiwave2: + _LOGGER.debug("Hub supports wifiwave2 Interface") + device_list = wireless_devices = self.get_list_from_interface(WIFIWAVE2) if not device_list or self.force_dhcp: device_list = self.all_devices diff --git a/tests/components/mikrotik/__init__.py b/tests/components/mikrotik/__init__.py index b7f79f8ea5..158f86fe45 100644 --- a/tests/components/mikrotik/__init__.py +++ b/tests/components/mikrotik/__init__.py @@ -62,6 +62,14 @@ DEVICE_3_DHCP_NUMERIC_NAME = { "host-name": 123, "comment": "Mobile", } +DEVICE_4_DHCP = { + ".id": "*F7", + "address": "0.0.0.4", + "mac-address": "00:00:00:00:00:04", + "active-address": "0.0.0.4", + "host-name": "Device_4", + "comment": "Wifiwave2 device", +} DEVICE_1_WIRELESS = { ".id": "*264", "interface": "wlan1", @@ -109,9 +117,27 @@ DEVICE_3_WIRELESS = { "mac-address": "00:00:00:00:00:03", "last-ip": "0.0.0.3", } + +DEVICE_4_WIFIWAVE2 = { + ".id": "*F7", + "interface": "wifi1", + "ssid": "test-ssid", + "mac-address": "00:00:00:00:00:04", + "uptime": "2d15h28m27s", + "signal": -47, + "tx-rate": 54000000, + "rx-rate": 54000000, + "packets": "17748,18516", + "bytes": "1851474,2037295", + "tx-bits-per-second": 0, + "rx-bits-per-second": 0, + "authorized": True, +} + DHCP_DATA = [DEVICE_1_DHCP, DEVICE_2_DHCP] WIRELESS_DATA = [DEVICE_1_WIRELESS] +WIFIWAVE2_DATA = [DEVICE_4_WIFIWAVE2] ARP_DATA = [ { @@ -144,16 +170,22 @@ ARP_DATA = [ async def setup_mikrotik_entry(hass: HomeAssistant, **kwargs: Any) -> None: """Set up Mikrotik integration successfully.""" support_wireless: bool = kwargs.get("support_wireless", True) + support_wifiwave2: bool = kwargs.get("support_wifiwave2", False) dhcp_data: list[dict[str, Any]] = kwargs.get("dhcp_data", DHCP_DATA) wireless_data: list[dict[str, Any]] = kwargs.get("wireless_data", WIRELESS_DATA) + wifiwave2_data: list[dict[str, Any]] = kwargs.get("wifiwave2_data", WIFIWAVE2_DATA) def mock_command(self, cmd: str, params: dict[str, Any] | None = None) -> Any: if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_WIRELESS]: return support_wireless + if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_WIFIWAVE2]: + return support_wifiwave2 if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.DHCP]: return dhcp_data if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.WIRELESS]: return wireless_data + if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.WIFIWAVE2]: + return wifiwave2_data if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.ARP]: return ARP_DATA return {} diff --git a/tests/components/mikrotik/test_device_tracker.py b/tests/components/mikrotik/test_device_tracker.py index bd921320d7..323c958eb2 100644 --- a/tests/components/mikrotik/test_device_tracker.py +++ b/tests/components/mikrotik/test_device_tracker.py @@ -18,6 +18,8 @@ from . import ( DEVICE_2_WIRELESS, DEVICE_3_DHCP_NUMERIC_NAME, DEVICE_3_WIRELESS, + DEVICE_4_DHCP, + DEVICE_4_WIFIWAVE2, DHCP_DATA, MOCK_DATA, MOCK_OPTIONS, @@ -39,6 +41,7 @@ def mock_device_registry_devices(hass: HomeAssistant) -> None: "00:00:00:00:00:01", "00:00:00:00:00:02", "00:00:00:00:00:03", + "00:00:00:00:00:04", ) ): dev_reg.async_get_or_create( @@ -184,6 +187,26 @@ async def test_device_trackers_numerical_name( assert device_3.attributes["host_name"] == "123" +async def test_hub_wifiwave2(hass: HomeAssistant, mock_device_registry_devices) -> None: + """Test device_trackers created when hub supports wifiwave2.""" + + await setup_mikrotik_entry( + hass, + dhcp_data=[DEVICE_4_DHCP], + wifiwave2_data=[DEVICE_4_WIFIWAVE2], + support_wireless=False, + support_wifiwave2=True, + ) + + device_4 = hass.states.get("device_tracker.device_4") + assert device_4 + assert device_4.state == "home" + assert device_4.attributes["friendly_name"] == "Device_4" + assert device_4.attributes["ip"] == "0.0.0.4" + assert device_4.attributes["mac"] == "00:00:00:00:00:04" + assert device_4.attributes["host_name"] == "Device_4" + + async def test_restoring_devices(hass: HomeAssistant) -> None: """Test restoring existing device_tracker entities if not detected on startup.""" config_entry = MockConfigEntry( From db6f0827aa9b9f9c31dd49f8c2ff552a8c9f7093 Mon Sep 17 00:00:00 2001 From: Penny Wood Date: Tue, 28 Mar 2023 14:47:45 +0800 Subject: [PATCH 1336/2810] Allow reloading iZone config entry (#89572) * Allow reloading of iZone config entries --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/izone/__init__.py | 28 +++++++++++++-------- homeassistant/components/izone/climate.py | 4 ++- homeassistant/components/izone/discovery.py | 17 +++++++------ 3 files changed, 29 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/izone/__init__.py b/homeassistant/components/izone/__init__.py index 3f2565bd8f..fd8d27ac42 100644 --- a/homeassistant/components/izone/__init__.py +++ b/homeassistant/components/izone/__init__.py @@ -3,7 +3,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EXCLUDE, Platform +from homeassistant.const import CONF_EXCLUDE, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -29,29 +29,35 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Register the iZone component config.""" - if not (conf := config.get(IZONE)): - return True - hass.data[DATA_CONFIG] = conf + # Check for manually added config, this may exclude some devices + if conf := config.get(IZONE): + hass.data[DATA_CONFIG] = conf - # Explicitly added in the config file, create a config entry. - hass.async_create_task( - hass.config_entries.flow.async_init( - IZONE, context={"source": config_entries.SOURCE_IMPORT} + # Explicitly added in the config file, create a config entry. + hass.async_create_task( + hass.config_entries.flow.async_init( + IZONE, context={"source": config_entries.SOURCE_IMPORT} + ) ) - ) + + # Start the discovery service + await async_start_discovery_service(hass) + + async def shutdown_event(event): + await async_stop_discovery_service(hass) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown_event) return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up from a config entry.""" - await async_start_discovery_service(hass) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload the config entry and stop discovery process.""" - await async_stop_discovery_service(hass) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py index 3e19afcca2..e5a45dbc5e 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -95,7 +95,9 @@ async def async_setup_entry( init_controller(controller) # connect to register any further components - async_dispatcher_connect(hass, DISPATCH_CONTROLLER_DISCOVERED, init_controller) + config.async_on_unload( + async_dispatcher_connect(hass, DISPATCH_CONTROLLER_DISCOVERED, init_controller) + ) platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( diff --git a/homeassistant/components/izone/discovery.py b/homeassistant/components/izone/discovery.py index eb6e7d4a19..a170ed30a7 100644 --- a/homeassistant/components/izone/discovery.py +++ b/homeassistant/components/izone/discovery.py @@ -1,7 +1,8 @@ """Internal discovery service for iZone AC.""" +import logging + import pizone -from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -15,15 +16,17 @@ from .const import ( DISPATCH_ZONE_UPDATE, ) +_LOGGER = logging.getLogger(__name__) + class DiscoveryService(pizone.Listener): """Discovery data and interfacing with pizone library.""" - def __init__(self, hass): + def __init__(self, hass: HomeAssistant) -> None: """Initialise discovery service.""" super().__init__() self.hass = hass - self.pi_disco = None + self.pi_disco: pizone.DiscoveryService | None = None # Listener interface def controller_discovered(self, ctrl: pizone.Controller) -> None: @@ -52,6 +55,7 @@ async def async_start_discovery_service(hass: HomeAssistant): if disco := hass.data.get(DATA_DISCOVERY_SERVICE): # Already started return disco + _LOGGER.debug("Starting iZone Discovery Service") # discovery local services disco = DiscoveryService(hass) @@ -62,11 +66,6 @@ async def async_start_discovery_service(hass: HomeAssistant): disco.pi_disco = pizone.discovery(disco, session=session) await disco.pi_disco.start_discovery() - async def shutdown_event(event): - await async_stop_discovery_service(hass) - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown_event) - return disco @@ -77,3 +76,5 @@ async def async_stop_discovery_service(hass: HomeAssistant): await disco.pi_disco.close() del hass.data[DATA_DISCOVERY_SERVICE] + + _LOGGER.debug("Stopped iZone Discovery Service") From 38f3b9f165f3ccd0366ee9a4ba59499b9cb43c44 Mon Sep 17 00:00:00 2001 From: Mark Adkins Date: Tue, 28 Mar 2023 02:48:32 -0400 Subject: [PATCH 1337/2810] Add SharkIQ EU region support (#89349) * SharkIQ Dep & Codeowner Update * Update code owners * Add EU Region Support * Update Config Flow Tests * Standardize Region Comparison Strings * Add Translation Support to Region Selector * Fix Validation Tests --- homeassistant/components/sharkiq/__init__.py | 5 +- .../components/sharkiq/config_flow.py | 49 ++++++++++++++++--- homeassistant/components/sharkiq/const.py | 5 ++ homeassistant/components/sharkiq/strings.json | 19 ++++++- tests/components/sharkiq/const.py | 9 +++- tests/components/sharkiq/test_config_flow.py | 11 +++-- 6 files changed, 80 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/sharkiq/__init__.py b/homeassistant/components/sharkiq/__init__.py index 0c4f7bb0bf..738dd595a5 100644 --- a/homeassistant/components/sharkiq/__init__.py +++ b/homeassistant/components/sharkiq/__init__.py @@ -13,11 +13,11 @@ from sharkiq import ( from homeassistant import exceptions from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import API_TIMEOUT, DOMAIN, LOGGER, PLATFORMS +from .const import API_TIMEOUT, DOMAIN, LOGGER, PLATFORMS, SHARKIQ_REGION_EUROPE from .update_coordinator import SharkIqUpdateCoordinator @@ -47,6 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b username=config_entry.data[CONF_USERNAME], password=config_entry.data[CONF_PASSWORD], websession=async_get_clientsession(hass), + europe=(config_entry.data[CONF_REGION] == SHARKIQ_REGION_EUROPE), ) try: diff --git a/homeassistant/components/sharkiq/config_flow.py b/homeassistant/components/sharkiq/config_flow.py index b0aae5259d..57de36ce41 100644 --- a/homeassistant/components/sharkiq/config_flow.py +++ b/homeassistant/components/sharkiq/config_flow.py @@ -11,14 +11,31 @@ from sharkiq import SharkIqAuthError, get_ayla_api import voluptuous as vol from homeassistant import config_entries, core, exceptions -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import selector from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN, LOGGER +from .const import ( + DOMAIN, + LOGGER, + SHARKIQ_REGION_DEFAULT, + SHARKIQ_REGION_EUROPE, + SHARKIQ_REGION_OPTIONS, +) SHARKIQ_SCHEMA = vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required( + CONF_REGION, default=SHARKIQ_REGION_DEFAULT + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=SHARKIQ_REGION_OPTIONS, translation_key="region" + ), + ), + } ) @@ -30,16 +47,29 @@ async def _validate_input( username=data[CONF_USERNAME], password=data[CONF_PASSWORD], websession=async_get_clientsession(hass), + europe=(data[CONF_REGION] == SHARKIQ_REGION_EUROPE), ) try: async with async_timeout.timeout(10): LOGGER.debug("Initialize connection to Ayla networks API") await ayla_api.async_sign_in() - except (asyncio.TimeoutError, aiohttp.ClientError) as errors: - raise CannotConnect from errors + except (asyncio.TimeoutError, aiohttp.ClientError, TypeError) as error: + LOGGER.error(error) + raise CannotConnect( + "Unable to connect to SharkIQ services. Check your region settings." + ) from error except SharkIqAuthError as error: - raise InvalidAuth from error + LOGGER.error(error) + raise InvalidAuth( + "Username or password incorrect. Please check your credentials." + ) from error + except Exception as error: + LOGGER.exception("Unexpected exception") + LOGGER.error(error) + raise UnknownAuth( + "An unknown error occurred. Check your region settings and open an issue on Github if the issue persists." + ) from error # Return info that you want to store in the config entry. return {"title": data[CONF_USERNAME]} @@ -64,8 +94,7 @@ class SharkIqConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except - LOGGER.exception("Unexpected exception") + except UnknownAuth: # pylint: disable=broad-except errors["base"] = "unknown" return info, errors @@ -114,3 +143,7 @@ class CannotConnect(exceptions.HomeAssistantError): class InvalidAuth(exceptions.HomeAssistantError): """Error to indicate there is invalid auth.""" + + +class UnknownAuth(exceptions.HomeAssistantError): + """Error to indicate there is an uncaught auth error.""" diff --git a/homeassistant/components/sharkiq/const.py b/homeassistant/components/sharkiq/const.py index fb683bb525..b12a86dc24 100644 --- a/homeassistant/components/sharkiq/const.py +++ b/homeassistant/components/sharkiq/const.py @@ -11,3 +11,8 @@ PLATFORMS = [Platform.VACUUM] DOMAIN = "sharkiq" SHARK = "Shark" UPDATE_INTERVAL = timedelta(seconds=30) + +SHARKIQ_REGION_EUROPE = "europe" +SHARKIQ_REGION_ELSEWHERE = "elsewhere" +SHARKIQ_REGION_DEFAULT = SHARKIQ_REGION_ELSEWHERE +SHARKIQ_REGION_OPTIONS = [SHARKIQ_REGION_EUROPE, SHARKIQ_REGION_ELSEWHERE] diff --git a/homeassistant/components/sharkiq/strings.json b/homeassistant/components/sharkiq/strings.json index bc920ac7c7..23f949be4c 100644 --- a/homeassistant/components/sharkiq/strings.json +++ b/homeassistant/components/sharkiq/strings.json @@ -1,16 +1,23 @@ { "config": { + "flow_title": "Add Shark IQ Account", "step": { "user": { + "description": "Sign into your Shark Clean account to control your devices.", "data": { "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "region": "Region" + }, + "data_description": { + "region": "Shark IQ uses different services in the EU. Select your region to connect to the correct service for your account." } }, "reauth": { "data": { "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "region": "Region" } } }, @@ -25,5 +32,13 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "selector": { + "region": { + "options": { + "europe": "Europe", + "elsewhere": "Everywhere Else" + } + } } } diff --git a/tests/components/sharkiq/const.py b/tests/components/sharkiq/const.py index 305d12ddfa..8ec7d424ff 100644 --- a/tests/components/sharkiq/const.py +++ b/tests/components/sharkiq/const.py @@ -1,6 +1,6 @@ """Constants used in shark iq tests.""" -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME # Dummy device dict of the form returned by AylaApi.list_devices() SHARK_DEVICE_DICT = { @@ -69,6 +69,11 @@ SHARK_PROPERTIES_DICT = { TEST_USERNAME = "test-username" TEST_PASSWORD = "test-password" +TEST_REGION = "elsewhere" UNIQUE_ID = "foo@bar.com" -CONFIG = {CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD} +CONFIG = { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_REGION: TEST_REGION, +} ENTRY_ID = "0123456789abcdef0123456789abcdef" diff --git a/tests/components/sharkiq/test_config_flow.py b/tests/components/sharkiq/test_config_flow.py index f611d8e6d8..c7a0603f86 100644 --- a/tests/components/sharkiq/test_config_flow.py +++ b/tests/components/sharkiq/test_config_flow.py @@ -3,13 +3,13 @@ from unittest.mock import patch import aiohttp import pytest -from sharkiq import AylaApi, SharkIqAuthError +from sharkiq import AylaApi, SharkIqAuthError, SharkIqError from homeassistant import config_entries from homeassistant.components.sharkiq.const import DOMAIN from homeassistant.core import HomeAssistant -from .const import CONFIG, TEST_PASSWORD, TEST_USERNAME, UNIQUE_ID +from .const import CONFIG, TEST_PASSWORD, TEST_REGION, TEST_USERNAME, UNIQUE_ID from tests.common import MockConfigEntry @@ -37,6 +37,7 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["data"] == { "username": TEST_USERNAME, "password": TEST_PASSWORD, + "region": TEST_REGION, } await hass.async_block_till_done() mock_setup_entry.assert_called_once() @@ -47,7 +48,8 @@ async def test_form(hass: HomeAssistant) -> None: [ (SharkIqAuthError, "invalid_auth"), (aiohttp.ClientError, "cannot_connect"), - (TypeError, "unknown"), + (TypeError, "cannot_connect"), + (SharkIqError, "unknown"), ], ) async def test_form_error(hass: HomeAssistant, exc: Exception, base_error: str) -> None: @@ -87,7 +89,8 @@ async def test_reauth_success(hass: HomeAssistant) -> None: [ (SharkIqAuthError, "form", "errors", "invalid_auth"), (aiohttp.ClientError, "abort", "reason", "cannot_connect"), - (TypeError, "abort", "reason", "unknown"), + (TypeError, "abort", "reason", "cannot_connect"), + (SharkIqError, "abort", "reason", "unknown"), ], ) async def test_reauth( From bfb5daa31c23afd788a9430dfec040bf96e4b90d Mon Sep 17 00:00:00 2001 From: gjong Date: Tue, 28 Mar 2023 08:49:31 +0200 Subject: [PATCH 1338/2810] Add phase information to YouLess (#89255) --- homeassistant/components/youless/sensor.py | 99 +++++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/youless/sensor.py b/homeassistant/components/youless/sensor.py index b9120f433d..057533081e 100644 --- a/homeassistant/components/youless/sensor.py +++ b/homeassistant/components/youless/sensor.py @@ -10,7 +10,14 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE, UnitOfEnergy, UnitOfPower, UnitOfVolume +from homeassistant.const import ( + CONF_DEVICE, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, + UnitOfVolume, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -47,6 +54,15 @@ async def async_setup_entry( DeliveryMeterSensor(coordinator, device, "high"), ExtraMeterSensor(coordinator, device, "total"), ExtraMeterPowerSensor(coordinator, device, "usage"), + PhasePowerSensor(coordinator, device, 1), + PhaseVoltageSensor(coordinator, device, 1), + PhaseCurrentSensor(coordinator, device, 1), + PhasePowerSensor(coordinator, device, 2), + PhaseVoltageSensor(coordinator, device, 2), + PhaseCurrentSensor(coordinator, device, 2), + PhasePowerSensor(coordinator, device, 3), + PhaseVoltageSensor(coordinator, device, 3), + PhaseCurrentSensor(coordinator, device, 3), ] ) @@ -193,6 +209,87 @@ class EnergyMeterSensor(YoulessBaseSensor): return getattr(self.coordinator.data.power_meter, f"_{self._type}", None) +class PhasePowerSensor(YoulessBaseSensor): + """The current power usage of a single phase.""" + + _attr_native_unit_of_measurement = UnitOfPower.WATT + _attr_device_class = SensorDeviceClass.POWER + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__( + self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, phase: int + ) -> None: + """Initialize the power phase sensor.""" + super().__init__( + coordinator, device, "power", "Energy usage", f"phase_{phase}_power" + ) + self._attr_name = f"Phase {phase} power" + self._phase = phase + + @property + def get_sensor(self) -> YoulessSensor | None: + """Get the sensor value from the coordinator.""" + phase_sensor = getattr(self.coordinator.data, f"phase{self._phase}", None) + if phase_sensor is None: + return None + + return phase_sensor.power + + +class PhaseVoltageSensor(YoulessBaseSensor): + """The current voltage of a single phase.""" + + _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT + _attr_device_class = SensorDeviceClass.VOLTAGE + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__( + self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, phase: int + ) -> None: + """Initialize the voltage phase sensor.""" + super().__init__( + coordinator, device, "power", "Energy usage", f"phase_{phase}_voltage" + ) + self._attr_name = f"Phase {phase} voltage" + self._phase = phase + + @property + def get_sensor(self) -> YoulessSensor | None: + """Get the sensor value from the coordinator for phase voltage.""" + phase_sensor = getattr(self.coordinator.data, f"phase{self._phase}", None) + if phase_sensor is None: + return None + + return phase_sensor.voltage + + +class PhaseCurrentSensor(YoulessBaseSensor): + """The current current of a single phase.""" + + _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE + _attr_device_class = SensorDeviceClass.CURRENT + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__( + self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, phase: int + ) -> None: + """Initialize the current phase sensor.""" + super().__init__( + coordinator, device, "power", "Energy usage", f"phase_{phase}_current" + ) + self._attr_name = f"Phase {phase} current" + self._phase = phase + + @property + def get_sensor(self) -> YoulessSensor | None: + """Get the sensor value from the coordinator for phase current.""" + phase_sensor = getattr(self.coordinator.data, f"phase{self._phase}", None) + if phase_sensor is None: + return None + + return phase_sensor.current + + class ExtraMeterSensor(YoulessBaseSensor): """The Youless extra meter value sensor (s0).""" From 586471b5a9eaffa99b94c577a07bc7a0add8d046 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Mar 2023 09:11:13 +0200 Subject: [PATCH 1339/2810] Improve threshold binary sensor (#88978) Improve threshold sensor --- .../components/threshold/binary_sensor.py | 58 ++++++++++------ .../threshold/test_binary_sensor.py | 68 +++++++++---------- 2 files changed, 71 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/threshold/binary_sensor.py b/homeassistant/components/threshold/binary_sensor.py index 0badf7eb41..538655ec0c 100644 --- a/homeassistant/components/threshold/binary_sensor.py +++ b/homeassistant/components/threshold/binary_sensor.py @@ -114,6 +114,15 @@ async def async_setup_platform( ) +def _threshold_type(lower: float | None, upper: float | None) -> str: + """Return the type of threshold this sensor represents.""" + if lower is not None and upper is not None: + return TYPE_RANGE + if lower is not None: + return TYPE_LOWER + return TYPE_UPPER + + class ThresholdSensor(BinarySensorEntity): """Representation of a Threshold sensor.""" @@ -134,8 +143,11 @@ class ThresholdSensor(BinarySensorEntity): self._attr_unique_id = unique_id self._entity_id = entity_id self._name = name - self._threshold_lower = lower - self._threshold_upper = upper + if lower is not None: + self._threshold_lower = lower + if upper is not None: + self._threshold_upper = upper + self.threshold_type = _threshold_type(lower, upper) self._hysteresis: float = hysteresis self._device_class = device_class self._state_position = POSITION_UNKNOWN @@ -187,26 +199,17 @@ class ThresholdSensor(BinarySensorEntity): """Return the sensor class of the sensor.""" return self._device_class - @property - def threshold_type(self) -> str: - """Return the type of threshold this sensor represents.""" - if self._threshold_lower is not None and self._threshold_upper is not None: - return TYPE_RANGE - if self._threshold_lower is not None: - return TYPE_LOWER - return TYPE_UPPER - @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the sensor.""" return { ATTR_ENTITY_ID: self._entity_id, ATTR_HYSTERESIS: self._hysteresis, - ATTR_LOWER: self._threshold_lower, + ATTR_LOWER: getattr(self, "_threshold_lower", None), ATTR_POSITION: self._state_position, ATTR_SENSOR_VALUE: self.sensor_value, ATTR_TYPE: self.threshold_type, - ATTR_UPPER: self._threshold_upper, + ATTR_UPPER: getattr(self, "_threshold_upper", None), } @callback @@ -223,30 +226,42 @@ class ThresholdSensor(BinarySensorEntity): if self.sensor_value is None: self._state_position = POSITION_UNKNOWN - self._state = False + self._state = None return - if self.threshold_type == TYPE_LOWER and self._threshold_lower is not None: + if self.threshold_type == TYPE_LOWER: + if self._state is None: + self._state = False + self._state_position = POSITION_ABOVE + if below(self.sensor_value, self._threshold_lower): self._state_position = POSITION_BELOW self._state = True elif above(self.sensor_value, self._threshold_lower): self._state_position = POSITION_ABOVE self._state = False + return + + if self.threshold_type == TYPE_UPPER: + assert self._threshold_upper is not None + + if self._state is None: + self._state = False + self._state_position = POSITION_BELOW - if self.threshold_type == TYPE_UPPER and self._threshold_upper is not None: if above(self.sensor_value, self._threshold_upper): self._state_position = POSITION_ABOVE self._state = True elif below(self.sensor_value, self._threshold_upper): self._state_position = POSITION_BELOW self._state = False + return + + if self.threshold_type == TYPE_RANGE: + if self._state is None: + self._state = True + self._state_position = POSITION_IN_RANGE - if ( - self.threshold_type == TYPE_RANGE - and self._threshold_lower is not None - and self._threshold_upper is not None - ): if below(self.sensor_value, self._threshold_lower): self._state_position = POSITION_BELOW self._state = False @@ -258,3 +273,4 @@ class ThresholdSensor(BinarySensorEntity): ): self._state_position = POSITION_IN_RANGE self._state = True + return diff --git a/tests/components/threshold/test_binary_sensor.py b/tests/components/threshold/test_binary_sensor.py index eed3a8a40e..9e11195d87 100644 --- a/tests/components/threshold/test_binary_sensor.py +++ b/tests/components/threshold/test_binary_sensor.py @@ -29,8 +29,8 @@ async def test_sensor_upper(hass: HomeAssistant) -> None: hass.states.async_set("sensor.test_monitored", 15) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "below" + assert state.state == "off" hass.states.async_set( "sensor.test_monitored", @@ -63,12 +63,12 @@ async def test_sensor_upper(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes["position"] == "unknown" - assert state.state == "off" + assert state.state == "unknown" hass.states.async_set("sensor.test_monitored", 15) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" + assert state.attributes["position"] == "below" assert state.state == "off" @@ -89,8 +89,8 @@ async def test_sensor_lower(hass: HomeAssistant) -> None: hass.states.async_set("sensor.test_monitored", 15) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "above" + assert state.state == "off" hass.states.async_set("sensor.test_monitored", 16) await hass.async_block_till_done() @@ -117,12 +117,12 @@ async def test_sensor_lower(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes["position"] == "unknown" - assert state.state == "off" + assert state.state == "unknown" hass.states.async_set("sensor.test_monitored", 15) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" + assert state.attributes["position"] == "above" assert state.state == "off" @@ -144,15 +144,15 @@ async def test_sensor_upper_hysteresis(hass: HomeAssistant) -> None: hass.states.async_set("sensor.test_monitored", 17.5) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "below" + assert state.state == "off" # Set the monitored sensor's state to the threshold - hysteresis hass.states.async_set("sensor.test_monitored", 12.5) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "below" + assert state.state == "off" hass.states.async_set("sensor.test_monitored", 20) await hass.async_block_till_done() @@ -192,7 +192,7 @@ async def test_sensor_upper_hysteresis(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes["position"] == "unknown" - assert state.state == "off" + assert state.state == "unknown" hass.states.async_set("sensor.test_monitored", 18) await hass.async_block_till_done() @@ -219,15 +219,15 @@ async def test_sensor_lower_hysteresis(hass: HomeAssistant) -> None: hass.states.async_set("sensor.test_monitored", 17.5) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "above" + assert state.state == "off" # Set the monitored sensor's state to the threshold - hysteresis hass.states.async_set("sensor.test_monitored", 12.5) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "above" + assert state.state == "off" hass.states.async_set("sensor.test_monitored", 20) await hass.async_block_till_done() @@ -267,7 +267,7 @@ async def test_sensor_lower_hysteresis(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes["position"] == "unknown" - assert state.state == "off" + assert state.state == "unknown" hass.states.async_set("sensor.test_monitored", 18) await hass.async_block_till_done() @@ -294,15 +294,15 @@ async def test_sensor_in_range_no_hysteresis(hass: HomeAssistant) -> None: hass.states.async_set("sensor.test_monitored", 10) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "in_range" + assert state.state == "on" # Set the monitored sensor's state to the upper threshold hass.states.async_set("sensor.test_monitored", 20) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "in_range" + assert state.state == "on" hass.states.async_set( "sensor.test_monitored", @@ -336,7 +336,7 @@ async def test_sensor_in_range_no_hysteresis(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes["position"] == "unknown" - assert state.state == "off" + assert state.state == "unknown" hass.states.async_set("sensor.test_monitored", 21) await hass.async_block_till_done() @@ -364,29 +364,29 @@ async def test_sensor_in_range_with_hysteresis(hass: HomeAssistant) -> None: hass.states.async_set("sensor.test_monitored", 8) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "in_range" + assert state.state == "on" # Set the monitored sensor's state to the lower threshold + hysteresis hass.states.async_set("sensor.test_monitored", 12) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "in_range" + assert state.state == "on" # Set the monitored sensor's state to the upper threshold + hysteresis hass.states.async_set("sensor.test_monitored", 22) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "in_range" + assert state.state == "on" # Set the monitored sensor's state to the upper threshold - hysteresis hass.states.async_set("sensor.test_monitored", 18) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "in_range" + assert state.state == "on" hass.states.async_set( "sensor.test_monitored", @@ -460,7 +460,7 @@ async def test_sensor_in_range_with_hysteresis(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes["position"] == "unknown" - assert state.state == "off" + assert state.state == "unknown" hass.states.async_set("sensor.test_monitored", 17) await hass.async_block_till_done() @@ -507,13 +507,13 @@ async def test_sensor_in_range_unknown_state( await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes["position"] == "unknown" - assert state.state == "off" + assert state.state == "unknown" hass.states.async_set("sensor.test_monitored", STATE_UNAVAILABLE) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes["position"] == "unknown" - assert state.state == "off" + assert state.state == "unknown" assert "State is not numerical" not in caplog.text From dc05272120af9560b120cf41567c3f5658724c8d Mon Sep 17 00:00:00 2001 From: stickpin <630000+stickpin@users.noreply.github.com> Date: Tue, 28 Mar 2023 09:14:19 +0200 Subject: [PATCH 1340/2810] Display only supported Home Connect appliance programs (#88801) Show only supported device programs --- homeassistant/components/home_connect/api.py | 115 ++----------------- 1 file changed, 7 insertions(+), 108 deletions(-) diff --git a/homeassistant/components/home_connect/api.py b/homeassistant/components/home_connect/api.py index f50ab71155..85d8abd1cb 100644 --- a/homeassistant/components/home_connect/api.py +++ b/homeassistant/components/home_connect/api.py @@ -145,11 +145,14 @@ class HomeConnectDevice: class DeviceWithPrograms(HomeConnectDevice): """Device with programs.""" - PROGRAMS: list[dict[str, str]] = [] - def get_programs_available(self): """Get the available programs.""" - return self.PROGRAMS + try: + programs_available = self.appliance.get_programs_available() + except (HomeConnectError, ValueError): + _LOGGER.debug("Unable to fetch available programs. Probably offline") + programs_available = None + return programs_available def get_program_switches(self): """Get a dictionary with info about program switches. @@ -157,7 +160,7 @@ class DeviceWithPrograms(HomeConnectDevice): There will be one switch for each program. """ programs = self.get_programs_available() - return [{ATTR_DEVICE: self, "program_name": p["name"]} for p in programs] + return [{ATTR_DEVICE: self, "program_name": p} for p in programs] def get_program_sensors(self): """Get a dictionary with info about program sensors. @@ -265,27 +268,6 @@ class Dryer( ): """Dryer class.""" - PROGRAMS = [ - {"name": "LaundryCare.Dryer.Program.Cotton"}, - {"name": "LaundryCare.Dryer.Program.Synthetic"}, - {"name": "LaundryCare.Dryer.Program.Mix"}, - {"name": "LaundryCare.Dryer.Program.Blankets"}, - {"name": "LaundryCare.Dryer.Program.BusinessShirts"}, - {"name": "LaundryCare.Dryer.Program.DownFeathers"}, - {"name": "LaundryCare.Dryer.Program.Hygiene"}, - {"name": "LaundryCare.Dryer.Program.Jeans"}, - {"name": "LaundryCare.Dryer.Program.Outdoor"}, - {"name": "LaundryCare.Dryer.Program.SyntheticRefresh"}, - {"name": "LaundryCare.Dryer.Program.Towels"}, - {"name": "LaundryCare.Dryer.Program.Delicates"}, - {"name": "LaundryCare.Dryer.Program.Super40"}, - {"name": "LaundryCare.Dryer.Program.Shirts15"}, - {"name": "LaundryCare.Dryer.Program.Pillow"}, - {"name": "LaundryCare.Dryer.Program.AntiShrink"}, - {"name": "LaundryCare.Dryer.Program.TimeCold"}, - {"name": "LaundryCare.Dryer.Program.TimeWarm"}, - ] - def get_entity_info(self): """Get a dictionary with infos about the associated entities.""" door_entity = self.get_door_entity() @@ -311,32 +293,6 @@ class Dishwasher( ): """Dishwasher class.""" - PROGRAMS = [ - {"name": "Dishcare.Dishwasher.Program.Auto1"}, - {"name": "Dishcare.Dishwasher.Program.Auto2"}, - {"name": "Dishcare.Dishwasher.Program.Auto3"}, - {"name": "Dishcare.Dishwasher.Program.Eco50"}, - {"name": "Dishcare.Dishwasher.Program.Quick45"}, - {"name": "Dishcare.Dishwasher.Program.Intensiv70"}, - {"name": "Dishcare.Dishwasher.Program.Normal65"}, - {"name": "Dishcare.Dishwasher.Program.Glas40"}, - {"name": "Dishcare.Dishwasher.Program.GlassCare"}, - {"name": "Dishcare.Dishwasher.Program.PreRinse"}, - {"name": "Dishcare.Dishwasher.Program.NightWash"}, - {"name": "Dishcare.Dishwasher.Program.Quick65"}, - {"name": "Dishcare.Dishwasher.Program.Normal45"}, - {"name": "Dishcare.Dishwasher.Program.Intensiv45"}, - {"name": "Dishcare.Dishwasher.Program.AutoHalfLoad"}, - {"name": "Dishcare.Dishwasher.Program.IntensivPower"}, - {"name": "Dishcare.Dishwasher.Program.MagicDaily"}, - {"name": "Dishcare.Dishwasher.Program.Super60"}, - {"name": "Dishcare.Dishwasher.Program.Kurz60"}, - {"name": "Dishcare.Dishwasher.Program.ExpressSparkle65"}, - {"name": "Dishcare.Dishwasher.Program.MachineCare"}, - {"name": "Dishcare.Dishwasher.Program.SteamFresh"}, - {"name": "Dishcare.Dishwasher.Program.MaximumCleaning"}, - ] - def get_entity_info(self): """Get a dictionary with infos about the associated entities.""" door_entity = self.get_door_entity() @@ -361,14 +317,6 @@ class Oven( ): """Oven class.""" - PROGRAMS = [ - {"name": "Cooking.Oven.Program.HeatingMode.PreHeating"}, - {"name": "Cooking.Oven.Program.HeatingMode.HotAir"}, - {"name": "Cooking.Oven.Program.HeatingMode.TopBottomHeating"}, - {"name": "Cooking.Oven.Program.HeatingMode.PizzaSetting"}, - {"name": "Cooking.Oven.Program.Microwave.600Watt"}, - ] - power_off_state = BSH_POWER_STANDBY def get_entity_info(self): @@ -395,30 +343,6 @@ class Washer( ): """Washer class.""" - PROGRAMS = [ - {"name": "LaundryCare.Washer.Program.Cotton"}, - {"name": "LaundryCare.Washer.Program.Cotton.CottonEco"}, - {"name": "LaundryCare.Washer.Program.EasyCare"}, - {"name": "LaundryCare.Washer.Program.Mix"}, - {"name": "LaundryCare.Washer.Program.DelicatesSilk"}, - {"name": "LaundryCare.Washer.Program.Wool"}, - {"name": "LaundryCare.Washer.Program.Sensitive"}, - {"name": "LaundryCare.Washer.Program.Auto30"}, - {"name": "LaundryCare.Washer.Program.Auto40"}, - {"name": "LaundryCare.Washer.Program.Auto60"}, - {"name": "LaundryCare.Washer.Program.Chiffon"}, - {"name": "LaundryCare.Washer.Program.Curtains"}, - {"name": "LaundryCare.Washer.Program.DarkWash"}, - {"name": "LaundryCare.Washer.Program.Dessous"}, - {"name": "LaundryCare.Washer.Program.Monsoon"}, - {"name": "LaundryCare.Washer.Program.Outdoor"}, - {"name": "LaundryCare.Washer.Program.PlushToy"}, - {"name": "LaundryCare.Washer.Program.ShirtsBlouses"}, - {"name": "LaundryCare.Washer.Program.SportFitness"}, - {"name": "LaundryCare.Washer.Program.Towels"}, - {"name": "LaundryCare.Washer.Program.WaterProof"}, - ] - def get_entity_info(self): """Get a dictionary with infos about the associated entities.""" door_entity = self.get_door_entity() @@ -437,23 +361,6 @@ class Washer( class CoffeeMaker(DeviceWithOpState, DeviceWithPrograms, DeviceWithRemoteStart): """Coffee maker class.""" - PROGRAMS = [ - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoMacchiato"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.Coffee"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.Cappuccino"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.LatteMacchiato"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeLatte"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Americano"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoDoppio"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.FlatWhite"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Galao"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.MilkFroth"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.WarmMilk"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.Ristretto"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Cortado"}, - ] - power_off_state = BSH_POWER_STANDBY def get_entity_info(self): @@ -479,12 +386,6 @@ class Hood( ): """Hood class.""" - PROGRAMS = [ - {"name": "Cooking.Common.Program.Hood.Automatic"}, - {"name": "Cooking.Common.Program.Hood.Venting"}, - {"name": "Cooking.Common.Program.Hood.DelayedShutOff"}, - ] - def get_entity_info(self): """Get a dictionary with infos about the associated entities.""" remote_control = self.get_remote_control() @@ -532,8 +433,6 @@ class Freezer(DeviceWithDoor): class Hob(DeviceWithOpState, DeviceWithPrograms, DeviceWithRemoteControl): """Hob class.""" - PROGRAMS = [{"name": "Cooking.Hob.Program.PowerLevelMode"}] - def get_entity_info(self): """Get a dictionary with infos about the associated entities.""" remote_control = self.get_remote_control() From 0666a4750c9b19aafa11da5911088655036ad7a5 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 28 Mar 2023 09:30:42 +0200 Subject: [PATCH 1341/2810] Add re-auth support to philips_js (#88774) * Add re-auth support to philips_js * Adjustments from review * Don't allow duplicate entries for now --- .../components/philips_js/__init__.py | 5 ++- .../components/philips_js/config_flow.py | 42 +++++++++++++++---- .../components/philips_js/strings.json | 3 +- .../components/philips_js/test_config_flow.py | 37 ++++++++++++++++ 4 files changed, 75 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/philips_js/__init__.py b/homeassistant/components/philips_js/__init__.py index 3145e82a94..55ac33d198 100644 --- a/homeassistant/components/philips_js/__init__.py +++ b/homeassistant/components/philips_js/__init__.py @@ -19,8 +19,9 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import CONF_ALLOW_NOTIFY, CONF_SYSTEM, DOMAIN @@ -171,4 +172,4 @@ class PhilipsTVDataUpdateCoordinator(DataUpdateCoordinator[None]): except ConnectionFailure: pass except AutenticationFailure as exception: - raise UpdateFailed(str(exception)) from exception + raise ConfigEntryAuthFailed(str(exception)) from exception diff --git a/homeassistant/components/philips_js/config_flow.py b/homeassistant/components/philips_js/config_flow.py index dab8d4fbe2..9b7e52c211 100644 --- a/homeassistant/components/philips_js/config_flow.py +++ b/homeassistant/components/philips_js/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Philips TV integration.""" from __future__ import annotations +from collections.abc import Mapping import platform from typing import Any @@ -20,6 +21,18 @@ from homeassistant.data_entry_flow import FlowResult from . import LOGGER from .const import CONF_ALLOW_NOTIFY, CONF_SYSTEM, CONST_APP_ID, CONST_APP_NAME, DOMAIN +USER_SCHEMA = vol.Schema( + { + vol.Required( + CONF_HOST, + ): str, + vol.Required( + CONF_API_VERSION, + default=1, + ): vol.In([1, 5, 6]), + } +) + async def _validate_input( hass: core.HomeAssistant, host: str, api_version: int @@ -47,9 +60,19 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._current: dict[str, Any] = {} self._hub: PhilipsTV | None = None self._pair_state: Any = None + self._entry: config_entries.ConfigEntry | None = None async def _async_create_current(self) -> FlowResult: system = self._current[CONF_SYSTEM] + if self._entry: + self.hass.config_entries.async_update_entry( + self._entry, data=self._entry.data | self._current + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self._entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") + return self.async_create_entry( title=f"{system['name']} ({system['serialnumber']})", data=self._current, @@ -108,6 +131,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._current[CONF_PASSWORD] = password return await self._async_create_current() + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle configuration by re-auth.""" + self._entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + self._current[CONF_HOST] = entry_data[CONF_HOST] + self._current[CONF_API_VERSION] = entry_data[CONF_API_VERSION] + return await self.async_step_user() + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -128,7 +158,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): else: if serialnumber := hub.system.get("serialnumber"): await self.async_set_unique_id(serialnumber) - self._abort_if_unique_id_configured() + if self._entry is None: + self._abort_if_unique_id_configured() self._current[CONF_SYSTEM] = hub.system self._current[CONF_API_VERSION] = hub.api_version @@ -138,14 +169,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_pair() return await self._async_create_current() - schema = vol.Schema( - { - vol.Required(CONF_HOST, default=self._current.get(CONF_HOST)): str, - vol.Required( - CONF_API_VERSION, default=self._current.get(CONF_API_VERSION, 1) - ): vol.In([1, 5, 6]), - } - ) + schema = self.add_suggested_values_to_schema(USER_SCHEMA, self._current) return self.async_show_form(step_id="user", data_schema=schema, errors=errors) @staticmethod diff --git a/homeassistant/components/philips_js/strings.json b/homeassistant/components/philips_js/strings.json index dc25838580..302e1b9acc 100644 --- a/homeassistant/components/philips_js/strings.json +++ b/homeassistant/components/philips_js/strings.json @@ -22,7 +22,8 @@ "invalid_pin": "Invalid PIN" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "options": { diff --git a/tests/components/philips_js/test_config_flow.py b/tests/components/philips_js/test_config_flow.py index 1662a2a3fc..603e278d59 100644 --- a/tests/components/philips_js/test_config_flow.py +++ b/tests/components/philips_js/test_config_flow.py @@ -12,6 +12,7 @@ from . import ( MOCK_CONFIG, MOCK_CONFIG_PAIRED, MOCK_PASSWORD, + MOCK_SYSTEM, MOCK_SYSTEM_UNPAIRED, MOCK_USERINPUT, MOCK_USERNAME, @@ -56,6 +57,42 @@ async def test_form(hass: HomeAssistant, mock_setup_entry) -> None: assert len(mock_setup_entry.mock_calls) == 1 +async def test_reauth( + hass: HomeAssistant, mock_setup_entry, mock_config_entry, mock_tv +) -> None: + """Test we get the form.""" + + mock_tv.system = MOCK_SYSTEM | {"model": "changed"} + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USERINPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert mock_config_entry.data == MOCK_CONFIG | {"system": mock_tv.system} + assert len(mock_setup_entry.mock_calls) == 2 + + async def test_form_cannot_connect(hass: HomeAssistant, mock_tv) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( From ff135ecdc667d6bd833719368c38637d3fc848e8 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 28 Mar 2023 01:31:36 -0600 Subject: [PATCH 1342/2810] Add a calendar entity to Ridwell (#88108) * Subclass a `DataUpdateCoordinator` for Ridwell * Add a calendar entity to Ridwell * Simpler unique ID * Fix tests * Docstring --- .coveragerc | 1 + homeassistant/components/ridwell/__init__.py | 2 +- homeassistant/components/ridwell/calendar.py | 78 +++++++++++++++++++ .../components/ridwell/coordinator.py | 8 +- .../components/ridwell/diagnostics.py | 6 +- homeassistant/components/ridwell/entity.py | 15 ++-- homeassistant/components/ridwell/sensor.py | 7 +- homeassistant/components/ridwell/switch.py | 16 +++- tests/components/ridwell/conftest.py | 21 +++-- tests/components/ridwell/test_diagnostics.py | 2 +- 10 files changed, 131 insertions(+), 25 deletions(-) create mode 100644 homeassistant/components/ridwell/calendar.py diff --git a/.coveragerc b/.coveragerc index 520b87b08b..dfc13304b1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -997,6 +997,7 @@ omit = homeassistant/components/rest/notify.py homeassistant/components/rest/switch.py homeassistant/components/ridwell/__init__.py + homeassistant/components/ridwell/calendar.py homeassistant/components/ridwell/coordinator.py homeassistant/components/ridwell/switch.py homeassistant/components/ring/camera.py diff --git a/homeassistant/components/ridwell/__init__.py b/homeassistant/components/ridwell/__init__.py index 116528f4ca..1b0a83f1c0 100644 --- a/homeassistant/components/ridwell/__init__.py +++ b/homeassistant/components/ridwell/__init__.py @@ -11,7 +11,7 @@ from homeassistant.helpers import entity_registry as er from .const import DOMAIN, LOGGER, SENSOR_TYPE_NEXT_PICKUP from .coordinator import RidwellDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH] +PLATFORMS: list[Platform] = [Platform.CALENDAR, Platform.SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/ridwell/calendar.py b/homeassistant/components/ridwell/calendar.py new file mode 100644 index 0000000000..57919ed1fe --- /dev/null +++ b/homeassistant/components/ridwell/calendar.py @@ -0,0 +1,78 @@ +"""Support for Ridwell calendars.""" +from __future__ import annotations + +import datetime + +from aioridwell.model import RidwellAccount, RidwellPickupEvent + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import RidwellDataUpdateCoordinator +from .entity import RidwellEntity + + +@callback +def async_get_calendar_event_from_pickup_event( + pickup_event: RidwellPickupEvent, +) -> CalendarEvent: + """Get a HASS CalendarEvent from an aioridwell PickupEvent.""" + pickup_type_string = ", ".join( + [ + f"{pickup.name} (quantity: {pickup.quantity})" + for pickup in pickup_event.pickups + ] + ) + return CalendarEvent( + summary=f"Ridwell Pickup ({pickup_event.state.value})", + description=f"Pickup types: {pickup_type_string}", + start=pickup_event.pickup_date, + end=pickup_event.pickup_date + datetime.timedelta(days=1), + ) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Ridwell calendars based on a config entry.""" + coordinator: RidwellDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + RidwellCalendar(coordinator, account) + for account in coordinator.accounts.values() + ) + + +class RidwellCalendar(RidwellEntity, CalendarEntity): + """Define a Ridwell calendar.""" + + _attr_icon = "mdi:delete-empty" + + def __init__( + self, coordinator: RidwellDataUpdateCoordinator, account: RidwellAccount + ) -> None: + """Initialize the Ridwell entity.""" + super().__init__(coordinator, account) + + self._attr_unique_id = self._account.account_id + self._event: CalendarEvent | None = None + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + return async_get_calendar_event_from_pickup_event(self.next_pickup_event) + + async def async_get_events( + self, + hass: HomeAssistant, + start_date: datetime.datetime, + end_date: datetime.datetime, + ) -> list[CalendarEvent]: + """Return calendar events within a datetime range.""" + return [ + async_get_calendar_event_from_pickup_event(event) + for event in self.coordinator.data[self._account.account_id] + ] diff --git a/homeassistant/components/ridwell/coordinator.py b/homeassistant/components/ridwell/coordinator.py index a3b83c70aa..9561cd26e4 100644 --- a/homeassistant/components/ridwell/coordinator.py +++ b/homeassistant/components/ridwell/coordinator.py @@ -22,14 +22,14 @@ UPDATE_INTERVAL = timedelta(hours=1) class RidwellDataUpdateCoordinator( - DataUpdateCoordinator[dict[str, RidwellPickupEvent]] + DataUpdateCoordinator[dict[str, list[RidwellPickupEvent]]] ): """Class to manage fetching data from single endpoint.""" config_entry: ConfigEntry def __init__(self, hass: HomeAssistant, *, name: str) -> None: - """Initialize global data updater.""" + """Initialize.""" # These will be filled in by async_initialize; we give them these defaults to # avoid arduous typing checks down the line: self.accounts: dict[str, RidwellAccount] = {} @@ -38,13 +38,13 @@ class RidwellDataUpdateCoordinator( super().__init__(hass, LOGGER, name=name, update_interval=UPDATE_INTERVAL) - async def _async_update_data(self) -> dict[str, RidwellPickupEvent]: + async def _async_update_data(self) -> dict[str, list[RidwellPickupEvent]]: """Fetch the latest data from the source.""" data = {} async def async_get_pickups(account: RidwellAccount) -> None: """Get the latest pickups for an account.""" - data[account.account_id] = await account.async_get_next_pickup_event() + data[account.account_id] = await account.async_get_pickup_events() tasks = [async_get_pickups(account) for account in self.accounts.values()] results = await asyncio.gather(*tasks, return_exceptions=True) diff --git a/homeassistant/components/ridwell/diagnostics.py b/homeassistant/components/ridwell/diagnostics.py index 772efb87ac..f48861cee1 100644 --- a/homeassistant/components/ridwell/diagnostics.py +++ b/homeassistant/components/ridwell/diagnostics.py @@ -32,7 +32,11 @@ async def async_get_config_entry_diagnostics( return async_redact_data( { "entry": entry.as_dict(), - "data": [dataclasses.asdict(event) for event in coordinator.data.values()], + "data": [ + dataclasses.asdict(event) + for events in coordinator.data.values() + for event in events + ], }, TO_REDACT, ) diff --git a/homeassistant/components/ridwell/entity.py b/homeassistant/components/ridwell/entity.py index 29dd68e2a8..9c7ceee7f5 100644 --- a/homeassistant/components/ridwell/entity.py +++ b/homeassistant/components/ridwell/entity.py @@ -1,8 +1,12 @@ """Define a base Ridwell entity.""" +from __future__ import annotations + +from datetime import date + from aioridwell.model import RidwellAccount, RidwellPickupEvent from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -18,7 +22,6 @@ class RidwellEntity(CoordinatorEntity[RidwellDataUpdateCoordinator]): self, coordinator: RidwellDataUpdateCoordinator, account: RidwellAccount, - description: EntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) @@ -31,10 +34,12 @@ class RidwellEntity(CoordinatorEntity[RidwellDataUpdateCoordinator]): manufacturer="Ridwell", name="Ridwell", ) - self._attr_unique_id = f"{account.account_id}_{description.key}" - self.entity_description = description @property def next_pickup_event(self) -> RidwellPickupEvent: """Get the next pickup event.""" - return self.coordinator.data[self._account.account_id] + return next( + event + for event in self.coordinator.data[self._account.account_id] + if event.pickup_date >= date.today() + ) diff --git a/homeassistant/components/ridwell/sensor.py b/homeassistant/components/ridwell/sensor.py index 05cee54ba9..1eba555e95 100644 --- a/homeassistant/components/ridwell/sensor.py +++ b/homeassistant/components/ridwell/sensor.py @@ -27,7 +27,7 @@ ATTR_QUANTITY = "quantity" SENSOR_DESCRIPTION = SensorEntityDescription( key=SENSOR_TYPE_NEXT_PICKUP, - name="Ridwell pickup", + name="Next Ridwell pickup", device_class=SensorDeviceClass.DATE, ) @@ -54,9 +54,10 @@ class RidwellSensor(RidwellEntity, SensorEntity): description: SensorEntityDescription, ) -> None: """Initialize.""" - super().__init__(coordinator, account, description) + super().__init__(coordinator, account) - self._attr_name = f"{description.name} ({account.address['street1']})" + self._attr_unique_id = f"{account.account_id}_{description.key}" + self.entity_description = description @property def extra_state_attributes(self) -> Mapping[str, Any]: diff --git a/homeassistant/components/ridwell/switch.py b/homeassistant/components/ridwell/switch.py index f16bbaebab..7a948f8b88 100644 --- a/homeassistant/components/ridwell/switch.py +++ b/homeassistant/components/ridwell/switch.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any from aioridwell.errors import RidwellError -from aioridwell.model import EventState +from aioridwell.model import EventState, RidwellAccount from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry @@ -38,7 +38,19 @@ async def async_setup_entry( class RidwellSwitch(RidwellEntity, SwitchEntity): - """Define a Ridwell button.""" + """Define a Ridwell switch.""" + + def __init__( + self, + coordinator: RidwellDataUpdateCoordinator, + account: RidwellAccount, + description: SwitchEntityDescription, + ) -> None: + """Initialize.""" + super().__init__(coordinator, account) + + self._attr_unique_id = f"{account.account_id}_{description.key}" + self.entity_description = description @property def is_on(self) -> bool: diff --git a/tests/components/ridwell/conftest.py b/tests/components/ridwell/conftest.py index e86243da53..57d485d428 100644 --- a/tests/components/ridwell/conftest.py +++ b/tests/components/ridwell/conftest.py @@ -3,6 +3,7 @@ from datetime import date from unittest.mock import AsyncMock, Mock, patch from aioridwell.model import EventState, RidwellPickup, RidwellPickupEvent +from freezegun import freeze_time import pytest from homeassistant.components.ridwell.const import DOMAIN @@ -28,14 +29,16 @@ def account_fixture(): "state": "New York", "postal_code": "10001", }, - async_get_next_pickup_event=AsyncMock( - return_value=RidwellPickupEvent( - None, - "event_123", - date(2022, 1, 24), - [RidwellPickup("Plastic Film", "offer_123", 1, "product_123", 1)], - EventState.INITIALIZED, - ) + async_get_pickup_events=AsyncMock( + return_value=[ + RidwellPickupEvent( + None, + "event_123", + date(2022, 1, 24), + [RidwellPickup("Plastic Film", "offer_123", 1, "product_123", 1)], + EventState.INITIALIZED, + ) + ] ), ) @@ -77,6 +80,8 @@ async def mock_aioridwell_fixture(hass, client, config): ), patch( "homeassistant.components.ridwell.coordinator.async_get_client", return_value=client, + ), freeze_time( + "2022-01-01" ): yield diff --git a/tests/components/ridwell/test_diagnostics.py b/tests/components/ridwell/test_diagnostics.py index e73b352f3d..caac488041 100644 --- a/tests/components/ridwell/test_diagnostics.py +++ b/tests/components/ridwell/test_diagnostics.py @@ -32,7 +32,7 @@ async def test_entry_diagnostics( "_async_request": None, "event_id": "event_123", "pickup_date": { - "__type": "", + "__type": "", "isoformat": "2022-01-24", }, "pickups": [ From 8b7594ae08c2b69d6c2a04644e9e8862e3e5efbe Mon Sep 17 00:00:00 2001 From: Aaron Godfrey Date: Tue, 28 Mar 2023 00:33:32 -0700 Subject: [PATCH 1343/2810] Look up todoist collaborators only when adding new task (#87957) * Look up collaborators only when adding new task. Also fixed a few api call arguments that were incorrect. The `labels` key should have been a list of strings and the `assignee` key should have been `assignee_id`. * Add missing type in test. * Remove print --- homeassistant/components/todoist/calendar.py | 24 +++------- tests/components/todoist/test_calendar.py | 49 +++++++++++++++++--- 2 files changed, 48 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 02459b429c..645fea865e 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -1,9 +1,7 @@ """Support for Todoist task management (https://todoist.com).""" from __future__ import annotations -import asyncio from datetime import date, datetime, timedelta -from itertools import chain import logging from typing import Any import uuid @@ -117,8 +115,6 @@ async def async_setup_platform( # Look up IDs based on (lowercase) names. project_id_lookup = {} - label_id_lookup = {} - collaborator_id_lookup = {} api = TodoistAPIAsync(token) @@ -126,9 +122,6 @@ async def async_setup_platform( # Grab all projects. projects = await api.get_projects() - collaborator_tasks = (api.get_collaborators(project.id) for project in projects) - collaborators = list(chain.from_iterable(await asyncio.gather(*collaborator_tasks))) - # Grab all labels labels = await api.get_labels() @@ -142,13 +135,6 @@ async def async_setup_platform( # Cache the names so we can easily look up name->ID. project_id_lookup[project.name.lower()] = project.id - # Cache all label names - label_id_lookup = {label.name.lower(): label.id for label in labels} - - collaborator_id_lookup = { - collab.name.lower(): collab.id for collab in collaborators - } - # Check config for more projects. extra_projects: list[CustomProject] = config[CONF_EXTRA_PROJECTS] for extra_project in extra_projects: @@ -194,14 +180,16 @@ async def async_setup_platform( data: dict[str, Any] = {"project_id": project_id} if task_labels := call.data.get(LABELS): - data["label_ids"] = [ - label_id_lookup[label.lower()] for label in task_labels - ] + data["labels"] = task_labels if ASSIGNEE in call.data: + collaborators = await api.get_collaborators(project_id) + collaborator_id_lookup = { + collab.name.lower(): collab.id for collab in collaborators + } task_assignee = call.data[ASSIGNEE].lower() if task_assignee in collaborator_id_lookup: - data["assignee"] = collaborator_id_lookup[task_assignee] + data["assignee_id"] = collaborator_id_lookup[task_assignee] else: raise ValueError( f"User is not part of the shared project. user: {task_assignee}" diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index adf0f8a14b..9c0680d144 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -1,14 +1,21 @@ """Unit tests for the Todoist calendar platform.""" -from datetime import datetime, timedelta +from datetime import timedelta from http import HTTPStatus from unittest.mock import AsyncMock, patch import urllib import pytest -from todoist_api_python.models import Due, Label, Project, Task +from todoist_api_python.models import Collaborator, Due, Label, Project, Task from homeassistant import setup -from homeassistant.components.todoist.calendar import DOMAIN +from homeassistant.components.todoist.const import ( + ASSIGNEE, + CONTENT, + DOMAIN, + LABELS, + PROJECT_NAME, + SERVICE_NEW_TASK, +) from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -30,9 +37,7 @@ def mock_task() -> Task: created_at="2021-10-01T00:00:00", creator_id="1", description="A task", - due=Due( - is_recurring=False, date=datetime.now().strftime("%Y-%m-%d"), string="today" - ), + due=Due(is_recurring=False, date=dt.now().strftime("%Y-%m-%d"), string="today"), id="1", labels=["Label1"], order=1, @@ -68,7 +73,9 @@ def mock_api(task) -> AsyncMock: api.get_labels.return_value = [ Label(id="1", name="Label1", color="1", order=1, is_favorite=False) ] - api.get_collaborators.return_value = [] + api.get_collaborators.return_value = [ + Collaborator(email="user@gmail.com", id="1", name="user") + ] api.get_tasks.return_value = [task] return api @@ -193,3 +200,31 @@ async def test_all_day_event( } ] assert events == expected + + +@patch("homeassistant.components.todoist.calendar.TodoistAPIAsync") +async def test_create_task_service_call(todoist_api, hass: HomeAssistant, api) -> None: + """Test api is called correctly after a new task service call.""" + todoist_api.return_value = api + assert await setup.async_setup_component( + hass, + "calendar", + { + "calendar": { + "platform": DOMAIN, + CONF_TOKEN: "token", + } + }, + ) + await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_NEW_TASK, + {ASSIGNEE: "user", CONTENT: "task", LABELS: ["Label1"], PROJECT_NAME: "Name"}, + ) + await hass.async_block_till_done() + + api.add_task.assert_called_with( + "task", project_id="12345", labels=["Label1"], assignee_id="1" + ) From 706e8d56128787478c0f1a28061005db097a8a5e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 28 Mar 2023 09:35:09 +0200 Subject: [PATCH 1344/2810] Add product calculation to Group sensor (#87373) * Group product * config flow --- homeassistant/components/group/config_flow.py | 1 + homeassistant/components/group/sensor.py | 14 ++++++++++++++ tests/components/group/test_sensor.py | 3 +++ 3 files changed, 18 insertions(+) diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index 069f74bf70..53a8fd0626 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -31,6 +31,7 @@ _STATISTIC_MEASURES = [ selector.SelectOptionDict(value="last", label="Most recently updated"), selector.SelectOptionDict(value="range", label="Statistical range"), selector.SelectOptionDict(value="sum", label="Sum"), + selector.SelectOptionDict(value="product", label="Product"), ] diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py index 265e1640d0..4c6e8dccc1 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -54,6 +54,7 @@ ATTR_LAST = "last" ATTR_LAST_ENTITY_ID = "last_entity_id" ATTR_RANGE = "range" ATTR_SUM = "sum" +ATTR_PRODUCT = "product" SENSOR_TYPES = { ATTR_MIN_VALUE: "min", ATTR_MAX_VALUE: "max", @@ -62,6 +63,7 @@ SENSOR_TYPES = { ATTR_LAST: "last", ATTR_RANGE: "range", ATTR_SUM: "sum", + ATTR_PRODUCT: "product", } SENSOR_TYPE_TO_ATTR = {v: k for k, v in SENSOR_TYPES.items()} @@ -226,6 +228,17 @@ def calc_sum( return {}, result +def calc_product( + sensor_values: list[tuple[str, float, State]] +) -> tuple[dict[str, str | None], float]: + """Calculate a product of values.""" + result = 1.0 + for _, sensor_value, _ in sensor_values: + result *= sensor_value + + return {}, result + + CALC_TYPES: dict[ str, Callable[ @@ -239,6 +252,7 @@ CALC_TYPES: dict[ "last": calc_last, "range": calc_range, "sum": calc_sum, + "product": calc_product, } diff --git a/tests/components/group/test_sensor.py b/tests/components/group/test_sensor.py index 5f85aa6485..39c9b788d5 100644 --- a/tests/components/group/test_sensor.py +++ b/tests/components/group/test_sensor.py @@ -1,6 +1,7 @@ """The tests for the Group Sensor platform.""" from __future__ import annotations +from math import prod import statistics from typing import Any from unittest.mock import patch @@ -45,6 +46,7 @@ MEAN = statistics.mean(VALUES) MEDIAN = statistics.median(VALUES) RANGE = max(VALUES) - min(VALUES) SUM_VALUE = sum(VALUES) +PRODUCT_VALUE = prod(VALUES) @pytest.mark.parametrize( @@ -57,6 +59,7 @@ SUM_VALUE = sum(VALUES) ("last", VALUES[2], {ATTR_LAST_ENTITY_ID: "sensor.test_3"}), ("range", RANGE, {}), ("sum", SUM_VALUE, {}), + ("product", PRODUCT_VALUE, {}), ], ) async def test_sensors( From 5e03272821d6923353b3554ec51d8f70aa9e24c7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Mar 2023 09:36:34 +0200 Subject: [PATCH 1345/2810] Bump pychromecast to 13.0.6 (#90390) --- homeassistant/components/cast/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index cc4a130a25..be80ca340f 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/cast", "iot_class": "local_polling", "loggers": ["casttube", "pychromecast"], - "requirements": ["pychromecast==13.0.4"], + "requirements": ["pychromecast==13.0.6"], "zeroconf": ["_googlecast._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index e0df5121a0..c3dfa944f1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1540,7 +1540,7 @@ pycfdns==2.0.1 pychannels==1.2.3 # homeassistant.components.cast -pychromecast==13.0.4 +pychromecast==13.0.6 # homeassistant.components.pocketcasts pycketcasts==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ceafe5d173..6edc309c81 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1128,7 +1128,7 @@ pybravia==0.3.2 pycfdns==2.0.1 # homeassistant.components.cast -pychromecast==13.0.4 +pychromecast==13.0.6 # homeassistant.components.comfoconnect pycomfoconnect==0.5.1 From 14ffda975893ebe897ef01e10458f941cec878ac Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 28 Mar 2023 09:37:07 +0200 Subject: [PATCH 1346/2810] Remove dependency on async_setup from mqtt integration (#87987) * Remove async_setup from mqtt integration * Final update common tests * Related tests init * Related tests diagnostics * Related tests config_flow * Cleanup and correct test * Keep websockets_api commands in async_setup --- homeassistant/components/mqtt/__init__.py | 143 +++---------- homeassistant/components/mqtt/config_flow.py | 2 +- homeassistant/components/mqtt/mixins.py | 2 +- tests/components/mqtt/test_common.py | 69 +++---- tests/components/mqtt/test_config_flow.py | 206 +++++++------------ tests/components/mqtt/test_diagnostics.py | 2 - tests/components/mqtt/test_init.py | 157 ++------------ 7 files changed, 153 insertions(+), 428 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 5a9eb7c3fc..24dc4b67cd 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -10,7 +10,7 @@ from typing import Any, cast import jinja2 import voluptuous as vol -from homeassistant import config as conf_util, config_entries +from homeassistant import config as conf_util from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -25,16 +25,10 @@ from homeassistant.const import ( ) from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import TemplateError, Unauthorized -from homeassistant.helpers import ( - config_validation as cv, - discovery_flow, - event, - template, -) +from homeassistant.helpers import config_validation as cv, event, template from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import async_get_platforms -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.reload import ( async_integration_yaml_config, async_reload_integration_platforms, @@ -52,11 +46,9 @@ from .client import ( # noqa: F401 subscribe, ) from .config_integration import ( - CONFIG_SCHEMA_BASE, CONFIG_SCHEMA_ENTRY, DEFAULT_VALUES, - DEPRECATED_CERTIFICATE_CONFIG_KEYS, - DEPRECATED_CONFIG_KEYS, + PLATFORM_CONFIG_SCHEMA_BASE, ) from .const import ( # noqa: F401 ATTR_PAYLOAD, @@ -99,7 +91,6 @@ from .models import ( # noqa: F401 from .util import ( async_create_certificate_temp_files, get_mqtt_data, - migrate_certificate_file_to_content, mqtt_config_entry_enabled, valid_publish_topic, valid_qos_schema, @@ -146,22 +137,22 @@ CONFIG_ENTRY_CONFIG_KEYS = [ CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.All( - cv.deprecated(CONF_BIRTH_MESSAGE), # Deprecated in HA Core 2022.3 - cv.deprecated(CONF_BROKER), # Deprecated in HA Core 2022.3 - cv.deprecated(CONF_CERTIFICATE), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_CLIENT_ID), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_CLIENT_CERT), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_CLIENT_KEY), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_DISCOVERY), # Deprecated in HA Core 2022.3 - cv.deprecated(CONF_DISCOVERY_PREFIX), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_KEEPALIVE), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_PASSWORD), # Deprecated in HA Core 2022.3 - cv.deprecated(CONF_PORT), # Deprecated in HA Core 2022.3 - cv.deprecated(CONF_PROTOCOL), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_TLS_INSECURE), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_USERNAME), # Deprecated in HA Core 2022.3 - cv.deprecated(CONF_WILL_MESSAGE), # Deprecated in HA Core 2022.3 - CONFIG_SCHEMA_BASE, + cv.removed(CONF_BIRTH_MESSAGE), # Removed in HA Core 2023.4 + cv.removed(CONF_BROKER), # Removed in HA Core 2023.4 + cv.removed(CONF_CERTIFICATE), # Removed in HA Core 2023.4 + cv.removed(CONF_CLIENT_ID), # Removed in HA Core 2023.4 + cv.removed(CONF_CLIENT_CERT), # Removed in HA Core 2023.4 + cv.removed(CONF_CLIENT_KEY), # Removed in HA Core 2023.4 + cv.removed(CONF_DISCOVERY), # Removed in HA Core 2022.3 + cv.removed(CONF_DISCOVERY_PREFIX), # Removed in HA Core 2023.4 + cv.removed(CONF_KEEPALIVE), # Removed in HA Core 2023.4 + cv.removed(CONF_PASSWORD), # Removed in HA Core 2023.4 + cv.removed(CONF_PORT), # Removed in HA Core 2023.4 + cv.removed(CONF_PROTOCOL), # Removed in HA Core 2023.4 + cv.removed(CONF_TLS_INSECURE), # Removed in HA Core 2023.4 + cv.removed(CONF_USERNAME), # Removed in HA Core 2023.4 + cv.removed(CONF_WILL_MESSAGE), # Removed in HA Core 2023.4 + PLATFORM_CONFIG_SCHEMA_BASE, ) }, extra=vol.ALLOW_EXTRA, @@ -197,34 +188,8 @@ async def _async_setup_discovery( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the MQTT protocol service.""" - mqtt_data = get_mqtt_data(hass, True) - - conf: ConfigType | None = config.get(DOMAIN) - websocket_api.async_register_command(hass, websocket_subscribe) websocket_api.async_register_command(hass, websocket_mqtt_info) - - if conf: - conf = dict(conf) - mqtt_data.config = conf - - if (mqtt_entry_status := mqtt_config_entry_enabled(hass)) is None: - # Create an import flow if the user has yaml configured entities etc. - # but no broker configuration. Note: The intention is not for this to - # import broker configuration from YAML because that has been deprecated. - discovery_flow.async_create_flow( - hass, - DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data={}, - ) - mqtt_data.reload_needed = True - elif mqtt_entry_status is False: - _LOGGER.info( - "MQTT will be not available until the config entry is enabled", - ) - mqtt_data.reload_needed = True - return True @@ -247,30 +212,15 @@ def _filter_entry_config(hass: HomeAssistant, entry: ConfigEntry) -> None: hass.config_entries.async_update_entry(entry, data=filtered_data) -async def _async_merge_basic_config( +async def _async_auto_mend_config( hass: HomeAssistant, entry: ConfigEntry, yaml_config: dict[str, Any] ) -> None: - """Merge basic options in configuration.yaml config with config entry. + """Mends config fetched from config entry and adds missing values. This mends incomplete migration from old version of HA Core. """ entry_updated = False entry_config = {**entry.data} - for key in DEPRECATED_CERTIFICATE_CONFIG_KEYS: - if key in yaml_config and key not in entry_config: - if ( - content := await hass.async_add_executor_job( - migrate_certificate_file_to_content, yaml_config[key] - ) - ) is not None: - entry_config[key] = content - entry_updated = True - - for key in DEPRECATED_CONFIG_KEYS: - if key in yaml_config and key not in entry_config: - entry_config[key] = yaml_config[key] - entry_updated = True - for key in MANDATORY_DEFAULT_VALUES: if key not in entry_config: entry_config[key] = DEFAULT_VALUES[key] @@ -298,17 +248,16 @@ async def _async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) - async def async_fetch_config( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any] | None: - """Fetch fresh MQTT yaml config from the hass config when (re)loading the entry.""" + """Fetch fresh MQTT yaml config from the hass config.""" mqtt_data = get_mqtt_data(hass) - if mqtt_data.reload_entry: - hass_config = await conf_util.async_hass_config_yaml(hass) - mqtt_data.config = CONFIG_SCHEMA_BASE(hass_config.get(DOMAIN, {})) + hass_config = await conf_util.async_hass_config_yaml(hass) + mqtt_data.config = PLATFORM_CONFIG_SCHEMA_BASE(hass_config.get(DOMAIN, {})) # Remove unknown keys from config entry data _filter_entry_config(hass, entry) - # Merge basic configuration, and add missing defaults for basic options - await _async_merge_basic_config(hass, entry, mqtt_data.config or {}) + # Add missing defaults to migrate older config entries + await _async_auto_mend_config(hass, entry, mqtt_data.config or {}) # Bail out if broker setting is missing if CONF_BROKER not in entry.data: _LOGGER.error("MQTT broker is not configured, please configure it") @@ -319,37 +268,6 @@ async def async_fetch_config( if (conf := mqtt_data.config) is None: conf = CONFIG_SCHEMA_ENTRY(dict(entry.data)) - # User has configuration.yaml config, warn about config entry overrides - elif any(key in conf for key in entry.data): - shared_keys = conf.keys() & entry.data.keys() - override = {k: entry.data[k] for k in shared_keys if conf[k] != entry.data[k]} - if CONF_PASSWORD in override: - override[CONF_PASSWORD] = "********" - if CONF_CLIENT_KEY in override: - override[CONF_CLIENT_KEY] = "-----PRIVATE KEY-----" - if override: - _LOGGER.warning( - ( - "Deprecated configuration settings found in configuration.yaml. " - "These settings from your configuration entry will override: %s" - ), - override, - ) - # Register a repair issue - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml_broker_settings", - breaks_in_ha_version="2023.4.0", # Warning first added in 2022.11.0 - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml_broker_settings", - translation_placeholders={ - "more_info_url": "https://www.home-assistant.io/integrations/mqtt/", - "deprecated_settings": str(shared_keys)[1:-1], - }, - ) - # Merge advanced configuration values from configuration.yaml conf = _merge_extended_config(entry, conf) return conf @@ -359,10 +277,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Load a config entry.""" mqtt_data = get_mqtt_data(hass, True) - # Merge basic configuration, and add missing defaults for basic options + # Fetch configuration and add missing defaults for basic options if (conf := await async_fetch_config(hass, entry)) is None: # Bail out return False + await async_create_certificate_temp_files(hass, dict(entry.data)) mqtt_data.client = MQTT(hass, entry, conf) # Restore saved subscriptions @@ -480,6 +399,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _reload_config(call: ServiceCall) -> None: """Reload the platforms.""" + # Fetch updated manual configured items and validate + config_yaml = await async_integration_yaml_config(hass, DOMAIN) or {} + mqtt_data.updated_config = config_yaml.get(DOMAIN, {}) + # Reload the modern yaml platforms mqtt_platforms = async_get_platforms(hass, DOMAIN) tasks = [ @@ -493,8 +416,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ] await asyncio.gather(*tasks) - config_yaml = await async_integration_yaml_config(hass, DOMAIN) or {} - mqtt_data.updated_config = config_yaml.get(DOMAIN, {}) await asyncio.gather( *( [ diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 66424f2c3d..77c3856aac 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -588,7 +588,7 @@ async def async_get_broker_settings( current_user = user_input_basic.get(CONF_USERNAME) current_pass = user_input_basic.get(CONF_PASSWORD) else: - # Get default settings from entry or yaml (if any) + # Get default settings from entry (if any) current_broker = current_config.get(CONF_BROKER) current_port = current_config.get(CONF_PORT, DEFAULT_PORT) current_user = current_config.get(CONF_USERNAME) diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index b52c57ce24..cecb4b88bc 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -247,7 +247,7 @@ def warn_for_legacy_schema(domain: str) -> Callable[[ConfigType], ConfigType]: ( "Manually configured MQTT %s(s) found under platform key '%s', " "please move to the mqtt integration key, see " - "https://www.home-assistant.io/integrations/%s.mqtt/#new_format" + "https://www.home-assistant.io/integrations/%s.mqtt/" ), domain, domain, diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index 6d238a63f4..154f91974a 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -36,7 +36,6 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, async_fire_mqtt_message from tests.typing import MqttMockHAClient, MqttMockHAClientGenerator, MqttMockPahoClient @@ -109,14 +108,15 @@ async def help_setup_component( item += 1 topic = f"homeassistant/{domain}/item_{item}/config" async_fire_mqtt_message(hass, topic, json.dumps(comp)) + await hass.async_block_till_done() else: - await async_setup_component( - hass, - mqtt.DOMAIN, - config, + entry = MockConfigEntry( + domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"} ) + entry.add_to_hass(hass) + with patch("homeassistant.config.load_yaml_config_file", return_value=config): + await entry.async_setup(hass) mqtt_mock = None - await hass.async_block_till_done() return mqtt_mock @@ -226,7 +226,7 @@ async def help_test_default_availability_payload( async def help_test_default_availability_list_payload( hass: HomeAssistant, - mqtt_mock_entry_with_no_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, domain: str, config: ConfigType, no_assumed_state: bool = False, @@ -243,7 +243,7 @@ async def help_test_default_availability_list_payload( {"topic": "availability-topic1"}, {"topic": "availability-topic2"}, ] - await help_setup_component(hass, mqtt_mock_entry_with_no_config, domain, config) + await help_setup_component(hass, mqtt_mock_entry_no_yaml_config, domain, config) state = hass.states.get(f"{domain}.test") assert state and state.state == STATE_UNAVAILABLE @@ -1169,7 +1169,7 @@ async def help_test_entity_id_update_subscriptions( entity_registry = er.async_get(hass) mqtt_mock = await help_setup_component( - hass, mqtt_mock_entry_no_yaml_config, domain, config, True + hass, mqtt_mock_entry_no_yaml_config, domain, config, use_discovery=True ) assert mqtt_mock is not None @@ -1796,27 +1796,6 @@ async def help_test_reload_with_config( await hass.async_block_till_done() -async def help_test_entry_reload_with_new_config( - hass: HomeAssistant, tmp_path: Path, new_config: ConfigType -) -> None: - """Test reloading with supplied config.""" - mqtt_config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] - assert mqtt_config_entry.state is ConfigEntryState.LOADED - new_yaml_config_file = tmp_path / "configuration.yaml" - new_yaml_config = yaml.dump(new_config) - new_yaml_config_file.write_text(new_yaml_config) - assert new_yaml_config_file.read_text() == new_yaml_config - - with patch.object( - module_hass_config, "YAML_CONFIG_FILE", new_yaml_config_file - ), patch("paho.mqtt.client.Client") as mock_client: - mock_client().connect = lambda *args: 0 - # reload the config entry - assert await hass.config_entries.async_reload(mqtt_config_entry.entry_id) - assert mqtt_config_entry.state is ConfigEntryState.LOADED - await hass.async_block_till_done() - - async def help_test_reloadable( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, @@ -1839,10 +1818,8 @@ async def help_test_reloadable( entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) entry.add_to_hass(hass) mqtt_client_mock.connect.return_value = 0 - # We should call await mqtt.async_setup_entry(hass, entry) when async_setup - # is removed (this is planned with #87987). Until then we set up the mqtt component - # to test reload after the async_setup setup has set the initial config - await help_setup_component(hass, None, domain, old_config, use_discovery=False) + with patch("homeassistant.config.load_yaml_config_file", return_value=old_config): + await entry.async_setup(hass) assert hass.states.get(f"{domain}.test_old_1") assert hass.states.get(f"{domain}.test_old_2") @@ -1860,15 +1837,15 @@ async def help_test_reloadable( new_config = { mqtt.DOMAIN: {domain: [new_config_1, new_config_2, new_config_extra]}, } - module_hass_config.load_yaml_config_file.return_value = new_config - # Reload the mqtt entry with the new config - await hass.services.async_call( - "mqtt", - SERVICE_RELOAD, - {}, - blocking=True, - ) - await hass.async_block_till_done() + with patch("homeassistant.config.load_yaml_config_file", return_value=new_config): + # Reload the mqtt entry with the new config + await hass.services.async_call( + "mqtt", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() assert len(hass.states.async_all(domain)) == 3 @@ -1900,9 +1877,9 @@ async def help_test_unload_config_entry_with_platform( config_setup: dict[str, dict[str, Any]] = copy.deepcopy(config) config_setup[mqtt.DOMAIN][domain]["name"] = "config_setup" config_name = config_setup - await help_setup_component( - hass, mqtt_mock_entry_no_yaml_config, domain, config_setup - ) + + with patch("homeassistant.config.load_yaml_config_file", return_value=config_name): + await mqtt_mock_entry_no_yaml_config() # prepare setup through discovery discovery_setup = copy.deepcopy(config[mqtt.DOMAIN][domain]) diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 99a90bac83..ae7c4089e5 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -9,13 +9,11 @@ from uuid import uuid4 import pytest import voluptuous as vol -import yaml -from homeassistant import config as hass_config, config_entries, data_entry_flow +from homeassistant import config_entries, data_entry_flow from homeassistant.components import mqtt from homeassistant.components.hassio import HassioServiceInfo from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient @@ -267,39 +265,13 @@ async def test_user_connection_fails( assert len(mock_finish_setup.mock_calls) == 0 -async def test_manual_config_starts_discovery_flow( - hass: HomeAssistant, - mock_try_connection: MqttMockPahoClient, - mock_finish_setup: MagicMock, -) -> None: - """Test manual config initiates a discovery flow.""" - # No flows in progress - assert hass.config_entries.flow.async_progress() == [] - - # MQTT config present in yaml config - assert await async_setup_component(hass, "mqtt", {"mqtt": {}}) - await hass.async_block_till_done() - assert len(mock_finish_setup.mock_calls) == 0 - - # There should now be a discovery flow - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - assert flows[0]["context"]["source"] == "integration_discovery" - assert flows[0]["handler"] == "mqtt" - assert flows[0]["step_id"] == "broker" - - +@pytest.mark.parametrize("hass_config", [{"mqtt": {"sensor": {"state_topic": "test"}}}]) async def test_manual_config_set( hass: HomeAssistant, mock_try_connection: MqttMockPahoClient, mock_finish_setup: MagicMock, ) -> None: """Test manual config does not create an entry, and entry can be setup late.""" - # MQTT config present in yaml config - assert await async_setup_component(hass, "mqtt", {"mqtt": {"broker": "bla"}}) - await hass.async_block_till_done() - # do not try to reload - hass.data["mqtt"].reload_needed = False assert len(mock_finish_setup.mock_calls) == 0 mock_try_connection.return_value = True @@ -1162,6 +1134,9 @@ async def test_options_bad_will_message_fails( } +@pytest.mark.parametrize( + "hass_config", [{"mqtt": {"sensor": [{"state_topic": "some-topic"}]}}] +) async def test_try_connection_with_advanced_parameters( hass: HomeAssistant, mock_try_connection_success: MqttMockPahoClient, @@ -1170,23 +1145,6 @@ async def test_try_connection_with_advanced_parameters( mock_process_uploaded_file: MagicMock, ) -> None: """Test config flow with advanced parameters from config.""" - - with open(tmp_path / "client.crt", "wb") as certfile: - certfile.write(MOCK_CLIENT_CERT) - with open(tmp_path / "client.key", "wb") as keyfile: - keyfile.write(MOCK_CLIENT_KEY) - - config = { - "certificate": "auto", - "tls_insecure": True, - "client_cert": str(tmp_path / "client.crt"), - "client_key": str(tmp_path / "client.key"), - } - new_yaml_config_file = tmp_path / "configuration.yaml" - new_yaml_config = yaml.dump({mqtt.DOMAIN: config}) - new_yaml_config_file.write_text(new_yaml_config) - assert new_yaml_config_file.read_text() == new_yaml_config - config_entry = MockConfigEntry(domain=mqtt.DOMAIN) config_entry.add_to_hass(hass) config_entry.data = { @@ -1195,6 +1153,10 @@ async def test_try_connection_with_advanced_parameters( mqtt.CONF_USERNAME: "user", mqtt.CONF_PASSWORD: "pass", mqtt.CONF_TRANSPORT: "websockets", + mqtt.CONF_CERTIFICATE: "auto", + mqtt.CONF_TLS_INSECURE: True, + mqtt.CONF_CLIENT_CERT: MOCK_CLIENT_CERT.decode(encoding="utf-8)"), + mqtt.CONF_CLIENT_KEY: MOCK_CLIENT_KEY.decode(encoding="utf-8"), mqtt.CONF_WS_PATH: "/path/", mqtt.CONF_WS_HEADERS: {"h1": "v1", "h2": "v2"}, mqtt.CONF_KEEPALIVE: 30, @@ -1212,95 +1174,81 @@ async def test_try_connection_with_advanced_parameters( mqtt.ATTR_RETAIN: False, }, } + # Test default/suggested values from config + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "broker" + defaults = { + mqtt.CONF_BROKER: "test-broker", + mqtt.CONF_PORT: 1234, + "set_client_cert": True, + "set_ca_cert": "auto", + } + suggested = { + mqtt.CONF_USERNAME: "user", + mqtt.CONF_PASSWORD: "pass", + mqtt.CONF_TLS_INSECURE: True, + mqtt.CONF_PROTOCOL: "3.1.1", + mqtt.CONF_TRANSPORT: "websockets", + mqtt.CONF_WS_PATH: "/path/", + mqtt.CONF_WS_HEADERS: '{"h1":"v1","h2":"v2"}', + } + for k, v in defaults.items(): + assert get_default(result["data_schema"].schema, k) == v + for k, v in suggested.items(): + assert get_suggested(result["data_schema"].schema, k) == v - with patch.object(hass_config, "YAML_CONFIG_FILE", new_yaml_config_file): - await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - # Test default/suggested values from config - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "broker" - defaults = { - mqtt.CONF_BROKER: "test-broker", - mqtt.CONF_PORT: 1234, - "set_client_cert": True, + # test we can change username and password + # as it was configured as auto in configuration.yaml is is migrated now + mock_try_connection_success.reset_mock() + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "another-broker", + mqtt.CONF_PORT: 2345, + mqtt.CONF_USERNAME: "us3r", + mqtt.CONF_PASSWORD: "p4ss", "set_ca_cert": "auto", - } - suggested = { - mqtt.CONF_USERNAME: "user", - mqtt.CONF_PASSWORD: "pass", + "set_client_cert": True, mqtt.CONF_TLS_INSECURE: True, - mqtt.CONF_PROTOCOL: "3.1.1", mqtt.CONF_TRANSPORT: "websockets", - mqtt.CONF_WS_PATH: "/path/", - mqtt.CONF_WS_HEADERS: '{"h1":"v1","h2":"v2"}', - } - for k, v in defaults.items(): - assert get_default(result["data_schema"].schema, k) == v - for k, v in suggested.items(): - assert get_suggested(result["data_schema"].schema, k) == v + mqtt.CONF_WS_PATH: "/new/path", + mqtt.CONF_WS_HEADERS: '{"h3": "v3"}', + }, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {} + assert result["step_id"] == "options" + await hass.async_block_till_done() - # test the client cert and key were migrated to the entry - assert config_entry.data[mqtt.CONF_CLIENT_CERT] == MOCK_CLIENT_CERT.decode( - "utf-8" - ) - assert config_entry.data[mqtt.CONF_CLIENT_KEY] == MOCK_CLIENT_KEY.decode( - "utf-8" - ) - assert config_entry.data[mqtt.CONF_CERTIFICATE] == "auto" + # check if the username and password was set from config flow and not from configuration.yaml + assert mock_try_connection_success.username_pw_set.mock_calls[0][1] == ( + "us3r", + "p4ss", + ) + # check if tls_insecure_set is called + assert mock_try_connection_success.tls_insecure_set.mock_calls[0][1] == (True,) - # test we can change username and password - # as it was configured as auto in configuration.yaml is is migrated now - mock_try_connection_success.reset_mock() - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - mqtt.CONF_BROKER: "another-broker", - mqtt.CONF_PORT: 2345, - mqtt.CONF_USERNAME: "us3r", - mqtt.CONF_PASSWORD: "p4ss", - "set_ca_cert": "auto", - "set_client_cert": True, - mqtt.CONF_TLS_INSECURE: True, - mqtt.CONF_TRANSPORT: "websockets", - mqtt.CONF_WS_PATH: "/new/path", - mqtt.CONF_WS_HEADERS: '{"h3": "v3"}', - }, - ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["errors"] == {} - assert result["step_id"] == "options" - await hass.async_block_till_done() + # check if the ca certificate settings were not set during connection test + assert mock_try_connection_success.tls_set.mock_calls[0].kwargs[ + "certfile" + ] == mqtt.util.get_file_path(mqtt.CONF_CLIENT_CERT) + assert mock_try_connection_success.tls_set.mock_calls[0].kwargs[ + "keyfile" + ] == mqtt.util.get_file_path(mqtt.CONF_CLIENT_KEY) - # check if the username and password was set from config flow and not from configuration.yaml - assert mock_try_connection_success.username_pw_set.mock_calls[0][1] == ( - "us3r", - "p4ss", - ) - # check if tls_insecure_set is called - assert mock_try_connection_success.tls_insecure_set.mock_calls[0][1] == (True,) - - # check if the ca certificate settings were not set during connection test - assert mock_try_connection_success.tls_set.mock_calls[0].kwargs[ - "certfile" - ] == mqtt.util.get_file_path(mqtt.CONF_CLIENT_CERT) - assert mock_try_connection_success.tls_set.mock_calls[0].kwargs[ - "keyfile" - ] == mqtt.util.get_file_path(mqtt.CONF_CLIENT_KEY) - - # check if websockets options are set - assert mock_try_connection_success.ws_set_options.mock_calls[0][1] == ( - "/new/path", - {"h3": "v3"}, - ) - - # Accept default option - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={}, - ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - await hass.async_block_till_done() + # check if websockets options are set + assert mock_try_connection_success.ws_set_options.mock_calls[0][1] == ( + "/new/path", + {"h3": "v3"}, + ) + # Accept default option + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + await hass.async_block_till_done() async def test_setup_with_advanced_settings( diff --git a/tests/components/mqtt/test_diagnostics.py b/tests/components/mqtt/test_diagnostics.py index 780be72925..cfc6f069b0 100644 --- a/tests/components/mqtt/test_diagnostics.py +++ b/tests/components/mqtt/test_diagnostics.py @@ -25,8 +25,6 @@ default_config = { "port": 1883, "protocol": "3.1.1", "transport": "tcp", - "ws_headers": {}, - "ws_path": "/", "will_message": { "payload": "offline", "qos": 0, diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 9fbca57e3a..cdc31429e2 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -5,18 +5,15 @@ import copy from datetime import datetime, timedelta from functools import partial import json -from pathlib import Path import ssl from typing import Any, TypedDict from unittest.mock import ANY, MagicMock, call, mock_open, patch import pytest import voluptuous as vol -import yaml -from homeassistant import config as module_hass_config from homeassistant.components import mqtt -from homeassistant.components.mqtt import CONFIG_SCHEMA, debug_info +from homeassistant.components.mqtt import debug_info from homeassistant.components.mqtt.client import EnsureJobAfterCooldown from homeassistant.components.mqtt.mixins import MQTT_ENTITY_DEVICE_INFO_SCHEMA from homeassistant.components.mqtt.models import MessageCallbackType, ReceiveMessage @@ -40,10 +37,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from .test_common import ( - help_test_entry_reload_with_new_config, - help_test_validate_platform_config, -) +from .test_common import help_test_validate_platform_config from tests.common import ( MockConfigEntry, @@ -1529,18 +1523,17 @@ async def test_subscribed_at_highest_qos( async def test_reload_entry_with_restored_subscriptions( hass: HomeAssistant, - tmp_path: Path, mqtt_client_mock: MqttMockPahoClient, record_calls: MessageCallbackType, calls: list[ReceiveMessage], ) -> None: """Test reloading the config entry with with subscriptions restored.""" - + # Setup the MQTT entry entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) entry.add_to_hass(hass) mqtt_client_mock.connect.return_value = 0 - assert await mqtt.async_setup_entry(hass, entry) - await hass.async_block_till_done() + with patch("homeassistant.config.load_yaml_config_file", return_value={}): + await entry.async_setup(hass) await mqtt.async_subscribe(hass, "test-topic", record_calls) await mqtt.async_subscribe(hass, "wild/+/card", record_calls) @@ -1557,10 +1550,10 @@ async def test_reload_entry_with_restored_subscriptions( calls.clear() # Reload the entry - config_yaml_new = {} - await help_test_entry_reload_with_new_config(hass, tmp_path, config_yaml_new) - - await hass.async_block_till_done() + with patch("homeassistant.config.load_yaml_config_file", return_value={}): + assert await hass.config_entries.async_reload(entry.entry_id) + assert entry.state is ConfigEntryState.LOADED + await hass.async_block_till_done() async_fire_mqtt_message(hass, "test-topic", "test-payload2") async_fire_mqtt_message(hass, "wild/any/card", "wild-card-payload2") @@ -1574,10 +1567,10 @@ async def test_reload_entry_with_restored_subscriptions( calls.clear() # Reload the entry again - config_yaml_new = {} - await help_test_entry_reload_with_new_config(hass, tmp_path, config_yaml_new) - - await hass.async_block_till_done() + with patch("homeassistant.config.load_yaml_config_file", return_value={}): + assert await hass.config_entries.async_reload(entry.entry_id) + assert entry.state is ConfigEntryState.LOADED + await hass.async_block_till_done() async_fire_mqtt_message(hass, "test-topic", "test-payload3") async_fire_mqtt_message(hass, "wild/any/card", "wild-card-payload3") @@ -1804,55 +1797,6 @@ async def test_handle_message_callback( assert callbacks[0].payload == "test-payload" -async def test_setup_override_configuration( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path -) -> None: - """Test override setup from configuration entry.""" - calls_username_password_set = [] - - def mock_usename_password_set(username: str, password: str) -> None: - calls_username_password_set.append((username, password)) - - # Mock password setup from config - config = { - "username": "someuser", - "password": "someyamlconfiguredpassword", - "protocol": "3.1", - } - new_yaml_config_file = tmp_path / "configuration.yaml" - new_yaml_config = yaml.dump({mqtt.DOMAIN: config}) - new_yaml_config_file.write_text(new_yaml_config) - assert new_yaml_config_file.read_text() == new_yaml_config - - with patch.object(module_hass_config, "YAML_CONFIG_FILE", new_yaml_config_file): - # Mock config entry - entry = MockConfigEntry( - domain=mqtt.DOMAIN, - data={mqtt.CONF_BROKER: "test-broker", "password": "somepassword"}, - ) - entry.add_to_hass(hass) - - with patch("paho.mqtt.client.Client") as mock_client: - mock_client().username_pw_set = mock_usename_password_set - mock_client.on_connect(return_value=0) - await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await entry.async_setup(hass) - await hass.async_block_till_done() - - assert ( - "Deprecated configuration settings found in configuration.yaml. " - "These settings from your configuration entry will override:" - in caplog.text - ) - - # Check if the protocol was set to 3.1 from configuration.yaml - assert mock_client.call_args[1]["protocol"] == 3 - - # Check if the password override worked - assert calls_username_password_set[0][0] == "someuser" - assert calls_username_password_set[0][1] == "somepassword" - - @patch("homeassistant.components.mqtt.PLATFORMS", []) async def test_setup_manual_mqtt_with_platform_key( hass: HomeAssistant, caplog: pytest.LogCaptureFixture @@ -2312,39 +2256,10 @@ async def test_mqtt_subscribes_topics_on_connect( mqtt_client_mock.subscribe.assert_any_call("still/pending", 1) -async def test_setup_entry_with_config_override( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, -) -> None: - """Test if the MQTT component loads with no config and config entry can be setup.""" - data = ( - '{ "device":{"identifiers":["0AFFD2"]},' - ' "state_topic": "foobar/sensor",' - ' "unique_id": "unique" }' - ) - - # mqtt present in yaml config - assert await async_setup_component(hass, mqtt.DOMAIN, {}) - await hass.async_block_till_done() - - # User sets up a config entry - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) - entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - # Discover a device to verify the entry was setup correctly - async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) - await hass.async_block_till_done() - - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) - assert device_entry is not None - - async def test_update_incomplete_entry( hass: HomeAssistant, device_registry: dr.DeviceRegistry, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, mqtt_client_mock: MqttMockPahoClient, caplog: pytest.LogCaptureFixture, ) -> None: @@ -2356,24 +2271,17 @@ async def test_update_incomplete_entry( ) # Config entry data is incomplete - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={"port": 1234}) - entry.add_to_hass(hass) - # Mqtt present in yaml config - config = {"broker": "yaml_broker"} - await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) + entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + entry.data = {"broker": "test-broker", "port": 1234} + await mqtt_mock_entry_no_yaml_config() await hass.async_block_till_done() # Config entry data should now be updated assert dict(entry.data) == { + "broker": "test-broker", "port": 1234, "discovery_prefix": "homeassistant", - "broker": "yaml_broker", } - # Warnings about broker deprecated, but not about other keys with default values - assert ( - "The 'broker' option is deprecated, please remove it from your configuration" - in caplog.text - ) # Discover a device to verify the entry was setup correctly async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) @@ -3219,7 +3127,7 @@ async def test_subscribe_connection_status( # This warning and test is to be removed from HA core 2023.6 async def test_one_deprecation_warning_per_platform( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test a deprecation warning is is logged once per platform.""" @@ -3230,8 +3138,6 @@ async def test_one_deprecation_warning_per_platform( config2 = copy.deepcopy(config) config2["name"] = "test2" await async_setup_component(hass, platform, {platform: [config1, config2]}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() count = 0 for record in caplog.records: if record.levelname == "ERROR" and ( @@ -3242,13 +3148,6 @@ async def test_one_deprecation_warning_per_platform( assert count == 1 -async def test_config_schema_validation(hass: HomeAssistant) -> None: - """Test invalid platform options in the config schema do not pass the config validation.""" - config = {"mqtt": {"sensor": [{"some_illegal_topic": "mystate/topic/path"}]}} - with pytest.raises(vol.MultipleInvalid): - CONFIG_SCHEMA(config) - - @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT]) async def test_unload_config_entry( hass: HomeAssistant, @@ -3277,24 +3176,6 @@ async def test_unload_config_entry( assert "No ACK from MQTT server" not in caplog.text -@patch("homeassistant.components.mqtt.PLATFORMS", []) -async def test_setup_with_disabled_entry( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test setting up the platform with a disabled config entry.""" - # Try to setup the platform with a disabled config entry - config_entry = MockConfigEntry( - domain=mqtt.DOMAIN, data={}, disabled_by=ConfigEntryDisabler.USER - ) - config_entry.add_to_hass(hass) - - config: ConfigType = {mqtt.DOMAIN: {}} - await async_setup_component(hass, mqtt.DOMAIN, config) - await hass.async_block_till_done() - - assert "MQTT will be not available until the config entry is enabled" in caplog.text - - @patch("homeassistant.components.mqtt.PLATFORMS", []) async def test_publish_or_subscribe_without_valid_config_entry( hass: HomeAssistant, record_calls: MessageCallbackType From c51ed4b3281368b6724610e6c7c4374f90367f52 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 28 Mar 2023 03:59:01 -0400 Subject: [PATCH 1347/2810] Redact secret zwave values in diagnostics (#90389) * redact secret zwave values from diagnostics * shhrink * rename --- .../components/zwave_js/diagnostics.py | 20 ++++++--- tests/components/zwave_js/test_diagnostics.py | 42 +++++++++++++++++++ 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zwave_js/diagnostics.py b/homeassistant/components/zwave_js/diagnostics.py index acb87a239a..4f52c41a08 100644 --- a/homeassistant/components/zwave_js/diagnostics.py +++ b/homeassistant/components/zwave_js/diagnostics.py @@ -34,16 +34,23 @@ VALUES_TO_REDACT = ( ) -def redact_value_of_zwave_value(zwave_value: ValueDataType) -> ValueDataType: - """Redact value of a Z-Wave value.""" +def _redacted_value(zwave_value: ValueDataType) -> ValueDataType: + """Return redacted value of a Z-Wave value.""" + redacted_value: ValueDataType = deepcopy(zwave_value) + redacted_value["value"] = REDACTED + return redacted_value + + +def optionally_redact_value_of_zwave_value(zwave_value: ValueDataType) -> ValueDataType: + """Redact value of a Z-Wave value if it matches criteria to redact.""" # If the value has no value, there is nothing to redact if zwave_value.get("value") in (None, ""): return zwave_value + if zwave_value.get("metadata", {}).get("secret"): + return _redacted_value(zwave_value) for value_to_redact in VALUES_TO_REDACT: if value_matches_matcher(value_to_redact, zwave_value): - redacted_value: ValueDataType = deepcopy(zwave_value) - redacted_value["value"] = REDACTED - return redacted_value + return _redacted_value(zwave_value) return zwave_value @@ -51,7 +58,8 @@ def redact_node_state(node_state: NodeDataType) -> NodeDataType: """Redact node state.""" redacted_state: NodeDataType = deepcopy(node_state) redacted_state["values"] = [ - redact_value_of_zwave_value(zwave_value) for zwave_value in node_state["values"] + optionally_redact_value_of_zwave_value(zwave_value) + for zwave_value in node_state["values"] ] return redacted_state diff --git a/tests/components/zwave_js/test_diagnostics.py b/tests/components/zwave_js/test_diagnostics.py index 773b799cd6..c7a711d106 100644 --- a/tests/components/zwave_js/test_diagnostics.py +++ b/tests/components/zwave_js/test_diagnostics.py @@ -1,10 +1,14 @@ """Test the Z-Wave JS diagnostics.""" +import copy from unittest.mock import patch import pytest +from zwave_js_server.const import CommandClass from zwave_js_server.event import Event +from zwave_js_server.model.node import Node from homeassistant.components.zwave_js.diagnostics import ( + REDACTED, ZwaveValueMatcher, async_get_device_diagnostics, ) @@ -179,3 +183,41 @@ async def test_device_diagnostics_missing_primary_value( assert air_entity["value_id"] == value.value_id assert air_entity["primary_value"] is None + + +async def test_device_diagnostics_secret_value( + hass: HomeAssistant, + client, + multisensor_6_state, + integration, + hass_client: ClientSessionGenerator, + version_state, +) -> None: + """Test that secret value in device level diagnostics gets redacted.""" + + def _find_ultraviolet_val(data: dict) -> dict: + """Find ultraviolet property value in data.""" + return next( + val + for val in data["values"] + if val["commandClass"] == CommandClass.SENSOR_MULTILEVEL + and val["property"] == PROPERTY_ULTRAVIOLET + ) + + node_state = copy.deepcopy(multisensor_6_state) + # Force a value to be secret so we can check if it gets redacted + secret_value = _find_ultraviolet_val(node_state) + secret_value["metadata"]["secret"] = True + node = Node(client, node_state) + client.driver.controller.nodes[node.node_id] = node + client.driver.controller.emit("node added", {"node": node}) + await hass.async_block_till_done() + dev_reg = async_get_dev_reg(hass) + device = dev_reg.async_get_device({get_device_id(client.driver, node)}) + assert device + + diagnostics_data = await get_diagnostics_for_device( + hass, hass_client, integration, device + ) + test_value = _find_ultraviolet_val(diagnostics_data["state"]) + assert test_value["value"] == REDACTED From 190393c6bbb741041a26c342a29bb60b51079396 Mon Sep 17 00:00:00 2001 From: dougiteixeira <31328123+dougiteixeira@users.noreply.github.com> Date: Tue, 28 Mar 2023 05:17:33 -0300 Subject: [PATCH 1348/2810] Improve Proxmox VE type hints (#90359) * Improves some type hints in Proxmox VE * update * update] * fix isort * Fix vm_id type * Fix vm_id type * Update homeassistant/components/proxmoxve/__init__.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/proxmoxve/__init__.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Change initialization of _proxmox * Move definition of _proxmox to class level --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/proxmoxve/__init__.py | 54 ++++++++++++------- .../components/proxmoxve/binary_sensor.py | 22 +++++--- 2 files changed, 50 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index 7ea4cac58d..2764f22b08 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import timedelta +from typing import Any from proxmoxer import AuthenticationError, ProxmoxAPI from proxmoxer.core import ResourceException @@ -185,14 +186,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: def create_coordinator_container_vm( - hass, proxmox, host_name, node_name, vm_id, vm_type -): + hass: HomeAssistant, + proxmox: ProxmoxAPI, + host_name: str, + node_name: str, + vm_id: int, + vm_type: int, +) -> DataUpdateCoordinator[dict[str, Any] | None]: """Create and return a DataUpdateCoordinator for a vm/container.""" - async def async_update_data(): + async def async_update_data() -> dict[str, Any] | None: """Call the api and handle the response.""" - def poll_api(): + def poll_api() -> dict[str, Any] | None: """Call the api.""" vm_status = call_api_container_vm(proxmox, node_name, vm_id, vm_type) return vm_status @@ -216,7 +222,7 @@ def create_coordinator_container_vm( ) -def parse_api_container_vm(status): +def parse_api_container_vm(status: dict[str, Any]) -> dict[str, Any]: """Get the container or vm api data and return it formatted in a dictionary. It is implemented in this way to allow for more data to be added for sensors @@ -226,7 +232,12 @@ def parse_api_container_vm(status): return {"status": status["status"], "name": status["name"]} -def call_api_container_vm(proxmox, node_name, vm_id, machine_type): +def call_api_container_vm( + proxmox: ProxmoxAPI, + node_name: str, + vm_id: int, + machine_type: int, +) -> dict[str, Any] | None: """Make proper api calls.""" status = None @@ -247,12 +258,12 @@ class ProxmoxEntity(CoordinatorEntity): def __init__( self, coordinator: DataUpdateCoordinator, - unique_id, - name, - icon, - host_name, - node_name, - vm_id=None, + unique_id: str, + name: str, + icon: str, + host_name: str, + node_name: str, + vm_id: int | None = None, ) -> None: """Initialize the Proxmox entity.""" super().__init__(coordinator) @@ -292,7 +303,17 @@ class ProxmoxEntity(CoordinatorEntity): class ProxmoxClient: """A wrapper for the proxmoxer ProxmoxAPI client.""" - def __init__(self, host, port, user, realm, password, verify_ssl): + _proxmox: ProxmoxAPI + + def __init__( + self, + host: str, + port: int, + user: str, + realm: str, + password: str, + verify_ssl: bool, + ) -> None: """Initialize the ProxmoxClient.""" self._host = host @@ -302,10 +323,7 @@ class ProxmoxClient: self._password = password self._verify_ssl = verify_ssl - self._proxmox = None - self._connection_start_time = None - - def build_client(self): + def build_client(self) -> None: """Construct the ProxmoxAPI client. Allows inserting the realm within the `user` value. @@ -324,6 +342,6 @@ class ProxmoxClient: verify_ssl=self._verify_ssl, ) - def get_api_client(self): + def get_api_client(self) -> ProxmoxAPI: """Return the ProxmoxAPI client.""" return self._proxmox diff --git a/homeassistant/components/proxmoxve/binary_sensor.py b/homeassistant/components/proxmoxve/binary_sensor.py index 9bb78d46ea..828c819114 100644 --- a/homeassistant/components/proxmoxve/binary_sensor.py +++ b/homeassistant/components/proxmoxve/binary_sensor.py @@ -51,7 +51,13 @@ async def async_setup_platform( add_entities(sensors) -def create_binary_sensor(coordinator, host_name, node_name, vm_id, name): +def create_binary_sensor( + coordinator, + host_name: str, + node_name: str, + vm_id: int, + name: str, +) -> ProxmoxBinarySensor: """Create a binary sensor based on the given data.""" return ProxmoxBinarySensor( coordinator=coordinator, @@ -72,12 +78,12 @@ class ProxmoxBinarySensor(ProxmoxEntity, BinarySensorEntity): def __init__( self, coordinator: DataUpdateCoordinator, - unique_id, - name, - icon, - host_name, - node_name, - vm_id, + unique_id: str, + name: str, + icon: str, + host_name: str, + node_name: str, + vm_id: int, ) -> None: """Create the binary sensor for vms or containers.""" super().__init__( @@ -85,7 +91,7 @@ class ProxmoxBinarySensor(ProxmoxEntity, BinarySensorEntity): ) @property - def is_on(self): + def is_on(self) -> bool | None: """Return the state of the binary sensor.""" if (data := self.coordinator.data) is None: return None From e0424c83228631eaa0d72f14655a035305f95bea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20=C3=98stergaard=20Nielsen?= Date: Tue, 28 Mar 2023 10:23:00 +0200 Subject: [PATCH 1349/2810] Use shorthand attributes in IHC (#90350) * typings to make linter happy * Moving device_class and native_value to init * remove is_on and use attr_is_on * Use try_parse_enum for sensor type * Remove not needed sensor_type Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/ihc/sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/ihc/binary_sensor.py | 23 ++++++---------- homeassistant/components/ihc/sensor.py | 26 +++---------------- homeassistant/components/ihc/switch.py | 8 +----- 3 files changed, 13 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/ihc/binary_sensor.py b/homeassistant/components/ihc/binary_sensor.py index 48035d27a4..badf0f4e92 100644 --- a/homeassistant/components/ihc/binary_sensor.py +++ b/homeassistant/components/ihc/binary_sensor.py @@ -3,11 +3,15 @@ from __future__ import annotations from ihcsdk.ihccontroller import IHCController -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) from homeassistant.const import CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util.enum import try_parse_enum from .const import CONF_INVERTING, DOMAIN, IHC_CONTROLLER from .ihcdevice import IHCDevice @@ -62,24 +66,13 @@ class IHCBinarySensor(IHCDevice, BinarySensorEntity): ) -> None: """Initialize the IHC binary sensor.""" super().__init__(ihc_controller, controller_id, name, ihc_id, product) - self._state = None - self._sensor_type = sensor_type + self._attr_device_class = try_parse_enum(BinarySensorDeviceClass, sensor_type) self.inverting = inverting - @property - def device_class(self): - """Return the class of this sensor.""" - return self._sensor_type - - @property - def is_on(self): - """Return true if the binary sensor is on/open.""" - return self._state - def on_ihc_change(self, ihc_id, value): """IHC resource has changed.""" if self.inverting: - self._state = not value + self._attr_is_on = not value else: - self._state = value + self._attr_is_on = value self.schedule_update_ha_state() diff --git a/homeassistant/components/ihc/sensor.py b/homeassistant/components/ihc/sensor.py index d3c38687ca..c1210a358d 100644 --- a/homeassistant/components/ihc/sensor.py +++ b/homeassistant/components/ihc/sensor.py @@ -51,29 +51,11 @@ class IHCSensor(IHCDevice, SensorEntity): ) -> None: """Initialize the IHC sensor.""" super().__init__(ihc_controller, controller_id, name, ihc_id, product) - self._state = None - self._unit_of_measurement = unit - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return ( - SensorDeviceClass.TEMPERATURE - if self._unit_of_measurement in TEMPERATURE_UNITS - else None - ) - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement + self._attr_native_unit_of_measurement = unit + if unit in TEMPERATURE_UNITS: + self._attr_device_class = SensorDeviceClass.TEMPERATURE def on_ihc_change(self, ihc_id, value): """Handle IHC resource change.""" - self._state = value + self._attr_native_value = value self.schedule_update_ha_state() diff --git a/homeassistant/components/ihc/switch.py b/homeassistant/components/ihc/switch.py index 8e8edb0b7f..d4593dad57 100644 --- a/homeassistant/components/ihc/switch.py +++ b/homeassistant/components/ihc/switch.py @@ -59,12 +59,6 @@ class IHCSwitch(IHCDevice, SwitchEntity): super().__init__(ihc_controller, controller_id, name, ihc_id, product) self._ihc_off_id = ihc_off_id self._ihc_on_id = ihc_on_id - self._state = False - - @property - def is_on(self): - """Return true if switch is on.""" - return self._state async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" @@ -82,5 +76,5 @@ class IHCSwitch(IHCDevice, SwitchEntity): def on_ihc_change(self, ihc_id, value): """Handle IHC resource change.""" - self._state = value + self._attr_is_on = value self.schedule_update_ha_state() From 2fd872b253c112765fd1ac367ffa35871b05d79e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Mar 2023 23:02:08 -1000 Subject: [PATCH 1350/2810] Speed up profiler lru test (#90395) --- tests/components/profiler/test_init.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index 2c283463b6..af642c779e 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -10,6 +10,7 @@ import py import pytest from homeassistant.components.profiler import ( + _LRU_CACHE_WRAPPER_OBJECT, CONF_SECONDS, SERVICE_DUMP_LOG_OBJECTS, SERVICE_LOG_EVENT_LOOP_SCHEDULED, @@ -253,10 +254,15 @@ async def test_lru_stats(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) domain_data = DomainData() assert hass.services.has_service(DOMAIN, SERVICE_LRU_STATS) - await hass.services.async_call(DOMAIN, SERVICE_LRU_STATS, blocking=True) + def _mock_by_type(type_): + if type_ == _LRU_CACHE_WRAPPER_OBJECT: + return [_dummy_test_lru_stats] + return [domain_data] + + with patch("objgraph.by_type", side_effect=_mock_by_type): + await hass.services.async_call(DOMAIN, SERVICE_LRU_STATS, blocking=True) assert "DomainData" in caplog.text assert "(0, 0)" in caplog.text assert "_dummy_test_lru_stats" in caplog.text assert "CacheInfo" in caplog.text - del domain_data From 23a1a8075c9f31f930dee0607f3e5b9b1f9802b5 Mon Sep 17 00:00:00 2001 From: Pascal Reeb Date: Tue, 28 Mar 2023 11:28:04 +0200 Subject: [PATCH 1351/2810] Add callback support to nuki (#88346) * feat(nuki): add callback support * fix(nuki): add webhook_enabled to tests * remove callback choice, add repair if it's https * black * fix(nuki): implemented feedback from pvizeli and frenck * remove unneded test change * remove issue_registry and http check * remove unneded response * add await to executor_job --- homeassistant/components/nuki/__init__.py | 111 +++++++++++++++++++- homeassistant/components/nuki/manifest.json | 1 + 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index 9504d38c93..74245d30d4 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -3,9 +3,11 @@ from __future__ import annotations from collections import defaultdict from datetime import timedelta +from http import HTTPStatus import logging from typing import Generic, TypeVar +from aiohttp import web import async_timeout from pynuki import NukiBridge, NukiLock, NukiOpener from pynuki.bridge import InvalidCredentialsException @@ -13,10 +15,18 @@ from pynuki.device import NukiDevice from requests.exceptions import RequestException from homeassistant import exceptions +from homeassistant.components import webhook from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN, Platform -from homeassistant.core import HomeAssistant +from homeassistant.const import ( + CONF_HOST, + CONF_PORT, + CONF_TOKEN, + EVENT_HOMEASSISTANT_STOP, + Platform, +) +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.network import get_url from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -46,6 +56,29 @@ def _get_bridge_devices(bridge: NukiBridge) -> tuple[list[NukiLock], list[NukiOp return bridge.locks, bridge.openers +def _register_webhook(bridge: NukiBridge, entry_id: str, url: str) -> bool: + # Register HA URL as webhook if not already + callbacks = bridge.callback_list() + for item in callbacks["callbacks"]: + if entry_id in item["url"]: + if item["url"] == url: + return True + bridge.callback_remove(item["id"]) + + if bridge.callback_add(url)["success"]: + return True + + return False + + +def _remove_webhook(bridge: NukiBridge, entry_id: str) -> None: + # Remove webhook if set + callbacks = bridge.callback_list() + for item in callbacks["callbacks"]: + if entry_id in item["url"]: + bridge.callback_remove(item["id"]) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the Nuki entry.""" @@ -88,6 +121,63 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: sw_version=info["versions"]["firmwareVersion"], ) + async def handle_webhook( + hass: HomeAssistant, webhook_id: str, request: web.Request + ) -> web.Response: + """Handle webhook callback.""" + try: + data = await request.json() + except ValueError: + return web.Response(status=HTTPStatus.BAD_REQUEST) + + locks = hass.data[DOMAIN][entry.entry_id][DATA_LOCKS] + openers = hass.data[DOMAIN][entry.entry_id][DATA_OPENERS] + + devices = [x for x in locks + openers if x.nuki_id == data["nukiId"]] + if len(devices) == 1: + devices[0].update_from_callback(data) + + coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + coordinator.async_set_updated_data(None) + + return web.Response(status=HTTPStatus.OK) + + webhook.async_register( + hass, DOMAIN, entry.title, entry.entry_id, handle_webhook, local_only=True + ) + + async def _stop_nuki(_: Event): + """Stop and remove the Nuki webhook.""" + webhook.async_unregister(hass, entry.entry_id) + try: + async with async_timeout.timeout(10): + await hass.async_add_executor_job( + _remove_webhook, bridge, entry.entry_id + ) + except InvalidCredentialsException as err: + raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err + except RequestException as err: + raise UpdateFailed(f"Error communicating with Bridge: {err}") from err + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_nuki) + ) + + webhook_url = webhook.async_generate_path(entry.entry_id) + hass_url = get_url( + hass, allow_cloud=False, allow_external=False, allow_ip=True, require_ssl=False + ) + url = f"{hass_url}{webhook_url}" + try: + async with async_timeout.timeout(10): + await hass.async_add_executor_job( + _register_webhook, bridge, entry.entry_id, url + ) + except InvalidCredentialsException as err: + raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err + except RequestException as err: + raise UpdateFailed(f"Error communicating with Bridge: {err}") from err + coordinator = NukiCoordinator(hass, bridge, locks, openers) hass.data[DOMAIN][entry.entry_id] = { @@ -107,6 +197,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload the Nuki entry.""" + webhook.async_unregister(hass, entry.entry_id) + try: + async with async_timeout.timeout(10): + await hass.async_add_executor_job( + _remove_webhook, + hass.data[DOMAIN][entry.entry_id][DATA_BRIDGE], + entry.entry_id, + ) + except InvalidCredentialsException as err: + raise UpdateFailed( + f"Unable to remove callback. Invalid credentials for Bridge: {err}" + ) from err + except RequestException as err: + raise UpdateFailed( + f"Unable to remove callback. Error communicating with Bridge: {err}" + ) from err + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) diff --git a/homeassistant/components/nuki/manifest.json b/homeassistant/components/nuki/manifest.json index e6b741d442..8b87816fb7 100644 --- a/homeassistant/components/nuki/manifest.json +++ b/homeassistant/components/nuki/manifest.json @@ -3,6 +3,7 @@ "name": "Nuki", "codeowners": ["@pschmitt", "@pvizeli", "@pree"], "config_flow": true, + "dependencies": ["webhook"], "dhcp": [ { "hostname": "nuki_bridge_*" From e617bfb1bb2436955ada65cd5c9b5589ca57b7f4 Mon Sep 17 00:00:00 2001 From: Chris Xiao <30990835+chrisx8@users.noreply.github.com> Date: Tue, 28 Mar 2023 05:51:35 -0400 Subject: [PATCH 1352/2810] Display unit of elevation in met config flow (#88283) * display unit of elevation in met config flow Co-authored-by: lijake8 Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * use NumberSelector for met config flow * met remove unused is_metric param --------- Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> Co-authored-by: lijake8 --- homeassistant/components/met/__init__.py | 20 ++------------ homeassistant/components/met/config_flow.py | 29 ++++++++++++++++++--- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/met/__init__.py b/homeassistant/components/met/__init__.py index c95c3abe05..c676f15336 100644 --- a/homeassistant/components/met/__init__.py +++ b/homeassistant/components/met/__init__.py @@ -18,15 +18,12 @@ from homeassistant.const import ( CONF_LONGITUDE, EVENT_CORE_CONFIG_UPDATE, Platform, - UnitOfLength, ) from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from homeassistant.util.unit_conversion import DistanceConverter -from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ( CONF_TRACK_HOME, @@ -102,9 +99,7 @@ class MetDataUpdateCoordinator(DataUpdateCoordinator["MetWeatherData"]): def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize global Met data updater.""" self._unsub_track_home: Callable[[], None] | None = None - self.weather = MetWeatherData( - hass, config_entry.data, hass.config.units is METRIC_SYSTEM - ) + self.weather = MetWeatherData(hass, config_entry.data) self.weather.set_coordinates() update_interval = timedelta(minutes=randrange(55, 65)) @@ -142,13 +137,10 @@ class MetDataUpdateCoordinator(DataUpdateCoordinator["MetWeatherData"]): class MetWeatherData: """Keep data for Met.no weather entities.""" - def __init__( - self, hass: HomeAssistant, config: MappingProxyType[str, Any], is_metric: bool - ) -> None: + def __init__(self, hass: HomeAssistant, config: MappingProxyType[str, Any]) -> None: """Initialise the weather entity data.""" self.hass = hass self._config = config - self._is_metric = is_metric self._weather_data: metno.MetWeatherData self.current_weather_data: dict = {} self.daily_forecast: list[dict] = [] @@ -165,14 +157,6 @@ class MetWeatherData: latitude = self._config[CONF_LATITUDE] longitude = self._config[CONF_LONGITUDE] elevation = self._config[CONF_ELEVATION] - if not self._is_metric: - elevation = int( - round( - DistanceConverter.convert( - elevation, UnitOfLength.FEET, UnitOfLength.METERS - ) - ) - ) coordinates = { "lat": str(latitude), diff --git a/homeassistant/components/met/config_flow.py b/homeassistant/components/met/config_flow.py index 453c0a9cee..d8cb31077c 100644 --- a/homeassistant/components/met/config_flow.py +++ b/homeassistant/components/met/config_flow.py @@ -6,10 +6,21 @@ from typing import Any import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.const import ( + CONF_ELEVATION, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, + UnitOfLength, +) from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, +) from .const import ( CONF_TRACK_HOME, @@ -47,7 +58,14 @@ def _get_data_schema( vol.Required( CONF_LONGITUDE, default=hass.config.longitude ): cv.longitude, - vol.Required(CONF_ELEVATION, default=hass.config.elevation): int, + vol.Required( + CONF_ELEVATION, default=hass.config.elevation + ): NumberSelector( + NumberSelectorConfig( + mode=NumberSelectorMode.BOX, + unit_of_measurement=UnitOfLength.METERS, + ) + ), } ) # Not tracking home, default values come from config entry @@ -62,7 +80,12 @@ def _get_data_schema( ): cv.longitude, vol.Required( CONF_ELEVATION, default=config_entry.data.get(CONF_ELEVATION) - ): int, + ): NumberSelector( + NumberSelectorConfig( + mode=NumberSelectorMode.BOX, + unit_of_measurement=UnitOfLength.METERS, + ) + ), } ) From 96dae587a9822ef3ee3e5c57905d3daa5ab812fb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Mar 2023 11:54:16 +0200 Subject: [PATCH 1353/2810] Fix ridwell tests (#90401) --- tests/components/ridwell/conftest.py | 3 --- tests/components/ridwell/test_diagnostics.py | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/components/ridwell/conftest.py b/tests/components/ridwell/conftest.py index 57d485d428..87ca00c37c 100644 --- a/tests/components/ridwell/conftest.py +++ b/tests/components/ridwell/conftest.py @@ -3,7 +3,6 @@ from datetime import date from unittest.mock import AsyncMock, Mock, patch from aioridwell.model import EventState, RidwellPickup, RidwellPickupEvent -from freezegun import freeze_time import pytest from homeassistant.components.ridwell.const import DOMAIN @@ -80,8 +79,6 @@ async def mock_aioridwell_fixture(hass, client, config): ), patch( "homeassistant.components.ridwell.coordinator.async_get_client", return_value=client, - ), freeze_time( - "2022-01-01" ): yield diff --git a/tests/components/ridwell/test_diagnostics.py b/tests/components/ridwell/test_diagnostics.py index caac488041..e73b352f3d 100644 --- a/tests/components/ridwell/test_diagnostics.py +++ b/tests/components/ridwell/test_diagnostics.py @@ -32,7 +32,7 @@ async def test_entry_diagnostics( "_async_request": None, "event_id": "event_123", "pickup_date": { - "__type": "", + "__type": "", "isoformat": "2022-01-24", }, "pickups": [ From 6fbdcac3232d4fd6d3c0170eb99691f18b132e58 Mon Sep 17 00:00:00 2001 From: BNolet Date: Tue, 28 Mar 2023 06:20:54 -0400 Subject: [PATCH 1354/2810] Fix setting color + brightness of Tuya lights (#88470) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Check if changing TO a color mode Changing brightness alone does not change work mode, but changing brightness with a color value will keep the light in white mode. By verifying the new state has color or not, rather than the existing state being in color work mode, the light will change to color correctly. Tuya interprets HSV as including the brightness in the (v) value (which is generally what that's used for when setting HSV values). The brightness value given by Home Assistant is still used in this case. * Fix brightness-only turning colour mode to white This will take into account the case where brightness is the only parameter for both the case of colour mode and white mode. Tests passed after this change: * Brightness only (colour mode) ✅ * Brightness only (white mode) ✅ * Colour only (colour mode) ✅ * Colour only (white mode) ✅ * Colour temp only (colour mode) ✅ * Colour temp only (white mode) ✅ * Colour + brightness (colour mode) ✅ * Colour + brightness (white mode) ✅ * Colour temp + brightness (colour mode) ✅ * Colour temp + brightness (white mode) ✅ * Fix code formatting --- homeassistant/components/tuya/light.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 3546e45451..959a1834f8 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -499,9 +499,14 @@ class TuyaLightEntity(TuyaEntity, LightEntity): ), }, ] - elif self._color_data_type and ( + + if self._color_data_type and ( ATTR_HS_COLOR in kwargs - or (ATTR_BRIGHTNESS in kwargs and self.color_mode == ColorMode.HS) + or ( + ATTR_BRIGHTNESS in kwargs + and self.color_mode == ColorMode.HS + and ATTR_COLOR_TEMP not in kwargs + ) ): if self._color_mode_dpcode: commands += [ @@ -542,11 +547,7 @@ class TuyaLightEntity(TuyaEntity, LightEntity): }, ] - if ( - ATTR_BRIGHTNESS in kwargs - and self.color_mode != ColorMode.HS - and self._brightness - ): + elif ATTR_BRIGHTNESS in kwargs and self._brightness: brightness = kwargs[ATTR_BRIGHTNESS] # If there is a min/max value, the brightness is actually limited. From be5714e3fd6f6f3ba3281a55a3b7d310943dce3e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 00:24:12 -1000 Subject: [PATCH 1355/2810] Use slots for recorder tasks to reduce memory (#90387) --- homeassistant/components/recorder/tasks.py | 59 +++++++++++----------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index ef11885705..dfa6ce32d2 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -26,7 +26,8 @@ if TYPE_CHECKING: from .core import Recorder -class RecorderTask(abc.ABC): +@dataclass(slots=True) +class RecorderTask: """ABC for recorder tasks.""" commit_before = True @@ -36,7 +37,7 @@ class RecorderTask(abc.ABC): """Handle the task.""" -@dataclass +@dataclass(slots=True) class ChangeStatisticsUnitTask(RecorderTask): """Object to store statistics_id and unit to convert unit of statistics.""" @@ -54,7 +55,7 @@ class ChangeStatisticsUnitTask(RecorderTask): ) -@dataclass +@dataclass(slots=True) class ClearStatisticsTask(RecorderTask): """Object to store statistics_ids which for which to remove statistics.""" @@ -65,7 +66,7 @@ class ClearStatisticsTask(RecorderTask): statistics.clear_statistics(instance, self.statistic_ids) -@dataclass +@dataclass(slots=True) class UpdateStatisticsMetadataTask(RecorderTask): """Object to store statistics_id and unit for update of statistics metadata.""" @@ -83,7 +84,7 @@ class UpdateStatisticsMetadataTask(RecorderTask): ) -@dataclass +@dataclass(slots=True) class UpdateStatesMetadataTask(RecorderTask): """Task to update states metadata.""" @@ -99,7 +100,7 @@ class UpdateStatesMetadataTask(RecorderTask): ) -@dataclass +@dataclass(slots=True) class PurgeTask(RecorderTask): """Object to store information about purge task.""" @@ -125,7 +126,7 @@ class PurgeTask(RecorderTask): ) -@dataclass +@dataclass(slots=True) class PurgeEntitiesTask(RecorderTask): """Object to store entity information about purge task.""" @@ -140,7 +141,7 @@ class PurgeEntitiesTask(RecorderTask): instance.queue_task(PurgeEntitiesTask(self.entity_filter, self.purge_before)) -@dataclass +@dataclass(slots=True) class PerodicCleanupTask(RecorderTask): """An object to insert into the recorder to trigger cleanup tasks. @@ -152,7 +153,7 @@ class PerodicCleanupTask(RecorderTask): periodic_db_cleanups(instance) -@dataclass +@dataclass(slots=True) class StatisticsTask(RecorderTask): """An object to insert into the recorder queue to run a statistics task.""" @@ -167,7 +168,7 @@ class StatisticsTask(RecorderTask): instance.queue_task(StatisticsTask(self.start, self.fire_events)) -@dataclass +@dataclass(slots=True) class CompileMissingStatisticsTask(RecorderTask): """An object to insert into the recorder queue to run a compile missing statistics.""" @@ -179,7 +180,7 @@ class CompileMissingStatisticsTask(RecorderTask): instance.queue_task(CompileMissingStatisticsTask()) -@dataclass +@dataclass(slots=True) class ImportStatisticsTask(RecorderTask): """An object to insert into the recorder queue to run an import statistics task.""" @@ -199,7 +200,7 @@ class ImportStatisticsTask(RecorderTask): ) -@dataclass +@dataclass(slots=True) class AdjustStatisticsTask(RecorderTask): """An object to insert into the recorder queue to run an adjust statistics task.""" @@ -229,7 +230,7 @@ class AdjustStatisticsTask(RecorderTask): ) -@dataclass +@dataclass(slots=True) class WaitTask(RecorderTask): """An object to insert into the recorder queue. @@ -243,7 +244,7 @@ class WaitTask(RecorderTask): instance._queue_watch.set() # pylint: disable=[protected-access] -@dataclass +@dataclass(slots=True) class DatabaseLockTask(RecorderTask): """An object to insert into the recorder queue to prevent writes to the database.""" @@ -256,7 +257,7 @@ class DatabaseLockTask(RecorderTask): instance._lock_database(self) # pylint: disable=[protected-access] -@dataclass +@dataclass(slots=True) class StopTask(RecorderTask): """An object to insert into the recorder queue to stop the event handler.""" @@ -267,7 +268,7 @@ class StopTask(RecorderTask): instance.stop_requested = True -@dataclass +@dataclass(slots=True) class EventTask(RecorderTask): """An event to be processed.""" @@ -280,7 +281,7 @@ class EventTask(RecorderTask): instance._process_one_event(self.event) -@dataclass +@dataclass(slots=True) class KeepAliveTask(RecorderTask): """A keep alive to be sent.""" @@ -292,7 +293,7 @@ class KeepAliveTask(RecorderTask): instance._send_keep_alive() -@dataclass +@dataclass(slots=True) class CommitTask(RecorderTask): """Commit the event session.""" @@ -304,7 +305,7 @@ class CommitTask(RecorderTask): instance._commit_event_session_or_retry() -@dataclass +@dataclass(slots=True) class AddRecorderPlatformTask(RecorderTask): """Add a recorder platform.""" @@ -321,7 +322,7 @@ class AddRecorderPlatformTask(RecorderTask): platforms[domain] = platform -@dataclass +@dataclass(slots=True) class SynchronizeTask(RecorderTask): """Ensure all pending data has been committed.""" @@ -335,7 +336,7 @@ class SynchronizeTask(RecorderTask): instance.hass.loop.call_soon_threadsafe(self.event.set) -@dataclass +@dataclass(slots=True) class PostSchemaMigrationTask(RecorderTask): """Post migration task to update schema.""" @@ -349,7 +350,7 @@ class PostSchemaMigrationTask(RecorderTask): ) -@dataclass +@dataclass(slots=True) class StatisticsTimestampMigrationCleanupTask(RecorderTask): """An object to insert into the recorder queue to run a statistics migration cleanup task.""" @@ -360,7 +361,7 @@ class StatisticsTimestampMigrationCleanupTask(RecorderTask): instance.queue_task(StatisticsTimestampMigrationCleanupTask()) -@dataclass +@dataclass(slots=True) class AdjustLRUSizeTask(RecorderTask): """An object to insert into the recorder queue to adjust the LRU size.""" @@ -371,7 +372,7 @@ class AdjustLRUSizeTask(RecorderTask): instance._adjust_lru_size() # pylint: disable=[protected-access] -@dataclass +@dataclass(slots=True) class StatesContextIDMigrationTask(RecorderTask): """An object to insert into the recorder queue to migrate states context ids.""" @@ -386,7 +387,7 @@ class StatesContextIDMigrationTask(RecorderTask): instance.queue_task(StatesContextIDMigrationTask()) -@dataclass +@dataclass(slots=True) class EventsContextIDMigrationTask(RecorderTask): """An object to insert into the recorder queue to migrate events context ids.""" @@ -401,7 +402,7 @@ class EventsContextIDMigrationTask(RecorderTask): instance.queue_task(EventsContextIDMigrationTask()) -@dataclass +@dataclass(slots=True) class EventTypeIDMigrationTask(RecorderTask): """An object to insert into the recorder queue to migrate event type ids.""" @@ -417,7 +418,7 @@ class EventTypeIDMigrationTask(RecorderTask): instance.queue_task(EventTypeIDMigrationTask()) -@dataclass +@dataclass(slots=True) class EntityIDMigrationTask(RecorderTask): """An object to insert into the recorder queue to migrate entity_ids to StatesMeta.""" @@ -440,7 +441,7 @@ class EntityIDMigrationTask(RecorderTask): instance.queue_task(EntityIDPostMigrationTask()) -@dataclass +@dataclass(slots=True) class EntityIDPostMigrationTask(RecorderTask): """An object to insert into the recorder queue to cleanup after entity_ids migration.""" @@ -453,7 +454,7 @@ class EntityIDPostMigrationTask(RecorderTask): instance.queue_task(EntityIDPostMigrationTask()) -@dataclass +@dataclass(slots=True) class EventIdMigrationTask(RecorderTask): """An object to insert into the recorder queue to cleanup legacy event_ids in the states table. From ae41547b73e4d1354ec1c43dd0021531aeff564c Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 28 Mar 2023 03:25:44 -0700 Subject: [PATCH 1356/2810] Update calendar to always request start/end dates in local time rather than UTC (#90386) --- homeassistant/components/calendar/__init__.py | 2 +- homeassistant/components/google/calendar.py | 4 +-- .../components/local_calendar/calendar.py | 11 +++---- .../local_calendar/test_calendar.py | 33 ++++++++++++++++--- 4 files changed, 37 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 9af3244656..0b1c37cea5 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -523,7 +523,7 @@ class CalendarEventView(http.HomeAssistantView): try: calendar_event_list = await entity.async_get_events( - request.app["hass"], start_date, end_date + request.app["hass"], dt.as_local(start_date), dt.as_local(end_date) ) except HomeAssistantError as err: _LOGGER.debug("Error reading events: %s", err) diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 1e1072940a..363b75c2c5 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -283,8 +283,8 @@ class CalendarSyncUpdateCoordinator(DataUpdateCoordinator[Timeline]): "Unable to get events: Sync from server has not completed" ) return self.data.overlapping( - dt_util.as_local(start_date), - dt_util.as_local(end_date), + start_date, + end_date, ) @property diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index 718c65ffce..4b6d9444fd 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -85,17 +85,16 @@ class LocalCalendarEntity(CalendarEntity): self, hass: HomeAssistant, start_date: datetime, end_date: datetime ) -> list[CalendarEvent]: """Get all events in a specific time frame.""" - events = self._calendar.timeline_tz(dt_util.DEFAULT_TIME_ZONE).overlapping( - dt_util.as_local(start_date), - dt_util.as_local(end_date), + events = self._calendar.timeline_tz(start_date.tzinfo).overlapping( + start_date, + end_date, ) return [_get_calendar_event(event) for event in events] async def async_update(self) -> None: """Update entity state with the next upcoming event.""" - events = self._calendar.timeline_tz(dt_util.DEFAULT_TIME_ZONE).active_after( - dt_util.now() - ) + now = dt_util.now() + events = self._calendar.timeline_tz(now.tzinfo).active_after(now) if event := next(events, None): self._event = _get_calendar_event(event) else: diff --git a/tests/components/local_calendar/test_calendar.py b/tests/components/local_calendar/test_calendar.py index 6bdb58cf65..a2f13ea289 100644 --- a/tests/components/local_calendar/test_calendar.py +++ b/tests/components/local_calendar/test_calendar.py @@ -37,10 +37,27 @@ async def test_empty_calendar( } +@pytest.mark.parametrize( + ("dtstart", "dtend"), + [ + ("1997-07-14T18:00:00+01:00", "1997-07-15T05:00:00+01:00"), + ("1997-07-14T17:00:00+00:00", "1997-07-15T04:00:00+00:00"), + ("1997-07-14T11:00:00-06:00", "1997-07-14T22:00:00-06:00"), + ("1997-07-14T10:00:00-07:00", "1997-07-14T21:00:00-07:00"), + ], +) async def test_api_date_time_event( - ws_client: ClientFixture, setup_integration: None, get_events: GetEventsFn + ws_client: ClientFixture, + setup_integration: None, + get_events: GetEventsFn, + dtstart: str, + dtend: str, ) -> None: - """Test an event with a start/end date time.""" + """Test an event with a start/end date time. + + Events created in various timezones are ultimately returned relative + to local home assistant timezone. + """ client = await ws_client() await client.cmd_result( "create", @@ -48,8 +65,8 @@ async def test_api_date_time_event( "entity_id": TEST_ENTITY, "event": { "summary": "Bastille Day Party", - "dtstart": "1997-07-14T17:00:00+00:00", - "dtend": "1997-07-15T04:00:00+00:00", + "dtstart": dtstart, + "dtend": dtend, }, }, ) @@ -63,6 +80,8 @@ async def test_api_date_time_event( } ] + # Query events in UTC + # Time range before event events = await get_events("1997-07-13T00:00:00Z", "1997-07-14T16:00:00Z") assert len(events) == 0 @@ -77,6 +96,12 @@ async def test_api_date_time_event( events = await get_events("1997-07-15T03:00:00Z", "1997-07-15T06:00:00Z") assert len(events) == 1 + # Query events overlapping with start and end but in another timezone + events = await get_events("1997-07-12T23:00:00-01:00", "1997-07-14T17:00:00-01:00") + assert len(events) == 1 + events = await get_events("1997-07-15T02:00:00-01:00", "1997-07-15T05:00:00-01:00") + assert len(events) == 1 + async def test_api_date_event( ws_client: ClientFixture, setup_integration: None, get_events: GetEventsFn From 3c3860c923b34cdd7b5c8442a0b9e9daefb14ba6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Mar 2023 12:34:25 +0200 Subject: [PATCH 1357/2810] Make OTBR use same channel as ZHA (#88546) --- .../silabs_multiprotocol_addon.py | 8 +++ homeassistant/components/otbr/config_flow.py | 23 ++++---- homeassistant/components/otbr/manifest.json | 4 +- homeassistant/components/otbr/util.py | 43 ++++++++++++++ .../components/otbr/websocket_api.py | 19 +++--- .../test_silabs_multiprotocol_addon.py | 11 ++++ tests/components/otbr/test_config_flow.py | 13 ++++- tests/components/otbr/test_util.py | 58 +++++++++++++++++++ tests/components/otbr/test_websocket_api.py | 26 ++++++--- 9 files changed, 171 insertions(+), 34 deletions(-) create mode 100644 homeassistant/components/otbr/util.py create mode 100644 tests/components/otbr/test_util.py diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index 41f16462cd..ff2bf9138f 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -8,6 +8,7 @@ import logging from typing import Any import voluptuous as vol +import yarl from homeassistant import config_entries from homeassistant.components.hassio import ( @@ -74,6 +75,13 @@ def get_zigbee_socket() -> str: return f"socket://{hostname}:9999" +def is_multiprotocol_url(url: str) -> bool: + """Return if the URL points at the Multiprotocol add-on.""" + parsed = yarl.URL(url) + hostname = hostname_from_addon_slug(SILABS_MULTIPROTOCOL_ADDON_SLUG) + return parsed.host == hostname + + class BaseMultiPanFlow(FlowHandler, ABC): """Support configuring the Silicon Labs Multiprotocol add-on.""" diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py index 4247d5dbd6..434b9026ae 100644 --- a/homeassistant/components/otbr/config_flow.py +++ b/homeassistant/components/otbr/config_flow.py @@ -18,6 +18,7 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DEFAULT_CHANNEL, DOMAIN +from .util import get_allowed_channel _LOGGER = logging.getLogger(__name__) @@ -27,13 +28,12 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - async def _connect_and_create_dataset(self, url: str) -> None: - """Connect to the OTBR and create a dataset if it doesn't have one.""" - api = python_otbr_api.OTBR(url, async_get_clientsession(self.hass), 10) + async def _connect_and_set_dataset(self, otbr_url: str) -> None: + """Connect to the OTBR and create or apply a dataset if it doesn't have one.""" + api = python_otbr_api.OTBR(otbr_url, async_get_clientsession(self.hass), 10) if await api.get_active_dataset_tlvs() is None: - # We currently have no way to know which channel zha is using, assume it's - # the default - zha_channel = DEFAULT_CHANNEL + allowed_channel = await get_allowed_channel(self.hass, otbr_url) + thread_dataset_channel = None thread_dataset_tlv = await async_get_preferred_dataset(self.hass) if thread_dataset_tlv: @@ -41,7 +41,9 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): if channel_str := dataset.get(tlv_parser.MeshcopTLVType.CHANNEL): thread_dataset_channel = int(channel_str, base=16) - if thread_dataset_tlv is not None and zha_channel == thread_dataset_channel: + if thread_dataset_tlv is not None and ( + not allowed_channel or allowed_channel == thread_dataset_channel + ): await api.set_active_dataset_tlvs(bytes.fromhex(thread_dataset_tlv)) else: _LOGGER.debug( @@ -49,7 +51,8 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): ) await api.create_active_dataset( python_otbr_api.OperationalDataSet( - channel=zha_channel, network_name="home-assistant" + channel=allowed_channel if allowed_channel else DEFAULT_CHANNEL, + network_name="home-assistant", ) ) await api.set_enabled(True) @@ -66,7 +69,7 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: url = user_input[CONF_URL] try: - await self._connect_and_create_dataset(url) + await self._connect_and_set_dataset(url) except ( python_otbr_api.OTBRError, aiohttp.ClientError, @@ -108,7 +111,7 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="single_instance_allowed") try: - await self._connect_and_create_dataset(url) + await self._connect_and_set_dataset(url) except python_otbr_api.OTBRError as exc: _LOGGER.warning("Failed to communicate with OTBR@%s: %s", url, exc) return self.async_abort(reason="unknown") diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index 2590e92210..8e9050ca9f 100644 --- a/homeassistant/components/otbr/manifest.json +++ b/homeassistant/components/otbr/manifest.json @@ -1,10 +1,10 @@ { "domain": "otbr", "name": "Open Thread Border Router", - "after_dependencies": ["hassio"], + "after_dependencies": ["hassio", "zha"], "codeowners": ["@home-assistant/core"], "config_flow": true, - "dependencies": ["thread"], + "dependencies": ["homeassistant_hardware", "thread"], "documentation": "https://www.home-assistant.io/integrations/otbr", "integration_type": "service", "iot_class": "local_polling", diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py new file mode 100644 index 0000000000..b1a3ee11b8 --- /dev/null +++ b/homeassistant/components/otbr/util.py @@ -0,0 +1,43 @@ +"""Utility functions for the Open Thread Border Router integration.""" +from __future__ import annotations + +import contextlib + +from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( + is_multiprotocol_url, +) +from homeassistant.components.zha import api as zha_api +from homeassistant.core import HomeAssistant + + +def _get_zha_url(hass: HomeAssistant) -> str | None: + """Get ZHA radio path, or None if there's no ZHA config entry.""" + with contextlib.suppress(ValueError): + return zha_api.async_get_radio_path(hass) + return None + + +async def _get_zha_channel(hass: HomeAssistant) -> int | None: + """Get ZHA channel, or None if there's no ZHA config entry.""" + zha_network_settings: zha_api.NetworkBackup | None + with contextlib.suppress(ValueError): + zha_network_settings = await zha_api.async_get_network_settings(hass) + if not zha_network_settings: + return None + channel: int = zha_network_settings.network_info.channel + # ZHA uses channel 0 when no channel is set + return channel or None + + +async def get_allowed_channel(hass: HomeAssistant, otbr_url: str) -> int | None: + """Return the allowed channel, or None if there's no restriction.""" + if not is_multiprotocol_url(otbr_url): + # The OTBR is not sharing the radio, no restriction + return None + + zha_url = _get_zha_url(hass) + if not zha_url or not is_multiprotocol_url(zha_url): + # ZHA is not configured or not sharing the radio with this OTBR, no restriction + return None + + return await _get_zha_channel(hass) diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py index aa8c1dd2dd..cd4f8875e7 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from .const import DEFAULT_CHANNEL, DOMAIN +from .util import get_allowed_channel if TYPE_CHECKING: from . import OTBRData @@ -72,11 +73,8 @@ async def websocket_create_network( connection.send_error(msg["id"], "not_loaded", "No OTBR API loaded") return - # We currently have no way to know which channel zha is using, assume it's - # the default - zha_channel = DEFAULT_CHANNEL - data: OTBRData = hass.data[DOMAIN] + channel = await get_allowed_channel(hass, data.url) or DEFAULT_CHANNEL try: await data.set_enabled(False) @@ -87,7 +85,7 @@ async def websocket_create_network( try: await data.create_active_dataset( python_otbr_api.OperationalDataSet( - channel=zha_channel, network_name="home-assistant" + channel=channel, network_name="home-assistant" ) ) except HomeAssistantError as exc: @@ -139,21 +137,18 @@ async def websocket_set_network( if channel_str := dataset.get(tlv_parser.MeshcopTLVType.CHANNEL): thread_dataset_channel = int(channel_str, base=16) - # We currently have no way to know which channel zha is using, assume it's - # the default - zha_channel = DEFAULT_CHANNEL + data: OTBRData = hass.data[DOMAIN] + allowed_channel = await get_allowed_channel(hass, data.url) - if thread_dataset_channel != zha_channel: + if allowed_channel and thread_dataset_channel != allowed_channel: connection.send_error( msg["id"], "channel_conflict", f"Can't connect to network on channel {thread_dataset_channel}, ZHA is " - f"using channel {zha_channel}", + f"using channel {allowed_channel}", ) return - data: OTBRData = hass.data[DOMAIN] - try: await data.set_enabled(False) except HomeAssistantError as exc: diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index 424e4126e0..a195899136 100644 --- a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py +++ b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py @@ -795,3 +795,14 @@ async def test_option_flow_install_multi_pan_addon_zha_migration_fails_step_2( result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "zha_migration_failed" + + +def test_is_multiprotocol_url() -> None: + """Test is_multiprotocol_url.""" + assert silabs_multiprotocol_addon.is_multiprotocol_url( + "socket://core-silabs-multiprotocol:9999" + ) + assert silabs_multiprotocol_addon.is_multiprotocol_url( + "http://core-silabs-multiprotocol:8081" + ) + assert not silabs_multiprotocol_addon.is_multiprotocol_url("/dev/ttyAMA1") diff --git a/tests/components/otbr/test_config_flow.py b/tests/components/otbr/test_config_flow.py index ae49c63002..b788c93610 100644 --- a/tests/components/otbr/test_config_flow.py +++ b/tests/components/otbr/test_config_flow.py @@ -2,7 +2,7 @@ import asyncio from http import HTTPStatus from typing import Any -from unittest.mock import patch +from unittest.mock import Mock, patch import aiohttp import pytest @@ -320,13 +320,22 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred_2( aioclient_mock.post(f"{url}/node/dataset/active", status=HTTPStatus.ACCEPTED) aioclient_mock.post(f"{url}/node/state", status=HTTPStatus.OK) + networksettings = Mock() + networksettings.network_info.channel = 15 + with patch( "homeassistant.components.otbr.config_flow.async_get_preferred_dataset", return_value=DATASET_CH16.hex(), ), patch( "homeassistant.components.otbr.async_setup_entry", return_value=True, - ) as mock_setup_entry: + ) as mock_setup_entry, patch( + "homeassistant.components.otbr.util.zha_api.async_get_radio_path", + return_value="socket://core-silabs-multiprotocol:9999", + ), patch( + "homeassistant.components.otbr.util.zha_api.async_get_network_settings", + return_value=networksettings, + ): result = await hass.config_entries.flow.async_init( otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA ) diff --git a/tests/components/otbr/test_util.py b/tests/components/otbr/test_util.py new file mode 100644 index 0000000000..af5306b358 --- /dev/null +++ b/tests/components/otbr/test_util.py @@ -0,0 +1,58 @@ +"""Test OTBR Utility functions.""" +from unittest.mock import Mock, patch + +from homeassistant.components import otbr +from homeassistant.core import HomeAssistant + +OTBR_MULTIPAN_URL = "http://core-silabs-multiprotocol:8081" +OTBR_NON_MULTIPAN_URL = "/dev/ttyAMA1" + + +async def test_get_allowed_channel(hass: HomeAssistant) -> None: + """Test get_allowed_channel.""" + + zha_networksettings = Mock() + zha_networksettings.network_info.channel = 15 + + # OTBR multipan + No ZHA -> no restriction + assert await otbr.util.get_allowed_channel(hass, OTBR_MULTIPAN_URL) is None + + # OTBR multipan + ZHA multipan empty settings -> no restriction + with patch( + "homeassistant.components.otbr.util.zha_api.async_get_radio_path", + return_value="socket://core-silabs-multiprotocol:9999", + ), patch( + "homeassistant.components.otbr.util.zha_api.async_get_network_settings", + return_value=None, + ): + assert await otbr.util.get_allowed_channel(hass, OTBR_MULTIPAN_URL) is None + + # OTBR multipan + ZHA not multipan using channel 15 -> no restriction + with patch( + "homeassistant.components.otbr.util.zha_api.async_get_radio_path", + return_value="/dev/ttyAMA1", + ), patch( + "homeassistant.components.otbr.util.zha_api.async_get_network_settings", + return_value=zha_networksettings, + ): + assert await otbr.util.get_allowed_channel(hass, OTBR_MULTIPAN_URL) is None + + # OTBR multipan + ZHA multipan using channel 15 -> 15 + with patch( + "homeassistant.components.otbr.util.zha_api.async_get_radio_path", + return_value="socket://core-silabs-multiprotocol:9999", + ), patch( + "homeassistant.components.otbr.util.zha_api.async_get_network_settings", + return_value=zha_networksettings, + ): + assert await otbr.util.get_allowed_channel(hass, OTBR_MULTIPAN_URL) == 15 + + # OTBR not multipan + ZHA multipan using channel 15 -> no restriction + with patch( + "homeassistant.components.otbr.util.zha_api.async_get_radio_path", + return_value="socket://core-silabs-multiprotocol:9999", + ), patch( + "homeassistant.components.otbr.util.zha_api.async_get_network_settings", + return_value=zha_networksettings, + ): + assert await otbr.util.get_allowed_channel(hass, OTBR_NON_MULTIPAN_URL) is None diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index 8442162257..e6f492f5e5 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -1,5 +1,5 @@ """Test OTBR Websocket API.""" -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest import python_otbr_api @@ -283,14 +283,24 @@ async def test_set_network_channel_conflict( dataset_store = await thread.dataset_store.async_get_store(hass) dataset_id = list(dataset_store.datasets)[0] - await websocket_client.send_json_auto_id( - { - "type": "otbr/set_network", - "dataset_id": dataset_id, - } - ) + networksettings = Mock() + networksettings.network_info.channel = 15 - msg = await websocket_client.receive_json() + with patch( + "homeassistant.components.otbr.util.zha_api.async_get_radio_path", + return_value="socket://core-silabs-multiprotocol:9999", + ), patch( + "homeassistant.components.otbr.util.zha_api.async_get_network_settings", + return_value=networksettings, + ): + await websocket_client.send_json_auto_id( + { + "type": "otbr/set_network", + "dataset_id": dataset_id, + } + ) + + msg = await websocket_client.receive_json() assert not msg["success"] assert msg["error"]["code"] == "channel_conflict" From 1c465b5ad07e5b9ab589d50297899d2191af3308 Mon Sep 17 00:00:00 2001 From: Olivier Ouellet <85790609+olivierouellet@users.noreply.github.com> Date: Tue, 28 Mar 2023 06:42:31 -0400 Subject: [PATCH 1358/2810] Add encoding configuration setting to REST and Scape (#90254) * Create new config parameter for default character encoding if no character encoding is declared * Changes suggested by gjohansson-ST * Added config flow for scape * Removed "character" * Change to create_async_httpx_client * Remove CONF_ENCODING from Scrape SENSOR_SCHEMA * Debug scrape test --- homeassistant/components/rest/__init__.py | 23 ++++++++++++++++--- homeassistant/components/rest/const.py | 2 ++ homeassistant/components/rest/data.py | 8 ++++--- homeassistant/components/rest/schema.py | 3 +++ .../components/scrape/config_flow.py | 11 ++++++++- homeassistant/components/scrape/const.py | 2 ++ homeassistant/components/scrape/strings.json | 12 ++++++---- tests/components/scrape/conftest.py | 9 +++++++- tests/components/scrape/test_config_flow.py | 9 ++++++++ 9 files changed, 67 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/rest/__init__.py b/homeassistant/components/rest/__init__.py index 37c483505b..637e9da6f9 100644 --- a/homeassistant/components/rest/__init__.py +++ b/homeassistant/components/rest/__init__.py @@ -41,7 +41,15 @@ from homeassistant.helpers.reload import ( from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import COORDINATOR, DOMAIN, PLATFORM_IDX, REST, REST_DATA, REST_IDX +from .const import ( + CONF_ENCODING, + COORDINATOR, + DOMAIN, + PLATFORM_IDX, + REST, + REST_DATA, + REST_IDX, +) from .data import RestData from .schema import CONFIG_SCHEMA, RESOURCE_SCHEMA # noqa: F401 @@ -182,7 +190,7 @@ def create_rest_data_from_config(hass: HomeAssistant, config: ConfigType) -> Res headers: dict[str, str] | None = config.get(CONF_HEADERS) params: dict[str, str] | None = config.get(CONF_PARAMS) timeout: int = config[CONF_TIMEOUT] - + encoding: str = config[CONF_ENCODING] if resource_template is not None: resource_template.hass = hass resource = resource_template.async_render(parse_result=False) @@ -201,5 +209,14 @@ def create_rest_data_from_config(hass: HomeAssistant, config: ConfigType) -> Res auth = (username, password) return RestData( - hass, method, resource, auth, headers, params, payload, verify_ssl, timeout + hass, + method, + resource, + encoding, + auth, + headers, + params, + payload, + verify_ssl, + timeout, ) diff --git a/homeassistant/components/rest/const.py b/homeassistant/components/rest/const.py index 5fd32d8fba..bdc0c5af49 100644 --- a/homeassistant/components/rest/const.py +++ b/homeassistant/components/rest/const.py @@ -5,6 +5,8 @@ DOMAIN = "rest" DEFAULT_METHOD = "GET" DEFAULT_VERIFY_SSL = True DEFAULT_FORCE_UPDATE = False +DEFAULT_ENCODING = "UTF-8" +CONF_ENCODING = "encoding" DEFAULT_BINARY_SENSOR_NAME = "REST Binary Sensor" DEFAULT_SENSOR_NAME = "REST Sensor" diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py index c1990b2833..7a5d62694b 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -7,7 +7,7 @@ import httpx from homeassistant.core import HomeAssistant from homeassistant.helpers import template -from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.httpx_client import create_async_httpx_client DEFAULT_TIMEOUT = 10 @@ -22,6 +22,7 @@ class RestData: hass: HomeAssistant, method: str, resource: str, + encoding: str, auth: httpx.DigestAuth | tuple[str, str] | None, headers: dict[str, str] | None, params: dict[str, str] | None, @@ -33,6 +34,7 @@ class RestData: self._hass = hass self._method = method self._resource = resource + self._encoding = encoding self._auth = auth self._headers = headers self._params = params @@ -51,8 +53,8 @@ class RestData: async def async_update(self, log_errors: bool = True) -> None: """Get the latest data from REST service with provided method.""" if not self._async_client: - self._async_client = get_async_client( - self._hass, verify_ssl=self._verify_ssl + self._async_client = create_async_httpx_client( + self._hass, verify_ssl=self._verify_ssl, default_encoding=self._encoding ) rendered_headers = template.render_complex(self._headers, parse_result=False) diff --git a/homeassistant/components/rest/schema.py b/homeassistant/components/rest/schema.py index cfd8f8a385..8e0fa9de00 100644 --- a/homeassistant/components/rest/schema.py +++ b/homeassistant/components/rest/schema.py @@ -33,8 +33,10 @@ from homeassistant.helpers.template_entity import ( ) from .const import ( + CONF_ENCODING, CONF_JSON_ATTRS, CONF_JSON_ATTRS_PATH, + DEFAULT_ENCODING, DEFAULT_FORCE_UPDATE, DEFAULT_METHOD, DEFAULT_VERIFY_SSL, @@ -57,6 +59,7 @@ RESOURCE_SCHEMA = { vol.Optional(CONF_PAYLOAD): cv.string, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string, } SENSOR_SCHEMA = { diff --git a/homeassistant/components/scrape/config_flow.py b/homeassistant/components/scrape/config_flow.py index 419dd04f60..1e3635a010 100644 --- a/homeassistant/components/scrape/config_flow.py +++ b/homeassistant/components/scrape/config_flow.py @@ -60,7 +60,15 @@ from homeassistant.helpers.selector import ( ) from . import COMBINED_SCHEMA -from .const import CONF_INDEX, CONF_SELECT, DEFAULT_NAME, DEFAULT_VERIFY_SSL, DOMAIN +from .const import ( + CONF_ENCODING, + CONF_INDEX, + CONF_SELECT, + DEFAULT_ENCODING, + DEFAULT_NAME, + DEFAULT_VERIFY_SSL, + DOMAIN, +) RESOURCE_SETUP = { vol.Required(CONF_RESOURCE): TextSelector( @@ -84,6 +92,7 @@ RESOURCE_SETUP = { vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): NumberSelector( NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) ), + vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): TextSelector(), } SENSOR_SETUP = { diff --git a/homeassistant/components/scrape/const.py b/homeassistant/components/scrape/const.py index fc433ebb6f..cd64199fa2 100644 --- a/homeassistant/components/scrape/const.py +++ b/homeassistant/components/scrape/const.py @@ -6,11 +6,13 @@ from datetime import timedelta from homeassistant.const import Platform DOMAIN = "scrape" +DEFAULT_ENCODING = "UTF-8" DEFAULT_NAME = "Web scrape" DEFAULT_VERIFY_SSL = True DEFAULT_SCAN_INTERVAL = timedelta(minutes=10) PLATFORMS = [Platform.SENSOR] +CONF_ENCODING = "encoding" CONF_SELECT = "select" CONF_INDEX = "index" diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index 061518cb1d..052ef22848 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -16,14 +16,16 @@ "password": "[%key:common::config_flow::data::password%]", "headers": "Headers", "method": "Method", - "timeout": "Timeout" + "timeout": "Timeout", + "encoding": "Character encoding" }, "data_description": { "resource": "The URL to the website that contains the value", "authentication": "Type of the HTTP authentication. Either basic or digest", "verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed", "headers": "Headers to use for the web request", - "timeout": "Timeout for connection to website" + "timeout": "Timeout for connection to website", + "encoding": "Character encoding to use. Defaults to UTF-8" } }, "sensor": { @@ -110,14 +112,16 @@ "password": "[%key:component::scrape::config::step::user::data::password%]", "headers": "[%key:component::scrape::config::step::user::data::headers%]", "verify_ssl": "[%key:component::scrape::config::step::user::data::verify_ssl%]", - "timeout": "[%key:component::scrape::config::step::user::data::timeout%]" + "timeout": "[%key:component::scrape::config::step::user::data::timeout%]", + "encoding": "[%key:component::scrape::config::step::user::data::encoding%]" }, "data_description": { "resource": "[%key:component::scrape::config::step::user::data_description::resource%]", "authentication": "[%key:component::scrape::config::step::user::data_description::authentication%]", "headers": "[%key:component::scrape::config::step::user::data_description::headers%]", "verify_ssl": "[%key:component::scrape::config::step::user::data_description::verify_ssl%]", - "timeout": "[%key:component::scrape::config::step::user::data_description::timeout%]" + "timeout": "[%key:component::scrape::config::step::user::data_description::timeout%]", + "encoding": "[%key:component::scrape::config::step::user::data_description::encoding%]" } } } diff --git a/tests/components/scrape/conftest.py b/tests/components/scrape/conftest.py index fa90786ec2..5ad4f39844 100644 --- a/tests/components/scrape/conftest.py +++ b/tests/components/scrape/conftest.py @@ -9,7 +9,13 @@ import pytest from homeassistant.components.rest.data import DEFAULT_TIMEOUT from homeassistant.components.rest.schema import DEFAULT_METHOD, DEFAULT_VERIFY_SSL -from homeassistant.components.scrape.const import CONF_INDEX, CONF_SELECT, DOMAIN +from homeassistant.components.scrape.const import ( + CONF_ENCODING, + CONF_INDEX, + CONF_SELECT, + DEFAULT_ENCODING, + DOMAIN, +) from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_METHOD, @@ -38,6 +44,7 @@ async def get_config_to_integration_load() -> dict[str, Any]: CONF_METHOD: DEFAULT_METHOD, CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, CONF_TIMEOUT: DEFAULT_TIMEOUT, + CONF_ENCODING: DEFAULT_ENCODING, "sensor": [ { CONF_NAME: "Current version", diff --git a/tests/components/scrape/test_config_flow.py b/tests/components/scrape/test_config_flow.py index e12a7c15a0..e508937fed 100644 --- a/tests/components/scrape/test_config_flow.py +++ b/tests/components/scrape/test_config_flow.py @@ -9,8 +9,10 @@ from homeassistant.components.rest.data import DEFAULT_TIMEOUT from homeassistant.components.rest.schema import DEFAULT_METHOD from homeassistant.components.scrape import DOMAIN from homeassistant.components.scrape.const import ( + CONF_ENCODING, CONF_INDEX, CONF_SELECT, + DEFAULT_ENCODING, DEFAULT_VERIFY_SSL, ) from homeassistant.const import ( @@ -75,6 +77,7 @@ async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None: CONF_METHOD: "GET", CONF_VERIFY_SSL: True, CONF_TIMEOUT: 10.0, + CONF_ENCODING: "UTF-8", "sensor": [ { CONF_NAME: "Current version", @@ -165,6 +168,7 @@ async def test_flow_fails(hass: HomeAssistant, get_data: MockRestData) -> None: CONF_METHOD: "GET", CONF_VERIFY_SSL: True, CONF_TIMEOUT: 10.0, + CONF_ENCODING: "UTF-8", "sensor": [ { CONF_NAME: "Current version", @@ -206,6 +210,7 @@ async def test_options_resource_flow( CONF_METHOD: DEFAULT_METHOD, CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, CONF_TIMEOUT: DEFAULT_TIMEOUT, + CONF_ENCODING: DEFAULT_ENCODING, CONF_USERNAME: "secret_username", CONF_PASSWORD: "secret_password", }, @@ -218,6 +223,7 @@ async def test_options_resource_flow( CONF_METHOD: "GET", CONF_VERIFY_SSL: True, CONF_TIMEOUT: 10.0, + CONF_ENCODING: "UTF-8", CONF_USERNAME: "secret_username", CONF_PASSWORD: "secret_password", "sensor": [ @@ -282,6 +288,7 @@ async def test_options_add_remove_sensor_flow( CONF_METHOD: "GET", CONF_VERIFY_SSL: True, CONF_TIMEOUT: 10, + CONF_ENCODING: "UTF-8", "sensor": [ { CONF_NAME: "Current version", @@ -341,6 +348,7 @@ async def test_options_add_remove_sensor_flow( CONF_METHOD: "GET", CONF_VERIFY_SSL: True, CONF_TIMEOUT: 10, + CONF_ENCODING: "UTF-8", "sensor": [ { CONF_NAME: "Template", @@ -407,6 +415,7 @@ async def test_options_edit_sensor_flow( CONF_METHOD: "GET", CONF_VERIFY_SSL: True, CONF_TIMEOUT: 10, + CONF_ENCODING: "UTF-8", "sensor": [ { CONF_NAME: "Current version", From e4bb339a1e80eb5db9139568d035c4d60c0076ba Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 28 Mar 2023 12:43:00 +0200 Subject: [PATCH 1359/2810] Add device info to Nextcloud integration (#90328) * add device_info * use entry_id as identifier + device name * use shorthand attributes * remove model from device info Co-authored-by: Franck Nijhof --------- Co-authored-by: Franck Nijhof --- .../components/nextcloud/binary_sensor.py | 2 +- homeassistant/components/nextcloud/entity.py | 20 +++++++++++++------ homeassistant/components/nextcloud/sensor.py | 2 +- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/nextcloud/binary_sensor.py b/homeassistant/components/nextcloud/binary_sensor.py index 0d960bea8e..3cf3cc3ae2 100644 --- a/homeassistant/components/nextcloud/binary_sensor.py +++ b/homeassistant/components/nextcloud/binary_sensor.py @@ -25,7 +25,7 @@ async def async_setup_entry( coordinator: NextcloudDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( [ - NextcloudBinarySensor(coordinator, name) + NextcloudBinarySensor(coordinator, name, entry) for name in coordinator.data if name in BINARY_SENSORS ] diff --git a/homeassistant/components/nextcloud/entity.py b/homeassistant/components/nextcloud/entity.py index 54976351dd..ed5882cfe7 100644 --- a/homeassistant/components/nextcloud/entity.py +++ b/homeassistant/components/nextcloud/entity.py @@ -1,23 +1,31 @@ """Base entity for the Nextcloud integration.""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .const import DOMAIN from .coordinator import NextcloudDataUpdateCoordinator class NextcloudEntity(CoordinatorEntity[NextcloudDataUpdateCoordinator]): """Base Nextcloud entity.""" + _attr_has_entity_name = True _attr_icon = "mdi:cloud" - def __init__(self, coordinator: NextcloudDataUpdateCoordinator, item: str) -> None: + def __init__( + self, coordinator: NextcloudDataUpdateCoordinator, item: str, entry: ConfigEntry + ) -> None: """Initialize the Nextcloud sensor.""" super().__init__(coordinator) self.item = item self._attr_name = item - - @property - def unique_id(self) -> str: - """Return the unique ID for this sensor.""" - return f"{self.coordinator.url}#{self.item}" + self._attr_unique_id = f"{coordinator.url}#{item}" + self._attr_device_info = DeviceInfo( + name="Nextcloud", + identifiers={(DOMAIN, entry.entry_id)}, + sw_version=coordinator.data.get("nextcloud_system_version"), + configuration_url=coordinator.url, + ) diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py index eb6043e4bc..a5df872e08 100644 --- a/homeassistant/components/nextcloud/sensor.py +++ b/homeassistant/components/nextcloud/sensor.py @@ -65,7 +65,7 @@ async def async_setup_entry( coordinator: NextcloudDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( [ - NextcloudSensor(coordinator, name) + NextcloudSensor(coordinator, name, entry) for name in coordinator.data if name in SENSORS ] From 45753521010ad844aa6d3177b0371d937e98530d Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Tue, 28 Mar 2023 12:50:57 +0200 Subject: [PATCH 1360/2810] Bump python-bsblan to 0.5.11 (#90377) --- homeassistant/components/bsblan/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bsblan/manifest.json b/homeassistant/components/bsblan/manifest.json index f53e395f0c..0e945d13d4 100644 --- a/homeassistant/components/bsblan/manifest.json +++ b/homeassistant/components/bsblan/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bsblan", "iot_class": "local_polling", "loggers": ["bsblan"], - "requirements": ["python-bsblan==0.5.9"] + "requirements": ["python-bsblan==0.5.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index c3dfa944f1..45d6c88ae8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2021,7 +2021,7 @@ pythinkingcleaner==0.0.3 python-blockchain-api==0.0.2 # homeassistant.components.bsblan -python-bsblan==0.5.9 +python-bsblan==0.5.11 # homeassistant.components.clementine python-clementine-remote==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6edc309c81..2da989a58a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1468,7 +1468,7 @@ pytankerkoenig==0.0.6 pytautulli==23.1.1 # homeassistant.components.bsblan -python-bsblan==0.5.9 +python-bsblan==0.5.11 # homeassistant.components.ecobee python-ecobee-api==0.2.14 From b207790177ac337745da172857b1dde54569663c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 01:01:41 -1000 Subject: [PATCH 1361/2810] Fix benign typo in discovery flow helper (#90396) --- homeassistant/helpers/discovery_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/discovery_flow.py b/homeassistant/helpers/discovery_flow.py index bd5ee4942d..586824b449 100644 --- a/homeassistant/helpers/discovery_flow.py +++ b/homeassistant/helpers/discovery_flow.py @@ -11,7 +11,7 @@ from homeassistant.loader import bind_hass from homeassistant.util.async_ import gather_with_concurrency FLOW_INIT_LIMIT = 2 -DISCOVERY_FLOW_DISPATCHER = "discovery_flow_disptacher" +DISCOVERY_FLOW_DISPATCHER = "discovery_flow_dispatcher" @bind_hass From b6a0ac6f0ad9abcda45d7395e2ae8997398037e5 Mon Sep 17 00:00:00 2001 From: Jan Iven Date: Tue, 28 Mar 2023 13:04:08 +0200 Subject: [PATCH 1362/2810] Fix envoy last_seven_days_energy* state class (#84528) Co-authored-by: Franck Nijhof --- homeassistant/components/enphase_envoy/const.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/enphase_envoy/const.py b/homeassistant/components/enphase_envoy/const.py index cd3235f1be..4a105e5a06 100644 --- a/homeassistant/components/enphase_envoy/const.py +++ b/homeassistant/components/enphase_envoy/const.py @@ -33,7 +33,6 @@ SENSORS = ( key="seven_days_production", name="Last Seven Days Energy Production", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( @@ -61,7 +60,6 @@ SENSORS = ( key="seven_days_consumption", name="Last Seven Days Energy Consumption", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( From e6c94d78548625f3148af10877b824924e7cbb45 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 28 Mar 2023 13:05:09 +0200 Subject: [PATCH 1363/2810] Remove mysensors notify (#90402) --- .../components/mysensors/__init__.py | 51 ++------- homeassistant/components/mysensors/const.py | 8 +- homeassistant/components/mysensors/notify.py | 100 ------------------ .../components/mysensors/test_config_flow.py | 15 --- tests/components/mysensors/test_notify.py | 95 ----------------- 5 files changed, 8 insertions(+), 261 deletions(-) delete mode 100644 homeassistant/components/mysensors/notify.py delete mode 100644 tests/components/mysensors/test_notify.py diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index d8c3debe7e..129b143062 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable -from functools import partial import logging from mysensors import BaseAsyncGateway @@ -12,24 +11,19 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntry -from homeassistant.helpers.discovery import async_load_platform -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_DEVICES, DOMAIN, - MYSENSORS_DISCOVERY, MYSENSORS_GATEWAYS, MYSENSORS_ON_UNLOAD, - PLATFORMS_WITH_ENTRY_SUPPORT, + PLATFORMS, DevId, DiscoveryInfo, SensorType, ) from .device import MySensorsDevice, get_mysensors_devices from .gateway import finish_setup, gw_stop, setup_gateway -from .helpers import on_unload _LOGGER = logging.getLogger(__name__) @@ -39,14 +33,6 @@ DATA_HASS_CONFIG = "hass_config" CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the MySensors component.""" - # This is needed to set up the notify platform via discovery. - hass.data[DOMAIN] = {DATA_HASS_CONFIG: config} - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up an instance of the MySensors integration. @@ -58,33 +44,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Gateway setup failed for %s", entry.data) return False - if MYSENSORS_GATEWAYS not in hass.data[DOMAIN]: - hass.data[DOMAIN][MYSENSORS_GATEWAYS] = {} - hass.data[DOMAIN][MYSENSORS_GATEWAYS][entry.entry_id] = gateway + mysensors_data = hass.data.setdefault(DOMAIN, {}) + if MYSENSORS_GATEWAYS not in mysensors_data: + mysensors_data[MYSENSORS_GATEWAYS] = {} + mysensors_data[MYSENSORS_GATEWAYS][entry.entry_id] = gateway - # Connect notify discovery as that integration doesn't support entry forwarding. - - load_discovery_platform = partial( - async_load_platform, - hass, - Platform.NOTIFY, - DOMAIN, - hass_config=hass.data[DOMAIN][DATA_HASS_CONFIG], - ) - - on_unload( - hass, - entry.entry_id, - async_dispatcher_connect( - hass, - MYSENSORS_DISCOVERY.format(entry.entry_id, Platform.NOTIFY), - load_discovery_platform, - ), - ) - - await hass.config_entries.async_forward_entry_setups( - entry, PLATFORMS_WITH_ENTRY_SUPPORT - ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await finish_setup(hass, entry, gateway) return True @@ -95,9 +60,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: gateway: BaseAsyncGateway = hass.data[DOMAIN][MYSENSORS_GATEWAYS][entry.entry_id] - unload_ok = await hass.config_entries.async_unload_platforms( - entry, PLATFORMS_WITH_ENTRY_SUPPORT - ) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if not unload_ok: return False diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py index 5368f65b83..bcdc6f80ab 100644 --- a/homeassistant/components/mysensors/const.py +++ b/homeassistant/components/mysensors/const.py @@ -40,7 +40,6 @@ class DiscoveryInfo(TypedDict): """Represent the discovery info type for mysensors platforms.""" devices: list[DevId] - name: str # CONF_NAME is used in the notify base integration. gateway_id: GatewayId @@ -92,8 +91,6 @@ LIGHT_TYPES: dict[SensorType, set[ValueType]] = { "S_RGBW_LIGHT": {"V_RGBW"}, } -NOTIFY_TYPES: dict[SensorType, set[ValueType]] = {"S_INFO": {"V_TEXT"}} - REMOTE_TYPES: dict[SensorType, set[ValueType]] = {"S_IR": {"V_IR_SEND"}} SENSOR_TYPES: dict[SensorType, set[ValueType]] = { @@ -148,7 +145,6 @@ PLATFORM_TYPES: dict[Platform, dict[SensorType, set[ValueType]]] = { Platform.COVER: COVER_TYPES, Platform.DEVICE_TRACKER: DEVICE_TRACKER_TYPES, Platform.LIGHT: LIGHT_TYPES, - Platform.NOTIFY: NOTIFY_TYPES, Platform.REMOTE: REMOTE_TYPES, Platform.SENSOR: SENSOR_TYPES, Platform.SWITCH: SWITCH_TYPES, @@ -167,6 +163,4 @@ for platform, platform_types in PLATFORM_TYPES.items(): for s_type_name in platform_types: TYPE_TO_PLATFORMS[s_type_name].append(platform) -PLATFORMS_WITH_ENTRY_SUPPORT = set(PLATFORM_TYPES.keys()) - { - Platform.NOTIFY, -} +PLATFORMS = tuple(PLATFORM_TYPES) diff --git a/homeassistant/components/mysensors/notify.py b/homeassistant/components/mysensors/notify.py deleted file mode 100644 index 97d4175a6f..0000000000 --- a/homeassistant/components/mysensors/notify.py +++ /dev/null @@ -1,100 +0,0 @@ -"""MySensors notification service.""" -from __future__ import annotations - -from typing import Any, cast - -from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import slugify - -from .. import mysensors -from .const import DOMAIN, DevId, DiscoveryInfo - - -async def async_get_service( - hass: HomeAssistant, - config: ConfigType, - discovery_info: DiscoveryInfoType | None = None, -) -> BaseNotificationService | None: - """Get the MySensors notification service.""" - if not discovery_info: - return None - - new_devices = mysensors.setup_mysensors_platform( - hass, - Platform.NOTIFY, - cast(DiscoveryInfo, discovery_info), - MySensorsNotificationDevice, - ) - if not new_devices: - return None - return MySensorsNotificationService(hass) - - -class MySensorsNotificationDevice(mysensors.device.MySensorsDevice): - """Represent a MySensors Notification device.""" - - @callback - def _async_update_callback(self) -> None: - """Update the device.""" - self._async_update() - - def send_msg(self, msg: str) -> None: - """Send a message.""" - for sub_msg in [msg[i : i + 25] for i in range(0, len(msg), 25)]: - # Max mysensors payload is 25 bytes. - self.gateway.set_child_value( - self.node_id, self.child_id, self.value_type, sub_msg - ) - - def __repr__(self) -> str: - """Return the representation.""" - return f"" - - -class MySensorsNotificationService(BaseNotificationService): - """Implement a MySensors notification service.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize the service.""" - self.devices: dict[ - DevId, MySensorsNotificationDevice - ] = mysensors.get_mysensors_devices( - hass, Platform.NOTIFY - ) # type: ignore[assignment] - self.hass = hass - - async def async_send_message(self, message: str = "", **kwargs: Any) -> None: - """Send a message to a user.""" - target_devices = kwargs.get(ATTR_TARGET) - devices = [ - device - for device in self.devices.values() - if target_devices is None or device.name in target_devices - ] - - placeholders = { - "alternate_service": "text.set_value", - "deprecated_service": f"notify.{self._service_name}", - "alternate_target": str( - [f"text.{slugify(device.name)}" for device in devices] - ), - } - - async_create_issue( - self.hass, - DOMAIN, - "deprecated_notify_service", - breaks_in_ha_version="2023.4.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_service", - translation_placeholders=placeholders, - ) - - for device in devices: - device.send_msg(message) diff --git a/tests/components/mysensors/test_config_flow.py b/tests/components/mysensors/test_config_flow.py index 98a6ae3b23..dc24a48edd 100644 --- a/tests/components/mysensors/test_config_flow.py +++ b/tests/components/mysensors/test_config_flow.py @@ -61,8 +61,6 @@ async def test_config_mqtt(hass: HomeAssistant, mqtt: None) -> None: flow_id = step["flow_id"] with patch( - "homeassistant.components.mysensors.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.mysensors.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -89,7 +87,6 @@ async def test_config_mqtt(hass: HomeAssistant, mqtt: None) -> None: CONF_VERSION: "2.4", CONF_GATEWAY_TYPE: "MQTT", } - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -121,8 +118,6 @@ async def test_config_serial(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.mysensors.config_flow.try_connect", return_value=True ), patch( - "homeassistant.components.mysensors.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.mysensors.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -146,7 +141,6 @@ async def test_config_serial(hass: HomeAssistant) -> None: CONF_VERSION: "2.4", CONF_GATEWAY_TYPE: "Serial", } - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -158,8 +152,6 @@ async def test_config_tcp(hass: HomeAssistant) -> None: with patch( "homeassistant.components.mysensors.config_flow.try_connect", return_value=True ), patch( - "homeassistant.components.mysensors.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.mysensors.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -183,7 +175,6 @@ async def test_config_tcp(hass: HomeAssistant) -> None: CONF_VERSION: "2.4", CONF_GATEWAY_TYPE: "TCP", } - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -195,8 +186,6 @@ async def test_fail_to_connect(hass: HomeAssistant) -> None: with patch( "homeassistant.components.mysensors.config_flow.try_connect", return_value=False ), patch( - "homeassistant.components.mysensors.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.mysensors.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -215,7 +204,6 @@ async def test_fail_to_connect(hass: HomeAssistant) -> None: errors = result["errors"] assert errors assert errors.get("base") == "cannot_connect" - assert len(mock_setup.mock_calls) == 0 assert len(mock_setup_entry.mock_calls) == 0 @@ -358,8 +346,6 @@ async def test_config_invalid( "homeassistant.components.mysensors.gateway.socket.getaddrinfo", side_effect=OSError, ), patch( - "homeassistant.components.mysensors.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.mysensors.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -375,7 +361,6 @@ async def test_config_invalid( assert errors assert err_field in errors assert errors[err_field] == err_string - assert len(mock_setup.mock_calls) == 0 assert len(mock_setup_entry.mock_calls) == 0 diff --git a/tests/components/mysensors/test_notify.py b/tests/components/mysensors/test_notify.py deleted file mode 100644 index e96b463cc7..0000000000 --- a/tests/components/mysensors/test_notify.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Provide tests for mysensors notify platform.""" -from __future__ import annotations - -from collections.abc import Callable -from unittest.mock import MagicMock, call - -from mysensors.sensor import Sensor - -from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry - - -async def test_text_type( - hass: HomeAssistant, - text_node: Sensor, - transport_write: MagicMock, - integration: MockConfigEntry, -) -> None: - """Test a text type child.""" - # Test without target. - await hass.services.async_call( - NOTIFY_DOMAIN, "mysensors", {"message": "Hello World"}, blocking=True - ) - - assert transport_write.call_count == 1 - assert transport_write.call_args == call("1;1;1;0;47;Hello World\n") - - # Test with target. - await hass.services.async_call( - NOTIFY_DOMAIN, - "mysensors", - {"message": "Hello", "target": "Text Node 1 1"}, - blocking=True, - ) - - assert transport_write.call_count == 2 - assert transport_write.call_args == call("1;1;1;0;47;Hello\n") - - transport_write.reset_mock() - - # Test a message longer than 25 characters. - await hass.services.async_call( - NOTIFY_DOMAIN, - "mysensors", - { - "message": "This is a long message that will be split", - "target": "Text Node 1 1", - }, - blocking=True, - ) - - assert transport_write.call_count == 2 - assert transport_write.call_args_list == [ - call("1;1;1;0;47;This is a long message th\n"), - call("1;1;1;0;47;at will be split\n"), - ] - - -async def test_text_type_discovery( - hass: HomeAssistant, - text_node: Sensor, - transport_write: MagicMock, - receive_message: Callable[[str], None], -) -> None: - """Test text type discovery.""" - receive_message("1;2;0;0;36;\n") - receive_message("1;2;1;0;47;test\n") - receive_message("1;2;1;0;47;test2\n") # Test that more than one set message works. - await hass.async_block_till_done() - - # Test targeting the discovered child. - await hass.services.async_call( - NOTIFY_DOMAIN, - "mysensors", - {"message": "Hello", "target": "Text Node 1 2"}, - blocking=True, - ) - - assert transport_write.call_count == 1 - assert transport_write.call_args == call("1;2;1;0;47;Hello\n") - - transport_write.reset_mock() - - # Test targeting all notify children. - await hass.services.async_call( - NOTIFY_DOMAIN, "mysensors", {"message": "Hello World"}, blocking=True - ) - - assert transport_write.call_count == 2 - assert transport_write.call_args_list == [ - call("1;1;1;0;47;Hello World\n"), - call("1;2;1;0;47;Hello World\n"), - ] From de2ca31a71399a6015fa02445913e053a47235eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 01:08:43 -1000 Subject: [PATCH 1364/2810] Remove lru_cache on websocket _state_diff (#90392) --- homeassistant/components/websocket_api/messages.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index 0765c6a5b7..ec1ab267a3 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -132,7 +132,6 @@ def _state_diff_event(event: Event) -> dict: return _state_diff(event_old_state, event_new_state) -@lru_cache(maxsize=128) def _state_diff( old_state: State, new_state: State ) -> dict[str, dict[str, dict[str, dict[str, str | list[str]]]]]: From f72bf73b0338a3da4ba9dfe9e9fa185a676fc8a3 Mon Sep 17 00:00:00 2001 From: PatrickGlesner <34370149+PatrickGlesner@users.noreply.github.com> Date: Tue, 28 Mar 2023 13:19:52 +0200 Subject: [PATCH 1365/2810] Fix NMBS IndexError (#90365) --- homeassistant/components/nmbs/sensor.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index b9a216875f..8fb227140a 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -162,7 +162,13 @@ class NMBSLiveBoard(SensorEntity): """Set the state equal to the next departure.""" liveboard = self._api_client.get_liveboard(self._station) - if liveboard is None or not liveboard.get("departures"): + if ( + liveboard is None + or liveboard.get("departures") is None + or liveboard.get("departures").get("number") is None + or liveboard.get("departures").get("number") == "0" + or liveboard.get("departures").get("departure") is None + ): return next_departure = liveboard["departures"]["departure"][0] From 08444eeb7608b2233439ce5a90364d113a664e1b Mon Sep 17 00:00:00 2001 From: Geoff Date: Tue, 28 Mar 2023 04:20:20 -0700 Subject: [PATCH 1366/2810] Update transmission up/down speed values (#88528) Co-authored-by: Erik Montnemery Co-authored-by: Franck Nijhof --- homeassistant/components/transmission/sensor.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index 9147773131..2c7bf24cdf 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -109,18 +109,19 @@ class TransmissionSpeedSensor(TransmissionSensor): """Representation of a Transmission speed sensor.""" _attr_device_class = SensorDeviceClass.DATA_RATE - _attr_native_unit_of_measurement = UnitOfDataRate.MEGABYTES_PER_SECOND + _attr_native_unit_of_measurement = UnitOfDataRate.BYTES_PER_SECOND + _attr_suggested_display_precision = 2 + _attr_suggested_unit_of_measurement = UnitOfDataRate.MEGABYTES_PER_SECOND def update(self) -> None: """Get the latest data from Transmission and updates the state.""" if data := self._tm_client.api.data: - mb_spd = ( + b_spd = ( float(data.downloadSpeed) if self._sub_type == "download" else float(data.uploadSpeed) ) - mb_spd = mb_spd / 1024 / 1024 - self._state = round(mb_spd, 2 if mb_spd < 0.1 else 1) + self._state = b_spd class TransmissionStatusSensor(TransmissionSensor): From 6e23e00b5a6152bec7ddd6d1fbd851ee552c84aa Mon Sep 17 00:00:00 2001 From: MarkGodwin Date: Tue, 28 Mar 2023 12:25:10 +0100 Subject: [PATCH 1367/2810] TP-Link Omada update entities code review feedback (#89668) --- .../components/tplink_omada/__init__.py | 2 +- .../components/tplink_omada/controller.py | 44 +++--- .../components/tplink_omada/coordinator.py | 9 +- .../components/tplink_omada/manifest.json | 2 +- .../components/tplink_omada/switch.py | 5 +- .../components/tplink_omada/update.py | 132 +++++++++--------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 8 files changed, 102 insertions(+), 96 deletions(-) diff --git a/homeassistant/components/tplink_omada/__init__.py b/homeassistant/components/tplink_omada/__init__.py index 709ad52012..824ea8df42 100644 --- a/homeassistant/components/tplink_omada/__init__.py +++ b/homeassistant/components/tplink_omada/__init__.py @@ -44,7 +44,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Unexpected error connecting to Omada controller: {ex}" ) from ex - site_client = await client.get_site_client(OmadaSite(None, entry.data[CONF_SITE])) + site_client = await client.get_site_client(OmadaSite("", entry.data[CONF_SITE])) controller = OmadaSiteController(hass, site_client) hass.data[DOMAIN][entry.entry_id] = controller diff --git a/homeassistant/components/tplink_omada/controller.py b/homeassistant/components/tplink_omada/controller.py index b42cb37ff7..508a8b914d 100644 --- a/homeassistant/components/tplink_omada/controller.py +++ b/homeassistant/components/tplink_omada/controller.py @@ -1,7 +1,5 @@ """Controller for sharing Omada API coordinators between platforms.""" -from functools import partial - from tplink_omada_client.devices import OmadaSwitch, OmadaSwitchPortDetails from tplink_omada_client.omadasiteclient import OmadaSiteClient @@ -9,13 +7,28 @@ from homeassistant.core import HomeAssistant from .coordinator import OmadaCoordinator +POLL_SWITCH_PORT = 300 -async def _poll_switch_state( - client: OmadaSiteClient, network_switch: OmadaSwitch -) -> dict[str, OmadaSwitchPortDetails]: - """Poll a switch's current state.""" - ports = await client.get_switch_ports(network_switch) - return {p.port_id: p for p in ports} + +class OmadaSwitchPortCoordinator(OmadaCoordinator[OmadaSwitchPortDetails]): + """Coordinator for getting details about ports on a switch.""" + + def __init__( + self, + hass: HomeAssistant, + omada_client: OmadaSiteClient, + network_switch: OmadaSwitch, + ) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, omada_client, f"{network_switch.name} Ports", POLL_SWITCH_PORT + ) + self._network_switch = network_switch + + async def poll_update(self) -> dict[str, OmadaSwitchPortDetails]: + """Poll a switch's current state.""" + ports = await self.omada_client.get_switch_ports(self._network_switch) + return {p.port_id: p for p in ports} class OmadaSiteController: @@ -26,9 +39,7 @@ class OmadaSiteController: self._hass = hass self._omada_client = omada_client - self._switch_port_coordinators: dict[ - str, OmadaCoordinator[OmadaSwitchPortDetails] - ] = {} + self._switch_port_coordinators: dict[str, OmadaSwitchPortCoordinator] = {} @property def omada_client(self) -> OmadaSiteClient: @@ -37,16 +48,11 @@ class OmadaSiteController: def get_switch_port_coordinator( self, switch: OmadaSwitch - ) -> OmadaCoordinator[OmadaSwitchPortDetails]: + ) -> OmadaSwitchPortCoordinator: """Get coordinator for network port information of a given switch.""" if switch.mac not in self._switch_port_coordinators: - self._switch_port_coordinators[switch.mac] = OmadaCoordinator[ - OmadaSwitchPortDetails - ]( - self._hass, - self._omada_client, - f"{switch.name} Ports", - partial(_poll_switch_state, network_switch=switch), + self._switch_port_coordinators[switch.mac] = OmadaSwitchPortCoordinator( + self._hass, self._omada_client, switch ) return self._switch_port_coordinators[switch.mac] diff --git a/homeassistant/components/tplink_omada/coordinator.py b/homeassistant/components/tplink_omada/coordinator.py index d73461dc78..3ff73501bd 100644 --- a/homeassistant/components/tplink_omada/coordinator.py +++ b/homeassistant/components/tplink_omada/coordinator.py @@ -1,5 +1,4 @@ """Generic Omada API coordinator.""" -from collections.abc import Awaitable, Callable from datetime import timedelta import logging from typing import Generic, TypeVar @@ -24,7 +23,6 @@ class OmadaCoordinator(DataUpdateCoordinator[dict[str, T]], Generic[T]): hass: HomeAssistant, omada_client: OmadaSiteClient, name: str, - update_func: Callable[[OmadaSiteClient], Awaitable[dict[str, T]]], poll_delay: int = 300, ) -> None: """Initialize my coordinator.""" @@ -35,12 +33,15 @@ class OmadaCoordinator(DataUpdateCoordinator[dict[str, T]], Generic[T]): update_interval=timedelta(seconds=poll_delay), ) self.omada_client = omada_client - self._update_func = update_func async def _async_update_data(self) -> dict[str, T]: """Fetch data from API endpoint.""" try: async with async_timeout.timeout(10): - return await self._update_func(self.omada_client) + return await self.poll_update() except OmadaClientException as err: raise UpdateFailed(f"Error communicating with API: {err}") from err + + async def poll_update(self) -> dict[str, T]: + """Poll the current data from the controller.""" + raise NotImplementedError("Update method not implemented") diff --git a/homeassistant/components/tplink_omada/manifest.json b/homeassistant/components/tplink_omada/manifest.json index a0fb58b3f6..9d72340776 100644 --- a/homeassistant/components/tplink_omada/manifest.json +++ b/homeassistant/components/tplink_omada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tplink_omada", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["tplink-omada-client==1.1.3"] + "requirements": ["tplink-omada-client==1.1.4"] } diff --git a/homeassistant/components/tplink_omada/switch.py b/homeassistant/components/tplink_omada/switch.py index e85b1c181f..830f75b6a9 100644 --- a/homeassistant/components/tplink_omada/switch.py +++ b/homeassistant/components/tplink_omada/switch.py @@ -14,8 +14,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .controller import OmadaSiteController -from .coordinator import OmadaCoordinator +from .controller import OmadaSiteController, OmadaSwitchPortCoordinator from .entity import OmadaDeviceEntity POE_SWITCH_ICON = "mdi:ethernet" @@ -68,7 +67,7 @@ class OmadaNetworkSwitchPortPoEControl( def __init__( self, - coordinator: OmadaCoordinator[OmadaSwitchPortDetails], + coordinator: OmadaSwitchPortCoordinator, device: OmadaSwitch, port_id: str, ) -> None: diff --git a/homeassistant/components/tplink_omada/update.py b/homeassistant/components/tplink_omada/update.py index 5581f61d82..685ad9c576 100644 --- a/homeassistant/components/tplink_omada/update.py +++ b/homeassistant/components/tplink_omada/update.py @@ -1,24 +1,26 @@ -"""Support for TPLink Omada device toggle options.""" +"""Support for TPLink Omada device firmware updates.""" from __future__ import annotations -import logging +from datetime import timedelta from typing import Any, NamedTuple from tplink_omada_client.devices import OmadaFirmwareUpdate, OmadaListDevice +from tplink_omada_client.exceptions import OmadaClientException, RequestFailed from tplink_omada_client.omadasiteclient import OmadaSiteClient from homeassistant.components.update import UpdateEntity, UpdateEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_call_later from .const import DOMAIN from .controller import OmadaSiteController from .coordinator import OmadaCoordinator from .entity import OmadaDeviceEntity -_LOGGER = logging.getLogger(__name__) +POLL_DELAY_IDLE = 6 * 60 * 60 +POLL_DELAY_UPGRADE = 60 class FirmwareUpdateStatus(NamedTuple): @@ -28,24 +30,39 @@ class FirmwareUpdateStatus(NamedTuple): firmware: OmadaFirmwareUpdate | None -async def _get_firmware_updates(client: OmadaSiteClient) -> list[FirmwareUpdateStatus]: - devices = await client.get_devices() - return [ - FirmwareUpdateStatus( - device=d, - firmware=None - if not d.need_upgrade - else await client.get_firmware_details(d), +class OmadaFirmwareUpdateCoodinator(OmadaCoordinator[FirmwareUpdateStatus]): + """Coordinator for getting details about ports on a switch.""" + + def __init__(self, hass: HomeAssistant, omada_client: OmadaSiteClient) -> None: + """Initialize my coordinator.""" + super().__init__(hass, omada_client, "Firmware Updates", POLL_DELAY_IDLE) + + async def _get_firmware_updates(self) -> list[FirmwareUpdateStatus]: + devices = await self.omada_client.get_devices() + + updates = [ + FirmwareUpdateStatus( + device=d, + firmware=None + if not d.need_upgrade + else await self.omada_client.get_firmware_details(d), + ) + for d in devices + ] + + # During a firmware upgrade, poll more frequently + self.update_interval = timedelta( + seconds=( + POLL_DELAY_UPGRADE + if any(u.device.fw_download for u in updates) + else POLL_DELAY_IDLE + ) ) - for d in devices - ] + return updates - -async def _poll_firmware_updates( - client: OmadaSiteClient, -) -> dict[str, FirmwareUpdateStatus]: - """Poll the state of Omada Devices firmware update availability.""" - return {d.device.mac: d for d in await _get_firmware_updates(client)} + async def poll_update(self) -> dict[str, FirmwareUpdateStatus]: + """Poll the state of Omada Devices firmware update availability.""" + return {d.device.mac: d for d in await self._get_firmware_updates()} async def async_setup_entry( @@ -59,19 +76,9 @@ async def async_setup_entry( devices = await omada_client.get_devices() - coordinator = OmadaCoordinator[FirmwareUpdateStatus]( - hass, - omada_client, - "Firmware Updates", - _poll_firmware_updates, - poll_delay=6 * 60 * 60, - ) + coordinator = OmadaFirmwareUpdateCoodinator(hass, omada_client) - entities: list = [] - for device in devices: - entities.append(OmadaDeviceUpdate(coordinator, device)) - - async_add_entities(entities) + async_add_entities(OmadaDeviceUpdate(coordinator, device) for device in devices) await coordinator.async_request_refresh() @@ -86,64 +93,57 @@ class OmadaDeviceUpdate( | UpdateEntityFeature.PROGRESS | UpdateEntityFeature.RELEASE_NOTES ) - _firmware_update: OmadaFirmwareUpdate = None + _attr_has_entity_name = True + _attr_name = "Firmware update" def __init__( self, - coordinator: OmadaCoordinator[FirmwareUpdateStatus], + coordinator: OmadaFirmwareUpdateCoodinator, device: OmadaListDevice, ) -> None: """Initialize the update entity.""" super().__init__(coordinator, device) self._mac = device.mac - self._device = device self._omada_client = coordinator.omada_client self._attr_unique_id = f"{device.mac}_firmware" - self._attr_has_entity_name = True - self._attr_name = "Firmware Update" - self._refresh_state() - - def _refresh_state(self) -> None: - if self._firmware_update and self._device.need_upgrade: - self._attr_installed_version = self._firmware_update.current_version - self._attr_latest_version = self._firmware_update.latest_version - else: - self._attr_installed_version = self._device.firmware_version - self._attr_latest_version = self._device.firmware_version - self._attr_in_progress = self._device.fw_download - - if self._attr_in_progress: - # While firmware update is in progress, poll more frequently - async_call_later(self.hass, 60, self._request_refresh) - - async def _request_refresh(self, _now: Any) -> None: - await self.coordinator.async_request_refresh() def release_notes(self) -> str | None: """Get the release notes for the latest update.""" - if self._firmware_update: - return str(self._firmware_update.release_notes) - return "" + status = self.coordinator.data[self._mac] + if status.firmware: + return status.firmware.release_notes + return None async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install a firmware update.""" - if self._firmware_update and ( - version is None or self._firmware_update.latest_version == version - ): - await self._omada_client.start_firmware_upgrade(self._device) + try: + await self._omada_client.start_firmware_upgrade( + self.coordinator.data[self._mac].device + ) + except RequestFailed as ex: + raise HomeAssistantError("Firmware update request rejected") from ex + except OmadaClientException as ex: + raise HomeAssistantError( + "Unable to send Firmware update request. Check the controller is online." + ) from ex + finally: await self.coordinator.async_request_refresh() - else: - _LOGGER.error("Firmware upgrade is not available for %s", self._device.name) @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" status = self.coordinator.data[self._mac] - self._device = status.device - self._firmware_update = status.firmware - self._refresh_state() + + if status.firmware and status.device.need_upgrade: + self._attr_installed_version = status.firmware.current_version + self._attr_latest_version = status.firmware.latest_version + else: + self._attr_installed_version = status.device.firmware_version + self._attr_latest_version = status.device.firmware_version + self._attr_in_progress = status.device.fw_download + self.async_write_ha_state() diff --git a/requirements_all.txt b/requirements_all.txt index 45d6c88ae8..1560012227 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2521,7 +2521,7 @@ total_connect_client==2023.2 tp-connected==0.0.4 # homeassistant.components.tplink_omada -tplink-omada-client==1.1.3 +tplink-omada-client==1.1.4 # homeassistant.components.transmission transmission-rpc==3.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2da989a58a..e1235f4734 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1788,7 +1788,7 @@ toonapi==0.2.1 total_connect_client==2023.2 # homeassistant.components.tplink_omada -tplink-omada-client==1.1.3 +tplink-omada-client==1.1.4 # homeassistant.components.transmission transmission-rpc==3.4.0 From 29645d5820a1240155493d4651ae5895e1928e07 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 28 Mar 2023 13:39:32 +0200 Subject: [PATCH 1368/2810] Remove mysensors ir switch (#90403) --- .../components/mysensors/__init__.py | 12 +- homeassistant/components/mysensors/const.py | 1 - homeassistant/components/mysensors/device.py | 4 +- homeassistant/components/mysensors/light.py | 4 +- .../components/mysensors/services.yaml | 18 -- .../components/mysensors/strings.json | 24 --- homeassistant/components/mysensors/switch.py | 158 +----------------- tests/components/mysensors/test_switch.py | 79 --------- 8 files changed, 18 insertions(+), 282 deletions(-) delete mode 100644 homeassistant/components/mysensors/services.yaml diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index 129b143062..5b8154e17a 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -1,7 +1,7 @@ """Connect to a MySensors gateway via pymysensors API.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping import logging from mysensors import BaseAsyncGateway @@ -22,7 +22,7 @@ from .const import ( DiscoveryInfo, SensorType, ) -from .device import MySensorsDevice, get_mysensors_devices +from .device import MySensorsEntity, get_mysensors_devices from .gateway import finish_setup, gw_stop, setup_gateway _LOGGER = logging.getLogger(__name__) @@ -99,12 +99,12 @@ def setup_mysensors_platform( hass: HomeAssistant, domain: Platform, # hass platform name discovery_info: DiscoveryInfo, - device_class: type[MySensorsDevice] | dict[SensorType, type[MySensorsDevice]], + device_class: type[MySensorsEntity] | Mapping[SensorType, type[MySensorsEntity]], device_args: ( None | tuple ) = None, # extra arguments that will be given to the entity constructor async_add_entities: Callable | None = None, -) -> list[MySensorsDevice] | None: +) -> list[MySensorsEntity] | None: """Set up a MySensors platform. Sets up a bunch of instances of a single platform that is supported by this @@ -118,10 +118,10 @@ def setup_mysensors_platform( """ if device_args is None: device_args = () - new_devices: list[MySensorsDevice] = [] + new_devices: list[MySensorsEntity] = [] new_dev_ids: list[DevId] = discovery_info[ATTR_DEVICES] for dev_id in new_dev_ids: - devices: dict[DevId, MySensorsDevice] = get_mysensors_devices(hass, domain) + devices: dict[DevId, MySensorsEntity] = get_mysensors_devices(hass, domain) if dev_id in devices: _LOGGER.debug( "Skipping setup of %s for platform %s as it already exists", diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py index bcdc6f80ab..7f9326091f 100644 --- a/homeassistant/components/mysensors/const.py +++ b/homeassistant/components/mysensors/const.py @@ -132,7 +132,6 @@ SWITCH_TYPES: dict[SensorType, set[ValueType]] = { "S_SOUND": {"V_ARMED"}, "S_VIBRATION": {"V_ARMED"}, "S_MOISTURE": {"V_ARMED"}, - "S_IR": {"V_IR_SEND"}, "S_LOCK": {"V_LOCK_STATUS"}, "S_WATER_QUALITY": {"V_STATUS"}, } diff --git a/homeassistant/components/mysensors/device.py b/homeassistant/components/mysensors/device.py index de4cbff9b9..d7405dba18 100644 --- a/homeassistant/components/mysensors/device.py +++ b/homeassistant/components/mysensors/device.py @@ -202,11 +202,11 @@ class MySensorsDevice(ABC): def get_mysensors_devices( hass: HomeAssistant, domain: Platform -) -> dict[DevId, MySensorsDevice]: +) -> dict[DevId, MySensorsEntity]: """Return MySensors devices for a hass platform name.""" if MYSENSORS_PLATFORM_DEVICES.format(domain) not in hass.data[DOMAIN]: hass.data[DOMAIN][MYSENSORS_PLATFORM_DEVICES.format(domain)] = {} - devices: dict[DevId, MySensorsDevice] = hass.data[DOMAIN][ + devices: dict[DevId, MySensorsEntity] = hass.data[DOMAIN][ MYSENSORS_PLATFORM_DEVICES.format(domain) ] return devices diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py index e83002ed87..68f8bb566f 100644 --- a/homeassistant/components/mysensors/light.py +++ b/homeassistant/components/mysensors/light.py @@ -19,7 +19,7 @@ from homeassistant.util.color import rgb_hex_to_rgb_list from .. import mysensors from .const import MYSENSORS_DISCOVERY, DiscoveryInfo, SensorType -from .device import MySensorsDevice +from .device import MySensorsEntity from .helpers import on_unload @@ -29,7 +29,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" - device_class_map: dict[SensorType, type[MySensorsDevice]] = { + device_class_map: dict[SensorType, type[MySensorsEntity]] = { "S_DIMMER": MySensorsLightDimmer, "S_RGB_LIGHT": MySensorsLightRGB, "S_RGBW_LIGHT": MySensorsLightRGBW, diff --git a/homeassistant/components/mysensors/services.yaml b/homeassistant/components/mysensors/services.yaml deleted file mode 100644 index 7293a676a7..0000000000 --- a/homeassistant/components/mysensors/services.yaml +++ /dev/null @@ -1,18 +0,0 @@ -send_ir_code: - name: Send IR code - description: Set an IR code as a state attribute for a MySensors IR device switch and turn the switch on. - fields: - entity_id: - name: Entity - description: Name of entity that should have the IR code set and be turned on. Platform dependent. - selector: - entity: - integration: mysensors - domain: switch - V_IR_SEND: - name: IR send - description: IR code to send. - required: true - example: "0xC284" - selector: - text: diff --git a/homeassistant/components/mysensors/strings.json b/homeassistant/components/mysensors/strings.json index c192db7549..dc5dc76c7a 100644 --- a/homeassistant/components/mysensors/strings.json +++ b/homeassistant/components/mysensors/strings.json @@ -83,29 +83,5 @@ "port_out_of_range": "Port number must be at least 1 and at most 65535", "unknown": "[%key:common::config_flow::error::unknown%]" } - }, - "issues": { - "deprecated_entity": { - "title": "The {deprecated_entity} entity will be removed", - "fix_flow": { - "step": { - "confirm": { - "title": "The {deprecated_entity} entity will be removed", - "description": "Update any automations or scripts that use this entity in service calls using the `{deprecated_service}` service to instead use the `{alternate_service}` service with a target entity ID of `{alternate_target}`." - } - } - } - }, - "deprecated_service": { - "title": "The {deprecated_service} service will be removed", - "fix_flow": { - "step": { - "confirm": { - "title": "The {deprecated_service} service will be removed", - "description": "Update any automations or scripts that use this service to instead use the `{alternate_service}` service with a target entity ID of `{alternate_target}`." - } - } - } - } } } diff --git a/homeassistant/components/mysensors/switch.py b/homeassistant/components/mysensors/switch.py index e5b0968785..6067a98af0 100644 --- a/homeassistant/components/mysensors/switch.py +++ b/homeassistant/components/mysensors/switch.py @@ -3,34 +3,18 @@ from __future__ import annotations from typing import Any -import voluptuous as vol - from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform -from homeassistant.core import HomeAssistant, ServiceCall, callback, split_entity_id -import homeassistant.helpers.config_validation as cv +from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from .. import mysensors -from .const import ( - DOMAIN as MYSENSORS_DOMAIN, - MYSENSORS_DISCOVERY, - SERVICE_SEND_IR_CODE, - DiscoveryInfo, - SensorType, -) -from .device import MySensorsDevice +from . import setup_mysensors_platform +from .const import MYSENSORS_DISCOVERY, DiscoveryInfo, SensorType +from .device import MySensorsEntity from .helpers import on_unload -ATTR_IR_CODE = "V_IR_SEND" - -SEND_IR_CODE_SERVICE_SCHEMA = vol.Schema( - {vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Required(ATTR_IR_CODE): cv.string} -) - async def async_setup_entry( hass: HomeAssistant, @@ -38,13 +22,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" - device_class_map: dict[SensorType, type[MySensorsDevice]] = { + device_class_map: dict[SensorType, type[MySensorsSwitch]] = { "S_DOOR": MySensorsSwitch, "S_MOTION": MySensorsSwitch, "S_SMOKE": MySensorsSwitch, "S_LIGHT": MySensorsSwitch, "S_LOCK": MySensorsSwitch, - "S_IR": MySensorsIRSwitch, "S_BINARY": MySensorsSwitch, "S_SPRINKLER": MySensorsSwitch, "S_WATER_LEAK": MySensorsSwitch, @@ -56,7 +39,7 @@ async def async_setup_entry( async def async_discover(discovery_info: DiscoveryInfo) -> None: """Discover and add a MySensors switch.""" - mysensors.setup_mysensors_platform( + setup_mysensors_platform( hass, Platform.SWITCH, discovery_info, @@ -64,37 +47,6 @@ async def async_setup_entry( async_add_entities=async_add_entities, ) - async def async_send_ir_code_service(service: ServiceCall) -> None: - """Set IR code as device state attribute.""" - entity_ids = service.data.get(ATTR_ENTITY_ID) - ir_code = service.data.get(ATTR_IR_CODE) - devices = mysensors.get_mysensors_devices(hass, Platform.SWITCH) - - if entity_ids: - _devices = [ - device - for device in devices.values() - if isinstance(device, MySensorsIRSwitch) - and device.entity_id in entity_ids - ] - else: - _devices = [ - device - for device in devices.values() - if isinstance(device, MySensorsIRSwitch) - ] - - kwargs = {ATTR_IR_CODE: ir_code} - for device in _devices: - await device.async_turn_on(**kwargs) - - hass.services.async_register( - MYSENSORS_DOMAIN, - SERVICE_SEND_IR_CODE, - async_send_ir_code_service, - schema=SEND_IR_CODE_SERVICE_SCHEMA, - ) - on_unload( hass, config_entry.entry_id, @@ -106,7 +58,7 @@ async def async_setup_entry( ) -class MySensorsSwitch(mysensors.device.MySensorsEntity, SwitchEntity): +class MySensorsSwitch(MySensorsEntity, SwitchEntity): """Representation of the value of a MySensors Switch child node.""" @property @@ -133,97 +85,3 @@ class MySensorsSwitch(mysensors.device.MySensorsEntity, SwitchEntity): # Optimistically assume that switch has changed state self._values[self.value_type] = STATE_OFF self.async_write_ha_state() - - -class MySensorsIRSwitch(MySensorsSwitch): - """IR switch child class to MySensorsSwitch.""" - - def __init__(self, *args: Any) -> None: - """Set up instance attributes.""" - super().__init__(*args) - self._ir_code: str | None = None - - @property - def is_on(self) -> bool: - """Return True if switch is on.""" - set_req = self.gateway.const.SetReq - return self._values.get(set_req.V_LIGHT) == STATE_ON - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the IR switch on.""" - set_req = self.gateway.const.SetReq - placeholders = { - "deprecated_entity": self.entity_id, - "alternate_target": f"remote.{split_entity_id(self.entity_id)[1]}", - } - - if ATTR_IR_CODE in kwargs: - self._ir_code = kwargs[ATTR_IR_CODE] - placeholders[ - "deprecated_service" - ] = f"{MYSENSORS_DOMAIN}.{SERVICE_SEND_IR_CODE}" - placeholders["alternate_service"] = "remote.send_command" - else: - placeholders["deprecated_service"] = "switch.turn_on" - placeholders["alternate_service"] = "remote.turn_on" - - async_create_issue( - self.hass, - MYSENSORS_DOMAIN, - ( - "deprecated_ir_switch_entity_" - f"{self.entity_id}_{placeholders['deprecated_service']}" - ), - breaks_in_ha_version="2023.4.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_entity", - translation_placeholders=placeholders, - ) - self.gateway.set_child_value( - self.node_id, self.child_id, self.value_type, self._ir_code - ) - self.gateway.set_child_value( - self.node_id, self.child_id, set_req.V_LIGHT, 1, ack=1 - ) - if self.assumed_state: - # Optimistically assume that switch has changed state - self._values[self.value_type] = self._ir_code - self._values[set_req.V_LIGHT] = STATE_ON - self.async_write_ha_state() - # Turn off switch after switch was turned on - await self.async_turn_off() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the IR switch off.""" - async_create_issue( - self.hass, - MYSENSORS_DOMAIN, - f"deprecated_ir_switch_entity_{self.entity_id}_switch.turn_off", - breaks_in_ha_version="2023.4.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_entity", - translation_placeholders={ - "deprecated_entity": self.entity_id, - "deprecated_service": "switch.turn_off", - "alternate_service": "remote.turn_off", - "alternate_target": f"remote.{split_entity_id(self.entity_id)[1]}", - }, - ) - set_req = self.gateway.const.SetReq - self.gateway.set_child_value( - self.node_id, self.child_id, set_req.V_LIGHT, 0, ack=1 - ) - if self.assumed_state: - # Optimistically assume that switch has changed state - self._values[set_req.V_LIGHT] = STATE_OFF - self.async_write_ha_state() - - @callback - def _async_update(self) -> None: - """Update the controller with the latest value from a sensor.""" - super()._async_update() - self._ir_code = self._values.get(self.value_type) diff --git a/tests/components/mysensors/test_switch.py b/tests/components/mysensors/test_switch.py index b77d540d54..59cea514d7 100644 --- a/tests/components/mysensors/test_switch.py +++ b/tests/components/mysensors/test_switch.py @@ -6,7 +6,6 @@ from unittest.mock import MagicMock, call from mysensors.sensor import Sensor -from homeassistant.components.mysensors.const import DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.core import HomeAssistant @@ -62,81 +61,3 @@ async def test_relay_node( assert state assert state.state == "off" - - -async def test_ir_transceiver( - hass: HomeAssistant, - ir_transceiver: Sensor, - receive_message: Callable[[str], None], - transport_write: MagicMock, -) -> None: - """Test an ir transceiver.""" - entity_id = "switch.ir_transceiver_1_1" - - state = hass.states.get(entity_id) - - assert state - assert state.state == "off" - - await hass.services.async_call( - SWITCH_DOMAIN, - "turn_on", - {"entity_id": entity_id}, - blocking=True, - ) - - assert transport_write.call_count == 2 - assert transport_write.call_args_list[0] == call("1;1;1;0;32;test_code\n") - assert transport_write.call_args_list[1] == call("1;1;1;1;2;1\n") - - receive_message("1;1;1;0;2;1\n") - await hass.async_block_till_done() - - state = hass.states.get(entity_id) - - assert state - assert state.state == "on" - assert state.attributes["V_IR_SEND"] == "test_code" - - transport_write.reset_mock() - - await hass.services.async_call( - SWITCH_DOMAIN, - "turn_off", - {"entity_id": entity_id}, - blocking=True, - ) - - assert transport_write.call_count == 1 - assert transport_write.call_args == call("1;1;1;1;2;0\n") - - receive_message("1;1;1;0;2;0\n") - await hass.async_block_till_done() - - state = hass.states.get(entity_id) - - assert state - assert state.state == "off" - - transport_write.reset_mock() - - await hass.services.async_call( - DOMAIN, - "send_ir_code", - {"entity_id": entity_id, "V_IR_SEND": "new_code"}, - blocking=True, - ) - - assert transport_write.call_count == 2 - assert transport_write.call_args_list[0] == call("1;1;1;0;32;new_code\n") - assert transport_write.call_args_list[1] == call("1;1;1;1;2;1\n") - - receive_message("1;1;1;0;32;new_code\n") - receive_message("1;1;1;0;2;1\n") - await hass.async_block_till_done() - - state = hass.states.get(entity_id) - - assert state - assert state.state == "on" - assert state.attributes["V_IR_SEND"] == "new_code" From 7b18df321b094d9f450a66553367c3648ddce245 Mon Sep 17 00:00:00 2001 From: Ryan Fleming Date: Tue, 28 Mar 2023 08:00:35 -0400 Subject: [PATCH 1369/2810] Have octoprint camera respect verify_ssl configuration (#90384) --- homeassistant/components/octoprint/camera.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/octoprint/camera.py b/homeassistant/components/octoprint/camera.py index 653c15f184..9c3049ff87 100644 --- a/homeassistant/components/octoprint/camera.py +++ b/homeassistant/components/octoprint/camera.py @@ -5,6 +5,7 @@ from pyoctoprintapi import OctoprintClient, WebcamSettings from homeassistant.components.mjpeg.camera import MjpegCamera from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -28,6 +29,7 @@ async def async_setup_entry( assert device_id is not None camera_info = await client.get_webcam_info() + verify_ssl = config_entry.data[CONF_VERIFY_SSL] if not camera_info or not camera_info.enabled: return @@ -38,6 +40,7 @@ async def async_setup_entry( camera_info, coordinator.device_info, device_id, + verify_ssl, ) ] ) @@ -47,7 +50,11 @@ class OctoprintCamera(MjpegCamera): """Representation of an OctoPrint Camera Stream.""" def __init__( - self, camera_settings: WebcamSettings, device_info: DeviceInfo, device_id: str + self, + camera_settings: WebcamSettings, + device_info: DeviceInfo, + device_id: str, + verify_ssl: bool, ) -> None: """Initialize as a subclass of MjpegCamera.""" super().__init__( @@ -56,4 +63,5 @@ class OctoprintCamera(MjpegCamera): name="OctoPrint Camera", still_image_url=camera_settings.external_snapshot_url, unique_id=device_id, + verify_ssl=verify_ssl, ) From 0eb409cff1a078b36abbdf47005de90a22560167 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Tue, 28 Mar 2023 14:01:31 +0200 Subject: [PATCH 1370/2810] Add support for select entities in velbus (#87568) * Add support for select entities in velbus * Implement comments * EntityCategory is now in homeassistant.const * more comments --- .coveragerc | 1 + homeassistant/components/velbus/__init__.py | 1 + homeassistant/components/velbus/select.py | 47 +++++++++++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 homeassistant/components/velbus/select.py diff --git a/.coveragerc b/.coveragerc index dfc13304b1..5b5096ee58 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1368,6 +1368,7 @@ omit = homeassistant/components/velbus/entity.py homeassistant/components/velbus/light.py homeassistant/components/velbus/sensor.py + homeassistant/components/velbus/select.py homeassistant/components/velbus/switch.py homeassistant/components/velux/__init__.py homeassistant/components/velux/cover.py diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index a51cef0a56..554b16877c 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -34,6 +34,7 @@ PLATFORMS = [ Platform.CLIMATE, Platform.COVER, Platform.LIGHT, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/velbus/select.py b/homeassistant/components/velbus/select.py new file mode 100644 index 0000000000..af79b5d127 --- /dev/null +++ b/homeassistant/components/velbus/select.py @@ -0,0 +1,47 @@ +"""Support for Velbus select.""" +from velbusaio.channels import SelectedProgram + +from homeassistant.components.select import SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import VelbusEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Velbus select based on config_entry.""" + await hass.data[DOMAIN][entry.entry_id]["tsk"] + cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] + async_add_entities(VelbusSelect(channel) for channel in cntrl.get_all("select")) + + +class VelbusSelect(VelbusEntity, SelectEntity): + """Representation of a select option for velbus.""" + + _channel: SelectedProgram + _attr_entity_category = EntityCategory.CONFIG + + def __init__( + self, + channel: SelectedProgram, + ) -> None: + """Initialize a select Velbus entity.""" + super().__init__(channel) + self._attr_options = self._channel.get_options() + self._attr_unique_id = f"{self._attr_unique_id}-program_select" + + async def async_select_option(self, option: str) -> None: + """Update the program on the module.""" + await self._channel.set_selected_program(option) + + @property + def current_option(self) -> str: + """Return the selected option.""" + return self._channel.get_selected_program() From cc404cfe770cf15908423384203ca6d6444de049 Mon Sep 17 00:00:00 2001 From: avee87 <6134677+avee87@users.noreply.github.com> Date: Tue, 28 Mar 2023 13:24:19 +0100 Subject: [PATCH 1371/2810] Refactor Tado to use entity descriptions and new naming style (#75750) * Refactor Tado to use entity descriptions and new naming style * minor fixes * typing --- .../components/tado/binary_sensor.py | 242 +++++++------- homeassistant/components/tado/climate.py | 16 +- homeassistant/components/tado/entity.py | 2 + homeassistant/components/tado/sensor.py | 312 ++++++++---------- 4 files changed, 274 insertions(+), 298 deletions(-) diff --git a/homeassistant/components/tado/binary_sensor.py b/homeassistant/components/tado/binary_sensor.py index 7f009c278f..24d62d7602 100644 --- a/homeassistant/components/tado/binary_sensor.py +++ b/homeassistant/components/tado/binary_sensor.py @@ -1,14 +1,21 @@ """Support for Tado sensors for each zone.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass import logging +from typing import Any from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from .const import ( DATA, @@ -24,31 +31,99 @@ from .entity import TadoDeviceEntity, TadoZoneEntity _LOGGER = logging.getLogger(__name__) + +@dataclass +class TadoBinarySensorEntityDescriptionMixin: + """Mixin for required keys.""" + + state_fn: Callable[[Any], bool] + + +@dataclass +class TadoBinarySensorEntityDescription( + BinarySensorEntityDescription, TadoBinarySensorEntityDescriptionMixin +): + """Describes Tado binary sensor entity.""" + + attributes_fn: Callable[[Any], dict[Any, StateType]] | None = None + + +BATTERY_STATE_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( + key="battery state", + name="Battery state", + state_fn=lambda data: data["batteryState"] == "LOW", + device_class=BinarySensorDeviceClass.BATTERY, +) +CONNECTION_STATE_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( + key="connection state", + name="Connection state", + state_fn=lambda data: data.get("connectionState", {}).get("value", False), + device_class=BinarySensorDeviceClass.CONNECTIVITY, +) +POWER_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( + key="power", + name="Power", + state_fn=lambda data: data.power == "ON", + device_class=BinarySensorDeviceClass.POWER, +) +LINK_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( + key="link", + name="Link", + state_fn=lambda data: data.link == "ONLINE", + device_class=BinarySensorDeviceClass.CONNECTIVITY, +) +OVERLAY_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( + key="overlay", + name="Overlay", + state_fn=lambda data: data.overlay_active, + attributes_fn=lambda data: {"termination": data.overlay_termination_type} + if data.overlay_active + else {}, + device_class=BinarySensorDeviceClass.POWER, +) +OPEN_WINDOW_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( + key="open window", + name="Open window", + state_fn=lambda data: bool(data.open_window or data.open_window_detected), + attributes_fn=lambda data: data.open_window_attr, + device_class=BinarySensorDeviceClass.WINDOW, +) +EARLY_START_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( + key="early start", + name="Early start", + state_fn=lambda data: data.preparation, + device_class=BinarySensorDeviceClass.POWER, +) + DEVICE_SENSORS = { TYPE_BATTERY: [ - "battery state", - "connection state", + BATTERY_STATE_ENTITY_DESCRIPTION, + CONNECTION_STATE_ENTITY_DESCRIPTION, ], TYPE_POWER: [ - "connection state", + CONNECTION_STATE_ENTITY_DESCRIPTION, ], } ZONE_SENSORS = { TYPE_HEATING: [ - "power", - "link", - "overlay", - "early start", - "open window", + POWER_ENTITY_DESCRIPTION, + LINK_ENTITY_DESCRIPTION, + OVERLAY_ENTITY_DESCRIPTION, + OPEN_WINDOW_ENTITY_DESCRIPTION, + EARLY_START_ENTITY_DESCRIPTION, ], TYPE_AIR_CONDITIONING: [ - "power", - "link", - "overlay", - "open window", + POWER_ENTITY_DESCRIPTION, + LINK_ENTITY_DESCRIPTION, + OVERLAY_ENTITY_DESCRIPTION, + OPEN_WINDOW_ENTITY_DESCRIPTION, + ], + TYPE_HOT_WATER: [ + POWER_ENTITY_DESCRIPTION, + LINK_ENTITY_DESCRIPTION, + OVERLAY_ENTITY_DESCRIPTION, ], - TYPE_HOT_WATER: ["power", "link", "overlay"], } @@ -71,8 +146,8 @@ async def async_setup_entry( entities.extend( [ - TadoDeviceBinarySensor(tado, device, variable) - for variable in DEVICE_SENSORS[device_type] + TadoDeviceBinarySensor(tado, device, entity_description) + for entity_description in DEVICE_SENSORS[device_type] ] ) @@ -85,8 +160,8 @@ async def async_setup_entry( entities.extend( [ - TadoZoneBinarySensor(tado, zone["name"], zone["id"], variable) - for variable in ZONE_SENSORS[zone_type] + TadoZoneBinarySensor(tado, zone["name"], zone["id"], entity_description) + for entity_description in ZONE_SENSORS[zone_type] ] ) @@ -96,16 +171,21 @@ async def async_setup_entry( class TadoDeviceBinarySensor(TadoDeviceEntity, BinarySensorEntity): """Representation of a tado Sensor.""" - def __init__(self, tado, device_info, device_variable): + entity_description: TadoBinarySensorEntityDescription + + _attr_has_entity_name = True + + def __init__( + self, tado, device_info, entity_description: TadoBinarySensorEntityDescription + ) -> None: """Initialize of the Tado Sensor.""" + self.entity_description = entity_description self._tado = tado super().__init__(device_info) - self.device_variable = device_variable - - self._unique_id = f"{device_variable} {self.device_id} {tado.home_id}" - - self._state = None + self._attr_unique_id = ( + f"{entity_description.key} {self.device_id} {tado.home_id}" + ) async def async_added_to_hass(self) -> None: """Register for sensor updates.""" @@ -121,30 +201,6 @@ class TadoDeviceBinarySensor(TadoDeviceEntity, BinarySensorEntity): ) self._async_update_device_data() - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.device_name} {self.device_variable}" - - @property - def is_on(self): - """Return true if sensor is on.""" - return self._state - - @property - def device_class(self): - """Return the class of this sensor.""" - if self.device_variable == "battery state": - return BinarySensorDeviceClass.BATTERY - if self.device_variable == "connection state": - return BinarySensorDeviceClass.CONNECTIVITY - return None - @callback def _async_update_callback(self): """Update and write state.""" @@ -159,29 +215,33 @@ class TadoDeviceBinarySensor(TadoDeviceEntity, BinarySensorEntity): except KeyError: return - if self.device_variable == "battery state": - self._state = self._device_info["batteryState"] == "LOW" - elif self.device_variable == "connection state": - self._state = self._device_info.get("connectionState", {}).get( - "value", False + self._attr_is_on = self.entity_description.state_fn(self._device_info) + if self.entity_description.attributes_fn is not None: + self._attr_extra_state_attributes = self.entity_description.attributes_fn( + self._device_info ) class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity): """Representation of a tado Sensor.""" - def __init__(self, tado, zone_name, zone_id, zone_variable): + entity_description: TadoBinarySensorEntityDescription + + _attr_has_entity_name = True + + def __init__( + self, + tado, + zone_name, + zone_id, + entity_description: TadoBinarySensorEntityDescription, + ) -> None: """Initialize of the Tado Sensor.""" + self.entity_description = entity_description self._tado = tado super().__init__(zone_name, tado.home_id, zone_id) - self.zone_variable = zone_variable - - self._unique_id = f"{zone_variable} {zone_id} {tado.home_id}" - - self._state = None - self._state_attributes = None - self._tado_zone_data = None + self._attr_unique_id = f"{entity_description.key} {zone_id} {tado.home_id}" async def async_added_to_hass(self) -> None: """Register for sensor updates.""" @@ -197,41 +257,6 @@ class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity): ) self._async_update_zone_data() - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.zone_name} {self.zone_variable}" - - @property - def is_on(self): - """Return true if sensor is on.""" - return self._state - - @property - def device_class(self): - """Return the class of this sensor.""" - if self.zone_variable == "early start": - return BinarySensorDeviceClass.POWER - if self.zone_variable == "link": - return BinarySensorDeviceClass.CONNECTIVITY - if self.zone_variable == "open window": - return BinarySensorDeviceClass.WINDOW - if self.zone_variable == "overlay": - return BinarySensorDeviceClass.POWER - if self.zone_variable == "power": - return BinarySensorDeviceClass.POWER - return None - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._state_attributes - @callback def _async_update_callback(self): """Update and write state.""" @@ -242,29 +267,12 @@ class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity): def _async_update_zone_data(self): """Handle update callbacks.""" try: - self._tado_zone_data = self._tado.data["zone"][self.zone_id] + tado_zone_data = self._tado.data["zone"][self.zone_id] except KeyError: return - if self.zone_variable == "power": - self._state = self._tado_zone_data.power == "ON" - - elif self.zone_variable == "link": - self._state = self._tado_zone_data.link == "ONLINE" - - elif self.zone_variable == "overlay": - self._state = self._tado_zone_data.overlay_active - if self._tado_zone_data.overlay_active: - self._state_attributes = { - "termination": self._tado_zone_data.overlay_termination_type - } - - elif self.zone_variable == "early start": - self._state = self._tado_zone_data.preparation - - elif self.zone_variable == "open window": - self._state = bool( - self._tado_zone_data.open_window - or self._tado_zone_data.open_window_detected + self._attr_is_on = self.entity_description.state_fn(tado_zone_data) + if self.entity_description.attributes_fn is not None: + self._attr_extra_state_attributes = self.entity_description.attributes_fn( + tado_zone_data ) - self._state_attributes = self._tado_zone_data.open_window_attr diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index a72451b002..cab3c42184 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -240,7 +240,11 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): self.zone_id = zone_id self.zone_type = zone_type - self._unique_id = f"{zone_type} {zone_id} {tado.home_id}" + + self._attr_unique_id = f"{zone_type} {zone_id} {tado.home_id}" + self._attr_name = zone_name + self._attr_temperature_unit = UnitOfTemperature.CELSIUS + self._device_info = device_info self._device_id = self._device_info["shortSerialNo"] @@ -288,16 +292,6 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): ) ) - @property - def name(self): - """Return the name of the entity.""" - return self.zone_name - - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - @property def current_humidity(self): """Return the current humidity.""" diff --git a/homeassistant/components/tado/entity.py b/homeassistant/components/tado/entity.py index 11de7ceb31..c825bafc4b 100644 --- a/homeassistant/components/tado/entity.py +++ b/homeassistant/components/tado/entity.py @@ -33,6 +33,8 @@ class TadoDeviceEntity(Entity): class TadoHomeEntity(Entity): """Base implementation for Tado home.""" + _attr_should_poll = False + def __init__(self, tado): """Initialize a Tado home.""" super().__init__() diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index 4289813494..d218e9ca93 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -1,9 +1,15 @@ """Support for Tado sensors for each zone.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass import logging +from typing import Any from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry @@ -11,6 +17,7 @@ from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from .const import ( CONDITIONS_MAP, @@ -25,26 +32,108 @@ from .entity import TadoHomeEntity, TadoZoneEntity _LOGGER = logging.getLogger(__name__) -HOME_SENSORS = { - "outdoor temperature", - "solar percentage", - "weather condition", -} + +@dataclass +class TadoSensorEntityDescriptionMixin: + """Mixin for required keys.""" + + state_fn: Callable[[Any], StateType] + + +@dataclass +class TadoSensorEntityDescription( + SensorEntityDescription, TadoSensorEntityDescriptionMixin +): + """Describes Tado sensor entity.""" + + attributes_fn: Callable[[Any], dict[Any, StateType]] | None = None + + +HOME_SENSORS = [ + TadoSensorEntityDescription( + key="outdoor temperature", + name="Outdoor temperature", + state_fn=lambda data: data["outsideTemperature"]["celsius"], + attributes_fn=lambda data: { + "time": data["outsideTemperature"]["timestamp"], + }, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TadoSensorEntityDescription( + key="solar percentage", + name="Solar percentage", + state_fn=lambda data: data["solarIntensity"]["percentage"], + attributes_fn=lambda data: { + "time": data["solarIntensity"]["timestamp"], + }, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + TadoSensorEntityDescription( + key="weather condition", + name="Weather condition", + state_fn=lambda data: format_condition(data["weatherState"]["value"]), + attributes_fn=lambda data: {"time": data["weatherState"]["timestamp"]}, + ), +] + +TEMPERATURE_ENTITY_DESCRIPTION = TadoSensorEntityDescription( + key="temperature", + name="Temperature", + state_fn=lambda data: data.current_temp, + attributes_fn=lambda data: { + "time": data.current_temp_timestamp, + "setting": 0, # setting is used in climate device + }, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, +) +HUMIDITY_ENTITY_DESCRIPTION = TadoSensorEntityDescription( + key="humidity", + name="Humidity", + state_fn=lambda data: data.current_humidity, + attributes_fn=lambda data: {"time": data.current_humidity_timestamp}, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, +) +TADO_MODE_ENTITY_DESCRIPTION = TadoSensorEntityDescription( + key="tado mode", + name="Tado mode", + state_fn=lambda data: data.tado_mode, +) +HEATING_ENTITY_DESCRIPTION = TadoSensorEntityDescription( + key="heating", + name="Heating", + state_fn=lambda data: data.heating_power_percentage, + attributes_fn=lambda data: {"time": data.heating_power_timestamp}, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, +) +AC_ENTITY_DESCRIPTION = TadoSensorEntityDescription( + key="ac", + name="AC", + state_fn=lambda data: data.ac_power, + attributes_fn=lambda data: {"time": data.ac_power_timestamp}, +) ZONE_SENSORS = { TYPE_HEATING: [ - "temperature", - "humidity", - "heating", - "tado mode", + TEMPERATURE_ENTITY_DESCRIPTION, + HUMIDITY_ENTITY_DESCRIPTION, + TADO_MODE_ENTITY_DESCRIPTION, + HEATING_ENTITY_DESCRIPTION, ], TYPE_AIR_CONDITIONING: [ - "temperature", - "humidity", - "ac", - "tado mode", + TEMPERATURE_ENTITY_DESCRIPTION, + HUMIDITY_ENTITY_DESCRIPTION, + TADO_MODE_ENTITY_DESCRIPTION, + AC_ENTITY_DESCRIPTION, ], - TYPE_HOT_WATER: ["tado mode"], + TYPE_HOT_WATER: [TADO_MODE_ENTITY_DESCRIPTION], } @@ -66,7 +155,12 @@ async def async_setup_entry( entities: list[SensorEntity] = [] # Create home sensors - entities.extend([TadoHomeSensor(tado, variable) for variable in HOME_SENSORS]) + entities.extend( + [ + TadoHomeSensor(tado, entity_description) + for entity_description in HOME_SENSORS + ] + ) # Create zone sensors for zone in zones: @@ -77,8 +171,8 @@ async def async_setup_entry( entities.extend( [ - TadoZoneSensor(tado, zone["name"], zone["id"], variable) - for variable in ZONE_SENSORS[zone_type] + TadoZoneSensor(tado, zone["name"], zone["id"], entity_description) + for entity_description in ZONE_SENSORS[zone_type] ] ) @@ -88,18 +182,17 @@ async def async_setup_entry( class TadoHomeSensor(TadoHomeEntity, SensorEntity): """Representation of a Tado Sensor.""" - def __init__(self, tado, home_variable): + entity_description: TadoSensorEntityDescription + + _attr_has_entity_name = True + + def __init__(self, tado, entity_description: TadoSensorEntityDescription) -> None: """Initialize of the Tado Sensor.""" + self.entity_description = entity_description super().__init__(tado) self._tado = tado - self.home_variable = home_variable - - self._unique_id = f"{home_variable} {tado.home_id}" - - self._state = None - self._state_attributes = None - self._tado_weather_data = self._tado.data["weather"] + self._attr_unique_id = f"{entity_description.key} {tado.home_id}" async def async_added_to_hass(self) -> None: """Register for sensor updates.""" @@ -115,50 +208,6 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity): ) self._async_update_home_data() - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._tado.home_name} {self.home_variable}" - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._state_attributes - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - if self.home_variable in ["temperature", "outdoor temperature"]: - return UnitOfTemperature.CELSIUS - if self.home_variable == "solar percentage": - return PERCENTAGE - if self.home_variable == "weather condition": - return None - - @property - def device_class(self): - """Return the device class.""" - if self.home_variable == "outdoor temperature": - return SensorDeviceClass.TEMPERATURE - return None - - @property - def state_class(self): - """Return the state class.""" - if self.home_variable in ["outdoor temperature", "solar percentage"]: - return SensorStateClass.MEASUREMENT - return None - @callback def _async_update_callback(self): """Update and write state.""" @@ -169,46 +218,37 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity): def _async_update_home_data(self): """Handle update callbacks.""" try: - self._tado_weather_data = self._tado.data["weather"] + tado_weather_data = self._tado.data["weather"] except KeyError: return - if self.home_variable == "outdoor temperature": - self._state = self._tado_weather_data["outsideTemperature"]["celsius"] - self._state_attributes = { - "time": self._tado_weather_data["outsideTemperature"]["timestamp"], - } - - elif self.home_variable == "solar percentage": - self._state = self._tado_weather_data["solarIntensity"]["percentage"] - self._state_attributes = { - "time": self._tado_weather_data["solarIntensity"]["timestamp"], - } - - elif self.home_variable == "weather condition": - self._state = format_condition( - self._tado_weather_data["weatherState"]["value"] + self._attr_native_value = self.entity_description.state_fn(tado_weather_data) + if self.entity_description.attributes_fn is not None: + self._attr_extra_state_attributes = self.entity_description.attributes_fn( + tado_weather_data ) - self._state_attributes = { - "time": self._tado_weather_data["weatherState"]["timestamp"] - } class TadoZoneSensor(TadoZoneEntity, SensorEntity): """Representation of a tado Sensor.""" - def __init__(self, tado, zone_name, zone_id, zone_variable): + entity_description: TadoSensorEntityDescription + + _attr_has_entity_name = True + + def __init__( + self, + tado, + zone_name, + zone_id, + entity_description: TadoSensorEntityDescription, + ) -> None: """Initialize of the Tado Sensor.""" + self.entity_description = entity_description self._tado = tado super().__init__(zone_name, tado.home_id, zone_id) - self.zone_variable = zone_variable - - self._unique_id = f"{zone_variable} {zone_id} {tado.home_id}" - - self._state = None - self._state_attributes = None - self._tado_zone_data = None + self._attr_unique_id = f"{entity_description.key} {zone_id} {tado.home_id}" async def async_added_to_hass(self) -> None: """Register for sensor updates.""" @@ -224,54 +264,6 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity): ) self._async_update_zone_data() - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.zone_name} {self.zone_variable}" - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._state_attributes - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - if self.zone_variable == "temperature": - return UnitOfTemperature.CELSIUS - if self.zone_variable == "humidity": - return PERCENTAGE - if self.zone_variable == "heating": - return PERCENTAGE - if self.zone_variable == "ac": - return None - - @property - def device_class(self): - """Return the device class.""" - if self.zone_variable == "humidity": - return SensorDeviceClass.HUMIDITY - if self.zone_variable == "temperature": - return SensorDeviceClass.TEMPERATURE - return None - - @property - def state_class(self): - """Return the state class.""" - if self.zone_variable in ["heating", "humidity", "temperature"]: - return SensorStateClass.MEASUREMENT - return None - @callback def _async_update_callback(self): """Update and write state.""" @@ -282,32 +274,12 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity): def _async_update_zone_data(self): """Handle update callbacks.""" try: - self._tado_zone_data = self._tado.data["zone"][self.zone_id] + tado_zone_data = self._tado.data["zone"][self.zone_id] except KeyError: return - if self.zone_variable == "temperature": - self._state = self._tado_zone_data.current_temp - self._state_attributes = { - "time": self._tado_zone_data.current_temp_timestamp, - "setting": 0, # setting is used in climate device - } - - elif self.zone_variable == "humidity": - self._state = self._tado_zone_data.current_humidity - self._state_attributes = { - "time": self._tado_zone_data.current_humidity_timestamp - } - - elif self.zone_variable == "heating": - self._state = self._tado_zone_data.heating_power_percentage - self._state_attributes = { - "time": self._tado_zone_data.heating_power_timestamp - } - - elif self.zone_variable == "ac": - self._state = self._tado_zone_data.ac_power - self._state_attributes = {"time": self._tado_zone_data.ac_power_timestamp} - - elif self.zone_variable == "tado mode": - self._state = self._tado_zone_data.tado_mode + self._attr_native_value = self.entity_description.state_fn(tado_zone_data) + if self.entity_description.attributes_fn is not None: + self._attr_extra_state_attributes = self.entity_description.attributes_fn( + tado_zone_data + ) From 8e7013b079761c8ef42a3c47945985cf68016dd0 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Tue, 28 Mar 2023 08:34:57 -0400 Subject: [PATCH 1372/2810] Add HTML support for Google Mail messages (#87201) --- homeassistant/components/google_mail/notify.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_mail/notify.py b/homeassistant/components/google_mail/notify.py index eba38c3249..974b2e4e4b 100644 --- a/homeassistant/components/google_mail/notify.py +++ b/homeassistant/components/google_mail/notify.py @@ -2,7 +2,7 @@ from __future__ import annotations import base64 -from email.message import EmailMessage +from email.mime.text import MIMEText from typing import Any from googleapiclient.http import HttpRequest @@ -43,8 +43,7 @@ class GMailNotificationService(BaseNotificationService): data: dict[str, Any] = kwargs.get(ATTR_DATA) or {} title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) - email = EmailMessage() - email.set_content(message) + email = MIMEText(message, "html") if to_addrs := kwargs.get(ATTR_TARGET): email["To"] = ", ".join(to_addrs) email["From"] = data.get(ATTR_FROM, ATTR_ME) From d228df6d818f663b75a2efd9696d7507789b8a3e Mon Sep 17 00:00:00 2001 From: "Erik J. Olson" Date: Tue, 28 Mar 2023 07:56:10 -0500 Subject: [PATCH 1373/2810] Fix Notify Group payload data mis-merge (#90253) Co-authored-by: Erik Montnemery --- homeassistant/components/group/notify.py | 24 ++++++++++----------- tests/components/group/test_notify.py | 27 +++++++++++++++++++++--- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/group/notify.py b/homeassistant/components/group/notify.py index 7e8ce92364..378a785234 100644 --- a/homeassistant/components/group/notify.py +++ b/homeassistant/components/group/notify.py @@ -32,18 +32,16 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def update(input_dict: dict[str, Any], update_source: dict[str, Any]) -> dict[str, Any]: - """Deep update a dictionary. - - Async friendly. - """ - for key, val in update_source.items(): +def add_defaults( + input_data: dict[str, Any], default_data: dict[str, Any] +) -> dict[str, Any]: + """Deep update a dictionary with default values.""" + for key, val in default_data.items(): if isinstance(val, Mapping): - recurse = update(input_dict.get(key, {}), val) # type: ignore[arg-type] - input_dict[key] = recurse - else: - input_dict[key] = update_source[key] - return input_dict + input_data[key] = add_defaults(input_data.get(key, {}), val) # type: ignore[arg-type] + elif key not in input_data: + input_data[key] = val + return input_data async def async_get_service( @@ -71,8 +69,8 @@ class GroupNotifyPlatform(BaseNotificationService): tasks: list[asyncio.Task[bool | None]] = [] for entity in self.entities: sending_payload = deepcopy(payload.copy()) - if (data := entity.get(ATTR_DATA)) is not None: - update(sending_payload, data) + if (default_data := entity.get(ATTR_DATA)) is not None: + add_defaults(sending_payload, default_data) tasks.append( asyncio.create_task( self.hass.services.async_call( diff --git a/tests/components/group/test_notify.py b/tests/components/group/test_notify.py index 6e4f9b5039..77569c80f0 100644 --- a/tests/components/group/test_notify.py +++ b/tests/components/group/test_notify.py @@ -54,14 +54,14 @@ async def test_send_message_with_data(hass: HomeAssistant) -> None: "service": "demo2", "data": { "target": "unnamed device", - "data": {"test": "message"}, + "data": {"test": "message", "default": "default"}, }, }, ] }, ) - """Test sending a message with to a notify group.""" + """Test sending a message to a notify group.""" await service.async_send_message( "Hello", title="Test notification", data={"hello": "world"} ) @@ -77,7 +77,28 @@ async def test_send_message_with_data(hass: HomeAssistant) -> None: assert service2.send_message.mock_calls[0][2] == { "target": ["unnamed device"], "title": "Test notification", - "data": {"hello": "world", "test": "message"}, + "data": {"hello": "world", "test": "message", "default": "default"}, + } + + """Test sending a message which overrides service defaults to a notify group.""" + await service.async_send_message( + "Hello", + title="Test notification", + data={"hello": "world", "default": "override"}, + ) + + await hass.async_block_till_done() + + assert service1.send_message.mock_calls[1][1][0] == "Hello" + assert service1.send_message.mock_calls[1][2] == { + "title": "Test notification", + "data": {"hello": "world", "default": "override"}, + } + assert service2.send_message.mock_calls[1][1][0] == "Hello" + assert service2.send_message.mock_calls[1][2] == { + "target": ["unnamed device"], + "title": "Test notification", + "data": {"hello": "world", "test": "message", "default": "override"}, } From 091932c3acd984465d4f2a474b8943e649c8fbd1 Mon Sep 17 00:00:00 2001 From: Renat Sibgatulin Date: Tue, 28 Mar 2023 12:59:03 +0000 Subject: [PATCH 1374/2810] Improve airq test coverage (#90192) * Add a missing test for aborting with "already_configured" Test that config_flow aborts with "already_configured" when the integration has already been configured * Don't copy test data Since #90232 is merged, it is no longer needed * Split the initialisation into two steps, as it should be --- tests/components/airq/test_config_flow.py | 24 +++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/components/airq/test_config_flow.py b/tests/components/airq/test_config_flow.py index af71dc813e..252c12f80f 100644 --- a/tests/components/airq/test_config_flow.py +++ b/tests/components/airq/test_config_flow.py @@ -11,6 +11,8 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry + pytestmark = pytest.mark.usefixtures("mock_setup_entry") TEST_USER_DATA = { @@ -91,3 +93,25 @@ async def test_form_invalid_input(hass: HomeAssistant) -> None: assert result2["type"] == FlowResultType.FORM assert result2["errors"] == {"base": "invalid_input"} + + +async def test_duplicate_error(hass: HomeAssistant) -> None: + """Test that errors are shown when duplicates are added.""" + MockConfigEntry( + data=TEST_USER_DATA, + domain=DOMAIN, + unique_id=TEST_DEVICE_INFO["id"], + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("aioairq.AirQ.validate"), patch( + "aioairq.AirQ.fetch_device_info", return_value=TEST_DEVICE_INFO + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_DATA + ) + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured" From cdefc48fcdbaa70674a91f77ed5a0cb4112afe50 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Tue, 28 Mar 2023 07:07:09 -0600 Subject: [PATCH 1375/2810] Add panel brightness control for Litter-Robot 4 (#86269) * Add panel brightness control for Litter-Robot 4 * Use translation_key * Fix test --- .../components/litterrobot/manifest.json | 2 +- .../components/litterrobot/select.py | 94 ++++++++++++------- .../components/litterrobot/strings.json | 9 ++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/litterrobot/test_select.py | 41 +++++++- 6 files changed, 111 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index e635e80a6e..0b162ee2e5 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["pylitterbot"], - "requirements": ["pylitterbot==2023.1.1"] + "requirements": ["pylitterbot==2023.1.2"] } diff --git a/homeassistant/components/litterrobot/select.py b/homeassistant/components/litterrobot/select.py index bc1613f1c2..feac85ecac 100644 --- a/homeassistant/components/litterrobot/select.py +++ b/homeassistant/components/litterrobot/select.py @@ -3,10 +3,10 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from dataclasses import dataclass -import itertools from typing import Any, Generic, TypeVar -from pylitterbot import FeederRobot, LitterRobot +from pylitterbot import FeederRobot, LitterRobot, LitterRobot4, Robot +from pylitterbot.robot.litterrobot4 import BrightnessLevel from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry @@ -18,14 +18,21 @@ from .const import DOMAIN from .entity import LitterRobotEntity, _RobotT from .hub import LitterRobotHub -_CastTypeT = TypeVar("_CastTypeT", int, float) +_CastTypeT = TypeVar("_CastTypeT", int, float, str) + +BRIGHTNESS_LEVEL_ICON_MAP: dict[BrightnessLevel | None, str] = { + BrightnessLevel.LOW: "mdi:lightbulb-on-30", + BrightnessLevel.MEDIUM: "mdi:lightbulb-on-50", + BrightnessLevel.HIGH: "mdi:lightbulb-on", + None: "mdi:lightbulb-question", +} @dataclass class RequiredKeysMixin(Generic[_RobotT, _CastTypeT]): """A class that describes robot select entity required keys.""" - current_fn: Callable[[_RobotT], _CastTypeT] + current_fn: Callable[[_RobotT], _CastTypeT | None] options_fn: Callable[[_RobotT], list[_CastTypeT]] select_fn: Callable[[_RobotT, str], Coroutine[Any, Any, bool]] @@ -37,26 +44,42 @@ class RobotSelectEntityDescription( """A class that describes robot select entities.""" entity_category: EntityCategory = EntityCategory.CONFIG + icon_fn: Callable[[_RobotT], str] | None = None -LITTER_ROBOT_SELECT = RobotSelectEntityDescription[LitterRobot, int]( - key="cycle_delay", - name="Clean cycle wait time minutes", - icon="mdi:timer-outline", - unit_of_measurement=UnitOfTime.MINUTES, - current_fn=lambda robot: robot.clean_cycle_wait_time_minutes, - options_fn=lambda robot: robot.VALID_WAIT_TIMES, - select_fn=lambda robot, option: robot.set_wait_time(int(option)), -) -FEEDER_ROBOT_SELECT = RobotSelectEntityDescription[FeederRobot, float]( - key="meal_insert_size", - name="Meal insert size", - icon="mdi:scale", - unit_of_measurement="cups", - current_fn=lambda robot: robot.meal_insert_size, - options_fn=lambda robot: robot.VALID_MEAL_INSERT_SIZES, - select_fn=lambda robot, option: robot.set_meal_insert_size(float(option)), -) +ROBOT_SELECT_MAP: dict[type[Robot], RobotSelectEntityDescription] = { + LitterRobot: RobotSelectEntityDescription[LitterRobot, int]( + key="cycle_delay", + name="Clean cycle wait time minutes", + icon="mdi:timer-outline", + unit_of_measurement=UnitOfTime.MINUTES, + current_fn=lambda robot: robot.clean_cycle_wait_time_minutes, + options_fn=lambda robot: robot.VALID_WAIT_TIMES, + select_fn=lambda robot, opt: robot.set_wait_time(int(opt)), + ), + LitterRobot4: RobotSelectEntityDescription[LitterRobot4, str]( + key="panel_brightness", + name="Panel brightness", + translation_key="brightness_level", + current_fn=lambda robot: bri.name.lower() + if (bri := robot.panel_brightness) is not None + else None, + options_fn=lambda _: [level.name.lower() for level in BrightnessLevel], + select_fn=lambda robot, opt: robot.set_panel_brightness( + BrightnessLevel[opt.upper()] + ), + icon_fn=lambda robot: BRIGHTNESS_LEVEL_ICON_MAP[robot.panel_brightness], + ), + FeederRobot: RobotSelectEntityDescription[FeederRobot, float]( + key="meal_insert_size", + name="Meal insert size", + icon="mdi:scale", + unit_of_measurement="cups", + current_fn=lambda robot: robot.meal_insert_size, + options_fn=lambda robot: robot.VALID_MEAL_INSERT_SIZES, + select_fn=lambda robot, opt: robot.set_meal_insert_size(float(opt)), + ), +} async def async_setup_entry( @@ -66,22 +89,16 @@ async def async_setup_entry( ) -> None: """Set up Litter-Robot selects using config entry.""" hub: LitterRobotHub = hass.data[DOMAIN][config_entry.entry_id] - entities: list[LitterRobotSelect] = list( - itertools.chain( - ( - LitterRobotSelect(robot=robot, hub=hub, description=LITTER_ROBOT_SELECT) - for robot in hub.litter_robots() - ), - ( - LitterRobotSelect(robot=robot, hub=hub, description=FEEDER_ROBOT_SELECT) - for robot in hub.feeder_robots() - ), - ) - ) + entities = [ + LitterRobotSelectEntity(robot=robot, hub=hub, description=description) + for robot in hub.account.robots + for robot_type, description in ROBOT_SELECT_MAP.items() + if isinstance(robot, robot_type) + ] async_add_entities(entities) -class LitterRobotSelect( +class LitterRobotSelectEntity( LitterRobotEntity[_RobotT], SelectEntity, Generic[_RobotT, _CastTypeT] ): """Litter-Robot Select.""" @@ -99,6 +116,13 @@ class LitterRobotSelect( options = self.entity_description.options_fn(self.robot) self._attr_options = list(map(str, options)) + @property + def icon(self) -> str | None: + """Return the icon to use in the frontend, if any.""" + if icon_fn := self.entity_description.icon_fn: + return str(icon_fn(self.robot)) + return super().icon + @property def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index 2d40eb6a04..b4aa8f0016 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -62,6 +62,15 @@ "spf": "Pinch Detect At Startup" } } + }, + "select": { + "brightness_level": { + "state": { + "low": "Low", + "medium": "Medium", + "high": "High" + } + } } } } diff --git a/requirements_all.txt b/requirements_all.txt index 1560012227..46599fbafe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1753,7 +1753,7 @@ pylibrespot-java==0.1.1 pylitejet==0.5.0 # homeassistant.components.litterrobot -pylitterbot==2023.1.1 +pylitterbot==2023.1.2 # homeassistant.components.lutron_caseta pylutron-caseta==0.18.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e1235f4734..e94cabd6ac 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1269,7 +1269,7 @@ pylibrespot-java==0.1.1 pylitejet==0.5.0 # homeassistant.components.litterrobot -pylitterbot==2023.1.1 +pylitterbot==2023.1.2 # homeassistant.components.lutron_caseta pylutron-caseta==0.18.1 diff --git a/tests/components/litterrobot/test_select.py b/tests/components/litterrobot/test_select.py index 478d801e4d..f6a32a6ef3 100644 --- a/tests/components/litterrobot/test_select.py +++ b/tests/components/litterrobot/test_select.py @@ -1,9 +1,12 @@ """Test the Litter-Robot select entity.""" -from pylitterbot import LitterRobot3 +from unittest.mock import AsyncMock, MagicMock + +from pylitterbot import LitterRobot3, LitterRobot4 import pytest from homeassistant.components.select import ( ATTR_OPTION, + ATTR_OPTIONS, DOMAIN as PLATFORM_DOMAIN, SERVICE_SELECT_OPTION, ) @@ -14,6 +17,7 @@ from homeassistant.helpers import entity_registry as er from .conftest import setup_integration SELECT_ENTITY_ID = "select.test_clean_cycle_wait_time_minutes" +PANEL_BRIGHTNESS_ENTITY_ID = "select.test_panel_brightness" async def test_wait_time_select( @@ -63,3 +67,38 @@ async def test_invalid_wait_time_select(hass: HomeAssistant, mock_account) -> No blocking=True, ) assert not mock_account.robots[0].set_wait_time.called + + +async def test_panel_brightness_select( + hass: HomeAssistant, + mock_account_with_litterrobot_4: MagicMock, + entity_registry: er.EntityRegistry, +) -> None: + """Tests the wait time select entity.""" + await setup_integration(hass, mock_account_with_litterrobot_4, PLATFORM_DOMAIN) + + select = hass.states.get(PANEL_BRIGHTNESS_ENTITY_ID) + assert select + assert len(select.attributes[ATTR_OPTIONS]) == 3 + + entity_entry = entity_registry.async_get(PANEL_BRIGHTNESS_ENTITY_ID) + assert entity_entry + assert entity_entry.entity_category is EntityCategory.CONFIG + + data = {ATTR_ENTITY_ID: PANEL_BRIGHTNESS_ENTITY_ID} + + robot: LitterRobot4 = mock_account_with_litterrobot_4.robots[0] + robot.set_panel_brightness = AsyncMock(return_value=True) + count = 0 + for option in select.attributes[ATTR_OPTIONS]: + count += 1 + data[ATTR_OPTION] = option + + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_SELECT_OPTION, + data, + blocking=True, + ) + + assert robot.set_panel_brightness.call_count == count From 2123600039cfbfe073d719aeaad26589213cdd7e Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Tue, 28 Mar 2023 09:10:28 -0400 Subject: [PATCH 1376/2810] Add minutely updates to relative_time and today_at template functions (#86815) * add minutely update * fix mypy --- homeassistant/helpers/template.py | 20 ++++++++++++++------ tests/helpers/test_template.py | 13 ++++++++++++- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 8f68c7af37..f21cfc08f1 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -1947,8 +1947,11 @@ def random_every_time(context, values): return random.choice(values) -def today_at(time_str: str = "") -> datetime: +def today_at(hass: HomeAssistant, time_str: str = "") -> datetime: """Record fetching now where the time has been replaced with value.""" + if (render_info := hass.data.get(_RENDER_INFO)) is not None: + render_info.has_time = True + today = dt_util.start_of_local_day() if not time_str: return today @@ -1961,7 +1964,7 @@ def today_at(time_str: str = "") -> datetime: return datetime.combine(today, time_today, today.tzinfo) -def relative_time(value): +def relative_time(hass: HomeAssistant, value: Any) -> Any: """Take a datetime and return its "age" as a string. The age can be in second, minute, hour, day, month or year. Only the @@ -1971,6 +1974,9 @@ def relative_time(value): If the input are not a datetime object the input will be returned unmodified. """ + if (render_info := hass.data.get(_RENDER_INFO)) is not None: + render_info.has_time = True + if not isinstance(value, datetime): return value if not value.tzinfo: @@ -2152,7 +2158,6 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["as_datetime"] = as_datetime self.filters["as_timedelta"] = as_timedelta self.filters["as_timestamp"] = forgiving_as_timestamp - self.filters["today_at"] = today_at self.filters["as_local"] = dt_util.as_local self.filters["timestamp_custom"] = timestamp_custom self.filters["timestamp_local"] = timestamp_local @@ -2178,7 +2183,6 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["is_number"] = is_number self.filters["float"] = forgiving_float_filter self.filters["int"] = forgiving_int_filter - self.filters["relative_time"] = relative_time self.filters["slugify"] = slugify self.filters["iif"] = iif self.filters["bool"] = forgiving_boolean @@ -2201,8 +2205,6 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["as_local"] = dt_util.as_local self.globals["as_timedelta"] = as_timedelta self.globals["as_timestamp"] = forgiving_as_timestamp - self.globals["today_at"] = today_at - self.globals["relative_time"] = relative_time self.globals["timedelta"] = timedelta self.globals["strptime"] = strptime self.globals["urlencode"] = urlencode @@ -2307,6 +2309,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): "device_id", "area_id", "area_name", + "relative_time", + "today_at", ] hass_filters = ["closest", "expand", "device_id", "area_id", "area_name"] for glob in hass_globals: @@ -2330,6 +2334,10 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["states"] = self.globals["states"] self.globals["utcnow"] = hassfunction(utcnow) self.globals["now"] = hassfunction(now) + self.globals["relative_time"] = hassfunction(relative_time) + self.filters["relative_time"] = self.globals["relative_time"] + self.globals["today_at"] = hassfunction(today_at) + self.filters["today_at"] = self.globals["today_at"] def is_safe_callable(self, obj): """Test if callback is safe.""" diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 750602c9d6..c9ef9494bf 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1696,6 +1696,11 @@ def test_today_at( with pytest.raises(TemplateError): template.Template("{{ today_at('bad') }}", hass).async_render() + info = template.Template( + "{{ today_at('10:00').isoformat() }}", hass + ).async_render_to_info() + assert info.has_time is True + freezer.stop() @@ -1707,9 +1712,12 @@ def test_relative_time(mock_is_safe, hass: HomeAssistant) -> None: """Test relative_time method.""" hass.config.set_time_zone("UTC") now = datetime.strptime("2000-01-01 10:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") + relative_time_template = ( + '{{relative_time(strptime("2000-01-01 09:00:00", "%Y-%m-%d %H:%M:%S"))}}' + ) with patch("homeassistant.util.dt.now", return_value=now): result = template.Template( - '{{relative_time(strptime("2000-01-01 09:00:00", "%Y-%m-%d %H:%M:%S"))}}', + relative_time_template, hass, ).async_render() assert result == "1 hour" @@ -1768,6 +1776,9 @@ def test_relative_time(mock_is_safe, hass: HomeAssistant) -> None: ).async_render() assert result == "string" + info = template.Template(relative_time_template, hass).async_render_to_info() + assert info.has_time is True + @patch( "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", From b4775ed2eba3826dcfbc0b03c2b5476ccaaacaa4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Mar 2023 15:22:48 +0200 Subject: [PATCH 1377/2810] Don't rely on the demo integration in voice_assistant tests (#90405) --- .../snapshots/test_websocket.ambr | 2 +- .../voice_assistant/test_websocket.py | 86 ++++++++++++++----- 2 files changed, 66 insertions(+), 22 deletions(-) diff --git a/tests/components/voice_assistant/snapshots/test_websocket.ambr b/tests/components/voice_assistant/snapshots/test_websocket.ambr index 07934df6c4..c18af44b21 100644 --- a/tests/components/voice_assistant/snapshots/test_websocket.ambr +++ b/tests/components/voice_assistant/snapshots/test_websocket.ambr @@ -66,7 +66,7 @@ dict({ 'tts_output': dict({ 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en_-_demo.mp3', + 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en_-_test.mp3', }), }) # --- diff --git a/tests/components/voice_assistant/test_websocket.py b/tests/components/voice_assistant/test_websocket.py index f02122a3e7..149d896dcf 100644 --- a/tests/components/voice_assistant/test_websocket.py +++ b/tests/components/voice_assistant/test_websocket.py @@ -1,15 +1,18 @@ """Websocket tests for Voice Assistant integration.""" import asyncio from collections.abc import AsyncIterable +from typing import Any from unittest.mock import MagicMock, patch import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import stt +from homeassistant.components import stt, tts from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_setup_component +from tests.common import MockModule, mock_integration, mock_platform from tests.components.tts.conftest import ( # noqa: F401, pylint: disable=unused-import mock_get_cache_files, mock_init_cache_dir, @@ -64,6 +67,61 @@ class MockSttProvider(stt.Provider): return stt.SpeechResult(self.text, stt.SpeechResultState.SUCCESS) +class MockSTT: + """A mock STT platform.""" + + async def async_get_engine( + self, + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, + ) -> tts.Provider: + """Set up a mock speech component.""" + return MockSttProvider(hass, _TRANSCRIPT) + + +class MockTTSProvider(tts.Provider): + """Mock TTS provider.""" + + name = "Test" + + @property + def default_language(self) -> str: + """Return the default language.""" + return "en" + + @property + def supported_languages(self) -> list[str]: + """Return list of supported languages.""" + return ["en"] + + @property + def supported_options(self) -> list[str]: + """Return list of supported options like voice, emotions.""" + return ["voice", "age"] + + def get_tts_audio( + self, message: str, language: str, options: dict[str, Any] | None = None + ) -> tts.TtsAudioType: + """Load TTS dat.""" + return ("mp3", b"") + + +class MockTTS: + """A mock TTS platform.""" + + PLATFORM_SCHEMA = tts.PLATFORM_SCHEMA + + async def async_get_engine( + self, + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, + ) -> tts.Provider: + """Set up a mock speech component.""" + return MockTTSProvider() + + @pytest.fixture(autouse=True) async def init_components( hass: HomeAssistant, @@ -71,29 +129,15 @@ async def init_components( mock_init_cache_dir, # noqa: F811 ): """Initialize relevant components with empty configs.""" + mock_integration(hass, MockModule(domain="test")) + mock_platform(hass, "test.tts", MockTTS()) + mock_platform(hass, "test.stt", MockSTT()) + + assert await async_setup_component(hass, tts.DOMAIN, {"tts": {"platform": "test"}}) + assert await async_setup_component(hass, stt.DOMAIN, {"stt": {"platform": "test"}}) assert await async_setup_component(hass, "media_source", {}) - assert await async_setup_component( - hass, - "tts", - { - "tts": { - "platform": "demo", - } - }, - ) - assert await async_setup_component(hass, "stt", {}) - - # mock_platform fails because it can't import - hass.data[stt.DOMAIN] = {"test": MockSttProvider(hass, _TRANSCRIPT)} - assert await async_setup_component(hass, "voice_assistant", {}) - with patch( - "homeassistant.components.demo.tts.DemoProvider.get_tts_audio", - return_value=("mp3", b""), - ) as mock_get_tts: - yield mock_get_tts - async def test_text_only_pipeline( hass: HomeAssistant, From 02e2e4d0395d6000211c5142e1437dd18f803bad Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Mar 2023 16:29:24 +0200 Subject: [PATCH 1378/2810] Add rest encoding test (#90404) * Add rest encoding test * docstring --- tests/components/rest/test_sensor.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index 46a972628e..5ae8530c29 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -106,6 +106,31 @@ async def test_setup_minimum(hass: HomeAssistant) -> None: assert len(hass.states.async_all("sensor")) == 1 +@respx.mock +async def test_setup_encoding(hass: HomeAssistant) -> None: + """Test setup with non-utf8 encoding.""" + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, + stream=httpx.ByteStream("tack själv".encode(encoding="iso-8859-1")), + ) + assert await async_setup_component( + hass, + DOMAIN, + { + "sensor": { + "name": "mysensor", + "encoding": "iso-8859-1", + "platform": "rest", + "resource": "http://localhost", + "method": "GET", + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all("sensor")) == 1 + assert hass.states.get("sensor.mysensor").state == "tack själv" + + @respx.mock async def test_manual_update(hass: HomeAssistant) -> None: """Test setup with minimum configuration.""" From bdf29b594f6b45b0cd759654b87f7d495e3c6a66 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Mar 2023 16:32:39 +0200 Subject: [PATCH 1379/2810] Replace comments with docstring in ColorMode enum (#90408) --- homeassistant/components/light/__init__.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 02f6e44a70..0c3a711a73 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -68,16 +68,20 @@ ATTR_SUPPORTED_COLOR_MODES = "supported_color_modes" class ColorMode(StrEnum): """Possible light color modes.""" - UNKNOWN = "unknown" # Ambiguous color mode - ONOFF = "onoff" # Must be the only supported mode - BRIGHTNESS = "brightness" # Must be the only supported mode + UNKNOWN = "unknown" + """Ambiguous color mode""" + ONOFF = "onoff" + """Must be the only supported mode""" + BRIGHTNESS = "brightness" + """Must be the only supported mode""" COLOR_TEMP = "color_temp" HS = "hs" XY = "xy" RGB = "rgb" RGBW = "rgbw" RGBWW = "rgbww" - WHITE = "white" # Must *NOT* be the only supported mode + WHITE = "white" + """Must *NOT* be the only supported mode""" # These COLOR_MODE_* constants are deprecated as of Home Assistant 2022.5. From 3662c651c9511a2081bf1d85e79b627a3c8c1eaa Mon Sep 17 00:00:00 2001 From: b-uwe <61052367+b-uwe@users.noreply.github.com> Date: Tue, 28 Mar 2023 16:37:57 +0200 Subject: [PATCH 1380/2810] Add brand for HomeSeer (#90066) --- homeassistant/brands/homeseer.json | 5 +++++ homeassistant/generated/integrations.json | 6 ++++++ 2 files changed, 11 insertions(+) create mode 100644 homeassistant/brands/homeseer.json diff --git a/homeassistant/brands/homeseer.json b/homeassistant/brands/homeseer.json new file mode 100644 index 0000000000..cfc36968c1 --- /dev/null +++ b/homeassistant/brands/homeseer.json @@ -0,0 +1,5 @@ +{ + "domain": "homeseer", + "name": "HomeSeer", + "iot_standards": ["zwave"] +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1fb801be12..3e89f9d12d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2305,6 +2305,12 @@ } } }, + "homeseer": { + "name": "HomeSeer", + "iot_standards": [ + "zwave" + ] + }, "homewizard": { "name": "HomeWizard Energy", "integration_type": "hub", From abe60375b378b0f7812348b3ba0f446bb3fd269b Mon Sep 17 00:00:00 2001 From: Alexander Momchilov Date: Tue, 28 Mar 2023 10:43:47 -0400 Subject: [PATCH 1381/2810] Disable esphome stopping a cover if the cover doesn't support stopping (#80104) * Make "CoverEntityFeature.STOP" conditional * Check APIVersion before checking false by default flag * sort --------- Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- homeassistant/components/esphome/cover.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/esphome/cover.py b/homeassistant/components/esphome/cover.py index 99d5f16b27..9d82b28529 100644 --- a/homeassistant/components/esphome/cover.py +++ b/homeassistant/components/esphome/cover.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from aioesphomeapi import CoverInfo, CoverOperation, CoverState +from aioesphomeapi import APIVersion, CoverInfo, CoverOperation, CoverState from homeassistant.components.cover import ( ATTR_POSITION, @@ -41,9 +41,10 @@ class EsphomeCover(EsphomeEntity[CoverInfo, CoverState], CoverEntity): @property def supported_features(self) -> CoverEntityFeature: """Flag supported features.""" - flags = ( - CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP - ) + flags = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + + if self._api_version < APIVersion(1, 8) or self._static_info.supports_stop: + flags |= CoverEntityFeature.STOP if self._static_info.supports_position: flags |= CoverEntityFeature.SET_POSITION if self._static_info.supports_tilt: From a26d95ec02ede0da684b2389d6cc67c0144eafc2 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Tue, 28 Mar 2023 16:45:06 +0200 Subject: [PATCH 1382/2810] Add switch tests for devolo_home_control (#80154) --- .coveragerc | 1 - tests/components/devolo_home_control/mocks.py | 23 +++++ .../devolo_home_control/test_switch.py | 86 +++++++++++++++++++ 3 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 tests/components/devolo_home_control/test_switch.py diff --git a/.coveragerc b/.coveragerc index 5b5096ee58..507e0dc895 100644 --- a/.coveragerc +++ b/.coveragerc @@ -198,7 +198,6 @@ omit = homeassistant/components/denonavr/__init__.py homeassistant/components/denonavr/media_player.py homeassistant/components/denonavr/receiver.py - homeassistant/components/devolo_home_control/switch.py homeassistant/components/digital_ocean/* homeassistant/components/discogs/sensor.py homeassistant/components/discord/__init__.py diff --git a/tests/components/devolo_home_control/mocks.py b/tests/components/devolo_home_control/mocks.py index 0fc01d6184..aef687936e 100644 --- a/tests/components/devolo_home_control/mocks.py +++ b/tests/components/devolo_home_control/mocks.py @@ -42,6 +42,7 @@ class BinarySwitchPropertyMock(BinarySwitchProperty): """Initialize the mock.""" self._logger = MagicMock() self.element_uid = "Test" + self.state = False class ConsumptionPropertyMock(ConsumptionProperty): @@ -233,6 +234,17 @@ class SensorMock(DeviceMock): } +class SwitchMock(DeviceMock): + """devolo Home Control switch device mock.""" + + def __init__(self) -> None: + """Initialize the mock.""" + super().__init__() + self.binary_switch_property = { + "devolo.BinarySwitch:Test": BinarySwitchPropertyMock() + } + + class HomeControlMock(HomeControl): """devolo Home Control gateway mock.""" @@ -353,3 +365,14 @@ class HomeControlMockSiren(HomeControlMock): self.devices = {"Test": SirenMock()} self.publisher = Publisher(self.devices.keys()) self.publisher.unregister = MagicMock() + + +class HomeControlMockSwitch(HomeControlMock): + """devolo Home Control gateway mock with switch device.""" + + def __init__(self, **kwargs: Any) -> None: + """Initialize the mock.""" + super().__init__() + self.devices = {"Test": SwitchMock()} + self.publisher = Publisher(self.devices.keys()) + self.publisher.unregister = MagicMock() diff --git a/tests/components/devolo_home_control/test_switch.py b/tests/components/devolo_home_control/test_switch.py new file mode 100644 index 0000000000..62de903848 --- /dev/null +++ b/tests/components/devolo_home_control/test_switch.py @@ -0,0 +1,86 @@ +"""Tests for the devolo Home Control switch platform.""" +from unittest.mock import patch + +from homeassistant.components.switch import DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant + +from . import configure_integration +from .mocks import HomeControlMock, HomeControlMockSwitch + + +async def test_switch(hass: HomeAssistant): + """Test setup and state change of a switch device.""" + entry = configure_integration(hass) + test_gateway = HomeControlMockSwitch() + with patch( + "homeassistant.components.devolo_home_control.HomeControl", + side_effect=[test_gateway, HomeControlMock()], + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(f"{DOMAIN}.test") + assert state is not None + assert state.state == STATE_OFF + + # Emulate websocket message: switched on + test_gateway.devices["Test"].binary_switch_property[ + "devolo.BinarySwitch:Test" + ].state = True + test_gateway.publisher.dispatch("Test", ("devolo.BinarySwitch:Test", True)) + await hass.async_block_till_done() + assert hass.states.get(f"{DOMAIN}.test").state == STATE_ON + + with patch( + "devolo_home_control_api.properties.binary_switch_property.BinarySwitchProperty.set" + ) as set_value: + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: f"{DOMAIN}.test"}, + blocking=True, + ) # In reality, this leads to a websocket message like already tested above + set_value.assert_called_once_with(state=True) + + set_value.reset_mock() + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: f"{DOMAIN}.test"}, + blocking=True, + ) # In reality, this leads to a websocket message like already tested above + set_value.assert_called_once_with(state=False) + + # Emulate websocket message: device went offline + test_gateway.devices["Test"].status = 1 + test_gateway.publisher.dispatch("Test", ("Status", False, "status")) + await hass.async_block_till_done() + assert hass.states.get(f"{DOMAIN}.test").state == STATE_UNAVAILABLE + + +async def test_remove_from_hass(hass: HomeAssistant): + """Test removing entity.""" + entry = configure_integration(hass) + test_gateway = HomeControlMockSwitch() + with patch( + "homeassistant.components.devolo_home_control.HomeControl", + side_effect=[test_gateway, HomeControlMock()], + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(f"{DOMAIN}.test") + assert state is not None + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + assert test_gateway.publisher.unregister.call_count == 1 From 866518c5a0877f73bd48620655d8f468309abc9e Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Tue, 28 Mar 2023 10:49:32 -0400 Subject: [PATCH 1383/2810] Add tests to Lidarr (#79610) * Add tests to Lidarr * fix js files * take out the trash * fix 3.9 * uno mas * fix fixture * ruff * Update const.py --------- Co-authored-by: Erik Montnemery --- .coveragerc | 3 - .prettierignore | 2 + homeassistant/components/lidarr/const.py | 2 - tests/components/lidarr/__init__.py | 52 ------- tests/components/lidarr/conftest.py | 142 ++++++++++++++++++ .../lidarr/fixtures/initialize-wrong.js | 12 ++ .../components/lidarr/fixtures/initialize.js | 12 ++ tests/components/lidarr/fixtures/queue.json | 57 +++++++ .../lidarr/fixtures/rootfolder-linux.json | 15 ++ .../lidarr/fixtures/system-status.json | 4 +- .../lidarr/fixtures/wanted-missing.json | 134 +++++++++++++++++ tests/components/lidarr/test_config_flow.py | 130 ++++++---------- tests/components/lidarr/test_init.py | 61 ++++++++ tests/components/lidarr/test_sensor.py | 33 ++++ 14 files changed, 518 insertions(+), 141 deletions(-) create mode 100644 tests/components/lidarr/conftest.py create mode 100644 tests/components/lidarr/fixtures/initialize-wrong.js create mode 100644 tests/components/lidarr/fixtures/initialize.js create mode 100644 tests/components/lidarr/fixtures/queue.json create mode 100644 tests/components/lidarr/fixtures/rootfolder-linux.json create mode 100644 tests/components/lidarr/fixtures/wanted-missing.json create mode 100644 tests/components/lidarr/test_init.py create mode 100644 tests/components/lidarr/test_sensor.py diff --git a/.coveragerc b/.coveragerc index 507e0dc895..82677177e6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -629,9 +629,6 @@ omit = homeassistant/components/lg_netcast/media_player.py homeassistant/components/lg_soundbar/__init__.py homeassistant/components/lg_soundbar/media_player.py - homeassistant/components/lidarr/__init__.py - homeassistant/components/lidarr/coordinator.py - homeassistant/components/lidarr/sensor.py homeassistant/components/life360/__init__.py homeassistant/components/life360/coordinator.py homeassistant/components/life360/device_tracker.py diff --git a/.prettierignore b/.prettierignore index a4d1d99079..aab23e2307 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,5 @@ azure-*.yml docs/source/_templates/* homeassistant/components/*/translations/*.json homeassistant/generated/* +tests/components/lidarr/fixtures/initialize.js +tests/components/lidarr/fixtures/initialize-wrong.js diff --git a/homeassistant/components/lidarr/const.py b/homeassistant/components/lidarr/const.py index feadedb6d4..ccf56db802 100644 --- a/homeassistant/components/lidarr/const.py +++ b/homeassistant/components/lidarr/const.py @@ -17,8 +17,6 @@ BYTE_SIZES = [ ] # Defaults -DEFAULT_DAYS = "1" -DEFAULT_HOST = "localhost" DEFAULT_NAME = "Lidarr" DEFAULT_UNIT = UnitOfInformation.GIGABYTES DEFAULT_MAX_RECORDS = 20 diff --git a/tests/components/lidarr/__init__.py b/tests/components/lidarr/__init__.py index 8c1220e4c6..6c1042e10d 100644 --- a/tests/components/lidarr/__init__.py +++ b/tests/components/lidarr/__init__.py @@ -1,53 +1 @@ """Tests for the Lidarr component.""" -from aiopyarr.lidarr_client import LidarrClient - -from homeassistant.components.lidarr.const import DOMAIN -from homeassistant.const import ( - CONF_API_KEY, - CONF_URL, - CONF_VERIFY_SSL, - CONTENT_TYPE_JSON, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession - -from tests.common import MockConfigEntry, load_fixture -from tests.test_util.aiohttp import AiohttpClientMocker - -BASE_PATH = "" -API_KEY = "1234567890abcdef1234567890abcdef" -URL = "http://127.0.0.1:8686" -client = LidarrClient(session=async_get_clientsession, api_token=API_KEY, url=URL) -API_URL = f"{URL}/api/{client._host.api_ver}" - -MOCK_REAUTH_INPUT = {CONF_API_KEY: "new_key"} - -MOCK_USER_INPUT = { - CONF_URL: URL, - CONF_VERIFY_SSL: False, -} - -CONF_DATA = MOCK_USER_INPUT | {CONF_API_KEY: API_KEY} - - -def mock_connection( - aioclient_mock: AiohttpClientMocker, - url: str = API_URL, -) -> None: - """Mock lidarr connection.""" - aioclient_mock.get( - f"{url}/system/status", - text=load_fixture("lidarr/system-status.json"), - headers={"Content-Type": CONTENT_TYPE_JSON}, - ) - - -def create_entry(hass: HomeAssistant) -> MockConfigEntry: - """Create Efergy entry in Home Assistant.""" - entry = MockConfigEntry( - domain=DOMAIN, - data=CONF_DATA, - ) - - entry.add_to_hass(hass) - return entry diff --git a/tests/components/lidarr/conftest.py b/tests/components/lidarr/conftest.py new file mode 100644 index 0000000000..308de36954 --- /dev/null +++ b/tests/components/lidarr/conftest.py @@ -0,0 +1,142 @@ +"""Configure pytest for Lidarr tests.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Generator +from http import HTTPStatus + +from aiohttp.client_exceptions import ClientError +from aiopyarr.lidarr_client import LidarrClient +import pytest + +from homeassistant.components.lidarr.const import DOMAIN +from homeassistant.const import ( + CONF_API_KEY, + CONF_URL, + CONF_VERIFY_SSL, + CONTENT_TYPE_JSON, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker + +URL = "http://127.0.0.1:8668" +API_KEY = "1234567890abcdef1234567890abcdef" +client = LidarrClient(session=async_get_clientsession, api_token=API_KEY, url=URL) +API_URL = f"{URL}/api/{client._host.api_ver}" + +MOCK_INPUT = {CONF_URL: URL, CONF_VERIFY_SSL: False} + +CONF_DATA = MOCK_INPUT | {CONF_API_KEY: API_KEY} + +ComponentSetup = Callable[[], Awaitable[None]] + + +def mock_error( + aioclient_mock: AiohttpClientMocker, status: HTTPStatus | None = None +) -> None: + """Mock an error.""" + if status: + aioclient_mock.get(f"{API_URL}/queue", status=status) + aioclient_mock.get(f"{API_URL}/rootfolder", status=status) + aioclient_mock.get(f"{API_URL}/system/status", status=status) + aioclient_mock.get(f"{API_URL}/wanted/missing", status=status) + aioclient_mock.get(f"{API_URL}/queue", exc=ClientError) + aioclient_mock.get(f"{API_URL}/rootfolder", exc=ClientError) + aioclient_mock.get(f"{API_URL}/system/status", exc=ClientError) + aioclient_mock.get(f"{API_URL}/wanted/missing", exc=ClientError) + + +@pytest.fixture +def cannot_connect(aioclient_mock: AiohttpClientMocker) -> None: + """Mock cannot connect error.""" + mock_error(aioclient_mock, status=HTTPStatus.INTERNAL_SERVER_ERROR) + + +@pytest.fixture +def invalid_auth(aioclient_mock: AiohttpClientMocker) -> None: + """Mock invalid authorization error.""" + mock_error(aioclient_mock, status=HTTPStatus.UNAUTHORIZED) + + +@pytest.fixture +def wrong_app(aioclient_mock: AiohttpClientMocker) -> None: + """Mock Lidarr wrong app.""" + aioclient_mock.get( + f"{URL}/initialize.js", + text=load_fixture("lidarr/initialize-wrong.js"), + headers={"Content-Type": "application/javascript"}, + ) + + +@pytest.fixture +def zeroconf_failed(aioclient_mock: AiohttpClientMocker) -> None: + """Mock Lidarr zero configuration failure.""" + aioclient_mock.get( + f"{URL}/initialize.js", + text="login-failed", + headers={"Content-Type": "application/javascript"}, + ) + + +@pytest.fixture +def unknown(aioclient_mock: AiohttpClientMocker) -> None: + """Mock Lidarr unknown error.""" + aioclient_mock.get( + f"{URL}/initialize.js", + text="something went wrong", + headers={"Content-Type": "application/javascript"}, + ) + + +@pytest.fixture(name="connection") +def mock_connection(aioclient_mock: AiohttpClientMocker) -> None: + """Mock Lidarr connection.""" + aioclient_mock.get( + f"{URL}/initialize.js", + text=load_fixture("lidarr/initialize.js"), + headers={"Content-Type": "application/javascript"}, + ) + aioclient_mock.get( + f"{API_URL}/system/status", + text=load_fixture("lidarr/system-status.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + aioclient_mock.get( + f"{API_URL}/queue", + text=load_fixture("lidarr/queue.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + aioclient_mock.get( + f"{API_URL}/wanted/missing", + text=load_fixture("lidarr/wanted-missing.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + aioclient_mock.get( + f"{API_URL}/rootfolder", + text=load_fixture("lidarr/rootfolder-linux.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + +@pytest.fixture(name="config_entry") +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create Lidarr entry in Home Assistant.""" + return MockConfigEntry(domain=DOMAIN, data=CONF_DATA) + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> Generator[ComponentSetup, None, None]: + """Set up the lidarr integration in Home Assistant.""" + config_entry.add_to_hass(hass) + + async def func() -> None: + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + return func diff --git a/tests/components/lidarr/fixtures/initialize-wrong.js b/tests/components/lidarr/fixtures/initialize-wrong.js new file mode 100644 index 0000000000..9d92f564da --- /dev/null +++ b/tests/components/lidarr/fixtures/initialize-wrong.js @@ -0,0 +1,12 @@ +window.Radarr = { + apiRoot: '/api/v3', + apiKey: '1234567890abcdef1234567890abcdef', + release: '4.0.3.5849-develop', + version: '4.0.3.5849', + instanceName: 'Radarr', + branch: 'nightly', + analytics: true, + userHash: 'abcd1234', + urlBase: '', + isProduction: true + }; \ No newline at end of file diff --git a/tests/components/lidarr/fixtures/initialize.js b/tests/components/lidarr/fixtures/initialize.js new file mode 100644 index 0000000000..d50aaf9cc6 --- /dev/null +++ b/tests/components/lidarr/fixtures/initialize.js @@ -0,0 +1,12 @@ +window.Lidarr = { + apiRoot: '/api/v1', + apiKey: '1234567890abcdef1234567890abcdef', + release: '10.0.0.34882-develop', + version: '10.0.0.34882', + instanceName: 'Lidarr', + branch: 'nightly', + analytics: true, + userHash: 'abcd1234', + urlBase: '', + isProduction: true + }; \ No newline at end of file diff --git a/tests/components/lidarr/fixtures/queue.json b/tests/components/lidarr/fixtures/queue.json new file mode 100644 index 0000000000..24a922b97a --- /dev/null +++ b/tests/components/lidarr/fixtures/queue.json @@ -0,0 +1,57 @@ +{ + "page": 1, + "pageSize": 20, + "sortKey": "timeleft", + "sortDirection": "default", + "totalRecords": 2, + "records": [ + { + "artistId": 1, + "albumId": 1, + "quality": { + "quality": { "id": 0, "name": "Unknown" }, + "revision": { "version": 1, "real": 0, "isRepack": false } + }, + "size": 1000000, + "title": "string", + "sizeleft": 100000, + "timeleft": "00:00:00", + "estimatedCompletionTime": "2020-09-26T18:47:46Z", + "status": "downloading", + "trackedDownloadStatus": "ok", + "trackedDownloadState": "downloading", + "statusMessages": [], + "downloadId": "string", + "protocol": "string", + "downloadClient": "testclient", + "indexer": "test", + "outputPath": "/downloads/string", + "downloadForced": false, + "id": 1 + }, + { + "artistId": 1, + "albumId": 1, + "quality": { + "quality": { "id": 0, "name": "Unknown" }, + "revision": { "version": 1, "real": 0, "isRepack": false } + }, + "size": 2000000, + "title": "string2", + "sizeleft": 1000000, + "timeleft": "00:00:10", + "estimatedCompletionTime": "2020-09-26T18:47:46Z", + "status": "downloading", + "trackedDownloadStatus": "ok", + "trackedDownloadState": "downloading", + "statusMessages": [], + "downloadId": "string", + "protocol": "string", + "downloadClient": "testclient", + "indexer": "test", + "outputPath": "/downloads/string", + "downloadForced": false, + "id": 1 + } + ] +} diff --git a/tests/components/lidarr/fixtures/rootfolder-linux.json b/tests/components/lidarr/fixtures/rootfolder-linux.json new file mode 100644 index 0000000000..070703279b --- /dev/null +++ b/tests/components/lidarr/fixtures/rootfolder-linux.json @@ -0,0 +1,15 @@ +[ + { + "name": "/music/", + "path": "/music/", + "defaultMetadataProfileId": 1, + "defaultQualityProfileId": 1, + "defaultMonitorOption": "all", + "defaultNewItemMonitorOption": "all", + "defaultTags": [], + "accessible": true, + "freeSpace": 1000000000, + "totalSpace": 100000000000, + "id": 2 + } +] diff --git a/tests/components/lidarr/fixtures/system-status.json b/tests/components/lidarr/fixtures/system-status.json index 6baa9428ff..bd49c58153 100644 --- a/tests/components/lidarr/fixtures/system-status.json +++ b/tests/components/lidarr/fixtures/system-status.json @@ -5,8 +5,8 @@ "isProduction": false, "isAdmin": false, "isUserInteractive": true, - "startupPath": "C:\\ProgramData\\Radarr", - "appData": "C:\\ProgramData\\Radarr", + "startupPath": "C:\\ProgramData\\Lidarr", + "appData": "C:\\ProgramData\\Lidarr", "osName": "Windows", "osVersion": "10.0.18363.0", "isNetCore": true, diff --git a/tests/components/lidarr/fixtures/wanted-missing.json b/tests/components/lidarr/fixtures/wanted-missing.json new file mode 100644 index 0000000000..2b5886b39d --- /dev/null +++ b/tests/components/lidarr/fixtures/wanted-missing.json @@ -0,0 +1,134 @@ +{ + "page": 1, + "pageSize": 20, + "sortKey": "title", + "sortDirection": "default", + "totalRecords": 1, + "records": [ + { + "title": "test", + "disambiguation": "string", + "overview": "string", + "artistId": 0, + "foreignAlbumId": "string", + "monitored": true, + "anyReleaseOk": true, + "profileId": 1, + "duration": 0, + "albumType": "Album", + "secondaryTypes": [ + { + "id": 0, + "name": "string" + } + ], + "mediumCount": 1, + "ratings": { + "votes": 0, + "value": 0 + }, + "releaseDate": "1968-01-01T00:00:00Z", + "releases": [ + { + "id": 0, + "albumId": 0, + "foreignReleaseId": "string", + "title": "string", + "status": "string", + "duration": 0, + "trackCount": 1, + "media": [ + { + "mediumNumber": 1, + "mediumName": "Unknown", + "mediumFormat": "Unknown" + } + ], + "mediumCount": 1, + "disambiguation": "", + "country": ["string"], + "label": ["test"], + "format": "Unknown", + "monitored": true + } + ], + "genres": ["string"], + "media": [ + { + "mediumNumber": 1, + "mediumName": "Unknown", + "mediumFormat": "Unknown" + } + ], + "artist": { + "artistMetadataId": 0, + "status": "continuing", + "ended": false, + "artistName": "test", + "foreignArtistId": "string", + "tadbId": 0, + "discogsId": 0, + "overview": "string", + "artistType": "Group", + "disambiguation": "", + "links": [ + { + "url": "string", + "name": "string" + } + ], + "images": [ + { + "url": "https://test.jpg", + "coverType": "fanart", + "extension": ".jpg" + } + ], + "path": "string", + "qualityProfileId": 1, + "metadataProfileId": 1, + "monitored": true, + "monitorNewItems": "all", + "genres": ["string"], + "cleanName": "string", + "sortName": "string", + "tags": [0], + "added": "2020-03-18T15:51:22Z", + "ratings": { + "votes": 0, + "value": 0 + }, + "statistics": { + "albumCount": 0, + "trackFileCount": 0, + "trackCount": 0, + "totalTrackCount": 0, + "sizeOnDisk": 0, + "percentOfTracks": 0 + }, + "id": 0 + }, + "images": [ + { + "url": "string", + "coverType": "poster" + } + ], + "links": [ + { + "url": "string", + "name": "string" + } + ], + "statistics": { + "trackFileCount": 0, + "trackCount": 1, + "totalTrackCount": 1, + "sizeOnDisk": 0, + "percentOfTracks": 0 + }, + "grabbed": false, + "id": 1 + } + ] +} diff --git a/tests/components/lidarr/test_config_flow.py b/tests/components/lidarr/test_config_flow.py index b785932434..d3c4352dc1 100644 --- a/tests/components/lidarr/test_config_flow.py +++ b/tests/components/lidarr/test_config_flow.py @@ -1,138 +1,104 @@ """Test Lidarr config flow.""" -from unittest.mock import patch - -from aiopyarr import exceptions - from homeassistant import data_entry_flow from homeassistant.components.lidarr.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_SOURCE from homeassistant.core import HomeAssistant -from . import API_KEY, CONF_DATA, MOCK_USER_INPUT, create_entry, mock_connection - -from tests.test_util.aiohttp import AiohttpClientMocker +from .conftest import CONF_DATA, MOCK_INPUT, ComponentSetup -def _patch_client(): - return patch( - "homeassistant.components.lidarr.config_flow.LidarrClient.async_get_system_status" - ) - - -async def test_flow_user_form( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +async def test_flow_user_form(hass: HomeAssistant, connection) -> None: """Test that the user set up form is served.""" - mock_connection(aioclient_mock) result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER}, ) - with patch( - "homeassistant.components.lidarr.config_flow.LidarrClient.async_try_zeroconf", - return_value=("/api/v3", API_KEY, ""), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=MOCK_USER_INPUT, - ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_INPUT, + ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == DEFAULT_NAME assert result["data"] == CONF_DATA -async def test_flow_user_invalid_auth(hass: HomeAssistant) -> None: +async def test_flow_user_invalid_auth(hass: HomeAssistant, invalid_auth) -> None: """Test invalid authentication.""" - with _patch_client() as client: - client.side_effect = exceptions.ArrAuthenticationException - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_USER}, - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=CONF_DATA, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=CONF_DATA, + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "invalid_auth" -async def test_flow_user_cannot_connect(hass: HomeAssistant) -> None: +async def test_flow_user_cannot_connect(hass: HomeAssistant, cannot_connect) -> None: """Test connection error.""" - with _patch_client() as client: - client.side_effect = exceptions.ArrConnectionException - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_USER}, - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=CONF_DATA, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=CONF_DATA, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "cannot_connect" -async def test_wrong_app(hass: HomeAssistant) -> None: +async def test_wrong_app(hass: HomeAssistant, wrong_app) -> None: """Test we show user form on wrong app.""" - with patch( - "homeassistant.components.lidarr.config_flow.LidarrClient.async_try_zeroconf", - side_effect=exceptions.ArrWrongAppException, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_USER}, - data=MOCK_USER_INPUT, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=MOCK_INPUT, + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "wrong_app" -async def test_zero_conf_failure(hass: HomeAssistant) -> None: - """Test we show user form on api key retrieval failure.""" - with patch( - "homeassistant.components.lidarr.config_flow.LidarrClient.async_try_zeroconf", - side_effect=exceptions.ArrZeroConfException, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_USER}, - data=MOCK_USER_INPUT, - ) +async def test_zeroconf_failed(hass: HomeAssistant, zeroconf_failed) -> None: + """Test we show user form on zeroconf failure.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=MOCK_INPUT, + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "zeroconf_failed" -async def test_flow_user_unknown_error(hass: HomeAssistant) -> None: +async def test_flow_user_unknown_error(hass: HomeAssistant, unknown) -> None: """Test unknown error.""" - with _patch_client() as client: - client.side_effect = exceptions.ArrException - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_USER}, - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=CONF_DATA, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DATA, + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "unknown" async def test_flow_reauth( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, setup_integration: ComponentSetup, connection ) -> None: """Test reauth.""" - entry = create_entry(hass) - mock_connection(aioclient_mock) + await setup_integration() + entry = hass.config_entries.async_entries(DOMAIN)[0] result = await hass.config_entries.flow.async_init( DOMAIN, context={ diff --git a/tests/components/lidarr/test_init.py b/tests/components/lidarr/test_init.py new file mode 100644 index 0000000000..2a217bebd5 --- /dev/null +++ b/tests/components/lidarr/test_init.py @@ -0,0 +1,61 @@ +"""Test Lidarr integration.""" +from homeassistant.components.lidarr.const import DEFAULT_NAME, DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .conftest import ComponentSetup + + +async def test_setup( + hass: HomeAssistant, setup_integration: ComponentSetup, connection +) -> None: + """Test setup.""" + await setup_integration() + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.state == ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN) + + +async def test_async_setup_entry_not_ready( + hass: HomeAssistant, setup_integration: ComponentSetup, cannot_connect +) -> None: + """Test that it throws ConfigEntryNotReady when exception occurs during setup.""" + await setup_integration() + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state == ConfigEntryState.SETUP_RETRY + assert not hass.data.get(DOMAIN) + + +async def test_async_setup_entry_auth_failed( + hass: HomeAssistant, setup_integration: ComponentSetup, invalid_auth +) -> None: + """Test that it throws ConfigEntryAuthFailed when authentication fails.""" + await setup_integration() + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state == ConfigEntryState.SETUP_ERROR + assert not hass.data.get(DOMAIN) + + +async def test_device_info( + hass: HomeAssistant, setup_integration: ComponentSetup, connection +) -> None: + """Test device info.""" + await setup_integration() + entry = hass.config_entries.async_entries(DOMAIN)[0] + device_registry = dr.async_get(hass) + await hass.async_block_till_done() + device = device_registry.async_get_device({(DOMAIN, entry.entry_id)}) + + assert device.configuration_url == "http://127.0.0.1:8668" + assert device.identifiers == {(DOMAIN, entry.entry_id)} + assert device.manufacturer == DEFAULT_NAME + assert device.name == "Mock Title" + assert device.sw_version == "10.0.0.34882" diff --git a/tests/components/lidarr/test_sensor.py b/tests/components/lidarr/test_sensor.py new file mode 100644 index 0000000000..7fe347f461 --- /dev/null +++ b/tests/components/lidarr/test_sensor.py @@ -0,0 +1,33 @@ +"""The tests for Lidarr sensor platform.""" +from unittest.mock import AsyncMock + +from homeassistant.components.sensor import CONF_STATE_CLASS, SensorStateClass +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT +from homeassistant.core import HomeAssistant + +from .conftest import ComponentSetup + + +async def test_sensors( + hass: HomeAssistant, + setup_integration: ComponentSetup, + entity_registry_enabled_by_default: AsyncMock, + connection, +): + """Test for successfully setting up the Lidarr platform.""" + await setup_integration() + + state = hass.states.get("sensor.mock_title_disk_space") + assert state.state == "0.93" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "GB" + state = hass.states.get("sensor.mock_title_queue") + assert state.state == "2" + assert state.attributes.get("string") == "stopped" + assert state.attributes.get("string2") == "downloading" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Albums" + assert state.attributes.get(CONF_STATE_CLASS) == SensorStateClass.TOTAL + state = hass.states.get("sensor.mock_title_wanted") + assert state.state == "1" + assert state.attributes.get("test") == "test" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Albums" + assert state.attributes.get(CONF_STATE_CLASS) == SensorStateClass.TOTAL From f081fa8febbd0f4cd2738077726419a903eb3a29 Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Tue, 28 Mar 2023 15:50:59 +0100 Subject: [PATCH 1384/2810] Add basic tests for temper USB temperature sensor integration (#80220) * Add basic tests * Updated requriements_test_all.txt * Update temperusb version * Add type hints Co-authored-by: Christian Knittl-Frank * Add type hints Co-authored-by: Christian Knittl-Frank * Correct typo in type hint * Fix isort * Fix requirements_test_all.txt --------- Co-authored-by: Dave T Co-authored-by: Christian Knittl-Frank --- requirements_test_all.txt | 3 +++ tests/components/temper/__init__.py | 1 + tests/components/temper/test_sensor.py | 35 ++++++++++++++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 tests/components/temper/__init__.py create mode 100644 tests/components/temper/test_sensor.py diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e94cabd6ac..7b41d46936 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1760,6 +1760,9 @@ tellduslive==0.10.11 # homeassistant.components.lg_soundbar temescal==0.5 +# homeassistant.components.temper +temperusb==1.6.0 + # homeassistant.components.powerwall tesla-powerwall==0.3.19 diff --git a/tests/components/temper/__init__.py b/tests/components/temper/__init__.py new file mode 100644 index 0000000000..6ce341cabc --- /dev/null +++ b/tests/components/temper/__init__.py @@ -0,0 +1 @@ +"""Tests for the temper integration.""" diff --git a/tests/components/temper/test_sensor.py b/tests/components/temper/test_sensor.py new file mode 100644 index 0000000000..d195ff85c8 --- /dev/null +++ b/tests/components/temper/test_sensor.py @@ -0,0 +1,35 @@ +"""The tests for the temper (USB temperature sensor) component.""" +from datetime import timedelta +from unittest.mock import Mock, patch + +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util + +from tests.common import async_fire_time_changed + + +async def test_temperature_readback(hass: HomeAssistant) -> None: + """Test for reading sensors.""" + mock_temper_device = Mock() + mock_temper_device.get_temperature.return_value = 12.3 + + utcnow = dt_util.utcnow() + + with patch( + "temperusb.temper.TemperHandler.get_devices", + return_value=[mock_temper_device], + ): + await async_setup_component( + hass, + "sensor", + {"sensor": {"platform": "temper", "name": "mydevicename"}}, + ) + await hass.async_block_till_done() + + async_fire_time_changed(hass, utcnow + timedelta(seconds=70)) + await hass.async_block_till_done() + + temperature = hass.states.get("sensor.mydevicename") + assert temperature + assert temperature.state == "12.3" From 048d30904e62d61fd5d5ab94635e381c94b6fdd4 Mon Sep 17 00:00:00 2001 From: Chris Xiao <30990835+chrisx8@users.noreply.github.com> Date: Tue, 28 Mar 2023 10:52:16 -0400 Subject: [PATCH 1385/2810] Simplify qbittorrent sensor class init (#90411) catch LoginException directly in QBittorrentSensor init Since the `exception` arg in QBittorrentSensor `__init__` is always LoginException, we catch LoginException directly in `__init__` instead of passing LoginException as an argument. --- homeassistant/components/qbittorrent/sensor.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index bee7a5d61a..cafb8d8b21 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -90,8 +90,7 @@ def setup_platform( name = config.get(CONF_NAME) entities = [ - QBittorrentSensor(description, client, name, LoginRequired) - for description in SENSOR_TYPES + QBittorrentSensor(description, client, name) for description in SENSOR_TYPES ] add_entities(entities, True) @@ -111,12 +110,10 @@ class QBittorrentSensor(SensorEntity): description: SensorEntityDescription, qbittorrent_client, client_name, - exception, ) -> None: """Initialize the qBittorrent sensor.""" self.entity_description = description self.client = qbittorrent_client - self._exception = exception self._attr_name = f"{client_name} {description.name}" self._attr_available = False @@ -130,7 +127,7 @@ class QBittorrentSensor(SensorEntity): _LOGGER.error("Connection lost") self._attr_available = False return - except self._exception: + except LoginRequired: _LOGGER.error("Invalid authentication") return From e45eab600ff053928a7bf1566984e53ceb31557f Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Tue, 28 Mar 2023 09:04:29 -0600 Subject: [PATCH 1386/2810] Add has_value function/test to Jinja2 template (#79550) --- homeassistant/helpers/template.py | 26 ++++++++++++++++++++- tests/helpers/test_template.py | 39 +++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index f21cfc08f1..d3aa7c81ff 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -49,6 +49,7 @@ from homeassistant.const import ( ATTR_LONGITUDE, ATTR_PERSONS, ATTR_UNIT_OF_MEASUREMENT, + STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfLength, ) @@ -1470,6 +1471,15 @@ def state_attr(hass: HomeAssistant, entity_id: str, name: str) -> Any: return None +def has_value(hass: HomeAssistant, entity_id: str) -> bool: + """Test if an entity has a valid value.""" + state_obj = _get_state(hass, entity_id) + + return state_obj is not None and ( + state_obj.state not in [STATE_UNAVAILABLE, STATE_UNKNOWN] + ) + + def now(hass: HomeAssistant) -> datetime: """Record fetching now.""" if (render_info := hass.data.get(_RENDER_INFO)) is not None: @@ -2302,6 +2312,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): "is_state_attr", "state_attr", "states", + "has_value", "utcnow", "now", "device_attr", @@ -2312,11 +2323,21 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): "relative_time", "today_at", ] - hass_filters = ["closest", "expand", "device_id", "area_id", "area_name"] + hass_filters = [ + "closest", + "expand", + "device_id", + "area_id", + "area_name", + "has_value", + ] + hass_tests = ["has_value"] for glob in hass_globals: self.globals[glob] = unsupported(glob) for filt in hass_filters: self.filters[filt] = unsupported(filt) + for test in hass_tests: + self.filters[test] = unsupported(test) return self.globals["expand"] = hassfunction(expand) @@ -2332,6 +2353,9 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["state_attr"] = self.globals["state_attr"] self.globals["states"] = AllStates(hass) self.filters["states"] = self.globals["states"] + self.globals["has_value"] = hassfunction(has_value) + self.filters["has_value"] = pass_context(self.globals["has_value"]) + self.tests["has_value"] = pass_eval_context(self.globals["has_value"]) self.globals["utcnow"] = hassfunction(utcnow) self.globals["now"] = hassfunction(now) self.globals["relative_time"] = hassfunction(relative_time) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index c9ef9494bf..45237a5cbf 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -21,6 +21,7 @@ from homeassistant.const import ( LENGTH_MILLIMETERS, MASS_GRAMS, STATE_ON, + STATE_UNAVAILABLE, TEMP_CELSIUS, VOLUME_LITERS, UnitOfPressure, @@ -1607,6 +1608,44 @@ def test_states_function(hass: HomeAssistant) -> None: assert tpl.async_render() == "available" +def test_has_value(hass): + """Test has_value method.""" + hass.states.async_set("test.value1", 1) + hass.states.async_set("test.unavailable", STATE_UNAVAILABLE) + + tpl = template.Template( + """ +{{ has_value("test.value1") }} + """, + hass, + ) + assert tpl.async_render() is True + + tpl = template.Template( + """ +{{ has_value("test.unavailable") }} + """, + hass, + ) + assert tpl.async_render() is False + + tpl = template.Template( + """ +{{ has_value("test.unknown") }} + """, + hass, + ) + assert tpl.async_render() is False + + tpl = template.Template( + """ +{% if "test.value1" is has_value %}yes{% else %}no{% endif %} + """, + hass, + ) + assert tpl.async_render() == "yes" + + @patch( "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", return_value=True, From 478a1d5e9a15334cba10a51eb7be50a95aa66f4c Mon Sep 17 00:00:00 2001 From: Wesley Vos <17592840+Wesley-Vos@users.noreply.github.com> Date: Tue, 28 Mar 2023 17:09:20 +0200 Subject: [PATCH 1387/2810] Add periodically resetting meter option to utility meter (#88446) * Use last valid state if meter is not periodically resetting * Fix unload of entry, used during options flow submit * Adjustments based on code review * Move DecimalException handling to validation method * Add test for invalid new state in calculate_adjustment method --- .../components/utility_meter/__init__.py | 26 +- .../components/utility_meter/config_flow.py | 10 + .../components/utility_meter/const.py | 1 + .../components/utility_meter/sensor.py | 133 +++++--- .../components/utility_meter/strings.json | 8 +- .../utility_meter/test_config_flow.py | 61 +++- tests/components/utility_meter/test_init.py | 3 + tests/components/utility_meter/test_sensor.py | 317 +++++++++++++++++- 8 files changed, 510 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index c436ea757a..11e58fca77 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -21,6 +21,7 @@ from .const import ( CONF_METER_DELTA_VALUES, CONF_METER_NET_CONSUMPTION, CONF_METER_OFFSET, + CONF_METER_PERIODICALLY_RESETTING, CONF_METER_TYPE, CONF_SOURCE_SENSOR, CONF_TARIFF, @@ -83,6 +84,7 @@ METER_CONFIG_SCHEMA = vol.Schema( ), vol.Optional(CONF_METER_DELTA_VALUES, default=False): cv.boolean, vol.Optional(CONF_METER_NET_CONSUMPTION, default=False): cv.boolean, + vol.Optional(CONF_METER_PERIODICALLY_RESETTING, default=True): cv.boolean, vol.Optional(CONF_TARIFFS, default=[]): vol.All( cv.ensure_list, vol.Unique(), [cv.string] ), @@ -221,13 +223,29 @@ async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + platforms_to_unload = [Platform.SENSOR] + if entry.options.get(CONF_TARIFFS): + platforms_to_unload.append(Platform.SELECT) + if unload_ok := await hass.config_entries.async_unload_platforms( entry, - ( - Platform.SELECT, - Platform.SENSOR, - ), + platforms_to_unload, ): hass.data[DATA_UTILITY].pop(entry.entry_id) return unload_ok + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + if config_entry.version == 1: + new = {**config_entry.options} + new[CONF_METER_PERIODICALLY_RESETTING] = True + config_entry.version = 2 + hass.config_entries.async_update_entry(config_entry, options=new) + + _LOGGER.info("Migration to version %s successful", config_entry.version) + + return True diff --git a/homeassistant/components/utility_meter/config_flow.py b/homeassistant/components/utility_meter/config_flow.py index c1f82e902d..eb5c19941d 100644 --- a/homeassistant/components/utility_meter/config_flow.py +++ b/homeassistant/components/utility_meter/config_flow.py @@ -21,6 +21,7 @@ from .const import ( CONF_METER_DELTA_VALUES, CONF_METER_NET_CONSUMPTION, CONF_METER_OFFSET, + CONF_METER_PERIODICALLY_RESETTING, CONF_METER_TYPE, CONF_SOURCE_SENSOR, CONF_TARIFFS, @@ -64,6 +65,9 @@ OPTIONS_SCHEMA = vol.Schema( vol.Required(CONF_SOURCE_SENSOR): selector.EntitySelector( selector.EntitySelectorConfig(domain=SENSOR_DOMAIN), ), + vol.Required( + CONF_METER_PERIODICALLY_RESETTING, + ): selector.BooleanSelector(), } ) @@ -95,6 +99,10 @@ CONFIG_SCHEMA = vol.Schema( vol.Required( CONF_METER_DELTA_VALUES, default=False ): selector.BooleanSelector(), + vol.Required( + CONF_METER_PERIODICALLY_RESETTING, + default=True, + ): selector.BooleanSelector(), } ) @@ -110,6 +118,8 @@ OPTIONS_FLOW = { class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config or options flow for Utility Meter.""" + VERSION = 2 + config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW diff --git a/homeassistant/components/utility_meter/const.py b/homeassistant/components/utility_meter/const.py index 9b85e9e3ae..f8a4c2d4b7 100644 --- a/homeassistant/components/utility_meter/const.py +++ b/homeassistant/components/utility_meter/const.py @@ -32,6 +32,7 @@ CONF_METER_TYPE = "cycle" CONF_METER_OFFSET = "offset" CONF_METER_DELTA_VALUES = "delta_values" CONF_METER_NET_CONSUMPTION = "net_consumption" +CONF_METER_PERIODICALLY_RESETTING = "periodically_resetting" CONF_PAUSED = "paused" CONF_TARIFFS = "tariffs" CONF_TARIFF = "tariff" diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 066a3cd6e1..dad2d8dfaf 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -27,7 +27,7 @@ from homeassistant.const import ( STATE_UNKNOWN, UnitOfEnergy, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers import entity_platform, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -50,6 +50,7 @@ from .const import ( CONF_METER_DELTA_VALUES, CONF_METER_NET_CONSUMPTION, CONF_METER_OFFSET, + CONF_METER_PERIODICALLY_RESETTING, CONF_METER_TYPE, CONF_SOURCE_SENSOR, CONF_TARIFF, @@ -85,6 +86,7 @@ ATTR_SOURCE_ID = "source" ATTR_STATUS = "status" ATTR_PERIOD = "meter_period" ATTR_LAST_PERIOD = "last_period" +ATTR_LAST_VALID_STATE = "last_valid_state" ATTR_TARIFF = "tariff" DEVICE_CLASS_MAP = { @@ -127,6 +129,7 @@ async def async_setup_entry( meter_type = None name = config_entry.title net_consumption = config_entry.options[CONF_METER_NET_CONSUMPTION] + periodically_resetting = config_entry.options[CONF_METER_PERIODICALLY_RESETTING] tariff_entity = hass.data[DATA_UTILITY][entry_id][CONF_TARIFF_ENTITY] meters = [] @@ -142,6 +145,7 @@ async def async_setup_entry( name=name, net_consumption=net_consumption, parent_meter=entry_id, + periodically_resetting=periodically_resetting, source_entity=source_entity_id, tariff_entity=tariff_entity, tariff=None, @@ -160,6 +164,7 @@ async def async_setup_entry( name=f"{name} {tariff}", net_consumption=net_consumption, parent_meter=entry_id, + periodically_resetting=periodically_resetting, source_entity=source_entity_id, tariff_entity=tariff_entity, tariff=tariff, @@ -223,6 +228,9 @@ async def async_setup_platform( conf_meter_net_consumption = hass.data[DATA_UTILITY][meter][ CONF_METER_NET_CONSUMPTION ] + conf_meter_periodically_resetting = hass.data[DATA_UTILITY][meter][ + CONF_METER_PERIODICALLY_RESETTING + ] conf_meter_tariff_entity = hass.data[DATA_UTILITY][meter].get( CONF_TARIFF_ENTITY ) @@ -235,6 +243,7 @@ async def async_setup_platform( name=conf_sensor_name, net_consumption=conf_meter_net_consumption, parent_meter=meter, + periodically_resetting=conf_meter_periodically_resetting, source_entity=conf_meter_source, tariff_entity=conf_meter_tariff_entity, tariff=conf_sensor_tariff, @@ -262,6 +271,7 @@ class UtilitySensorExtraStoredData(SensorExtraStoredData): last_period: Decimal last_reset: datetime | None + last_valid_state: Decimal | None status: str def as_dict(self) -> dict[str, Any]: @@ -270,6 +280,9 @@ class UtilitySensorExtraStoredData(SensorExtraStoredData): data["last_period"] = str(self.last_period) if isinstance(self.last_reset, (datetime)): data["last_reset"] = self.last_reset.isoformat() + data["last_valid_state"] = ( + str(self.last_valid_state) if self.last_valid_state else None + ) data["status"] = self.status return data @@ -284,6 +297,11 @@ class UtilitySensorExtraStoredData(SensorExtraStoredData): try: last_period: Decimal = Decimal(restored["last_period"]) last_reset: datetime | None = dt_util.parse_datetime(restored["last_reset"]) + last_valid_state: Decimal | None = ( + Decimal(restored["last_valid_state"]) + if restored.get("last_valid_state") + else None + ) status: str = restored["status"] except KeyError: # restored is a dict, but does not have all values @@ -297,6 +315,7 @@ class UtilitySensorExtraStoredData(SensorExtraStoredData): extra.native_unit_of_measurement, last_period, last_reset, + last_valid_state, status, ) @@ -316,6 +335,7 @@ class UtilityMeterSensor(RestoreSensor): name, net_consumption, parent_meter, + periodically_resetting, source_entity, tariff_entity, tariff, @@ -330,6 +350,7 @@ class UtilityMeterSensor(RestoreSensor): self._state = None self._last_period = Decimal(0) self._last_reset = dt_util.utcnow() + self._last_valid_state = None self._collecting = None self._name = name self._unit_of_measurement = None @@ -346,6 +367,7 @@ class UtilityMeterSensor(RestoreSensor): self._cron_pattern = cron_pattern self._sensor_delta_values = delta_values self._sensor_net_consumption = net_consumption + self._sensor_periodically_resetting = periodically_resetting self._tariff = tariff self._tariff_entity = tariff_entity @@ -355,53 +377,70 @@ class UtilityMeterSensor(RestoreSensor): self._state = 0 self.async_write_ha_state() - @callback - def async_reading(self, event): - """Handle the sensor state changes.""" - old_state = event.data.get("old_state") - new_state = event.data.get("new_state") + @staticmethod + def _validate_state(state: State | None) -> Decimal | None: + """Parse the state as a Decimal if available. Throws DecimalException if the state is not a number.""" + try: + return ( + None + if state is None or state.state in [STATE_UNAVAILABLE, STATE_UNKNOWN] + else Decimal(state.state) + ) + except DecimalException: + return None - if self._state is None and new_state.state: + def calculate_adjustment( + self, old_state: State | None, new_state: State + ) -> Decimal | None: + """Calculate the adjustment based on the old and new state.""" + + # First check if the new_state is valid (see discussion in PR #88446) + if (new_state_val := self._validate_state(new_state)) is None: + _LOGGER.warning("Invalid state %s", new_state.state) + return None + + if self._sensor_delta_values: + return new_state_val + + if ( + not self._sensor_periodically_resetting + and self._last_valid_state is not None + ): # Fallback to old_state if sensor is periodically resetting but last_valid_state is None + return new_state_val - self._last_valid_state + + if (old_state_val := self._validate_state(old_state)) is not None: + return new_state_val - old_state_val + _LOGGER.warning( + "Invalid state (%s > %s)", + old_state.state if old_state else None, + new_state_val, + ) + return None + + @callback + def async_reading(self, event: Event): + """Handle the sensor state changes.""" + old_state: State | None = event.data.get("old_state") + new_state: State = event.data.get("new_state") # type: ignore[assignment] # a state change event always has a new state + + if (new_state_val := self._validate_state(new_state)) is None: + _LOGGER.warning("Invalid state %s", new_state.state) + return + + if self._state is None: # First state update initializes the utility_meter sensors - source_state = self.hass.states.get(self._sensor_source_id) for sensor in self.hass.data[DATA_UTILITY][self._parent_meter][ DATA_TARIFF_SENSORS ]: - sensor.start(source_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) + sensor.start(new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) if ( - new_state is None - or new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] - or ( - not self._sensor_delta_values - and ( - old_state is None - or old_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] - ) - ) - ): - return + adjustment := self.calculate_adjustment(old_state, new_state) + ) is not None and (self._sensor_net_consumption or adjustment >= 0): + # If net_consumption is off, the adjustment must be non-negative + self._state += adjustment # type: ignore[operator] # self._state will be set to by the start function if it is None, therefore it always has a valid Decimal value at this line - self._unit_of_measurement = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - - try: - if self._sensor_delta_values: - adjustment = Decimal(new_state.state) - else: - adjustment = Decimal(new_state.state) - Decimal(old_state.state) - - if (not self._sensor_net_consumption) and adjustment < 0: - # Source sensor just rolled over for unknown reasons, - return - self._state += adjustment - - except DecimalException as err: - if self._sensor_delta_values: - _LOGGER.warning("Invalid adjustment of %s: %s", new_state.state, err) - else: - _LOGGER.warning( - "Invalid state (%s > %s): %s", old_state.state, new_state.state, err - ) + self._last_valid_state = new_state_val self.async_write_ha_state() @callback @@ -422,6 +461,11 @@ class UtilityMeterSensor(RestoreSensor): self._collecting() self._collecting = None + # Reset the last_valid_state during state change because if the last state before the tariff change was invalid, + # there is no way to know how much "adjustment" counts for which tariff. Therefore, we set the last_valid_state + # to None and let the fallback mechanism handle the case that the old state was valid + self._last_valid_state = None + _LOGGER.debug( "%s - %s - source <%s>", self._name, @@ -484,6 +528,7 @@ class UtilityMeterSensor(RestoreSensor): self._unit_of_measurement = last_sensor_data.native_unit_of_measurement self._last_period = last_sensor_data.last_period self._last_reset = last_sensor_data.last_reset + self._last_valid_state = last_sensor_data.last_valid_state if last_sensor_data.status == COLLECTING: # Null lambda to allow cancelling the collection on tariff change self._collecting = lambda: None @@ -508,6 +553,12 @@ class UtilityMeterSensor(RestoreSensor): and is_number(state.attributes[ATTR_LAST_PERIOD]) else Decimal(0) ) + self._last_valid_state = ( + Decimal(state.attributes[ATTR_LAST_VALID_STATE]) + if state.attributes.get(ATTR_LAST_VALID_STATE) + and is_number(state.attributes[ATTR_LAST_VALID_STATE]) + else None + ) self._last_reset = dt_util.as_utc( dt_util.parse_datetime(state.attributes.get(ATTR_LAST_RESET)) ) @@ -590,6 +641,7 @@ class UtilityMeterSensor(RestoreSensor): ATTR_SOURCE_ID: self._sensor_source_id, ATTR_STATUS: PAUSED if self._collecting is None else COLLECTING, ATTR_LAST_PERIOD: str(self._last_period), + ATTR_LAST_VALID_STATE: str(self._last_valid_state), } if self._period is not None: state_attr[ATTR_PERIOD] = self._period @@ -620,6 +672,7 @@ class UtilityMeterSensor(RestoreSensor): self.native_unit_of_measurement, self._last_period, self._last_reset, + self._last_valid_state, PAUSED if self._collecting is None else COLLECTING, ) diff --git a/homeassistant/components/utility_meter/strings.json b/homeassistant/components/utility_meter/strings.json index e9f8e7f250..1eeacbae80 100644 --- a/homeassistant/components/utility_meter/strings.json +++ b/homeassistant/components/utility_meter/strings.json @@ -9,6 +9,7 @@ "cycle": "Meter reset cycle", "delta_values": "Delta values", "name": "Name", + "periodically_resetting": "Periodically resetting", "net_consumption": "Net consumption", "offset": "Meter reset offset", "source": "Input sensor", @@ -17,6 +18,7 @@ "data_description": { "delta_values": "Enable if the source values are delta values since the last reading instead of absolute values.", "net_consumption": "Enable if the source is a net meter, meaning it can both increase and decrease.", + "periodically_resetting": "Enable if the source may periodically reset to 0, for example at boot of the measuring device. If disabled, new readings are directly recorded after data inavailability.", "offset": "Offset the day of a monthly meter reset.", "tariffs": "A list of supported tariffs, leave empty if only a single tariff is needed." } @@ -27,7 +29,11 @@ "step": { "init": { "data": { - "source": "[%key:component::utility_meter::config::step::user::data::source%]" + "source": "[%key:component::utility_meter::config::step::user::data::source%]", + "periodically_resetting": "[%key:component::utility_meter::config::step::user::data::periodically_resetting%]" + }, + "data_description": { + "periodically_resetting": "[%key:component::utility_meter::config::step::user::data_description::periodically_resetting%]" } } } diff --git a/tests/components/utility_meter/test_config_flow.py b/tests/components/utility_meter/test_config_flow.py index 8deb7601aa..302d3879a0 100644 --- a/tests/components/utility_meter/test_config_flow.py +++ b/tests/components/utility_meter/test_config_flow.py @@ -47,6 +47,7 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: "name": "Electricity meter", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": input_sensor_entity_id, "tariffs": [], } @@ -60,6 +61,7 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: "name": "Electricity meter", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": input_sensor_entity_id, "tariffs": [], } @@ -96,6 +98,7 @@ async def test_tariffs(hass: HomeAssistant) -> None: "delta_values": False, "name": "Electricity meter", "net_consumption": False, + "periodically_resetting": True, "offset": 0, "source": input_sensor_entity_id, "tariffs": ["cat", "dog", "horse", "cow"], @@ -109,6 +112,7 @@ async def test_tariffs(hass: HomeAssistant) -> None: "name": "Electricity meter", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": input_sensor_entity_id, "tariffs": ["cat", "dog", "horse", "cow"], } @@ -136,6 +140,57 @@ async def test_tariffs(hass: HomeAssistant) -> None: assert result["errors"]["base"] == "tariffs_not_unique" +async def test_non_periodically_resetting(hass: HomeAssistant) -> None: + """Test periodically resetting.""" + input_sensor_entity_id = "sensor.input" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "cycle": "monthly", + "name": "Electricity meter", + "offset": 0, + "periodically_resetting": False, + "source": input_sensor_entity_id, + "tariffs": [], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Electricity meter" + assert result["data"] == {} + assert result["options"] == { + "cycle": "monthly", + "delta_values": False, + "name": "Electricity meter", + "net_consumption": False, + "periodically_resetting": False, + "offset": 0, + "source": input_sensor_entity_id, + "tariffs": [], + } + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + assert config_entry.data == {} + assert config_entry.options == { + "cycle": "monthly", + "delta_values": False, + "name": "Electricity meter", + "net_consumption": False, + "offset": 0, + "periodically_resetting": False, + "source": input_sensor_entity_id, + "tariffs": [], + } + + def get_suggested(schema, key): """Get suggested value for key in voluptuous schema.""" for k in schema: @@ -162,6 +217,7 @@ async def test_options(hass: HomeAssistant) -> None: "name": "Electricity meter", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": input_sensor1_entity_id, "tariffs": "", }, @@ -176,10 +232,11 @@ async def test_options(hass: HomeAssistant) -> None: assert result["step_id"] == "init" schema = result["data_schema"].schema assert get_suggested(schema, "source") == input_sensor1_entity_id + assert get_suggested(schema, "periodically_resetting") is True result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"source": input_sensor2_entity_id}, + user_input={"source": input_sensor2_entity_id, "periodically_resetting": False}, ) assert result["type"] == FlowResultType.CREATE_ENTRY assert result["data"] == { @@ -188,6 +245,7 @@ async def test_options(hass: HomeAssistant) -> None: "name": "Electricity meter", "net_consumption": False, "offset": 0, + "periodically_resetting": False, "source": input_sensor2_entity_id, "tariffs": "", } @@ -198,6 +256,7 @@ async def test_options(hass: HomeAssistant) -> None: "name": "Electricity meter", "net_consumption": False, "offset": 0, + "periodically_resetting": False, "source": input_sensor2_entity_id, "tariffs": "", } diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index ad4fc5e6e9..5c8d8d4253 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -186,6 +186,7 @@ async def test_services_config_entry(hass: HomeAssistant) -> None: "name": "Energy bill", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": "sensor.energy", "tariffs": ["peak", "offpeak"], }, @@ -202,6 +203,7 @@ async def test_services_config_entry(hass: HomeAssistant) -> None: "name": "Energy bill2", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": "sensor.energy", "tariffs": ["peak", "offpeak"], }, @@ -413,6 +415,7 @@ async def test_setup_and_remove_config_entry( "name": "Electricity meter", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": input_sensor_entity_id, "tariffs": tariffs, }, diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index c56010e36e..d84099b4d6 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -14,6 +14,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorStateClass, ) +from homeassistant.components.utility_meter import DEFAULT_OFFSET from homeassistant.components.utility_meter.const import ( ATTR_VALUE, DAILY, @@ -24,9 +25,11 @@ from homeassistant.components.utility_meter.const import ( ) from homeassistant.components.utility_meter.sensor import ( ATTR_LAST_RESET, + ATTR_LAST_VALID_STATE, ATTR_STATUS, COLLECTING, PAUSED, + UtilityMeterSensor, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -50,7 +53,7 @@ from tests.common import ( @pytest.fixture(autouse=True) -def set_utc(hass): +def set_utc(hass: HomeAssistant): """Set timezone to UTC.""" hass.config.set_time_zone("UTC") @@ -77,6 +80,7 @@ def set_utc(hass): "name": "Energy bill", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": "sensor.energy", "tariffs": ["onpeak", "midpeak", "offpeak"], }, @@ -272,6 +276,7 @@ async def test_not_unique_tariffs(hass: HomeAssistant, yaml_config) -> None: "name": "Energy bill", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": "sensor.energy", "tariffs": ["onpeak", "midpeak", "offpeak"], }, @@ -430,6 +435,7 @@ async def test_entity_name(hass: HomeAssistant, yaml_config, entity_id, name) -> "name": "Energy meter", "net_consumption": True, "offset": 0, + "periodically_resetting": True, "source": "sensor.energy", "tariffs": [], }, @@ -439,6 +445,7 @@ async def test_entity_name(hass: HomeAssistant, yaml_config, entity_id, name) -> "name": "Gas meter", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": "sensor.gas", "tariffs": [], }, @@ -516,6 +523,7 @@ async def test_device_class( "name": "Energy bill", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": "sensor.energy", "tariffs": ["onpeak", "midpeak", "offpeak", "superpeak"], }, @@ -552,6 +560,7 @@ async def test_restore_state( "native_unit_of_measurement": "kWh", "last_reset": last_reset, "last_period": "7", + "last_valid_state": "None", "status": "paused", }, ), @@ -562,6 +571,7 @@ async def test_restore_state( attributes={ ATTR_STATUS: PAUSED, ATTR_LAST_RESET: last_reset, + ATTR_LAST_VALID_STATE: None, ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, }, ), @@ -571,6 +581,7 @@ async def test_restore_state( "decimal_str": "3", }, "native_unit_of_measurement": "kWh", + "last_valid_state": "None", }, ), ( @@ -580,6 +591,7 @@ async def test_restore_state( attributes={ ATTR_STATUS: COLLECTING, ATTR_LAST_RESET: last_reset, + ATTR_LAST_VALID_STATE: None, ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, }, ), @@ -589,6 +601,7 @@ async def test_restore_state( "decimal_str": "3f", }, "native_unit_of_measurement": "kWh", + "last_valid_state": "None", }, ), ( @@ -598,6 +611,7 @@ async def test_restore_state( attributes={ ATTR_STATUS: COLLECTING, ATTR_LAST_RESET: last_reset, + ATTR_LAST_VALID_STATE: None, ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, }, ), @@ -625,15 +639,18 @@ async def test_restore_state( assert state.state == "3" assert state.attributes.get("status") == PAUSED assert state.attributes.get("last_reset") == last_reset + assert state.attributes.get("last_valid_state") == "None" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR state = hass.states.get("sensor.energy_bill_midpeak") assert state.state == "5" + assert state.attributes.get("last_valid_state") == "None" state = hass.states.get("sensor.energy_bill_offpeak") assert state.state == "6" assert state.attributes.get("status") == COLLECTING assert state.attributes.get("last_reset") == last_reset + assert state.attributes.get("last_valid_state") == "None" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR state = hass.states.get("sensor.energy_bill_superpeak") @@ -675,6 +692,7 @@ async def test_restore_state( "name": "Energy bill", "net_consumption": True, "offset": 0, + "periodically_resetting": True, "source": "sensor.energy", "tariffs": [], }, @@ -829,6 +847,7 @@ async def test_non_net_consumption( "name": "Energy bill", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": "sensor.energy", "tariffs": [], }, @@ -884,7 +903,7 @@ async def test_delta_values( force_update=True, ) await hass.async_block_till_done() - assert "Invalid adjustment of None" in caplog.text + assert "Invalid state None" in caplog.text now += timedelta(seconds=30) with freeze_time(now): @@ -918,6 +937,272 @@ async def test_delta_values( assert state.state == "9" +@pytest.mark.parametrize( + ("yaml_config", "config_entry_config"), + ( + ( + { + "utility_meter": { + "energy_bill": { + "source": "sensor.energy", + "periodically_resetting": False, + } + } + }, + None, + ), + ( + None, + { + "cycle": "none", + "delta_values": False, + "name": "Energy bill", + "net_consumption": False, + "offset": 0, + "periodically_resetting": False, + "source": "sensor.energy", + "tariffs": [], + }, + ), + ), +) +async def test_non_periodically_resetting( + hass: HomeAssistant, yaml_config, config_entry_config +) -> None: + """Test utility meter "non periodically resetting" mode.""" + # Home assistant is not runnit yet + hass.state = CoreState.not_running + + now = dt_util.utcnow() + with freeze_time(now): + if yaml_config: + assert await async_setup_component(hass, DOMAIN, yaml_config) + await hass.async_block_till_done() + entity_id = yaml_config[DOMAIN]["energy_bill"]["source"] + else: + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options=config_entry_config, + title=config_entry_config["name"], + version=2, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + entity_id = config_entry_config["source"] + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + + async_fire_time_changed(hass, now) + hass.states.async_set( + entity_id, 1, {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR} + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill") + assert state.attributes.get("status") == PAUSED + + now += timedelta(seconds=30) + with freeze_time(now): + async_fire_time_changed(hass, now) + hass.states.async_set( + entity_id, + 3, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR}, + force_update=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill") + assert state.state == "2" + assert state.attributes.get("last_valid_state") == "3" + assert state.attributes.get("status") == COLLECTING + + now += timedelta(seconds=30) + with freeze_time(now): + async_fire_time_changed(hass, now) + hass.states.async_set( + entity_id, + STATE_UNKNOWN, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR}, + force_update=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill") + assert state.state == "2" + assert state.attributes.get("last_valid_state") == "3" + assert state.attributes.get("status") == COLLECTING + + now += timedelta(seconds=30) + with freeze_time(now): + async_fire_time_changed(hass, now) + hass.states.async_set( + entity_id, + 6, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR}, + force_update=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill") + assert state.state == "5" + assert state.attributes.get("last_valid_state") == "6" + assert state.attributes.get("status") == COLLECTING + + now += timedelta(seconds=30) + with freeze_time(now): + async_fire_time_changed(hass, now) + await hass.async_block_till_done() + hass.states.async_set( + entity_id, + 9, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR}, + force_update=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill") + assert state.state == "8" + assert state.attributes.get("last_valid_state") == "9" + assert state.attributes.get("status") == COLLECTING + + +@pytest.mark.parametrize( + ("yaml_config", "config_entry_config"), + ( + ( + { + "utility_meter": { + "energy_bill": { + "source": "sensor.energy", + "periodically_resetting": False, + "tariffs": ["low", "high"], + } + } + }, + None, + ), + ( + None, + { + "cycle": "none", + "delta_values": False, + "name": "Energy bill", + "net_consumption": False, + "offset": 0, + "periodically_resetting": False, + "source": "sensor.energy", + "tariffs": ["low", "high"], + }, + ), + ), +) +async def test_non_periodically_resetting_meter_with_tariffs( + hass: HomeAssistant, yaml_config, config_entry_config +) -> None: + """Test test_non_periodically_resetting_meter_with_tariffs.""" + if yaml_config: + assert await async_setup_component(hass, DOMAIN, yaml_config) + await hass.async_block_till_done() + entity_id = yaml_config[DOMAIN]["energy_bill"]["source"] + else: + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options=config_entry_config, + title=config_entry_config["name"], + version=2, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + entity_id = config_entry_config["source"] + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + hass.states.async_set( + entity_id, 2, {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR} + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill_low") + assert state is not None + assert state.state == "0" + assert state.attributes.get("status") == COLLECTING + assert state.attributes.get("last_valid_state") == "2" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR + + state = hass.states.get("sensor.energy_bill_high") + assert state is not None + assert state.state == "0" + assert state.attributes.get("status") == PAUSED + assert state.attributes.get("last_valid_state") == "None" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR + + now = dt_util.utcnow() + timedelta(seconds=10) + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.states.async_set( + entity_id, + 3, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR}, + force_update=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill_low") + assert state is not None + assert state.state == "1" + assert state.attributes.get("last_valid_state") == "3" + assert state.attributes.get("status") == COLLECTING + + state = hass.states.get("sensor.energy_bill_high") + assert state is not None + assert state.state == "0" + assert state.attributes.get("last_valid_state") == "None" + assert state.attributes.get("status") == PAUSED + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.energy_bill", "option": "high"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill_low") + assert state.attributes.get("last_valid_state") == "None" + assert state.attributes.get("status") == PAUSED + + state = hass.states.get("sensor.energy_bill_high") + assert state.attributes.get("last_valid_state") == "None" + assert state.attributes.get("status") == COLLECTING + + now = dt_util.utcnow() + timedelta(seconds=20) + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.states.async_set( + entity_id, + 6, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR}, + force_update=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill_low") + assert state is not None + assert state.state == "1" + assert state.attributes.get("last_valid_state") == "None" + assert state.attributes.get("status") == PAUSED + + state = hass.states.get("sensor.energy_bill_high") + assert state is not None + assert state.state == "3" + assert state.attributes.get("last_valid_state") == "6" + assert state.attributes.get("status") == COLLECTING + + def gen_config(cycle, offset=None): """Generate configuration.""" config = { @@ -932,7 +1217,9 @@ def gen_config(cycle, offset=None): return config -async def _test_self_reset(hass, config, start_time, expect_reset=True): +async def _test_self_reset( + hass: HomeAssistant, config, start_time, expect_reset=True +) -> None: """Test energy sensor self reset.""" now = dt_util.parse_datetime(start_time) with freeze_time(now): @@ -1142,3 +1429,27 @@ async def test_bad_offset(hass: HomeAssistant) -> None: assert not await async_setup_component( hass, DOMAIN, gen_config("monthly", timedelta(days=31)) ) + + +def test_calculate_adjustment_invalid_new_state( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that calculate_adjustment method returns None if the new state is invalid.""" + mock_sensor = UtilityMeterSensor( + cron_pattern=None, + delta_values=False, + meter_offset=DEFAULT_OFFSET, + meter_type=DAILY, + name="Test utility meter", + net_consumption=False, + parent_meter="sensor.test", + periodically_resetting=True, + unique_id="test_utility_meter", + source_entity="sensor.test", + tariff=None, + tariff_entity=None, + ) + + new_state: State = State(entity_id="sensor.test", state="unknown") + assert mock_sensor.calculate_adjustment(None, new_state) is None + assert "Invalid state unknown" in caplog.text From d907bd2ca3980f9f065273c0591a925e6b22c393 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 28 Mar 2023 17:09:59 +0200 Subject: [PATCH 1388/2810] Add connected relayer region to system health (#90410) --- homeassistant/components/cloud/client.py | 11 +++++++++++ homeassistant/components/cloud/manifest.json | 2 +- homeassistant/components/cloud/strings.json | 1 + .../components/cloud/system_health.py | 1 + homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/cloud/test_client.py | 19 +++++++++++++++++++ tests/components/cloud/test_system_health.py | 4 +++- 9 files changed, 39 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 08d4364424..900779f6b0 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -47,6 +47,7 @@ class CloudClient(Interface): self._google_config: google_config.CloudGoogleConfig | None = None self._alexa_config_init_lock = asyncio.Lock() self._google_config_init_lock = asyncio.Lock() + self._relayer_region: str | None = None @property def base_path(self) -> Path: @@ -84,6 +85,11 @@ class CloudClient(Interface): """Return true if we want start a remote connection.""" return self._prefs.remote_enabled + @property + def relayer_region(self) -> str | None: + """Return the connected relayer region.""" + return self._relayer_region + async def get_alexa_config(self) -> alexa_config.CloudAlexaConfig: """Return Alexa config.""" if self._alexa_config is None: @@ -256,6 +262,11 @@ class CloudClient(Interface): "headers": {"Content-Type": response.content_type}, } + async def async_system_message(self, payload: dict[Any, Any] | None) -> None: + """Handle system messages.""" + if payload and (region := payload.get("region")): + self._relayer_region = region + async def async_cloudhooks_update(self, data: dict[str, dict[str, str]]) -> None: """Update local list of cloudhooks.""" await self._prefs.async_update(cloudhooks=data) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 7bd4a822fb..2bff400366 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.62.0"] + "requirements": ["hass-nabucasa==0.63.1"] } diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index e437fca9ed..432a4db0f7 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -5,6 +5,7 @@ "can_reach_cloud": "Reach Home Assistant Cloud", "can_reach_cloud_auth": "Reach Authentication Server", "relayer_connected": "Relayer Connected", + "relayer_region": "Relayer Region", "remote_connected": "Remote Connected", "remote_enabled": "Remote Enabled", "remote_server": "Remote Server", diff --git a/homeassistant/components/cloud/system_health.py b/homeassistant/components/cloud/system_health.py index 9f836114b3..b1f1774aa4 100644 --- a/homeassistant/components/cloud/system_health.py +++ b/homeassistant/components/cloud/system_health.py @@ -28,6 +28,7 @@ async def system_health_info(hass): if cloud.is_logged_in: data["subscription_expiration"] = cloud.expiration_date data["relayer_connected"] = cloud.is_connected + data["relayer_region"] = client.relayer_region data["remote_enabled"] = client.prefs.remote_enabled data["remote_connected"] = cloud.remote.is_connected data["alexa_enabled"] = client.prefs.alexa_enabled diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 518bea69fb..dea105f296 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ cryptography==40.0.1 dbus-fast==1.84.2 fnvhash==0.1.0 ha-av==10.0.0 -hass-nabucasa==0.62.0 +hass-nabucasa==0.63.1 hassil==1.0.6 home-assistant-bluetooth==1.9.3 home-assistant-frontend==20230309.1 diff --git a/requirements_all.txt b/requirements_all.txt index 46599fbafe..27a50ca564 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -868,7 +868,7 @@ ha-philipsjs==3.0.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.62.0 +hass-nabucasa==0.63.1 # homeassistant.components.splunk hass_splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7b41d46936..f4a9437f9b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -666,7 +666,7 @@ ha-philipsjs==3.0.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.62.0 +hass-nabucasa==0.63.1 # homeassistant.components.conversation hassil==1.0.6 diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index 9f463803b8..b7bfed53aa 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -312,3 +312,22 @@ async def test_login_recovers_bad_internet( await hass.async_block_till_done() assert len(client._alexa_config.async_enable_proactive_mode.mock_calls) == 2 + + +async def test_system_msg(hass: HomeAssistant) -> None: + """Test system msg.""" + with patch("hass_nabucasa.Cloud.initialize"): + setup = await async_setup_component(hass, "cloud", {"cloud": {}}) + assert setup + cloud = hass.data["cloud"] + + assert cloud.client.relayer_region is None + + response = await cloud.client.async_system_message( + { + "region": "xx-earth-616", + } + ) + + assert response is None + assert cloud.client.relayer_region == "xx-earth-616" diff --git a/tests/components/cloud/test_system_health.py b/tests/components/cloud/test_system_health.py index b2b74d8929..96b87936da 100644 --- a/tests/components/cloud/test_system_health.py +++ b/tests/components/cloud/test_system_health.py @@ -36,11 +36,12 @@ async def test_cloud_system_health( expiration_date=now, is_connected=True, client=Mock( + relayer_region="xx-earth-616", prefs=Mock( remote_enabled=True, alexa_enabled=True, google_enabled=False, - ) + ), ), ) @@ -54,6 +55,7 @@ async def test_cloud_system_health( "logged_in": True, "subscription_expiration": now, "relayer_connected": True, + "relayer_region": "xx-earth-616", "remote_enabled": True, "remote_connected": False, "remote_server": "us-west-1", From 0a51914740c8fc4810f8cd4625c62e3d01b1d548 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Huryn?= Date: Tue, 28 Mar 2023 17:11:48 +0200 Subject: [PATCH 1389/2810] Blebox cover tilt (#85515) * feature: added tilt for shutterBox * test: include tilt in tests --- homeassistant/components/blebox/cover.py | 17 ++++++++ tests/components/blebox/test_cover.py | 49 ++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/homeassistant/components/blebox/cover.py b/homeassistant/components/blebox/cover.py index 80e2fbd30e..658a9bc30c 100644 --- a/homeassistant/components/blebox/cover.py +++ b/homeassistant/components/blebox/cover.py @@ -8,6 +8,7 @@ import blebox_uniapi.cover from homeassistant.components.cover import ( ATTR_POSITION, + ATTR_TILT_POSITION, CoverDeviceClass, CoverEntity, CoverEntityFeature, @@ -67,6 +68,10 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity): self._attr_supported_features = ( position | stop | CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE ) + if feature.has_tilt: + self._attr_supported_features = ( + self._attr_supported_features | CoverEntityFeature.SET_TILT_POSITION + ) @property def current_cover_position(self) -> int | None: @@ -77,6 +82,12 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity): return None if position is None else 100 - position + @property + def current_cover_tilt_position(self) -> int | None: + """Return the current tilt of shutter.""" + position = self._feature.tilt_current + return None if position is None else 100 - position + @property def is_opening(self) -> bool | None: """Return whether cover is opening.""" @@ -110,6 +121,12 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity): """Stop the cover.""" await self._feature.async_stop() + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: + """Set the tilt position.""" + + position = kwargs[ATTR_TILT_POSITION] + await self._feature.async_set_tilt_position(100 - position) + def _is_state(self, state_name) -> bool | None: value = BLEBOX_TO_HASS_COVER_STATES[self._feature.state] return None if value is None else value == state_name diff --git a/tests/components/blebox/test_cover.py b/tests/components/blebox/test_cover.py index ce7006951f..d0a10cb5bd 100644 --- a/tests/components/blebox/test_cover.py +++ b/tests/components/blebox/test_cover.py @@ -7,7 +7,9 @@ import pytest from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, ATTR_POSITION, + ATTR_TILT_POSITION, STATE_CLOSED, STATE_CLOSING, STATE_OPEN, @@ -21,6 +23,7 @@ from homeassistant.const import ( SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, SERVICE_STOP_COVER, STATE_UNKNOWN, ) @@ -43,8 +46,10 @@ def shutterbox_fixture(): full_name="shutterBox-position", device_class="shutter", current=None, + tilt_current=None, state=None, has_stop=True, + has_tilt=True, is_slider=True, ) product = feature.product @@ -420,3 +425,47 @@ async def test_closed_state(feature, hass: HomeAssistant) -> None: feature_mock.async_update = AsyncMock(side_effect=initial_update) await async_setup_entity(hass, entity_id) assert hass.states.get(entity_id).state == STATE_CLOSED + + +async def test_tilt_position(shutterbox, hass): + """Test tilt capability is available.""" + + feature_mock, entity_id = shutterbox + + def tilt_update(): + feature_mock.tilt_current = 90 + + feature_mock.async_update = AsyncMock(side_effect=tilt_update) + + await async_setup_entity(hass, entity_id) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 10 + + +async def test_set_tilt_position(shutterbox, hass): + """Test tilt position setting.""" + + feature_mock, entity_id = shutterbox + + def initial_update(): + feature_mock.state = 3 + + def set_tilt(tilt_position): + assert tilt_position == 20 + feature_mock.state = 1 + + feature_mock.async_update = AsyncMock(side_effect=initial_update) + feature_mock.async_set_tilt_position = AsyncMock(side_effect=set_tilt) + + await async_setup_entity(hass, entity_id) + assert hass.states.get(entity_id).state == STATE_CLOSED + + feature_mock.async_update = AsyncMock() + await hass.services.async_call( + "cover", + SERVICE_SET_COVER_TILT_POSITION, + {"entity_id": entity_id, ATTR_TILT_POSITION: 80}, + blocking=True, + ) + assert hass.states.get(entity_id).state == STATE_OPENING From 9fecdddf0114079676c45d1e4c593473ee11e016 Mon Sep 17 00:00:00 2001 From: DerEnderKeks Date: Tue, 28 Mar 2023 17:14:52 +0200 Subject: [PATCH 1390/2810] Don't use force_update for Tasmota sensors (#85943) * fix: don't use force_update for Tasmota sensors * Update binary_sensor.py * Update test_binary_sensor.py * Update test_sensor.py --------- Co-authored-by: Erik Montnemery --- homeassistant/components/tasmota/binary_sensor.py | 8 ++++---- homeassistant/components/tasmota/sensor.py | 1 - tests/components/tasmota/test_binary_sensor.py | 2 +- tests/components/tasmota/test_sensor.py | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/tasmota/binary_sensor.py b/homeassistant/components/tasmota/binary_sensor.py index 2bc23655a2..d84087b313 100644 --- a/homeassistant/components/tasmota/binary_sensor.py +++ b/homeassistant/components/tasmota/binary_sensor.py @@ -58,17 +58,17 @@ class TasmotaBinarySensor( ): """Representation a Tasmota binary sensor.""" - _attr_force_update = True + _delay_listener: Callable | None = None + _on_off_state: bool | None = None _tasmota_entity: tasmota_switch.TasmotaSwitch def __init__(self, **kwds: Any) -> None: """Initialize the Tasmota binary sensor.""" - self._delay_listener: Callable | None = None - self._on_off_state: bool | None = None - super().__init__( **kwds, ) + if self._tasmota_entity.off_delay is not None: + self._attr_force_update = True async def async_added_to_hass(self) -> None: """Subscribe to MQTT events.""" diff --git a/homeassistant/components/tasmota/sensor.py b/homeassistant/components/tasmota/sensor.py index 61c03b707c..ddcdb3e8c2 100644 --- a/homeassistant/components/tasmota/sensor.py +++ b/homeassistant/components/tasmota/sensor.py @@ -268,7 +268,6 @@ async def async_setup_entry( class TasmotaSensor(TasmotaAvailability, TasmotaDiscoveryUpdate, SensorEntity): """Representation of a Tasmota sensor.""" - _attr_force_update = True _tasmota_entity: tasmota_sensor.TasmotaSensor def __init__(self, **kwds: Any) -> None: diff --git a/tests/components/tasmota/test_binary_sensor.py b/tests/components/tasmota/test_binary_sensor.py index 8b3607bb9f..6a82a0f0e7 100644 --- a/tests/components/tasmota/test_binary_sensor.py +++ b/tests/components/tasmota/test_binary_sensor.py @@ -106,7 +106,7 @@ async def test_controlling_state_via_mqtt( entity = hass.data["entity_components"]["binary_sensor"].get_entity( "binary_sensor.tasmota_binary_sensor_1" ) - assert entity.force_update + assert not entity.force_update async def test_controlling_state_via_mqtt_switchname( diff --git a/tests/components/tasmota/test_sensor.py b/tests/components/tasmota/test_sensor.py index 3a715ea95e..7eee8fcbe7 100644 --- a/tests/components/tasmota/test_sensor.py +++ b/tests/components/tasmota/test_sensor.py @@ -545,7 +545,7 @@ async def test_status_sensor_state_via_mqtt( entity = hass.data["entity_components"]["sensor"].get_entity( "sensor.tasmota_status" ) - assert entity.force_update + assert not entity.force_update @pytest.mark.parametrize("status_sensor_disabled", [False]) From 4b3c1f2800ee18e3c3da1fff1e138daf8c79abd0 Mon Sep 17 00:00:00 2001 From: Dmitry Vlasov Date: Tue, 28 Mar 2023 18:24:19 +0300 Subject: [PATCH 1391/2810] Update zwave-me-ws version to 0.3.6 (#90233) --- homeassistant/components/zwave_me/__init__.py | 35 ++++++++++++++++++- .../components/zwave_me/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zwave_me/__init__.py b/homeassistant/components/zwave_me/__init__.py index 346831b34d..1740820d0b 100644 --- a/homeassistant/components/zwave_me/__init__.py +++ b/homeassistant/components/zwave_me/__init__.py @@ -50,6 +50,8 @@ class ZWaveMeController: self.zwave_api = ZWaveMe( on_device_create=self.on_device_create, on_device_update=self.on_device_update, + on_device_remove=self.on_device_unavailable, + on_device_destroy=self.on_device_destroy, on_new_device=self.add_device, token=self.config.data[CONF_TOKEN], url=self.config.data[CONF_URL], @@ -82,6 +84,14 @@ class ZWaveMeController: """Send signal to update device.""" dispatcher_send(self._hass, f"ZWAVE_ME_INFO_{new_info.id}", new_info) + def on_device_unavailable(self, device_id: str) -> None: + """Send signal to set device unavailable.""" + dispatcher_send(self._hass, f"ZWAVE_ME_UNAVAILABLE_{device_id}") + + def on_device_destroy(self, device_id: str) -> None: + """Send signal to destroy device.""" + dispatcher_send(self._hass, f"ZWAVE_ME_DESTROY_{device_id}") + def remove_stale_devices(self, registry: dr.DeviceRegistry): """Remove old-format devices in the registry.""" for device_id in self.device_ids: @@ -133,10 +143,33 @@ class ZWaveMeEntity(Entity): self.hass, f"ZWAVE_ME_INFO_{self.device.id}", self.get_new_data ) ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"ZWAVE_ME_UNAVAILABLE_{self.device.id}", + self.set_unavailable_status, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, f"ZWAVE_ME_DESTROY_{self.device.id}", self.delete_entity + ) + ) @callback - def get_new_data(self, new_data): + def get_new_data(self, new_data: ZWaveMeData) -> None: """Update info in the HAss.""" self.device = new_data self._attr_available = not new_data.isFailed self.async_write_ha_state() + + @callback + def set_unavailable_status(self): + """Update status in the HAss.""" + self._attr_available = False + self.async_write_ha_state() + + @callback + def delete_entity(self) -> None: + """Remove this entity.""" + self.hass.async_create_task(self.async_remove(force_remove=True)) diff --git a/homeassistant/components/zwave_me/manifest.json b/homeassistant/components/zwave_me/manifest.json index 6339015968..388a8c2c1d 100644 --- a/homeassistant/components/zwave_me/manifest.json +++ b/homeassistant/components/zwave_me/manifest.json @@ -6,7 +6,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_me", "iot_class": "local_push", - "requirements": ["zwave_me_ws==0.3.1", "url-normalize==1.4.3"], + "requirements": ["zwave_me_ws==0.3.6", "url-normalize==1.4.3"], "zeroconf": [ { "type": "_hap._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 27a50ca564..4c5dec6d8c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2731,4 +2731,4 @@ zm-py==0.5.2 zwave-js-server-python==0.47.0 # homeassistant.components.zwave_me -zwave_me_ws==0.3.1 +zwave_me_ws==0.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f4a9437f9b..5ab64c066c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1956,4 +1956,4 @@ zigpy==0.53.2 zwave-js-server-python==0.47.0 # homeassistant.components.zwave_me -zwave_me_ws==0.3.1 +zwave_me_ws==0.3.6 From 89a3c304c2b9216510600ed66e2a064fb161e76d Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Tue, 28 Mar 2023 18:39:10 +0200 Subject: [PATCH 1392/2810] Refactor ZHA binary sensors to read from zigpy cache (#89481) * Construct binary sensor state from zigpy cache (WIP) * Workaround zha-quirks issue where "MotionWithReset" quirks don't update attribute cache (WIP) zha-quirks currently has an issue where the ZONE_STATE attribute is updated (when the zone_STATUS changes). https://github.com/zigpy/zha-device-handlers/pull/2231 is a proper fix for this. For now, we just update the attribute cache when we get the "zone status update notification" command. This wasn't noticed before, as the "attribute report signal" was sent from the `cluster_command()` method and the used the provided attribute (in the signal) to update the `_state` value in the binary sensor class. As we just tell HA to write state again when we get an attribute report now, the ZONE_STATUS attribute is read now (and needs to be correct). * Use parse() method of main class for IasZone entity (with stripped bits) * Change wording in comment, remove explicitly sending attr signal (This comment should be removed/changed later anyway) * Remove note * Get zone_status attribute id with zigpy * Remove `security.` prefix for `IasZone` import `AceCluster` was already directly imported and `IasZone` is too now for getting the attribute id * Store full zone status attribute in cache * Check that non-alarm bits are ignored in IasZone sensor test * Re-enable occupancy binary sensor test This test seems to work fine and I don't see any reason why it was commented out for a while * Fix cached read mix-up for `zone_status`/`zone_state` This allows cached reads for `zone_state` (enrolled or not), but forces a new read for `zone_status` (alarm or not). --- homeassistant/components/zha/binary_sensor.py | 37 ++++++------------- .../components/zha/core/channels/security.py | 20 +++++----- tests/components/zha/test_binary_sensor.py | 7 +++- 3 files changed, 28 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index b6a0af8e45..9c2fb49de6 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_ON, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -76,34 +76,23 @@ class BinarySensor(ZhaEntity, BinarySensorEntity): self._channel, SIGNAL_ATTR_UPDATED, self.async_set_state ) - @callback - def async_restore_last_state(self, last_state): - """Restore previous state.""" - super().async_restore_last_state(last_state) - self._state = last_state.state == STATE_ON - @property def is_on(self) -> bool: """Return True if the switch is on based on the state machine.""" - if self._state is None: + raw_state = self._channel.cluster.get(self.SENSOR_ATTR) + if raw_state is None: return False - return self._state + return self.parse(raw_state) @callback def async_set_state(self, attr_id, attr_name, value): """Set the state.""" - if self.SENSOR_ATTR is None or attr_name != self.SENSOR_ATTR: - return - self._state = bool(value) self.async_write_ha_state() - async def async_update(self) -> None: - """Attempt to retrieve on off state from the binary sensor.""" - await super().async_update() - attribute = getattr(self._channel, "value_attribute", "on_off") - attr_value = await self._channel.get_attribute_value(attribute) - if attr_value is not None: - self._state = attr_value + @staticmethod + def parse(value: bool | int) -> bool: + """Parse the raw attribute into a bool state.""" + return bool(value) @MULTI_MATCH(channel_names=CHANNEL_ACCELEROMETER) @@ -167,12 +156,10 @@ class IASZone(BinarySensor): """Return device class from component DEVICE_CLASSES.""" return CLASS_MAPPING.get(self._channel.cluster.get("zone_type")) - async def async_update(self) -> None: - """Attempt to retrieve on off state from the binary sensor.""" - await super().async_update() - value = await self._channel.get_attribute_value("zone_status") - if value is not None: - self._state = value & 3 + @staticmethod + def parse(value: bool | int) -> bool: + """Parse the raw attribute into a bool state.""" + return BinarySensor.parse(value & 3) # use only bit 0 and 1 for alarm state @MULTI_MATCH( diff --git a/homeassistant/components/zha/core/channels/security.py b/homeassistant/components/zha/core/channels/security.py index b5a8d5d8cf..404e4a8d25 100644 --- a/homeassistant/components/zha/core/channels/security.py +++ b/homeassistant/components/zha/core/channels/security.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any from zigpy.exceptions import ZigbeeException import zigpy.zcl from zigpy.zcl.clusters import security -from zigpy.zcl.clusters.security import IasAce as AceCluster +from zigpy.zcl.clusters.security import IasAce as AceCluster, IasZone from homeassistant.core import callback @@ -332,21 +332,22 @@ class IasWd(ZigbeeChannel): ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(security.IasZone.cluster_id) +@registries.ZIGBEE_CHANNEL_REGISTRY.register(IasZone.cluster_id) class IASZoneChannel(ZigbeeChannel): """Channel for the IASZone Zigbee cluster.""" - ZCL_INIT_ATTRS = {"zone_status": True, "zone_state": False, "zone_type": True} + ZCL_INIT_ATTRS = {"zone_status": False, "zone_state": True, "zone_type": True} @callback def cluster_command(self, tsn, command_id, args): """Handle commands received to this cluster.""" if command_id == 0: - state = args[0] & 3 - self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", 2, "zone_status", state + zone_status = args[0] + # update attribute cache with new zone status + self.cluster.update_attribute( + IasZone.attributes_by_name["zone_status"].id, zone_status ) - self.debug("Updated alarm state: %s", state) + self.debug("Updated alarm state: %s", zone_status) elif command_id == 1: self.debug("Enroll requested") res = self._cluster.enroll_response(0, 0) @@ -389,11 +390,10 @@ class IASZoneChannel(ZigbeeChannel): @callback def attribute_updated(self, attrid, value): """Handle attribute updates on this cluster.""" - if attrid == 2: - value = value & 3 + if attrid == IasZone.attributes_by_name["zone_status"].id: self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, - self.cluster.attributes.get(attrid, [attrid])[0], + "zone_status", value, ) diff --git a/tests/components/zha/test_binary_sensor.py b/tests/components/zha/test_binary_sensor.py index 58264bf666..d633e9173e 100644 --- a/tests/components/zha/test_binary_sensor.py +++ b/tests/components/zha/test_binary_sensor.py @@ -75,12 +75,17 @@ async def async_test_iaszone_on_off(hass, cluster, entity_id): await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_OFF + # check that binary sensor remains off when non-alarm bits change + cluster.listener_event("cluster_command", 1, 0, [0b1111111100]) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_OFF + @pytest.mark.parametrize( ("device", "on_off_test", "cluster_name", "reporting"), [ (DEVICE_IAS, async_test_iaszone_on_off, "ias_zone", (0,)), - # (DEVICE_OCCUPANCY, async_test_binary_sensor_on_off, "occupancy", (1,)), + (DEVICE_OCCUPANCY, async_test_binary_sensor_on_off, "occupancy", (1,)), ], ) async def test_binary_sensor( From 9ccd43e5f1fe6e3e01a3f61d77475dc27e6ee5bc Mon Sep 17 00:00:00 2001 From: Aaron Godfrey Date: Tue, 28 Mar 2023 09:57:24 -0700 Subject: [PATCH 1393/2810] Add DataUpdateCoordinator to the Todoist integration (#89836) Co-authored-by: Franck Nijhof --- homeassistant/components/todoist/calendar.py | 28 ++++++++++------- .../components/todoist/coordinator.py | 31 +++++++++++++++++++ tests/components/todoist/test_calendar.py | 24 ++++++++++++++ 3 files changed, 72 insertions(+), 11 deletions(-) create mode 100644 homeassistant/components/todoist/coordinator.py diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 645fea865e..c3e8f61fcc 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -23,6 +23,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt from .const import ( @@ -54,6 +55,7 @@ from .const import ( START, SUMMARY, ) +from .coordinator import TodoistCoordinator from .types import CalData, CustomProject, ProjectData, TodoistEvent _LOGGER = logging.getLogger(__name__) @@ -117,6 +119,8 @@ async def async_setup_platform( project_id_lookup = {} api = TodoistAPIAsync(token) + coordinator = TodoistCoordinator(hass, _LOGGER, SCAN_INTERVAL, api) + await coordinator.async_config_entry_first_refresh() # Setup devices: # Grab all projects. @@ -131,7 +135,7 @@ async def async_setup_platform( # Project is an object, not a dict! # Because of that, we convert what we need to a dict. project_data: ProjectData = {CONF_NAME: project.name, CONF_ID: project.id} - project_devices.append(TodoistProjectEntity(project_data, labels, api)) + project_devices.append(TodoistProjectEntity(coordinator, project_data, labels)) # Cache the names so we can easily look up name->ID. project_id_lookup[project.name.lower()] = project.id @@ -157,9 +161,9 @@ async def async_setup_platform( # Create the custom project and add it to the devices array. project_devices.append( TodoistProjectEntity( + coordinator, {"id": None, "name": extra_project["name"]}, labels, - api, due_date_days=project_due_date, whitelisted_labels=project_label_filter, whitelisted_projects=project_id_filter, @@ -267,23 +271,24 @@ async def async_setup_platform( ) -class TodoistProjectEntity(CalendarEntity): +class TodoistProjectEntity(CoordinatorEntity[TodoistCoordinator], CalendarEntity): """A device for getting the next Task from a Todoist Project.""" def __init__( self, + coordinator: TodoistCoordinator, data: ProjectData, labels: list[Label], - api: TodoistAPIAsync, due_date_days: int | None = None, whitelisted_labels: list[str] | None = None, whitelisted_projects: list[str] | None = None, ) -> None: """Create the Todoist Calendar Entity.""" + super().__init__(coordinator=coordinator) self.data = TodoistProjectData( data, labels, - api, + coordinator, due_date_days=due_date_days, whitelisted_labels=whitelisted_labels, whitelisted_projects=whitelisted_projects, @@ -306,6 +311,7 @@ class TodoistProjectEntity(CalendarEntity): async def async_update(self) -> None: """Update all Todoist Calendars.""" + await super().async_update() await self.data.async_update() # Set Todoist-specific data that can't easily be grabbed self._cal_data["all_tasks"] = [ @@ -373,7 +379,7 @@ class TodoistProjectData: self, project_data: ProjectData, labels: list[Label], - api: TodoistAPIAsync, + coordinator: TodoistCoordinator, due_date_days: int | None = None, whitelisted_labels: list[str] | None = None, whitelisted_projects: list[str] | None = None, @@ -381,7 +387,7 @@ class TodoistProjectData: """Initialize a Todoist Project.""" self.event: TodoistEvent | None = None - self._api = api + self._coordinator = coordinator self._name = project_data[CONF_NAME] # If no ID is defined, fetch all tasks. self._id = project_data.get(CONF_ID) @@ -569,8 +575,8 @@ class TodoistProjectData: self, start_date: datetime, end_date: datetime ) -> list[CalendarEvent]: """Get all tasks in a specific time frame.""" + tasks = self._coordinator.data if self._id is None: - tasks = await self._api.get_tasks() project_task_data = [ task for task in tasks @@ -578,7 +584,7 @@ class TodoistProjectData: or task.project_id in self._project_id_whitelist ] else: - project_task_data = await self._api.get_tasks(project_id=self._id) + project_task_data = [task for task in tasks if task.project_id == self._id] events = [] for task in project_task_data: @@ -607,8 +613,8 @@ class TodoistProjectData: async def async_update(self) -> None: """Get the latest data.""" + tasks = self._coordinator.data if self._id is None: - tasks = await self._api.get_tasks() project_task_data = [ task for task in tasks @@ -616,7 +622,7 @@ class TodoistProjectData: or task.project_id in self._project_id_whitelist ] else: - project_task_data = await self._api.get_tasks(project_id=self._id) + project_task_data = [task for task in tasks if task.project_id == self._id] # If we have no data, we can just return right away. if not project_task_data: diff --git a/homeassistant/components/todoist/coordinator.py b/homeassistant/components/todoist/coordinator.py new file mode 100644 index 0000000000..b573d1d112 --- /dev/null +++ b/homeassistant/components/todoist/coordinator.py @@ -0,0 +1,31 @@ +"""DataUpdateCoordinator for the Todoist component.""" +from datetime import timedelta +import logging + +from todoist_api_python.api_async import TodoistAPIAsync +from todoist_api_python.models import Task + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + + +class TodoistCoordinator(DataUpdateCoordinator[list[Task]]): + """Coordinator for updating task data from Todoist.""" + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + update_interval: timedelta, + api: TodoistAPIAsync, + ) -> None: + """Initialize the Todoist coordinator.""" + super().__init__(hass, logger, name="Todoist", update_interval=update_interval) + self.api = api + + async def _async_update_data(self) -> list[Task]: + """Fetch tasks from the Todoist API.""" + try: + return await self.api.get_tasks() + except Exception as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index 9c0680d144..4f792b3cc0 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -132,6 +132,30 @@ async def test_update_entity_for_custom_project_with_labels_on( assert state.state == "on" +@patch("homeassistant.components.todoist.calendar.TodoistAPIAsync") +async def test_failed_coordinator_update(todoist_api, hass: HomeAssistant, api) -> None: + """Test a failed data coordinator update is handled correctly.""" + api.get_tasks.side_effect = Exception("API error") + todoist_api.return_value = api + + assert await setup.async_setup_component( + hass, + "calendar", + { + "calendar": { + "platform": DOMAIN, + CONF_TOKEN: "token", + "custom_projects": [{"name": "All projects", "labels": ["Label1"]}], + } + }, + ) + await hass.async_block_till_done() + + await async_update_entity(hass, "calendar.all_projects") + state = hass.states.get("calendar.all_projects") + assert state is None + + @patch("homeassistant.components.todoist.calendar.TodoistAPIAsync") async def test_calendar_custom_project_unique_id( todoist_api, hass: HomeAssistant, api, entity_registry: er.EntityRegistry From d21433b6af99a2223c7895b0300d5e9ae9018238 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 08:50:10 -1000 Subject: [PATCH 1394/2810] Ensure filters are generated inside the lambda locks (#90418) --- .../components/logbook/queries/__init__.py | 7 +----- .../components/logbook/queries/all.py | 25 ++++++++----------- homeassistant/components/recorder/filters.py | 14 ++++++----- .../components/recorder/history/legacy.py | 8 +++--- .../components/recorder/history/modern.py | 10 +++++--- tests/components/recorder/test_filters.py | 24 ++++++++++++++++++ 6 files changed, 54 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/logbook/queries/__init__.py b/homeassistant/components/logbook/queries/__init__.py index 0172700df4..b83f7a4428 100644 --- a/homeassistant/components/logbook/queries/__init__.py +++ b/homeassistant/components/logbook/queries/__init__.py @@ -34,16 +34,11 @@ def statement_for_request( # limited by the context_id and the yaml configured filter if not entity_ids and not device_ids: context_id_bin = ulid_to_bytes_or_none(context_id) - states_entity_filter = ( - filters.states_metadata_entity_filter() if filters else None - ) - events_entity_filter = filters.events_entity_filter() if filters else None return all_stmt( start_day, end_day, event_types, - states_entity_filter, - events_entity_filter, + filters, context_id_bin, ) diff --git a/homeassistant/components/logbook/queries/all.py b/homeassistant/components/logbook/queries/all.py index 8c37bf22da..70214fbb04 100644 --- a/homeassistant/components/logbook/queries/all.py +++ b/homeassistant/components/logbook/queries/all.py @@ -2,7 +2,6 @@ from __future__ import annotations from sqlalchemy import lambda_stmt -from sqlalchemy.sql.elements import ColumnElement from sqlalchemy.sql.lambdas import StatementLambdaElement from sqlalchemy.sql.selectable import Select @@ -11,6 +10,7 @@ from homeassistant.components.recorder.db_schema import ( Events, States, ) +from homeassistant.components.recorder.filters import Filters from .common import apply_states_filters, select_events_without_states, select_states @@ -19,8 +19,7 @@ def all_stmt( start_day: float, end_day: float, event_types: tuple[str, ...], - states_entity_filter: ColumnElement | None = None, - events_entity_filter: ColumnElement | None = None, + filters: Filters | None, context_id_bin: bytes | None = None, ) -> StatementLambdaElement: """Generate a logbook query for all entities.""" @@ -36,19 +35,17 @@ def all_stmt( context_id_bin, # type:ignore[arg-type] ), ) - else: - if events_entity_filter is not None: - stmt += lambda s: s.where(events_entity_filter) - - if states_entity_filter is not None: - stmt += lambda s: s.union_all( + elif filters and filters.has_config: + stmt = stmt.add_criteria( + lambda q: q.filter(filters.events_entity_filter()).union_all( # type: ignore[union-attr] _states_query_for_all(start_day, end_day).where( - # https://github.com/python/mypy/issues/2608 - states_entity_filter # type:ignore[arg-type] + filters.states_metadata_entity_filter() # type: ignore[union-attr] ) - ) - else: - stmt += lambda s: s.union_all(_states_query_for_all(start_day, end_day)) + ), + track_on=[filters], + ) + else: + stmt += lambda s: s.union_all(_states_query_for_all(start_day, end_day)) stmt += lambda s: s.order_by(Events.time_fired_ts) return stmt diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index 63eed2d145..de0929cf9f 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -125,8 +125,8 @@ class Filters: def _generate_filter_for_columns( self, columns: Iterable[Column], encoder: Callable[[Any], Any] - ) -> ColumnElement | None: - """Generate a filter from pre-comuted sets and pattern lists. + ) -> ColumnElement: + """Generate a filter from pre-computed sets and pattern lists. This must match exactly how homeassistant.helpers.entityfilter works. """ @@ -146,7 +146,9 @@ class Filters: # Case 1 - No filter # - All entities included if not have_include and not have_exclude: - return None + raise RuntimeError( + "No filter configuration provided, check has_config before calling this method." + ) # Case 2 - Only includes # - Entity listed in entities include: include @@ -193,7 +195,7 @@ class Filters: # - Otherwise: exclude return i_entities - def states_entity_filter(self) -> ColumnElement | None: + def states_entity_filter(self) -> ColumnElement: """Generate the States.entity_id filter query. This is no longer used except by the legacy queries. @@ -206,7 +208,7 @@ class Filters: # The type annotation should be improved so the type ignore can be removed return self._generate_filter_for_columns((States.entity_id,), _encoder) # type: ignore[arg-type] - def states_metadata_entity_filter(self) -> ColumnElement | None: + def states_metadata_entity_filter(self) -> ColumnElement: """Generate the StatesMeta.entity_id filter query.""" def _encoder(data: Any) -> Any: @@ -232,7 +234,7 @@ class Filters: (OLD_ENTITY_ID_IN_EVENT == JSON_NULL) | OLD_ENTITY_ID_IN_EVENT.is_(None) ), # Needs https://github.com/bdraco/home-assistant/commit/bba91945006a46f3a01870008eb048e4f9cbb1ef - self._generate_filter_for_columns( # type: ignore[union-attr] + self._generate_filter_for_columns( (ENTITY_ID_IN_EVENT, OLD_ENTITY_ID_IN_EVENT), _encoder # type: ignore[arg-type] ).self_group(), ) diff --git a/homeassistant/components/recorder/history/legacy.py b/homeassistant/components/recorder/history/legacy.py index e51b1a2568..c33825a767 100644 --- a/homeassistant/components/recorder/history/legacy.py +++ b/homeassistant/components/recorder/history/legacy.py @@ -306,9 +306,8 @@ def _significant_states_stmt( else: stmt += _ignore_domains_filter if filters and filters.has_config: - entity_filter = filters.states_entity_filter() stmt = stmt.add_criteria( - lambda q: q.filter(entity_filter), track_on=[filters] + lambda q: q.filter(filters.states_entity_filter()), track_on=[filters] # type: ignore[union-attr] ) if schema_version >= 31: @@ -713,8 +712,9 @@ def _get_states_for_all_stmt( ) stmt += _ignore_domains_filter if filters and filters.has_config: - entity_filter = filters.states_entity_filter() - stmt = stmt.add_criteria(lambda q: q.filter(entity_filter), track_on=[filters]) + stmt = stmt.add_criteria( + lambda q: q.filter(filters.states_entity_filter()), track_on=[filters] # type: ignore[union-attr] + ) if join_attributes: stmt += lambda q: q.outerjoin( StateAttributes, (States.attributes_id == StateAttributes.attributes_id) diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index 22bfdc3ee9..f7d08c6bba 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -192,9 +192,9 @@ def _significant_states_stmt( else: stmt += _ignore_domains_filter if filters and filters.has_config: - entity_filter = filters.states_metadata_entity_filter() stmt = stmt.add_criteria( - lambda q: q.filter(entity_filter), track_on=[filters] + lambda q: q.filter(filters.states_metadata_entity_filter()), # type: ignore[union-attr] + track_on=[filters], ) join_states_meta = True @@ -567,8 +567,10 @@ def _get_states_for_all_stmt( ) stmt += _ignore_domains_filter if filters and filters.has_config: - entity_filter = filters.states_metadata_entity_filter() - stmt = stmt.add_criteria(lambda q: q.filter(entity_filter), track_on=[filters]) + stmt = stmt.add_criteria( + lambda q: q.filter(filters.states_metadata_entity_filter()), # type: ignore[union-attr] + track_on=[filters], + ) if join_attributes: stmt += lambda q: q.outerjoin( StateAttributes, (States.attributes_id == StateAttributes.attributes_id) diff --git a/tests/components/recorder/test_filters.py b/tests/components/recorder/test_filters.py index 7f7d12364e..13a2a325f1 100644 --- a/tests/components/recorder/test_filters.py +++ b/tests/components/recorder/test_filters.py @@ -1,6 +1,9 @@ """The tests for recorder filters.""" +import pytest + from homeassistant.components.recorder.filters import ( + Filters, extract_include_exclude_filter_conf, merge_include_exclude_filters, ) @@ -132,3 +135,24 @@ def test_merge_include_exclude_filters() -> None: CONF_ENTITY_GLOBS: {"climate.*", "not_climate.*"}, }, } + + +async def test_an_empty_filter_raises() -> None: + """Test empty filter raises when not guarding with has_config.""" + filters = Filters() + assert not filters.has_config + with pytest.raises( + RuntimeError, + match="No filter configuration provided, check has_config before calling this method", + ): + filters.states_metadata_entity_filter() + with pytest.raises( + RuntimeError, + match="No filter configuration provided, check has_config before calling this method", + ): + filters.states_entity_filter() + with pytest.raises( + RuntimeError, + match="No filter configuration provided, check has_config before calling this method", + ): + filters.events_entity_filter() From 24d0d15f38a33b2ee8387d2ed45640b7f45663c4 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 28 Mar 2023 21:02:43 +0200 Subject: [PATCH 1395/2810] Implement imap_content event for imap integration (#90242) --- .coveragerc | 3 - homeassistant/components/imap/coordinator.py | 111 ++++++- tests/components/imap/conftest.py | 104 +++++- tests/components/imap/const.py | 139 ++++++++ tests/components/imap/test_init.py | 323 +++++++++++++++++++ 5 files changed, 671 insertions(+), 9 deletions(-) create mode 100644 tests/components/imap/const.py create mode 100644 tests/components/imap/test_init.py diff --git a/.coveragerc b/.coveragerc index 82677177e6..4b831fc3d3 100644 --- a/.coveragerc +++ b/.coveragerc @@ -518,9 +518,6 @@ omit = homeassistant/components/ifttt/alarm_control_panel.py homeassistant/components/iglo/light.py homeassistant/components/ihc/* - homeassistant/components/imap/__init__.py - homeassistant/components/imap/coordinator.py - homeassistant/components/imap/sensor.py homeassistant/components/imap_email_content/sensor.py homeassistant/components/incomfort/* homeassistant/components/insteon/binary_sensor.py diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 69f291df6e..76eb8e46f5 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Mapping from datetime import timedelta +import email import logging from typing import Any @@ -11,7 +12,12 @@ from aioimaplib import AUTH, IMAP4_SSL, SELECTED, AioImapException import async_timeout from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.const import ( + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONTENT_TYPE_TEXT_PLAIN, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -23,6 +29,8 @@ _LOGGER = logging.getLogger(__name__) BACKOFF_TIME = 10 +EVENT_IMAP = "imap_content" + async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: """Connect to imap server and return client.""" @@ -37,6 +45,70 @@ async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: return client +class ImapMessage: + """Class to parse an RFC822 email message.""" + + def __init__(self, raw_message: bytes) -> None: + """Initialize IMAP message.""" + self.email_message = email.message_from_bytes(raw_message) + + @property + def headers(self) -> dict[str, tuple[str,]]: + """Get the email headers.""" + header_base: dict[str, tuple[str,]] = {} + for key, value in self.email_message.items(): + header: tuple[str,] = (str(value),) + if header_base.setdefault(key, header) != header: + header_base[key] += header # type: ignore[assignment] + return header_base + + @property + def sender(self) -> str: + """Get the parsed message sender from the email.""" + return str(email.utils.parseaddr(self.email_message["From"])[1]) + + @property + def subject(self) -> str: + """Decode the message subject.""" + decoded_header = email.header.decode_header(self.email_message["Subject"]) + header = email.header.make_header(decoded_header) + return str(header) + + @property + def text(self) -> str: + """Get the message text from the email. + + Will look for text/plain or use text/html if not found. + """ + message_text = None + message_html = None + message_untyped_text = None + + for part in self.email_message.walk(): + if part.get_content_type() == CONTENT_TYPE_TEXT_PLAIN: + if message_text is None: + message_text = part.get_payload() + elif part.get_content_type() == "text/html": + if message_html is None: + message_html = part.get_payload() + elif ( + part.get_content_type().startswith("text") + and message_untyped_text is None + ): + message_untyped_text = part.get_payload() + + if message_text is not None: + return message_text + + if message_html is not None: + return message_html + + if message_untyped_text is not None: + return message_untyped_text + + return self.email_message.get_payload() + + class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): """Base class for imap client.""" @@ -50,6 +122,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): ) -> None: """Initiate imap client.""" self.imap_client = imap_client + self._last_message_id: str | None = None super().__init__( hass, _LOGGER, @@ -65,8 +138,30 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): if self.imap_client is None: self.imap_client = await connect_to_server(self.config_entry.data) + async def _async_process_event(self, last_message_id: str) -> None: + """Send a event for the last message if the last message was changed.""" + response = await self.imap_client.fetch(last_message_id, "BODY.PEEK[]") + if response.result == "OK": + message = ImapMessage(response.lines[1]) + data = { + "server": self.config_entry.data[CONF_SERVER], + "username": self.config_entry.data[CONF_USERNAME], + "search": self.config_entry.data[CONF_SEARCH], + "folder": self.config_entry.data[CONF_FOLDER], + "text": message.text, + "sender": message.sender, + "subject": message.subject, + "headers": message.headers, + } + self.hass.bus.fire(EVENT_IMAP, data) + _LOGGER.debug( + "Message processed, sender: %s, subject: %s", + message.sender, + message.subject, + ) + async def _async_fetch_number_of_messages(self) -> int | None: - """Fetch number of messages.""" + """Fetch last message and messages count.""" await self._async_reconnect_if_needed() await self.imap_client.noop() result, lines = await self.imap_client.search( @@ -77,7 +172,17 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): raise UpdateFailed( f"Invalid response for search '{self.config_entry.data[CONF_SEARCH]}': {result} / {lines[0]}" ) - return len(lines[0].split()) + count: int = len(message_ids := lines[0].split()) + last_message_id = ( + str(message_ids[-1:][0], encoding=self.config_entry.data[CONF_CHARSET]) + if count + else None + ) + if count and last_message_id is not None: + self._last_message_id = last_message_id + await self._async_process_event(last_message_id) + + return count async def _cleanup(self, log_error: bool = False) -> None: """Close resources.""" diff --git a/tests/components/imap/conftest.py b/tests/components/imap/conftest.py index bc82cf57d8..74176efab1 100644 --- a/tests/components/imap/conftest.py +++ b/tests/components/imap/conftest.py @@ -1,9 +1,13 @@ -"""Test the iamp config flow.""" -from collections.abc import Generator -from unittest.mock import AsyncMock, patch +"""Fixtures for imap tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from aioimaplib import AUTH, LOGOUT, NONAUTH, SELECTED, STARTED, Response import pytest +from .const import EMPTY_SEARCH_RESPONSE, TEST_FETCH_RESPONSE_TEXT_PLAIN + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: @@ -12,3 +16,97 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: "homeassistant.components.imap.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +def imap_has_capability() -> bool: + """Fixture to set the imap capabilities.""" + return True + + +@pytest.fixture +def imap_login_state() -> str: + """Fixture to set the imap state after login.""" + return AUTH + + +@pytest.fixture +def imap_select_state() -> str: + """Fixture to set the imap capabilities.""" + return SELECTED + + +@pytest.fixture +def imap_search() -> tuple[str, list[bytes]]: + """Fixture to set the imap search response.""" + return EMPTY_SEARCH_RESPONSE + + +@pytest.fixture +def imap_fetch() -> tuple[str, list[bytes | bytearray]]: + """Fixture to set the imap fetch response.""" + return TEST_FETCH_RESPONSE_TEXT_PLAIN + + +@pytest.fixture +def imap_pending_idle() -> bool: + """Fixture to set the imap pending idle feature.""" + return True + + +@pytest.fixture +async def mock_imap_protocol( + imap_search: tuple[str, list[bytes]], + imap_fetch: tuple[str, list[bytes | bytearray]], + imap_has_capability: bool, + imap_pending_idle: bool, + imap_login_state: str, + imap_select_state: str, +) -> Generator[MagicMock, None]: + """Mock the aioimaplib IMAP protocol handler.""" + + with patch( + "homeassistant.components.imap.coordinator.IMAP4_SSL", autospec=True + ) as imap_mock: + imap_mock = imap_mock.return_value + + async def login(user: str, password: str) -> Response: + """Mock imap login.""" + imap_mock.protocol.state = imap_login_state + if imap_login_state != AUTH: + return Response("BAD", []) + return Response("OK", [b"CAPABILITY IMAP4rev1 ...", b"Logged in"]) + + async def close() -> Response: + """Mock imap close the selected folder.""" + imap_mock.protocol.state = imap_login_state + return Response("OK", []) + + async def logout() -> Response: + """Mock imap logout.""" + imap_mock.protocol.state = LOGOUT + return Response("OK", []) + + async def select(mailbox: str = "INBOX") -> Response: + """Mock imap folder select.""" + imap_mock.protocol.state = imap_select_state + if imap_login_state != SELECTED: + return Response("BAD", []) + return Response("OK", []) + + async def wait_hello_from_server() -> None: + """Mock wait for hello.""" + imap_mock.protocol.state = NONAUTH + + imap_mock.has_pending_idle.return_value = imap_pending_idle + imap_mock.protocol = MagicMock() + imap_mock.protocol.state = STARTED + imap_mock.has_capability.return_value = imap_has_capability + imap_mock.login.side_effect = login + imap_mock.close.side_effect = close + imap_mock.logout.side_effect = logout + imap_mock.select.side_effect = select + imap_mock.search.return_value = Response(*imap_search) + imap_mock.fetch.return_value = Response(*imap_fetch) + imap_mock.wait_hello_from_server.side_effect = wait_hello_from_server + yield imap_mock diff --git a/tests/components/imap/const.py b/tests/components/imap/const.py new file mode 100644 index 0000000000..68fab7d38c --- /dev/null +++ b/tests/components/imap/const.py @@ -0,0 +1,139 @@ +"""Constants for tests imap integration.""" + +TEST_MESSAGE = ( + b"Return-Path: \r\nDelivered-To: notify@example.com\r\n" + b"Received: from beta.example.com\r\n\tby beta with LMTP\r\n\t" + b"id eLp2M/GcHWQTLxQAho4UZQ\r\n\t(envelope-from )\r\n\t" + b"for ; Fri, 24 Mar 2023 13:52:01 +0100\r\n" + b"Received: from localhost (localhost [127.0.0.1])\r\n\t" + b"by beta.example.com (Postfix) with ESMTP id D0FFA61425\r\n\t" + b"for ; Fri, 24 Mar 2023 13:52:01 +0100 (CET)\r\n" + b"Date: Fri, 24 Mar 2023 13:52:00 +0100\r\n" + b"MIME-Version: 1.0\r\n" + b"To: notify@example.com\r\n" + b"From: John Doe \r\n" + b"Subject: Test subject\r\n" +) + +TEST_CONTENT_TEXT_BARE = b"\r\n" b"Test body\r\n" b"\r\n" + +TEST_CONTENT_BINARY = ( + b"Content-Type: application/binary\r\n" + b"Content-Transfer-Encoding: base64\r\n" + b"\r\n" + b"VGVzdCBib2R5\r\n" +) + +TEST_CONTENT_TEXT_PLAIN = ( + b"Content-Type: text/plain; charset=UTF-8; format=flowed\r\n" + b"Content-Transfer-Encoding: 7bit\r\n\r\nTest body\r\n\r\n" +) + +TEST_CONTENT_TEXT_OTHER = ( + b"Content-Type: text/other; charset=UTF-8\r\n" + b"Content-Transfer-Encoding: 7bit\r\n\r\nTest body\r\n\r\n" +) + +TEST_CONTENT_HTML = ( + b"Content-Type: text/html; charset=UTF-8\r\n" + b"Content-Transfer-Encoding: 7bit\r\n" + b"\r\n" + b"\r\n" + b" \r\n" + b' \r\n' + b" \r\n" + b" \r\n" + b"

Test body
\r\n" + b"

\r\n" + b" \r\n" + b"\r\n" + b"\r\n" +) + +TEST_CONTENT_MULTIPART = ( + b"\r\nThis is a multi-part message in MIME format.\r\n" + + b"--------------McwBciN2C0o3rWeF1tmFo2oI\r\n" + + TEST_CONTENT_TEXT_PLAIN + + b"--------------McwBciN2C0o3rWeF1tmFo2oI\r\n" + + TEST_CONTENT_HTML + + b"--------------McwBciN2C0o3rWeF1tmFo2oI--\r\n" +) + +EMPTY_SEARCH_RESPONSE = ("OK", [b"", b"Search completed (0.0001 + 0.000 secs)."]) +BAD_RESPONSE = ("BAD", [b"", b"Unexpected error"]) + +TEST_SEARCH_RESPONSE = ("OK", [b"1", b"Search completed (0.0001 + 0.000 secs)."]) + +TEST_FETCH_RESPONSE_TEXT_BARE = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE + TEST_CONTENT_TEXT_BARE)).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE + TEST_CONTENT_TEXT_BARE), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + +TEST_FETCH_RESPONSE_TEXT_PLAIN = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE + TEST_CONTENT_TEXT_PLAIN)).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE + TEST_CONTENT_TEXT_PLAIN), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + +TEST_FETCH_RESPONSE_TEXT_OTHER = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE + TEST_CONTENT_TEXT_OTHER)).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE + TEST_CONTENT_TEXT_OTHER), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + +TEST_FETCH_RESPONSE_BINARY = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE + TEST_CONTENT_BINARY)).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE + TEST_CONTENT_BINARY), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + +TEST_FETCH_RESPONSE_HTML = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE + TEST_CONTENT_HTML)).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE + TEST_CONTENT_HTML), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + +TEST_FETCH_RESPONSE_MULTIPART = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE + TEST_CONTENT_MULTIPART)).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE + TEST_CONTENT_MULTIPART), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + +RESPONSE_BAD = ("BAD", []) diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py new file mode 100644 index 0000000000..ec9058830d --- /dev/null +++ b/tests/components/imap/test_init.py @@ -0,0 +1,323 @@ +"""Test the imap entry initialization.""" +import asyncio +from datetime import timedelta +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from aioimaplib import AUTH, NONAUTH, SELECTED, AioImapException, Response +import pytest + +from homeassistant.components.imap import DOMAIN +from homeassistant.components.imap.errors import InvalidAuth, InvalidFolder +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from .const import ( + BAD_RESPONSE, + TEST_FETCH_RESPONSE_BINARY, + TEST_FETCH_RESPONSE_HTML, + TEST_FETCH_RESPONSE_MULTIPART, + TEST_FETCH_RESPONSE_TEXT_BARE, + TEST_FETCH_RESPONSE_TEXT_OTHER, + TEST_FETCH_RESPONSE_TEXT_PLAIN, + TEST_SEARCH_RESPONSE, +) +from .test_config_flow import MOCK_CONFIG + +from tests.common import MockConfigEntry, async_capture_events, async_fire_time_changed + + +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +async def test_entry_startup_and_unload( + hass: HomeAssistant, mock_imap_protocol: MagicMock +) -> None: + """Test imap entry startup and unload with push and polling coordinator.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert await config_entry.async_unload(hass) + + +@pytest.mark.parametrize( + "effect", + [ + InvalidAuth, + InvalidFolder, + asyncio.TimeoutError, + ], +) +async def test_entry_startup_fails( + hass: HomeAssistant, + mock_imap_protocol: MagicMock, + effect: Exception, +) -> None: + """Test imap entry startup fails on invalid auth or folder.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.imap.connect_to_server", + side_effect=effect, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) is False + + +@pytest.mark.parametrize("imap_search", [TEST_SEARCH_RESPONSE]) +@pytest.mark.parametrize( + "imap_fetch", + [ + TEST_FETCH_RESPONSE_TEXT_BARE, + TEST_FETCH_RESPONSE_TEXT_PLAIN, + TEST_FETCH_RESPONSE_TEXT_OTHER, + TEST_FETCH_RESPONSE_HTML, + TEST_FETCH_RESPONSE_MULTIPART, + TEST_FETCH_RESPONSE_BINARY, + ], + ids=["bare", "plain", "other", "html", "multipart", "binary"], +) +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +async def test_receiving_message_successfully( + hass: HomeAssistant, mock_imap_protocol: MagicMock +) -> None: + """Test receiving a message successfully.""" + event_called = async_capture_events(hass, "imap_content") + + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Make sure we have had one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + state = hass.states.get("sensor.imap_email_email_com") + # we should have received one message + assert state is not None + assert state.state == "1" + + # we should have received one event + assert len(event_called) == 1 + data: dict[str, Any] = event_called[0].data + assert data["server"] == "imap.server.com" + assert data["username"] == "email@email.com" + assert data["search"] == "UnSeen UnDeleted" + assert data["folder"] == "INBOX" + assert data["sender"] == "john.doe@example.com" + assert data["subject"] == "Test subject" + assert data["text"] + + +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +@pytest.mark.parametrize( + ("imap_login_state", "success"), [(AUTH, True), (NONAUTH, False)] +) +async def test_initial_authentication_error( + hass: HomeAssistant, mock_imap_protocol: MagicMock, success: bool +) -> None: + """Test authentication error when starting the entry.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) == success + await hass.async_block_till_done() + + state = hass.states.get("sensor.imap_email_email_com") + assert (state is not None) == success + + +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +@pytest.mark.parametrize( + ("imap_select_state", "success"), [(AUTH, False), (SELECTED, True)] +) +async def test_initial_invalid_folder_error( + hass: HomeAssistant, mock_imap_protocol: MagicMock, success: bool +) -> None: + """Test invalid folder error when starting the entry.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) == success + await hass.async_block_till_done() + + state = hass.states.get("sensor.imap_email_email_com") + assert (state is not None) == success + + +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +async def test_late_authentication_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_imap_protocol: MagicMock, +) -> None: + """Test authentication error handling after a search was failed.""" + + # Mock an error in waiting for a pushed update + mock_imap_protocol.wait_server_push.side_effect = AioImapException( + "Something went wrong" + ) + + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + + async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + + # Mock that the search fails, this will trigger + # that the connection will be restarted + # Then fail selecting the folder + mock_imap_protocol.search.return_value = Response(*BAD_RESPONSE) + mock_imap_protocol.login.side_effect = Response(*BAD_RESPONSE) + + async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + + async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + assert "Username or password incorrect, starting reauthentication" in caplog.text + + # we still should have an entity with an unavailable state + state = hass.states.get("sensor.imap_email_email_com") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +async def test_late_folder_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_imap_protocol: MagicMock, +) -> None: + """Test invalid folder error handling after a search was failed. + + Asserting the IMAP push coordinator. + """ + # Mock an error in waiting for a pushed update + mock_imap_protocol.wait_server_push.side_effect = AioImapException( + "Something went wrong" + ) + + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Make sure we have had at least one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + + # Mock that the search fails, this will trigger + # that the connection will be restarted + # Then fail selecting the folder + mock_imap_protocol.search.return_value = Response(*BAD_RESPONSE) + mock_imap_protocol.select.side_effect = Response(*BAD_RESPONSE) + + # Make sure we have had at least one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + assert "Selected mailbox folder is invalid" in caplog.text + + # we still should have an entity with an unavailable state + state = hass.states.get("sensor.imap_email_email_com") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +@pytest.mark.parametrize( + "imap_close", + [ + AsyncMock(side_effect=AioImapException("Something went wrong")), + AsyncMock(side_effect=asyncio.TimeoutError), + ], + ids=["AioImapException", "TimeoutError"], +) +async def test_handle_cleanup_exception( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_imap_protocol: MagicMock, + imap_close: Exception, +) -> None: + """Test handling an excepton during cleaning up.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Make sure we have had one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + + state = hass.states.get("sensor.imap_email_email_com") + # we should have an entity + assert state is not None + assert state.state == "0" + + # Fail cleaning up + mock_imap_protocol.close.side_effect = imap_close + + assert await config_entry.async_unload(hass) + await hass.async_block_till_done() + assert "Error while cleaning up imap connection" in caplog.text + + state = hass.states.get("sensor.imap_email_email_com") + + # we should have an entity with an unavailable state + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("imap_has_capability", [True], ids=["push"]) +@pytest.mark.parametrize( + "imap_wait_server_push_exception", + [ + AioImapException("Something went wrong"), + asyncio.TimeoutError, + ], + ids=["AioImapException", "TimeoutError"], +) +async def test_lost_connection_with_imap_push( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_imap_protocol: MagicMock, + imap_wait_server_push_exception: AioImapException | asyncio.TimeoutError, +) -> None: + """Test error handling when the connection is lost.""" + # Mock an error in waiting for a pushed update + mock_imap_protocol.wait_server_push.side_effect = imap_wait_server_push_exception + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert "Lost imap.server.com (will attempt to reconnect after 10 s)" in caplog.text + + state = hass.states.get("sensor.imap_email_email_com") + # we should have an entity with an unavailable state + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("imap_has_capability", [True], ids=["push"]) +async def test_fetch_number_of_messages( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_imap_protocol: MagicMock, +) -> None: + """Test _async_fetch_number_of_messages fails with push coordinator.""" + # Mock an error in waiting for a pushed update + mock_imap_protocol.search.return_value = Response(*BAD_RESPONSE) + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Make sure we wait for the backoff time + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + assert "Invalid response for search" in caplog.text + + state = hass.states.get("sensor.imap_email_email_com") + # we should have an entity with an unavailable state + assert state is not None + assert state.state == STATE_UNAVAILABLE From e9925f6062bb6e60a540de26d1f2367572462795 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 28 Mar 2023 22:46:59 +0200 Subject: [PATCH 1396/2810] Check webhook url is reachable in Reolink (#89585) Co-authored-by: Franck Nijhof --- homeassistant/components/reolink/host.py | 42 ++++++++++++++++--- homeassistant/components/reolink/strings.json | 6 ++- tests/components/reolink/conftest.py | 2 + tests/components/reolink/test_init.py | 19 ++++++++- 4 files changed, 61 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 9ba4809e90..1c0f97b6a2 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -54,7 +54,9 @@ class ReolinkHost: ) self.webhook_id: str | None = None - self._webhook_url: str | None = None + self._base_url: str = "" + self._webhook_url: str = "" + self._webhook_reachable: asyncio.Event = asyncio.Event() self._lost_subscription: bool = False @property @@ -138,6 +140,32 @@ class ReolinkHost: await self.subscribe() + _LOGGER.debug( + "Waiting for initial ONVIF state on webhook '%s'", self._webhook_url + ) + try: + await asyncio.wait_for(self._webhook_reachable.wait(), timeout=15) + except asyncio.TimeoutError: + _LOGGER.debug( + "Did not receive initial ONVIF state on webhook '%s' after 15 seconds", + self._webhook_url, + ) + ir.async_create_issue( + self._hass, + DOMAIN, + "webhook_url", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="webhook_url", + translation_placeholders={ + "name": self._api.nvr_name, + "base_url": self._base_url, + "network_link": "https://my.home-assistant.io/redirect/network/", + }, + ) + else: + ir.async_delete_issue(self._hass, DOMAIN, "webhook_url") + if self._api.sw_version_update_required: ir.async_create_issue( self._hass, @@ -287,10 +315,10 @@ class ReolinkHost: ) try: - base_url = get_url(self._hass, prefer_external=False) + self._base_url = get_url(self._hass, prefer_external=False) except NoURLAvailableError: try: - base_url = get_url(self._hass, prefer_external=True) + self._base_url = get_url(self._hass, prefer_external=True) except NoURLAvailableError as err: self.unregister_webhook() raise ReolinkWebhookException( @@ -299,9 +327,9 @@ class ReolinkHost: ) from err webhook_path = webhook.async_generate_path(event_id) - self._webhook_url = f"{base_url}{webhook_path}" + self._webhook_url = f"{self._base_url}{webhook_path}" - if base_url.startswith("https"): + if self._base_url.startswith("https"): ir.async_create_issue( self._hass, DOMAIN, @@ -310,7 +338,7 @@ class ReolinkHost: severity=ir.IssueSeverity.WARNING, translation_key="https_webhook", translation_placeholders={ - "base_url": base_url, + "base_url": self._base_url, "network_link": "https://my.home-assistant.io/redirect/network/", }, ) @@ -337,6 +365,8 @@ class ReolinkHost: """Handle incoming webhook from Reolink for inbound messages and calls.""" _LOGGER.debug("Webhook '%s' called", webhook_id) + if not self._webhook_reachable.is_set(): + self._webhook_reachable.set() if not request.body_exists: _LOGGER.debug("Webhook '%s' triggered without payload", webhook_id) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 74759c12f9..50c561530e 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -41,7 +41,11 @@ "issues": { "https_webhook": { "title": "Reolink webhook URL uses HTTPS (SSL)", - "description": "Reolink products can not push motion events to an HTTPS address (SSL), please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}). The current (local) address is: `{base_url}`" + "description": "Reolink products can not push motion events to an HTTPS address (SSL), please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}). The current (local) address is: `{base_url}`, a valid address could, for example, be `http://192.168.1.10:8123` where `192.168.1.10` is the IP of the Home Assistant device" + }, + "webhook_url": { + "title": "Reolink webhook URL unreachable", + "description": "Did not receive initial ONVIF state from {name}. Most likely, the Reolink camera can not reach the current (local) Home Assistant URL `{base_url}`, please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}) that points to Home Assistant. For example `http://192.168.1.10:8123` where `192.168.1.10` is the IP of the Home Assistant device. Also, make sure the Reolink camera can reach that URL." }, "enable_port": { "title": "Reolink port not enabled", diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 941a1ca7c8..be748ef2c4 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -39,6 +39,8 @@ def reolink_connect(mock_get_source_ip: None) -> Generator[MagicMock, None, None with patch( "homeassistant.components.reolink.host.webhook.async_register", return_value=True, + ), patch( + "homeassistant.components.reolink.host.asyncio.Event.wait", AsyncMock() ), patch( "homeassistant.components.reolink.host.Host", autospec=True ) as host_mock_class: diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 8849c7d52d..57d0dbd7cb 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -1,6 +1,7 @@ """Test the Reolink init.""" +import asyncio from typing import Any -from unittest.mock import AsyncMock, MagicMock, Mock +from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from reolink_aio.exceptions import ReolinkError @@ -99,6 +100,7 @@ async def test_no_repair_issue( issue_registry = ir.async_get(hass) assert (const.DOMAIN, "https_webhook") not in issue_registry.issues + assert (const.DOMAIN, "webhook_url") not in issue_registry.issues assert (const.DOMAIN, "enable_port") not in issue_registry.issues assert (const.DOMAIN, "firmware_update") not in issue_registry.issues @@ -138,6 +140,21 @@ async def test_port_repair_issue( assert (const.DOMAIN, "enable_port") in issue_registry.issues +async def test_webhook_repair_issue( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test repairs issue is raised when the webhook url is unreachable.""" + with patch( + "homeassistant.components.reolink.host.asyncio.Event.wait", + AsyncMock(side_effect=asyncio.TimeoutError()), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + issue_registry = ir.async_get(hass) + assert (const.DOMAIN, "webhook_url") in issue_registry.issues + + async def test_firmware_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry, reolink_connect: MagicMock ) -> None: From 0ceee2b6c3a632caa25df8b76a6db5dfdb89fbb1 Mon Sep 17 00:00:00 2001 From: mkmer Date: Tue, 28 Mar 2023 16:48:27 -0400 Subject: [PATCH 1397/2810] Catch somecomfort error in Honeywell (#90425) --- homeassistant/components/honeywell/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index 93c29446a5..ff54488228 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -63,6 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b except ( aiosomecomfort.device.ConnectionError, aiosomecomfort.device.ConnectionTimeout, + aiosomecomfort.device.SomeComfortError, asyncio.TimeoutError, ) as ex: raise ConfigEntryNotReady( From 93e1cd8dd8064e2594ad10993cdd26d64523e5f5 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 28 Mar 2023 22:50:25 +0200 Subject: [PATCH 1398/2810] Add header with parsed date to imap event data (#90422) --- homeassistant/components/imap/coordinator.py | 16 +++++++- tests/components/imap/const.py | 39 +++++++++++++++++++- tests/components/imap/test_init.py | 39 +++++++++++++++----- 3 files changed, 81 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 76eb8e46f5..97432b9105 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio from collections.abc import Mapping -from datetime import timedelta +from datetime import datetime, timedelta import email import logging from typing import Any @@ -62,6 +62,19 @@ class ImapMessage: header_base[key] += header # type: ignore[assignment] return header_base + @property + def date(self) -> datetime | None: + """Get the date the email was sent.""" + # See https://www.rfc-editor.org/rfc/rfc2822#section-3.3 + date_str: str | None + if (date_str := self.email_message["Date"]) is None: + return None + # In some cases a timezone or comment is added in parenthesis after the date + # We want to strip that part to avoid parsing errors + return datetime.strptime( + date_str.split("(")[0].strip(), "%a, %d %b %Y %H:%M:%S %z" + ) + @property def sender(self) -> str: """Get the parsed message sender from the email.""" @@ -148,6 +161,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): "username": self.config_entry.data[CONF_USERNAME], "search": self.config_entry.data[CONF_SEARCH], "folder": self.config_entry.data[CONF_FOLDER], + "date": message.date, "text": message.text, "sender": message.sender, "subject": message.subject, diff --git a/tests/components/imap/const.py b/tests/components/imap/const.py index 68fab7d38c..7c774527b3 100644 --- a/tests/components/imap/const.py +++ b/tests/components/imap/const.py @@ -1,6 +1,11 @@ """Constants for tests imap integration.""" -TEST_MESSAGE = ( + +DATE_HEADER1 = b"Date: Fri, 24 Mar 2023 13:52:00 +0100\r\n" +DATE_HEADER2 = b"Date: Fri, 24 Mar 2023 13:52:00 +0100 (CET)\r\n" +DATE_HEADER_INVALID = b"2023-03-27T13:52:00 +0100\r\n" + +TEST_MESSAGE_HEADERS1 = ( b"Return-Path: \r\nDelivered-To: notify@example.com\r\n" b"Received: from beta.example.com\r\n\tby beta with LMTP\r\n\t" b"id eLp2M/GcHWQTLxQAho4UZQ\r\n\t(envelope-from )\r\n\t" @@ -8,13 +13,18 @@ TEST_MESSAGE = ( b"Received: from localhost (localhost [127.0.0.1])\r\n\t" b"by beta.example.com (Postfix) with ESMTP id D0FFA61425\r\n\t" b"for ; Fri, 24 Mar 2023 13:52:01 +0100 (CET)\r\n" - b"Date: Fri, 24 Mar 2023 13:52:00 +0100\r\n" +) +TEST_MESSAGE_HEADERS2 = ( b"MIME-Version: 1.0\r\n" b"To: notify@example.com\r\n" b"From: John Doe \r\n" b"Subject: Test subject\r\n" ) +TEST_MESSAGE = TEST_MESSAGE_HEADERS1 + DATE_HEADER1 + TEST_MESSAGE_HEADERS2 +TEST_MESSAGE_ALT = TEST_MESSAGE_HEADERS1 + DATE_HEADER2 + TEST_MESSAGE_HEADERS2 +TEST_INVALID_DATE = TEST_MESSAGE_HEADERS1 + DATE_HEADER_INVALID + TEST_MESSAGE_HEADERS2 + TEST_CONTENT_TEXT_BARE = b"\r\n" b"Test body\r\n" b"\r\n" TEST_CONTENT_BINARY = ( @@ -88,6 +98,31 @@ TEST_FETCH_RESPONSE_TEXT_PLAIN = ( ], ) +TEST_FETCH_RESPONSE_TEXT_PLAIN_ALT = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE_ALT + TEST_CONTENT_TEXT_PLAIN)).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE_ALT + TEST_CONTENT_TEXT_PLAIN), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + +TEST_FETCH_RESPONSE_INVALID_DATE = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_INVALID_DATE + TEST_CONTENT_TEXT_PLAIN)).encode("utf-8") + + b"}", + bytearray(TEST_INVALID_DATE + TEST_CONTENT_TEXT_PLAIN), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + + TEST_FETCH_RESPONSE_TEXT_OTHER = ( "OK", [ diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index ec9058830d..fdcf37b76b 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -1,6 +1,6 @@ """Test the imap entry initialization.""" import asyncio -from datetime import timedelta +from datetime import datetime, timedelta from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -17,10 +17,12 @@ from .const import ( BAD_RESPONSE, TEST_FETCH_RESPONSE_BINARY, TEST_FETCH_RESPONSE_HTML, + TEST_FETCH_RESPONSE_INVALID_DATE, TEST_FETCH_RESPONSE_MULTIPART, TEST_FETCH_RESPONSE_TEXT_BARE, TEST_FETCH_RESPONSE_TEXT_OTHER, TEST_FETCH_RESPONSE_TEXT_PLAIN, + TEST_FETCH_RESPONSE_TEXT_PLAIN_ALT, TEST_SEARCH_RESPONSE, ) from .test_config_flow import MOCK_CONFIG @@ -66,20 +68,31 @@ async def test_entry_startup_fails( @pytest.mark.parametrize("imap_search", [TEST_SEARCH_RESPONSE]) @pytest.mark.parametrize( - "imap_fetch", + ("imap_fetch", "valid_date"), [ - TEST_FETCH_RESPONSE_TEXT_BARE, - TEST_FETCH_RESPONSE_TEXT_PLAIN, - TEST_FETCH_RESPONSE_TEXT_OTHER, - TEST_FETCH_RESPONSE_HTML, - TEST_FETCH_RESPONSE_MULTIPART, - TEST_FETCH_RESPONSE_BINARY, + (TEST_FETCH_RESPONSE_TEXT_BARE, True), + (TEST_FETCH_RESPONSE_TEXT_PLAIN, True), + (TEST_FETCH_RESPONSE_TEXT_PLAIN_ALT, True), + (TEST_FETCH_RESPONSE_INVALID_DATE, False), + (TEST_FETCH_RESPONSE_TEXT_OTHER, True), + (TEST_FETCH_RESPONSE_HTML, True), + (TEST_FETCH_RESPONSE_MULTIPART, True), + (TEST_FETCH_RESPONSE_BINARY, True), + ], + ids=[ + "bare", + "plain", + "plain_alt", + "invalid_date", + "other", + "html", + "multipart", + "binary", ], - ids=["bare", "plain", "other", "html", "multipart", "binary"], ) @pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) async def test_receiving_message_successfully( - hass: HomeAssistant, mock_imap_protocol: MagicMock + hass: HomeAssistant, mock_imap_protocol: MagicMock, valid_date: bool ) -> None: """Test receiving a message successfully.""" event_called = async_capture_events(hass, "imap_content") @@ -106,6 +119,12 @@ async def test_receiving_message_successfully( assert data["sender"] == "john.doe@example.com" assert data["subject"] == "Test subject" assert data["text"] + assert ( + valid_date + and isinstance(data["date"], datetime) + or not valid_date + and data["date"] is None + ) @pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) From 0550b17d543c471851064e8c81cd7abbd2d0660b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 10:51:46 -1000 Subject: [PATCH 1399/2810] Rework recorder filters to avoid caching mistakes (#90419) --- homeassistant/components/recorder/filters.py | 89 +++++++++++-------- tests/components/history/test_init.py | 19 ++-- .../history/test_init_db_schema_30.py | 19 ++-- 3 files changed, 68 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index de0929cf9f..24d22704a8 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -63,42 +63,53 @@ def merge_include_exclude_filters( def sqlalchemy_filter_from_include_exclude_conf(conf: ConfigType) -> Filters | None: """Build a sql filter from config.""" - filters = Filters() - if exclude := conf.get(CONF_EXCLUDE): - filters.excluded_entities = exclude.get(CONF_ENTITIES, []) - filters.excluded_domains = exclude.get(CONF_DOMAINS, []) - filters.excluded_entity_globs = exclude.get(CONF_ENTITY_GLOBS, []) - if include := conf.get(CONF_INCLUDE): - filters.included_entities = include.get(CONF_ENTITIES, []) - filters.included_domains = include.get(CONF_DOMAINS, []) - filters.included_entity_globs = include.get(CONF_ENTITY_GLOBS, []) - + exclude = conf.get(CONF_EXCLUDE, {}) + include = conf.get(CONF_INCLUDE, {}) + filters = Filters( + excluded_entities=exclude.get(CONF_ENTITIES, []), + excluded_domains=exclude.get(CONF_DOMAINS, []), + excluded_entity_globs=exclude.get(CONF_ENTITY_GLOBS, []), + included_entities=include.get(CONF_ENTITIES, []), + included_domains=include.get(CONF_DOMAINS, []), + included_entity_globs=include.get(CONF_ENTITY_GLOBS, []), + ) return filters if filters.has_config else None class Filters: - """Container for the configured include and exclude filters.""" + """Container for the configured include and exclude filters. - def __init__(self) -> None: + A filter must never change after it is created since it is used in a + cache key. + """ + + def __init__( + self, + excluded_entities: Collection[str] | None = None, + excluded_domains: Collection[str] | None = None, + excluded_entity_globs: Collection[str] | None = None, + included_entities: Collection[str] | None = None, + included_domains: Collection[str] | None = None, + included_entity_globs: Collection[str] | None = None, + ) -> None: """Initialise the include and exclude filters.""" - self.excluded_entities: Collection[str] = [] - self.excluded_domains: Collection[str] = [] - self.excluded_entity_globs: Collection[str] = [] - - self.included_entities: Collection[str] = [] - self.included_domains: Collection[str] = [] - self.included_entity_globs: Collection[str] = [] + self._excluded_entities = excluded_entities or [] + self._excluded_domains = excluded_domains or [] + self._excluded_entity_globs = excluded_entity_globs or [] + self._included_entities = included_entities or [] + self._included_domains = included_domains or [] + self._included_entity_globs = included_entity_globs or [] def __repr__(self) -> str: """Return human readable excludes/includes.""" return ( "" ) @@ -110,17 +121,17 @@ class Filters: @property def _have_exclude(self) -> bool: return bool( - self.excluded_entities - or self.excluded_domains - or self.excluded_entity_globs + self._excluded_entities + or self._excluded_domains + or self._excluded_entity_globs ) @property def _have_include(self) -> bool: return bool( - self.included_entities - or self.included_domains - or self.included_entity_globs + self._included_entities + or self._included_domains + or self._included_entity_globs ) def _generate_filter_for_columns( @@ -130,14 +141,14 @@ class Filters: This must match exactly how homeassistant.helpers.entityfilter works. """ - i_domains = _domain_matcher(self.included_domains, columns, encoder) - i_entities = _entity_matcher(self.included_entities, columns, encoder) - i_entity_globs = _globs_to_like(self.included_entity_globs, columns, encoder) + i_domains = _domain_matcher(self._included_domains, columns, encoder) + i_entities = _entity_matcher(self._included_entities, columns, encoder) + i_entity_globs = _globs_to_like(self._included_entity_globs, columns, encoder) includes = [i_domains, i_entities, i_entity_globs] - e_domains = _domain_matcher(self.excluded_domains, columns, encoder) - e_entities = _entity_matcher(self.excluded_entities, columns, encoder) - e_entity_globs = _globs_to_like(self.excluded_entity_globs, columns, encoder) + e_domains = _domain_matcher(self._excluded_domains, columns, encoder) + e_entities = _entity_matcher(self._excluded_entities, columns, encoder) + e_entity_globs = _globs_to_like(self._excluded_entity_globs, columns, encoder) excludes = [e_domains, e_entities, e_entity_globs] have_exclude = self._have_exclude @@ -173,7 +184,7 @@ class Filters: # - Otherwise, entity matches glob exclude: exclude # - Otherwise, entity matches domain include: include # - Otherwise: exclude - if self.included_domains or self.included_entity_globs: + if self._included_domains or self._included_entity_globs: return or_( i_entities, # https://github.com/sqlalchemy/sqlalchemy/issues/9190 @@ -187,7 +198,7 @@ class Filters: # - Otherwise, entity matches glob exclude: exclude # - Otherwise, entity matches domain exclude: exclude # - Otherwise: include - if self.excluded_domains or self.excluded_entity_globs: + if self._excluded_domains or self._excluded_entity_globs: return (not_(or_(*excludes)) | i_entities).self_group() # type: ignore[no-any-return, no-untyped-call] # Case 6 - No Domain and/or glob includes or excludes diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index a5c3919505..8b46bd9760 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -545,16 +545,15 @@ def test_get_significant_states_only(hass_history) -> None: def check_significant_states(hass, zero, four, states, config): """Check if significant states are retrieved.""" - filters = history.Filters() - exclude = config[history.DOMAIN].get(CONF_EXCLUDE) - if exclude: - filters.excluded_entities = exclude.get(CONF_ENTITIES, []) - filters.excluded_domains = exclude.get(CONF_DOMAINS, []) - include = config[history.DOMAIN].get(CONF_INCLUDE) - if include: - filters.included_entities = include.get(CONF_ENTITIES, []) - filters.included_domains = include.get(CONF_DOMAINS, []) - + domain_config = config[history.DOMAIN] + exclude = domain_config.get(CONF_EXCLUDE, {}) + include = domain_config.get(CONF_INCLUDE, {}) + filters = history.Filters( + excluded_entities=exclude.get(CONF_ENTITIES, []), + excluded_domains=exclude.get(CONF_DOMAINS, []), + included_entities=include.get(CONF_ENTITIES, []), + included_domains=include.get(CONF_DOMAINS, []), + ) hist = get_significant_states(hass, zero, four, filters=filters) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) diff --git a/tests/components/history/test_init_db_schema_30.py b/tests/components/history/test_init_db_schema_30.py index a300f58b96..7668d6794d 100644 --- a/tests/components/history/test_init_db_schema_30.py +++ b/tests/components/history/test_init_db_schema_30.py @@ -600,16 +600,15 @@ def test_get_significant_states_only(legacy_hass_history) -> None: def check_significant_states(hass, zero, four, states, config): """Check if significant states are retrieved.""" - filters = history.Filters() - exclude = config[history.DOMAIN].get(CONF_EXCLUDE) - if exclude: - filters.excluded_entities = exclude.get(CONF_ENTITIES, []) - filters.excluded_domains = exclude.get(CONF_DOMAINS, []) - include = config[history.DOMAIN].get(CONF_INCLUDE) - if include: - filters.included_entities = include.get(CONF_ENTITIES, []) - filters.included_domains = include.get(CONF_DOMAINS, []) - + domain_config = config[history.DOMAIN] + exclude = domain_config.get(CONF_EXCLUDE, {}) + include = domain_config.get(CONF_INCLUDE, {}) + filters = history.Filters( + excluded_entities=exclude.get(CONF_ENTITIES, []), + excluded_domains=exclude.get(CONF_DOMAINS, []), + included_entities=include.get(CONF_ENTITIES, []), + included_domains=include.get(CONF_DOMAINS, []), + ) hist = get_significant_states(hass, zero, four, filters=filters) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) From e22618a555f9d28522b355211a2f53a2bf436b43 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Mar 2023 22:56:51 +0200 Subject: [PATCH 1400/2810] Write protect entity options (#90185) --- homeassistant/components/camera/prefs.py | 3 +- homeassistant/components/sensor/__init__.py | 2 +- homeassistant/helpers/entity_registry.py | 33 +++++++++++++++++---- tests/helpers/test_entity_registry.py | 10 +++++++ tests/syrupy.py | 1 + 5 files changed, 41 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/camera/prefs.py b/homeassistant/components/camera/prefs.py index 28e4e1eeac..160f896c86 100644 --- a/homeassistant/components/camera/prefs.py +++ b/homeassistant/components/camera/prefs.py @@ -1,6 +1,7 @@ """Preference management for camera component.""" from __future__ import annotations +from collections.abc import Mapping from dataclasses import asdict, dataclass from typing import Final, cast @@ -89,7 +90,7 @@ class CameraPreferences: # Get preload stream setting from prefs # Get orientation setting from entity registry reg_entry = er.async_get(self._hass).async_get(entity_id) - er_prefs = reg_entry.options.get(DOMAIN, {}) if reg_entry else {} + er_prefs: Mapping = reg_entry.options.get(DOMAIN, {}) if reg_entry else {} preload_prefs = await self._store.async_load() or {} settings = DynamicStreamSettings( preload_stream=cast( diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 1812f41693..4f56be77a9 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -737,7 +737,7 @@ class SensorEntity(Entity): or "suggested_display_precision" not in self.registry_entry.options ): return - sensor_options = self.registry_entry.options.get(DOMAIN, {}) + sensor_options: Mapping[str, Any] = self.registry_entry.options.get(DOMAIN, {}) if ( "suggested_display_precision" in sensor_options and sensor_options["suggested_display_precision"] == display_precision diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 4c192d916c..9cb119b81b 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -12,6 +12,7 @@ from __future__ import annotations from collections import UserDict from collections.abc import Callable, Iterable, Mapping, ValuesView import logging +from types import MappingProxyType from typing import TYPE_CHECKING, Any, TypeVar, cast import attr @@ -111,6 +112,29 @@ DISLAY_DICT_OPTIONAL = ( ) +class _EntityOptions(UserDict[str, MappingProxyType]): + """Container for entity options.""" + + def __init__(self, data: Mapping[str, Mapping] | None) -> None: + """Initialize.""" + super().__init__() + if data is None: + return + self.data = {key: MappingProxyType(val) for key, val in data.items()} + + def __setitem__(self, key: str, entry: Mapping) -> None: + """Add an item.""" + raise NotImplementedError + + def __delitem__(self, key: str) -> None: + """Remove an item.""" + raise NotImplementedError + + def as_dict(self) -> dict[str, dict]: + """Return dictionary version.""" + return {key: dict(val) for key, val in self.data.items()} + + @attr.s(slots=True, frozen=True) class RegistryEntry: """Entity Registry Entry.""" @@ -132,10 +156,7 @@ class RegistryEntry: id: str = attr.ib(factory=uuid_util.random_uuid_hex) has_entity_name: bool = attr.ib(default=False) name: str | None = attr.ib(default=None) - options: EntityOptionsType = attr.ib( - default=None, - converter=attr.converters.default_if_none(factory=dict), # type: ignore[misc] - ) + options: _EntityOptions = attr.ib(default=None, converter=_EntityOptions) # As set by integration original_device_class: str | None = attr.ib(default=None) original_icon: str | None = attr.ib(default=None) @@ -930,7 +951,7 @@ class EntityRegistry: If the domain options are set to None, they will be removed. """ old = self.entities[entity_id] - new_options = { + new_options: dict[str, Mapping] = { key: value for key, value in old.options.items() if key != domain } if options is not None: @@ -1010,7 +1031,7 @@ class EntityRegistry: "id": entry.id, "has_entity_name": entry.has_entity_name, "name": entry.name, - "options": entry.options, + "options": entry.options.as_dict(), "original_device_class": entry.original_device_class, "original_icon": entry.original_icon, "original_name": entry.original_name, diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 79d6de3290..e3b91c46e1 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -747,6 +747,16 @@ async def test_update_entity_options(entity_registry: er.EntityRegistry) -> None assert entry.options == {} assert new_entry_1.options == {"light": {"minimum_brightness": 20}} + # Test it's not possible to modify the options + with pytest.raises(NotImplementedError): + new_entry_1.options["blah"] = {} + with pytest.raises(NotImplementedError): + new_entry_1.options["light"] = {} + with pytest.raises(TypeError): + new_entry_1.options["light"]["blah"] = 123 + with pytest.raises(TypeError): + new_entry_1.options["light"]["minimum_brightness"] = 123 + entity_registry.async_update_entity_options( entry.entity_id, "light", {"minimum_brightness": 30} ) diff --git a/tests/syrupy.py b/tests/syrupy.py index f18c11bf5d..af34cb628f 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -170,6 +170,7 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): "config_entry_id": ANY, "device_id": ANY, "id": ANY, + "options": data.options.as_dict(), } ) serialized.pop("_partial_repr") From f60e9c71a2135d2b18f433a194a733acdb7f1908 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 11:22:41 -1000 Subject: [PATCH 1401/2810] Make bootstrap cancelation safe (#90420) --- homeassistant/bootstrap.py | 19 +++++++++---------- tests/test_bootstrap.py | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 9ba4e99a08..eb3aa3a223 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -515,16 +515,15 @@ async def async_setup_multi_components( ) for domain in domains } - await asyncio.wait(futures.values()) - errors = [domain for domain in domains if futures[domain].exception()] - for domain in errors: - exception = futures[domain].exception() - assert exception is not None - _LOGGER.error( - "Error setting up integration %s - received exception", - domain, - exc_info=(type(exception), exception, exception.__traceback__), - ) + results = await asyncio.gather(*futures.values(), return_exceptions=True) + for idx, domain in enumerate(futures): + result = results[idx] + if isinstance(result, BaseException): + _LOGGER.error( + "Error setting up integration %s - received exception", + domain, + exc_info=(type(result), result, result.__traceback__), + ) async def _async_set_up_integrations( diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 9f02d6394e..cd0d7ef069 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -806,3 +806,26 @@ async def test_warning_logged_on_wrap_up_timeout( await hass.async_block_till_done() assert "Setup timed out for bootstrap - moving forward" in caplog.text + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_bootstrap_is_cancellation_safe( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test cancellation during async_setup_component does not cancel bootstrap.""" + with patch.object( + bootstrap, "async_setup_component", side_effect=asyncio.CancelledError + ): + await bootstrap._async_set_up_integrations(hass, {"cancel_integration": {}}) + await hass.async_block_till_done() + + assert "Error setting up integration cancel_integration" in caplog.text + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_bootstrap_empty_integrations( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test setting up an empty integrations does not raise.""" + await bootstrap.async_setup_multi_components(hass, set(), {}) + await hass.async_block_till_done() From 9ae02362083077a7122e821edc4c0f96dfc05532 Mon Sep 17 00:00:00 2001 From: mletenay Date: Tue, 28 Mar 2023 23:31:14 +0200 Subject: [PATCH 1402/2810] Add goodwe sensors for apparent/reactive pwr (#87940) --- homeassistant/components/goodwe/sensor.py | 24 +++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/homeassistant/components/goodwe/sensor.py b/homeassistant/components/goodwe/sensor.py index b4adf97c3e..d76d620283 100644 --- a/homeassistant/components/goodwe/sensor.py +++ b/homeassistant/components/goodwe/sensor.py @@ -19,13 +19,16 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, + POWER_VOLT_AMPERE_REACTIVE, EntityCategory, + UnitOfApparentPower, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, UnitOfFrequency, UnitOfPower, UnitOfTemperature, + UnitOfTime, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo @@ -111,6 +114,20 @@ _DESCRIPTIONS: dict[str, GoodweSensorEntityDescription] = { value=lambda coordinator, sensor: coordinator.total_sensor_value(sensor), available=lambda coordinator: coordinator.data is not None, ), + "VA": GoodweSensorEntityDescription( + key="VA", + device_class=SensorDeviceClass.APPARENT_POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + entity_registry_enabled_default=False, + ), + "var": GoodweSensorEntityDescription( + key="var", + device_class=SensorDeviceClass.REACTIVE_POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + entity_registry_enabled_default=False, + ), "C": GoodweSensorEntityDescription( key="C", device_class=SensorDeviceClass.TEMPERATURE, @@ -123,6 +140,13 @@ _DESCRIPTIONS: dict[str, GoodweSensorEntityDescription] = { state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.HERTZ, ), + "h": GoodweSensorEntityDescription( + key="h", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.HOURS, + entity_registry_enabled_default=False, + ), "%": GoodweSensorEntityDescription( key="%", state_class=SensorStateClass.MEASUREMENT, From 9dc936f8b9a443a9052210c0b6d6e89369193f48 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 12:02:35 -1000 Subject: [PATCH 1403/2810] Add sqlalchemy LRUs to the profiler lru service (#90428) --- homeassistant/components/profiler/__init__.py | 12 +++++++++++- tests/components/profiler/test_init.py | 10 ++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index b838f67d02..27e302f47c 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -35,6 +35,7 @@ SERVICE_LOG_THREAD_FRAMES = "log_thread_frames" SERVICE_LOG_EVENT_LOOP_SCHEDULED = "log_event_loop_scheduled" _LRU_CACHE_WRAPPER_OBJECT = _lru_cache_wrapper.__name__ +_SQLALCHEMY_LRU_OBJECT = "LRUCache" _KNOWN_LRU_CLASSES = ( "EventDataManager", @@ -67,7 +68,9 @@ LOG_INTERVAL_SUB = "log_interval_subscription" _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( # noqa: C901 + hass: HomeAssistant, entry: ConfigEntry +) -> bool: """Set up Profiler from a config entry.""" lock = asyncio.Lock() domain_data = hass.data[DOMAIN] = {} @@ -176,6 +179,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: maybe_lru.get_stats(), ) + for lru in objgraph.by_type(_SQLALCHEMY_LRU_OBJECT): + if (data := getattr(lru, "_data", None)) and isinstance(data, dict): + for key, value in dict(data).items(): + _LOGGER.critical( + "Cache data for sqlalchemy LRUCache %s: %s: %s", lru, key, value + ) + persistent_notification.create( hass, ( diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index af642c779e..9466660dca 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -11,6 +11,7 @@ import pytest from homeassistant.components.profiler import ( _LRU_CACHE_WRAPPER_OBJECT, + _SQLALCHEMY_LRU_OBJECT, CONF_SECONDS, SERVICE_DUMP_LOG_OBJECTS, SERVICE_LOG_EVENT_LOOP_SCHEDULED, @@ -254,9 +255,17 @@ async def test_lru_stats(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) domain_data = DomainData() assert hass.services.has_service(DOMAIN, SERVICE_LRU_STATS) + class LRUCache: + def __init__(self): + self._data = {"sqlalchemy_test": 1} + + sqlalchemy_lru_cache = LRUCache() + def _mock_by_type(type_): if type_ == _LRU_CACHE_WRAPPER_OBJECT: return [_dummy_test_lru_stats] + if type_ == _SQLALCHEMY_LRU_OBJECT: + return [sqlalchemy_lru_cache] return [domain_data] with patch("objgraph.by_type", side_effect=_mock_by_type): @@ -266,3 +275,4 @@ async def test_lru_stats(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) assert "(0, 0)" in caplog.text assert "_dummy_test_lru_stats" in caplog.text assert "CacheInfo" in caplog.text + assert "sqlalchemy_test" in caplog.text From ee2101ef38870149ce1b61e46596259c847fdd35 Mon Sep 17 00:00:00 2001 From: MattWestb <49618193+MattWestb@users.noreply.github.com> Date: Wed, 29 Mar 2023 01:33:06 +0200 Subject: [PATCH 1404/2810] Add binding of IKEA Matter Switch cluster in ZHA (#89623) * Adding binding of IKEA Matter Switch cluster IKEA Symfonisk Gen 2 is using Matter ZCL Switch command but on manufacture cluster then its not supported in ZVL R8 that need being bond for sending the commands to the coordinator. * Update manufacturerspecific.py * Update manufacturerspecific.py Delete not needed function `@registries.BINDABLE_CLUSTERS.register(0xFC80)` --- .../components/zha/core/channels/manufacturerspecific.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/channels/manufacturerspecific.py index 85a478b0d4..107df3a2da 100644 --- a/homeassistant/components/zha/core/channels/manufacturerspecific.py +++ b/homeassistant/components/zha/core/channels/manufacturerspecific.py @@ -324,3 +324,11 @@ class IkeaAirPurifierChannel(ZigbeeChannel): self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, attr_name, value ) + + +@registries.CHANNEL_ONLY_CLUSTERS.register(0xFC80) +@registries.ZIGBEE_CHANNEL_REGISTRY.register(0xFC80) +class IkeaRemote(ZigbeeChannel): + """Ikea Matter remote channel.""" + + REPORT_CONFIG = () From 4f05246654625787f153b23166e315970329802c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 13:54:33 -1000 Subject: [PATCH 1405/2810] Bump onvif-zeep-async to 1.2.3 (#90382) --- homeassistant/components/onvif/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index 4b998bdd6c..ef4497fa28 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==1.2.2", "WSDiscovery==2.0.0"] + "requirements": ["onvif-zeep-async==1.2.3", "WSDiscovery==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4c5dec6d8c..ca51ee1b7f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1260,7 +1260,7 @@ ondilo==0.2.0 onkyo-eiscp==1.2.7 # homeassistant.components.onvif -onvif-zeep-async==1.2.2 +onvif-zeep-async==1.2.3 # homeassistant.components.opengarage open-garage==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ab64c066c..6bfaf522c6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -938,7 +938,7 @@ omnilogic==0.4.5 ondilo==0.2.0 # homeassistant.components.onvif -onvif-zeep-async==1.2.2 +onvif-zeep-async==1.2.3 # homeassistant.components.opengarage open-garage==0.2.0 From 86600350275c695bb5c867ea09fde6f13d2abec0 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Tue, 28 Mar 2023 20:59:26 -0400 Subject: [PATCH 1406/2810] Bump ZHA quirks to 0.0.95 (#90435) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 3061d867b6..d82fe5ed0f 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -23,7 +23,7 @@ "bellows==0.34.10", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.94", + "zha-quirks==0.0.95", "zigpy-deconz==0.19.2", "zigpy==0.53.2", "zigpy-xbee==0.16.2", diff --git a/requirements_all.txt b/requirements_all.txt index ca51ee1b7f..49ac9171ec 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2701,7 +2701,7 @@ zeroconf==0.47.4 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.94 +zha-quirks==0.0.95 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6bfaf522c6..4213338da8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1935,7 +1935,7 @@ zeroconf==0.47.4 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.94 +zha-quirks==0.0.95 # homeassistant.components.zha zigpy-deconz==0.19.2 From 12f49006cfc699120f874d0b97e9357ba9302e64 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 29 Mar 2023 03:12:21 +0200 Subject: [PATCH 1407/2810] Add Aqara E1 thermostat entities to ZHA (#90158) * Add Aqara E1 thermostat entities (WIP) * Remove calibrate button for now * Add diagnostic entity category to calibrated + external sensor * Add multiplier for ZHA config number/away preset temp * Set default multiplier correctly * Add and use `CONFIG_DIAGNOSTIC_MATCH` for diagnostic entities --- homeassistant/components/zha/binary_sensor.py | 41 ++++++++++++++++++- .../zha/core/channels/manufacturerspecific.py | 14 +++++++ homeassistant/components/zha/number.py | 24 +++++++++-- homeassistant/components/zha/select.py | 17 ++++++++ homeassistant/components/zha/switch.py | 29 +++++++++++++ 5 files changed, 121 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index 9c2fb49de6..fc49026d88 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -39,6 +39,9 @@ CLASS_MAPPING = { STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.BINARY_SENSOR) MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.BINARY_SENSOR) +CONFIG_DIAGNOSTIC_MATCH = functools.partial( + ZHA_ENTITIES.config_diagnostic_match, Platform.BINARY_SENSOR +) async def async_setup_entry( @@ -201,3 +204,39 @@ class XiaomiPlugConsumerConnected(BinarySensor, id_suffix="consumer_connected"): SENSOR_ATTR = "consumer_connected" _attr_name: str = "Consumer connected" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PLUG + + +@MULTI_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +class AqaraThermostatWindowOpen(BinarySensor, id_suffix="window_open"): + """ZHA Aqara thermostat window open binary sensor.""" + + SENSOR_ATTR = "window_open" + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.WINDOW + _attr_name: str = "Window open" + + +@MULTI_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +class AqaraThermostatValveAlarm(BinarySensor, id_suffix="valve_alarm"): + """ZHA Aqara thermostat valve alarm binary sensor.""" + + SENSOR_ATTR = "valve_alarm" + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PROBLEM + _attr_name: str = "Valve alarm" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +class AqaraThermostatCalibrated(BinarySensor, id_suffix="calibrated"): + """ZHA Aqara thermostat calibrated binary sensor.""" + + SENSOR_ATTR = "calibrated" + _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC + _attr_name: str = "Calibrated" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +class AqaraThermostatExternalSensor(BinarySensor, id_suffix="sensor"): + """ZHA Aqara thermostat external sensor binary sensor.""" + + SENSOR_ATTR = "sensor" + _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC + _attr_name: str = "External sensor" diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/channels/manufacturerspecific.py index 107df3a2da..629e618db0 100644 --- a/homeassistant/components/zha/core/channels/manufacturerspecific.py +++ b/homeassistant/components/zha/core/channels/manufacturerspecific.py @@ -138,6 +138,20 @@ class OppleRemote(ZigbeeChannel): "serving_size": True, "portion_weight": True, } + elif self.cluster.endpoint.model == "lumi.airrtc.agl001": + self.ZCL_INIT_ATTRS = { + "system_mode": True, + "preset": True, + "window_detection": True, + "valve_detection": True, + "valve_alarm": True, + "child_lock": True, + "away_preset_temperature": True, + "window_open": True, + "calibrated": True, + "schedule": True, + "sensor": True, + } async def async_initialize_channel_specific(self, from_cache: bool) -> None: """Initialize channel specific.""" diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index 334b72dccc..d0ec62eaf6 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -11,7 +11,7 @@ from zigpy.zcl.foundation import Status from homeassistant.components.number import NumberEntity, NumberMode from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, Platform, UnitOfMass +from homeassistant.const import EntityCategory, Platform, UnitOfMass, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -375,6 +375,7 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): _attr_entity_category = EntityCategory.CONFIG _attr_native_step: float = 1.0 + _attr_multiplier: float = 1 _zcl_attribute: str @classmethod @@ -417,13 +418,13 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): @property def native_value(self) -> float: """Return the current value.""" - return self._channel.cluster.get(self._zcl_attribute) + return self._channel.cluster.get(self._zcl_attribute) * self._attr_multiplier async def async_set_native_value(self, value: float) -> None: """Update the current value from HA.""" try: res = await self._channel.cluster.write_attributes( - {self._zcl_attribute: int(value)} + {self._zcl_attribute: int(value / self._attr_multiplier)} ) except zigpy.exceptions.ZigbeeException as ex: self.error("Could not set value: %s", ex) @@ -861,3 +862,20 @@ class AqaraPetFeederPortionWeight( _attr_mode: NumberMode = NumberMode.BOX _attr_native_unit_of_measurement: str = UnitOfMass.GRAMS _attr_icon: str = "mdi:weight-gram" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +class AqaraThermostatAwayTemp( + ZHANumberConfigurationEntity, id_suffix="away_preset_temperature" +): + """Aqara away preset temperature configuration entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_native_min_value: float = 5 + _attr_native_max_value: float = 30 + _attr_multiplier: float = 0.01 + _zcl_attribute: str = "away_preset_temperature" + _attr_name: str = "Away preset temperature" + _attr_mode: NumberMode = NumberMode.SLIDER + _attr_native_unit_of_measurement: str = UnitOfTemperature.CELSIUS + _attr_icon: str = ICONS[0] diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index b4cbce5540..605c7d507c 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -503,3 +503,20 @@ class AqaraPetFeederMode(ZCLEnumSelectEntity, id_suffix="feeding_mode"): _enum = AqaraFeedingMode _attr_name = "Mode" _attr_icon: str = "mdi:wrench-clock" + + +class AqaraThermostatPresetMode(types.enum8): + """Thermostat preset mode.""" + + Manual = 0x00 + Auto = 0x01 + Away = 0x02 + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +class AqaraThermostatPreset(ZCLEnumSelectEntity, id_suffix="preset"): + """Representation of an Aqara thermostat preset configuration entity.""" + + _select_attr = "preset" + _enum = AqaraThermostatPresetMode + _attr_name = "Preset" diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 09cebc8f4c..9323e3ddc8 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -477,3 +477,32 @@ class TuyaChildLockSwitch(ZHASwitchConfigurationEntity, id_suffix="child_lock"): _zcl_attribute: str = "child_lock" _attr_name = "Child lock" _attr_icon: str = "mdi:account-lock" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +class AqaraThermostatWindowDetection( + ZHASwitchConfigurationEntity, id_suffix="window_detection" +): + """Representation of an Aqara thermostat window detection configuration entity.""" + + _zcl_attribute: str = "window_detection" + _attr_name = "Window detection" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +class AqaraThermostatValveDetection( + ZHASwitchConfigurationEntity, id_suffix="valve_detection" +): + """Representation of an Aqara thermostat valve detection configuration entity.""" + + _zcl_attribute: str = "valve_detection" + _attr_name = "Valve detection" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +class AqaraThermostatChildLock(ZHASwitchConfigurationEntity, id_suffix="child_lock"): + """Representation of an Aqara thermostat child lock configuration entity.""" + + _zcl_attribute: str = "child_lock" + _attr_name = "Child lock" + _attr_icon: str = "mdi:account-lock" From 47a2598b6652b46e31b1a9a55f7614a83ed49b24 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 29 Mar 2023 03:30:56 +0200 Subject: [PATCH 1408/2810] Add Aqara smoke sensor entities to ZHA (#90159) * Add Aqara smoke sensor entities (WIP) * Update smoke sensor entities (WIP) * Drop two init attributes * Move self-test button * Remove self-test switch, add icons * Add smoke sensor dbm entity * Also add SMOKE device class to linkage alarm Note: Enable "Linkage alarm" for this --- homeassistant/components/zha/binary_sensor.py | 9 ++++ homeassistant/components/zha/button.py | 12 +++++ .../zha/core/channels/manufacturerspecific.py | 9 ++++ homeassistant/components/zha/sensor.py | 12 +++++ homeassistant/components/zha/switch.py | 50 +++++++++++++++++++ 5 files changed, 92 insertions(+) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index fc49026d88..b277b3fe26 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -240,3 +240,12 @@ class AqaraThermostatExternalSensor(BinarySensor, id_suffix="sensor"): SENSOR_ATTR = "sensor" _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC _attr_name: str = "External sensor" + + +@MULTI_MATCH(channel_names="opple_cluster", models={"lumi.sensor_smoke.acn03"}) +class AqaraLinkageAlarmState(BinarySensor, id_suffix="linkage_alarm_state"): + """ZHA Aqara linkage alarm state binary sensor.""" + + SENSOR_ATTR = "linkage_alarm_state" + _attr_name: str = "Linkage alarm state" + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.SMOKE diff --git a/homeassistant/components/zha/button.py b/homeassistant/components/zha/button.py index 14547216dc..b3ff3f5aed 100644 --- a/homeassistant/components/zha/button.py +++ b/homeassistant/components/zha/button.py @@ -184,3 +184,15 @@ class AqaraPetFeederFeedButton(ZHAAttributeButton, id_suffix="feeding"): _attribute_name = "feeding" _attr_name = "Feed" _attribute_value = 1 + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} +) +class AqaraSelfTestButton(ZHAAttributeButton, id_suffix="self_test"): + """Defines a ZHA self-test button for Aqara smoke sensors.""" + + _attribute_name = "self_test" + _attr_name = "Self-test" + _attribute_value = 1 + _attr_entity_category = EntityCategory.CONFIG diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/channels/manufacturerspecific.py index 629e618db0..e312f398b5 100644 --- a/homeassistant/components/zha/core/channels/manufacturerspecific.py +++ b/homeassistant/components/zha/core/channels/manufacturerspecific.py @@ -152,6 +152,15 @@ class OppleRemote(ZigbeeChannel): "schedule": True, "sensor": True, } + elif self.cluster.endpoint.model == "lumi.sensor_smoke.acn03": + self.ZCL_INIT_ATTRS = { + "buzzer_manual_mute": True, + "smoke_density": True, + "heartbeat_indicator": True, + "buzzer_manual_alarm": True, + "buzzer": True, + "linkage_alarm": True, + } async def async_initialize_channel_specific(self, from_cache: bool) -> None: """Initialize channel specific.""" diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 78ce47c7e5..a7a090b13a 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -955,3 +955,15 @@ class AqaraPetFeederWeightDispensed(Sensor, id_suffix="weight_dispensed"): _attr_native_unit_of_measurement = UnitOfMass.GRAMS _attr_state_class: SensorStateClass = SensorStateClass.TOTAL_INCREASING _attr_icon: str = "mdi:weight-gram" + + +@MULTI_MATCH(channel_names="opple_cluster", models={"lumi.sensor_smoke.acn03"}) +class AqaraSmokeDensityDbm(Sensor, id_suffix="smoke_density_dbm"): + """Sensor that displays the smoke density of an Aqara smoke sensor in dB/m.""" + + SENSOR_ATTR = "smoke_density_dbm" + _attr_name: str = "Smoke density" + _attr_native_unit_of_measurement = "dB/m" + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_icon: str = "mdi:google-circles-communities" + _attr_suggested_display_precision: int = 3 diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 9323e3ddc8..f0e3675079 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -506,3 +506,53 @@ class AqaraThermostatChildLock(ZHASwitchConfigurationEntity, id_suffix="child_lo _zcl_attribute: str = "child_lock" _attr_name = "Child lock" _attr_icon: str = "mdi:account-lock" + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} +) +class AqaraHeartbeatIndicator( + ZHASwitchConfigurationEntity, id_suffix="heartbeat_indicator" +): + """Representation of a heartbeat indicator configuration entity for Aqara smoke sensors.""" + + _zcl_attribute: str = "heartbeat_indicator" + _attr_name = "Heartbeat indicator" + _attr_icon: str = "mdi:heart-flash" + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} +) +class AqaraLinkageAlarm(ZHASwitchConfigurationEntity, id_suffix="linkage_alarm"): + """Representation of a linkage alarm configuration entity for Aqara smoke sensors.""" + + _zcl_attribute: str = "linkage_alarm" + _attr_name = "Linkage alarm" + _attr_icon: str = "mdi:shield-link-variant" + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} +) +class AqaraBuzzerManualMute( + ZHASwitchConfigurationEntity, id_suffix="buzzer_manual_mute" +): + """Representation of a buzzer manual mute configuration entity for Aqara smoke sensors.""" + + _zcl_attribute: str = "buzzer_manual_mute" + _attr_name = "Buzzer manual mute" + _attr_icon: str = "mdi:volume-off" + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} +) +class AqaraBuzzerManualAlarm( + ZHASwitchConfigurationEntity, id_suffix="buzzer_manual_alarm" +): + """Representation of a buzzer manual mute configuration entity for Aqara smoke sensors.""" + + _zcl_attribute: str = "buzzer_manual_alarm" + _attr_name = "Buzzer manual alarm" + _attr_icon: str = "mdi:bullhorn" From e3cad8baac96ec60cfc1d30fbf6d9bdd03ffe12b Mon Sep 17 00:00:00 2001 From: Thijs W Date: Wed, 29 Mar 2023 04:06:21 +0200 Subject: [PATCH 1409/2810] Migrate ssdp to config_flow for frontier_silicon (#89496) Co-authored-by: J. Nick Koston --- .../components/discovery/__init__.py | 1 - .../frontier_silicon/config_flow.py | 97 ++++++++++-- .../components/frontier_silicon/const.py | 3 + .../components/frontier_silicon/manifest.json | 3 +- .../frontier_silicon/media_player.py | 14 -- homeassistant/generated/ssdp.py | 5 + .../frontier_silicon/test_config_flow.py | 143 ++++++++++++++++-- 7 files changed, 221 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 0ffd6fe49e..204992b48f 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -60,7 +60,6 @@ class ServiceDetails(NamedTuple): SERVICE_HANDLERS = { SERVICE_ENIGMA2: ServiceDetails("media_player", "enigma2"), "yamaha": ServiceDetails("media_player", "yamaha"), - "frontier_silicon": ServiceDetails("media_player", "frontier_silicon"), "openhome": ServiceDetails("media_player", "openhome"), "bluesound": ServiceDetails("media_player", "bluesound"), } diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py index a3fbdb52c1..a054bd2b30 100644 --- a/homeassistant/components/frontier_silicon/config_flow.py +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -3,15 +3,24 @@ from __future__ import annotations import logging from typing import Any +from urllib.parse import urlparse from afsapi import AFSAPI, ConnectionError as FSConnectionError, InvalidPinException import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import ssdp from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.data_entry_flow import FlowResult -from .const import CONF_PIN, CONF_WEBFSAPI_URL, DEFAULT_PIN, DEFAULT_PORT, DOMAIN +from .const import ( + CONF_PIN, + CONF_WEBFSAPI_URL, + DEFAULT_PIN, + DEFAULT_PORT, + DOMAIN, + SSDP_ATTR_SPEAKER_NAME, +) _LOGGER = logging.getLogger(__name__) @@ -32,11 +41,17 @@ STEP_DEVICE_CONFIG_DATA_SCHEMA = vol.Schema( ) +def hostname_from_url(url: str) -> str: + """Return the hostname from a url.""" + return str(urlparse(url).hostname) + + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Frontier Silicon Media Player.""" VERSION = 1 + _name: str _webfsapi_url: str async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult: @@ -101,6 +116,46 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=data_schema, errors=errors ) + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: + """Process entity discovered via SSDP.""" + + device_url = discovery_info.ssdp_location + if device_url is None: + return self.async_abort(reason="cannot_connect") + + device_hostname = hostname_from_url(device_url) + for entry in self._async_current_entries(include_ignore=False): + if device_hostname == hostname_from_url(entry.data[CONF_WEBFSAPI_URL]): + return self.async_abort(reason="already_configured") + + speaker_name = discovery_info.ssdp_headers.get(SSDP_ATTR_SPEAKER_NAME) + self.context["title_placeholders"] = {"name": speaker_name} + + try: + self._webfsapi_url = await AFSAPI.get_webfsapi_endpoint(device_url) + except FSConnectionError: + return self.async_abort(reason="cannot_connect") + except Exception as exception: # pylint: disable=broad-except + _LOGGER.debug(exception) + return self.async_abort(reason="unknown") + + try: + # try to login with default pin + afsapi = AFSAPI(self._webfsapi_url, DEFAULT_PIN) + + unique_id = await afsapi.get_radio_id() + except InvalidPinException: + return self.async_abort(reason="invalid_auth") + + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured( + updates={CONF_WEBFSAPI_URL: self._webfsapi_url}, reload_on_update=True + ) + + self._name = await afsapi.get_friendly_name() + + return await self.async_step_confirm() + async def _async_step_device_config_if_needed(self) -> FlowResult: """Most users will not have changed the default PIN on their radio. @@ -111,21 +166,29 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # try to login with default pin afsapi = AFSAPI(self._webfsapi_url, DEFAULT_PIN) - name = await afsapi.get_friendly_name() + self._name = await afsapi.get_friendly_name() except InvalidPinException: # Ask for a PIN return await self.async_step_device_config() - self.context["title_placeholders"] = {"name": name} + self.context["title_placeholders"] = {"name": self._name} unique_id = await afsapi.get_radio_id() await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() - return self.async_create_entry( - title=name, - data={CONF_WEBFSAPI_URL: self._webfsapi_url, CONF_PIN: DEFAULT_PIN}, - ) + return await self._async_create_entry() + + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Allow the user to confirm adding the device. Used when the default PIN could successfully be used.""" + + if user_input is not None: + return await self._async_create_entry() + + self._set_confirm_only() + return self.async_show_form(step_id="confirm") async def async_step_device_config( self, user_input: dict[str, Any] | None = None @@ -145,7 +208,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: afsapi = AFSAPI(self._webfsapi_url, user_input[CONF_PIN]) - name = await afsapi.get_friendly_name() + self._name = await afsapi.get_friendly_name() except FSConnectionError: errors["base"] = "cannot_connect" @@ -156,15 +219,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" else: unique_id = await afsapi.get_radio_id() - await self.async_set_unique_id(unique_id) + await self.async_set_unique_id(unique_id, raise_on_progress=False) self._abort_if_unique_id_configured() - return self.async_create_entry( - title=name, - data={ - CONF_WEBFSAPI_URL: self._webfsapi_url, - CONF_PIN: user_input[CONF_PIN], - }, - ) + return await self._async_create_entry(user_input[CONF_PIN]) data_schema = self.add_suggested_values_to_schema( STEP_DEVICE_CONFIG_DATA_SCHEMA, user_input @@ -174,3 +231,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data_schema=data_schema, errors=errors, ) + + async def _async_create_entry(self, pin: str | None = None): + """Create the entry.""" + + return self.async_create_entry( + title=self._name, + data={CONF_WEBFSAPI_URL: self._webfsapi_url, CONF_PIN: pin or DEFAULT_PIN}, + ) diff --git a/homeassistant/components/frontier_silicon/const.py b/homeassistant/components/frontier_silicon/const.py index 9206db8916..34201fe8f4 100644 --- a/homeassistant/components/frontier_silicon/const.py +++ b/homeassistant/components/frontier_silicon/const.py @@ -4,6 +4,9 @@ DOMAIN = "frontier_silicon" CONF_WEBFSAPI_URL = "webfsapi_url" CONF_PIN = "pin" +SSDP_ST = "urn:schemas-frontier-silicon-com:undok:fsapi:1" +SSDP_ATTR_SPEAKER_NAME = "SPEAKER-NAME" + DEFAULT_PIN = "1234" DEFAULT_PORT = 80 diff --git a/homeassistant/components/frontier_silicon/manifest.json b/homeassistant/components/frontier_silicon/manifest.json index 62e7e61703..9cc928e6f8 100644 --- a/homeassistant/components/frontier_silicon/manifest.json +++ b/homeassistant/components/frontier_silicon/manifest.json @@ -5,5 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/frontier_silicon", "iot_class": "local_polling", - "requirements": ["afsapi==0.2.7"] + "requirements": ["afsapi==0.2.7"], + "ssdp": [{ "st": "urn:schemas-frontier-silicon-com:undok:fsapi:1" }] } diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index 7f73823239..54c17429b5 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -54,21 +54,7 @@ async def async_setup_platform( """Set up the Frontier Silicon platform. YAML is deprecated, and imported automatically. - SSDP discovery is temporarily retained - to be refactor subsequently. """ - if discovery_info is not None: - webfsapi_url = await AFSAPI.get_webfsapi_endpoint( - discovery_info["ssdp_description"] - ) - afsapi = AFSAPI(webfsapi_url, DEFAULT_PIN) - - name = await afsapi.get_friendly_name() - async_add_entities( - [AFSAPIDevice(name, afsapi)], - True, - ) - - return ir.async_create_issue( hass, diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index e5e83d5eae..3f26ec8fa7 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -130,6 +130,11 @@ SSDP = { "st": "urn:schemas-upnp-org:device:fritzbox:1", }, ], + "frontier_silicon": [ + { + "st": "urn:schemas-frontier-silicon-com:undok:fsapi:1", + }, + ], "harmony": [ { "deviceType": "urn:myharmony-com:device:harmony:1", diff --git a/tests/components/frontier_silicon/test_config_flow.py b/tests/components/frontier_silicon/test_config_flow.py index 6a61f0b618..612058af0a 100644 --- a/tests/components/frontier_silicon/test_config_flow.py +++ b/tests/components/frontier_silicon/test_config_flow.py @@ -5,7 +5,12 @@ from afsapi import ConnectionError, InvalidPinException import pytest from homeassistant import config_entries -from homeassistant.components.frontier_silicon.const import CONF_WEBFSAPI_URL, DOMAIN +from homeassistant.components import ssdp +from homeassistant.components.frontier_silicon.const import ( + CONF_WEBFSAPI_URL, + DEFAULT_PIN, + DOMAIN, +) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PIN, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -15,6 +20,23 @@ from tests.common import MockConfigEntry pytestmark = pytest.mark.usefixtures("mock_setup_entry") +MOCK_DISCOVERY = ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_udn="uuid:3dcc7100-f76c-11dd-87af-00226124ca30", + ssdp_st="mock_st", + ssdp_location="http://1.1.1.1/device", + upnp={"SPEAKER-NAME": "Speaker Name"}, +) + +INVALID_MOCK_DISCOVERY = ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_udn="uuid:3dcc7100-f76c-11dd-87af-00226124ca30", + ssdp_st="mock_st", + ssdp_location=None, + upnp={"SPEAKER-NAME": "Speaker Name"}, +) + + async def test_import_success(hass: HomeAssistant) -> None: """Test successful import.""" @@ -49,7 +71,7 @@ async def test_import_webfsapi_endpoint_failures( ) -> None: """Test various failure of get_webfsapi_endpoint.""" with patch( - "afsapi.AFSAPI.get_webfsapi_endpoint", + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_webfsapi_endpoint", side_effect=webfsapi_endpoint_error, ): result = await hass.config_entries.flow.async_init( @@ -80,7 +102,7 @@ async def test_import_radio_id_failures( ) -> None: """Test various failure of get_radio_id.""" with patch( - "afsapi.AFSAPI.get_radio_id", + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_radio_id", side_effect=radio_id_error, ): result = await hass.config_entries.flow.async_init( @@ -157,7 +179,7 @@ async def test_form_nondefault_pin( assert result["errors"] == {} with patch( - "afsapi.AFSAPI.get_friendly_name", + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_friendly_name", side_effect=InvalidPinException, ): result2 = await hass.config_entries.flow.async_configure( @@ -179,8 +201,8 @@ async def test_form_nondefault_pin( assert result3["type"] == FlowResultType.CREATE_ENTRY assert result3["title"] == "Name of the device" assert result3["data"] == { - "webfsapi_url": "http://1.1.1.1:80/webfsapi", - "pin": "4321", + CONF_WEBFSAPI_URL: "http://1.1.1.1:80/webfsapi", + CONF_PIN: "4321", } mock_setup_entry.assert_called_once() @@ -208,7 +230,7 @@ async def test_form_nondefault_pin_invalid( assert result["errors"] == {} with patch( - "afsapi.AFSAPI.get_friendly_name", + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_friendly_name", side_effect=InvalidPinException, ): result2 = await hass.config_entries.flow.async_configure( @@ -222,7 +244,7 @@ async def test_form_nondefault_pin_invalid( assert result2["errors"] is None with patch( - "afsapi.AFSAPI.get_friendly_name", + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_friendly_name", side_effect=friendly_name_error, ): result3 = await hass.config_entries.flow.async_configure( @@ -244,8 +266,8 @@ async def test_form_nondefault_pin_invalid( assert result4["type"] == FlowResultType.CREATE_ENTRY assert result4["title"] == "Name of the device" assert result4["data"] == { - "webfsapi_url": "http://1.1.1.1:80/webfsapi", - "pin": "4321", + CONF_WEBFSAPI_URL: "http://1.1.1.1:80/webfsapi", + CONF_PIN: "4321", } mock_setup_entry.assert_called_once() @@ -272,7 +294,7 @@ async def test_invalid_device_url( assert result["errors"] == {} with patch( - "afsapi.AFSAPI.get_webfsapi_endpoint", + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_webfsapi_endpoint", side_effect=webfsapi_endpoint_error, ): result2 = await hass.config_entries.flow.async_configure( @@ -294,7 +316,102 @@ async def test_invalid_device_url( assert result3["type"] == FlowResultType.CREATE_ENTRY assert result3["title"] == "Name of the device" assert result3["data"] == { - "webfsapi_url": "http://1.1.1.1:80/webfsapi", - "pin": "1234", + CONF_WEBFSAPI_URL: "http://1.1.1.1:80/webfsapi", + CONF_PIN: "1234", } mock_setup_entry.assert_called_once() + + +async def test_ssdp(hass: HomeAssistant, mock_setup_entry: MockConfigEntry) -> None: + """Test a device being discovered.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_DISCOVERY, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "confirm" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Name of the device" + assert result2["data"] == { + CONF_WEBFSAPI_URL: "http://1.1.1.1:80/webfsapi", + CONF_PIN: DEFAULT_PIN, + } + mock_setup_entry.assert_called_once() + + +async def test_ssdp_invalid_location(hass: HomeAssistant) -> None: + """Test a device being discovered.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=INVALID_MOCK_DISCOVERY, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_ssdp_already_configured( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test an already known device being discovered.""" + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_DISCOVERY, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("webfsapi_endpoint_error", "result_error"), + [(ValueError, "unknown"), (ConnectionError, "cannot_connect")], +) +async def test_ssdp_fail( + hass: HomeAssistant, webfsapi_endpoint_error: Exception, result_error: str +) -> None: + """Test a device being discovered but failing to reply.""" + with patch( + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_webfsapi_endpoint", + side_effect=webfsapi_endpoint_error, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_DISCOVERY, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == result_error + + +async def test_ssdp_nondefault_pin(hass: HomeAssistant) -> None: + """Test a device being discovered.""" + + with patch( + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_radio_id", + side_effect=InvalidPinException, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_DISCOVERY, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "invalid_auth" From 2c7c8ccbfe55a70fd5f6e5722c2f3902b1d9fe7e Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Tue, 28 Mar 2023 20:36:26 -0600 Subject: [PATCH 1410/2810] Fix bluetooth polling recovered log missing argument (#90436) --- homeassistant/components/bluetooth/active_update_coordinator.py | 2 +- homeassistant/components/bluetooth/active_update_processor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bluetooth/active_update_coordinator.py b/homeassistant/components/bluetooth/active_update_coordinator.py index d5cf65d872..6d4e67119d 100644 --- a/homeassistant/components/bluetooth/active_update_coordinator.py +++ b/homeassistant/components/bluetooth/active_update_coordinator.py @@ -143,7 +143,7 @@ class ActiveBluetoothDataUpdateCoordinator( self._last_poll = monotonic_time_coarse() if not self.last_poll_successful: - self.logger.debug("%s: Polling recovered") + self.logger.debug("%s: Polling recovered", self.address) self.last_poll_successful = True self._async_handle_bluetooth_poll() diff --git a/homeassistant/components/bluetooth/active_update_processor.py b/homeassistant/components/bluetooth/active_update_processor.py index aabc27ff14..b450c61225 100644 --- a/homeassistant/components/bluetooth/active_update_processor.py +++ b/homeassistant/components/bluetooth/active_update_processor.py @@ -136,7 +136,7 @@ class ActiveBluetoothProcessorCoordinator( self._last_poll = monotonic_time_coarse() if not self.last_poll_successful: - self.logger.debug("%s: Polling recovered") + self.logger.debug("%s: Polling recovered", self.address) self.last_poll_successful = True for processor in self._processors: From ce28bfe5b21a2364727f3722f200546461ac0f77 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 17:01:11 -1000 Subject: [PATCH 1411/2810] Remove unused types argument in statistics query generation (#90431) * Remove unused types argument in statistics query generation * update test --- homeassistant/components/recorder/statistics.py | 3 +-- tests/components/recorder/test_statistics.py | 5 ++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 8025616d24..9f78b0534b 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -1034,7 +1034,6 @@ def _generate_statistics_during_period_stmt( end_time: datetime | None, metadata_ids: list[int] | None, table: type[StatisticsBase], - types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], ) -> StatementLambdaElement: """Prepare a database query for statistics during a given period. @@ -1535,7 +1534,7 @@ def _statistics_during_period_with_session( if "sum" in types: columns = columns.add_columns(table.sum) stmt = _generate_statistics_during_period_stmt( - columns, start_time, end_time, metadata_ids, table, types + columns, start_time, end_time, metadata_ids, table ) stats = cast(Sequence[Row], execute_stmt_lambda_element(session, stmt)) diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index ebad039ca4..ff42979431 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -1246,11 +1246,11 @@ def test_cache_key_for_generate_statistics_during_period_stmt() -> None: """Test cache key for _generate_statistics_during_period_stmt.""" columns = select(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start_ts) stmt = _generate_statistics_during_period_stmt( - columns, dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm, {} + columns, dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm ) cache_key_1 = stmt._generate_cache_key() stmt2 = _generate_statistics_during_period_stmt( - columns, dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm, {} + columns, dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm ) cache_key_2 = stmt2._generate_cache_key() assert cache_key_1 == cache_key_2 @@ -1266,7 +1266,6 @@ def test_cache_key_for_generate_statistics_during_period_stmt() -> None: dt_util.utcnow(), [0], StatisticsShortTerm, - {"max", "mean"}, ) cache_key_3 = stmt3._generate_cache_key() assert cache_key_1 != cache_key_3 From 403dffc12d40aae08f8f6e82a12eb79a9b71412b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 17:28:24 -1000 Subject: [PATCH 1412/2810] Reduce cache key size for queries that only need single columns (#90430) * Reduce cache key size for queries that only need single columns These queries only cared about a single row but would select the whole set of columns from the orm object * wrap it --- homeassistant/components/recorder/migration.py | 6 +++++- homeassistant/components/recorder/statistics.py | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 0eee065a0c..4be0132765 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -143,7 +143,11 @@ def raise_if_exception_missing_str(ex: Exception, match_substrs: Iterable[str]) def _get_schema_version(session: Session) -> int | None: """Get the schema version.""" - res = session.query(SchemaChanges).order_by(SchemaChanges.change_id.desc()).first() + res = ( + session.query(SchemaChanges.schema_version) + .order_by(SchemaChanges.change_id.desc()) + .first() + ) return getattr(res, "schema_version", None) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 9f78b0534b..0122ba4464 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -480,6 +480,11 @@ def compile_statistics(instance: Recorder, start: datetime, fire_events: bool) - return True +def _get_first_id_stmt(start: datetime) -> StatementLambdaElement: + """Return a statement that returns the first run_id at start.""" + return lambda_stmt(lambda: select(StatisticsRuns.run_id).filter_by(start=start)) + + def _compile_statistics( instance: Recorder, session: Session, start: datetime, fire_events: bool ) -> set[str]: @@ -496,7 +501,7 @@ def _compile_statistics( modified_statistic_ids: set[str] = set() # Return if we already have 5-minute statistics for the requested period - if session.query(StatisticsRuns).filter_by(start=start).first(): + if execute_stmt_lambda_element(session, _get_first_id_stmt(start)): _LOGGER.debug("Statistics already compiled for %s-%s", start, end) return modified_statistic_ids From 885be98f8fcc279840cbdb6d5850231d2076e328 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 28 Mar 2023 23:37:43 -0400 Subject: [PATCH 1413/2810] OpenAI to use GPT3.5 (#90423) * OpenAI to use GPT3.5 * Add snapshot --- .../openai_conversation/__init__.py | 42 ++++++---------- .../openai_conversation/config_flow.py | 41 +++++++++++----- .../components/openai_conversation/const.py | 8 +--- .../openai_conversation/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../snapshots/test_init.ambr | 34 +++++++++++++ .../openai_conversation/test_config_flow.py | 6 +-- .../openai_conversation/test_init.py | 48 ++++++++----------- 9 files changed, 107 insertions(+), 78 deletions(-) create mode 100644 tests/components/openai_conversation/snapshots/test_init.ambr diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 355b7764b0..3e67d4e27d 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -16,13 +16,13 @@ from homeassistant.helpers import area_registry as ar, intent, template from homeassistant.util import ulid from .const import ( + CONF_CHAT_MODEL, CONF_MAX_TOKENS, - CONF_MODEL, CONF_PROMPT, CONF_TEMPERATURE, CONF_TOP_P, + DEFAULT_CHAT_MODEL, DEFAULT_MAX_TOKENS, - DEFAULT_MODEL, DEFAULT_PROMPT, DEFAULT_TEMPERATURE, DEFAULT_TOP_P, @@ -63,7 +63,7 @@ class OpenAIAgent(conversation.AbstractConversationAgent): """Initialize the agent.""" self.hass = hass self.entry = entry - self.history: dict[str, str] = {} + self.history: dict[str, list[dict]] = {} @property def attribution(self): @@ -75,14 +75,14 @@ class OpenAIAgent(conversation.AbstractConversationAgent): ) -> conversation.ConversationResult: """Process a sentence.""" raw_prompt = self.entry.options.get(CONF_PROMPT, DEFAULT_PROMPT) - model = self.entry.options.get(CONF_MODEL, DEFAULT_MODEL) + model = self.entry.options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL) max_tokens = self.entry.options.get(CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS) top_p = self.entry.options.get(CONF_TOP_P, DEFAULT_TOP_P) temperature = self.entry.options.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE) if user_input.conversation_id in self.history: conversation_id = user_input.conversation_id - prompt = self.history[conversation_id] + messages = self.history[conversation_id] else: conversation_id = ulid.ulid() try: @@ -97,25 +97,16 @@ class OpenAIAgent(conversation.AbstractConversationAgent): return conversation.ConversationResult( response=intent_response, conversation_id=conversation_id ) + messages = [{"role": "system", "content": prompt}] - user_name = "User" - if ( - user_input.context.user_id - and ( - user := await self.hass.auth.async_get_user(user_input.context.user_id) - ) - and user.name - ): - user_name = user.name + messages.append({"role": "user", "content": user_input.text}) - prompt += f"\n{user_name}: {user_input.text}\nSmart home: " - - _LOGGER.debug("Prompt for %s: %s", model, prompt) + _LOGGER.debug("Prompt for %s: %s", model, messages) try: - result = await openai.Completion.acreate( - engine=model, - prompt=prompt, + result = await openai.ChatCompletion.acreate( + model=model, + messages=messages, max_tokens=max_tokens, top_p=top_p, temperature=temperature, @@ -132,15 +123,12 @@ class OpenAIAgent(conversation.AbstractConversationAgent): ) _LOGGER.debug("Response %s", result) - response = result["choices"][0]["text"].strip() - self.history[conversation_id] = prompt + response - - stripped_response = response - if response.startswith("Smart home:"): - stripped_response = response[11:].strip() + response = result["choices"][0]["message"] + messages.append(response) + self.history[conversation_id] = messages intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_speech(stripped_response) + intent_response.async_set_speech(response["content"]) return conversation.ConversationResult( response=intent_response, conversation_id=conversation_id ) diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index 2db5e98a1f..892d794bca 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -22,13 +22,13 @@ from homeassistant.helpers.selector import ( ) from .const import ( + CONF_CHAT_MODEL, CONF_MAX_TOKENS, - CONF_MODEL, CONF_PROMPT, CONF_TEMPERATURE, CONF_TOP_P, + DEFAULT_CHAT_MODEL, DEFAULT_MAX_TOKENS, - DEFAULT_MODEL, DEFAULT_PROMPT, DEFAULT_TEMPERATURE, DEFAULT_TOP_P, @@ -46,7 +46,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( DEFAULT_OPTIONS = types.MappingProxyType( { CONF_PROMPT: DEFAULT_PROMPT, - CONF_MODEL: DEFAULT_MODEL, + CONF_CHAT_MODEL: DEFAULT_CHAT_MODEL, CONF_MAX_TOKENS: DEFAULT_MAX_TOKENS, CONF_TOP_P: DEFAULT_TOP_P, CONF_TEMPERATURE: DEFAULT_TEMPERATURE, @@ -131,13 +131,32 @@ def openai_config_option_schema(options: MappingProxyType[str, Any]) -> dict: if not options: options = DEFAULT_OPTIONS return { - vol.Required(CONF_PROMPT, default=options.get(CONF_PROMPT)): TemplateSelector(), - vol.Required(CONF_MODEL, default=options.get(CONF_MODEL)): str, - vol.Required(CONF_MAX_TOKENS, default=options.get(CONF_MAX_TOKENS)): int, - vol.Required(CONF_TOP_P, default=options.get(CONF_TOP_P)): NumberSelector( - NumberSelectorConfig(min=0, max=1, step=0.05) - ), - vol.Required( - CONF_TEMPERATURE, default=options.get(CONF_TEMPERATURE) + vol.Optional( + CONF_PROMPT, + description={"suggested_value": options[CONF_PROMPT]}, + default=DEFAULT_PROMPT, + ): TemplateSelector(), + vol.Optional( + CONF_CHAT_MODEL, + description={ + # New key in HA 2023.4 + "suggested_value": options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL) + }, + default=DEFAULT_CHAT_MODEL, + ): str, + vol.Optional( + CONF_MAX_TOKENS, + description={"suggested_value": options[CONF_MAX_TOKENS]}, + default=DEFAULT_MAX_TOKENS, + ): int, + vol.Optional( + CONF_TOP_P, + description={"suggested_value": options[CONF_TOP_P]}, + default=DEFAULT_TOP_P, + ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), + vol.Optional( + CONF_TEMPERATURE, + description={"suggested_value": options[CONF_TEMPERATURE]}, + default=DEFAULT_TEMPERATURE, ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), } diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index ed914efeb6..88289eb90b 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -22,13 +22,9 @@ An overview of the areas and the devices in this smart home: Answer the user's questions about the world truthfully. If the user wants to control a device, reject the request and suggest using the Home Assistant app. - -Now finish this conversation: - -Smart home: How can I assist? """ -CONF_MODEL = "model" -DEFAULT_MODEL = "text-davinci-003" +CONF_CHAT_MODEL = "chat_model" +DEFAULT_CHAT_MODEL = "gpt-3.5-turbo" CONF_MAX_TOKENS = "max_tokens" DEFAULT_MAX_TOKENS = 150 CONF_TOP_P = "top_p" diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index 0e245eb78b..88d347355e 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/openai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["openai==0.26.2"] + "requirements": ["openai==0.27.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 49ac9171ec..aeb7033d0a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1269,7 +1269,7 @@ open-garage==0.2.0 open-meteo==0.2.1 # homeassistant.components.openai_conversation -openai==0.26.2 +openai==0.27.2 # homeassistant.components.opencv # opencv-python-headless==4.6.0.66 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4213338da8..a21ef8b32b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -947,7 +947,7 @@ open-garage==0.2.0 open-meteo==0.2.1 # homeassistant.components.openai_conversation -openai==0.26.2 +openai==0.27.2 # homeassistant.components.openerz openerz-api==0.2.0 diff --git a/tests/components/openai_conversation/snapshots/test_init.ambr b/tests/components/openai_conversation/snapshots/test_init.ambr new file mode 100644 index 0000000000..bc06f51f41 --- /dev/null +++ b/tests/components/openai_conversation/snapshots/test_init.ambr @@ -0,0 +1,34 @@ +# serializer version: 1 +# name: test_default_prompt + list([ + dict({ + 'content': ''' + This smart home is controlled by Home Assistant. + + An overview of the areas and the devices in this smart home: + + Test Area: + - Test Device (Test Model) + + Test Area 2: + - Test Device 2 + - Test Device 3 (Test Model 3A) + - Test Device 4 + - 1 (3) + + Answer the user's questions about the world truthfully. + + If the user wants to control a device, reject the request and suggest using the Home Assistant app. + ''', + 'role': 'system', + }), + dict({ + 'content': 'hello', + 'role': 'user', + }), + dict({ + 'content': 'Hello, how can I help you?', + 'role': 'assistant', + }), + ]) +# --- diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index 25849882e8..4ce677d8cc 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -6,8 +6,8 @@ import pytest from homeassistant import config_entries from homeassistant.components.openai_conversation.const import ( - CONF_MODEL, - DEFAULT_MODEL, + CONF_CHAT_MODEL, + DEFAULT_CHAT_MODEL, DOMAIN, ) from homeassistant.core import HomeAssistant @@ -72,7 +72,7 @@ async def test_options( assert options["type"] == FlowResultType.CREATE_ENTRY assert options["data"]["prompt"] == "Speak like a pirate" assert options["data"]["max_tokens"] == 200 - assert options["data"][CONF_MODEL] == DEFAULT_MODEL + assert options["data"][CONF_CHAT_MODEL] == DEFAULT_CHAT_MODEL @pytest.mark.parametrize( diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index 3b78a90f40..144d77beab 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -2,6 +2,7 @@ from unittest.mock import patch from openai import error +from syrupy.assertion import SnapshotAssertion from homeassistant.components import conversation from homeassistant.core import Context, HomeAssistant @@ -15,6 +16,7 @@ async def test_default_prompt( mock_init_component, area_registry: ar.AreaRegistry, device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test that the default prompt works.""" for i in range(3): @@ -86,40 +88,30 @@ async def test_default_prompt( model=3, suggested_area="Test Area 2", ) - with patch("openai.Completion.acreate") as mock_create: + with patch( + "openai.ChatCompletion.acreate", + return_value={ + "choices": [ + { + "message": { + "role": "assistant", + "content": "Hello, how can I help you?", + } + } + ] + }, + ) as mock_create: result = await conversation.async_converse(hass, "hello", None, Context()) assert result.response.response_type == intent.IntentResponseType.ACTION_DONE - assert ( - mock_create.mock_calls[0][2]["prompt"] - == """This smart home is controlled by Home Assistant. - -An overview of the areas and the devices in this smart home: - -Test Area: -- Test Device (Test Model) - -Test Area 2: -- Test Device 2 -- Test Device 3 (Test Model 3A) -- Test Device 4 -- 1 (3) - -Answer the user's questions about the world truthfully. - -If the user wants to control a device, reject the request and suggest using the Home Assistant app. - -Now finish this conversation: - -Smart home: How can I assist? -User: hello -Smart home: """ - ) + assert mock_create.mock_calls[0][2]["messages"] == snapshot async def test_error_handling(hass: HomeAssistant, mock_init_component) -> None: """Test that the default prompt works.""" - with patch("openai.Completion.acreate", side_effect=error.ServiceUnavailableError): + with patch( + "openai.ChatCompletion.acreate", side_effect=error.ServiceUnavailableError + ): result = await conversation.async_converse(hass, "hello", None, Context()) assert result.response.response_type == intent.IntentResponseType.ERROR, result @@ -138,7 +130,7 @@ async def test_template_error( ) with patch( "openai.Engine.list", - ), patch("openai.Completion.acreate"): + ), patch("openai.ChatCompletion.acreate"): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() result = await conversation.async_converse(hass, "hello", None, Context()) From 5dc96a6952edf659c9037cec7d2313748759f51f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 17:52:44 -1000 Subject: [PATCH 1414/2810] Fix unbound variable in sql when session setup fails (#90439) Traceback (most recent call last): File "/Users/bdraco/home-assistant/homeassistant/helpers/entity_platform.py", line 304, in _async_setup_platform await asyncio.shield(task) File "/Users/bdraco/home-assistant/homeassistant/components/sql/sensor.py", line 75, in async_setup_platform await async_setup_sensor( File "/Users/bdraco/home-assistant/homeassistant/components/sql/sensor.py", line 150, in async_setup_sensor sessmaker := await hass.async_add_executor_job( File "/opt/homebrew/Cellar/python@3.10/3.10.9/Frameworks/Python.framework/Versions/3.10/lib/python3.10/concurrent/futures/thread.py", line 58, in run result = self.fn(*self.args, **self.kwargs) File "/Users/bdraco/home-assistant/homeassistant/components/sql/sensor.py", line 205, in _validate_and_get_session_maker_for_db_url if sess: UnboundLocalError: local variable 'sess' referenced before assignment --- homeassistant/components/sql/sensor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 57818ef27e..93d3e7be35 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -180,11 +180,12 @@ def _validate_and_get_session_maker_for_db_url(db_url: str) -> scoped_session | This does I/O and should be run in the executor. """ + sess: Session | None = None try: engine = sqlalchemy.create_engine(db_url, future=True) sessmaker = scoped_session(sessionmaker(bind=engine, future=True)) # Run a dummy query just to test the db_url - sess: Session = sessmaker() + sess = sessmaker() sess.execute(sqlalchemy.text("SELECT 1;")) except SQLAlchemyError as err: From 8096be768d1098547dcdfff813158cd94d9acf5e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 17:54:03 -1000 Subject: [PATCH 1415/2810] Isolate the sql integration with a separate query cache (#90438) * Isolate the sql integration with a seperate query cache If there were a lot of sql integrations they could affect the performance of the recorder/logbook/history since they were sharing the same LRU and since the sql sensor updates frequently it would evict the recorder queries from the LRU. * generate in stmt * avoid double gen * Revert "avoid double gen" This reverts commit 6a5aa65268da12e2cd0e73e0bfb46db6e7e6214d. --- homeassistant/components/sql/sensor.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 93d3e7be35..c19c2c258b 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -6,9 +6,12 @@ import decimal import logging import sqlalchemy +from sqlalchemy import lambda_stmt from sqlalchemy.engine import Result from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session, scoped_session, sessionmaker +from sqlalchemy.sql.lambdas import StatementLambdaElement +from sqlalchemy.util import LRUCache from homeassistant.components.recorder import CONF_DB_URL, get_instance from homeassistant.components.sensor import ( @@ -38,6 +41,8 @@ from .util import resolve_db_url _LOGGER = logging.getLogger(__name__) +_SQL_LAMBDA_CACHE: LRUCache = LRUCache(1000) + def redact_credentials(data: str) -> str: """Redact credentials from string data.""" @@ -202,6 +207,12 @@ def _validate_and_get_session_maker_for_db_url(db_url: str) -> scoped_session | sess.close() +def _generate_lambda_stmt(query: str) -> StatementLambdaElement: + """Generate the lambda statement.""" + text = sqlalchemy.text(query) + return lambda_stmt(lambda: text, lambda_cache=_SQL_LAMBDA_CACHE) + + class SQLSensor(SensorEntity): """Representation of an SQL sensor.""" @@ -234,6 +245,7 @@ class SQLSensor(SensorEntity): self._attr_extra_state_attributes = {} self._attr_unique_id = unique_id self._use_database_executor = use_database_executor + self._lambda_stmt = _generate_lambda_stmt(query) if not yaml and unique_id: self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, @@ -255,7 +267,7 @@ class SQLSensor(SensorEntity): self._attr_extra_state_attributes = {} sess: scoped_session = self.sessionmaker() try: - result: Result = sess.execute(sqlalchemy.text(self._query)) + result: Result = sess.execute(self._lambda_stmt) except SQLAlchemyError as err: _LOGGER.error( "Error executing query %s: %s", From 12edaa052c26c258d4c6bb938f1a19cddd159797 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Wed, 29 Mar 2023 08:29:44 +0200 Subject: [PATCH 1416/2810] Fix data issue for energyzero during midnight (#90433) --- homeassistant/components/energyzero/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/energyzero/manifest.json b/homeassistant/components/energyzero/manifest.json index 8bdfb36ad7..05d23ca446 100644 --- a/homeassistant/components/energyzero/manifest.json +++ b/homeassistant/components/energyzero/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/energyzero", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["energyzero==0.3.1"] + "requirements": ["energyzero==0.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index aeb7033d0a..ef36e83a15 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -652,7 +652,7 @@ emulated_roku==0.2.1 energyflip-client==0.2.2 # homeassistant.components.energyzero -energyzero==0.3.1 +energyzero==0.4.1 # homeassistant.components.enocean enocean==0.50 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a21ef8b32b..0783ab3fc8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -511,7 +511,7 @@ emulated_roku==0.2.1 energyflip-client==0.2.2 # homeassistant.components.energyzero -energyzero==0.3.1 +energyzero==0.4.1 # homeassistant.components.enocean enocean==0.50 From 0327f312f280d43132764fe4a70b3bf01089ee40 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Wed, 29 Mar 2023 08:37:20 +0200 Subject: [PATCH 1417/2810] Fix data issue for easyEnergy during midnight (#90434) --- homeassistant/components/easyenergy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json index 0954269628..803530fd6f 100644 --- a/homeassistant/components/easyenergy/manifest.json +++ b/homeassistant/components/easyenergy/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/easyenergy", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["easyenergy==0.2.2"] + "requirements": ["easyenergy==0.2.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index ef36e83a15..c331110390 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -625,7 +625,7 @@ dynalite_devices==0.1.47 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==0.2.2 +easyenergy==0.2.3 # homeassistant.components.ebusd ebusdpy==0.0.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0783ab3fc8..11c99c5896 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -493,7 +493,7 @@ dynalite_devices==0.1.47 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==0.2.2 +easyenergy==0.2.3 # homeassistant.components.elgato elgato==4.0.1 From b58c90602fa73b1d9b528834bd083712c9fa5d42 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 21:39:44 -1000 Subject: [PATCH 1418/2810] Bump yalexs-ble to 2.1.13 (#90442) --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 5528b79353..07ecc2a1be 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.12"] + "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.13"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 6cff0dd8c6..7c45f309e6 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.1.12"] + "requirements": ["yalexs-ble==2.1.13"] } diff --git a/requirements_all.txt b/requirements_all.txt index c331110390..ea3c58add0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2668,7 +2668,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.12 +yalexs-ble==2.1.13 # homeassistant.components.august yalexs==1.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 11c99c5896..3db436ada3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1911,7 +1911,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.12 +yalexs-ble==2.1.13 # homeassistant.components.august yalexs==1.2.7 From c06bc28434233f29d78f257fcf8d5684c4d924ac Mon Sep 17 00:00:00 2001 From: MatthewFlamm <39341281+MatthewFlamm@users.noreply.github.com> Date: Wed, 29 Mar 2023 04:34:29 -0400 Subject: [PATCH 1419/2810] Limit observations requested for NWS (#90137) * fetch data only for 70 minutes * Use timezone aware now * Type hint Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/nws/__init__.py | 7 ++++++- homeassistant/components/nws/const.py | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nws/__init__.py b/homeassistant/components/nws/__init__.py index fed7642605..ef0731ee94 100644 --- a/homeassistant/components/nws/__init__.py +++ b/homeassistant/components/nws/__init__.py @@ -26,6 +26,7 @@ from .const import ( COORDINATOR_OBSERVATION, DOMAIN, NWS_DATA, + UPDATE_TIME_PERIOD, ) _LOGGER = logging.getLogger(__name__) @@ -110,11 +111,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: nws_data = SimpleNWS(latitude, longitude, api_key, client_session) await nws_data.set_station(station) + async def update_observation() -> None: + """Retrieve recent observations.""" + await nws_data.update_observation(start_time=utcnow() - UPDATE_TIME_PERIOD) + coordinator_observation = NwsDataUpdateCoordinator( hass, _LOGGER, name=f"NWS observation station {station}", - update_method=nws_data.update_observation, + update_method=update_observation, update_interval=DEFAULT_SCAN_INTERVAL, failed_update_interval=FAILED_SCAN_INTERVAL, request_refresh_debouncer=debounce.Debouncer( diff --git a/homeassistant/components/nws/const.py b/homeassistant/components/nws/const.py index 96844edd80..109af7a565 100644 --- a/homeassistant/components/nws/const.py +++ b/homeassistant/components/nws/const.py @@ -82,3 +82,5 @@ COORDINATOR_FORECAST_HOURLY = "coordinator_forecast_hourly" OBSERVATION_VALID_TIME = timedelta(minutes=20) FORECAST_VALID_TIME = timedelta(minutes=45) +# A lot of stations update once hourly plus some wiggle room +UPDATE_TIME_PERIOD = timedelta(minutes=70) From 8dbcbd156adb3fa1f9c2e70e90936422313faa98 Mon Sep 17 00:00:00 2001 From: Renat Sibgatulin Date: Wed, 29 Mar 2023 09:33:27 +0000 Subject: [PATCH 1420/2810] Add new sensors to airq (#90413) Support for the sensors introduced in air-Q firmware v1.82.0 --- homeassistant/components/airq/sensor.py | 130 +++++++++++++++++++++++- 1 file changed, 125 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/airq/sensor.py b/homeassistant/components/airq/sensor.py index a47c308279..7f0d51fcaa 100644 --- a/homeassistant/components/airq/sensor.py +++ b/homeassistant/components/airq/sensor.py @@ -51,6 +51,13 @@ class AirQEntityDescription(SensorEntityDescription, AirQEntityDescriptionMixin) # Keys must match those in the data dictionary SENSOR_TYPES: list[AirQEntityDescription] = [ + AirQEntityDescription( + key="c2h4o", + name="Acetaldehyde", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("c2h4o"), + ), AirQEntityDescription( key="nh3_MR100", name="Ammonia", @@ -58,6 +65,27 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("nh3_MR100"), ), + AirQEntityDescription( + key="ash3", + name="Arsine", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("ash3"), + ), + AirQEntityDescription( + key="br2", + name="Bromine", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("br2"), + ), + AirQEntityDescription( + key="ch4s", + name="CH4S", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("ch4s"), + ), AirQEntityDescription( key="cl2_M20", name="Chlorine", @@ -65,6 +93,13 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("cl2_M20"), ), + AirQEntityDescription( + key="clo2", + name="ClO2", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("clo2"), + ), AirQEntityDescription( key="co", name="CO", @@ -80,6 +115,13 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("co2"), ), + AirQEntityDescription( + key="cs2", + name="CS2", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("cs2"), + ), AirQEntityDescription( key="dewpt", name="Dew point", @@ -95,6 +137,13 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("ethanol"), ), + AirQEntityDescription( + key="c2h4", + name="Ethylene", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("c2h4"), + ), AirQEntityDescription( key="ch2o_M10", name="Formaldehyde", @@ -102,6 +151,13 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("ch2o_M10"), ), + AirQEntityDescription( + key="f2", + name="Fluorine", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("f2"), + ), AirQEntityDescription( key="h2s", name="H2S", @@ -109,6 +165,27 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("h2s"), ), + AirQEntityDescription( + key="hcl", + name="HCl", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("hcl"), + ), + AirQEntityDescription( + key="hcn", + name="HCN", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("hcn"), + ), + AirQEntityDescription( + key="hf", + name="HF", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("hf"), + ), AirQEntityDescription( key="health", name="Health Index", @@ -140,6 +217,13 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("h2_M1000"), ), + AirQEntityDescription( + key="h2o2", + name="Hydrogen peroxide", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("h2o2"), + ), AirQEntityDescription( key="ch4_MIPEX", name="Methane", @@ -172,12 +256,11 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ value=lambda data: data.get("no2"), ), AirQEntityDescription( - key="o3", - name="Ozone", - device_class=SensorDeviceClass.OZONE, - native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + key="acid_M100", + name="Organic acid", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=SensorStateClass.MEASUREMENT, - value=lambda data: data.get("o3"), + value=lambda data: data.get("acid_M100"), ), AirQEntityDescription( key="oxygen", @@ -187,6 +270,14 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ value=lambda data: data.get("oxygen"), icon="mdi:leaf", ), + AirQEntityDescription( + key="o3", + name="Ozone", + device_class=SensorDeviceClass.OZONE, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("o3"), + ), AirQEntityDescription( key="performance", name="Performance Index", @@ -195,6 +286,13 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ icon="mdi:head-check", value=lambda data: data.get("performance", 0.0) / 10.0, ), + AirQEntityDescription( + key="ph3", + name="PH3", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("ph3"), + ), AirQEntityDescription( key="pm1", name="PM1", @@ -245,6 +343,20 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("c3h8_MIPEX"), ), + AirQEntityDescription( + key="refigerant", + name="Refrigerant", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("refigerant"), + ), + AirQEntityDescription( + key="sih4", + name="SiH4", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("sih4"), + ), AirQEntityDescription( key="so2", name="SO2", @@ -299,6 +411,14 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("tvoc_ionsc"), ), + AirQEntityDescription( + key="virus", + name="Virus Index", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:virus-off", + value=lambda data: data.get("virus", 0.0), + ), ] From d427c35c871ff3e4be4adbc803d7bfc9677b624c Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 29 Mar 2023 14:41:38 +0200 Subject: [PATCH 1421/2810] Reolink improve config flow login (#90036) --- .../components/reolink/config_flow.py | 5 ++++- homeassistant/components/reolink/host.py | 18 ++++++++++-------- homeassistant/components/reolink/strings.json | 4 ++-- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index 15f3dfa613..a29871f28d 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -108,7 +108,10 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle the initial step.""" errors = {} - placeholders = {"error": ""} + placeholders = { + "error": "", + "troubleshooting_link": "https://www.home-assistant.io/integrations/reolink/#troubleshooting", + } if user_input is not None: if CONF_HOST not in user_input: diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 1c0f97b6a2..f781074648 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -82,9 +82,15 @@ class ReolinkHost: f"'{self._api.user_level}', only admin users can change camera settings" ) + enable_rtsp = None enable_onvif = None enable_rtmp = None - enable_rtsp = None + + if not self._api.rtsp_enabled: + _LOGGER.debug( + "RTSP is disabled on %s, trying to enable it", self._api.nvr_name + ) + enable_rtsp = True if not self._api.onvif_enabled: _LOGGER.debug( @@ -97,11 +103,6 @@ class ReolinkHost: "RTMP is disabled on %s, trying to enable it", self._api.nvr_name ) enable_rtmp = True - elif not self._api.rtsp_enabled and self._api.protocol == "rtsp": - _LOGGER.debug( - "RTSP is disabled on %s, trying to enable it", self._api.nvr_name - ) - enable_rtsp = True if enable_onvif or enable_rtmp or enable_rtsp: try: @@ -112,13 +113,14 @@ class ReolinkHost: ) except ReolinkError: ports = "" + if enable_rtsp: + ports += "RTSP " + if enable_onvif: ports += "ONVIF " if enable_rtmp: ports += "RTMP " - elif enable_rtsp: - ports += "RTSP " ir.async_create_issue( self._hass, diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 50c561530e..c36001e037 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -3,7 +3,7 @@ "flow_title": "{hostname} ({ip_address})", "step": { "user": { - "description": "{error}", + "description": "See the [troubleshooting steps]({troubleshooting_link}) if you encounter problems. {error}", "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", @@ -19,7 +19,7 @@ }, "error": { "api_error": "API error occurred", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%], check the IP address of the camera and see the troubleshooting steps in the documentation", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "not_admin": "User needs to be admin, user ''{username}'' has authorisation level ''{userlevel}''", "unknown": "[%key:common::config_flow::error::unknown%]" From a1c94919ded23ae1201e891ddff48620adaefc4b Mon Sep 17 00:00:00 2001 From: mletenay Date: Wed, 29 Mar 2023 16:01:14 +0200 Subject: [PATCH 1422/2810] Fix goodwe export limit unit on single phase DT inverters (#90427) * Fix export limit unit on single phase DT inverters * Update homeassistant/components/goodwe/number.py --------- Co-authored-by: Erik Montnemery --- homeassistant/components/goodwe/manifest.json | 2 +- homeassistant/components/goodwe/number.py | 13 +++++++++---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/goodwe/manifest.json b/homeassistant/components/goodwe/manifest.json index 53e093758c..8dad8454d6 100644 --- a/homeassistant/components/goodwe/manifest.json +++ b/homeassistant/components/goodwe/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/goodwe", "iot_class": "local_polling", "loggers": ["goodwe"], - "requirements": ["goodwe==0.2.25"] + "requirements": ["goodwe==0.2.29"] } diff --git a/homeassistant/components/goodwe/number.py b/homeassistant/components/goodwe/number.py index 9f997daec4..3f9714aa37 100644 --- a/homeassistant/components/goodwe/number.py +++ b/homeassistant/components/goodwe/number.py @@ -39,8 +39,13 @@ class GoodweNumberEntityDescription( """Class describing Goodwe number entities.""" +def _get_setting_unit(inverter: Inverter, setting: str) -> str: + """Return the unit of an inverter setting.""" + return next((s.unit for s in inverter.settings() if s.id_ == setting), "") + + NUMBERS = ( - # non DT inverters (limit in W) + # Export limit in W GoodweNumberEntityDescription( key="grid_export_limit", name="Grid export limit", @@ -53,9 +58,9 @@ NUMBERS = ( native_max_value=10000, getter=lambda inv: inv.get_grid_export_limit(), setter=lambda inv, val: inv.set_grid_export_limit(val), - filter=lambda inv: type(inv).__name__ != "DT", + filter=lambda inv: _get_setting_unit(inv, "grid_export_limit") != "%", ), - # DT inverters (limit is in %) + # Export limit in % GoodweNumberEntityDescription( key="grid_export_limit", name="Grid export limit", @@ -67,7 +72,7 @@ NUMBERS = ( native_max_value=100, getter=lambda inv: inv.get_grid_export_limit(), setter=lambda inv, val: inv.set_grid_export_limit(val), - filter=lambda inv: type(inv).__name__ == "DT", + filter=lambda inv: _get_setting_unit(inv, "grid_export_limit") == "%", ), GoodweNumberEntityDescription( key="battery_discharge_depth", diff --git a/requirements_all.txt b/requirements_all.txt index ea3c58add0..f0c54881fc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -798,7 +798,7 @@ glances_api==0.4.1 goalzero==0.2.1 # homeassistant.components.goodwe -goodwe==0.2.25 +goodwe==0.2.29 # homeassistant.components.google_mail google-api-python-client==2.71.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3db436ada3..ea2d06816e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -614,7 +614,7 @@ glances_api==0.4.1 goalzero==0.2.1 # homeassistant.components.goodwe -goodwe==0.2.25 +goodwe==0.2.29 # homeassistant.components.google_mail google-api-python-client==2.71.0 From f7925763a46a52cf7f6191285a309d7d2030b906 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Mar 2023 17:20:51 +0200 Subject: [PATCH 1423/2810] Make abort_entries_match available in options flow (#90406) * Make abort_entries_match available in options flow * Add tests * Exclude ignore entries and add test * Move to OptionsFlow * Adjust tests * Use mock_config_flow * Use AbortFlow * Remove duplicate code --- homeassistant/components/imap/config_flow.py | 67 ++++++-------- homeassistant/config_entries.py | 58 +++++++++--- tests/test_config_entries.py | 93 ++++++++++++++++++++ 3 files changed, 167 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index c855d099b4..8dd3019878 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import callback -from homeassistant.data_entry_flow import FlowResult +from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import config_validation as cv from .const import ( @@ -148,50 +148,39 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class OptionsFlow(config_entries.OptionsFlowWithConfigEntry): """Option flow handler.""" - def _async_abort_entries_match( - self, match_dict: dict[str, Any] | None - ) -> dict[str, str]: - """Validate the user input against other config entries.""" - if match_dict is None: - return {} - - errors: dict[str, str] = {} - for entry in [ - entry - for entry in self.hass.config_entries.async_entries(DOMAIN) - if entry is not self.config_entry - ]: - if all(item in entry.data.items() for item in match_dict.items()): - errors["base"] = "already_configured" - break - return errors - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Manage the options.""" - errors: dict[str, str] = self._async_abort_entries_match( - { - CONF_SERVER: self._config_entry.data[CONF_SERVER], - CONF_USERNAME: self._config_entry.data[CONF_USERNAME], - CONF_FOLDER: user_input[CONF_FOLDER], - CONF_SEARCH: user_input[CONF_SEARCH], - } - if user_input - else None - ) + errors: dict[str, str] | None = None entry_data: dict[str, Any] = dict(self._config_entry.data) - if not errors and user_input is not None: - entry_data.update(user_input) - errors = await validate_input(entry_data) - if not errors: - self.hass.config_entries.async_update_entry( - self.config_entry, data=entry_data + if user_input is not None: + try: + self._async_abort_entries_match( + { + CONF_SERVER: self._config_entry.data[CONF_SERVER], + CONF_USERNAME: self._config_entry.data[CONF_USERNAME], + CONF_FOLDER: user_input[CONF_FOLDER], + CONF_SEARCH: user_input[CONF_SEARCH], + } + if user_input + else None ) - self.hass.async_create_task( - self.hass.config_entries.async_reload(self.config_entry.entry_id) - ) - return self.async_create_entry(data={}) + except AbortFlow as err: + errors = {"base": err.reason} + else: + entry_data.update(user_input) + errors = await validate_input(entry_data) + if not errors: + self.hass.config_entries.async_update_entry( + self.config_entry, data=entry_data + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload( + self.config_entry.entry_id + ) + ) + return self.async_create_entry(data={}) schema = self.add_suggested_values_to_schema(OPTIONS_SCHEMA, entry_data) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index b21ae391e2..454cfeade2 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1468,6 +1468,28 @@ async def _old_conf_migrator(old_config: dict[str, Any]) -> dict[str, Any]: return {"entries": old_config} +@callback +def _async_abort_entries_match( + other_entries: list[ConfigEntry], match_dict: dict[str, Any] | None = None +) -> None: + """Abort if current entries match all data. + + Requires `already_configured` in strings.json in user visible flows. + """ + if match_dict is None: + match_dict = {} # Match any entry + for entry in other_entries: + if all( + item + in ChainMap( + entry.options, # type: ignore[arg-type] + entry.data, # type: ignore[arg-type] + ).items() + for item in match_dict.items() + ): + raise data_entry_flow.AbortFlow("already_configured") + + class ConfigFlow(data_entry_flow.FlowHandler): """Base class for config flows with some helpers.""" @@ -1505,18 +1527,9 @@ class ConfigFlow(data_entry_flow.FlowHandler): Requires `already_configured` in strings.json in user visible flows. """ - if match_dict is None: - match_dict = {} # Match any entry - for entry in self._async_current_entries(include_ignore=False): - if all( - item - in ChainMap( - entry.options, # type: ignore[arg-type] - entry.data, # type: ignore[arg-type] - ).items() - for item in match_dict.items() - ): - raise data_entry_flow.AbortFlow("already_configured") + _async_abort_entries_match( + self._async_current_entries(include_ignore=False), match_dict + ) @callback def _abort_if_unique_id_configured( @@ -1858,6 +1871,27 @@ class OptionsFlow(data_entry_flow.FlowHandler): handler: str + @callback + def _async_abort_entries_match( + self, match_dict: dict[str, Any] | None = None + ) -> None: + """Abort if another current entry matches all data. + + Requires `already_configured` in strings.json in user visible flows. + """ + + config_entry = cast( + ConfigEntry, self.hass.config_entries.async_get_entry(self.handler) + ) + _async_abort_entries_match( + [ + entry + for entry in self.hass.config_entries.async_entries(config_entry.domain) + if entry is not config_entry and entry.source != SOURCE_IGNORE + ], + match_dict, + ) + class OptionsFlowWithConfigEntry(OptionsFlow): """Base class for options flows with config entry and options.""" diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index c8cdc56198..60b9a250c1 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -40,6 +40,7 @@ from .common import ( MockModule, MockPlatform, async_fire_time_changed, + mock_config_flow, mock_coro, mock_entity_platform, mock_integration, @@ -3388,6 +3389,98 @@ async def test__async_abort_entries_match( assert result["reason"] == reason +@pytest.mark.parametrize( + ("matchers", "reason"), + [ + ({}, "already_configured"), + ({"host": "3.3.3.3"}, "no_match"), + ({"vendor": "no_match"}, "no_match"), + ({"host": "3.4.5.6"}, "already_configured"), + ({"host": "3.4.5.6", "ip": "3.4.5.6"}, "no_match"), + ({"host": "3.4.5.6", "ip": "1.2.3.4"}, "already_configured"), + ({"host": "3.4.5.6", "ip": "1.2.3.4", "port": 23}, "already_configured"), + ( + {"host": "9.9.9.9", "ip": "6.6.6.6", "port": 12, "vendor": "zoo"}, + "already_configured", + ), + ({"vendor": "zoo"}, "already_configured"), + ({"ip": "9.9.9.9"}, "already_configured"), + ({"ip": "7.7.7.7"}, "no_match"), # ignored + ({"vendor": "data"}, "no_match"), + ( + {"vendor": "options"}, + "already_configured", + ), # ensure options takes precedence over data + ], +) +async def test__async_abort_entries_match_options_flow( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + matchers: dict[str, str], + reason: str, +) -> None: + """Test aborting if matching config entries exist.""" + MockConfigEntry( + domain="test_abort", data={"ip": "1.2.3.4", "host": "4.5.6.7", "port": 23} + ).add_to_hass(hass) + MockConfigEntry( + domain="test_abort", data={"ip": "9.9.9.9", "host": "4.5.6.7", "port": 23} + ).add_to_hass(hass) + MockConfigEntry( + domain="test_abort", data={"ip": "1.2.3.4", "host": "3.4.5.6", "port": 23} + ).add_to_hass(hass) + MockConfigEntry( + domain="test_abort", + source=config_entries.SOURCE_IGNORE, + data={"ip": "7.7.7.7", "host": "4.5.6.7", "port": 23}, + ).add_to_hass(hass) + MockConfigEntry( + domain="test_abort", + data={"ip": "6.6.6.6", "host": "9.9.9.9", "port": 12}, + options={"vendor": "zoo"}, + ).add_to_hass(hass) + MockConfigEntry( + domain="test_abort", + data={"vendor": "data"}, + options={"vendor": "options"}, + ).add_to_hass(hass) + + original_entry = MockConfigEntry(domain="test_abort", data={}) + original_entry.add_to_hass(hass) + + mock_setup_entry = AsyncMock(return_value=True) + + mock_integration(hass, MockModule("test_abort", async_setup_entry=mock_setup_entry)) + mock_entity_platform(hass, "config_flow.test_abort", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Test options flow.""" + + class _OptionsFlow(config_entries.OptionsFlow): + """Test flow.""" + + async def async_step_init(self, user_input=None): + """Test user step.""" + if errors := self._async_abort_entries_match(user_input): + return self.async_abort(reason=errors["base"]) + return self.async_abort(reason="no_match") + + return _OptionsFlow() + + with mock_config_flow("test_abort", TestFlow): + result = await hass.config_entries.options.async_init( + original_entry.entry_id, data=matchers + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == reason + + async def test_loading_old_data( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: From 81c39e42f49b23a82e667421d222e365f9765bdf Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 29 Mar 2023 12:25:08 -0400 Subject: [PATCH 1424/2810] Bump home-assistant-intents to 2023.3.29 (#90459) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 7630eed01f..0753fcd5af 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.0.6", "home-assistant-intents==2023.2.28"] + "requirements": ["hassil==1.0.6", "home-assistant-intents==2023.3.29"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index dea105f296..7a1ae9b5c4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -26,7 +26,7 @@ hass-nabucasa==0.63.1 hassil==1.0.6 home-assistant-bluetooth==1.9.3 home-assistant-frontend==20230309.1 -home-assistant-intents==2023.2.28 +home-assistant-intents==2023.3.29 httpx==0.23.3 ifaddr==0.1.7 janus==1.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index f0c54881fc..15553abc14 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -910,7 +910,7 @@ holidays==0.21.13 home-assistant-frontend==20230309.1 # homeassistant.components.conversation -home-assistant-intents==2023.2.28 +home-assistant-intents==2023.3.29 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ea2d06816e..cd04c60f15 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -696,7 +696,7 @@ holidays==0.21.13 home-assistant-frontend==20230309.1 # homeassistant.components.conversation -home-assistant-intents==2023.2.28 +home-assistant-intents==2023.3.29 # homeassistant.components.home_connect homeconnect==0.7.2 From 7ca5beddfc750b4215ef721409cf3aa7a281fe1a Mon Sep 17 00:00:00 2001 From: Luke Date: Wed, 29 Mar 2023 12:36:01 -0400 Subject: [PATCH 1425/2810] Fix Oralb Logger (#90460) --- homeassistant/components/oralb/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/oralb/manifest.json b/homeassistant/components/oralb/manifest.json index a1071cc0a1..adf72f5fe5 100644 --- a/homeassistant/components/oralb/manifest.json +++ b/homeassistant/components/oralb/manifest.json @@ -11,6 +11,6 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/oralb", "iot_class": "local_push", - "loggers": ["oralb-ble"], + "loggers": ["oralb_ble"], "requirements": ["oralb-ble==0.17.6"] } From a33c70e59557eecfb376508b5ad3969d88f4c999 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 29 Mar 2023 18:52:21 +0200 Subject: [PATCH 1426/2810] Bump python-matter-server to 3.2.0 (#90457) --- homeassistant/components/matter/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index b81ac2c62b..190bf33dcf 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==3.1.0"] + "requirements": ["python-matter-server==3.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 15553abc14..ea4a5c918a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2078,7 +2078,7 @@ python-kasa==0.5.1 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==3.1.0 +python-matter-server==3.2.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cd04c60f15..daa52bc318 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1492,7 +1492,7 @@ python-juicenet==1.1.0 python-kasa==0.5.1 # homeassistant.components.matter -python-matter-server==3.1.0 +python-matter-server==3.2.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 From b881995efc82fbe71fecfdb8af7b87ab2e868436 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 29 Mar 2023 19:36:42 +0200 Subject: [PATCH 1427/2810] Add verify ssl option to nextcloud (#90462) add verify sssl option to config flow --- homeassistant/components/nextcloud/__init__.py | 6 +++++- homeassistant/components/nextcloud/config_flow.py | 7 +++++-- homeassistant/components/nextcloud/const.py | 1 + homeassistant/components/nextcloud/strings.json | 3 ++- .../components/nextcloud/snapshots/test_config_flow.ambr | 2 ++ tests/components/nextcloud/test_config_flow.py | 9 +++++++-- 6 files changed, 22 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py index d2514b9091..60489b3e30 100644 --- a/homeassistant/components/nextcloud/__init__.py +++ b/homeassistant/components/nextcloud/__init__.py @@ -10,6 +10,7 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, CONF_URL, CONF_USERNAME, + CONF_VERIFY_SSL, Platform, ) from homeassistant.core import HomeAssistant @@ -73,7 +74,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def _connect_nc(): return NextcloudMonitor( - entry.data[CONF_URL], entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD] + entry.data[CONF_URL], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + entry.data[CONF_VERIFY_SSL], ) try: diff --git a/homeassistant/components/nextcloud/config_flow.py b/homeassistant/components/nextcloud/config_flow.py index e297a6893a..f22d0a01a5 100644 --- a/homeassistant/components/nextcloud/config_flow.py +++ b/homeassistant/components/nextcloud/config_flow.py @@ -8,16 +8,17 @@ from nextcloudmonitor import NextcloudMonitor, NextcloudMonitorError import voluptuous as vol from homeassistant.config_entries import ConfigFlow -from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.data_entry_flow import FlowResult -from .const import DOMAIN +from .const import DEFAULT_VERIFY_SSL, DOMAIN DATA_SCHEMA_USER = vol.Schema( { vol.Required(CONF_URL): str, vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool, } ) _LOGGER = logging.getLogger(__name__) @@ -34,6 +35,7 @@ class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN): user_input[CONF_URL], user_input[CONF_USERNAME], user_input[CONF_PASSWORD], + user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), ) async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: @@ -51,6 +53,7 @@ class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN): CONF_URL: user_input[CONF_URL], CONF_PASSWORD: user_input[CONF_PASSWORD], CONF_USERNAME: user_input[CONF_USERNAME], + CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, } ) diff --git a/homeassistant/components/nextcloud/const.py b/homeassistant/components/nextcloud/const.py index 223d21771b..248128dd53 100644 --- a/homeassistant/components/nextcloud/const.py +++ b/homeassistant/components/nextcloud/const.py @@ -3,3 +3,4 @@ from datetime import timedelta DOMAIN = "nextcloud" DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) +DEFAULT_VERIFY_SSL = True diff --git a/homeassistant/components/nextcloud/strings.json b/homeassistant/components/nextcloud/strings.json index 9ae7ed24a6..dc0175ea8e 100644 --- a/homeassistant/components/nextcloud/strings.json +++ b/homeassistant/components/nextcloud/strings.json @@ -7,7 +7,8 @@ "data": { "url": "[%key:common::config_flow::data::url%]", "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" } } }, diff --git a/tests/components/nextcloud/snapshots/test_config_flow.ambr b/tests/components/nextcloud/snapshots/test_config_flow.ambr index 0c9df1238c..caa9528507 100644 --- a/tests/components/nextcloud/snapshots/test_config_flow.ambr +++ b/tests/components/nextcloud/snapshots/test_config_flow.ambr @@ -4,6 +4,7 @@ 'password': 'nc_pass', 'url': 'nc_url', 'username': 'nc_user', + 'verify_ssl': True, }) # --- # name: test_user_create_entry @@ -11,5 +12,6 @@ 'password': 'nc_pass', 'url': 'nc_url', 'username': 'nc_user', + 'verify_ssl': True, }) # --- diff --git a/tests/components/nextcloud/test_config_flow.py b/tests/components/nextcloud/test_config_flow.py index 118d8fef0d..582ad3e77a 100644 --- a/tests/components/nextcloud/test_config_flow.py +++ b/tests/components/nextcloud/test_config_flow.py @@ -7,7 +7,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.nextcloud import DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER -from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -15,7 +15,12 @@ from tests.common import MockConfigEntry pytestmark = pytest.mark.usefixtures("mock_setup_entry") -VALID_CONFIG = {CONF_URL: "nc_url", CONF_USERNAME: "nc_user", CONF_PASSWORD: "nc_pass"} +VALID_CONFIG = { + CONF_URL: "nc_url", + CONF_USERNAME: "nc_user", + CONF_PASSWORD: "nc_pass", + CONF_VERIFY_SSL: True, +} async def test_user_create_entry( From 4877cf8d5daf19eb1dc975194c077a9611cc47cd Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 29 Mar 2023 14:30:30 -0400 Subject: [PATCH 1428/2810] Bump zwave-js-server-python to 0.47.1 (#90464) --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 0ad934103d..5fb7726577 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -8,7 +8,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.47.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.47.1"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index ea4a5c918a..39dad08e2b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2728,7 +2728,7 @@ zigpy==0.53.2 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.47.0 +zwave-js-server-python==0.47.1 # homeassistant.components.zwave_me zwave_me_ws==0.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index daa52bc318..bfa3149095 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1953,7 +1953,7 @@ zigpy-znp==0.9.3 zigpy==0.53.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.47.0 +zwave-js-server-python==0.47.1 # homeassistant.components.zwave_me zwave_me_ws==0.3.6 From a478e278fdbedec0130cb65760512d3a43439643 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 29 Mar 2023 21:04:04 +0200 Subject: [PATCH 1429/2810] Update frontend to 20230329.0 (#90461) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 2c13e81ee3..8c3fb8c143 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20230309.1"] + "requirements": ["home-assistant-frontend==20230329.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7a1ae9b5c4..0ed98c78e1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.0.0 hass-nabucasa==0.63.1 hassil==1.0.6 home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230309.1 +home-assistant-frontend==20230329.0 home-assistant-intents==2023.3.29 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index 39dad08e2b..d51947b81c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -907,7 +907,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230309.1 +home-assistant-frontend==20230329.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bfa3149095..514e653d34 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -693,7 +693,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230309.1 +home-assistant-frontend==20230329.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 From 28d045cf75f85526c2d0a1cb64085e7e003406aa Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Wed, 29 Mar 2023 21:05:20 +0200 Subject: [PATCH 1430/2810] Allow resetting filters for waze_travel_time (#88253) * Allow resetting filters by using vol.Maybe * Fix return types * Use suggested values * Apply feedback * Apply nitpick --- .../waze_travel_time/config_flow.py | 52 ++++++------------- .../components/waze_travel_time/sensor.py | 19 ++++--- 2 files changed, 27 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/waze_travel_time/config_flow.py b/homeassistant/components/waze_travel_time/config_flow.py index b26732e4cb..b885da3f37 100644 --- a/homeassistant/components/waze_travel_time/config_flow.py +++ b/homeassistant/components/waze_travel_time/config_flow.py @@ -31,6 +31,19 @@ from .const import ( ) from .helpers import is_valid_config_entry +OPTIONS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_INCL_FILTER, default=""): cv.string, + vol.Optional(CONF_EXCL_FILTER, default=""): cv.string, + vol.Optional(CONF_REALTIME): cv.boolean, + vol.Optional(CONF_VEHICLE_TYPE): vol.In(VEHICLE_TYPES), + vol.Optional(CONF_UNITS): vol.In(UNITS), + vol.Optional(CONF_AVOID_TOLL_ROADS): cv.boolean, + vol.Optional(CONF_AVOID_SUBSCRIPTION_ROADS): cv.boolean, + vol.Optional(CONF_AVOID_FERRIES): cv.boolean, + } +) + def default_options(hass: HomeAssistant) -> dict[str, str | bool]: """Get the default options.""" @@ -57,43 +70,8 @@ class WazeOptionsFlow(config_entries.OptionsFlow): return self.async_show_form( step_id="init", - data_schema=vol.Schema( - { - vol.Optional( - CONF_INCL_FILTER, - default=self.config_entry.options.get(CONF_INCL_FILTER, ""), - ): cv.string, - vol.Optional( - CONF_EXCL_FILTER, - default=self.config_entry.options.get(CONF_EXCL_FILTER, ""), - ): cv.string, - vol.Optional( - CONF_REALTIME, - default=self.config_entry.options[CONF_REALTIME], - ): cv.boolean, - vol.Optional( - CONF_VEHICLE_TYPE, - default=self.config_entry.options[CONF_VEHICLE_TYPE], - ): vol.In(VEHICLE_TYPES), - vol.Optional( - CONF_UNITS, - default=self.config_entry.options[CONF_UNITS], - ): vol.In(UNITS), - vol.Optional( - CONF_AVOID_TOLL_ROADS, - default=self.config_entry.options[CONF_AVOID_TOLL_ROADS], - ): cv.boolean, - vol.Optional( - CONF_AVOID_SUBSCRIPTION_ROADS, - default=self.config_entry.options[ - CONF_AVOID_SUBSCRIPTION_ROADS - ], - ): cv.boolean, - vol.Optional( - CONF_AVOID_FERRIES, - default=self.config_entry.options[CONF_AVOID_FERRIES], - ): cv.boolean, - } + data_schema=self.add_suggested_values_to_schema( + OPTIONS_SCHEMA, self.config_entry.options ), ) diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index ecbf3e9e12..cf709805f6 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -60,8 +60,6 @@ async def async_setup_entry( name = config_entry.data.get(CONF_NAME, DEFAULT_NAME) data = WazeTravelTimeData( - None, - None, region, config_entry, ) @@ -85,7 +83,14 @@ class WazeTravelTime(SensorEntity): configuration_url="https://www.waze.com", ) - def __init__(self, unique_id, name, origin, destination, waze_data): + def __init__( + self, + unique_id: str, + name: str, + origin: str, + destination: str, + waze_data: WazeTravelTimeData, + ) -> None: """Initialize the Waze travel time sensor.""" self._attr_unique_id = unique_id self._waze_data = waze_data @@ -126,7 +131,7 @@ class WazeTravelTime(SensorEntity): "destination": self._waze_data.destination, } - async def first_update(self, _=None): + async def first_update(self, _=None) -> None: """Run first update and write state.""" await self.hass.async_add_executor_job(self.update) self.async_write_ha_state() @@ -142,12 +147,12 @@ class WazeTravelTime(SensorEntity): class WazeTravelTimeData: """WazeTravelTime Data object.""" - def __init__(self, origin, destination, region, config_entry): + def __init__(self, region: str, config_entry: ConfigEntry) -> None: """Set up WazeRouteCalculator.""" - self.origin = origin - self.destination = destination self.region = region self.config_entry = config_entry + self.origin: str | None = None + self.destination: str | None = None self.duration = None self.distance = None self.route = None From cf0550f5c279eba18c55ce5289ffd75e23e994f9 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 29 Mar 2023 21:46:08 +0200 Subject: [PATCH 1431/2810] Add re-auth flow to nextcloud (#90472) --- .../components/nextcloud/__init__.py | 15 +- .../components/nextcloud/config_flow.py | 64 ++++++++- .../components/nextcloud/strings.json | 13 +- .../nextcloud/snapshots/test_config_flow.ambr | 8 ++ .../components/nextcloud/test_config_flow.py | 133 +++++++++++++++++- 5 files changed, 221 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py index 60489b3e30..65829f713e 100644 --- a/homeassistant/components/nextcloud/__init__.py +++ b/homeassistant/components/nextcloud/__init__.py @@ -1,7 +1,12 @@ """The Nextcloud integration.""" import logging -from nextcloudmonitor import NextcloudMonitor, NextcloudMonitorError +from nextcloudmonitor import ( + NextcloudMonitor, + NextcloudMonitorAuthorizationError, + NextcloudMonitorConnectionError, + NextcloudMonitorRequestError, +) import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry @@ -14,6 +19,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType @@ -82,9 +88,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: ncm = await hass.async_add_executor_job(_connect_nc) - except NextcloudMonitorError: - _LOGGER.error("Nextcloud setup failed - Check configuration") - return False + except NextcloudMonitorAuthorizationError as ex: + raise ConfigEntryAuthFailed from ex + except (NextcloudMonitorConnectionError, NextcloudMonitorRequestError) as ex: + raise ConfigEntryNotReady from ex coordinator = NextcloudDataUpdateCoordinator( hass, diff --git a/homeassistant/components/nextcloud/config_flow.py b/homeassistant/components/nextcloud/config_flow.py index f22d0a01a5..c5019603c0 100644 --- a/homeassistant/components/nextcloud/config_flow.py +++ b/homeassistant/components/nextcloud/config_flow.py @@ -1,13 +1,20 @@ """Config flow to configure the Nextcloud integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any -from nextcloudmonitor import NextcloudMonitor, NextcloudMonitorError +from nextcloudmonitor import ( + NextcloudMonitor, + NextcloudMonitorAuthorizationError, + NextcloudMonitorConnectionError, + NextcloudMonitorError, + NextcloudMonitorRequestError, +) import voluptuous as vol -from homeassistant.config_entries import ConfigFlow +from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.data_entry_flow import FlowResult @@ -21,6 +28,13 @@ DATA_SCHEMA_USER = vol.Schema( vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool, } ) +DATA_SCHEMA_REAUTH = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + _LOGGER = logging.getLogger(__name__) @@ -29,6 +43,8 @@ class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + _entry: ConfigEntry | None = None + def _try_connect_nc(self, user_input: dict) -> NextcloudMonitor: """Try to connect to nextcloud server.""" return NextcloudMonitor( @@ -67,7 +83,9 @@ class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({CONF_URL: user_input.get(CONF_URL)}) try: await self.hass.async_add_executor_job(self._try_connect_nc, user_input) - except NextcloudMonitorError: + except NextcloudMonitorAuthorizationError: + errors["base"] = "invalid_auth" + except (NextcloudMonitorConnectionError, NextcloudMonitorRequestError): errors["base"] = "connection_error" else: return self.async_create_entry( @@ -79,3 +97,43 @@ class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=data_schema, errors=errors ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle flow upon an API authentication error.""" + self._entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle reauthorization flow.""" + errors = {} + assert self._entry is not None + + if user_input is not None: + try: + await self.hass.async_add_executor_job( + self._try_connect_nc, {**self._entry.data, **user_input} + ) + except NextcloudMonitorAuthorizationError: + errors["base"] = "invalid_auth" + except (NextcloudMonitorConnectionError, NextcloudMonitorRequestError): + errors["base"] = "connection_error" + else: + self.hass.config_entries.async_update_entry( + self._entry, + data={**self._entry.data, **user_input}, + ) + await self.hass.config_entries.async_reload(self._entry.entry_id) + return self.async_abort(reason="reauth_successful") + + data_schema = self.add_suggested_values_to_schema( + DATA_SCHEMA_REAUTH, + {CONF_USERNAME: self._entry.data[CONF_USERNAME], **(user_input or {})}, + ) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=data_schema, + description_placeholders={"url": self._entry.data[CONF_URL]}, + errors=errors, + ) diff --git a/homeassistant/components/nextcloud/strings.json b/homeassistant/components/nextcloud/strings.json index dc0175ea8e..782865032a 100644 --- a/homeassistant/components/nextcloud/strings.json +++ b/homeassistant/components/nextcloud/strings.json @@ -10,14 +10,23 @@ "password": "[%key:common::config_flow::data::password%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" } + }, + "reauth_confirm": { + "description": "Update your login information for {url}.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "connection_error_during_import": "Connection error occured during yaml configuration import" + "connection_error_during_import": "Connection error occured during yaml configuration import", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { - "connection_error": "[%key:common::config_flow::error::cannot_connect%]" + "connection_error": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" } }, "issues": { diff --git a/tests/components/nextcloud/snapshots/test_config_flow.ambr b/tests/components/nextcloud/snapshots/test_config_flow.ambr index caa9528507..3334478ba2 100644 --- a/tests/components/nextcloud/snapshots/test_config_flow.ambr +++ b/tests/components/nextcloud/snapshots/test_config_flow.ambr @@ -7,6 +7,14 @@ 'verify_ssl': True, }) # --- +# name: test_reauth + dict({ + 'password': 'other_password', + 'url': 'nc_url', + 'username': 'other_user', + 'verify_ssl': True, + }) +# --- # name: test_user_create_entry dict({ 'password': 'nc_pass', diff --git a/tests/components/nextcloud/test_config_flow.py b/tests/components/nextcloud/test_config_flow.py index 582ad3e77a..ba465c5f8a 100644 --- a/tests/components/nextcloud/test_config_flow.py +++ b/tests/components/nextcloud/test_config_flow.py @@ -1,12 +1,17 @@ """Tests for the Nextcloud config flow.""" from unittest.mock import Mock, patch -from nextcloudmonitor import NextcloudMonitorError +from nextcloudmonitor import ( + NextcloudMonitorAuthorizationError, + NextcloudMonitorConnectionError, + NextcloudMonitorError, + NextcloudMonitorRequestError, +) import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.nextcloud import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -27,6 +32,7 @@ async def test_user_create_entry( hass: HomeAssistant, mock_nextcloud_monitor: Mock, snapshot: SnapshotAssertion ) -> None: """Test that the user step works.""" + # start user flow result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -34,9 +40,24 @@ async def test_user_create_entry( assert result["step_id"] == "user" assert result["errors"] == {} + # test NextcloudMonitorAuthorizationError with patch( "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", - side_effect=NextcloudMonitorError, + side_effect=NextcloudMonitorAuthorizationError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + # test NextcloudMonitorConnectionError + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + side_effect=NextcloudMonitorConnectionError, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -47,6 +68,21 @@ async def test_user_create_entry( assert result["step_id"] == "user" assert result["errors"] == {"base": "connection_error"} + # test NextcloudMonitorRequestError + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + side_effect=NextcloudMonitorRequestError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "connection_error"} + + # test success with patch( "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", return_value=mock_nextcloud_monitor, @@ -154,3 +190,94 @@ async def test_import_connection_error(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result["type"] == FlowResultType.ABORT assert result["reason"] == "connection_error_during_import" + + +async def test_reauth( + hass: HomeAssistant, mock_nextcloud_monitor: Mock, snapshot: SnapshotAssertion +) -> None: + """Test that the re-auth flow works.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="nc_url", + unique_id="nc_url", + data=VALID_CONFIG, + ) + entry.add_to_hass(hass) + + # start reauth flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "entry_id": entry.entry_id}, + data=entry.data, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + # test NextcloudMonitorAuthorizationError + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + side_effect=NextcloudMonitorAuthorizationError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "other_user", + CONF_PASSWORD: "other_password", + }, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_auth"} + + # test NextcloudMonitorConnectionError + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + side_effect=NextcloudMonitorConnectionError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "other_user", + CONF_PASSWORD: "other_password", + }, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "connection_error"} + + # test NextcloudMonitorRequestError + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + side_effect=NextcloudMonitorRequestError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "other_user", + CONF_PASSWORD: "other_password", + }, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "connection_error"} + + # test success + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + return_value=mock_nextcloud_monitor, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "other_user", + CONF_PASSWORD: "other_password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert entry.data == snapshot From 5bc9545b81d016c002c7672daec237a580bce74f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Mar 2023 21:58:25 +0200 Subject: [PATCH 1432/2810] Rename custom_jinja to custom_templates (#90473) Co-authored-by: Franck Nijhof --- homeassistant/bootstrap.py | 2 +- .../components/homeassistant/__init__.py | 14 +++++++------- .../components/homeassistant/services.yaml | 4 ++-- homeassistant/helpers/template.py | 12 ++++++------ tests/components/homeassistant/test_init.py | 16 ++++++++-------- tests/helpers/test_template.py | 6 +++--- .../inner/inner_test.jinja | 0 .../test.jinja | 0 8 files changed, 27 insertions(+), 27 deletions(-) rename tests/testing_config/{custom_jinja => custom_templates}/inner/inner_test.jinja (100%) rename tests/testing_config/{custom_jinja => custom_templates}/test.jinja (100%) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index eb3aa3a223..445ff35793 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -245,7 +245,7 @@ async def load_registries(hass: core.HomeAssistant) -> None: entity_registry.async_load(hass), issue_registry.async_load(hass), hass.async_add_executor_job(_cache_uname_processor), - template.async_load_custom_jinja(hass), + template.async_load_custom_templates(hass), ) diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 4b033fd711..91dd742e80 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -30,7 +30,7 @@ from homeassistant.helpers.service import ( async_extract_referenced_entity_ids, async_register_admin_service, ) -from homeassistant.helpers.template import async_load_custom_jinja +from homeassistant.helpers.template import async_load_custom_templates from homeassistant.helpers.typing import ConfigType ATTR_ENTRY_ID = "entry_id" @@ -39,7 +39,7 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = ha.DOMAIN SERVICE_RELOAD_CORE_CONFIG = "reload_core_config" SERVICE_RELOAD_CONFIG_ENTRY = "reload_config_entry" -SERVICE_RELOAD_CUSTOM_JINJA = "reload_custom_jinja" +SERVICE_RELOAD_CUSTOM_TEMPLATES = "reload_custom_templates" SERVICE_CHECK_CONFIG = "check_config" SERVICE_UPDATE_ENTITY = "update_entity" SERVICE_SET_LOCATION = "set_location" @@ -260,12 +260,12 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no vol.Schema({ATTR_LATITUDE: cv.latitude, ATTR_LONGITUDE: cv.longitude}), ) - async def async_handle_reload_jinja(call: ha.ServiceCall) -> None: + async def async_handle_reload_templates(call: ha.ServiceCall) -> None: """Service handler to reload custom Jinja.""" - await async_load_custom_jinja(hass) + await async_load_custom_templates(hass) async_register_admin_service( - hass, ha.DOMAIN, SERVICE_RELOAD_CUSTOM_JINJA, async_handle_reload_jinja + hass, ha.DOMAIN, SERVICE_RELOAD_CUSTOM_TEMPLATES, async_handle_reload_templates ) async def async_handle_reload_config_entry(call: ha.ServiceCall) -> None: @@ -300,7 +300,7 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no Additionally, it also calls the `homeasssitant.reload_core_config` service, as that reloads the core YAML configuration, the `frontend.reload_themes` service that reloads the themes, and the - `homeassistant.reload_custom_jinja` service that reloads any custom + `homeassistant.reload_custom_templates` service that reloads any custom jinja into memory. We only do so, if there are no configuration errors. @@ -330,7 +330,7 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no for domain, service in ( (ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG), ("frontend", "reload_themes"), - (ha.DOMAIN, SERVICE_RELOAD_CUSTOM_JINJA), + (ha.DOMAIN, SERVICE_RELOAD_CUSTOM_TEMPLATES), ) ] diff --git a/homeassistant/components/homeassistant/services.yaml b/homeassistant/components/homeassistant/services.yaml index 20f23402a7..2fe27769c3 100644 --- a/homeassistant/components/homeassistant/services.yaml +++ b/homeassistant/components/homeassistant/services.yaml @@ -59,10 +59,10 @@ update_entity: target: entity: {} -reload_custom_jinja: +reload_custom_templates: name: Reload custom Jinja2 templates description: >- - Reload Jinja2 templates found in the custom_jinja folder in your config. + Reload Jinja2 templates found in the custom_templates folder in your config. New values will be applied on the next render of the template. reload_config_entry: diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index d3aa7c81ff..481a59cee8 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -124,7 +124,7 @@ template_cv: ContextVar[tuple[str, str] | None] = ContextVar( CACHED_TEMPLATE_STATES = 512 EVAL_CACHE_SIZE = 512 -MAX_CUSTOM_JINJA_SIZE = 5 * 1024 * 1024 +MAX_CUSTOM_TEMPLATE_SIZE = 5 * 1024 * 1024 @bind_hass @@ -2084,18 +2084,18 @@ class LoggingUndefined(jinja2.Undefined): return super().__bool__() -async def async_load_custom_jinja(hass: HomeAssistant) -> None: +async def async_load_custom_templates(hass: HomeAssistant) -> None: """Load all custom jinja files under 5MiB into memory.""" - return await hass.async_add_executor_job(_load_custom_jinja, hass) + return await hass.async_add_executor_job(_load_custom_templates, hass) -def _load_custom_jinja(hass: HomeAssistant) -> None: +def _load_custom_templates(hass: HomeAssistant) -> None: result = {} - jinja_path = hass.config.path("custom_jinja") + jinja_path = hass.config.path("custom_templates") all_files = [ item for item in pathlib.Path(jinja_path).rglob("*.jinja") - if item.is_file() and item.stat().st_size <= MAX_CUSTOM_JINJA_SIZE + if item.is_file() and item.stat().st_size <= MAX_CUSTOM_TEMPLATE_SIZE ] for file in all_files: content = file.read_text() diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index 4a04241696..652fc4a1fd 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -14,7 +14,7 @@ from homeassistant.components.homeassistant import ( SERVICE_CHECK_CONFIG, SERVICE_RELOAD_ALL, SERVICE_RELOAD_CORE_CONFIG, - SERVICE_RELOAD_CUSTOM_JINJA, + SERVICE_RELOAD_CUSTOM_TEMPLATES, SERVICE_SET_LOCATION, ) from homeassistant.const import ( @@ -576,19 +576,19 @@ async def test_save_persistent_states(hass: HomeAssistant) -> None: assert mock_save.called -async def test_reload_custom_jinja(hass: HomeAssistant) -> None: - """Test we can call reload_custom_jinja.""" +async def test_reload_custom_templates(hass: HomeAssistant) -> None: + """Test we can call reload_custom_templates.""" await async_setup_component(hass, "homeassistant", {}) with patch( - "homeassistant.components.homeassistant.async_load_custom_jinja", + "homeassistant.components.homeassistant.async_load_custom_templates", return_value=None, - ) as mock_load_custom_jinja: + ) as mock_load_custom_templates: await hass.services.async_call( "homeassistant", - SERVICE_RELOAD_CUSTOM_JINJA, + SERVICE_RELOAD_CUSTOM_TEMPLATES, blocking=True, ) - assert mock_load_custom_jinja.called + assert mock_load_custom_templates.called async def test_reload_all( @@ -602,7 +602,7 @@ async def test_reload_all( notify = async_mock_service(hass, "notify", "reload") core_config = async_mock_service(hass, "homeassistant", "reload_core_config") themes = async_mock_service(hass, "frontend", "reload_themes") - jinja = async_mock_service(hass, "homeassistant", "reload_custom_jinja") + jinja = async_mock_service(hass, "homeassistant", "reload_custom_templates") with patch( "homeassistant.config.async_check_ha_config_file", diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 45237a5cbf..b381775f1e 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -245,8 +245,8 @@ def test_iterating_domain_states(hass: HomeAssistant) -> None: async def test_import(hass: HomeAssistant) -> None: - """Test that imports work from the config/custom_jinja folder.""" - await template.async_load_custom_jinja(hass) + """Test that imports work from the config/custom_templates folder.""" + await template.async_load_custom_templates(hass) assert "test.jinja" in template._get_hass_loader(hass).sources assert "inner/inner_test.jinja" in template._get_hass_loader(hass).sources assert ( @@ -283,7 +283,7 @@ async def test_import(hass: HomeAssistant) -> None: async def test_import_change(hass: HomeAssistant) -> None: """Test that a change in HassLoader results in updated imports.""" - await template.async_load_custom_jinja(hass) + await template.async_load_custom_templates(hass) to_test = template.Template( """ {% import 'test.jinja' as t %} diff --git a/tests/testing_config/custom_jinja/inner/inner_test.jinja b/tests/testing_config/custom_templates/inner/inner_test.jinja similarity index 100% rename from tests/testing_config/custom_jinja/inner/inner_test.jinja rename to tests/testing_config/custom_templates/inner/inner_test.jinja diff --git a/tests/testing_config/custom_jinja/test.jinja b/tests/testing_config/custom_templates/test.jinja similarity index 100% rename from tests/testing_config/custom_jinja/test.jinja rename to tests/testing_config/custom_templates/test.jinja From fc67a147ce15b59c16b07cf419e7eaf5344f7710 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Mar 2023 22:01:31 +0200 Subject: [PATCH 1433/2810] Bumped version to 2023.4.0b0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 1559560f11..289f536089 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -8,7 +8,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 4 -PATCH_VERSION: Final = "0.dev0" +PATCH_VERSION: Final = "0b0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 577ba18140..e7e82d2ed5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.4.0.dev0" +version = "2023.4.0b0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 7c778847e7dbc052d35f1d4758e8829d73be1e28 Mon Sep 17 00:00:00 2001 From: Chris Xiao <30990835+chrisx8@users.noreply.github.com> Date: Wed, 29 Mar 2023 16:13:41 -0400 Subject: [PATCH 1434/2810] Add config flow to qBittorrent (#82560) * qbittorrent: implement config_flow Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * qbittorrent: add English translations Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * qbittorrent: create sensors with config_flow Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * qbittorrent: set unique_id and icon Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * qbittorrent: add tests for config_flow Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * qbittorrent: detect duplicate config entries Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * qbittorrent: import YAML config Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * qbittorrent: update coveragerc Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * qbittorrent: delete translations file * create `deprecated_yaml` issue in `setup_platform` * move qbittorrent test fixtures to conftest.py * improve code quality & remove wrong unique_id * keep PLATFORM_SCHEMA until YAML support is removed * remove CONF_NAME in config entry, fix setup_entry * improve test suite * clean up QBittorrentSensor class * improve user flow tests * explicit result assertion & minor tweaks in tests Co-authored-by: epenet * implement entry unloading Co-authored-by: epenet * add type hints * tweak config_flow data handling --------- Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> Co-authored-by: epenet --- .coveragerc | 1 + CODEOWNERS | 1 + .../components/qbittorrent/__init__.py | 53 +++++++ .../components/qbittorrent/config_flow.py | 76 ++++++++++ homeassistant/components/qbittorrent/const.py | 4 + .../components/qbittorrent/helpers.py | 11 ++ .../components/qbittorrent/manifest.json | 1 + .../components/qbittorrent/sensor.py | 56 +++++--- .../components/qbittorrent/strings.json | 27 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/qbittorrent/__init__.py | 1 + tests/components/qbittorrent/conftest.py | 25 ++++ .../qbittorrent/test_config_flow.py | 136 ++++++++++++++++++ 15 files changed, 375 insertions(+), 23 deletions(-) create mode 100644 homeassistant/components/qbittorrent/config_flow.py create mode 100644 homeassistant/components/qbittorrent/helpers.py create mode 100644 homeassistant/components/qbittorrent/strings.json create mode 100644 tests/components/qbittorrent/__init__.py create mode 100644 tests/components/qbittorrent/conftest.py create mode 100644 tests/components/qbittorrent/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 4b831fc3d3..2c06c7d0bb 100644 --- a/.coveragerc +++ b/.coveragerc @@ -938,6 +938,7 @@ omit = homeassistant/components/pushover/notify.py homeassistant/components/pushsafer/notify.py homeassistant/components/pyload/sensor.py + homeassistant/components/qbittorrent/__init__.py homeassistant/components/qbittorrent/sensor.py homeassistant/components/qnap/sensor.py homeassistant/components/qrcode/image_processing.py diff --git a/CODEOWNERS b/CODEOWNERS index 1acd5f6c9f..509f3e5f30 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -933,6 +933,7 @@ build.json @home-assistant/supervisor /homeassistant/components/pvpc_hourly_pricing/ @azogue /tests/components/pvpc_hourly_pricing/ @azogue /homeassistant/components/qbittorrent/ @geoffreylagaisse +/tests/components/qbittorrent/ @geoffreylagaisse /homeassistant/components/qingping/ @bdraco @skgsergio /tests/components/qingping/ @bdraco @skgsergio /homeassistant/components/qld_bushfire/ @exxamalte diff --git a/homeassistant/components/qbittorrent/__init__.py b/homeassistant/components/qbittorrent/__init__.py index a5274f7a5a..5154ae155e 100644 --- a/homeassistant/components/qbittorrent/__init__.py +++ b/homeassistant/components/qbittorrent/__init__.py @@ -1 +1,54 @@ """The qbittorrent component.""" +import logging + +from qbittorrent.client import LoginRequired +from requests.exceptions import RequestException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_PASSWORD, + CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN +from .helpers import setup_client + +PLATFORMS = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up qBittorrent from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + try: + hass.data[DOMAIN][entry.entry_id] = await hass.async_add_executor_job( + setup_client, + entry.data[CONF_URL], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + entry.data[CONF_VERIFY_SSL], + ) + except LoginRequired as err: + _LOGGER.error("Invalid credentials") + raise ConfigEntryNotReady from err + except RequestException as err: + _LOGGER.error("Failed to connect") + raise ConfigEntryNotReady from err + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload qBittorrent config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + del hass.data[DOMAIN][entry.entry_id] + if not hass.data[DOMAIN]: + del hass.data[DOMAIN] + return unload_ok diff --git a/homeassistant/components/qbittorrent/config_flow.py b/homeassistant/components/qbittorrent/config_flow.py new file mode 100644 index 0000000000..54c47c5389 --- /dev/null +++ b/homeassistant/components/qbittorrent/config_flow.py @@ -0,0 +1,76 @@ +"""Config flow for qBittorrent.""" +from __future__ import annotations + +import logging +from typing import Any + +from qbittorrent.client import LoginRequired +from requests.exceptions import RequestException +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.data_entry_flow import FlowResult + +from .const import DEFAULT_NAME, DEFAULT_URL, DOMAIN +from .helpers import setup_client + +_LOGGER = logging.getLogger(__name__) + +USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL, default=DEFAULT_URL): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_VERIFY_SSL, default=True): bool, + } +) + + +class QbittorrentConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow for the qBittorrent integration.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a user-initiated config flow.""" + errors = {} + + if user_input is not None: + self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]}) + try: + await self.hass.async_add_executor_job( + setup_client, + user_input[CONF_URL], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + user_input[CONF_VERIFY_SSL], + ) + except LoginRequired: + errors = {"base": "invalid_auth"} + except RequestException: + errors = {"base": "cannot_connect"} + else: + return self.async_create_entry(title=DEFAULT_NAME, data=user_input) + + schema = self.add_suggested_values_to_schema(USER_DATA_SCHEMA, user_input) + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + self._async_abort_entries_match({CONF_URL: config[CONF_URL]}) + return self.async_create_entry( + title=config.get(CONF_NAME, DEFAULT_NAME), + data={ + CONF_URL: config[CONF_URL], + CONF_USERNAME: config[CONF_USERNAME], + CONF_PASSWORD: config[CONF_PASSWORD], + CONF_VERIFY_SSL: True, + }, + ) diff --git a/homeassistant/components/qbittorrent/const.py b/homeassistant/components/qbittorrent/const.py index 5f9ad42f7f..0a79c67f40 100644 --- a/homeassistant/components/qbittorrent/const.py +++ b/homeassistant/components/qbittorrent/const.py @@ -1,3 +1,7 @@ """Constants for qBittorrent.""" +from typing import Final + +DOMAIN: Final = "qbittorrent" DEFAULT_NAME = "qBittorrent" +DEFAULT_URL = "http://127.0.0.1:8080" diff --git a/homeassistant/components/qbittorrent/helpers.py b/homeassistant/components/qbittorrent/helpers.py new file mode 100644 index 0000000000..7f7833e912 --- /dev/null +++ b/homeassistant/components/qbittorrent/helpers.py @@ -0,0 +1,11 @@ +"""Helper functions for qBittorrent.""" +from qbittorrent.client import Client + + +def setup_client(url: str, username: str, password: str, verify_ssl: bool) -> Client: + """Create a qBittorrent client.""" + client = Client(url, verify=verify_ssl) + client.login(username, password) + # Get an arbitrary attribute to test if connection succeeds + client.get_alternative_speed_status() + return client diff --git a/homeassistant/components/qbittorrent/manifest.json b/homeassistant/components/qbittorrent/manifest.json index 47090ab8b9..c56bb8102b 100644 --- a/homeassistant/components/qbittorrent/manifest.json +++ b/homeassistant/components/qbittorrent/manifest.json @@ -2,6 +2,7 @@ "domain": "qbittorrent", "name": "qBittorrent", "codeowners": ["@geoffreylagaisse"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/qbittorrent", "integration_type": "service", "iot_class": "local_polling", diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index cafb8d8b21..6b758daab0 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -14,6 +14,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, @@ -23,12 +24,12 @@ from homeassistant.const import ( UnitOfDataRate, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import issue_registry as ir import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import DEFAULT_NAME +from .const import DEFAULT_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -69,31 +70,41 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the qBittorrent sensors.""" + """Set up the qBittorrent platform.""" + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) + ir.async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.6.0", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) - try: - client = Client(config[CONF_URL]) - client.login(config[CONF_USERNAME], config[CONF_PASSWORD]) - except LoginRequired: - _LOGGER.error("Invalid authentication") - return - except RequestException as err: - _LOGGER.error("Connection failed") - raise PlatformNotReady from err - - name = config.get(CONF_NAME) +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entites: AddEntitiesCallback, +) -> None: + """Set up qBittorrent sensor entries.""" + client: Client = hass.data[DOMAIN][config_entry.entry_id] entities = [ - QBittorrentSensor(description, client, name) for description in SENSOR_TYPES + QBittorrentSensor(description, client, config_entry) + for description in SENSOR_TYPES ] - - add_entities(entities, True) + async_add_entites(entities, True) def format_speed(speed): @@ -108,14 +119,15 @@ class QBittorrentSensor(SensorEntity): def __init__( self, description: SensorEntityDescription, - qbittorrent_client, - client_name, + qbittorrent_client: Client, + config_entry: ConfigEntry, ) -> None: """Initialize the qBittorrent sensor.""" self.entity_description = description self.client = qbittorrent_client - self._attr_name = f"{client_name} {description.name}" + self._attr_unique_id = f"{config_entry.entry_id}-{description.key}" + self._attr_name = f"{config_entry.title} {description.name}" self._attr_available = False def update(self) -> None: diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json new file mode 100644 index 0000000000..24d1885a91 --- /dev/null +++ b/homeassistant/components/qbittorrent/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "url": "[%key:common::config_flow::data::url%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The qBittorrent YAML configuration is being removed", + "description": "Configuring qBittorrent using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the qBittorrent YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 37480904f9..240e30ec86 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -339,6 +339,7 @@ FLOWS = { "pushover", "pvoutput", "pvpc_hourly_pricing", + "qbittorrent", "qingping", "qnap_qsw", "rachio", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 3e89f9d12d..bc0e9e1c33 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4316,7 +4316,7 @@ "qbittorrent": { "name": "qBittorrent", "integration_type": "service", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "qingping": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 514e653d34..5b2f1be04c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1507,6 +1507,9 @@ python-otbr-api==1.0.9 # homeassistant.components.picnic python-picnic-api==1.1.0 +# homeassistant.components.qbittorrent +python-qbittorrent==0.4.2 + # homeassistant.components.smarttub python-smarttub==0.0.33 diff --git a/tests/components/qbittorrent/__init__.py b/tests/components/qbittorrent/__init__.py new file mode 100644 index 0000000000..2be020668c --- /dev/null +++ b/tests/components/qbittorrent/__init__.py @@ -0,0 +1 @@ +"""Tests for the qBittorrent integration.""" diff --git a/tests/components/qbittorrent/conftest.py b/tests/components/qbittorrent/conftest.py new file mode 100644 index 0000000000..448f68db81 --- /dev/null +++ b/tests/components/qbittorrent/conftest.py @@ -0,0 +1,25 @@ +"""Fixtures for testing qBittorrent component.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +import requests_mock + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock qbittorrent entry setup.""" + with patch( + "homeassistant.components.qbittorrent.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_api() -> Generator[requests_mock.Mocker, None, None]: + """Mock the qbittorrent API.""" + with requests_mock.Mocker() as mocker: + mocker.get("http://localhost:8080/api/v2/app/preferences", status_code=403) + mocker.get("http://localhost:8080/api/v2/transfer/speedLimitsMode") + mocker.post("http://localhost:8080/api/v2/auth/login", text="Ok.") + yield mocker diff --git a/tests/components/qbittorrent/test_config_flow.py b/tests/components/qbittorrent/test_config_flow.py new file mode 100644 index 0000000000..b7244ccef8 --- /dev/null +++ b/tests/components/qbittorrent/test_config_flow.py @@ -0,0 +1,136 @@ +"""Test the qBittorrent config flow.""" +import pytest +from requests.exceptions import RequestException +import requests_mock + +from homeassistant.components.qbittorrent.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import ( + CONF_PASSWORD, + CONF_SOURCE, + CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + +USER_INPUT = { + CONF_URL: "http://localhost:8080", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", + CONF_VERIFY_SSL: True, +} + +YAML_IMPORT = { + CONF_URL: "http://localhost:8080", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", +} + + +async def test_flow_user(hass: HomeAssistant, mock_api: requests_mock.Mocker) -> None: + """Test the user flow.""" + # Open flow as USER with no input + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + # Test flow with connection failure, fail with cannot_connect + with requests_mock.Mocker() as mock: + mock.get( + f"{USER_INPUT[CONF_URL]}/api/v2/app/preferences", + exc=RequestException, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + # Test flow with wrong creds, fail with invalid_auth + with requests_mock.Mocker() as mock: + mock.get(f"{USER_INPUT[CONF_URL]}/api/v2/transfer/speedLimitsMode") + mock.get(f"{USER_INPUT[CONF_URL]}/api/v2/app/preferences", status_code=403) + mock.post( + f"{USER_INPUT[CONF_URL]}/api/v2/auth/login", + text="Wrong username/password", + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + # Test flow with proper input, succeed + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_URL: "http://localhost:8080", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", + CONF_VERIFY_SSL: True, + } + + +async def test_flow_user_already_configured(hass: HomeAssistant) -> None: + """Test user initialized flow with duplicate server.""" + entry = MockConfigEntry(domain=DOMAIN, data=USER_INPUT) + entry.add_to_hass(hass) + + # Open flow as USER with no input + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + # Test flow with duplicate config + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_import(hass: HomeAssistant) -> None: + """Test import step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_IMPORT}, + data=YAML_IMPORT, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_URL: "http://localhost:8080", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", + CONF_VERIFY_SSL: True, + } + + +async def test_flow_import_already_configured(hass: HomeAssistant) -> None: + """Test import step already configured.""" + entry = MockConfigEntry(domain=DOMAIN, data=USER_INPUT) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_IMPORT}, + data=YAML_IMPORT, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" From 7010447b04be53d4c9f1cc696cd9210f87611d90 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Mar 2023 22:46:32 +0200 Subject: [PATCH 1435/2810] Bump version to 2023.5.0dev0 (#90477) --- .github/workflows/ci.yaml | 2 +- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e4fd319e71..8464b1a299 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -31,7 +31,7 @@ env: CACHE_VERSION: 5 PIP_CACHE_VERSION: 4 MYPY_CACHE_VERSION: 4 - HA_SHORT_VERSION: 2023.4 + HA_SHORT_VERSION: 2023.5 DEFAULT_PYTHON: "3.10" ALL_PYTHON_VERSIONS: "['3.10', '3.11']" # 10.3 is the oldest supported version diff --git a/homeassistant/const.py b/homeassistant/const.py index 1559560f11..23b4a9a132 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 -MINOR_VERSION: Final = 4 +MINOR_VERSION: Final = 5 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" diff --git a/pyproject.toml b/pyproject.toml index 577ba18140..d409ef188d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.4.0.dev0" +version = "2023.5.0.dev0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 43a7247ddeb9545d03cabd1ce6b2f4fc46c59663 Mon Sep 17 00:00:00 2001 From: dougiteixeira <31328123+dougiteixeira@users.noreply.github.com> Date: Wed, 29 Mar 2023 18:04:37 -0300 Subject: [PATCH 1436/2810] Move ProxmoxEntity to entity.py (#90480) * Move ProxmoxEntity to entity.py * Update homeassistant/components/proxmoxve/entity.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/proxmoxve/entity.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/proxmoxve/entity.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/proxmoxve/entity.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/proxmoxve/binary_sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/proxmoxve/__init__.py | 53 +------------------ .../components/proxmoxve/binary_sensor.py | 3 +- homeassistant/components/proxmoxve/entity.py | 39 ++++++++++++++ 3 files changed, 42 insertions(+), 53 deletions(-) create mode 100644 homeassistant/components/proxmoxve/entity.py diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index 2764f22b08..b61f0ca4df 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -22,10 +22,7 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( _LOGGER, @@ -252,54 +249,6 @@ def call_api_container_vm( return status -class ProxmoxEntity(CoordinatorEntity): - """Represents any entity created for the Proxmox VE platform.""" - - def __init__( - self, - coordinator: DataUpdateCoordinator, - unique_id: str, - name: str, - icon: str, - host_name: str, - node_name: str, - vm_id: int | None = None, - ) -> None: - """Initialize the Proxmox entity.""" - super().__init__(coordinator) - - self.coordinator = coordinator - self._unique_id = unique_id - self._name = name - self._host_name = host_name - self._icon = icon - self._available = True - self._node_name = node_name - self._vm_id = vm_id - - self._state = None - - @property - def unique_id(self) -> str: - """Return the unique ID for this sensor.""" - return self._unique_id - - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._name - - @property - def icon(self) -> str: - """Return the mdi icon of the entity.""" - return self._icon - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self.coordinator.last_update_success and self._available - - class ProxmoxClient: """A wrapper for the proxmoxer ProxmoxAPI client.""" diff --git a/homeassistant/components/proxmoxve/binary_sensor.py b/homeassistant/components/proxmoxve/binary_sensor.py index 828c819114..ea02e547e9 100644 --- a/homeassistant/components/proxmoxve/binary_sensor.py +++ b/homeassistant/components/proxmoxve/binary_sensor.py @@ -10,7 +10,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import COORDINATORS, DOMAIN, PROXMOX_CLIENTS, ProxmoxEntity +from .const import COORDINATORS, DOMAIN, PROXMOX_CLIENTS +from .entity import ProxmoxEntity async def async_setup_platform( diff --git a/homeassistant/components/proxmoxve/entity.py b/homeassistant/components/proxmoxve/entity.py new file mode 100644 index 0000000000..5dfd264df2 --- /dev/null +++ b/homeassistant/components/proxmoxve/entity.py @@ -0,0 +1,39 @@ +"""Proxmox parent entity class.""" + +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + + +class ProxmoxEntity(CoordinatorEntity): + """Represents any entity created for the Proxmox VE platform.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + unique_id: str, + name: str, + icon: str, + host_name: str, + node_name: str, + vm_id: int | None = None, + ) -> None: + """Initialize the Proxmox entity.""" + super().__init__(coordinator) + + self.coordinator = coordinator + self._attr_unique_id = unique_id + self._attr_name = name + self._host_name = host_name + self._attr_icon = icon + self._available = True + self._node_name = node_name + self._vm_id = vm_id + + self._state = None + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.coordinator.last_update_success and self._available From d0a492644d9773124692de286f34922b454edf53 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 29 Mar 2023 17:24:26 -0400 Subject: [PATCH 1437/2810] Correctly load ZHA settings from API when integration is not running (#90476) Correctly load settings from the zigpy database when ZHA is not running --- homeassistant/components/zha/api.py | 23 ++++++++--------------- tests/components/zha/test_api.py | 5 ++++- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index d34dd2338e..652f19d24b 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -18,8 +18,6 @@ from .core.const import ( from .core.gateway import ZHAGateway if TYPE_CHECKING: - from zigpy.application import ControllerApplication - from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -49,21 +47,17 @@ def _get_config_entry(hass: HomeAssistant) -> ConfigEntry: return entries[0] -def _wrap_network_settings(app: ControllerApplication) -> NetworkBackup: - """Wrap the ZHA network settings into a `NetworkBackup`.""" +def async_get_active_network_settings(hass: HomeAssistant) -> NetworkBackup: + """Get the network settings for the currently active ZHA network.""" + zha_gateway: ZHAGateway = _get_gateway(hass) + app = zha_gateway.application_controller + return NetworkBackup( node_info=app.state.node_info, network_info=app.state.network_info, ) -def async_get_active_network_settings(hass: HomeAssistant) -> NetworkBackup: - """Get the network settings for the currently active ZHA network.""" - zha_gateway: ZHAGateway = _get_gateway(hass) - - return _wrap_network_settings(zha_gateway.application_controller) - - async def async_get_last_network_settings( hass: HomeAssistant, config_entry: ConfigEntry | None = None ) -> NetworkBackup | None: @@ -79,13 +73,12 @@ async def async_get_last_network_settings( try: await app._load_db() # pylint: disable=protected-access - settings = _wrap_network_settings(app) + settings = max(app.backups, key=lambda b: b.backup_time) + except ValueError: + settings = None finally: await app.shutdown() - if settings.network_info.channel == 0: - return None - return settings diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py index c607909980..59daf2179b 100644 --- a/tests/components/zha/test_api.py +++ b/tests/components/zha/test_api.py @@ -2,6 +2,7 @@ from unittest.mock import patch import pytest +import zigpy.backups import zigpy.state from homeassistant.components import zha @@ -36,7 +37,9 @@ async def test_async_get_network_settings_inactive( gateway = api._get_gateway(hass) await zha.async_unload_entry(hass, gateway.config_entry) - zigpy_app_controller.state.network_info.channel = 20 + backup = zigpy.backups.NetworkBackup() + backup.network_info.channel = 20 + zigpy_app_controller.backups.backups.append(backup) with patch( "bellows.zigbee.application.ControllerApplication.__new__", From 3bebd4318e99e94ae82a0d941b458e4014a86b38 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Mar 2023 11:24:47 -1000 Subject: [PATCH 1438/2810] Bump yalexs-ble to 2.1.14 (#90474) changelog: https://github.com/bdraco/yalexs-ble/compare/v2.1.13...v2.1.14 reduces ble traffic (fixes a bug were we were checking when we did not need to be) --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 07ecc2a1be..84b5ae7e20 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.13"] + "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.14"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 7c45f309e6..f1ec6ba14c 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.1.13"] + "requirements": ["yalexs-ble==2.1.14"] } diff --git a/requirements_all.txt b/requirements_all.txt index d51947b81c..bd80ded757 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2668,7 +2668,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.13 +yalexs-ble==2.1.14 # homeassistant.components.august yalexs==1.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5b2f1be04c..3519962af1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1914,7 +1914,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.13 +yalexs-ble==2.1.14 # homeassistant.components.august yalexs==1.2.7 From 706e6597d8189f02d17cfb87b0ef0c4ebbc6cb5f Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Wed, 29 Mar 2023 23:25:33 +0200 Subject: [PATCH 1439/2810] Add entity name translations for devolo Home Network (#90471) --- .../devolo_home_network/binary_sensor.py | 1 - .../components/devolo_home_network/entity.py | 1 + .../components/devolo_home_network/sensor.py | 3 --- .../devolo_home_network/strings.json | 26 +++++++++++++++++++ .../components/devolo_home_network/switch.py | 2 -- 5 files changed, 27 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/devolo_home_network/binary_sensor.py b/homeassistant/components/devolo_home_network/binary_sensor.py index e927ea9333..809dc9086b 100644 --- a/homeassistant/components/devolo_home_network/binary_sensor.py +++ b/homeassistant/components/devolo_home_network/binary_sensor.py @@ -53,7 +53,6 @@ SENSOR_TYPES: dict[str, DevoloBinarySensorEntityDescription] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:router-network", - name="Connected to router", value_func=_is_connected_to_router, ), } diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py index a26d8dce8f..8b665d7bf0 100644 --- a/homeassistant/components/devolo_home_network/entity.py +++ b/homeassistant/components/devolo_home_network/entity.py @@ -57,4 +57,5 @@ class DevoloEntity(CoordinatorEntity[DataUpdateCoordinator[_DataT]]): name=entry.title, sw_version=device.firmware_version, ) + self._attr_translation_key = self.entity_description.key self._attr_unique_id = f"{device.serial_number}_{self.entity_description.key}" diff --git a/homeassistant/components/devolo_home_network/sensor.py b/homeassistant/components/devolo_home_network/sensor.py index 2c2637c2f8..aeeab2ce89 100644 --- a/homeassistant/components/devolo_home_network/sensor.py +++ b/homeassistant/components/devolo_home_network/sensor.py @@ -54,7 +54,6 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:lan", - name="Connected PLC devices", value_func=lambda data: len( {device.mac_address_from for device in data.data_rates} ), @@ -62,7 +61,6 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = { CONNECTED_WIFI_CLIENTS: DevoloSensorEntityDescription[list[ConnectedStationInfo]]( key=CONNECTED_WIFI_CLIENTS, icon="mdi:wifi", - name="Connected Wifi clients", state_class=SensorStateClass.MEASUREMENT, value_func=len, ), @@ -71,7 +69,6 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:wifi-marker", - name="Neighboring Wifi networks", value_func=len, ), } diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index 6c320710a1..3472886cd5 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -27,5 +27,31 @@ "home_control": "The devolo Home Control Central Unit does not work with this integration.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "binary_sensor": { + "connected_to_router": { + "name": "Connected to router" + } + }, + "sensor": { + "connected_plc_devices": { + "name": "Connected PLC devices" + }, + "connected_wifi_clients": { + "name": "Connected Wifi clients" + }, + "neighboring_wifi_networks": { + "name": "Neighboring Wifi networks" + } + }, + "switch": { + "switch_guest_wifi": { + "name": "Enable guest Wifi" + }, + "switch_leds": { + "name": "Enable LEDs" + } + } } } diff --git a/homeassistant/components/devolo_home_network/switch.py b/homeassistant/components/devolo_home_network/switch.py index fa2447985d..6f387fdf05 100644 --- a/homeassistant/components/devolo_home_network/switch.py +++ b/homeassistant/components/devolo_home_network/switch.py @@ -42,7 +42,6 @@ SWITCH_TYPES: dict[str, DevoloSwitchEntityDescription[Any]] = { SWITCH_GUEST_WIFI: DevoloSwitchEntityDescription[WifiGuestAccessGet]( key=SWITCH_GUEST_WIFI, icon="mdi:wifi", - name="Enable guest Wifi", is_on_func=lambda data: data.enabled is True, turn_on_func=lambda device: device.device.async_set_wifi_guest_access(True), # type: ignore[union-attr] turn_off_func=lambda device: device.device.async_set_wifi_guest_access(False), # type: ignore[union-attr] @@ -51,7 +50,6 @@ SWITCH_TYPES: dict[str, DevoloSwitchEntityDescription[Any]] = { key=SWITCH_LEDS, entity_category=EntityCategory.CONFIG, icon="mdi:led-off", - name="Enable LEDs", is_on_func=bool, turn_on_func=lambda device: device.device.async_set_led_setting(True), # type: ignore[union-attr] turn_off_func=lambda device: device.device.async_set_led_setting(False), # type: ignore[union-attr] From 1023628821e8e68b3b16b42003e276c297c364fa Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 29 Mar 2023 23:26:05 +0200 Subject: [PATCH 1440/2810] Bump reolink-aio to 0.5.8 (#90467) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 95b180fc16..79fc15c571 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.5.7"] + "requirements": ["reolink-aio==0.5.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index bd80ded757..cae40bd2c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2234,7 +2234,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.7 +reolink-aio==0.5.8 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3519962af1..b819133fc7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1600,7 +1600,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.7 +reolink-aio==0.5.8 # homeassistant.components.python_script restrictedpython==6.0 From 4c21caa917fe8f77f869c895c5b068504a27a068 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Mar 2023 11:26:28 -1000 Subject: [PATCH 1441/2810] Fix filesize doing blocking I/O in the event loop (#90479) Fix filesize doing I/O in the event loop --- homeassistant/components/filesize/__init__.py | 19 +++++++------------ .../components/filesize/config_flow.py | 4 +++- homeassistant/core.py | 6 +++++- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/filesize/__init__.py b/homeassistant/components/filesize/__init__.py index 9e08615d4a..73f060e79b 100644 --- a/homeassistant/components/filesize/__init__.py +++ b/homeassistant/components/filesize/__init__.py @@ -11,24 +11,19 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import PLATFORMS -def check_path(path: pathlib.Path) -> bool: - """Check path.""" - return path.exists() and path.is_file() - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up from a config entry.""" - - path = entry.data[CONF_FILE_PATH] +def _check_path(hass: HomeAssistant, path: str) -> None: + """Check if path is valid and allowed.""" get_path = pathlib.Path(path) - - check_file = await hass.async_add_executor_job(check_path, get_path) - if not check_file: + if not get_path.exists() or not get_path.is_file(): raise ConfigEntryNotReady(f"Can not access file {path}") if not hass.config.is_allowed_path(path): raise ConfigEntryNotReady(f"Filepath {path} is not valid or allowed") + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up from a config entry.""" + await hass.async_add_executor_job(_check_path, hass, entry.data[CONF_FILE_PATH]) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/filesize/config_flow.py b/homeassistant/components/filesize/config_flow.py index 3f58e636b0..8633e6ec46 100644 --- a/homeassistant/components/filesize/config_flow.py +++ b/homeassistant/components/filesize/config_flow.py @@ -49,7 +49,9 @@ class FilesizeConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: try: - full_path = validate_path(self.hass, user_input[CONF_FILE_PATH]) + full_path = await self.hass.async_add_executor_job( + validate_path, self.hass, user_input[CONF_FILE_PATH] + ) except NotValidError: errors["base"] = "not_valid" except NotAllowedError: diff --git a/homeassistant/core.py b/homeassistant/core.py index 900355d4a5..78ceb620e5 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1950,7 +1950,11 @@ class Config: ) def is_allowed_path(self, path: str) -> bool: - """Check if the path is valid for access from outside.""" + """Check if the path is valid for access from outside. + + This function does blocking I/O and should not be called from the event loop. + Use hass.async_add_executor_job to schedule it on the executor. + """ assert path is not None thepath = pathlib.Path(path) From 93d1961aae130ce12c354c1b6c9ba6668540b699 Mon Sep 17 00:00:00 2001 From: RenierM26 <66512715+RenierM26@users.noreply.github.com> Date: Wed, 29 Mar 2023 23:43:54 +0200 Subject: [PATCH 1442/2810] Use auth token in Ezviz (#54663) * Initial commit * Revert "Initial commit" This reverts commit 452027f1a3c1be186cedd4115cea6928917c9467. * Change ezviz to token auth * Bump API version. * Add fix for token expired. Fix options update and unload. * Fix tests (PLATFORM to PLATFORM_BY_TYPE) * Uses and stores token only, added reauth step when token expires. * Add tests MFA code exceptions. * Fix tests. * Remove redundant try/except blocks. * Rebase fixes. * Fix errors in reauth config flow * Implement recommendations * Fix typing error in config_flow * Fix tests after rebase, readd camera check on init * Change to platform setup * Cleanup init. * Test for MFA required under user form * Remove useless if block. * Fix formating after rebase * Fix formating. * No longer stored in the repository --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/ezviz/__init__.py | 137 +++++---- homeassistant/components/ezviz/camera.py | 14 +- homeassistant/components/ezviz/config_flow.py | 286 +++++++++++------- homeassistant/components/ezviz/const.py | 5 +- homeassistant/components/ezviz/coordinator.py | 20 +- homeassistant/components/ezviz/strings.json | 14 +- tests/components/ezviz/__init__.py | 26 +- tests/components/ezviz/conftest.py | 8 +- tests/components/ezviz/test_config_flow.py | 242 +++++++++++++-- 9 files changed, 535 insertions(+), 217 deletions(-) diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py index fbd49102f3..489ff97eb4 100644 --- a/homeassistant/components/ezviz/__init__.py +++ b/homeassistant/components/ezviz/__init__.py @@ -2,26 +2,26 @@ import logging from pyezviz.client import EzvizClient -from pyezviz.exceptions import HTTPError, InvalidURL, PyEzvizError +from pyezviz.exceptions import ( + EzvizAuthTokenExpired, + EzvizAuthVerificationCode, + HTTPError, + InvalidURL, + PyEzvizError, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_PASSWORD, - CONF_TIMEOUT, - CONF_TYPE, - CONF_URL, - CONF_USERNAME, - Platform, -) +from homeassistant.const import CONF_TIMEOUT, CONF_TYPE, CONF_URL, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import ( ATTR_TYPE_CAMERA, ATTR_TYPE_CLOUD, CONF_FFMPEG_ARGUMENTS, + CONF_RFSESSION_ID, + CONF_SESSION_ID, DATA_COORDINATOR, - DATA_UNDO_UPDATE_LISTENER, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, DOMAIN, @@ -30,17 +30,22 @@ from .coordinator import EzvizDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORMS = [ - Platform.BINARY_SENSOR, - Platform.CAMERA, - Platform.SENSOR, - Platform.SWITCH, -] +PLATFORMS_BY_TYPE: dict[str, list] = { + ATTR_TYPE_CAMERA: [], + ATTR_TYPE_CLOUD: [ + Platform.BINARY_SENSOR, + Platform.CAMERA, + Platform.SENSOR, + Platform.SWITCH, + ], +} async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up EZVIZ from a config entry.""" hass.data.setdefault(DOMAIN, {}) + sensor_type: str = entry.data[CONF_TYPE] + ezviz_client = None if not entry.options: options = { @@ -50,69 +55,71 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_update_entry(entry, options=options) - if entry.data.get(CONF_TYPE) == ATTR_TYPE_CAMERA: - if hass.data.get(DOMAIN): - # Should only execute on addition of new camera entry. - # Fetch Entry id of main account and reload it. - for item in hass.config_entries.async_entries(): - if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: - _LOGGER.info("Reload EZVIZ integration with new camera rtsp entry") - await hass.config_entries.async_reload(item.entry_id) + # Initialize EZVIZ cloud entities + if PLATFORMS_BY_TYPE[sensor_type]: + # Initiate reauth config flow if account token if not present. + if not entry.data.get(CONF_SESSION_ID): + raise ConfigEntryAuthFailed - return True - - try: - ezviz_client = await hass.async_add_executor_job( - _get_ezviz_client_instance, entry + ezviz_client = EzvizClient( + token={ + CONF_SESSION_ID: entry.data.get(CONF_SESSION_ID), + CONF_RFSESSION_ID: entry.data.get(CONF_RFSESSION_ID), + "api_url": entry.data.get(CONF_URL), + }, + timeout=entry.options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), ) - except (InvalidURL, HTTPError, PyEzvizError) as error: - _LOGGER.error("Unable to connect to EZVIZ service: %s", str(error)) - raise ConfigEntryNotReady from error - coordinator = EzvizDataUpdateCoordinator( - hass, api=ezviz_client, api_timeout=entry.options[CONF_TIMEOUT] + try: + await hass.async_add_executor_job(ezviz_client.login) + + except (EzvizAuthTokenExpired, EzvizAuthVerificationCode) as error: + raise ConfigEntryAuthFailed from error + + except (InvalidURL, HTTPError, PyEzvizError) as error: + _LOGGER.error("Unable to connect to Ezviz service: %s", str(error)) + raise ConfigEntryNotReady from error + + coordinator = EzvizDataUpdateCoordinator( + hass, api=ezviz_client, api_timeout=entry.options[CONF_TIMEOUT] + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = {DATA_COORDINATOR: coordinator} + + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + + # Check EZVIZ cloud account entity is present, reload cloud account entities for camera entity change to take effect. + # Cameras are accessed via local RTSP stream with unique credentials per camera. + # Separate camera entities allow for credential changes per camera. + if sensor_type == ATTR_TYPE_CAMERA and hass.data[DOMAIN]: + for item in hass.config_entries.async_entries(domain=DOMAIN): + if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: + _LOGGER.info("Reload Ezviz main account with camera entry") + await hass.config_entries.async_reload(item.entry_id) + return True + + await hass.config_entries.async_forward_entry_setups( + entry, PLATFORMS_BY_TYPE[sensor_type] ) - await coordinator.async_refresh() - - if not coordinator.last_update_success: - raise ConfigEntryNotReady - - undo_listener = entry.add_update_listener(_async_update_listener) - - hass.data[DOMAIN][entry.entry_id] = { - DATA_COORDINATOR: coordinator, - DATA_UNDO_UPDATE_LISTENER: undo_listener, - } - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + sensor_type = entry.data[CONF_TYPE] - if entry.data.get(CONF_TYPE) == ATTR_TYPE_CAMERA: - return True - - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN][entry.entry_id][DATA_UNDO_UPDATE_LISTENER]() + unload_ok = await hass.config_entries.async_unload_platforms( + entry, PLATFORMS_BY_TYPE[sensor_type] + ) + if sensor_type == ATTR_TYPE_CLOUD and unload_ok: hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) - - -def _get_ezviz_client_instance(entry: ConfigEntry) -> EzvizClient: - """Initialize a new instance of EzvizClientApi.""" - ezviz_client = EzvizClient( - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - entry.data[CONF_URL], - entry.options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), - ) - ezviz_client.login() - return ezviz_client diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index 7901061c02..0456e7ade9 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -34,7 +34,6 @@ from .const import ( DATA_COORDINATOR, DEFAULT_CAMERA_USERNAME, DEFAULT_FFMPEG_ARGUMENTS, - DEFAULT_RTSP_PORT, DIR_DOWN, DIR_LEFT, DIR_RIGHT, @@ -70,24 +69,17 @@ async def async_setup_entry( if item.unique_id == camera and item.source != SOURCE_IGNORE ] - # There seem to be a bug related to localRtspPort in EZVIZ API. - local_rtsp_port = ( - value["local_rtsp_port"] - if value["local_rtsp_port"] != 0 - else DEFAULT_RTSP_PORT - ) - if camera_rtsp_entry: ffmpeg_arguments = camera_rtsp_entry[0].options[CONF_FFMPEG_ARGUMENTS] camera_username = camera_rtsp_entry[0].data[CONF_USERNAME] camera_password = camera_rtsp_entry[0].data[CONF_PASSWORD] - camera_rtsp_stream = f"rtsp://{camera_username}:{camera_password}@{value['local_ip']}:{local_rtsp_port}{ffmpeg_arguments}" + camera_rtsp_stream = f"rtsp://{camera_username}:{camera_password}@{value['local_ip']}:{value['local_rtsp_port']}{ffmpeg_arguments}" _LOGGER.debug( "Configuring Camera %s with ip: %s rtsp port: %s ffmpeg arguments: %s", camera, value["local_ip"], - local_rtsp_port, + value["local_rtsp_port"], ffmpeg_arguments, ) @@ -123,7 +115,7 @@ async def async_setup_entry( camera_username, camera_password, camera_rtsp_stream, - local_rtsp_port, + value["local_rtsp_port"], ffmpeg_arguments, ) ) diff --git a/homeassistant/components/ezviz/config_flow.py b/homeassistant/components/ezviz/config_flow.py index 4c8b1418fa..77598ad6a1 100644 --- a/homeassistant/components/ezviz/config_flow.py +++ b/homeassistant/components/ezviz/config_flow.py @@ -1,12 +1,14 @@ -"""Config flow for ezviz.""" +"""Config flow for EZVIZ.""" from __future__ import annotations +from collections.abc import Mapping import logging +from typing import Any from pyezviz.client import EzvizClient from pyezviz.exceptions import ( AuthTestResultFailed, - HTTPError, + EzvizAuthVerificationCode, InvalidHost, InvalidURL, PyEzvizError, @@ -25,12 +27,15 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from .const import ( ATTR_SERIAL, ATTR_TYPE_CAMERA, ATTR_TYPE_CLOUD, CONF_FFMPEG_ARGUMENTS, + CONF_RFSESSION_ID, + CONF_SESSION_ID, DEFAULT_CAMERA_USERNAME, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, @@ -40,23 +45,37 @@ from .const import ( ) _LOGGER = logging.getLogger(__name__) +DEFAULT_OPTIONS = { + CONF_FFMPEG_ARGUMENTS: DEFAULT_FFMPEG_ARGUMENTS, + CONF_TIMEOUT: DEFAULT_TIMEOUT, +} -def _get_ezviz_client_instance(data): - """Initialize a new instance of EzvizClientApi.""" +def _validate_and_create_auth(data: dict) -> dict[str, Any]: + """Try to login to EZVIZ cloud account and return token.""" + # Verify cloud credentials by attempting a login request with username and password. + # Return login token. ezviz_client = EzvizClient( data[CONF_USERNAME], data[CONF_PASSWORD], - data.get(CONF_URL, EU_URL), + data[CONF_URL], data.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), ) - ezviz_client.login() - return ezviz_client + ezviz_token = ezviz_client.login() + + auth_data = { + CONF_SESSION_ID: ezviz_token[CONF_SESSION_ID], + CONF_RFSESSION_ID: ezviz_token[CONF_RFSESSION_ID], + CONF_URL: ezviz_token["api_url"], + CONF_TYPE: ATTR_TYPE_CLOUD, + } + + return auth_data -def _test_camera_rtsp_creds(data): +def _test_camera_rtsp_creds(data: dict) -> None: """Try DESCRIBE on RTSP camera with credentials.""" test_rtsp = TestRTSPAuth( @@ -71,89 +90,43 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - async def _validate_and_create_auth(self, data): - """Try to login to ezviz cloud account and create entry if successful.""" - await self.async_set_unique_id(data[CONF_USERNAME]) - self._abort_if_unique_id_configured() - - # Verify cloud credentials by attempting a login request. - try: - await self.hass.async_add_executor_job(_get_ezviz_client_instance, data) - - except InvalidURL as err: - raise InvalidURL from err - - except HTTPError as err: - raise InvalidHost from err - - except PyEzvizError as err: - raise PyEzvizError from err - - auth_data = { - CONF_USERNAME: data[CONF_USERNAME], - CONF_PASSWORD: data[CONF_PASSWORD], - CONF_URL: data.get(CONF_URL, EU_URL), - CONF_TYPE: ATTR_TYPE_CLOUD, - } - - return self.async_create_entry(title=data[CONF_USERNAME], data=auth_data) - - async def _validate_and_create_camera_rtsp(self, data): + async def _validate_and_create_camera_rtsp(self, data: dict) -> FlowResult: """Try DESCRIBE on RTSP camera with credentials.""" # Get EZVIZ cloud credentials from config entry - ezviz_client_creds = { - CONF_USERNAME: None, - CONF_PASSWORD: None, - CONF_URL: None, + ezviz_token = { + CONF_SESSION_ID: None, + CONF_RFSESSION_ID: None, + "api_url": None, } + ezviz_timeout = DEFAULT_TIMEOUT for item in self._async_current_entries(): if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: - ezviz_client_creds = { - CONF_USERNAME: item.data.get(CONF_USERNAME), - CONF_PASSWORD: item.data.get(CONF_PASSWORD), - CONF_URL: item.data.get(CONF_URL), + ezviz_token = { + CONF_SESSION_ID: item.data.get(CONF_SESSION_ID), + CONF_RFSESSION_ID: item.data.get(CONF_RFSESSION_ID), + "api_url": item.data.get(CONF_URL), } + ezviz_timeout = item.data.get(CONF_TIMEOUT, DEFAULT_TIMEOUT) # Abort flow if user removed cloud account before adding camera. - if ezviz_client_creds[CONF_USERNAME] is None: + if ezviz_token.get(CONF_SESSION_ID) is None: return self.async_abort(reason="ezviz_cloud_account_missing") + ezviz_client = EzvizClient(token=ezviz_token, timeout=ezviz_timeout) + # We need to wake hibernating cameras. # First create EZVIZ API instance. - try: - ezviz_client = await self.hass.async_add_executor_job( - _get_ezviz_client_instance, ezviz_client_creds - ) + await self.hass.async_add_executor_job(ezviz_client.login) - except InvalidURL as err: - raise InvalidURL from err - - except HTTPError as err: - raise InvalidHost from err - - except PyEzvizError as err: - raise PyEzvizError from err - - # Secondly try to wake hibernating camera. - try: - await self.hass.async_add_executor_job( - ezviz_client.get_detection_sensibility, data[ATTR_SERIAL] - ) - - except HTTPError as err: - raise InvalidHost from err + # Secondly try to wake hybernating camera. + await self.hass.async_add_executor_job( + ezviz_client.get_detection_sensibility, data[ATTR_SERIAL] + ) # Thirdly attempts an authenticated RTSP DESCRIBE request. - try: - await self.hass.async_add_executor_job(_test_camera_rtsp_creds, data) - - except InvalidHost as err: - raise InvalidHost from err - - except AuthTestResultFailed as err: - raise AuthTestResultFailed from err + await self.hass.async_add_executor_job(_test_camera_rtsp_creds, data) return self.async_create_entry( title=data[ATTR_SERIAL], @@ -162,6 +135,7 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): CONF_PASSWORD: data[CONF_PASSWORD], CONF_TYPE: ATTR_TYPE_CAMERA, }, + options=DEFAULT_OPTIONS, ) @staticmethod @@ -170,18 +144,24 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return EzvizOptionsFlowHandler(config_entry) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by the user.""" - # Check if ezviz cloud account is present in entry config, + # Check if EZVIZ cloud account is present in entry config, # abort if already configured. for item in self._async_current_entries(): if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: return self.async_abort(reason="already_configured_account") errors = {} + auth_data = {} if user_input is not None: + await self.async_set_unique_id(user_input[CONF_USERNAME]) + self._abort_if_unique_id_configured() + if user_input[CONF_URL] == CONF_CUSTOMIZE: self.context["data"] = { CONF_USERNAME: user_input[CONF_USERNAME], @@ -189,11 +169,10 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): } return await self.async_step_user_custom_url() - if CONF_TIMEOUT not in user_input: - user_input[CONF_TIMEOUT] = DEFAULT_TIMEOUT - try: - return await self._validate_and_create_auth(user_input) + auth_data = await self.hass.async_add_executor_job( + _validate_and_create_auth, user_input + ) except InvalidURL: errors["base"] = "invalid_host" @@ -201,6 +180,9 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): except InvalidHost: errors["base"] = "cannot_connect" + except EzvizAuthVerificationCode: + errors["base"] = "mfa_required" + except PyEzvizError: errors["base"] = "invalid_auth" @@ -208,6 +190,13 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") + else: + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data=auth_data, + options=DEFAULT_OPTIONS, + ) + data_schema = vol.Schema( { vol.Required(CONF_USERNAME): str, @@ -222,20 +211,21 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=data_schema, errors=errors ) - async def async_step_user_custom_url(self, user_input=None): + async def async_step_user_custom_url( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by the user for custom region url.""" - errors = {} + auth_data = {} if user_input is not None: user_input[CONF_USERNAME] = self.context["data"][CONF_USERNAME] user_input[CONF_PASSWORD] = self.context["data"][CONF_PASSWORD] - if CONF_TIMEOUT not in user_input: - user_input[CONF_TIMEOUT] = DEFAULT_TIMEOUT - try: - return await self._validate_and_create_auth(user_input) + auth_data = await self.hass.async_add_executor_job( + _validate_and_create_auth, user_input + ) except InvalidURL: errors["base"] = "invalid_host" @@ -243,6 +233,9 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): except InvalidHost: errors["base"] = "cannot_connect" + except EzvizAuthVerificationCode: + errors["base"] = "mfa_required" + except PyEzvizError: errors["base"] = "invalid_auth" @@ -250,6 +243,13 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") + else: + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data=auth_data, + options=DEFAULT_OPTIONS, + ) + data_schema_custom_url = vol.Schema( { vol.Required(CONF_URL, default=EU_URL): str, @@ -260,18 +260,22 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user_custom_url", data_schema=data_schema_custom_url, errors=errors ) - async def async_step_integration_discovery(self, discovery_info): + async def async_step_integration_discovery( + self, discovery_info: dict[str, Any] + ) -> FlowResult: """Handle a flow for discovered camera without rtsp config entry.""" await self.async_set_unique_id(discovery_info[ATTR_SERIAL]) self._abort_if_unique_id_configured() - self.context["title_placeholders"] = {"serial": self.unique_id} + self.context["title_placeholders"] = {ATTR_SERIAL: self.unique_id} self.context["data"] = {CONF_IP_ADDRESS: discovery_info[CONF_IP_ADDRESS]} return await self.async_step_confirm() - async def async_step_confirm(self, user_input=None): + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Confirm and create entry from discovery step.""" errors = {} @@ -284,6 +288,9 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): except (InvalidHost, InvalidURL): errors["base"] = "invalid_host" + except EzvizAuthVerificationCode: + errors["base"] = "mfa_required" + except (PyEzvizError, AuthTestResultFailed): errors["base"] = "invalid_auth" @@ -303,11 +310,76 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=discovered_camera_schema, errors=errors, description_placeholders={ - "serial": self.unique_id, + ATTR_SERIAL: self.unique_id, CONF_IP_ADDRESS: self.context["data"][CONF_IP_ADDRESS], }, ) + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + """Handle a flow for reauthentication with password.""" + + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a Confirm flow for reauthentication with password.""" + auth_data = {} + errors = {} + entry = None + + for item in self._async_current_entries(): + if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: + self.context["title_placeholders"] = {ATTR_SERIAL: item.title} + entry = await self.async_set_unique_id(item.title) + + if not entry: + return self.async_abort(reason="ezviz_cloud_account_missing") + + if user_input is not None: + user_input[CONF_URL] = entry.data[CONF_URL] + + try: + auth_data = await self.hass.async_add_executor_job( + _validate_and_create_auth, user_input + ) + + except (InvalidHost, InvalidURL): + errors["base"] = "invalid_host" + + except EzvizAuthVerificationCode: + errors["base"] = "mfa_required" + + except (PyEzvizError, AuthTestResultFailed): + errors["base"] = "invalid_auth" + + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + else: + self.hass.config_entries.async_update_entry( + entry, + data=auth_data, + ) + + await self.hass.config_entries.async_reload(entry.entry_id) + + return self.async_abort(reason="reauth_successful") + + data_schema = vol.Schema( + { + vol.Required(CONF_USERNAME, default=entry.title): vol.In([entry.title]), + vol.Required(CONF_PASSWORD): str, + } + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=data_schema, + errors=errors, + ) + class EzvizOptionsFlowHandler(OptionsFlow): """Handle EZVIZ client options.""" @@ -316,22 +388,28 @@ class EzvizOptionsFlowHandler(OptionsFlow): """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage EZVIZ options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) - options = { - vol.Optional( - CONF_TIMEOUT, - default=self.config_entry.options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), - ): int, - vol.Optional( - CONF_FFMPEG_ARGUMENTS, - default=self.config_entry.options.get( - CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS - ), - ): str, - } + options = vol.Schema( + { + vol.Optional( + CONF_TIMEOUT, + default=self.config_entry.options.get( + CONF_TIMEOUT, DEFAULT_TIMEOUT + ), + ): int, + vol.Optional( + CONF_FFMPEG_ARGUMENTS, + default=self.config_entry.options.get( + CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS + ), + ): str, + } + ) - return self.async_show_form(step_id="init", data_schema=vol.Schema(options)) + return self.async_show_form(step_id="init", data_schema=options) diff --git a/homeassistant/components/ezviz/const.py b/homeassistant/components/ezviz/const.py index b9183772b6..d052a4b821 100644 --- a/homeassistant/components/ezviz/const.py +++ b/homeassistant/components/ezviz/const.py @@ -10,6 +10,9 @@ ATTR_HOME = "HOME_MODE" ATTR_AWAY = "AWAY_MODE" ATTR_TYPE_CLOUD = "EZVIZ_CLOUD_ACCOUNT" ATTR_TYPE_CAMERA = "CAMERA_ACCOUNT" +CONF_SESSION_ID = "session_id" +CONF_RFSESSION_ID = "rf_session_id" +CONF_EZVIZ_ACCOUNT = "ezviz_account" # Services data DIR_UP = "up" @@ -33,10 +36,8 @@ SERVICE_DETECTION_SENSITIVITY = "set_alarm_detection_sensibility" EU_URL = "apiieu.ezvizlife.com" RUSSIA_URL = "apirus.ezvizru.com" DEFAULT_CAMERA_USERNAME = "admin" -DEFAULT_RTSP_PORT = 554 DEFAULT_TIMEOUT = 25 DEFAULT_FFMPEG_ARGUMENTS = "" # Data DATA_COORDINATOR = "coordinator" -DATA_UNDO_UPDATE_LISTENER = "undo_update_listener" diff --git a/homeassistant/components/ezviz/coordinator.py b/homeassistant/components/ezviz/coordinator.py index cc4537bb9b..ba8ed336a5 100644 --- a/homeassistant/components/ezviz/coordinator.py +++ b/homeassistant/components/ezviz/coordinator.py @@ -4,9 +4,16 @@ import logging from async_timeout import timeout from pyezviz.client import EzvizClient -from pyezviz.exceptions import HTTPError, InvalidURL, PyEzvizError +from pyezviz.exceptions import ( + EzvizAuthTokenExpired, + EzvizAuthVerificationCode, + HTTPError, + InvalidURL, + PyEzvizError, +) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -27,15 +34,16 @@ class EzvizDataUpdateCoordinator(DataUpdateCoordinator): super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) - def _update_data(self) -> dict: - """Fetch data from EZVIZ via camera load function.""" - return self.ezviz_client.load_cameras() - async def _async_update_data(self) -> dict: """Fetch data from EZVIZ.""" try: async with timeout(self._api_timeout): - return await self.hass.async_add_executor_job(self._update_data) + return await self.hass.async_add_executor_job( + self.ezviz_client.load_cameras + ) + + except (EzvizAuthTokenExpired, EzvizAuthVerificationCode) as error: + raise ConfigEntryAuthFailed from error except (InvalidURL, HTTPError, PyEzvizError) as error: raise UpdateFailed(f"Invalid response from API: {error}") from error diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json index 91fa32ad9b..5e258e4270 100644 --- a/homeassistant/components/ezviz/strings.json +++ b/homeassistant/components/ezviz/strings.json @@ -26,17 +26,27 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Enter credentials to reauthenticate to ezviz cloud account", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "invalid_host": "[%key:common::config_flow::error::invalid_host%]" + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "mfa_required": "2FA enabled on account, please disable and retry" }, "abort": { "already_configured_account": "[%key:common::config_flow::abort::already_configured_account%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "ezviz_cloud_account_missing": "EZVIZ cloud account missing. Please reconfigure EZVIZ cloud account" + "ezviz_cloud_account_missing": "Ezviz cloud account missing. Please reconfigure Ezviz cloud account", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "options": { diff --git a/tests/components/ezviz/__init__.py b/tests/components/ezviz/__init__.py index 64dcbfc26e..768fc30cc8 100644 --- a/tests/components/ezviz/__init__.py +++ b/tests/components/ezviz/__init__.py @@ -3,8 +3,11 @@ from unittest.mock import patch from homeassistant.components.ezviz.const import ( ATTR_SERIAL, + ATTR_TYPE_CAMERA, ATTR_TYPE_CLOUD, CONF_FFMPEG_ARGUMENTS, + CONF_RFSESSION_ID, + CONF_SESSION_ID, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, DOMAIN, @@ -22,8 +25,8 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry ENTRY_CONFIG = { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", + CONF_SESSION_ID: "test-username", + CONF_RFSESSION_ID: "test-password", CONF_URL: "apiieu.ezvizlife.com", CONF_TYPE: ATTR_TYPE_CLOUD, } @@ -46,6 +49,18 @@ USER_INPUT = { CONF_TYPE: ATTR_TYPE_CLOUD, } +USER_INPUT_CAMERA_VALIDATE = { + ATTR_SERIAL: "C666666", + CONF_PASSWORD: "test-password", + CONF_USERNAME: "test-username", +} + +USER_INPUT_CAMERA = { + CONF_PASSWORD: "test-password", + CONF_USERNAME: "test-username", + CONF_TYPE: ATTR_TYPE_CAMERA, +} + DISCOVERY_INFO = { ATTR_SERIAL: "C666666", CONF_USERNAME: None, @@ -59,6 +74,13 @@ TEST = { CONF_IP_ADDRESS: "127.0.0.1", } +API_LOGIN_RETURN_VALIDATE = { + CONF_SESSION_ID: "fake_token", + CONF_RFSESSION_ID: "fake_rf_token", + CONF_URL: "apiieu.ezvizlife.com", + CONF_TYPE: ATTR_TYPE_CLOUD, +} + def _patch_async_setup_entry(return_value=True): return patch( diff --git a/tests/components/ezviz/conftest.py b/tests/components/ezviz/conftest.py index 76b962250b..e89e375fb5 100644 --- a/tests/components/ezviz/conftest.py +++ b/tests/components/ezviz/conftest.py @@ -5,6 +5,12 @@ from pyezviz import EzvizClient from pyezviz.test_cam_rtsp import TestRTSPAuth import pytest +ezviz_login_token_return = { + "session_id": "fake_token", + "rf_session_id": "fake_rf_token", + "api_url": "apiieu.ezvizlife.com", +} + @pytest.fixture(autouse=True) def mock_ffmpeg(hass): @@ -42,7 +48,7 @@ def ezviz_config_flow(hass): "1", ) - instance.login = MagicMock(return_value=True) + instance.login = MagicMock(return_value=ezviz_login_token_return) instance.get_detection_sensibility = MagicMock(return_value=True) yield mock_ezviz diff --git a/tests/components/ezviz/test_config_flow.py b/tests/components/ezviz/test_config_flow.py index 624827220c..939bb92bcc 100644 --- a/tests/components/ezviz/test_config_flow.py +++ b/tests/components/ezviz/test_config_flow.py @@ -3,6 +3,7 @@ from unittest.mock import patch from pyezviz.exceptions import ( AuthTestResultFailed, + EzvizAuthVerificationCode, HTTPError, InvalidHost, InvalidURL, @@ -12,13 +13,16 @@ from pyezviz.exceptions import ( from homeassistant.components.ezviz.const import ( ATTR_SERIAL, ATTR_TYPE_CAMERA, - ATTR_TYPE_CLOUD, CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, DOMAIN, ) -from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, SOURCE_USER +from homeassistant.config_entries import ( + SOURCE_INTEGRATION_DISCOVERY, + SOURCE_REAUTH, + SOURCE_USER, +) from homeassistant.const import ( CONF_CUSTOMIZE, CONF_IP_ADDRESS, @@ -32,8 +36,8 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import ( + API_LOGIN_RETURN_VALIDATE, DISCOVERY_INFO, - USER_INPUT, USER_INPUT_VALIDATE, _patch_async_setup_entry, init_integration, @@ -59,7 +63,7 @@ async def test_user_form(hass: HomeAssistant, ezviz_config_flow) -> None: assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "test-username" - assert result["data"] == {**USER_INPUT} + assert result["data"] == {**API_LOGIN_RETURN_VALIDATE} assert len(mock_setup_entry.mock_calls) == 1 @@ -78,7 +82,11 @@ async def test_user_custom_url(hass: HomeAssistant, ezviz_config_flow) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_USERNAME: "test-user", CONF_PASSWORD: "test-pass", CONF_URL: "customize"}, + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_URL: CONF_CUSTOMIZE, + }, ) assert result["type"] == FlowResultType.FORM @@ -90,21 +98,58 @@ async def test_user_custom_url(hass: HomeAssistant, ezviz_config_flow) -> None: result["flow_id"], {CONF_URL: "test-user"}, ) + await hass.async_block_till_done() assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"] == { - CONF_PASSWORD: "test-pass", - CONF_TYPE: ATTR_TYPE_CLOUD, - CONF_URL: "test-user", - CONF_USERNAME: "test-user", - } + assert result["data"] == API_LOGIN_RETURN_VALIDATE assert len(mock_setup_entry.mock_calls) == 1 -async def test_step_discovery_abort_if_cloud_account_missing( - hass: HomeAssistant, -) -> None: +async def test_async_step_reauth(hass, ezviz_config_flow): + """Test the reauth step.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with _patch_async_setup_entry() as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_VALIDATE, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == {**API_LOGIN_RETURN_VALIDATE} + + assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_REAUTH}, data=USER_INPUT_VALIDATE + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_step_discovery_abort_if_cloud_account_missing(hass): """Test discovery and confirm step, abort if cloud account was removed.""" result = await hass.config_entries.flow.async_init( @@ -127,11 +172,21 @@ async def test_step_discovery_abort_if_cloud_account_missing( assert result["reason"] == "ezviz_cloud_account_missing" +async def test_step_reauth_abort_if_cloud_account_missing(hass): + """Test reauth and confirm step, abort if cloud account was removed.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_REAUTH}, data=USER_INPUT_VALIDATE + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "ezviz_cloud_account_missing" + + async def test_async_step_integration_discovery( - hass: HomeAssistant, ezviz_config_flow, ezviz_test_rtsp_config_flow -) -> None: + hass, ezviz_config_flow, ezviz_test_rtsp_config_flow +): """Test discovery and confirm step.""" - with patch("homeassistant.components.ezviz.PLATFORMS", []): + with patch("homeassistant.components.ezviz.PLATFORMS_BY_TYPE", []): await init_integration(hass) result = await hass.config_entries.flow.async_init( @@ -189,11 +244,14 @@ async def test_options_flow(hass: HomeAssistant) -> None: async def test_user_form_exception(hass: HomeAssistant, ezviz_config_flow) -> None: """Test we handle exception on user form.""" - ezviz_config_flow.side_effect = PyEzvizError - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + ezviz_config_flow.side_effect = PyEzvizError result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -215,6 +273,17 @@ async def test_user_form_exception(hass: HomeAssistant, ezviz_config_flow) -> No assert result["step_id"] == "user" assert result["errors"] == {"base": "invalid_host"} + ezviz_config_flow.side_effect = EzvizAuthVerificationCode + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_VALIDATE, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "mfa_required"} + ezviz_config_flow.side_effect = HTTPError result = await hass.config_entries.flow.async_configure( @@ -224,7 +293,7 @@ async def test_user_form_exception(hass: HomeAssistant, ezviz_config_flow) -> No assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": "invalid_auth"} ezviz_config_flow.side_effect = Exception @@ -242,7 +311,7 @@ async def test_discover_exception_step1( ezviz_config_flow, ) -> None: """Test we handle unexpected exception on discovery.""" - with patch("homeassistant.components.ezviz.PLATFORMS", []): + with patch("homeassistant.components.ezviz.PLATFORMS_BY_TYPE", []): await init_integration(hass) result = await hass.config_entries.flow.async_init( @@ -295,7 +364,21 @@ async def test_discover_exception_step1( assert result["type"] == FlowResultType.FORM assert result["step_id"] == "confirm" - assert result["errors"] == {"base": "invalid_host"} + assert result["errors"] == {"base": "invalid_auth"} + + ezviz_config_flow.side_effect = EzvizAuthVerificationCode + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-user", + CONF_PASSWORD: "test-pass", + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "confirm" + assert result["errors"] == {"base": "mfa_required"} ezviz_config_flow.side_effect = Exception @@ -317,7 +400,7 @@ async def test_discover_exception_step3( ezviz_test_rtsp_config_flow, ) -> None: """Test we handle unexpected exception on discovery.""" - with patch("homeassistant.components.ezviz.PLATFORMS", []): + with patch("homeassistant.components.ezviz.PLATFORMS_BY_TYPE", []): await init_integration(hass) result = await hass.config_entries.flow.async_init( @@ -423,7 +506,18 @@ async def test_user_custom_url_exception( assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user_custom_url" - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": "invalid_auth"} + + ezviz_config_flow.side_effect = EzvizAuthVerificationCode + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "test-user"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user_custom_url" + assert result["errors"] == {"base": "mfa_required"} ezviz_config_flow.side_effect = Exception @@ -434,3 +528,103 @@ async def test_user_custom_url_exception( assert result["type"] == FlowResultType.ABORT assert result["reason"] == "unknown" + + +async def test_async_step_reauth_exception(hass, ezviz_config_flow): + """Test the reauth step exceptions.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with _patch_async_setup_entry() as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_VALIDATE, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == {**API_LOGIN_RETURN_VALIDATE} + + assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_REAUTH}, data=USER_INPUT_VALIDATE + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + ezviz_config_flow.side_effect = InvalidURL() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_host"} + + ezviz_config_flow.side_effect = InvalidHost() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_host"} + + ezviz_config_flow.side_effect = EzvizAuthVerificationCode() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "mfa_required"} + + ezviz_config_flow.side_effect = PyEzvizError() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_auth"} + + ezviz_config_flow.side_effect = Exception() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "unknown" From fc78290e2f425aaeed11b61255b2f0adf8f3be1c Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 29 Mar 2023 18:04:39 -0400 Subject: [PATCH 1443/2810] Remove callback decorators where unneeded (#90478) * Remove callback decorators where unneeded * revert extra replace --- homeassistant/components/zwave_js/api.py | 4 ++-- homeassistant/components/zwave_js/helpers.py | 9 +-------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 91b1e2a715..29e0dcf9e0 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -82,8 +82,8 @@ from .const import ( from .helpers import ( async_enable_statistics, async_get_node_from_device_id, + async_update_data_collection_preference, get_device_id, - update_data_collection_preference, ) DATA_UNSUBSCRIBE = "unsubs" @@ -1860,7 +1860,7 @@ async def websocket_update_data_collection_preference( ) -> None: """Update preference for data collection and enable/disable collection.""" opted_in = msg[OPTED_IN] - update_data_collection_preference(hass, entry, opted_in) + async_update_data_collection_preference(hass, entry, opted_in) if opted_in: await async_enable_statistics(driver) diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index d856e987af..6c54a46483 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -92,7 +92,6 @@ def value_matches_matcher( ) -@callback def get_value_id_from_unique_id(unique_id: str) -> str | None: """Get the value ID and optional state key from a unique ID. @@ -106,7 +105,6 @@ def get_value_id_from_unique_id(unique_id: str) -> str | None: return None -@callback def get_state_key_from_unique_id(unique_id: str) -> int | None: """Get the state key from a unique ID.""" # If the unique ID has more than two parts, it's a special unique ID. If the last @@ -119,7 +117,6 @@ def get_state_key_from_unique_id(unique_id: str) -> int | None: return None -@callback def get_value_of_zwave_value(value: ZwaveValue | None) -> Any | None: """Return the value of a ZwaveValue.""" return value.value if value else None @@ -132,7 +129,7 @@ async def async_enable_statistics(driver: Driver) -> None: @callback -def update_data_collection_preference( +def async_update_data_collection_preference( hass: HomeAssistant, entry: ConfigEntry, preference: bool ) -> None: """Update data collection preference on config entry.""" @@ -141,7 +138,6 @@ def update_data_collection_preference( hass.config_entries.async_update_entry(entry, data=new_data) -@callback def get_valueless_base_unique_id(driver: Driver, node: ZwaveNode) -> str: """Return the base unique ID for an entity that is not based on a value.""" return f"{driver.controller.home_id}.{node.node_id}" @@ -152,13 +148,11 @@ def get_unique_id(driver: Driver, value_id: str) -> str: return f"{driver.controller.home_id}.{value_id}" -@callback def get_device_id(driver: Driver, node: ZwaveNode) -> tuple[str, str]: """Get device registry identifier for Z-Wave node.""" return (DOMAIN, f"{driver.controller.home_id}-{node.node_id}") -@callback def get_device_id_ext(driver: Driver, node: ZwaveNode) -> tuple[str, str] | None: """Get extended device registry identifier for Z-Wave node.""" if None in (node.manufacturer_id, node.product_type, node.product_id): @@ -171,7 +165,6 @@ def get_device_id_ext(driver: Driver, node: ZwaveNode) -> tuple[str, str] | None ) -@callback def get_home_and_node_id_from_device_entry( device_entry: dr.DeviceEntry, ) -> tuple[str, int] | None: From f0710bae06e1d02df8fa70eb758b9386517d204e Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Thu, 30 Mar 2023 07:42:09 +0200 Subject: [PATCH 1444/2810] Add config-flow to Snapcast (#80288) * initial stab at snapcast config flow * fix linting errors * Fix linter errors * Add import flow, support unloading * Add test for import flow * Add dataclass and remove unique ID in config-flow * remove translations * Apply suggestions from code review Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Refactor config flow and terminate connection * Rename test_config_flow.py * Fix tests * Minor fixes * Make mock_create_server a fixture * Combine tests * Abort if entry already exists * Apply suggestions from code review Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Move HomeAssistantSnapcast to own file. Clean-up last commit * Split import flow from user flow. Fix tests. * Use explicit asserts. Add default values to dataclass * Change entry title to Snapcast --------- Co-authored-by: Barrett Lowe Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .coveragerc | 4 +- CODEOWNERS | 1 + homeassistant/components/snapcast/__init__.py | 42 ++++++- .../components/snapcast/config_flow.py | 63 ++++++++++ homeassistant/components/snapcast/const.py | 6 +- .../components/snapcast/manifest.json | 1 + .../components/snapcast/media_player.py | 87 +++++++++----- homeassistant/components/snapcast/server.py | 15 +++ .../components/snapcast/strings.json | 27 +++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/snapcast/__init__.py | 1 + tests/components/snapcast/conftest.py | 23 ++++ tests/components/snapcast/test_config_flow.py | 110 ++++++++++++++++++ 15 files changed, 352 insertions(+), 34 deletions(-) create mode 100644 homeassistant/components/snapcast/config_flow.py create mode 100644 homeassistant/components/snapcast/server.py create mode 100644 homeassistant/components/snapcast/strings.json create mode 100644 tests/components/snapcast/__init__.py create mode 100644 tests/components/snapcast/conftest.py create mode 100644 tests/components/snapcast/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 2c06c7d0bb..d313da55dd 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1101,7 +1101,9 @@ omit = homeassistant/components/sms/notify.py homeassistant/components/sms/sensor.py homeassistant/components/smtp/notify.py - homeassistant/components/snapcast/* + homeassistant/components/snapcast/__init__.py + homeassistant/components/snapcast/media_player.py + homeassistant/components/snapcast/server.py homeassistant/components/snmp/device_tracker.py homeassistant/components/snmp/sensor.py homeassistant/components/snmp/switch.py diff --git a/CODEOWNERS b/CODEOWNERS index 509f3e5f30..0e918caade 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1105,6 +1105,7 @@ build.json @home-assistant/supervisor /tests/components/smhi/ @gjohansson-ST /homeassistant/components/sms/ @ocalvo /homeassistant/components/snapcast/ @luar123 +/tests/components/snapcast/ @luar123 /homeassistant/components/snooz/ @AustinBrunkhorst /tests/components/snooz/ @AustinBrunkhorst /homeassistant/components/solaredge/ @frenck diff --git a/homeassistant/components/snapcast/__init__.py b/homeassistant/components/snapcast/__init__.py index b5279fa3ce..309669a849 100644 --- a/homeassistant/components/snapcast/__init__.py +++ b/homeassistant/components/snapcast/__init__.py @@ -1 +1,41 @@ -"""The snapcast component.""" +"""Snapcast Integration.""" +import logging + +import snapcast.control + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN, PLATFORMS +from .server import HomeAssistantSnapcast + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Snapcast from a config entry.""" + host = entry.data[CONF_HOST] + port = entry.data[CONF_PORT] + try: + server = await snapcast.control.create_server( + hass.loop, host, port, reconnect=True + ) + except OSError as ex: + raise ConfigEntryNotReady( + f"Could not connect to Snapcast server at {host}:{port}" + ) from ex + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantSnapcast(server) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/snapcast/config_flow.py b/homeassistant/components/snapcast/config_flow.py new file mode 100644 index 0000000000..896d3f8b5a --- /dev/null +++ b/homeassistant/components/snapcast/config_flow.py @@ -0,0 +1,63 @@ +"""Snapcast config flow.""" + +from __future__ import annotations + +import logging +import socket + +import snapcast.control +from snapcast.control.server import CONTROL_PORT +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.data_entry_flow import FlowResult + +from .const import DEFAULT_TITLE, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SNAPCAST_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=CONTROL_PORT): int, + } +) + + +class SnapcastConfigFlow(ConfigFlow, domain=DOMAIN): + """Snapcast config flow.""" + + async def async_step_user(self, user_input=None) -> FlowResult: + """Handle first step.""" + errors = {} + if user_input: + self._async_abort_entries_match(user_input) + host = user_input[CONF_HOST] + port = user_input[CONF_PORT] + + # Attempt to create the server - make sure it's going to work + try: + client = await snapcast.control.create_server( + self.hass.loop, host, port, reconnect=False + ) + except socket.gaierror: + errors["base"] = "invalid_host" + except OSError: + errors["base"] = "cannot_connect" + else: + await client.stop() + return self.async_create_entry(title=DEFAULT_TITLE, data=user_input) + return self.async_show_form( + step_id="user", data_schema=SNAPCAST_SCHEMA, errors=errors + ) + + async def async_step_import(self, import_config: dict[str, str]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + self._async_abort_entries_match( + { + CONF_HOST: (import_config[CONF_HOST]), + CONF_PORT: (import_config[CONF_PORT]), + } + ) + return self.async_create_entry(title=DEFAULT_TITLE, data=import_config) diff --git a/homeassistant/components/snapcast/const.py b/homeassistant/components/snapcast/const.py index 674a22993b..ded57e6fb0 100644 --- a/homeassistant/components/snapcast/const.py +++ b/homeassistant/components/snapcast/const.py @@ -1,6 +1,7 @@ """Constants for Snapcast.""" +from homeassistant.const import Platform -DATA_KEY = "snapcast" +PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER] GROUP_PREFIX = "snapcast_group_" GROUP_SUFFIX = "Snapcast Group" @@ -15,3 +16,6 @@ SERVICE_SET_LATENCY = "set_latency" ATTR_MASTER = "master" ATTR_LATENCY = "latency" + +DOMAIN = "snapcast" +DEFAULT_TITLE = "Snapcast" diff --git a/homeassistant/components/snapcast/manifest.json b/homeassistant/components/snapcast/manifest.json index bdcadc84e7..8701fca0ad 100644 --- a/homeassistant/components/snapcast/manifest.json +++ b/homeassistant/components/snapcast/manifest.json @@ -2,6 +2,7 @@ "domain": "snapcast", "name": "Snapcast", "codeowners": ["@luar123"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/snapcast", "iot_class": "local_polling", "loggers": ["construct", "snapcast"], diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index 9e0e10ac0e..6f965155bb 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -2,9 +2,7 @@ from __future__ import annotations import logging -import socket -import snapcast.control from snapcast.control.server import CONTROL_PORT import voluptuous as vol @@ -14,10 +12,12 @@ from homeassistant.components.media_player import ( MediaPlayerEntityFeature, MediaPlayerState, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( @@ -25,7 +25,7 @@ from .const import ( ATTR_MASTER, CLIENT_PREFIX, CLIENT_SUFFIX, - DATA_KEY, + DOMAIN, GROUP_PREFIX, GROUP_SUFFIX, SERVICE_JOIN, @@ -34,6 +34,7 @@ from .const import ( SERVICE_SNAPSHOT, SERVICE_UNJOIN, ) +from .server import HomeAssistantSnapcast _LOGGER = logging.getLogger(__name__) @@ -42,18 +43,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Snapcast platform.""" - - host = config.get(CONF_HOST) - port = config.get(CONF_PORT, CONTROL_PORT) - +def register_services(): + """Register snapcast services.""" platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service(SERVICE_SNAPSHOT, {}, "snapshot") platform.async_register_entity_service(SERVICE_RESTORE, {}, "async_restore") platform.async_register_entity_service( @@ -66,23 +59,55 @@ async def async_setup_platform( handle_set_latency, ) - try: - server = await snapcast.control.create_server( - hass.loop, host, port, reconnect=True - ) - except socket.gaierror: - _LOGGER.error("Could not connect to Snapcast server at %s:%d", host, port) - return - # Note: Host part is needed, when using multiple snapservers +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the snapcast config entry.""" + snapcast_data: HomeAssistantSnapcast = hass.data[DOMAIN][config_entry.entry_id] + + register_services() + + host = config_entry.data[CONF_HOST] + port = config_entry.data[CONF_PORT] hpid = f"{host}:{port}" - devices: list[MediaPlayerEntity] = [ - SnapcastGroupDevice(group, hpid) for group in server.groups + snapcast_data.groups = [ + SnapcastGroupDevice(group, hpid) for group in snapcast_data.server.groups ] - devices.extend(SnapcastClientDevice(client, hpid) for client in server.clients) - hass.data[DATA_KEY] = devices - async_add_entities(devices) + snapcast_data.clients = [ + SnapcastClientDevice(client, hpid, config_entry.entry_id) + for client in snapcast_data.server.clients + ] + async_add_entities(snapcast_data.clients + snapcast_data.groups) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the Snapcast platform.""" + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.6.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + + config[CONF_PORT] = config.get(CONF_PORT, CONTROL_PORT) + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) async def handle_async_join(entity, service_call): @@ -211,10 +236,11 @@ class SnapcastClientDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.SELECT_SOURCE ) - def __init__(self, client, uid_part): + def __init__(self, client, uid_part, entry_id): """Initialize the Snapcast client device.""" self._client = client self._uid = f"{CLIENT_PREFIX}{uid_part}_{self._client.identifier}" + self._entry_id = entry_id async def async_added_to_hass(self) -> None: """Subscribe to client events.""" @@ -303,9 +329,10 @@ class SnapcastClientDevice(MediaPlayerEntity): async def async_join(self, master): """Join the group of the master player.""" - master_entity = next( - entity for entity in self.hass.data[DATA_KEY] if entity.entity_id == master + entity + for entity in self.hass.data[DOMAIN][self._entry_id].clients + if entity.entity_id == master ) if not isinstance(master_entity, SnapcastClientDevice): raise TypeError("Master is not a client device. Can only join clients.") diff --git a/homeassistant/components/snapcast/server.py b/homeassistant/components/snapcast/server.py new file mode 100644 index 0000000000..507ad6393a --- /dev/null +++ b/homeassistant/components/snapcast/server.py @@ -0,0 +1,15 @@ +"""Snapcast Integration.""" +from dataclasses import dataclass, field + +from snapcast.control import Snapserver + +from homeassistant.components.media_player import MediaPlayerEntity + + +@dataclass +class HomeAssistantSnapcast: + """Snapcast data stored in the Home Assistant data object.""" + + server: Snapserver + clients: list[MediaPlayerEntity] = field(default_factory=list) + groups: list[MediaPlayerEntity] = field(default_factory=list) diff --git a/homeassistant/components/snapcast/strings.json b/homeassistant/components/snapcast/strings.json new file mode 100644 index 0000000000..0087b70d82 --- /dev/null +++ b/homeassistant/components/snapcast/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "step": { + "user": { + "description": "Please enter your server connection details", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "title": "Connect" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The Snapcast YAML configuration is being removed", + "description": "Configuring Snapcast using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Snapcast YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 240e30ec86..2f84b0b10d 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -399,6 +399,7 @@ FLOWS = { "smarttub", "smhi", "sms", + "snapcast", "snooz", "solaredge", "solarlog", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index bc0e9e1c33..02273b8d97 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5061,7 +5061,7 @@ "snapcast": { "name": "Snapcast", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "snips": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b819133fc7..314cd2efdf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1687,6 +1687,9 @@ smart-meter-texas==0.4.7 # homeassistant.components.smhi smhi-pkg==1.0.16 +# homeassistant.components.snapcast +snapcast==2.3.2 + # homeassistant.components.sonos soco==0.29.1 diff --git a/tests/components/snapcast/__init__.py b/tests/components/snapcast/__init__.py new file mode 100644 index 0000000000..a325bd41bd --- /dev/null +++ b/tests/components/snapcast/__init__.py @@ -0,0 +1 @@ +"""Tests for the Snapcast integration.""" diff --git a/tests/components/snapcast/conftest.py b/tests/components/snapcast/conftest.py new file mode 100644 index 0000000000..00d031192d --- /dev/null +++ b/tests/components/snapcast/conftest.py @@ -0,0 +1,23 @@ +"""Test the snapcast config flow.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.snapcast.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_create_server() -> Generator[AsyncMock, None, None]: + """Create mock snapcast connection.""" + mock_connection = AsyncMock() + mock_connection.start = AsyncMock(return_value=None) + with patch("snapcast.control.create_server", return_value=mock_connection): + yield mock_connection diff --git a/tests/components/snapcast/test_config_flow.py b/tests/components/snapcast/test_config_flow.py new file mode 100644 index 0000000000..b6ff43503a --- /dev/null +++ b/tests/components/snapcast/test_config_flow.py @@ -0,0 +1,110 @@ +"""Test the Snapcast module.""" + +import socket +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant import config_entries, setup +from homeassistant.components.snapcast.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +TEST_CONNECTION = {CONF_HOST: "snapserver.test", CONF_PORT: 1705} + +pytestmark = pytest.mark.usefixtures("mock_setup_entry", "mock_create_server") + + +async def test_form( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_create_server: AsyncMock +) -> None: + """Test we get the form and handle errors and successful connection.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + # test invalid host error + with patch("snapcast.control.create_server", side_effect=socket.gaierror): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONNECTION, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_host"} + + # test connection error + with patch("snapcast.control.create_server", side_effect=ConnectionRefusedError): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONNECTION, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + # test success + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_CONNECTION + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Snapcast" + assert result["data"] == {CONF_HOST: "snapserver.test", CONF_PORT: 1705} + assert len(mock_create_server.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_abort( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_create_server: AsyncMock +) -> None: + """Test config flow abort if device is already configured.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=TEST_CONNECTION, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + with patch("snapcast.control.create_server", side_effect=socket.gaierror): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONNECTION, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_import(hass: HomeAssistant) -> None: + """Test successful import.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=TEST_CONNECTION, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Snapcast" + assert result["data"] == {CONF_HOST: "snapserver.test", CONF_PORT: 1705} From 40cb0eeb68d20fc3367a21efa1b85dda6c1f089a Mon Sep 17 00:00:00 2001 From: Thijs W Date: Thu, 30 Mar 2023 08:05:24 +0200 Subject: [PATCH 1445/2810] Add missing strings in frontier_silicon (#90446) Improve confirm message for ssdp flow --- homeassistant/components/frontier_silicon/config_flow.py | 4 +++- homeassistant/components/frontier_silicon/strings.json | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py index a054bd2b30..0ccc61e99c 100644 --- a/homeassistant/components/frontier_silicon/config_flow.py +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -188,7 +188,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self._async_create_entry() self._set_confirm_only() - return self.async_show_form(step_id="confirm") + return self.async_show_form( + step_id="confirm", description_placeholders={"name": self._name} + ) async def async_step_device_config( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/frontier_silicon/strings.json b/homeassistant/components/frontier_silicon/strings.json index 3a0a504761..a7c3f3e439 100644 --- a/homeassistant/components/frontier_silicon/strings.json +++ b/homeassistant/components/frontier_silicon/strings.json @@ -13,6 +13,9 @@ "data": { "pin": "[%key:common::config_flow::data::pin%]" } + }, + "confirm": { + "description": "Do you want to set up {name}?" } }, "error": { From 053ed3cfdc0550fff487e1602018017c30162a2c Mon Sep 17 00:00:00 2001 From: Thijs W Date: Thu, 30 Mar 2023 08:49:46 +0200 Subject: [PATCH 1446/2810] Add reauth to frontier_silicon config flow (#90443) * Add reauth to frontier_silicon config flow * Update patch target * Apply suggestions from code review Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Add reauth_successful to strings.json * Don't manually set "title_placeholders" Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../frontier_silicon/config_flow.py | 20 +++++ .../components/frontier_silicon/strings.json | 3 +- .../frontier_silicon/test_config_flow.py | 79 +++++++++++++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py index 0ccc61e99c..7067f88297 100644 --- a/homeassistant/components/frontier_silicon/config_flow.py +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Frontier Silicon Media Player integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any from urllib.parse import urlparse @@ -53,6 +54,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _name: str _webfsapi_url: str + _reauth_entry: config_entries.ConfigEntry | None = None # Only used in reauth flows async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult: """Handle the import of legacy configuration.yaml entries.""" @@ -192,6 +194,16 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="confirm", description_placeholders={"name": self._name} ) + async def async_step_reauth(self, config: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self._webfsapi_url = config[CONF_WEBFSAPI_URL] + + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + + return await self.async_step_device_config() + async def async_step_device_config( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -220,6 +232,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception(exception) errors["base"] = "unknown" else: + if self._reauth_entry: + self.hass.config_entries.async_update_entry( + self._reauth_entry, + data={CONF_PIN: user_input[CONF_PIN]}, + ) + await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + unique_id = await afsapi.get_radio_id() await self.async_set_unique_id(unique_id, raise_on_progress=False) self._abort_if_unique_id_configured() diff --git a/homeassistant/components/frontier_silicon/strings.json b/homeassistant/components/frontier_silicon/strings.json index a7c3f3e439..f40abe1675 100644 --- a/homeassistant/components/frontier_silicon/strings.json +++ b/homeassistant/components/frontier_silicon/strings.json @@ -24,7 +24,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "issues": { diff --git a/tests/components/frontier_silicon/test_config_flow.py b/tests/components/frontier_silicon/test_config_flow.py index 612058af0a..524b985b12 100644 --- a/tests/components/frontier_silicon/test_config_flow.py +++ b/tests/components/frontier_silicon/test_config_flow.py @@ -415,3 +415,82 @@ async def test_ssdp_nondefault_pin(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.ABORT assert result["reason"] == "invalid_auth" + + +async def test_reauth_flow(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Test reauth flow.""" + config_entry.add_to_hass(hass) + assert config_entry.data[CONF_PIN] == "1234" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": config_entry.unique_id, + "entry_id": config_entry.entry_id, + }, + data=config_entry.data, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "device_config" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PIN: "4242"}, + ) + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert config_entry.data[CONF_PIN] == "4242" + + +@pytest.mark.parametrize( + ("exception", "reason"), + [ + (ConnectionError, "cannot_connect"), + (InvalidPinException, "invalid_auth"), + (ValueError, "unknown"), + ], +) +async def test_reauth_flow_friendly_name_error( + hass: HomeAssistant, + exception: Exception, + reason: str, + config_entry: MockConfigEntry, +) -> None: + """Test reauth flow with failures.""" + config_entry.add_to_hass(hass) + assert config_entry.data[CONF_PIN] == "1234" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": config_entry.unique_id, + "entry_id": config_entry.entry_id, + }, + data=config_entry.data, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "device_config" + + with patch( + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_friendly_name", + side_effect=exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PIN: "4321"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "device_config" + assert result2["errors"] == {"base": reason} + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PIN: "4242"}, + ) + assert result3["type"] == FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + assert config_entry.data[CONF_PIN] == "4242" From ba32e28fc67fe27a0c94448dfa62deb6a4059d82 Mon Sep 17 00:00:00 2001 From: jellenijhof12 Date: Thu, 30 Mar 2023 08:59:29 +0200 Subject: [PATCH 1447/2810] Add dimmable lights support to niko home control (#90141) * added support for dimmable lights and auto host discover * split up merge request * fixed feedback brightness support * fixed feedback Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * resolved feedback --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/niko_home_control/light.py | 47 +++++++++---------- 1 file changed, 21 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/niko_home_control/light.py b/homeassistant/components/niko_home_control/light.py index 4d12591a47..b541a145a6 100644 --- a/homeassistant/components/niko_home_control/light.py +++ b/homeassistant/components/niko_home_control/light.py @@ -8,8 +8,13 @@ from typing import Any import nikohomecontrol import voluptuous as vol -# Import the device class from the component that you want to support -from homeassistant.components.light import PLATFORM_SCHEMA, ColorMode, LightEntity +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + PLATFORM_SCHEMA, + ColorMode, + LightEntity, + brightness_supported, +) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady @@ -52,36 +57,23 @@ async def async_setup_platform( class NikoHomeControlLight(LightEntity): """Representation of an Niko Light.""" - _attr_color_mode = ColorMode.ONOFF - _attr_supported_color_modes = {ColorMode.ONOFF} - def __init__(self, light, data): """Set up the Niko Home Control light platform.""" self._data = data self._light = light - self._unique_id = f"light-{light.id}" - self._name = light.name - self._state = light.is_on - - @property - def unique_id(self): - """Return unique ID for light.""" - return self._unique_id - - @property - def name(self): - """Return the display name of this light.""" - return self._name - - @property - def is_on(self): - """Return true if light is on.""" - return self._state + self._attr_unique_id = f"light-{light.id}" + self._attr_name = light.name + self._attr_is_on = light.is_on + self._attr_color_mode = ColorMode.ONOFF + self._attr_supported_color_modes = {ColorMode.ONOFF} + if light._state["type"] == 2: + self._attr_color_mode = ColorMode.BRIGHTNESS + self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} def turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" _LOGGER.debug("Turn on: %s", self.name) - self._light.turn_on() + self._light.turn_on(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55) def turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" @@ -91,7 +83,10 @@ class NikoHomeControlLight(LightEntity): async def async_update(self) -> None: """Get the latest data from NikoHomeControl API.""" await self._data.async_update() - self._state = self._data.get_state(self._light.id) + state = self._data.get_state(self._light.id) + self._attr_is_on = state != 0 + if brightness_supported(self.supported_color_modes): + self._attr_brightness = state * 2.55 class NikoHomeControlData: @@ -122,5 +117,5 @@ class NikoHomeControlData: """Find and filter state based on action id.""" for state in self.data: if state["id"] == aid: - return state["value1"] != 0 + return state["value1"] _LOGGER.error("Failed to retrieve state off unknown light") From a7040a0487cb92d92838a707760b62310255cc7b Mon Sep 17 00:00:00 2001 From: Vincent Knoop Pathuis <48653141+vpathuis@users.noreply.github.com> Date: Thu, 30 Mar 2023 09:07:47 +0200 Subject: [PATCH 1448/2810] Add Landis+Gyr MWh-readings from ultraheat-api (#89937) * Use mwh values from ultraheat api when available Remove manifest cleanup from PR Remove added device class from this PR Restore entity registry fixture Replace filter by attr_entity_registry_enabled_default * Catchup with #90182 and #90183 * Add comment explaining disabling some entities * Add parameterisation of test cases --- .../components/landisgyr_heat_meter/sensor.py | 27 +- .../snapshots/test_sensor.ambr | 307 +++++++++++++++++- .../landisgyr_heat_meter/test_sensor.py | 47 ++- 3 files changed, 369 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/landisgyr_heat_meter/sensor.py b/homeassistant/components/landisgyr_heat_meter/sensor.py index 244515a07d..947ab2b2a8 100644 --- a/homeassistant/components/landisgyr_heat_meter/sensor.py +++ b/homeassistant/components/landisgyr_heat_meter/sensor.py @@ -54,6 +54,15 @@ class HeatMeterSensorEntityDescription( HEAT_METER_SENSOR_TYPES = ( + HeatMeterSensorEntityDescription( + key="heat_usage_mwh", + icon="mdi:fire", + name="Heat usage MWh", + native_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + value_fn=lambda res: getattr(res, "heat_usage_mwh", None), + ), HeatMeterSensorEntityDescription( key="volume_usage_m3", icon="mdi:fire", @@ -72,6 +81,15 @@ HEAT_METER_SENSOR_TYPES = ( state_class=SensorStateClass.TOTAL, value_fn=lambda res: getattr(res, "heat_usage_gj", None), ), + HeatMeterSensorEntityDescription( + key="heat_previous_year_mwh", + icon="mdi:fire", + name="Heat previous year MWh", + native_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "heat_previous_year_mwh", None), + ), HeatMeterSensorEntityDescription( key="heat_previous_year_gj", icon="mdi:fire", @@ -277,7 +295,6 @@ async def async_setup_entry( ) sensors = [] - for description in HEAT_METER_SENSOR_TYPES: sensors.append(HeatMeterSensor(coordinator, description, device)) @@ -306,6 +323,14 @@ class HeatMeterSensor( self.entity_description = description self._attr_device_info = device + if ( + description.native_unit_of_measurement + in {UnitOfEnergy.GIGA_JOULE, UnitOfEnergy.MEGA_WATT_HOUR} + and self.native_value is None + ): + # Some meters will return MWh, others will return GJ. + self._attr_entity_registry_enabled_default = False + @property def native_value(self) -> StateType | datetime: """Return the state of the sensor.""" diff --git a/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr b/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr index 9c62ca3f94..d3ab9d5ade 100644 --- a/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr +++ b/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_create_sensors +# name: test_create_sensors[mock_heat_meter_response0] list([ StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -276,7 +276,310 @@ 'entity_id': 'sensor.heat_meter_meter_date_time', 'last_changed': , 'last_updated': , - 'state': '2022-05-20T02:41:17+00:00', + 'state': '2022-05-19T19:41:17+00:00', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Measuring range', + 'icon': 'mdi:water-outline', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_measuring_range', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Settings and firmware', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_settings_and_firmware', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_create_sensors[mock_heat_meter_response1] + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat Meter Heat usage MWh', + 'icon': 'mdi:fire', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_heat_usage_mwh', + 'last_changed': , + 'last_updated': , + 'state': '123.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume', + 'friendly_name': 'Heat Meter Volume usage', + 'icon': 'mdi:fire', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_volume_usage', + 'last_changed': , + 'last_updated': , + 'state': '456.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat Meter Heat previous year MWh', + 'icon': 'mdi:fire', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_heat_previous_year_mwh', + 'last_changed': , + 'last_updated': , + 'state': '111.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume', + 'friendly_name': 'Heat Meter Volume usage previous year', + 'icon': 'mdi:fire', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_volume_usage_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Ownership number', + 'icon': 'mdi:identifier', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_ownership_number', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Error number', + 'icon': 'mdi:home-alert', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_error_number', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Device number', + 'icon': 'mdi:identifier', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_device_number', + 'last_changed': , + 'last_updated': , + 'state': 'devicenr_789', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Measurement period minutes', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_measurement_period_minutes', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Heat Meter Power max', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_power_max', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Heat Meter Power max previous year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_power_max_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Flowrate max', + 'icon': 'mdi:water-outline', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flowrate_max', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Flowrate max previous year', + 'icon': 'mdi:water-outline', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flowrate_max_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Heat Meter Return temperature max', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_return_temperature_max', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Heat Meter Return temperature max previous year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_return_temperature_max_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Heat Meter Flow temperature max', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flow_temperature_max', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Heat Meter Flow temperature max previous year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flow_temperature_max_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Operating hours', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_operating_hours', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Flow hours', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flow_hours', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Fault hours', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_fault_hours', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Fault hours previous year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_fault_hours_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Yearly set day', + 'icon': 'mdi:clock-outline', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_yearly_set_day', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Monthly set day', + 'icon': 'mdi:clock-outline', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_monthly_set_day', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Heat Meter Meter date time', + 'icon': 'mdi:clock-outline', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_meter_date_time', + 'last_changed': , + 'last_updated': , + 'state': '2022-05-19T19:41:17+00:00', }), StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/landisgyr_heat_meter/test_sensor.py b/tests/components/landisgyr_heat_meter/test_sensor.py index 4de58a206e..9f4ad24124 100644 --- a/tests/components/landisgyr_heat_meter/test_sensor.py +++ b/tests/components/landisgyr_heat_meter/test_sensor.py @@ -3,6 +3,7 @@ from dataclasses import dataclass import datetime from unittest.mock import patch +import pytest import serial from syrupy import SnapshotAssertion @@ -25,19 +26,49 @@ API_HEAT_METER_SERVICE = ( class MockHeatMeterResponse: """Mock for HeatMeterResponse.""" - heat_usage_gj: float + heat_usage_gj: float | None + heat_usage_mwh: float | None volume_usage_m3: float - heat_previous_year_gj: float + heat_previous_year_gj: float | None + heat_previous_year_mwh: float | None device_number: str meter_date_time: datetime.datetime +@pytest.mark.parametrize( + "mock_heat_meter_response", + [ + { + "heat_usage_gj": 123.0, + "heat_usage_mwh": None, + "volume_usage_m3": 456.0, + "heat_previous_year_gj": 111.0, + "heat_previous_year_mwh": None, + "device_number": "devicenr_789", + "meter_date_time": dt_util.as_utc( + datetime.datetime(2022, 5, 19, 19, 41, 17) + ), + }, + { + "heat_usage_gj": None, + "heat_usage_mwh": 123.0, + "volume_usage_m3": 456.0, + "heat_previous_year_gj": None, + "heat_previous_year_mwh": 111.0, + "device_number": "devicenr_789", + "meter_date_time": dt_util.as_utc( + datetime.datetime(2022, 5, 19, 19, 41, 17) + ), + }, + ], +) @patch(API_HEAT_METER_SERVICE) async def test_create_sensors( mock_heat_meter, hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + mock_heat_meter_response, ) -> None: """Test sensor.""" entry_data = { @@ -48,13 +79,7 @@ async def test_create_sensors( mock_entry = MockConfigEntry(domain=DOMAIN, unique_id=DOMAIN, data=entry_data) mock_entry.add_to_hass(hass) - mock_heat_meter_response = MockHeatMeterResponse( - heat_usage_gj=123.0, - volume_usage_m3=456.0, - heat_previous_year_gj=111.0, - device_number="devicenr_789", - meter_date_time=dt_util.as_utc(datetime.datetime(2022, 5, 19, 19, 41, 17)), - ) + mock_heat_meter_response = MockHeatMeterResponse(**mock_heat_meter_response) mock_heat_meter().read.return_value = mock_heat_meter_response @@ -79,8 +104,10 @@ async def test_exception_on_polling(mock_heat_meter, hass: HomeAssistant) -> Non # First setup normally mock_heat_meter_response = MockHeatMeterResponse( heat_usage_gj=123.0, + heat_usage_mwh=None, volume_usage_m3=456.0, heat_previous_year_gj=111.0, + heat_previous_year_mwh=None, device_number="devicenr_789", meter_date_time=dt_util.as_utc(datetime.datetime(2022, 5, 19, 19, 41, 17)), ) @@ -106,8 +133,10 @@ async def test_exception_on_polling(mock_heat_meter, hass: HomeAssistant) -> Non # Now 'enable' and see if next poll succeeds mock_heat_meter_response = MockHeatMeterResponse( heat_usage_gj=124.0, + heat_usage_mwh=None, volume_usage_m3=457.0, heat_previous_year_gj=112.0, + heat_previous_year_mwh=None, device_number="devicenr_789", meter_date_time=dt_util.as_utc(datetime.datetime(2022, 5, 19, 20, 41, 17)), ) From 35995153259e8a01a8a3b8594b1cb8fdb42d230a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 30 Mar 2023 10:21:11 +0200 Subject: [PATCH 1449/2810] Add missing strings to sensor integration (#90475) * Add missing strings to sensor integration * Enumeration * Apply suggestion Co-authored-by: Franck Nijhof --------- Co-authored-by: Franck Nijhof --- homeassistant/components/sensor/strings.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 5b34c5a28e..16e0da0d51 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -160,6 +160,9 @@ "energy_storage": { "name": "Stored energy" }, + "enum": { + "name": "[%key:component::sensor::title%]" + }, "frequency": { "name": "Frequency" }, @@ -235,6 +238,9 @@ "temperature": { "name": "Temperature" }, + "timestamp": { + "name": "Timestamp" + }, "volatile_organic_compounds": { "name": "VOCs" }, From 0e7d7f32c155f16090e27e65c86da2ffe77fc50b Mon Sep 17 00:00:00 2001 From: Nalin Mahajan Date: Thu, 30 Mar 2023 03:33:01 -0500 Subject: [PATCH 1450/2810] Add new control4 helper function (#90234) * Add new helper function to retrieve device variables and update light platform * seperate try catch from helper function and fix typing * Change helper function name * Remove unnecessary forced type changes * More type changes --- .../components/control4/director_utils.py | 32 ++++++++++++------- homeassistant/components/control4/light.py | 16 +++++++--- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/control4/director_utils.py b/homeassistant/components/control4/director_utils.py index bab8c8634c..3d360e3643 100644 --- a/homeassistant/components/control4/director_utils.py +++ b/homeassistant/components/control4/director_utils.py @@ -1,5 +1,8 @@ """Provides data updates from the Control4 controller for platforms.""" +from collections import defaultdict +from collections.abc import Set import logging +from typing import Any from pyControl4.account import C4Account from pyControl4.director import C4Director @@ -15,21 +18,28 @@ from .const import CONF_ACCOUNT, CONF_CONTROLLER_UNIQUE_ID, CONF_DIRECTOR, DOMAI _LOGGER = logging.getLogger(__name__) -async def director_update_data( - hass: HomeAssistant, entry: ConfigEntry, var: str -) -> dict: - """Retrieve data from the Control4 director for update_coordinator.""" - # possibly implement usage of director_token_expiration to start - # token refresh without waiting for error to occur +async def _update_variables_for_config_entry( + hass: HomeAssistant, entry: ConfigEntry, variable_names: Set[str] +) -> dict[int, dict[str, Any]]: + """Retrieve data from the Control4 director.""" + director: C4Director = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR] + data = await director.getAllItemVariableValue(variable_names) + result_dict: defaultdict[int, dict[str, Any]] = defaultdict(dict) + for item in data: + result_dict[item["id"]][item["varName"]] = item["value"] + return dict(result_dict) + + +async def update_variables_for_config_entry( + hass: HomeAssistant, entry: ConfigEntry, variable_names: Set[str] +) -> dict[int, dict[str, Any]]: + """Try to Retrieve data from the Control4 director for update_coordinator.""" try: - director = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR] - data = await director.getAllItemVariableValue(var) + return await _update_variables_for_config_entry(hass, entry, variable_names) except BadToken: _LOGGER.info("Updating Control4 director token") await refresh_tokens(hass, entry) - director = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR] - data = await director.getAllItemVariableValue(var) - return {key["id"]: key for key in data} + return await _update_variables_for_config_entry(hass, entry, variable_names) async def refresh_tokens(hass: HomeAssistant, entry: ConfigEntry): diff --git a/homeassistant/components/control4/light.py b/homeassistant/components/control4/light.py index 5748664119..fde9b00aba 100644 --- a/homeassistant/components/control4/light.py +++ b/homeassistant/components/control4/light.py @@ -24,7 +24,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from . import Control4Entity, get_items_of_category from .const import CONF_DIRECTOR, CONTROL4_ENTITY_TYPE, DOMAIN -from .director_utils import director_update_data +from .director_utils import update_variables_for_config_entry _LOGGER = logging.getLogger(__name__) @@ -47,14 +47,18 @@ async def async_setup_entry( async def async_update_data_non_dimmer(): """Fetch data from Control4 director for non-dimmer lights.""" try: - return await director_update_data(hass, entry, CONTROL4_NON_DIMMER_VAR) + return await update_variables_for_config_entry( + hass, entry, {CONTROL4_NON_DIMMER_VAR} + ) except C4Exception as err: raise UpdateFailed(f"Error communicating with API: {err}") from err async def async_update_data_dimmer(): """Fetch data from Control4 director for dimmer lights.""" try: - return await director_update_data(hass, entry, CONTROL4_DIMMER_VAR) + return await update_variables_for_config_entry( + hass, entry, {CONTROL4_DIMMER_VAR} + ) except C4Exception as err: raise UpdateFailed(f"Error communicating with API: {err}") from err @@ -185,13 +189,15 @@ class Control4Light(Control4Entity, LightEntity): @property def is_on(self): """Return whether this light is on or off.""" - return self.coordinator.data[self._idx]["value"] > 0 + if self._is_dimmer: + return self.coordinator.data[self._idx][CONTROL4_DIMMER_VAR] > 0 + return self.coordinator.data[self._idx][CONTROL4_NON_DIMMER_VAR] > 0 @property def brightness(self): """Return the brightness of this light between 0..255.""" if self._is_dimmer: - return round(self.coordinator.data[self._idx]["value"] * 2.55) + return round(self.coordinator.data[self._idx][CONTROL4_DIMMER_VAR] * 2.55) return None @property From 196f5702b8fe23a14e23d97d89ff989b5660759e Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Thu, 30 Mar 2023 12:25:14 +0300 Subject: [PATCH 1451/2810] Make hassfest.dependencies faster with multiprocessing (#81486) * hassfest.dependencies: split to two loops * hassfest.dependencies: use multiprocessing for import scan --- script/hassfest/dependencies.py | 86 ++++++++++++++++++++++----------- 1 file changed, 59 insertions(+), 27 deletions(-) diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index 8d2f179aef..28c73d890a 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -3,6 +3,7 @@ from __future__ import annotations import ast from collections import deque +import multiprocessing from pathlib import Path from homeassistant.const import Platform @@ -227,35 +228,49 @@ def find_non_referenced_integrations( return referenced -def validate_dependencies( - integrations: dict[str, Integration], +def _compute_integration_dependencies( integration: Integration, - check_dependencies: bool, -) -> None: - """Validate all dependencies.""" +) -> tuple[str, dict[Path, set[str]] | None]: + """Compute integration dependencies.""" # Some integrations are allowed to have violations. if integration.domain in IGNORE_VIOLATIONS: - return + return (integration.domain, None) # Find usage of hass.components collector = ImportCollector(integration) collector.collect() + return (integration.domain, collector.referenced) - for domain in sorted( - find_non_referenced_integrations( - integrations, integration, collector.referenced - ) - ): - integration.add_error( - "dependencies", - f"Using component {domain} but it's not in 'dependencies' " - "or 'after_dependencies'", + +def _validate_dependency_imports( + integrations: dict[str, Integration], +) -> None: + """Validate all dependencies.""" + + # Find integration dependencies with multiprocessing + # (because it takes some time to parse thousands of files) + with multiprocessing.Pool() as pool: + integration_imports = dict( + pool.imap_unordered( + _compute_integration_dependencies, + integrations.values(), + chunksize=10, + ) ) - if check_dependencies: - _check_circular_deps( - integrations, integration.domain, integration, set(), deque() - ) + for integration in integrations.values(): + referenced = integration_imports[integration.domain] + if not referenced: # Either ignored or has no references + continue + + for domain in sorted( + find_non_referenced_integrations(integrations, integration, referenced) + ): + integration.add_error( + "dependencies", + f"Using component {domain} but it's not in 'dependencies' " + "or 'after_dependencies'", + ) def _check_circular_deps( @@ -266,6 +281,7 @@ def _check_circular_deps( checking: deque[str], ) -> None: """Check for circular dependencies pointing at starting_domain.""" + if integration.domain in checked or integration.domain in checking: return @@ -297,17 +313,21 @@ def _check_circular_deps( checking.remove(integration.domain) -def validate(integrations: dict[str, Integration], config: Config) -> None: - """Handle dependencies for integrations.""" - # check for non-existing dependencies +def _validate_circular_dependencies(integrations: dict[str, Integration]) -> None: for integration in integrations.values(): - validate_dependencies( - integrations, - integration, - check_dependencies=not config.specific_integrations, + if integration.domain in IGNORE_VIOLATIONS: + continue + + _check_circular_deps( + integrations, integration.domain, integration, set(), deque() ) - if config.specific_integrations: + +def _validate_dependencies_exist( + integrations: dict[str, Integration], +) -> None: + for integration in integrations.values(): + if not integration.manifest: continue # check that all referenced dependencies exist @@ -323,3 +343,15 @@ def validate(integrations: dict[str, Integration], config: Config) -> None: integration.add_error( "dependencies", f"Dependency {dep} does not exist" ) + + +def validate( + integrations: dict[str, Integration], + config: Config, +) -> None: + """Handle dependencies for integrations.""" + _validate_dependency_imports(integrations) + + if not config.specific_integrations: + _validate_dependencies_exist(integrations) + _validate_circular_dependencies(integrations) From b316ffff9bc6efa93ff536c7bd6b631157d4a4cb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Mar 2023 12:05:11 +0200 Subject: [PATCH 1452/2810] Rename hassfest _validate_dependencies_exist (#90503) --- script/hassfest/dependencies.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index 28c73d890a..c0733841ed 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -323,14 +323,14 @@ def _validate_circular_dependencies(integrations: dict[str, Integration]) -> Non ) -def _validate_dependencies_exist( +def _validate_dependencies( integrations: dict[str, Integration], ) -> None: + """Check that all referenced dependencies exist and are not duplicated.""" for integration in integrations.values(): if not integration.manifest: continue - # check that all referenced dependencies exist after_deps = integration.manifest.get("after_dependencies", []) for dep in integration.manifest.get("dependencies", []): if dep in after_deps: @@ -353,5 +353,5 @@ def validate( _validate_dependency_imports(integrations) if not config.specific_integrations: - _validate_dependencies_exist(integrations) + _validate_dependencies(integrations) _validate_circular_dependencies(integrations) From ead88cc3f8e27169129af86cb6d1400156e68da8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Mar 2023 12:54:12 +0200 Subject: [PATCH 1453/2810] Add preferred wind speed unit to unit systems (#90504) * Add preferred wind speed unit to unit systems * Tweak * Update tests --- homeassistant/util/unit_system.py | 12 ++++++++++ tests/util/test_unit_system.py | 38 +++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py index 2a7af57776..c9da324e8a 100644 --- a/homeassistant/util/unit_system.py +++ b/homeassistant/util/unit_system.py @@ -277,6 +277,12 @@ METRIC_SYSTEM = UnitSystem( ("water", UnitOfVolume.CENTUM_CUBIC_FEET): UnitOfVolume.CUBIC_METERS, ("water", UnitOfVolume.CUBIC_FEET): UnitOfVolume.CUBIC_METERS, ("water", UnitOfVolume.GALLONS): UnitOfVolume.LITERS, + # Convert wind speeds except knots to km/h + **{ + ("wind_speed", unit): UnitOfSpeed.KILOMETERS_PER_HOUR + for unit in UnitOfSpeed + if unit not in (UnitOfSpeed.KILOMETERS_PER_HOUR, UnitOfSpeed.KNOTS) + }, }, length=UnitOfLength.KILOMETERS, mass=UnitOfMass.GRAMS, @@ -341,6 +347,12 @@ US_CUSTOMARY_SYSTEM = UnitSystem( # Convert non-USCS volumes of water meters ("water", UnitOfVolume.CUBIC_METERS): UnitOfVolume.CUBIC_FEET, ("water", UnitOfVolume.LITERS): UnitOfVolume.GALLONS, + # Convert wind speeds except knots to mph + **{ + ("wind_speed", unit): UnitOfSpeed.MILES_PER_HOUR + for unit in UnitOfSpeed + if unit not in (UnitOfSpeed.KNOTS, UnitOfSpeed.MILES_PER_HOUR) + }, }, length=UnitOfLength.MILES, mass=UnitOfMass.POUNDS, diff --git a/tests/util/test_unit_system.py b/tests/util/test_unit_system.py index 01aa1256fd..44b287bd05 100644 --- a/tests/util/test_unit_system.py +++ b/tests/util/test_unit_system.py @@ -457,6 +457,25 @@ def test_get_unit_system_invalid(key: str) -> None: (SensorDeviceClass.WATER, UnitOfVolume.CUBIC_METERS, None), (SensorDeviceClass.WATER, UnitOfVolume.LITERS, None), (SensorDeviceClass.WATER, "very_much", None), + # Test wind speed conversion + ( + SensorDeviceClass.WIND_SPEED, + UnitOfSpeed.FEET_PER_SECOND, + UnitOfSpeed.KILOMETERS_PER_HOUR, + ), + ( + SensorDeviceClass.WIND_SPEED, + UnitOfSpeed.MILES_PER_HOUR, + UnitOfSpeed.KILOMETERS_PER_HOUR, + ), + (SensorDeviceClass.WIND_SPEED, UnitOfSpeed.KILOMETERS_PER_HOUR, None), + (SensorDeviceClass.WIND_SPEED, UnitOfSpeed.KNOTS, None), + ( + SensorDeviceClass.WIND_SPEED, + UnitOfSpeed.METERS_PER_SECOND, + UnitOfSpeed.KILOMETERS_PER_HOUR, + ), + (SensorDeviceClass.WIND_SPEED, "very_fast", None), ), ) def test_get_metric_converted_unit_( @@ -657,6 +676,25 @@ def test_metric_converted_units(device_class: SensorDeviceClass) -> None: (SensorDeviceClass.WATER, UnitOfVolume.CUBIC_FEET, None), (SensorDeviceClass.WATER, UnitOfVolume.GALLONS, None), (SensorDeviceClass.WATER, "very_much", None), + # Test wind speed conversion + ( + SensorDeviceClass.WIND_SPEED, + UnitOfSpeed.METERS_PER_SECOND, + UnitOfSpeed.MILES_PER_HOUR, + ), + ( + SensorDeviceClass.WIND_SPEED, + UnitOfSpeed.KILOMETERS_PER_HOUR, + UnitOfSpeed.MILES_PER_HOUR, + ), + ( + SensorDeviceClass.WIND_SPEED, + UnitOfSpeed.FEET_PER_SECOND, + UnitOfSpeed.MILES_PER_HOUR, + ), + (SensorDeviceClass.WIND_SPEED, UnitOfSpeed.KNOTS, None), + (SensorDeviceClass.WIND_SPEED, UnitOfSpeed.MILES_PER_HOUR, None), + (SensorDeviceClass.WIND_SPEED, "very_fast", None), ), ) def test_get_us_converted_unit( From 8d21e2b168c995346c8c6af7fe077ca0e97e6ab3 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 30 Mar 2023 13:11:33 +0200 Subject: [PATCH 1454/2810] Use metric units internally in Accuweather integration (#90444) * Use metric units internally * Remove unnecessary code * Simplify sensor classes * Remove AccuWeatherForecastSensor class * Update wind speed value in test * Return suggested_unit_of_measurement for wind entities * Clean test * Use _attr_suggested_unit_of_measurement * Remove _get_suggested_unit() * Remove unnecessarey code --- .../components/accuweather/__init__.py | 7 +- homeassistant/components/accuweather/const.py | 1 - .../components/accuweather/sensor.py | 162 +++++++----------- .../components/accuweather/weather.py | 45 ++--- tests/components/accuweather/test_sensor.py | 16 +- 5 files changed, 82 insertions(+), 149 deletions(-) diff --git a/homeassistant/components/accuweather/__init__.py b/homeassistant/components/accuweather/__init__.py index 89af284f87..4a015728d6 100644 --- a/homeassistant/components/accuweather/__init__.py +++ b/homeassistant/components/accuweather/__init__.py @@ -17,7 +17,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ATTR_FORECAST, CONF_FORECAST, DOMAIN, MANUFACTURER @@ -116,11 +115,7 @@ class AccuWeatherDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): async with timeout(10): current = await self.accuweather.async_get_current_conditions() forecast = ( - await self.accuweather.async_get_forecast( - metric=self.hass.config.units is METRIC_SYSTEM - ) - if self.forecast - else {} + await self.accuweather.async_get_forecast() if self.forecast else {} ) except ( ApiError, diff --git a/homeassistant/components/accuweather/const.py b/homeassistant/components/accuweather/const.py index 1336e31f41..87bc8eaef8 100644 --- a/homeassistant/components/accuweather/const.py +++ b/homeassistant/components/accuweather/const.py @@ -20,7 +20,6 @@ from homeassistant.components.weather import ( ATTR_CONDITION_WINDY, ) -API_IMPERIAL: Final = "Imperial" API_METRIC: Final = "Metric" ATTRIBUTION: Final = "Data provided by AccuWeather" ATTR_CATEGORY: Final = "Category" diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py index 6cb0b45418..4d58919947 100644 --- a/homeassistant/components/accuweather/sensor.py +++ b/homeassistant/components/accuweather/sensor.py @@ -26,11 +26,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util.unit_system import METRIC_SYSTEM from . import AccuWeatherDataUpdateCoordinator from .const import ( - API_IMPERIAL, API_METRIC, ATTR_CATEGORY, ATTR_DIRECTION, @@ -51,7 +49,7 @@ PARALLEL_UPDATES = 1 class AccuWeatherSensorDescriptionMixin: """Mixin for AccuWeather sensor.""" - value_fn: Callable[[dict[str, Any], str], StateType] + value_fn: Callable[[dict[str, Any]], StateType] @dataclass @@ -61,8 +59,6 @@ class AccuWeatherSensorDescription( """Class describing AccuWeather sensor entities.""" attr_fn: Callable[[dict[str, Any]], dict[str, StateType]] = lambda _: {} - metric_unit: str | None = None - us_customary_unit: str | None = None FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( @@ -72,7 +68,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Cloud cover day", entity_registry_enabled_default=False, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), ), AccuWeatherSensorDescription( key="CloudCoverNight", @@ -80,7 +76,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Cloud cover night", entity_registry_enabled_default=False, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), ), AccuWeatherSensorDescription( key="Grass", @@ -88,7 +84,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Grass pollen", entity_registry_enabled_default=False, native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, ), AccuWeatherSensorDescription( @@ -96,7 +92,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( icon="mdi:weather-partly-cloudy", name="Hours of sun", native_unit_of_measurement=UnitOfTime.HOURS, - value_fn=lambda data, _: cast(float, data), + value_fn=lambda data: cast(float, data), ), AccuWeatherSensorDescription( key="Mold", @@ -104,7 +100,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Mold pollen", entity_registry_enabled_default=False, native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, ), AccuWeatherSensorDescription( @@ -112,7 +108,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( icon="mdi:vector-triangle", name="Ozone", entity_registry_enabled_default=False, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, ), AccuWeatherSensorDescription( @@ -121,56 +117,52 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Ragweed pollen", native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, ), AccuWeatherSensorDescription( key="RealFeelTemperatureMax", device_class=SensorDeviceClass.TEMPERATURE, name="RealFeel temperature max", - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, _: cast(float, data[ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), ), AccuWeatherSensorDescription( key="RealFeelTemperatureMin", device_class=SensorDeviceClass.TEMPERATURE, name="RealFeel temperature min", - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, _: cast(float, data[ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), ), AccuWeatherSensorDescription( key="RealFeelTemperatureShadeMax", device_class=SensorDeviceClass.TEMPERATURE, name="RealFeel temperature shade max", entity_registry_enabled_default=False, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, _: cast(float, data[ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), ), AccuWeatherSensorDescription( key="RealFeelTemperatureShadeMin", device_class=SensorDeviceClass.TEMPERATURE, name="RealFeel temperature shade min", entity_registry_enabled_default=False, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, _: cast(float, data[ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), ), AccuWeatherSensorDescription( key="ThunderstormProbabilityDay", icon="mdi:weather-lightning", name="Thunderstorm probability day", native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), ), AccuWeatherSensorDescription( key="ThunderstormProbabilityNight", icon="mdi:weather-lightning", name="Thunderstorm probability night", native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), ), AccuWeatherSensorDescription( key="Tree", @@ -178,7 +170,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Tree pollen", native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, ), AccuWeatherSensorDescription( @@ -186,7 +178,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( icon="mdi:weather-sunny", name="UV index", native_unit_of_measurement=UV_INDEX, - value_fn=lambda data, _: cast(int, data[ATTR_VALUE]), + value_fn=lambda data: cast(int, data[ATTR_VALUE]), attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, ), AccuWeatherSensorDescription( @@ -194,9 +186,8 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( device_class=SensorDeviceClass.WIND_SPEED, name="Wind gust day", entity_registry_enabled_default=False, - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, _: cast(float, data[ATTR_SPEED][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, ), AccuWeatherSensorDescription( @@ -204,27 +195,24 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( device_class=SensorDeviceClass.WIND_SPEED, name="Wind gust night", entity_registry_enabled_default=False, - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, _: cast(float, data[ATTR_SPEED][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, ), AccuWeatherSensorDescription( key="WindDay", device_class=SensorDeviceClass.WIND_SPEED, name="Wind day", - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, _: cast(float, data[ATTR_SPEED][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, ), AccuWeatherSensorDescription( key="WindNight", device_class=SensorDeviceClass.WIND_SPEED, name="Wind night", - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, _: cast(float, data[ATTR_SPEED][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, ), ) @@ -236,9 +224,8 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Apparent temperature", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="Ceiling", @@ -246,9 +233,8 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( icon="mdi:weather-fog", name="Cloud ceiling", state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfLength.METERS, - us_customary_unit=UnitOfLength.FEET, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfLength.METERS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), suggested_display_precision=0, ), AccuWeatherSensorDescription( @@ -258,7 +244,7 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), ), AccuWeatherSensorDescription( key="DewPoint", @@ -266,18 +252,16 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Dew point", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="RealFeelTemperature", device_class=SensorDeviceClass.TEMPERATURE, name="RealFeel temperature", state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="RealFeelTemperatureShade", @@ -285,18 +269,16 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="RealFeel temperature shade", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="Precipitation", device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, name="Precipitation", state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, - us_customary_unit=UnitOfVolumetricFlux.INCHES_PER_HOUR, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), attr_fn=lambda data: {"type": data["PrecipitationType"]}, ), AccuWeatherSensorDescription( @@ -306,7 +288,7 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Pressure tendency", options=["falling", "rising", "steady"], translation_key="pressure_tendency", - value_fn=lambda data, _: cast(str, data["LocalizedText"]).lower(), + value_fn=lambda data: cast(str, data["LocalizedText"]).lower(), ), AccuWeatherSensorDescription( key="UVIndex", @@ -314,7 +296,7 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="UV index", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UV_INDEX, - value_fn=lambda data, _: cast(int, data), + value_fn=lambda data: cast(int, data), attr_fn=lambda data: {ATTR_LEVEL: data["UVIndexText"]}, ), AccuWeatherSensorDescription( @@ -323,9 +305,8 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Wet bulb temperature", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="WindChillTemperature", @@ -333,18 +314,16 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Wind chill temperature", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfTemperature.CELSIUS, - us_customary_unit=UnitOfTemperature.FAHRENHEIT, - value_fn=lambda data, unit: cast(float, data[unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="Wind", device_class=SensorDeviceClass.WIND_SPEED, name="Wind", state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, unit: cast(float, data[ATTR_SPEED][unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][API_METRIC][ATTR_VALUE]), ), AccuWeatherSensorDescription( key="WindGust", @@ -352,9 +331,8 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( name="Wind gust", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, - metric_unit=UnitOfSpeed.KILOMETERS_PER_HOUR, - us_customary_unit=UnitOfSpeed.MILES_PER_HOUR, - value_fn=lambda data, unit: cast(float, data[ATTR_SPEED][unit][ATTR_VALUE]), + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][API_METRIC][ATTR_VALUE]), ), ) @@ -374,7 +352,7 @@ async def async_setup_entry( # Some air quality/allergy sensors are only available for certain # locations. sensors.extend( - AccuWeatherForecastSensor(coordinator, description, forecast_day=day) + AccuWeatherSensor(coordinator, description, forecast_day=day) for day in range(MAX_FORECAST_DAYS + 1) for description in FORECAST_SENSOR_TYPES if description.key in coordinator.data[ATTR_FORECAST][0] @@ -413,34 +391,27 @@ class AccuWeatherSensor( self._attr_unique_id = ( f"{coordinator.location_key}-{description.key}".lower() ) - self._attr_native_unit_of_measurement = description.native_unit_of_measurement - if self.coordinator.hass.config.units is METRIC_SYSTEM: - self._unit_system = API_METRIC - if metric_unit := description.metric_unit: - self._attr_native_unit_of_measurement = metric_unit - else: - self._unit_system = API_IMPERIAL - if us_customary_unit := description.us_customary_unit: - self._attr_native_unit_of_measurement = us_customary_unit self._attr_device_info = coordinator.device_info - if forecast_day is not None: - self.forecast_day = forecast_day + self.forecast_day = forecast_day @property def native_value(self) -> StateType: """Return the state.""" - return self.entity_description.value_fn(self._sensor_data, self._unit_system) + return self.entity_description.value_fn(self._sensor_data) @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" + if self.forecast_day is not None: + return self.entity_description.attr_fn(self._sensor_data) + return self.entity_description.attr_fn(self.coordinator.data) @callback def _handle_coordinator_update(self) -> None: """Handle data update.""" self._sensor_data = _get_sensor_data( - self.coordinator.data, self.entity_description.key + self.coordinator.data, self.entity_description.key, self.forecast_day ) self.async_write_ha_state() @@ -458,20 +429,3 @@ def _get_sensor_data( return sensors["PrecipitationSummary"]["PastHour"] return sensors[kind] - - -class AccuWeatherForecastSensor(AccuWeatherSensor): - """Define an AccuWeather forecast entity.""" - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the state attributes.""" - return self.entity_description.attr_fn(self._sensor_data) - - @callback - def _handle_coordinator_update(self) -> None: - """Handle data update.""" - self._sensor_data = _get_sensor_data( - self.coordinator.data, self.entity_description.key, self.forecast_day - ) - self.async_write_ha_state() diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py index 5c5ba303ad..76a5d62a10 100644 --- a/homeassistant/components/accuweather/weather.py +++ b/homeassistant/components/accuweather/weather.py @@ -28,17 +28,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utc_from_timestamp -from homeassistant.util.unit_system import METRIC_SYSTEM from . import AccuWeatherDataUpdateCoordinator -from .const import ( - API_IMPERIAL, - API_METRIC, - ATTR_FORECAST, - ATTRIBUTION, - CONDITION_CLASSES, - DOMAIN, -) +from .const import API_METRIC, ATTR_FORECAST, ATTRIBUTION, CONDITION_CLASSES, DOMAIN PARALLEL_UPDATES = 1 @@ -66,20 +58,11 @@ class AccuWeatherEntity( # Coordinator data is used also for sensors which don't have units automatically # converted, hence the weather entity's native units follow the configured unit # system - if coordinator.hass.config.units is METRIC_SYSTEM: - self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS - self._attr_native_pressure_unit = UnitOfPressure.HPA - self._attr_native_temperature_unit = UnitOfTemperature.CELSIUS - self._attr_native_visibility_unit = UnitOfLength.KILOMETERS - self._attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR - self._unit_system = API_METRIC - else: - self._unit_system = API_IMPERIAL - self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.INCHES - self._attr_native_pressure_unit = UnitOfPressure.INHG - self._attr_native_temperature_unit = UnitOfTemperature.FAHRENHEIT - self._attr_native_visibility_unit = UnitOfLength.MILES - self._attr_native_wind_speed_unit = UnitOfSpeed.MILES_PER_HOUR + self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS + self._attr_native_pressure_unit = UnitOfPressure.HPA + self._attr_native_temperature_unit = UnitOfTemperature.CELSIUS + self._attr_native_visibility_unit = UnitOfLength.KILOMETERS + self._attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR self._attr_unique_id = coordinator.location_key self._attr_attribution = ATTRIBUTION self._attr_device_info = coordinator.device_info @@ -99,16 +82,12 @@ class AccuWeatherEntity( @property def native_temperature(self) -> float: """Return the temperature.""" - return cast( - float, self.coordinator.data["Temperature"][self._unit_system]["Value"] - ) + return cast(float, self.coordinator.data["Temperature"][API_METRIC]["Value"]) @property def native_pressure(self) -> float: """Return the pressure.""" - return cast( - float, self.coordinator.data["Pressure"][self._unit_system]["Value"] - ) + return cast(float, self.coordinator.data["Pressure"][API_METRIC]["Value"]) @property def humidity(self) -> int: @@ -118,9 +97,7 @@ class AccuWeatherEntity( @property def native_wind_speed(self) -> float: """Return the wind speed.""" - return cast( - float, self.coordinator.data["Wind"]["Speed"][self._unit_system]["Value"] - ) + return cast(float, self.coordinator.data["Wind"]["Speed"][API_METRIC]["Value"]) @property def wind_bearing(self) -> int: @@ -130,9 +107,7 @@ class AccuWeatherEntity( @property def native_visibility(self) -> float: """Return the visibility.""" - return cast( - float, self.coordinator.data["Visibility"][self._unit_system]["Value"] - ) + return cast(float, self.coordinator.data["Visibility"][API_METRIC]["Value"]) @property def ozone(self) -> int | None: diff --git a/tests/components/accuweather/test_sensor.py b/tests/components/accuweather/test_sensor.py index e4f564f133..29f698ca52 100644 --- a/tests/components/accuweather/test_sensor.py +++ b/tests/components/accuweather/test_sensor.py @@ -741,11 +741,21 @@ async def test_sensor_imperial_units(hass: HomeAssistant) -> None: state = hass.states.get("sensor.home_cloud_ceiling") assert state - assert state.state == "10500.0" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_ICON) == "mdi:weather-fog" + assert state.state == "10498.687664042" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfLength.FEET + state = hass.states.get("sensor.home_wind") + assert state + assert state.state == "9.0" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfSpeed.MILES_PER_HOUR + + state = hass.states.get("sensor.home_realfeel_temperature") + assert state + assert state.state == "77.2" + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.FAHRENHEIT + ) + async def test_state_update(hass: HomeAssistant) -> None: """Ensure the sensor state changes after updating the data.""" From 642984a04272ce83064e5b3fc31f200cf98872de Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Thu, 30 Mar 2023 09:14:58 -0400 Subject: [PATCH 1455/2810] Fix for is_hidden_entity when using it in select, selectattr, reject, and rejectattr (#90512) fix --- homeassistant/helpers/template.py | 15 +++++++++++---- tests/helpers/test_template.py | 5 +++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 481a59cee8..36e0a597b8 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -2285,9 +2285,6 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["area_devices"] = hassfunction(area_devices) self.filters["area_devices"] = pass_context(self.globals["area_devices"]) - self.globals["is_hidden_entity"] = hassfunction(is_hidden_entity) - self.tests["is_hidden_entity"] = pass_context(self.globals["is_hidden_entity"]) - self.globals["integration_entities"] = hassfunction(integration_entities) self.filters["integration_entities"] = pass_context( self.globals["integration_entities"] @@ -2308,6 +2305,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): "closest", "distance", "expand", + "is_hidden_entity", "is_state", "is_state_attr", "state_attr", @@ -2331,7 +2329,12 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): "area_name", "has_value", ] - hass_tests = ["has_value"] + hass_tests = [ + "has_value", + "is_hidden_entity", + "is_state", + "is_state_attr", + ] for glob in hass_globals: self.globals[glob] = unsupported(glob) for filt in hass_filters: @@ -2345,6 +2348,10 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["closest"] = hassfunction(closest) self.filters["closest"] = pass_context(hassfunction(closest_filter)) self.globals["distance"] = hassfunction(distance) + self.globals["is_hidden_entity"] = hassfunction(is_hidden_entity) + self.tests["is_hidden_entity"] = pass_eval_context( + self.globals["is_hidden_entity"] + ) self.globals["is_state"] = hassfunction(is_state) self.tests["is_state"] = pass_eval_context(self.globals["is_state"]) self.globals["is_state_attr"] = hassfunction(is_state_attr) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index b381775f1e..f185191d1b 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1463,6 +1463,11 @@ def test_is_hidden_entity( hass, ).async_render() + assert not template.Template( + f"{{{{ ['{visible_entity.entity_id}'] | select('is_hidden_entity') | first }}}}", + hass, + ).async_render() + def test_is_state(hass: HomeAssistant) -> None: """Test is_state method.""" From 976efb437b68afd32a275fde0630c956f029f9d6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Mar 2023 15:16:27 +0200 Subject: [PATCH 1456/2810] Include channel in response to WS thread/list_datasets (#90493) --- .../components/thread/dataset_store.py | 10 +++++++ .../components/thread/websocket_api.py | 1 + tests/components/thread/test_dataset_store.py | 29 +++++++++++++++++++ tests/components/thread/test_websocket_api.py | 3 ++ 4 files changed, 43 insertions(+) diff --git a/homeassistant/components/thread/dataset_store.py b/homeassistant/components/thread/dataset_store.py index ea5a16f90c..786ea55b34 100644 --- a/homeassistant/components/thread/dataset_store.py +++ b/homeassistant/components/thread/dataset_store.py @@ -1,6 +1,7 @@ """Persistently store thread datasets.""" from __future__ import annotations +from contextlib import suppress import dataclasses from datetime import datetime from functools import cached_property @@ -35,6 +36,15 @@ class DatasetEntry: created: datetime = dataclasses.field(default_factory=dt_util.utcnow) id: str = dataclasses.field(default_factory=ulid_util.ulid) + @property + def channel(self) -> int | None: + """Return channel as an integer.""" + if (channel := self.dataset.get(tlv_parser.MeshcopTLVType.CHANNEL)) is None: + return None + with suppress(ValueError): + return int(channel, 16) + return None + @cached_property def dataset(self) -> dict[tlv_parser.MeshcopTLVType, str]: """Return the dataset in dict format.""" diff --git a/homeassistant/components/thread/websocket_api.py b/homeassistant/components/thread/websocket_api.py index 9f9bc3455a..aca0d5e5d9 100644 --- a/homeassistant/components/thread/websocket_api.py +++ b/homeassistant/components/thread/websocket_api.py @@ -144,6 +144,7 @@ async def ws_list_datasets( for dataset in store.datasets.values(): result.append( { + "channel": dataset.channel, "created": dataset.created, "dataset_id": dataset.id, "extended_pan_id": dataset.extended_pan_id, diff --git a/tests/components/thread/test_dataset_store.py b/tests/components/thread/test_dataset_store.py index 581329e860..212db0de06 100644 --- a/tests/components/thread/test_dataset_store.py +++ b/tests/components/thread/test_dataset_store.py @@ -19,6 +19,18 @@ DATASET_1_REORDERED = ( "10445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F801021234" ) +DATASET_1_BAD_CHANNEL = ( + "0E080000000000010000000035060004001FFFE0020811111111222222220708FDAD70BF" + "E5AA15DD051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444656D6F01" + "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" +) + +DATASET_1_NO_CHANNEL = ( + "0E08000000000001000035060004001FFFE0020811111111222222220708FDAD70BF" + "E5AA15DD051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444656D6F01" + "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" +) + async def test_add_invalid_dataset(hass: HomeAssistant) -> None: """Test adding an invalid dataset.""" @@ -109,6 +121,8 @@ async def test_dataset_properties(hass: HomeAssistant) -> None: {"source": "Google", "tlv": DATASET_1}, {"source": "Multipan", "tlv": DATASET_2}, {"source": "🎅", "tlv": DATASET_3}, + {"source": "test1", "tlv": DATASET_1_BAD_CHANNEL}, + {"source": "test2", "tlv": DATASET_1_NO_CHANNEL}, ] for dataset in datasets: @@ -122,25 +136,40 @@ async def test_dataset_properties(hass: HomeAssistant) -> None: dataset_2 = dataset if dataset.source == "🎅": dataset_3 = dataset + if dataset.source == "test1": + dataset_4 = dataset + if dataset.source == "test2": + dataset_5 = dataset dataset = store.async_get(dataset_1.id) assert dataset == dataset_1 + assert dataset.channel == 15 assert dataset.extended_pan_id == "1111111122222222" assert dataset.network_name == "OpenThreadDemo" assert dataset.pan_id == "1234" dataset = store.async_get(dataset_2.id) assert dataset == dataset_2 + assert dataset.channel == 15 assert dataset.extended_pan_id == "1111111122222222" assert dataset.network_name == "HomeAssistant!" assert dataset.pan_id == "1234" dataset = store.async_get(dataset_3.id) assert dataset == dataset_3 + assert dataset.channel == 15 assert dataset.extended_pan_id == "1111111122222222" assert dataset.network_name == "~🐣🐥🐤~" assert dataset.pan_id == "1234" + dataset = store.async_get(dataset_4.id) + assert dataset == dataset_4 + assert dataset.channel is None + + dataset = store.async_get(dataset_5.id) + assert dataset == dataset_5 + assert dataset.channel is None + async def test_load_datasets(hass: HomeAssistant) -> None: """Make sure that we can load/save data correctly.""" diff --git a/tests/components/thread/test_websocket_api.py b/tests/components/thread/test_websocket_api.py index c2e9e5f593..c7bdd78188 100644 --- a/tests/components/thread/test_websocket_api.py +++ b/tests/components/thread/test_websocket_api.py @@ -153,6 +153,7 @@ async def test_list_get_dataset( assert msg["result"] == { "datasets": [ { + "channel": 15, "created": dataset_1.created.isoformat(), "dataset_id": dataset_1.id, "extended_pan_id": "1111111122222222", @@ -162,6 +163,7 @@ async def test_list_get_dataset( "source": "Google", }, { + "channel": 15, "created": dataset_2.created.isoformat(), "dataset_id": dataset_2.id, "extended_pan_id": "1111111122222222", @@ -171,6 +173,7 @@ async def test_list_get_dataset( "source": "Multipan", }, { + "channel": 15, "created": dataset_3.created.isoformat(), "dataset_id": dataset_3.id, "extended_pan_id": "1111111122222222", From 0b72cc9f5ef4494fa2d0e7e52ca21894626b21e8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 Mar 2023 09:21:45 -0400 Subject: [PATCH 1457/2810] OpenAI to rely on built-in `areas` variable (#90481) --- homeassistant/components/openai_conversation/__init__.py | 3 +-- homeassistant/components/openai_conversation/const.py | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 3e67d4e27d..6f76142106 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, TemplateError -from homeassistant.helpers import area_registry as ar, intent, template +from homeassistant.helpers import intent, template from homeassistant.util import ulid from .const import ( @@ -138,7 +138,6 @@ class OpenAIAgent(conversation.AbstractConversationAgent): return template.Template(raw_prompt, self.hass).async_render( { "ha_name": self.hass.config.location_name, - "areas": list(ar.async_get(self.hass).areas.values()), }, parse_result=False, ) diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 88289eb90b..46f8603c5f 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -5,13 +5,13 @@ CONF_PROMPT = "prompt" DEFAULT_PROMPT = """This smart home is controlled by Home Assistant. An overview of the areas and the devices in this smart home: -{%- for area in areas %} +{%- for area in areas() %} {%- set area_info = namespace(printed=false) %} - {%- for device in area_devices(area.name) -%} + {%- for device in area_devices(area) -%} {%- if not device_attr(device, "disabled_by") and not device_attr(device, "entry_type") and device_attr(device, "name") %} {%- if not area_info.printed %} -{{ area.name }}: +{{ area_name(area) }}: {%- set area_info.printed = true %} {%- endif %} - {{ device_attr(device, "name") }}{% if device_attr(device, "model") and (device_attr(device, "model") | string) not in (device_attr(device, "name") | string) %} ({{ device_attr(device, "model") }}){% endif %} From 87c46595207ce7b4baf74adcfbce26b87e75b7f0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 Mar 2023 09:23:13 -0400 Subject: [PATCH 1458/2810] Unregister webhook when registering webhook with nuki fials (#90514) --- homeassistant/components/nuki/__init__.py | 43 +++++++++++++---------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index 74245d30d4..8a7985fe28 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -25,6 +25,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.network import get_url from homeassistant.helpers.update_coordinator import ( @@ -146,23 +147,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, DOMAIN, entry.title, entry.entry_id, handle_webhook, local_only=True ) - async def _stop_nuki(_: Event): - """Stop and remove the Nuki webhook.""" - webhook.async_unregister(hass, entry.entry_id) - try: - async with async_timeout.timeout(10): - await hass.async_add_executor_job( - _remove_webhook, bridge, entry.entry_id - ) - except InvalidCredentialsException as err: - raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err - except RequestException as err: - raise UpdateFailed(f"Error communicating with Bridge: {err}") from err - - entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_nuki) - ) - webhook_url = webhook.async_generate_path(entry.entry_id) hass_url = get_url( hass, allow_cloud=False, allow_external=False, allow_ip=True, require_ssl=False @@ -174,9 +158,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _register_webhook, bridge, entry.entry_id, url ) except InvalidCredentialsException as err: - raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err + webhook.async_unregister(hass, entry.entry_id) + raise ConfigEntryNotReady(f"Invalid credentials for Bridge: {err}") from err except RequestException as err: - raise UpdateFailed(f"Error communicating with Bridge: {err}") from err + webhook.async_unregister(hass, entry.entry_id) + raise ConfigEntryNotReady(f"Error communicating with Bridge: {err}") from err + + async def _stop_nuki(_: Event): + """Stop and remove the Nuki webhook.""" + webhook.async_unregister(hass, entry.entry_id) + try: + async with async_timeout.timeout(10): + await hass.async_add_executor_job( + _remove_webhook, bridge, entry.entry_id + ) + except InvalidCredentialsException as err: + _LOGGER.error( + "Error unregistering webhook, invalid credentials for bridge: %s", err + ) + except RequestException as err: + _LOGGER.error("Error communicating with bridge: %s", err) + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_nuki) + ) coordinator = NukiCoordinator(hass, bridge, locks, openers) From cf628dbf23c3e149e2ed904553fc58b7267ab8cb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 30 Mar 2023 16:38:35 +0200 Subject: [PATCH 1459/2810] Add a device to the sun (#90517) --- homeassistant/components/sun/sensor.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/sun/sensor.py b/homeassistant/components/sun/sensor.py index 527ccc4069..8a253566e2 100644 --- a/homeassistant/components/sun/sensor.py +++ b/homeassistant/components/sun/sensor.py @@ -15,6 +15,8 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEGREE from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -126,6 +128,12 @@ class SunSensor(SensorEntity): self._attr_unique_id = f"{entry_id}-{entity_description.key}" self.sun = sun + self._attr_device_info = DeviceInfo( + name="Sun", + identifiers={(DOMAIN, entry_id)}, + entry_type=DeviceEntryType.SERVICE, + ) + @property def native_value(self) -> StateType | datetime: """Return value of sensor.""" From e877fd6682ced68742a701598e3f0c129ef16f26 Mon Sep 17 00:00:00 2001 From: RenierM26 <66512715+RenierM26@users.noreply.github.com> Date: Wed, 29 Mar 2023 23:43:54 +0200 Subject: [PATCH 1460/2810] Use auth token in Ezviz (#54663) * Initial commit * Revert "Initial commit" This reverts commit 452027f1a3c1be186cedd4115cea6928917c9467. * Change ezviz to token auth * Bump API version. * Add fix for token expired. Fix options update and unload. * Fix tests (PLATFORM to PLATFORM_BY_TYPE) * Uses and stores token only, added reauth step when token expires. * Add tests MFA code exceptions. * Fix tests. * Remove redundant try/except blocks. * Rebase fixes. * Fix errors in reauth config flow * Implement recommendations * Fix typing error in config_flow * Fix tests after rebase, readd camera check on init * Change to platform setup * Cleanup init. * Test for MFA required under user form * Remove useless if block. * Fix formating after rebase * Fix formating. * No longer stored in the repository --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/ezviz/__init__.py | 137 +++++---- homeassistant/components/ezviz/camera.py | 14 +- homeassistant/components/ezviz/config_flow.py | 286 +++++++++++------- homeassistant/components/ezviz/const.py | 5 +- homeassistant/components/ezviz/coordinator.py | 20 +- homeassistant/components/ezviz/strings.json | 14 +- tests/components/ezviz/__init__.py | 26 +- tests/components/ezviz/conftest.py | 8 +- tests/components/ezviz/test_config_flow.py | 242 +++++++++++++-- 9 files changed, 535 insertions(+), 217 deletions(-) diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py index fbd49102f3..489ff97eb4 100644 --- a/homeassistant/components/ezviz/__init__.py +++ b/homeassistant/components/ezviz/__init__.py @@ -2,26 +2,26 @@ import logging from pyezviz.client import EzvizClient -from pyezviz.exceptions import HTTPError, InvalidURL, PyEzvizError +from pyezviz.exceptions import ( + EzvizAuthTokenExpired, + EzvizAuthVerificationCode, + HTTPError, + InvalidURL, + PyEzvizError, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_PASSWORD, - CONF_TIMEOUT, - CONF_TYPE, - CONF_URL, - CONF_USERNAME, - Platform, -) +from homeassistant.const import CONF_TIMEOUT, CONF_TYPE, CONF_URL, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import ( ATTR_TYPE_CAMERA, ATTR_TYPE_CLOUD, CONF_FFMPEG_ARGUMENTS, + CONF_RFSESSION_ID, + CONF_SESSION_ID, DATA_COORDINATOR, - DATA_UNDO_UPDATE_LISTENER, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, DOMAIN, @@ -30,17 +30,22 @@ from .coordinator import EzvizDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORMS = [ - Platform.BINARY_SENSOR, - Platform.CAMERA, - Platform.SENSOR, - Platform.SWITCH, -] +PLATFORMS_BY_TYPE: dict[str, list] = { + ATTR_TYPE_CAMERA: [], + ATTR_TYPE_CLOUD: [ + Platform.BINARY_SENSOR, + Platform.CAMERA, + Platform.SENSOR, + Platform.SWITCH, + ], +} async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up EZVIZ from a config entry.""" hass.data.setdefault(DOMAIN, {}) + sensor_type: str = entry.data[CONF_TYPE] + ezviz_client = None if not entry.options: options = { @@ -50,69 +55,71 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_update_entry(entry, options=options) - if entry.data.get(CONF_TYPE) == ATTR_TYPE_CAMERA: - if hass.data.get(DOMAIN): - # Should only execute on addition of new camera entry. - # Fetch Entry id of main account and reload it. - for item in hass.config_entries.async_entries(): - if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: - _LOGGER.info("Reload EZVIZ integration with new camera rtsp entry") - await hass.config_entries.async_reload(item.entry_id) + # Initialize EZVIZ cloud entities + if PLATFORMS_BY_TYPE[sensor_type]: + # Initiate reauth config flow if account token if not present. + if not entry.data.get(CONF_SESSION_ID): + raise ConfigEntryAuthFailed - return True - - try: - ezviz_client = await hass.async_add_executor_job( - _get_ezviz_client_instance, entry + ezviz_client = EzvizClient( + token={ + CONF_SESSION_ID: entry.data.get(CONF_SESSION_ID), + CONF_RFSESSION_ID: entry.data.get(CONF_RFSESSION_ID), + "api_url": entry.data.get(CONF_URL), + }, + timeout=entry.options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), ) - except (InvalidURL, HTTPError, PyEzvizError) as error: - _LOGGER.error("Unable to connect to EZVIZ service: %s", str(error)) - raise ConfigEntryNotReady from error - coordinator = EzvizDataUpdateCoordinator( - hass, api=ezviz_client, api_timeout=entry.options[CONF_TIMEOUT] + try: + await hass.async_add_executor_job(ezviz_client.login) + + except (EzvizAuthTokenExpired, EzvizAuthVerificationCode) as error: + raise ConfigEntryAuthFailed from error + + except (InvalidURL, HTTPError, PyEzvizError) as error: + _LOGGER.error("Unable to connect to Ezviz service: %s", str(error)) + raise ConfigEntryNotReady from error + + coordinator = EzvizDataUpdateCoordinator( + hass, api=ezviz_client, api_timeout=entry.options[CONF_TIMEOUT] + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = {DATA_COORDINATOR: coordinator} + + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + + # Check EZVIZ cloud account entity is present, reload cloud account entities for camera entity change to take effect. + # Cameras are accessed via local RTSP stream with unique credentials per camera. + # Separate camera entities allow for credential changes per camera. + if sensor_type == ATTR_TYPE_CAMERA and hass.data[DOMAIN]: + for item in hass.config_entries.async_entries(domain=DOMAIN): + if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: + _LOGGER.info("Reload Ezviz main account with camera entry") + await hass.config_entries.async_reload(item.entry_id) + return True + + await hass.config_entries.async_forward_entry_setups( + entry, PLATFORMS_BY_TYPE[sensor_type] ) - await coordinator.async_refresh() - - if not coordinator.last_update_success: - raise ConfigEntryNotReady - - undo_listener = entry.add_update_listener(_async_update_listener) - - hass.data[DOMAIN][entry.entry_id] = { - DATA_COORDINATOR: coordinator, - DATA_UNDO_UPDATE_LISTENER: undo_listener, - } - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + sensor_type = entry.data[CONF_TYPE] - if entry.data.get(CONF_TYPE) == ATTR_TYPE_CAMERA: - return True - - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN][entry.entry_id][DATA_UNDO_UPDATE_LISTENER]() + unload_ok = await hass.config_entries.async_unload_platforms( + entry, PLATFORMS_BY_TYPE[sensor_type] + ) + if sensor_type == ATTR_TYPE_CLOUD and unload_ok: hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) - - -def _get_ezviz_client_instance(entry: ConfigEntry) -> EzvizClient: - """Initialize a new instance of EzvizClientApi.""" - ezviz_client = EzvizClient( - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - entry.data[CONF_URL], - entry.options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), - ) - ezviz_client.login() - return ezviz_client diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index 7901061c02..0456e7ade9 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -34,7 +34,6 @@ from .const import ( DATA_COORDINATOR, DEFAULT_CAMERA_USERNAME, DEFAULT_FFMPEG_ARGUMENTS, - DEFAULT_RTSP_PORT, DIR_DOWN, DIR_LEFT, DIR_RIGHT, @@ -70,24 +69,17 @@ async def async_setup_entry( if item.unique_id == camera and item.source != SOURCE_IGNORE ] - # There seem to be a bug related to localRtspPort in EZVIZ API. - local_rtsp_port = ( - value["local_rtsp_port"] - if value["local_rtsp_port"] != 0 - else DEFAULT_RTSP_PORT - ) - if camera_rtsp_entry: ffmpeg_arguments = camera_rtsp_entry[0].options[CONF_FFMPEG_ARGUMENTS] camera_username = camera_rtsp_entry[0].data[CONF_USERNAME] camera_password = camera_rtsp_entry[0].data[CONF_PASSWORD] - camera_rtsp_stream = f"rtsp://{camera_username}:{camera_password}@{value['local_ip']}:{local_rtsp_port}{ffmpeg_arguments}" + camera_rtsp_stream = f"rtsp://{camera_username}:{camera_password}@{value['local_ip']}:{value['local_rtsp_port']}{ffmpeg_arguments}" _LOGGER.debug( "Configuring Camera %s with ip: %s rtsp port: %s ffmpeg arguments: %s", camera, value["local_ip"], - local_rtsp_port, + value["local_rtsp_port"], ffmpeg_arguments, ) @@ -123,7 +115,7 @@ async def async_setup_entry( camera_username, camera_password, camera_rtsp_stream, - local_rtsp_port, + value["local_rtsp_port"], ffmpeg_arguments, ) ) diff --git a/homeassistant/components/ezviz/config_flow.py b/homeassistant/components/ezviz/config_flow.py index 4c8b1418fa..77598ad6a1 100644 --- a/homeassistant/components/ezviz/config_flow.py +++ b/homeassistant/components/ezviz/config_flow.py @@ -1,12 +1,14 @@ -"""Config flow for ezviz.""" +"""Config flow for EZVIZ.""" from __future__ import annotations +from collections.abc import Mapping import logging +from typing import Any from pyezviz.client import EzvizClient from pyezviz.exceptions import ( AuthTestResultFailed, - HTTPError, + EzvizAuthVerificationCode, InvalidHost, InvalidURL, PyEzvizError, @@ -25,12 +27,15 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from .const import ( ATTR_SERIAL, ATTR_TYPE_CAMERA, ATTR_TYPE_CLOUD, CONF_FFMPEG_ARGUMENTS, + CONF_RFSESSION_ID, + CONF_SESSION_ID, DEFAULT_CAMERA_USERNAME, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, @@ -40,23 +45,37 @@ from .const import ( ) _LOGGER = logging.getLogger(__name__) +DEFAULT_OPTIONS = { + CONF_FFMPEG_ARGUMENTS: DEFAULT_FFMPEG_ARGUMENTS, + CONF_TIMEOUT: DEFAULT_TIMEOUT, +} -def _get_ezviz_client_instance(data): - """Initialize a new instance of EzvizClientApi.""" +def _validate_and_create_auth(data: dict) -> dict[str, Any]: + """Try to login to EZVIZ cloud account and return token.""" + # Verify cloud credentials by attempting a login request with username and password. + # Return login token. ezviz_client = EzvizClient( data[CONF_USERNAME], data[CONF_PASSWORD], - data.get(CONF_URL, EU_URL), + data[CONF_URL], data.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), ) - ezviz_client.login() - return ezviz_client + ezviz_token = ezviz_client.login() + + auth_data = { + CONF_SESSION_ID: ezviz_token[CONF_SESSION_ID], + CONF_RFSESSION_ID: ezviz_token[CONF_RFSESSION_ID], + CONF_URL: ezviz_token["api_url"], + CONF_TYPE: ATTR_TYPE_CLOUD, + } + + return auth_data -def _test_camera_rtsp_creds(data): +def _test_camera_rtsp_creds(data: dict) -> None: """Try DESCRIBE on RTSP camera with credentials.""" test_rtsp = TestRTSPAuth( @@ -71,89 +90,43 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - async def _validate_and_create_auth(self, data): - """Try to login to ezviz cloud account and create entry if successful.""" - await self.async_set_unique_id(data[CONF_USERNAME]) - self._abort_if_unique_id_configured() - - # Verify cloud credentials by attempting a login request. - try: - await self.hass.async_add_executor_job(_get_ezviz_client_instance, data) - - except InvalidURL as err: - raise InvalidURL from err - - except HTTPError as err: - raise InvalidHost from err - - except PyEzvizError as err: - raise PyEzvizError from err - - auth_data = { - CONF_USERNAME: data[CONF_USERNAME], - CONF_PASSWORD: data[CONF_PASSWORD], - CONF_URL: data.get(CONF_URL, EU_URL), - CONF_TYPE: ATTR_TYPE_CLOUD, - } - - return self.async_create_entry(title=data[CONF_USERNAME], data=auth_data) - - async def _validate_and_create_camera_rtsp(self, data): + async def _validate_and_create_camera_rtsp(self, data: dict) -> FlowResult: """Try DESCRIBE on RTSP camera with credentials.""" # Get EZVIZ cloud credentials from config entry - ezviz_client_creds = { - CONF_USERNAME: None, - CONF_PASSWORD: None, - CONF_URL: None, + ezviz_token = { + CONF_SESSION_ID: None, + CONF_RFSESSION_ID: None, + "api_url": None, } + ezviz_timeout = DEFAULT_TIMEOUT for item in self._async_current_entries(): if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: - ezviz_client_creds = { - CONF_USERNAME: item.data.get(CONF_USERNAME), - CONF_PASSWORD: item.data.get(CONF_PASSWORD), - CONF_URL: item.data.get(CONF_URL), + ezviz_token = { + CONF_SESSION_ID: item.data.get(CONF_SESSION_ID), + CONF_RFSESSION_ID: item.data.get(CONF_RFSESSION_ID), + "api_url": item.data.get(CONF_URL), } + ezviz_timeout = item.data.get(CONF_TIMEOUT, DEFAULT_TIMEOUT) # Abort flow if user removed cloud account before adding camera. - if ezviz_client_creds[CONF_USERNAME] is None: + if ezviz_token.get(CONF_SESSION_ID) is None: return self.async_abort(reason="ezviz_cloud_account_missing") + ezviz_client = EzvizClient(token=ezviz_token, timeout=ezviz_timeout) + # We need to wake hibernating cameras. # First create EZVIZ API instance. - try: - ezviz_client = await self.hass.async_add_executor_job( - _get_ezviz_client_instance, ezviz_client_creds - ) + await self.hass.async_add_executor_job(ezviz_client.login) - except InvalidURL as err: - raise InvalidURL from err - - except HTTPError as err: - raise InvalidHost from err - - except PyEzvizError as err: - raise PyEzvizError from err - - # Secondly try to wake hibernating camera. - try: - await self.hass.async_add_executor_job( - ezviz_client.get_detection_sensibility, data[ATTR_SERIAL] - ) - - except HTTPError as err: - raise InvalidHost from err + # Secondly try to wake hybernating camera. + await self.hass.async_add_executor_job( + ezviz_client.get_detection_sensibility, data[ATTR_SERIAL] + ) # Thirdly attempts an authenticated RTSP DESCRIBE request. - try: - await self.hass.async_add_executor_job(_test_camera_rtsp_creds, data) - - except InvalidHost as err: - raise InvalidHost from err - - except AuthTestResultFailed as err: - raise AuthTestResultFailed from err + await self.hass.async_add_executor_job(_test_camera_rtsp_creds, data) return self.async_create_entry( title=data[ATTR_SERIAL], @@ -162,6 +135,7 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): CONF_PASSWORD: data[CONF_PASSWORD], CONF_TYPE: ATTR_TYPE_CAMERA, }, + options=DEFAULT_OPTIONS, ) @staticmethod @@ -170,18 +144,24 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return EzvizOptionsFlowHandler(config_entry) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by the user.""" - # Check if ezviz cloud account is present in entry config, + # Check if EZVIZ cloud account is present in entry config, # abort if already configured. for item in self._async_current_entries(): if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: return self.async_abort(reason="already_configured_account") errors = {} + auth_data = {} if user_input is not None: + await self.async_set_unique_id(user_input[CONF_USERNAME]) + self._abort_if_unique_id_configured() + if user_input[CONF_URL] == CONF_CUSTOMIZE: self.context["data"] = { CONF_USERNAME: user_input[CONF_USERNAME], @@ -189,11 +169,10 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): } return await self.async_step_user_custom_url() - if CONF_TIMEOUT not in user_input: - user_input[CONF_TIMEOUT] = DEFAULT_TIMEOUT - try: - return await self._validate_and_create_auth(user_input) + auth_data = await self.hass.async_add_executor_job( + _validate_and_create_auth, user_input + ) except InvalidURL: errors["base"] = "invalid_host" @@ -201,6 +180,9 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): except InvalidHost: errors["base"] = "cannot_connect" + except EzvizAuthVerificationCode: + errors["base"] = "mfa_required" + except PyEzvizError: errors["base"] = "invalid_auth" @@ -208,6 +190,13 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") + else: + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data=auth_data, + options=DEFAULT_OPTIONS, + ) + data_schema = vol.Schema( { vol.Required(CONF_USERNAME): str, @@ -222,20 +211,21 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=data_schema, errors=errors ) - async def async_step_user_custom_url(self, user_input=None): + async def async_step_user_custom_url( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by the user for custom region url.""" - errors = {} + auth_data = {} if user_input is not None: user_input[CONF_USERNAME] = self.context["data"][CONF_USERNAME] user_input[CONF_PASSWORD] = self.context["data"][CONF_PASSWORD] - if CONF_TIMEOUT not in user_input: - user_input[CONF_TIMEOUT] = DEFAULT_TIMEOUT - try: - return await self._validate_and_create_auth(user_input) + auth_data = await self.hass.async_add_executor_job( + _validate_and_create_auth, user_input + ) except InvalidURL: errors["base"] = "invalid_host" @@ -243,6 +233,9 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): except InvalidHost: errors["base"] = "cannot_connect" + except EzvizAuthVerificationCode: + errors["base"] = "mfa_required" + except PyEzvizError: errors["base"] = "invalid_auth" @@ -250,6 +243,13 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") + else: + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data=auth_data, + options=DEFAULT_OPTIONS, + ) + data_schema_custom_url = vol.Schema( { vol.Required(CONF_URL, default=EU_URL): str, @@ -260,18 +260,22 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user_custom_url", data_schema=data_schema_custom_url, errors=errors ) - async def async_step_integration_discovery(self, discovery_info): + async def async_step_integration_discovery( + self, discovery_info: dict[str, Any] + ) -> FlowResult: """Handle a flow for discovered camera without rtsp config entry.""" await self.async_set_unique_id(discovery_info[ATTR_SERIAL]) self._abort_if_unique_id_configured() - self.context["title_placeholders"] = {"serial": self.unique_id} + self.context["title_placeholders"] = {ATTR_SERIAL: self.unique_id} self.context["data"] = {CONF_IP_ADDRESS: discovery_info[CONF_IP_ADDRESS]} return await self.async_step_confirm() - async def async_step_confirm(self, user_input=None): + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Confirm and create entry from discovery step.""" errors = {} @@ -284,6 +288,9 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): except (InvalidHost, InvalidURL): errors["base"] = "invalid_host" + except EzvizAuthVerificationCode: + errors["base"] = "mfa_required" + except (PyEzvizError, AuthTestResultFailed): errors["base"] = "invalid_auth" @@ -303,11 +310,76 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=discovered_camera_schema, errors=errors, description_placeholders={ - "serial": self.unique_id, + ATTR_SERIAL: self.unique_id, CONF_IP_ADDRESS: self.context["data"][CONF_IP_ADDRESS], }, ) + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + """Handle a flow for reauthentication with password.""" + + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a Confirm flow for reauthentication with password.""" + auth_data = {} + errors = {} + entry = None + + for item in self._async_current_entries(): + if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: + self.context["title_placeholders"] = {ATTR_SERIAL: item.title} + entry = await self.async_set_unique_id(item.title) + + if not entry: + return self.async_abort(reason="ezviz_cloud_account_missing") + + if user_input is not None: + user_input[CONF_URL] = entry.data[CONF_URL] + + try: + auth_data = await self.hass.async_add_executor_job( + _validate_and_create_auth, user_input + ) + + except (InvalidHost, InvalidURL): + errors["base"] = "invalid_host" + + except EzvizAuthVerificationCode: + errors["base"] = "mfa_required" + + except (PyEzvizError, AuthTestResultFailed): + errors["base"] = "invalid_auth" + + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + else: + self.hass.config_entries.async_update_entry( + entry, + data=auth_data, + ) + + await self.hass.config_entries.async_reload(entry.entry_id) + + return self.async_abort(reason="reauth_successful") + + data_schema = vol.Schema( + { + vol.Required(CONF_USERNAME, default=entry.title): vol.In([entry.title]), + vol.Required(CONF_PASSWORD): str, + } + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=data_schema, + errors=errors, + ) + class EzvizOptionsFlowHandler(OptionsFlow): """Handle EZVIZ client options.""" @@ -316,22 +388,28 @@ class EzvizOptionsFlowHandler(OptionsFlow): """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage EZVIZ options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) - options = { - vol.Optional( - CONF_TIMEOUT, - default=self.config_entry.options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), - ): int, - vol.Optional( - CONF_FFMPEG_ARGUMENTS, - default=self.config_entry.options.get( - CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS - ), - ): str, - } + options = vol.Schema( + { + vol.Optional( + CONF_TIMEOUT, + default=self.config_entry.options.get( + CONF_TIMEOUT, DEFAULT_TIMEOUT + ), + ): int, + vol.Optional( + CONF_FFMPEG_ARGUMENTS, + default=self.config_entry.options.get( + CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS + ), + ): str, + } + ) - return self.async_show_form(step_id="init", data_schema=vol.Schema(options)) + return self.async_show_form(step_id="init", data_schema=options) diff --git a/homeassistant/components/ezviz/const.py b/homeassistant/components/ezviz/const.py index b9183772b6..d052a4b821 100644 --- a/homeassistant/components/ezviz/const.py +++ b/homeassistant/components/ezviz/const.py @@ -10,6 +10,9 @@ ATTR_HOME = "HOME_MODE" ATTR_AWAY = "AWAY_MODE" ATTR_TYPE_CLOUD = "EZVIZ_CLOUD_ACCOUNT" ATTR_TYPE_CAMERA = "CAMERA_ACCOUNT" +CONF_SESSION_ID = "session_id" +CONF_RFSESSION_ID = "rf_session_id" +CONF_EZVIZ_ACCOUNT = "ezviz_account" # Services data DIR_UP = "up" @@ -33,10 +36,8 @@ SERVICE_DETECTION_SENSITIVITY = "set_alarm_detection_sensibility" EU_URL = "apiieu.ezvizlife.com" RUSSIA_URL = "apirus.ezvizru.com" DEFAULT_CAMERA_USERNAME = "admin" -DEFAULT_RTSP_PORT = 554 DEFAULT_TIMEOUT = 25 DEFAULT_FFMPEG_ARGUMENTS = "" # Data DATA_COORDINATOR = "coordinator" -DATA_UNDO_UPDATE_LISTENER = "undo_update_listener" diff --git a/homeassistant/components/ezviz/coordinator.py b/homeassistant/components/ezviz/coordinator.py index cc4537bb9b..ba8ed336a5 100644 --- a/homeassistant/components/ezviz/coordinator.py +++ b/homeassistant/components/ezviz/coordinator.py @@ -4,9 +4,16 @@ import logging from async_timeout import timeout from pyezviz.client import EzvizClient -from pyezviz.exceptions import HTTPError, InvalidURL, PyEzvizError +from pyezviz.exceptions import ( + EzvizAuthTokenExpired, + EzvizAuthVerificationCode, + HTTPError, + InvalidURL, + PyEzvizError, +) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -27,15 +34,16 @@ class EzvizDataUpdateCoordinator(DataUpdateCoordinator): super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) - def _update_data(self) -> dict: - """Fetch data from EZVIZ via camera load function.""" - return self.ezviz_client.load_cameras() - async def _async_update_data(self) -> dict: """Fetch data from EZVIZ.""" try: async with timeout(self._api_timeout): - return await self.hass.async_add_executor_job(self._update_data) + return await self.hass.async_add_executor_job( + self.ezviz_client.load_cameras + ) + + except (EzvizAuthTokenExpired, EzvizAuthVerificationCode) as error: + raise ConfigEntryAuthFailed from error except (InvalidURL, HTTPError, PyEzvizError) as error: raise UpdateFailed(f"Invalid response from API: {error}") from error diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json index 91fa32ad9b..5e258e4270 100644 --- a/homeassistant/components/ezviz/strings.json +++ b/homeassistant/components/ezviz/strings.json @@ -26,17 +26,27 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Enter credentials to reauthenticate to ezviz cloud account", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "invalid_host": "[%key:common::config_flow::error::invalid_host%]" + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "mfa_required": "2FA enabled on account, please disable and retry" }, "abort": { "already_configured_account": "[%key:common::config_flow::abort::already_configured_account%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "ezviz_cloud_account_missing": "EZVIZ cloud account missing. Please reconfigure EZVIZ cloud account" + "ezviz_cloud_account_missing": "Ezviz cloud account missing. Please reconfigure Ezviz cloud account", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "options": { diff --git a/tests/components/ezviz/__init__.py b/tests/components/ezviz/__init__.py index 64dcbfc26e..768fc30cc8 100644 --- a/tests/components/ezviz/__init__.py +++ b/tests/components/ezviz/__init__.py @@ -3,8 +3,11 @@ from unittest.mock import patch from homeassistant.components.ezviz.const import ( ATTR_SERIAL, + ATTR_TYPE_CAMERA, ATTR_TYPE_CLOUD, CONF_FFMPEG_ARGUMENTS, + CONF_RFSESSION_ID, + CONF_SESSION_ID, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, DOMAIN, @@ -22,8 +25,8 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry ENTRY_CONFIG = { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", + CONF_SESSION_ID: "test-username", + CONF_RFSESSION_ID: "test-password", CONF_URL: "apiieu.ezvizlife.com", CONF_TYPE: ATTR_TYPE_CLOUD, } @@ -46,6 +49,18 @@ USER_INPUT = { CONF_TYPE: ATTR_TYPE_CLOUD, } +USER_INPUT_CAMERA_VALIDATE = { + ATTR_SERIAL: "C666666", + CONF_PASSWORD: "test-password", + CONF_USERNAME: "test-username", +} + +USER_INPUT_CAMERA = { + CONF_PASSWORD: "test-password", + CONF_USERNAME: "test-username", + CONF_TYPE: ATTR_TYPE_CAMERA, +} + DISCOVERY_INFO = { ATTR_SERIAL: "C666666", CONF_USERNAME: None, @@ -59,6 +74,13 @@ TEST = { CONF_IP_ADDRESS: "127.0.0.1", } +API_LOGIN_RETURN_VALIDATE = { + CONF_SESSION_ID: "fake_token", + CONF_RFSESSION_ID: "fake_rf_token", + CONF_URL: "apiieu.ezvizlife.com", + CONF_TYPE: ATTR_TYPE_CLOUD, +} + def _patch_async_setup_entry(return_value=True): return patch( diff --git a/tests/components/ezviz/conftest.py b/tests/components/ezviz/conftest.py index 76b962250b..e89e375fb5 100644 --- a/tests/components/ezviz/conftest.py +++ b/tests/components/ezviz/conftest.py @@ -5,6 +5,12 @@ from pyezviz import EzvizClient from pyezviz.test_cam_rtsp import TestRTSPAuth import pytest +ezviz_login_token_return = { + "session_id": "fake_token", + "rf_session_id": "fake_rf_token", + "api_url": "apiieu.ezvizlife.com", +} + @pytest.fixture(autouse=True) def mock_ffmpeg(hass): @@ -42,7 +48,7 @@ def ezviz_config_flow(hass): "1", ) - instance.login = MagicMock(return_value=True) + instance.login = MagicMock(return_value=ezviz_login_token_return) instance.get_detection_sensibility = MagicMock(return_value=True) yield mock_ezviz diff --git a/tests/components/ezviz/test_config_flow.py b/tests/components/ezviz/test_config_flow.py index 624827220c..939bb92bcc 100644 --- a/tests/components/ezviz/test_config_flow.py +++ b/tests/components/ezviz/test_config_flow.py @@ -3,6 +3,7 @@ from unittest.mock import patch from pyezviz.exceptions import ( AuthTestResultFailed, + EzvizAuthVerificationCode, HTTPError, InvalidHost, InvalidURL, @@ -12,13 +13,16 @@ from pyezviz.exceptions import ( from homeassistant.components.ezviz.const import ( ATTR_SERIAL, ATTR_TYPE_CAMERA, - ATTR_TYPE_CLOUD, CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, DOMAIN, ) -from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, SOURCE_USER +from homeassistant.config_entries import ( + SOURCE_INTEGRATION_DISCOVERY, + SOURCE_REAUTH, + SOURCE_USER, +) from homeassistant.const import ( CONF_CUSTOMIZE, CONF_IP_ADDRESS, @@ -32,8 +36,8 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import ( + API_LOGIN_RETURN_VALIDATE, DISCOVERY_INFO, - USER_INPUT, USER_INPUT_VALIDATE, _patch_async_setup_entry, init_integration, @@ -59,7 +63,7 @@ async def test_user_form(hass: HomeAssistant, ezviz_config_flow) -> None: assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "test-username" - assert result["data"] == {**USER_INPUT} + assert result["data"] == {**API_LOGIN_RETURN_VALIDATE} assert len(mock_setup_entry.mock_calls) == 1 @@ -78,7 +82,11 @@ async def test_user_custom_url(hass: HomeAssistant, ezviz_config_flow) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_USERNAME: "test-user", CONF_PASSWORD: "test-pass", CONF_URL: "customize"}, + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_URL: CONF_CUSTOMIZE, + }, ) assert result["type"] == FlowResultType.FORM @@ -90,21 +98,58 @@ async def test_user_custom_url(hass: HomeAssistant, ezviz_config_flow) -> None: result["flow_id"], {CONF_URL: "test-user"}, ) + await hass.async_block_till_done() assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"] == { - CONF_PASSWORD: "test-pass", - CONF_TYPE: ATTR_TYPE_CLOUD, - CONF_URL: "test-user", - CONF_USERNAME: "test-user", - } + assert result["data"] == API_LOGIN_RETURN_VALIDATE assert len(mock_setup_entry.mock_calls) == 1 -async def test_step_discovery_abort_if_cloud_account_missing( - hass: HomeAssistant, -) -> None: +async def test_async_step_reauth(hass, ezviz_config_flow): + """Test the reauth step.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with _patch_async_setup_entry() as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_VALIDATE, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == {**API_LOGIN_RETURN_VALIDATE} + + assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_REAUTH}, data=USER_INPUT_VALIDATE + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_step_discovery_abort_if_cloud_account_missing(hass): """Test discovery and confirm step, abort if cloud account was removed.""" result = await hass.config_entries.flow.async_init( @@ -127,11 +172,21 @@ async def test_step_discovery_abort_if_cloud_account_missing( assert result["reason"] == "ezviz_cloud_account_missing" +async def test_step_reauth_abort_if_cloud_account_missing(hass): + """Test reauth and confirm step, abort if cloud account was removed.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_REAUTH}, data=USER_INPUT_VALIDATE + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "ezviz_cloud_account_missing" + + async def test_async_step_integration_discovery( - hass: HomeAssistant, ezviz_config_flow, ezviz_test_rtsp_config_flow -) -> None: + hass, ezviz_config_flow, ezviz_test_rtsp_config_flow +): """Test discovery and confirm step.""" - with patch("homeassistant.components.ezviz.PLATFORMS", []): + with patch("homeassistant.components.ezviz.PLATFORMS_BY_TYPE", []): await init_integration(hass) result = await hass.config_entries.flow.async_init( @@ -189,11 +244,14 @@ async def test_options_flow(hass: HomeAssistant) -> None: async def test_user_form_exception(hass: HomeAssistant, ezviz_config_flow) -> None: """Test we handle exception on user form.""" - ezviz_config_flow.side_effect = PyEzvizError - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + ezviz_config_flow.side_effect = PyEzvizError result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -215,6 +273,17 @@ async def test_user_form_exception(hass: HomeAssistant, ezviz_config_flow) -> No assert result["step_id"] == "user" assert result["errors"] == {"base": "invalid_host"} + ezviz_config_flow.side_effect = EzvizAuthVerificationCode + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_VALIDATE, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "mfa_required"} + ezviz_config_flow.side_effect = HTTPError result = await hass.config_entries.flow.async_configure( @@ -224,7 +293,7 @@ async def test_user_form_exception(hass: HomeAssistant, ezviz_config_flow) -> No assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": "invalid_auth"} ezviz_config_flow.side_effect = Exception @@ -242,7 +311,7 @@ async def test_discover_exception_step1( ezviz_config_flow, ) -> None: """Test we handle unexpected exception on discovery.""" - with patch("homeassistant.components.ezviz.PLATFORMS", []): + with patch("homeassistant.components.ezviz.PLATFORMS_BY_TYPE", []): await init_integration(hass) result = await hass.config_entries.flow.async_init( @@ -295,7 +364,21 @@ async def test_discover_exception_step1( assert result["type"] == FlowResultType.FORM assert result["step_id"] == "confirm" - assert result["errors"] == {"base": "invalid_host"} + assert result["errors"] == {"base": "invalid_auth"} + + ezviz_config_flow.side_effect = EzvizAuthVerificationCode + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-user", + CONF_PASSWORD: "test-pass", + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "confirm" + assert result["errors"] == {"base": "mfa_required"} ezviz_config_flow.side_effect = Exception @@ -317,7 +400,7 @@ async def test_discover_exception_step3( ezviz_test_rtsp_config_flow, ) -> None: """Test we handle unexpected exception on discovery.""" - with patch("homeassistant.components.ezviz.PLATFORMS", []): + with patch("homeassistant.components.ezviz.PLATFORMS_BY_TYPE", []): await init_integration(hass) result = await hass.config_entries.flow.async_init( @@ -423,7 +506,18 @@ async def test_user_custom_url_exception( assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user_custom_url" - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": "invalid_auth"} + + ezviz_config_flow.side_effect = EzvizAuthVerificationCode + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "test-user"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user_custom_url" + assert result["errors"] == {"base": "mfa_required"} ezviz_config_flow.side_effect = Exception @@ -434,3 +528,103 @@ async def test_user_custom_url_exception( assert result["type"] == FlowResultType.ABORT assert result["reason"] == "unknown" + + +async def test_async_step_reauth_exception(hass, ezviz_config_flow): + """Test the reauth step exceptions.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with _patch_async_setup_entry() as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_VALIDATE, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == {**API_LOGIN_RETURN_VALIDATE} + + assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_REAUTH}, data=USER_INPUT_VALIDATE + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + ezviz_config_flow.side_effect = InvalidURL() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_host"} + + ezviz_config_flow.side_effect = InvalidHost() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_host"} + + ezviz_config_flow.side_effect = EzvizAuthVerificationCode() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "mfa_required"} + + ezviz_config_flow.side_effect = PyEzvizError() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_auth"} + + ezviz_config_flow.side_effect = Exception() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "unknown" From 9d116799d603ffaab8b2942c3b9cd915c181f198 Mon Sep 17 00:00:00 2001 From: Thijs W Date: Thu, 30 Mar 2023 08:05:24 +0200 Subject: [PATCH 1461/2810] Add missing strings in frontier_silicon (#90446) Improve confirm message for ssdp flow --- homeassistant/components/frontier_silicon/config_flow.py | 4 +++- homeassistant/components/frontier_silicon/strings.json | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py index a054bd2b30..0ccc61e99c 100644 --- a/homeassistant/components/frontier_silicon/config_flow.py +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -188,7 +188,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self._async_create_entry() self._set_confirm_only() - return self.async_show_form(step_id="confirm") + return self.async_show_form( + step_id="confirm", description_placeholders={"name": self._name} + ) async def async_step_device_config( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/frontier_silicon/strings.json b/homeassistant/components/frontier_silicon/strings.json index 3a0a504761..a7c3f3e439 100644 --- a/homeassistant/components/frontier_silicon/strings.json +++ b/homeassistant/components/frontier_silicon/strings.json @@ -13,6 +13,9 @@ "data": { "pin": "[%key:common::config_flow::data::pin%]" } + }, + "confirm": { + "description": "Do you want to set up {name}?" } }, "error": { From baccbd98c7257216d7082e54be451727dac32f7a Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 29 Mar 2023 23:26:05 +0200 Subject: [PATCH 1462/2810] Bump reolink-aio to 0.5.8 (#90467) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 95b180fc16..79fc15c571 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.5.7"] + "requirements": ["reolink-aio==0.5.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index d51947b81c..22e80fced4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2234,7 +2234,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.7 +reolink-aio==0.5.8 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 514e653d34..fa7b257f8e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1597,7 +1597,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.7 +reolink-aio==0.5.8 # homeassistant.components.python_script restrictedpython==6.0 From b5811ad1c2fa31d05111ebc0fbcba8c6b257e331 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Wed, 29 Mar 2023 23:25:33 +0200 Subject: [PATCH 1463/2810] Add entity name translations for devolo Home Network (#90471) --- .../devolo_home_network/binary_sensor.py | 1 - .../components/devolo_home_network/entity.py | 1 + .../components/devolo_home_network/sensor.py | 3 --- .../devolo_home_network/strings.json | 26 +++++++++++++++++++ .../components/devolo_home_network/switch.py | 2 -- 5 files changed, 27 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/devolo_home_network/binary_sensor.py b/homeassistant/components/devolo_home_network/binary_sensor.py index e927ea9333..809dc9086b 100644 --- a/homeassistant/components/devolo_home_network/binary_sensor.py +++ b/homeassistant/components/devolo_home_network/binary_sensor.py @@ -53,7 +53,6 @@ SENSOR_TYPES: dict[str, DevoloBinarySensorEntityDescription] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:router-network", - name="Connected to router", value_func=_is_connected_to_router, ), } diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py index a26d8dce8f..8b665d7bf0 100644 --- a/homeassistant/components/devolo_home_network/entity.py +++ b/homeassistant/components/devolo_home_network/entity.py @@ -57,4 +57,5 @@ class DevoloEntity(CoordinatorEntity[DataUpdateCoordinator[_DataT]]): name=entry.title, sw_version=device.firmware_version, ) + self._attr_translation_key = self.entity_description.key self._attr_unique_id = f"{device.serial_number}_{self.entity_description.key}" diff --git a/homeassistant/components/devolo_home_network/sensor.py b/homeassistant/components/devolo_home_network/sensor.py index 2c2637c2f8..aeeab2ce89 100644 --- a/homeassistant/components/devolo_home_network/sensor.py +++ b/homeassistant/components/devolo_home_network/sensor.py @@ -54,7 +54,6 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:lan", - name="Connected PLC devices", value_func=lambda data: len( {device.mac_address_from for device in data.data_rates} ), @@ -62,7 +61,6 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = { CONNECTED_WIFI_CLIENTS: DevoloSensorEntityDescription[list[ConnectedStationInfo]]( key=CONNECTED_WIFI_CLIENTS, icon="mdi:wifi", - name="Connected Wifi clients", state_class=SensorStateClass.MEASUREMENT, value_func=len, ), @@ -71,7 +69,6 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:wifi-marker", - name="Neighboring Wifi networks", value_func=len, ), } diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index 6c320710a1..3472886cd5 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -27,5 +27,31 @@ "home_control": "The devolo Home Control Central Unit does not work with this integration.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "binary_sensor": { + "connected_to_router": { + "name": "Connected to router" + } + }, + "sensor": { + "connected_plc_devices": { + "name": "Connected PLC devices" + }, + "connected_wifi_clients": { + "name": "Connected Wifi clients" + }, + "neighboring_wifi_networks": { + "name": "Neighboring Wifi networks" + } + }, + "switch": { + "switch_guest_wifi": { + "name": "Enable guest Wifi" + }, + "switch_leds": { + "name": "Enable LEDs" + } + } } } diff --git a/homeassistant/components/devolo_home_network/switch.py b/homeassistant/components/devolo_home_network/switch.py index fa2447985d..6f387fdf05 100644 --- a/homeassistant/components/devolo_home_network/switch.py +++ b/homeassistant/components/devolo_home_network/switch.py @@ -42,7 +42,6 @@ SWITCH_TYPES: dict[str, DevoloSwitchEntityDescription[Any]] = { SWITCH_GUEST_WIFI: DevoloSwitchEntityDescription[WifiGuestAccessGet]( key=SWITCH_GUEST_WIFI, icon="mdi:wifi", - name="Enable guest Wifi", is_on_func=lambda data: data.enabled is True, turn_on_func=lambda device: device.device.async_set_wifi_guest_access(True), # type: ignore[union-attr] turn_off_func=lambda device: device.device.async_set_wifi_guest_access(False), # type: ignore[union-attr] @@ -51,7 +50,6 @@ SWITCH_TYPES: dict[str, DevoloSwitchEntityDescription[Any]] = { key=SWITCH_LEDS, entity_category=EntityCategory.CONFIG, icon="mdi:led-off", - name="Enable LEDs", is_on_func=bool, turn_on_func=lambda device: device.device.async_set_led_setting(True), # type: ignore[union-attr] turn_off_func=lambda device: device.device.async_set_led_setting(False), # type: ignore[union-attr] From 9f3c0fa9271c1e405d850c18d0332da27f55c6fe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Mar 2023 11:24:47 -1000 Subject: [PATCH 1464/2810] Bump yalexs-ble to 2.1.14 (#90474) changelog: https://github.com/bdraco/yalexs-ble/compare/v2.1.13...v2.1.14 reduces ble traffic (fixes a bug were we were checking when we did not need to be) --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 07ecc2a1be..84b5ae7e20 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.13"] + "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.14"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 7c45f309e6..f1ec6ba14c 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.1.13"] + "requirements": ["yalexs-ble==2.1.14"] } diff --git a/requirements_all.txt b/requirements_all.txt index 22e80fced4..cae40bd2c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2668,7 +2668,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.13 +yalexs-ble==2.1.14 # homeassistant.components.august yalexs==1.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fa7b257f8e..75b7dfeb3b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1911,7 +1911,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.13 +yalexs-ble==2.1.14 # homeassistant.components.august yalexs==1.2.7 From 02f108498cf729e944038affa1a9205243853df9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 30 Mar 2023 10:21:11 +0200 Subject: [PATCH 1465/2810] Add missing strings to sensor integration (#90475) * Add missing strings to sensor integration * Enumeration * Apply suggestion Co-authored-by: Franck Nijhof --------- Co-authored-by: Franck Nijhof --- homeassistant/components/sensor/strings.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 5b34c5a28e..16e0da0d51 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -160,6 +160,9 @@ "energy_storage": { "name": "Stored energy" }, + "enum": { + "name": "[%key:component::sensor::title%]" + }, "frequency": { "name": "Frequency" }, @@ -235,6 +238,9 @@ "temperature": { "name": "Temperature" }, + "timestamp": { + "name": "Timestamp" + }, "volatile_organic_compounds": { "name": "VOCs" }, From 30af4c769e723fda0f7511e34a88a3ab0bd7ba59 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 29 Mar 2023 17:24:26 -0400 Subject: [PATCH 1466/2810] Correctly load ZHA settings from API when integration is not running (#90476) Correctly load settings from the zigpy database when ZHA is not running --- homeassistant/components/zha/api.py | 23 ++++++++--------------- tests/components/zha/test_api.py | 5 ++++- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index d34dd2338e..652f19d24b 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -18,8 +18,6 @@ from .core.const import ( from .core.gateway import ZHAGateway if TYPE_CHECKING: - from zigpy.application import ControllerApplication - from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -49,21 +47,17 @@ def _get_config_entry(hass: HomeAssistant) -> ConfigEntry: return entries[0] -def _wrap_network_settings(app: ControllerApplication) -> NetworkBackup: - """Wrap the ZHA network settings into a `NetworkBackup`.""" +def async_get_active_network_settings(hass: HomeAssistant) -> NetworkBackup: + """Get the network settings for the currently active ZHA network.""" + zha_gateway: ZHAGateway = _get_gateway(hass) + app = zha_gateway.application_controller + return NetworkBackup( node_info=app.state.node_info, network_info=app.state.network_info, ) -def async_get_active_network_settings(hass: HomeAssistant) -> NetworkBackup: - """Get the network settings for the currently active ZHA network.""" - zha_gateway: ZHAGateway = _get_gateway(hass) - - return _wrap_network_settings(zha_gateway.application_controller) - - async def async_get_last_network_settings( hass: HomeAssistant, config_entry: ConfigEntry | None = None ) -> NetworkBackup | None: @@ -79,13 +73,12 @@ async def async_get_last_network_settings( try: await app._load_db() # pylint: disable=protected-access - settings = _wrap_network_settings(app) + settings = max(app.backups, key=lambda b: b.backup_time) + except ValueError: + settings = None finally: await app.shutdown() - if settings.network_info.channel == 0: - return None - return settings diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py index c607909980..59daf2179b 100644 --- a/tests/components/zha/test_api.py +++ b/tests/components/zha/test_api.py @@ -2,6 +2,7 @@ from unittest.mock import patch import pytest +import zigpy.backups import zigpy.state from homeassistant.components import zha @@ -36,7 +37,9 @@ async def test_async_get_network_settings_inactive( gateway = api._get_gateway(hass) await zha.async_unload_entry(hass, gateway.config_entry) - zigpy_app_controller.state.network_info.channel = 20 + backup = zigpy.backups.NetworkBackup() + backup.network_info.channel = 20 + zigpy_app_controller.backups.backups.append(backup) with patch( "bellows.zigbee.application.ControllerApplication.__new__", From 2a627e63f1c5ac837f53a052278e6df90a0620ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Mar 2023 11:26:28 -1000 Subject: [PATCH 1467/2810] Fix filesize doing blocking I/O in the event loop (#90479) Fix filesize doing I/O in the event loop --- homeassistant/components/filesize/__init__.py | 19 +++++++------------ .../components/filesize/config_flow.py | 4 +++- homeassistant/core.py | 6 +++++- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/filesize/__init__.py b/homeassistant/components/filesize/__init__.py index 9e08615d4a..73f060e79b 100644 --- a/homeassistant/components/filesize/__init__.py +++ b/homeassistant/components/filesize/__init__.py @@ -11,24 +11,19 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import PLATFORMS -def check_path(path: pathlib.Path) -> bool: - """Check path.""" - return path.exists() and path.is_file() - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up from a config entry.""" - - path = entry.data[CONF_FILE_PATH] +def _check_path(hass: HomeAssistant, path: str) -> None: + """Check if path is valid and allowed.""" get_path = pathlib.Path(path) - - check_file = await hass.async_add_executor_job(check_path, get_path) - if not check_file: + if not get_path.exists() or not get_path.is_file(): raise ConfigEntryNotReady(f"Can not access file {path}") if not hass.config.is_allowed_path(path): raise ConfigEntryNotReady(f"Filepath {path} is not valid or allowed") + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up from a config entry.""" + await hass.async_add_executor_job(_check_path, hass, entry.data[CONF_FILE_PATH]) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/filesize/config_flow.py b/homeassistant/components/filesize/config_flow.py index 3f58e636b0..8633e6ec46 100644 --- a/homeassistant/components/filesize/config_flow.py +++ b/homeassistant/components/filesize/config_flow.py @@ -49,7 +49,9 @@ class FilesizeConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: try: - full_path = validate_path(self.hass, user_input[CONF_FILE_PATH]) + full_path = await self.hass.async_add_executor_job( + validate_path, self.hass, user_input[CONF_FILE_PATH] + ) except NotValidError: errors["base"] = "not_valid" except NotAllowedError: diff --git a/homeassistant/core.py b/homeassistant/core.py index 900355d4a5..78ceb620e5 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1950,7 +1950,11 @@ class Config: ) def is_allowed_path(self, path: str) -> bool: - """Check if the path is valid for access from outside.""" + """Check if the path is valid for access from outside. + + This function does blocking I/O and should not be called from the event loop. + Use hass.async_add_executor_job to schedule it on the executor. + """ assert path is not None thepath = pathlib.Path(path) From b83cb5d1b1c3be759e84f08f8faa3ac5ce1f8ccd Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 Mar 2023 09:21:45 -0400 Subject: [PATCH 1468/2810] OpenAI to rely on built-in `areas` variable (#90481) --- homeassistant/components/openai_conversation/__init__.py | 3 +-- homeassistant/components/openai_conversation/const.py | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 3e67d4e27d..6f76142106 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, TemplateError -from homeassistant.helpers import area_registry as ar, intent, template +from homeassistant.helpers import intent, template from homeassistant.util import ulid from .const import ( @@ -138,7 +138,6 @@ class OpenAIAgent(conversation.AbstractConversationAgent): return template.Template(raw_prompt, self.hass).async_render( { "ha_name": self.hass.config.location_name, - "areas": list(ar.async_get(self.hass).areas.values()), }, parse_result=False, ) diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 88289eb90b..46f8603c5f 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -5,13 +5,13 @@ CONF_PROMPT = "prompt" DEFAULT_PROMPT = """This smart home is controlled by Home Assistant. An overview of the areas and the devices in this smart home: -{%- for area in areas %} +{%- for area in areas() %} {%- set area_info = namespace(printed=false) %} - {%- for device in area_devices(area.name) -%} + {%- for device in area_devices(area) -%} {%- if not device_attr(device, "disabled_by") and not device_attr(device, "entry_type") and device_attr(device, "name") %} {%- if not area_info.printed %} -{{ area.name }}: +{{ area_name(area) }}: {%- set area_info.printed = true %} {%- endif %} - {{ device_attr(device, "name") }}{% if device_attr(device, "model") and (device_attr(device, "model") | string) not in (device_attr(device, "name") | string) %} ({{ device_attr(device, "model") }}){% endif %} From 2157a4d0fcb95ea43c688b84db0a7aa8335a149c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Mar 2023 15:16:27 +0200 Subject: [PATCH 1469/2810] Include channel in response to WS thread/list_datasets (#90493) --- .../components/thread/dataset_store.py | 10 +++++++ .../components/thread/websocket_api.py | 1 + tests/components/thread/test_dataset_store.py | 29 +++++++++++++++++++ tests/components/thread/test_websocket_api.py | 3 ++ 4 files changed, 43 insertions(+) diff --git a/homeassistant/components/thread/dataset_store.py b/homeassistant/components/thread/dataset_store.py index ea5a16f90c..786ea55b34 100644 --- a/homeassistant/components/thread/dataset_store.py +++ b/homeassistant/components/thread/dataset_store.py @@ -1,6 +1,7 @@ """Persistently store thread datasets.""" from __future__ import annotations +from contextlib import suppress import dataclasses from datetime import datetime from functools import cached_property @@ -35,6 +36,15 @@ class DatasetEntry: created: datetime = dataclasses.field(default_factory=dt_util.utcnow) id: str = dataclasses.field(default_factory=ulid_util.ulid) + @property + def channel(self) -> int | None: + """Return channel as an integer.""" + if (channel := self.dataset.get(tlv_parser.MeshcopTLVType.CHANNEL)) is None: + return None + with suppress(ValueError): + return int(channel, 16) + return None + @cached_property def dataset(self) -> dict[tlv_parser.MeshcopTLVType, str]: """Return the dataset in dict format.""" diff --git a/homeassistant/components/thread/websocket_api.py b/homeassistant/components/thread/websocket_api.py index 9f9bc3455a..aca0d5e5d9 100644 --- a/homeassistant/components/thread/websocket_api.py +++ b/homeassistant/components/thread/websocket_api.py @@ -144,6 +144,7 @@ async def ws_list_datasets( for dataset in store.datasets.values(): result.append( { + "channel": dataset.channel, "created": dataset.created, "dataset_id": dataset.id, "extended_pan_id": dataset.extended_pan_id, diff --git a/tests/components/thread/test_dataset_store.py b/tests/components/thread/test_dataset_store.py index 581329e860..212db0de06 100644 --- a/tests/components/thread/test_dataset_store.py +++ b/tests/components/thread/test_dataset_store.py @@ -19,6 +19,18 @@ DATASET_1_REORDERED = ( "10445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F801021234" ) +DATASET_1_BAD_CHANNEL = ( + "0E080000000000010000000035060004001FFFE0020811111111222222220708FDAD70BF" + "E5AA15DD051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444656D6F01" + "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" +) + +DATASET_1_NO_CHANNEL = ( + "0E08000000000001000035060004001FFFE0020811111111222222220708FDAD70BF" + "E5AA15DD051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444656D6F01" + "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" +) + async def test_add_invalid_dataset(hass: HomeAssistant) -> None: """Test adding an invalid dataset.""" @@ -109,6 +121,8 @@ async def test_dataset_properties(hass: HomeAssistant) -> None: {"source": "Google", "tlv": DATASET_1}, {"source": "Multipan", "tlv": DATASET_2}, {"source": "🎅", "tlv": DATASET_3}, + {"source": "test1", "tlv": DATASET_1_BAD_CHANNEL}, + {"source": "test2", "tlv": DATASET_1_NO_CHANNEL}, ] for dataset in datasets: @@ -122,25 +136,40 @@ async def test_dataset_properties(hass: HomeAssistant) -> None: dataset_2 = dataset if dataset.source == "🎅": dataset_3 = dataset + if dataset.source == "test1": + dataset_4 = dataset + if dataset.source == "test2": + dataset_5 = dataset dataset = store.async_get(dataset_1.id) assert dataset == dataset_1 + assert dataset.channel == 15 assert dataset.extended_pan_id == "1111111122222222" assert dataset.network_name == "OpenThreadDemo" assert dataset.pan_id == "1234" dataset = store.async_get(dataset_2.id) assert dataset == dataset_2 + assert dataset.channel == 15 assert dataset.extended_pan_id == "1111111122222222" assert dataset.network_name == "HomeAssistant!" assert dataset.pan_id == "1234" dataset = store.async_get(dataset_3.id) assert dataset == dataset_3 + assert dataset.channel == 15 assert dataset.extended_pan_id == "1111111122222222" assert dataset.network_name == "~🐣🐥🐤~" assert dataset.pan_id == "1234" + dataset = store.async_get(dataset_4.id) + assert dataset == dataset_4 + assert dataset.channel is None + + dataset = store.async_get(dataset_5.id) + assert dataset == dataset_5 + assert dataset.channel is None + async def test_load_datasets(hass: HomeAssistant) -> None: """Make sure that we can load/save data correctly.""" diff --git a/tests/components/thread/test_websocket_api.py b/tests/components/thread/test_websocket_api.py index c2e9e5f593..c7bdd78188 100644 --- a/tests/components/thread/test_websocket_api.py +++ b/tests/components/thread/test_websocket_api.py @@ -153,6 +153,7 @@ async def test_list_get_dataset( assert msg["result"] == { "datasets": [ { + "channel": 15, "created": dataset_1.created.isoformat(), "dataset_id": dataset_1.id, "extended_pan_id": "1111111122222222", @@ -162,6 +163,7 @@ async def test_list_get_dataset( "source": "Google", }, { + "channel": 15, "created": dataset_2.created.isoformat(), "dataset_id": dataset_2.id, "extended_pan_id": "1111111122222222", @@ -171,6 +173,7 @@ async def test_list_get_dataset( "source": "Multipan", }, { + "channel": 15, "created": dataset_3.created.isoformat(), "dataset_id": dataset_3.id, "extended_pan_id": "1111111122222222", From 01734c0dab46a55f0bd6f001320fb08077c1b36a Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Thu, 30 Mar 2023 09:14:58 -0400 Subject: [PATCH 1470/2810] Fix for is_hidden_entity when using it in select, selectattr, reject, and rejectattr (#90512) fix --- homeassistant/helpers/template.py | 15 +++++++++++---- tests/helpers/test_template.py | 5 +++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 481a59cee8..36e0a597b8 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -2285,9 +2285,6 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["area_devices"] = hassfunction(area_devices) self.filters["area_devices"] = pass_context(self.globals["area_devices"]) - self.globals["is_hidden_entity"] = hassfunction(is_hidden_entity) - self.tests["is_hidden_entity"] = pass_context(self.globals["is_hidden_entity"]) - self.globals["integration_entities"] = hassfunction(integration_entities) self.filters["integration_entities"] = pass_context( self.globals["integration_entities"] @@ -2308,6 +2305,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): "closest", "distance", "expand", + "is_hidden_entity", "is_state", "is_state_attr", "state_attr", @@ -2331,7 +2329,12 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): "area_name", "has_value", ] - hass_tests = ["has_value"] + hass_tests = [ + "has_value", + "is_hidden_entity", + "is_state", + "is_state_attr", + ] for glob in hass_globals: self.globals[glob] = unsupported(glob) for filt in hass_filters: @@ -2345,6 +2348,10 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["closest"] = hassfunction(closest) self.filters["closest"] = pass_context(hassfunction(closest_filter)) self.globals["distance"] = hassfunction(distance) + self.globals["is_hidden_entity"] = hassfunction(is_hidden_entity) + self.tests["is_hidden_entity"] = pass_eval_context( + self.globals["is_hidden_entity"] + ) self.globals["is_state"] = hassfunction(is_state) self.tests["is_state"] = pass_eval_context(self.globals["is_state"]) self.globals["is_state_attr"] = hassfunction(is_state_attr) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index b381775f1e..f185191d1b 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1463,6 +1463,11 @@ def test_is_hidden_entity( hass, ).async_render() + assert not template.Template( + f"{{{{ ['{visible_entity.entity_id}'] | select('is_hidden_entity') | first }}}}", + hass, + ).async_render() + def test_is_state(hass: HomeAssistant) -> None: """Test is_state method.""" From 576780be74c9a0e4ac2ad2e347afb59a043dd546 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 Mar 2023 09:23:13 -0400 Subject: [PATCH 1471/2810] Unregister webhook when registering webhook with nuki fials (#90514) --- homeassistant/components/nuki/__init__.py | 43 +++++++++++++---------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index 74245d30d4..8a7985fe28 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -25,6 +25,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.network import get_url from homeassistant.helpers.update_coordinator import ( @@ -146,23 +147,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, DOMAIN, entry.title, entry.entry_id, handle_webhook, local_only=True ) - async def _stop_nuki(_: Event): - """Stop and remove the Nuki webhook.""" - webhook.async_unregister(hass, entry.entry_id) - try: - async with async_timeout.timeout(10): - await hass.async_add_executor_job( - _remove_webhook, bridge, entry.entry_id - ) - except InvalidCredentialsException as err: - raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err - except RequestException as err: - raise UpdateFailed(f"Error communicating with Bridge: {err}") from err - - entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_nuki) - ) - webhook_url = webhook.async_generate_path(entry.entry_id) hass_url = get_url( hass, allow_cloud=False, allow_external=False, allow_ip=True, require_ssl=False @@ -174,9 +158,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _register_webhook, bridge, entry.entry_id, url ) except InvalidCredentialsException as err: - raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err + webhook.async_unregister(hass, entry.entry_id) + raise ConfigEntryNotReady(f"Invalid credentials for Bridge: {err}") from err except RequestException as err: - raise UpdateFailed(f"Error communicating with Bridge: {err}") from err + webhook.async_unregister(hass, entry.entry_id) + raise ConfigEntryNotReady(f"Error communicating with Bridge: {err}") from err + + async def _stop_nuki(_: Event): + """Stop and remove the Nuki webhook.""" + webhook.async_unregister(hass, entry.entry_id) + try: + async with async_timeout.timeout(10): + await hass.async_add_executor_job( + _remove_webhook, bridge, entry.entry_id + ) + except InvalidCredentialsException as err: + _LOGGER.error( + "Error unregistering webhook, invalid credentials for bridge: %s", err + ) + except RequestException as err: + _LOGGER.error("Error communicating with bridge: %s", err) + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_nuki) + ) coordinator = NukiCoordinator(hass, bridge, locks, openers) From 4a319c73ab70b5a3079df00951a9e3268a0fd2b2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 30 Mar 2023 16:38:35 +0200 Subject: [PATCH 1472/2810] Add a device to the sun (#90517) --- homeassistant/components/sun/sensor.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/sun/sensor.py b/homeassistant/components/sun/sensor.py index 527ccc4069..8a253566e2 100644 --- a/homeassistant/components/sun/sensor.py +++ b/homeassistant/components/sun/sensor.py @@ -15,6 +15,8 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEGREE from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -126,6 +128,12 @@ class SunSensor(SensorEntity): self._attr_unique_id = f"{entry_id}-{entity_description.key}" self.sun = sun + self._attr_device_info = DeviceInfo( + name="Sun", + identifiers={(DOMAIN, entry_id)}, + entry_type=DeviceEntryType.SERVICE, + ) + @property def native_value(self) -> StateType | datetime: """Return value of sensor.""" From 705e68be9e3253f4740433c00b0f728f1c179f94 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 Mar 2023 10:40:19 -0400 Subject: [PATCH 1473/2810] Bumped version to 2023.4.0b1 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 289f536089..fba1d65459 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -8,7 +8,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 4 -PATCH_VERSION: Final = "0b0" +PATCH_VERSION: Final = "0b1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index e7e82d2ed5..73d680092f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.4.0b0" +version = "2023.4.0b1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From fd55d0f2ddcea0eb1fb24d89501db09ca627d109 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 30 Mar 2023 17:15:12 +0200 Subject: [PATCH 1474/2810] Migrate old ZHA IasZone sensor state to zigpy cache (#90508) * Migrate old ZHA IasZone sensor state to zigpy cache * Use correct type for ZoneStatus * Test that migration happens * Test that migration only happens once * Fix parametrize --- homeassistant/components/zha/binary_sensor.py | 35 ++++++- tests/components/zha/test_binary_sensor.py | 92 +++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index b277b3fe26..4e3c7166bf 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -2,13 +2,16 @@ from __future__ import annotations import functools +from typing import Any + +from zigpy.zcl.clusters.security import IasZone from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, Platform +from homeassistant.const import STATE_ON, EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -164,6 +167,36 @@ class IASZone(BinarySensor): """Parse the raw attribute into a bool state.""" return BinarySensor.parse(value & 3) # use only bit 0 and 1 for alarm state + # temporary code to migrate old IasZone sensors to update attribute cache state once + # remove in 2024.4.0 + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return state attributes.""" + return {"migrated_to_cache": True} # writing new state means we're migrated + + # temporary migration code + @callback + def async_restore_last_state(self, last_state): + """Restore previous state.""" + # trigger migration if extra state attribute is not present + if "migrated_to_cache" not in last_state.attributes: + self.migrate_to_zigpy_cache(last_state) + + # temporary migration code + @callback + def migrate_to_zigpy_cache(self, last_state): + """Save old IasZone sensor state to attribute cache.""" + # previous HA versions did not update the attribute cache for IasZone sensors, so do it once here + # a HA state write is triggered shortly afterwards and writes the "migrated_to_cache" extra state attribute + if last_state.state == STATE_ON: + migrated_state = IasZone.ZoneStatus.Alarm_1 + else: + migrated_state = IasZone.ZoneStatus(0) + + self._channel.cluster.update_attribute( + IasZone.attributes_by_name[self.SENSOR_ATTR].id, migrated_state + ) + @MULTI_MATCH( channel_names="tuya_manufacturer", diff --git a/tests/components/zha/test_binary_sensor.py b/tests/components/zha/test_binary_sensor.py index d633e9173e..ec25295ed5 100644 --- a/tests/components/zha/test_binary_sensor.py +++ b/tests/components/zha/test_binary_sensor.py @@ -8,12 +8,15 @@ import zigpy.zcl.clusters.security as security from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import restore_state +from homeassistant.util import dt as dt_util from .common import ( async_enable_traffic, async_test_rejoin, find_entity_id, send_attributes_report, + update_attribute_cache, ) from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE @@ -120,3 +123,92 @@ async def test_binary_sensor( # test rejoin await async_test_rejoin(hass, zigpy_device, [cluster], reporting) assert hass.states.get(entity_id).state == STATE_OFF + + +@pytest.fixture +def core_rs(hass_storage): + """Core.restore_state fixture.""" + + def _storage(entity_id, attributes, state): + now = dt_util.utcnow().isoformat() + + hass_storage[restore_state.STORAGE_KEY] = { + "version": restore_state.STORAGE_VERSION, + "key": restore_state.STORAGE_KEY, + "data": [ + { + "state": { + "entity_id": entity_id, + "state": str(state), + "attributes": attributes, + "last_changed": now, + "last_updated": now, + "context": { + "id": "3c2243ff5f30447eb12e7348cfd5b8ff", + "user_id": None, + }, + }, + "last_seen": now, + } + ], + } + return + + return _storage + + +@pytest.mark.parametrize( + "restored_state", + [ + STATE_ON, + STATE_OFF, + ], +) +async def test_binary_sensor_migration_not_migrated( + hass: HomeAssistant, + zigpy_device_mock, + core_rs, + zha_device_restored, + restored_state, +) -> None: + """Test temporary ZHA IasZone binary_sensor migration to zigpy cache.""" + + entity_id = "binary_sensor.fakemanufacturer_fakemodel_iaszone" + core_rs(entity_id, state=restored_state, attributes={}) # migration sensor state + + zigpy_device = zigpy_device_mock(DEVICE_IAS) + zha_device = await zha_device_restored(zigpy_device) + entity_id = await find_entity_id(Platform.BINARY_SENSOR, zha_device, hass) + + assert entity_id is not None + assert hass.states.get(entity_id).state == restored_state + + # confirm migration extra state attribute was set to True + assert hass.states.get(entity_id).attributes["migrated_to_cache"] + + +async def test_binary_sensor_migration_already_migrated( + hass: HomeAssistant, + zigpy_device_mock, + core_rs, + zha_device_restored, +) -> None: + """Test temporary ZHA IasZone binary_sensor migration doesn't migrate multiple times.""" + + entity_id = "binary_sensor.fakemanufacturer_fakemodel_iaszone" + core_rs(entity_id, state=STATE_OFF, attributes={"migrated_to_cache": True}) + + zigpy_device = zigpy_device_mock(DEVICE_IAS) + + cluster = zigpy_device.endpoints.get(1).ias_zone + cluster.PLUGGED_ATTR_READS = { + "zone_status": security.IasZone.ZoneStatus.Alarm_1, + } + update_attribute_cache(cluster) + + zha_device = await zha_device_restored(zigpy_device) + entity_id = await find_entity_id(Platform.BINARY_SENSOR, zha_device, hass) + + assert entity_id is not None + assert hass.states.get(entity_id).state == STATE_ON # matches attribute cache + assert hass.states.get(entity_id).attributes["migrated_to_cache"] From 565f311f5c7b7d20adee8e0445ae5ff48eb6cd4a Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Thu, 30 Mar 2023 19:37:03 +0200 Subject: [PATCH 1475/2810] Add EV charging remote services for BMW/Mini (#88759) * Add select for EV charging to bmw_connected_drive * Use snapshot for select tests, split select_option tests * Apply suggestions from code review Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Further adjustments from code review --------- Co-authored-by: rikroe Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../bmw_connected_drive/__init__.py | 1 + .../components/bmw_connected_drive/select.py | 139 + .../bmw_connected_drive/conftest.py | 9 + ...x-crccs_v2_vehicles_WBA00000000DEMO02.json | 80 + .../G26/bmw-eadrax-vcs_v4_vehicles.json | 50 + ...s_v4_vehicles_state_WBA00000000DEMO02.json | 313 ++ .../snapshots/test_diagnostics.ambr | 2914 +++++++++++++++-- .../snapshots/test_select.ambr | 97 + .../bmw_connected_drive/test_select.py | 84 + 9 files changed, 3356 insertions(+), 331 deletions(-) create mode 100644 homeassistant/components/bmw_connected_drive/select.py create mode 100644 tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-crccs_v2_vehicles_WBA00000000DEMO02.json create mode 100644 tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles.json create mode 100644 tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles_state_WBA00000000DEMO02.json create mode 100644 tests/components/bmw_connected_drive/snapshots/test_select.ambr create mode 100644 tests/components/bmw_connected_drive/test_select.py diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index a47f2bed59..e91943034d 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -41,6 +41,7 @@ PLATFORMS = [ Platform.DEVICE_TRACKER, Platform.LOCK, Platform.NOTIFY, + Platform.SELECT, Platform.SENSOR, ] diff --git a/homeassistant/components/bmw_connected_drive/select.py b/homeassistant/components/bmw_connected_drive/select.py new file mode 100644 index 0000000000..e8e8dd5ca4 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/select.py @@ -0,0 +1,139 @@ +"""Select platform for BMW.""" +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any + +from bimmer_connected.vehicle import MyBMWVehicle +from bimmer_connected.vehicle.charging_profile import ChargingMode + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, UnitOfElectricCurrent +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import BMWBaseEntity +from .const import DOMAIN +from .coordinator import BMWDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class BMWRequiredKeysMixin: + """Mixin for required keys.""" + + current_option: Callable[[MyBMWVehicle], str] + remote_service: Callable[[MyBMWVehicle, str], Coroutine[Any, Any, Any]] + + +@dataclass +class BMWSelectEntityDescription(SelectEntityDescription, BMWRequiredKeysMixin): + """Describes BMW sensor entity.""" + + is_available: Callable[[MyBMWVehicle], bool] = lambda _: False + dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None + + +SELECT_TYPES: dict[str, BMWSelectEntityDescription] = { + # --- Generic --- + "target_soc": BMWSelectEntityDescription( + key="target_soc", + name="Target SoC", + is_available=lambda v: v.is_remote_set_target_soc_enabled, + options=[str(i * 5 + 20) for i in range(17)], + current_option=lambda v: str(v.fuel_and_battery.charging_target), + remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update( + target_soc=int(o) + ), + icon="mdi:battery-charging-medium", + unit_of_measurement=PERCENTAGE, + ), + "ac_limit": BMWSelectEntityDescription( + key="ac_limit", + name="AC Charging Limit", + is_available=lambda v: v.is_remote_set_ac_limit_enabled, + dynamic_options=lambda v: [ + str(lim) for lim in v.charging_profile.ac_available_limits # type: ignore[union-attr] + ], + current_option=lambda v: str(v.charging_profile.ac_current_limit), # type: ignore[union-attr] + remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update( + ac_limit=int(o) + ), + icon="mdi:current-ac", + unit_of_measurement=UnitOfElectricCurrent.AMPERE, + ), + "charging_mode": BMWSelectEntityDescription( + key="charging_mode", + name="Charging Mode", + is_available=lambda v: v.is_charging_plan_supported, + options=[c.value for c in ChargingMode if c != ChargingMode.UNKNOWN], + current_option=lambda v: str(v.charging_profile.charging_mode.value), # type: ignore[union-attr] + remote_service=lambda v, o: v.remote_services.trigger_charging_profile_update( + charging_mode=ChargingMode(o) + ), + icon="mdi:vector-point-select", + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the MyBMW lock from config entry.""" + coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[BMWSelect] = [] + + for vehicle in coordinator.account.vehicles: + if not coordinator.read_only: + entities.extend( + [ + BMWSelect(coordinator, vehicle, description) + for description in SELECT_TYPES.values() + if description.is_available(vehicle) + ] + ) + async_add_entities(entities) + + +class BMWSelect(BMWBaseEntity, SelectEntity): + """Representation of BMW select entity.""" + + entity_description: BMWSelectEntityDescription + + def __init__( + self, + coordinator: BMWDataUpdateCoordinator, + vehicle: MyBMWVehicle, + description: BMWSelectEntityDescription, + ) -> None: + """Initialize an BMW select.""" + super().__init__(coordinator, vehicle) + self.entity_description = description + self._attr_unique_id = f"{vehicle.vin}-{description.key}" + if description.dynamic_options: + self._attr_options = description.dynamic_options(vehicle) + self._attr_current_option = description.current_option(vehicle) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + _LOGGER.debug( + "Updating select '%s' of %s", self.entity_description.key, self.vehicle.name + ) + self._attr_current_option = self.entity_description.current_option(self.vehicle) + super()._handle_coordinator_update() + + async def async_select_option(self, option: str) -> None: + """Update to the vehicle.""" + _LOGGER.debug( + "Executing '%s' on vehicle '%s' to value '%s'", + self.entity_description.key, + self.vehicle.vin, + option, + ) + await self.entity_description.remote_service(self.vehicle, option) diff --git a/tests/components/bmw_connected_drive/conftest.py b/tests/components/bmw_connected_drive/conftest.py index 887df4da60..73e8f9a9b9 100644 --- a/tests/components/bmw_connected_drive/conftest.py +++ b/tests/components/bmw_connected_drive/conftest.py @@ -1,6 +1,9 @@ """Fixtures for BMW tests.""" +from unittest.mock import AsyncMock + from bimmer_connected.api.authentication import MyBMWAuthentication +from bimmer_connected.vehicle.remote_services import RemoteServices, RemoteServiceStatus import pytest from . import mock_login, mock_vehicles @@ -11,5 +14,11 @@ async def bmw_fixture(monkeypatch): """Patch the MyBMW Login and mock HTTP calls.""" monkeypatch.setattr(MyBMWAuthentication, "login", mock_login) + monkeypatch.setattr( + RemoteServices, + "trigger_remote_service", + AsyncMock(return_value=RemoteServiceStatus({"eventStatus": "EXECUTED"})), + ) + with mock_vehicles(): yield mock_vehicles diff --git a/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-crccs_v2_vehicles_WBA00000000DEMO02.json b/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-crccs_v2_vehicles_WBA00000000DEMO02.json new file mode 100644 index 0000000000..af850f1ff2 --- /dev/null +++ b/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-crccs_v2_vehicles_WBA00000000DEMO02.json @@ -0,0 +1,80 @@ +{ + "chargeAndClimateSettings": { + "chargeAndClimateTimer": { + "chargingMode": "Sofort laden", + "chargingModeSemantics": "Sofort laden", + "departureTimer": ["Aus"], + "departureTimerSemantics": "Aus", + "preconditionForDeparture": "Aus", + "showDepartureTimers": false + }, + "chargingFlap": { + "permanentlyUnlockLabel": "Aus" + }, + "chargingSettings": { + "acCurrentLimitLabel": "16A", + "acCurrentLimitLabelSemantics": "16 Ampere", + "chargingTargetLabel": "80%", + "dcLoudnessLabel": "Nicht begrenzt", + "unlockCableAutomaticallyLabel": "Aus" + } + }, + "chargeAndClimateTimerDetail": { + "chargingMode": { + "chargingPreference": "NO_PRESELECTION", + "endTimeSlot": "0001-01-01T00:00:00", + "startTimeSlot": "0001-01-01T00:00:00", + "type": "CHARGING_IMMEDIATELY" + }, + "departureTimer": { + "type": "WEEKLY_DEPARTURE_TIMER", + "weeklyTimers": [ + { + "daysOfTheWeek": [], + "id": 1, + "time": "0001-01-01T00:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 2, + "time": "0001-01-01T00:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 3, + "time": "0001-01-01T00:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 4, + "time": "0001-01-01T00:00:00", + "timerAction": "DEACTIVATE" + } + ] + }, + "isPreconditionForDepartureActive": false + }, + "chargingFlapDetail": { + "isPermanentlyUnlock": false + }, + "chargingSettingsDetail": { + "acLimit": { + "current": { + "unit": "A", + "value": 16 + }, + "isUnlimited": false, + "max": 32, + "min": 6, + "values": [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 20, 32] + }, + "chargingTarget": 80, + "dcLoudness": "UNLIMITED_LOUD", + "isUnlockCableActive": false, + "minChargingTargetToWarning": 0 + }, + "servicePack": "WAVE_01" +} diff --git a/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles.json b/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles.json new file mode 100644 index 0000000000..f954fb103a --- /dev/null +++ b/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles.json @@ -0,0 +1,50 @@ +[ + { + "appVehicleType": "DEMO", + "attributes": { + "a4aType": "NOT_SUPPORTED", + "bodyType": "G26", + "brand": "BMW", + "color": 4284245350, + "countryOfOrigin": "DE", + "driveTrain": "ELECTRIC", + "driverGuideInfo": { + "androidAppScheme": "com.bmwgroup.driversguide.row", + "androidStoreUrl": "https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row", + "iosAppScheme": "bmwdriversguide:///open", + "iosStoreUrl": "https://apps.apple.com/de/app/id714042749?mt=8" + }, + "headUnitRaw": "HU_MGU", + "headUnitType": "MGU", + "hmiVersion": "ID8", + "lastFetched": "2023-01-04T14:57:06.019Z", + "model": "i4 eDrive40", + "softwareVersionCurrent": { + "iStep": 470, + "puStep": { + "month": 11, + "year": 21 + }, + "seriesCluster": "G026" + }, + "softwareVersionExFactory": { + "iStep": 470, + "puStep": { + "month": 11, + "year": 21 + }, + "seriesCluster": "G026" + }, + "telematicsUnit": "WAVE01", + "year": 2021 + }, + "mappingInfo": { + "isAssociated": false, + "isLmmEnabled": false, + "isPrimaryUser": true, + "lmmStatusReasons": [], + "mappingStatus": "CONFIRMED" + }, + "vin": "WBA00000000DEMO02" + } +] diff --git a/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles_state_WBA00000000DEMO02.json b/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles_state_WBA00000000DEMO02.json new file mode 100644 index 0000000000..8a0be88edf --- /dev/null +++ b/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles_state_WBA00000000DEMO02.json @@ -0,0 +1,313 @@ +{ + "capabilities": { + "a4aType": "NOT_SUPPORTED", + "checkSustainabilityDPP": false, + "climateFunction": "AIR_CONDITIONING", + "climateNow": true, + "digitalKey": { + "bookedServicePackage": "SMACC_1_5", + "readerGraphics": "readerGraphics", + "state": "ACTIVATED" + }, + "horn": true, + "isBmwChargingSupported": true, + "isCarSharingSupported": false, + "isChargeNowForBusinessSupported": true, + "isChargingHistorySupported": true, + "isChargingHospitalityEnabled": true, + "isChargingLoudnessEnabled": true, + "isChargingPlanSupported": true, + "isChargingPowerLimitEnabled": true, + "isChargingSettingsEnabled": true, + "isChargingTargetSocEnabled": true, + "isClimateTimerWeeklyActive": false, + "isCustomerEsimSupported": true, + "isDCSContractManagementSupported": true, + "isDataPrivacyEnabled": false, + "isEasyChargeEnabled": true, + "isEvGoChargingSupported": false, + "isMiniChargingSupported": false, + "isNonLscFeatureEnabled": false, + "isPersonalPictureUploadSupported": false, + "isRemoteEngineStartSupported": false, + "isRemoteHistoryDeletionSupported": false, + "isRemoteHistorySupported": true, + "isRemoteParkingSupported": false, + "isRemoteServicesActivationRequired": false, + "isRemoteServicesBookingRequired": false, + "isScanAndChargeSupported": true, + "isSustainabilityAccumulatedViewEnabled": false, + "isSustainabilitySupported": false, + "isWifiHotspotServiceSupported": false, + "lastStateCallState": "ACTIVATED", + "lights": true, + "lock": true, + "remote360": true, + "remoteChargingCommands": {}, + "remoteSoftwareUpgrade": true, + "sendPoi": true, + "specialThemeSupport": [], + "speechThirdPartyAlexa": false, + "speechThirdPartyAlexaSDK": false, + "unlock": true, + "vehicleFinder": true, + "vehicleStateSource": "LAST_STATE_CALL" + }, + "state": { + "chargingProfile": { + "chargingControlType": "WEEKLY_PLANNER", + "chargingMode": "IMMEDIATE_CHARGING", + "chargingPreference": "NO_PRESELECTION", + "chargingSettings": { + "acCurrentLimit": 16, + "hospitality": "NO_ACTION", + "idcc": "UNLIMITED_LOUD", + "targetSoc": 80 + }, + "departureTimes": [ + { + "action": "DEACTIVATE", + "id": 1, + "timeStamp": { + "hour": 0, + "minute": 0 + }, + "timerWeekDays": [] + }, + { + "action": "DEACTIVATE", + "id": 2, + "timeStamp": { + "hour": 0, + "minute": 0 + }, + "timerWeekDays": [] + }, + { + "action": "DEACTIVATE", + "id": 3, + "timeStamp": { + "hour": 0, + "minute": 0 + }, + "timerWeekDays": [] + }, + { + "action": "DEACTIVATE", + "id": 4, + "timeStamp": { + "hour": 0, + "minute": 0 + }, + "timerWeekDays": [] + } + ] + }, + "checkControlMessages": [ + { + "severity": "LOW", + "type": "TIRE_PRESSURE" + } + ], + "climateControlState": { + "activity": "STANDBY" + }, + "climateTimers": [ + { + "departureTime": { + "hour": 0, + "minute": 0 + }, + "isWeeklyTimer": false, + "timerAction": "DEACTIVATE", + "timerWeekDays": [] + }, + { + "departureTime": { + "hour": 0, + "minute": 0 + }, + "isWeeklyTimer": true, + "timerAction": "DEACTIVATE", + "timerWeekDays": [] + }, + { + "departureTime": { + "hour": 0, + "minute": 0 + }, + "isWeeklyTimer": true, + "timerAction": "DEACTIVATE", + "timerWeekDays": [] + } + ], + "combustionFuelLevel": {}, + "currentMileage": 1121, + "doorsState": { + "combinedSecurityState": "LOCKED", + "combinedState": "CLOSED", + "hood": "CLOSED", + "leftFront": "CLOSED", + "leftRear": "CLOSED", + "rightFront": "CLOSED", + "rightRear": "CLOSED", + "trunk": "CLOSED" + }, + "driverPreferences": { + "lscPrivacyMode": "OFF" + }, + "electricChargingState": { + "chargingConnectionType": "UNKNOWN", + "chargingLevelPercent": 80, + "chargingStatus": "INVALID", + "chargingTarget": 80, + "isChargerConnected": false, + "range": 472, + "remainingChargingMinutes": 10 + }, + "isLeftSteering": true, + "isLscSupported": true, + "lastFetched": "2023-01-04T14:57:06.386Z", + "lastUpdatedAt": "2023-01-04T14:57:06.407Z", + "location": { + "address": { + "formatted": "Am Olympiapark 1, 80809 München" + }, + "coordinates": { + "latitude": 48.177334, + "longitude": 11.556274 + }, + "heading": 180 + }, + "range": 472, + "requiredServices": [ + { + "dateTime": "2024-12-01T00:00:00.000Z", + "description": "", + "mileage": 50000, + "status": "OK", + "type": "BRAKE_FLUID" + }, + { + "dateTime": "2024-12-01T00:00:00.000Z", + "description": "", + "mileage": 50000, + "status": "OK", + "type": "VEHICLE_TUV" + }, + { + "dateTime": "2024-12-01T00:00:00.000Z", + "description": "", + "mileage": 50000, + "status": "OK", + "type": "VEHICLE_CHECK" + }, + { + "status": "OK", + "type": "TIRE_WEAR_REAR" + }, + { + "status": "OK", + "type": "TIRE_WEAR_FRONT" + } + ], + "tireState": { + "frontLeft": { + "details": { + "dimension": "225/35 R20 90Y XL", + "isOptimizedForOemBmw": true, + "manufacturer": "Pirelli", + "manufacturingWeek": 4021, + "mountingDate": "2022-03-07T00:00:00.000Z", + "partNumber": "2461756", + "season": 2, + "speedClassification": { + "atLeast": false, + "speedRating": 300 + }, + "treadDesign": "P-ZERO" + }, + "status": { + "currentPressure": 241, + "pressureStatus": 0, + "targetPressure": 269, + "wearStatus": 0 + } + }, + "frontRight": { + "details": { + "dimension": "225/35 R20 90Y XL", + "isOptimizedForOemBmw": true, + "manufacturer": "Pirelli", + "manufacturingWeek": 2419, + "mountingDate": "2022-03-07T00:00:00.000Z", + "partNumber": "2461756", + "season": 2, + "speedClassification": { + "atLeast": false, + "speedRating": 300 + }, + "treadDesign": "P-ZERO" + }, + "status": { + "currentPressure": 255, + "pressureStatus": 0, + "targetPressure": 269, + "wearStatus": 0 + } + }, + "rearLeft": { + "details": { + "dimension": "255/30 R20 92Y XL", + "isOptimizedForOemBmw": true, + "manufacturer": "Pirelli", + "manufacturingWeek": 1219, + "mountingDate": "2022-03-07T00:00:00.000Z", + "partNumber": "2461757", + "season": 2, + "speedClassification": { + "atLeast": false, + "speedRating": 300 + }, + "treadDesign": "P-ZERO" + }, + "status": { + "currentPressure": 324, + "pressureStatus": 0, + "targetPressure": 303, + "wearStatus": 0 + } + }, + "rearRight": { + "details": { + "dimension": "255/30 R20 92Y XL", + "isOptimizedForOemBmw": true, + "manufacturer": "Pirelli", + "manufacturingWeek": 1219, + "mountingDate": "2022-03-07T00:00:00.000Z", + "partNumber": "2461757", + "season": 2, + "speedClassification": { + "atLeast": false, + "speedRating": 300 + }, + "treadDesign": "P-ZERO" + }, + "status": { + "currentPressure": 331, + "pressureStatus": 0, + "targetPressure": 303, + "wearStatus": 0 + } + } + }, + "windowsState": { + "combinedState": "CLOSED", + "leftFront": "CLOSED", + "leftRear": "CLOSED", + "rear": "CLOSED", + "rightFront": "CLOSED", + "rightRear": "CLOSED" + } + } +} diff --git a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr index 349706f593..2cd6622d14 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr @@ -2,6 +2,824 @@ # name: test_config_entry_diagnostics dict({ 'data': list([ + dict({ + 'available_attributes': list([ + 'gps_position', + 'vin', + 'remaining_range_total', + 'mileage', + 'charging_time_remaining', + 'charging_start_time', + 'charging_end_time', + 'charging_time_label', + 'charging_status', + 'connection_status', + 'remaining_battery_percent', + 'remaining_range_electric', + 'last_charging_end_result', + 'ac_current_limit', + 'charging_target', + 'charging_mode', + 'charging_preferences', + 'is_pre_entry_climatization_enabled', + 'condition_based_services', + 'check_control_messages', + 'door_lock_state', + 'timestamp', + 'lids', + 'windows', + ]), + 'brand': 'bmw', + 'charging_profile': dict({ + 'ac_available_limits': list([ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 20, + 32, + ]), + 'ac_current_limit': 16, + 'charging_mode': 'IMMEDIATE_CHARGING', + 'charging_preferences': 'NO_PRESELECTION', + 'charging_preferences_service_pack': 'WAVE_01', + 'departure_times': list([ + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '00:00:00', + 'timer_id': 1, + 'weekdays': list([ + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '00:00:00', + 'timer_id': 2, + 'weekdays': list([ + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '00:00:00', + 'timer_id': 3, + 'weekdays': list([ + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '00:00:00', + 'timer_id': 4, + 'weekdays': list([ + ]), + }), + ]), + 'is_pre_entry_climatization_enabled': False, + 'preferred_charging_window': dict({ + '_window_dict': dict({ + }), + 'end_time': '00:00:00', + 'start_time': '00:00:00', + }), + 'timer_type': 'WEEKLY_PLANNER', + }), + 'check_control_messages': dict({ + 'has_check_control_messages': False, + 'messages': list([ + dict({ + 'description_long': None, + 'description_short': 'TIRE_PRESSURE', + 'state': 'LOW', + }), + ]), + }), + 'condition_based_services': dict({ + 'is_service_required': False, + 'messages': list([ + dict({ + 'due_date': '2024-12-01T00:00:00+00:00', + 'due_distance': list([ + 50000, + 'km', + ]), + 'service_type': 'BRAKE_FLUID', + 'state': 'OK', + }), + dict({ + 'due_date': '2024-12-01T00:00:00+00:00', + 'due_distance': list([ + 50000, + 'km', + ]), + 'service_type': 'VEHICLE_TUV', + 'state': 'OK', + }), + dict({ + 'due_date': '2024-12-01T00:00:00+00:00', + 'due_distance': list([ + 50000, + 'km', + ]), + 'service_type': 'VEHICLE_CHECK', + 'state': 'OK', + }), + dict({ + 'due_date': None, + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'TIRE_WEAR_REAR', + 'state': 'OK', + }), + dict({ + 'due_date': None, + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'TIRE_WEAR_FRONT', + 'state': 'OK', + }), + ]), + }), + 'data': dict({ + 'appVehicleType': 'DEMO', + 'attributes': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'bodyType': 'G26', + 'brand': 'BMW', + 'color': 4284245350, + 'countryOfOrigin': 'DE', + 'driveTrain': 'ELECTRIC', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitRaw': 'HU_MGU', + 'headUnitType': 'MGU', + 'hmiVersion': 'ID8', + 'lastFetched': '2023-01-04T14:57:06.019Z', + 'model': 'i4 eDrive40', + 'softwareVersionCurrent': dict({ + 'iStep': 470, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'G026', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 470, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'G026', + }), + 'telematicsUnit': 'WAVE01', + 'year': 2021, + }), + 'capabilities': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'checkSustainabilityDPP': False, + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'digitalKey': dict({ + 'bookedServicePackage': 'SMACC_1_5', + 'readerGraphics': 'readerGraphics', + 'state': 'ACTIVATED', + }), + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': True, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': True, + 'isChargingLoudnessEnabled': True, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': True, + 'isChargingSettingsEnabled': True, + 'isChargingTargetSocEnabled': True, + 'isClimateTimerWeeklyActive': False, + 'isCustomerEsimSupported': True, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': True, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isPersonalPictureUploadSupported': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': True, + 'isSustainabilityAccumulatedViewEnabled': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remote360': True, + 'remoteChargingCommands': dict({ + }), + 'remoteSoftwareUpgrade': True, + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'speechThirdPartyAlexa': False, + 'speechThirdPartyAlexaSDK': False, + 'unlock': True, + 'vehicleFinder': True, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'charging_settings': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'chargingMode': 'Sofort laden', + 'chargingModeSemantics': 'Sofort laden', + 'departureTimer': list([ + 'Aus', + ]), + 'departureTimerSemantics': 'Aus', + 'preconditionForDeparture': 'Aus', + 'showDepartureTimers': False, + }), + 'chargingFlap': dict({ + 'permanentlyUnlockLabel': 'Aus', + }), + 'chargingSettings': dict({ + 'acCurrentLimitLabel': '16A', + 'acCurrentLimitLabelSemantics': '16 Ampere', + 'chargingTargetLabel': '80%', + 'dcLoudnessLabel': 'Nicht begrenzt', + 'unlockCableAutomaticallyLabel': 'Aus', + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'NO_PRESELECTION', + 'endTimeSlot': '0001-01-01T00:00:00', + 'startTimeSlot': '0001-01-01T00:00:00', + 'type': 'CHARGING_IMMEDIATELY', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 1, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 2, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'chargingFlapDetail': dict({ + 'isPermanentlyUnlock': False, + }), + 'chargingSettingsDetail': dict({ + 'acLimit': dict({ + 'current': dict({ + 'unit': 'A', + 'value': 16, + }), + 'isUnlimited': False, + 'max': 32, + 'min': 6, + 'values': list([ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 20, + 32, + ]), + }), + 'chargingTarget': 80, + 'dcLoudness': 'UNLIMITED_LOUD', + 'isUnlockCableActive': False, + 'minChargingTargetToWarning': 0, + }), + 'servicePack': 'WAVE_01', + }), + 'fetched_at': '2022-07-10T11:00:00+00:00', + 'is_metric': True, + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'lmmStatusReasons': list([ + ]), + 'mappingStatus': 'CONFIRMED', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'IMMEDIATE_CHARGING', + 'chargingPreference': 'NO_PRESELECTION', + 'chargingSettings': dict({ + 'acCurrentLimit': 16, + 'hospitality': 'NO_ACTION', + 'idcc': 'UNLIMITED_LOUD', + 'targetSoc': 80, + }), + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + ]), + }), + 'checkControlMessages': list([ + dict({ + 'severity': 'LOW', + 'type': 'TIRE_PRESSURE', + }), + ]), + 'climateControlState': dict({ + 'activity': 'STANDBY', + }), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': False, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + ]), + 'combustionFuelLevel': dict({ + }), + 'currentMileage': 1121, + 'doorsState': dict({ + 'combinedSecurityState': 'LOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'UNKNOWN', + 'chargingLevelPercent': 80, + 'chargingStatus': 'INVALID', + 'chargingTarget': 80, + 'isChargerConnected': False, + 'range': 472, + 'remainingChargingMinutes': 10, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2023-01-04T14:57:06.386Z', + 'lastUpdatedAt': '2023-01-04T14:57:06.407Z', + 'location': dict({ + 'address': dict({ + 'formatted': '**REDACTED**', + }), + 'coordinates': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'heading': '**REDACTED**', + }), + 'range': 472, + 'requiredServices': list([ + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_REAR', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_FRONT', + }), + ]), + 'tireState': dict({ + 'frontLeft': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 241, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'frontRight': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 2419, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 255, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'rearLeft': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 324, + 'pressureStatus': 0, + 'targetPressure': 303, + 'wearStatus': 0, + }), + }), + 'rearRight': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 331, + 'pressureStatus': 0, + 'targetPressure': 303, + 'wearStatus': 0, + }), + }), + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + }), + }), + 'vin': '**REDACTED**', + }), + 'doors_and_windows': dict({ + 'all_lids_closed': True, + 'all_windows_closed': True, + 'door_lock_state': 'LOCKED', + 'lids': list([ + dict({ + 'is_closed': True, + 'name': 'hood', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'leftFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'leftRear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightRear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'trunk', + 'state': 'CLOSED', + }), + ]), + 'open_lids': list([ + ]), + 'open_windows': list([ + ]), + 'windows': list([ + dict({ + 'is_closed': True, + 'name': 'leftFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'leftRear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightRear', + 'state': 'CLOSED', + }), + ]), + }), + 'drive_train': 'ELECTRIC', + 'drive_train_attributes': list([ + 'remaining_range_total', + 'mileage', + 'charging_time_remaining', + 'charging_start_time', + 'charging_end_time', + 'charging_time_label', + 'charging_status', + 'connection_status', + 'remaining_battery_percent', + 'remaining_range_electric', + 'last_charging_end_result', + 'ac_current_limit', + 'charging_target', + 'charging_mode', + 'charging_preferences', + 'is_pre_entry_climatization_enabled', + ]), + 'fuel_and_battery': dict({ + 'account_timezone': dict({ + '_dst_offset': '0:00:00', + '_dst_saved': '0:00:00', + '_hasdst': False, + '_std_offset': '0:00:00', + '_tznames': list([ + 'UTC', + 'UTC', + ]), + }), + 'charging_end_time': '2022-07-10T11:10:00+00:00', + 'charging_start_time': None, + 'charging_start_time_no_tz': None, + 'charging_status': 'NOT_CHARGING', + 'charging_target': 80, + 'is_charger_connected': False, + 'remaining_battery_percent': 80, + 'remaining_fuel': list([ + None, + None, + ]), + 'remaining_fuel_percent': None, + 'remaining_range_electric': list([ + 472, + 'km', + ]), + 'remaining_range_fuel': list([ + None, + None, + ]), + 'remaining_range_total': list([ + 472, + 'km', + ]), + }), + 'has_combustion_drivetrain': False, + 'has_electric_drivetrain': True, + 'is_charging_plan_supported': True, + 'is_lsc_enabled': True, + 'is_remote_charge_start_enabled': False, + 'is_remote_charge_stop_enabled': False, + 'is_remote_climate_start_enabled': True, + 'is_remote_climate_stop_enabled': True, + 'is_remote_horn_enabled': True, + 'is_remote_lights_enabled': True, + 'is_remote_lock_enabled': True, + 'is_remote_sendpoi_enabled': True, + 'is_remote_set_ac_limit_enabled': True, + 'is_remote_set_target_soc_enabled': True, + 'is_remote_unlock_enabled': True, + 'is_vehicle_active': False, + 'is_vehicle_tracking_enabled': True, + 'lsc_type': 'ACTIVATED', + 'mileage': list([ + 1121, + 'km', + ]), + 'name': 'i4 eDrive40', + 'timestamp': '2023-01-04T14:57:06+00:00', + 'vehicle_location': dict({ + 'account_region': 'row', + 'heading': '**REDACTED**', + 'location': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'remote_service_position': None, + 'vehicle_update_timestamp': '2023-01-04T14:57:06+00:00', + }), + 'vin': '**REDACTED**', + }), dict({ 'available_attributes': list([ 'gps_position', @@ -661,6 +1479,55 @@ 'fingerprint': list([ dict({ 'content': list([ + dict({ + 'appVehicleType': 'DEMO', + 'attributes': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'bodyType': 'G26', + 'brand': 'BMW', + 'color': 4284245350, + 'countryOfOrigin': 'DE', + 'driveTrain': 'ELECTRIC', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitRaw': 'HU_MGU', + 'headUnitType': 'MGU', + 'hmiVersion': 'ID8', + 'lastFetched': '2023-01-04T14:57:06.019Z', + 'model': 'i4 eDrive40', + 'softwareVersionCurrent': dict({ + 'iStep': 470, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'G026', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 470, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'G026', + }), + 'telematicsUnit': 'WAVE01', + 'year': 2021, + }), + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'lmmStatusReasons': list([ + ]), + 'mappingStatus': 'CONFIRMED', + }), + 'vin': '**REDACTED**', + }), dict({ 'appVehicleType': 'CONNECTED', 'attributes': dict({ @@ -714,6 +1581,435 @@ ]), 'filename': 'mini-eadrax-vcs_v4_vehicles.json', }), + dict({ + 'content': dict({ + 'capabilities': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'checkSustainabilityDPP': False, + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'digitalKey': dict({ + 'bookedServicePackage': 'SMACC_1_5', + 'readerGraphics': 'readerGraphics', + 'state': 'ACTIVATED', + }), + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': True, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': True, + 'isChargingLoudnessEnabled': True, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': True, + 'isChargingSettingsEnabled': True, + 'isChargingTargetSocEnabled': True, + 'isClimateTimerWeeklyActive': False, + 'isCustomerEsimSupported': True, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': True, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isPersonalPictureUploadSupported': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': True, + 'isSustainabilityAccumulatedViewEnabled': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remote360': True, + 'remoteChargingCommands': dict({ + }), + 'remoteSoftwareUpgrade': True, + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'speechThirdPartyAlexa': False, + 'speechThirdPartyAlexaSDK': False, + 'unlock': True, + 'vehicleFinder': True, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'IMMEDIATE_CHARGING', + 'chargingPreference': 'NO_PRESELECTION', + 'chargingSettings': dict({ + 'acCurrentLimit': 16, + 'hospitality': 'NO_ACTION', + 'idcc': 'UNLIMITED_LOUD', + 'targetSoc': 80, + }), + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + ]), + }), + 'checkControlMessages': list([ + dict({ + 'severity': 'LOW', + 'type': 'TIRE_PRESSURE', + }), + ]), + 'climateControlState': dict({ + 'activity': 'STANDBY', + }), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': False, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + ]), + 'combustionFuelLevel': dict({ + }), + 'currentMileage': 1121, + 'doorsState': dict({ + 'combinedSecurityState': 'LOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'UNKNOWN', + 'chargingLevelPercent': 80, + 'chargingStatus': 'INVALID', + 'chargingTarget': 80, + 'isChargerConnected': False, + 'range': 472, + 'remainingChargingMinutes': 10, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2023-01-04T14:57:06.386Z', + 'lastUpdatedAt': '2023-01-04T14:57:06.407Z', + 'location': dict({ + 'address': dict({ + 'formatted': '**REDACTED**', + }), + 'coordinates': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'heading': '**REDACTED**', + }), + 'range': 472, + 'requiredServices': list([ + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_REAR', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_FRONT', + }), + ]), + 'tireState': dict({ + 'frontLeft': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 241, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'frontRight': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 2419, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 255, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'rearLeft': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 324, + 'pressureStatus': 0, + 'targetPressure': 303, + 'wearStatus': 0, + }), + }), + 'rearRight': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 331, + 'pressureStatus': 0, + 'targetPressure': 303, + 'wearStatus': 0, + }), + }), + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + }), + }), + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBA0FINGERPRINT01.json', + }), + dict({ + 'content': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'chargingMode': 'Sofort laden', + 'chargingModeSemantics': 'Sofort laden', + 'departureTimer': list([ + 'Aus', + ]), + 'departureTimerSemantics': 'Aus', + 'preconditionForDeparture': 'Aus', + 'showDepartureTimers': False, + }), + 'chargingFlap': dict({ + 'permanentlyUnlockLabel': 'Aus', + }), + 'chargingSettings': dict({ + 'acCurrentLimitLabel': '16A', + 'acCurrentLimitLabelSemantics': '16 Ampere', + 'chargingTargetLabel': '80%', + 'dcLoudnessLabel': 'Nicht begrenzt', + 'unlockCableAutomaticallyLabel': 'Aus', + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'NO_PRESELECTION', + 'endTimeSlot': '0001-01-01T00:00:00', + 'startTimeSlot': '0001-01-01T00:00:00', + 'type': 'CHARGING_IMMEDIATELY', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 1, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 2, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'chargingFlapDetail': dict({ + 'isPermanentlyUnlock': False, + }), + 'chargingSettingsDetail': dict({ + 'acLimit': dict({ + 'current': dict({ + 'unit': 'A', + 'value': 16, + }), + 'isUnlimited': False, + 'max': 32, + 'min': 6, + 'values': list([ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 20, + 32, + ]), + }), + 'chargingTarget': 80, + 'dcLoudness': 'UNLIMITED_LOUD', + 'isUnlockCableActive': False, + 'minChargingTargetToWarning': 0, + }), + 'servicePack': 'WAVE_01', + }), + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBA0FINGERPRINT01.json', + }), dict({ 'content': dict({ 'capabilities': dict({ @@ -933,7 +2229,7 @@ }), }), }), - 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT01.json', + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT02.json', }), dict({ 'content': dict({ @@ -998,7 +2294,7 @@ }), 'servicePack': 'TCB1', }), - 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT01.json', + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT02.json', }), ]), 'info': dict({ @@ -1670,359 +2966,54 @@ dict({ 'content': list([ dict({ - 'appVehicleType': 'CONNECTED', + 'appVehicleType': 'DEMO', 'attributes': dict({ - 'a4aType': 'USB_ONLY', - 'bodyType': 'I01', - 'brand': 'BMW_I', - 'color': 4284110934, - 'countryOfOrigin': 'CZ', - 'driveTrain': 'ELECTRIC_WITH_RANGE_EXTENDER', + 'a4aType': 'NOT_SUPPORTED', + 'bodyType': 'G26', + 'brand': 'BMW', + 'color': 4284245350, + 'countryOfOrigin': 'DE', + 'driveTrain': 'ELECTRIC', 'driverGuideInfo': dict({ 'androidAppScheme': 'com.bmwgroup.driversguide.row', 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', 'iosAppScheme': 'bmwdriversguide:///open', 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', }), - 'headUnitType': 'NBT', - 'hmiVersion': 'ID4', - 'lastFetched': '2022-07-10T09:25:53.104Z', - 'model': 'i3 (+ REX)', + 'headUnitRaw': 'HU_MGU', + 'headUnitType': 'MGU', + 'hmiVersion': 'ID8', + 'lastFetched': '2023-01-04T14:57:06.019Z', + 'model': 'i4 eDrive40', 'softwareVersionCurrent': dict({ - 'iStep': 510, + 'iStep': 470, 'puStep': dict({ 'month': 11, 'year': 21, }), - 'seriesCluster': 'I001', + 'seriesCluster': 'G026', }), 'softwareVersionExFactory': dict({ - 'iStep': 502, + 'iStep': 470, 'puStep': dict({ - 'month': 3, - 'year': 15, + 'month': 11, + 'year': 21, }), - 'seriesCluster': 'I001', + 'seriesCluster': 'G026', }), - 'year': 2015, + 'telematicsUnit': 'WAVE01', + 'year': 2021, }), 'mappingInfo': dict({ 'isAssociated': False, 'isLmmEnabled': False, 'isPrimaryUser': True, + 'lmmStatusReasons': list([ + ]), 'mappingStatus': 'CONFIRMED', }), 'vin': '**REDACTED**', }), - ]), - 'filename': 'bmw-eadrax-vcs_v4_vehicles.json', - }), - dict({ - 'content': list([ - ]), - 'filename': 'mini-eadrax-vcs_v4_vehicles.json', - }), - dict({ - 'content': dict({ - 'capabilities': dict({ - 'climateFunction': 'AIR_CONDITIONING', - 'climateNow': True, - 'climateTimerTrigger': 'DEPARTURE_TIMER', - 'horn': True, - 'isBmwChargingSupported': True, - 'isCarSharingSupported': False, - 'isChargeNowForBusinessSupported': False, - 'isChargingHistorySupported': True, - 'isChargingHospitalityEnabled': False, - 'isChargingLoudnessEnabled': False, - 'isChargingPlanSupported': True, - 'isChargingPowerLimitEnabled': False, - 'isChargingSettingsEnabled': False, - 'isChargingTargetSocEnabled': False, - 'isClimateTimerSupported': True, - 'isCustomerEsimSupported': False, - 'isDCSContractManagementSupported': True, - 'isDataPrivacyEnabled': False, - 'isEasyChargeEnabled': False, - 'isEvGoChargingSupported': False, - 'isMiniChargingSupported': False, - 'isNonLscFeatureEnabled': False, - 'isRemoteEngineStartSupported': False, - 'isRemoteHistoryDeletionSupported': False, - 'isRemoteHistorySupported': True, - 'isRemoteParkingSupported': False, - 'isRemoteServicesActivationRequired': False, - 'isRemoteServicesBookingRequired': False, - 'isScanAndChargeSupported': False, - 'isSustainabilitySupported': False, - 'isWifiHotspotServiceSupported': False, - 'lastStateCallState': 'ACTIVATED', - 'lights': True, - 'lock': True, - 'remoteChargingCommands': dict({ - }), - 'sendPoi': True, - 'specialThemeSupport': list([ - ]), - 'unlock': True, - 'vehicleFinder': False, - 'vehicleStateSource': 'LAST_STATE_CALL', - }), - 'state': dict({ - 'chargingProfile': dict({ - 'chargingControlType': 'WEEKLY_PLANNER', - 'chargingMode': 'DELAYED_CHARGING', - 'chargingPreference': 'CHARGING_WINDOW', - 'chargingSettings': dict({ - 'hospitality': 'NO_ACTION', - 'idcc': 'NO_ACTION', - 'targetSoc': 100, - }), - 'climatisationOn': False, - 'departureTimes': list([ - dict({ - 'action': 'DEACTIVATE', - 'id': 1, - 'timeStamp': dict({ - 'hour': 7, - 'minute': 35, - }), - 'timerWeekDays': list([ - 'MONDAY', - 'TUESDAY', - 'WEDNESDAY', - 'THURSDAY', - 'FRIDAY', - ]), - }), - dict({ - 'action': 'DEACTIVATE', - 'id': 2, - 'timeStamp': dict({ - 'hour': 18, - 'minute': 0, - }), - 'timerWeekDays': list([ - 'MONDAY', - 'TUESDAY', - 'WEDNESDAY', - 'THURSDAY', - 'FRIDAY', - 'SATURDAY', - 'SUNDAY', - ]), - }), - dict({ - 'action': 'DEACTIVATE', - 'id': 3, - 'timeStamp': dict({ - 'hour': 7, - 'minute': 0, - }), - 'timerWeekDays': list([ - ]), - }), - dict({ - 'action': 'DEACTIVATE', - 'id': 4, - 'timerWeekDays': list([ - ]), - }), - ]), - 'reductionOfChargeCurrent': dict({ - 'end': dict({ - 'hour': 1, - 'minute': 30, - }), - 'start': dict({ - 'hour': 18, - 'minute': 1, - }), - }), - }), - 'checkControlMessages': list([ - ]), - 'climateTimers': list([ - dict({ - 'departureTime': dict({ - 'hour': 6, - 'minute': 40, - }), - 'isWeeklyTimer': True, - 'timerAction': 'ACTIVATE', - 'timerWeekDays': list([ - 'THURSDAY', - 'SUNDAY', - ]), - }), - dict({ - 'departureTime': dict({ - 'hour': 12, - 'minute': 50, - }), - 'isWeeklyTimer': False, - 'timerAction': 'ACTIVATE', - 'timerWeekDays': list([ - 'MONDAY', - ]), - }), - dict({ - 'departureTime': dict({ - 'hour': 18, - 'minute': 59, - }), - 'isWeeklyTimer': True, - 'timerAction': 'DEACTIVATE', - 'timerWeekDays': list([ - 'WEDNESDAY', - ]), - }), - ]), - 'combustionFuelLevel': dict({ - 'range': 105, - 'remainingFuelLiters': 6, - 'remainingFuelPercent': 65, - }), - 'currentMileage': 137009, - 'doorsState': dict({ - 'combinedSecurityState': 'UNLOCKED', - 'combinedState': 'CLOSED', - 'hood': 'CLOSED', - 'leftFront': 'CLOSED', - 'leftRear': 'CLOSED', - 'rightFront': 'CLOSED', - 'rightRear': 'CLOSED', - 'trunk': 'CLOSED', - }), - 'driverPreferences': dict({ - 'lscPrivacyMode': 'OFF', - }), - 'electricChargingState': dict({ - 'chargingConnectionType': 'CONDUCTIVE', - 'chargingLevelPercent': 82, - 'chargingStatus': 'WAITING_FOR_CHARGING', - 'chargingTarget': 100, - 'isChargerConnected': True, - 'range': 174, - }), - 'isLeftSteering': True, - 'isLscSupported': True, - 'lastFetched': '2022-06-22T14:24:23.982Z', - 'lastUpdatedAt': '2022-06-22T13:58:52Z', - 'range': 174, - 'requiredServices': list([ - dict({ - 'dateTime': '2022-10-01T00:00:00.000Z', - 'description': 'Next service due by the specified date.', - 'status': 'OK', - 'type': 'BRAKE_FLUID', - }), - dict({ - 'dateTime': '2023-05-01T00:00:00.000Z', - 'description': 'Next vehicle check due after the specified distance or date.', - 'status': 'OK', - 'type': 'VEHICLE_CHECK', - }), - dict({ - 'dateTime': '2023-05-01T00:00:00.000Z', - 'description': 'Next state inspection due by the specified date.', - 'status': 'OK', - 'type': 'VEHICLE_TUV', - }), - ]), - 'roofState': dict({ - 'roofState': 'CLOSED', - 'roofStateType': 'SUN_ROOF', - }), - 'windowsState': dict({ - 'combinedState': 'CLOSED', - 'leftFront': 'CLOSED', - 'rightFront': 'CLOSED', - }), - }), - }), - 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT01.json', - }), - dict({ - 'content': dict({ - 'chargeAndClimateSettings': dict({ - 'chargeAndClimateTimer': dict({ - 'showDepartureTimers': False, - }), - }), - 'chargeAndClimateTimerDetail': dict({ - 'chargingMode': dict({ - 'chargingPreference': 'CHARGING_WINDOW', - 'endTimeSlot': '0001-01-01T01:30:00', - 'startTimeSlot': '0001-01-01T18:01:00', - 'type': 'TIME_SLOT', - }), - 'departureTimer': dict({ - 'type': 'WEEKLY_DEPARTURE_TIMER', - 'weeklyTimers': list([ - dict({ - 'daysOfTheWeek': list([ - 'MONDAY', - 'TUESDAY', - 'WEDNESDAY', - 'THURSDAY', - 'FRIDAY', - ]), - 'id': 1, - 'time': '0001-01-01T07:35:00', - 'timerAction': 'DEACTIVATE', - }), - dict({ - 'daysOfTheWeek': list([ - 'MONDAY', - 'TUESDAY', - 'WEDNESDAY', - 'THURSDAY', - 'FRIDAY', - 'SATURDAY', - 'SUNDAY', - ]), - 'id': 2, - 'time': '0001-01-01T18:00:00', - 'timerAction': 'DEACTIVATE', - }), - dict({ - 'daysOfTheWeek': list([ - ]), - 'id': 3, - 'time': '0001-01-01T07:00:00', - 'timerAction': 'DEACTIVATE', - }), - dict({ - 'daysOfTheWeek': list([ - ]), - 'id': 4, - 'time': '0001-01-01T00:00:00', - 'timerAction': 'DEACTIVATE', - }), - ]), - }), - 'isPreconditionForDepartureActive': False, - }), - 'servicePack': 'TCB1', - }), - 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT01.json', - }), - ]), - 'info': dict({ - 'password': '**REDACTED**', - 'refresh_token': '**REDACTED**', - 'region': 'rest_of_world', - 'username': '**REDACTED**', - }), - }) -# --- -# name: test_device_diagnostics_vehicle_not_found - dict({ - 'data': None, - 'fingerprint': list([ - dict({ - 'content': list([ dict({ 'appVehicleType': 'CONNECTED', 'attributes': dict({ @@ -2076,6 +3067,435 @@ ]), 'filename': 'mini-eadrax-vcs_v4_vehicles.json', }), + dict({ + 'content': dict({ + 'capabilities': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'checkSustainabilityDPP': False, + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'digitalKey': dict({ + 'bookedServicePackage': 'SMACC_1_5', + 'readerGraphics': 'readerGraphics', + 'state': 'ACTIVATED', + }), + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': True, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': True, + 'isChargingLoudnessEnabled': True, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': True, + 'isChargingSettingsEnabled': True, + 'isChargingTargetSocEnabled': True, + 'isClimateTimerWeeklyActive': False, + 'isCustomerEsimSupported': True, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': True, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isPersonalPictureUploadSupported': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': True, + 'isSustainabilityAccumulatedViewEnabled': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remote360': True, + 'remoteChargingCommands': dict({ + }), + 'remoteSoftwareUpgrade': True, + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'speechThirdPartyAlexa': False, + 'speechThirdPartyAlexaSDK': False, + 'unlock': True, + 'vehicleFinder': True, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'IMMEDIATE_CHARGING', + 'chargingPreference': 'NO_PRESELECTION', + 'chargingSettings': dict({ + 'acCurrentLimit': 16, + 'hospitality': 'NO_ACTION', + 'idcc': 'UNLIMITED_LOUD', + 'targetSoc': 80, + }), + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + ]), + }), + 'checkControlMessages': list([ + dict({ + 'severity': 'LOW', + 'type': 'TIRE_PRESSURE', + }), + ]), + 'climateControlState': dict({ + 'activity': 'STANDBY', + }), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': False, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + ]), + 'combustionFuelLevel': dict({ + }), + 'currentMileage': 1121, + 'doorsState': dict({ + 'combinedSecurityState': 'LOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'UNKNOWN', + 'chargingLevelPercent': 80, + 'chargingStatus': 'INVALID', + 'chargingTarget': 80, + 'isChargerConnected': False, + 'range': 472, + 'remainingChargingMinutes': 10, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2023-01-04T14:57:06.386Z', + 'lastUpdatedAt': '2023-01-04T14:57:06.407Z', + 'location': dict({ + 'address': dict({ + 'formatted': '**REDACTED**', + }), + 'coordinates': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'heading': '**REDACTED**', + }), + 'range': 472, + 'requiredServices': list([ + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_REAR', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_FRONT', + }), + ]), + 'tireState': dict({ + 'frontLeft': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 241, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'frontRight': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 2419, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 255, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'rearLeft': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 324, + 'pressureStatus': 0, + 'targetPressure': 303, + 'wearStatus': 0, + }), + }), + 'rearRight': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 331, + 'pressureStatus': 0, + 'targetPressure': 303, + 'wearStatus': 0, + }), + }), + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + }), + }), + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBA0FINGERPRINT01.json', + }), + dict({ + 'content': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'chargingMode': 'Sofort laden', + 'chargingModeSemantics': 'Sofort laden', + 'departureTimer': list([ + 'Aus', + ]), + 'departureTimerSemantics': 'Aus', + 'preconditionForDeparture': 'Aus', + 'showDepartureTimers': False, + }), + 'chargingFlap': dict({ + 'permanentlyUnlockLabel': 'Aus', + }), + 'chargingSettings': dict({ + 'acCurrentLimitLabel': '16A', + 'acCurrentLimitLabelSemantics': '16 Ampere', + 'chargingTargetLabel': '80%', + 'dcLoudnessLabel': 'Nicht begrenzt', + 'unlockCableAutomaticallyLabel': 'Aus', + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'NO_PRESELECTION', + 'endTimeSlot': '0001-01-01T00:00:00', + 'startTimeSlot': '0001-01-01T00:00:00', + 'type': 'CHARGING_IMMEDIATELY', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 1, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 2, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'chargingFlapDetail': dict({ + 'isPermanentlyUnlock': False, + }), + 'chargingSettingsDetail': dict({ + 'acLimit': dict({ + 'current': dict({ + 'unit': 'A', + 'value': 16, + }), + 'isUnlimited': False, + 'max': 32, + 'min': 6, + 'values': list([ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 20, + 32, + ]), + }), + 'chargingTarget': 80, + 'dcLoudness': 'UNLIMITED_LOUD', + 'isUnlockCableActive': False, + 'minChargingTargetToWarning': 0, + }), + 'servicePack': 'WAVE_01', + }), + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBA0FINGERPRINT01.json', + }), dict({ 'content': dict({ 'capabilities': dict({ @@ -2295,7 +3715,7 @@ }), }), }), - 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT01.json', + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT02.json', }), dict({ 'content': dict({ @@ -2360,7 +3780,839 @@ }), 'servicePack': 'TCB1', }), - 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT01.json', + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT02.json', + }), + ]), + 'info': dict({ + 'password': '**REDACTED**', + 'refresh_token': '**REDACTED**', + 'region': 'rest_of_world', + 'username': '**REDACTED**', + }), + }) +# --- +# name: test_device_diagnostics_vehicle_not_found + dict({ + 'data': None, + 'fingerprint': list([ + dict({ + 'content': list([ + dict({ + 'appVehicleType': 'DEMO', + 'attributes': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'bodyType': 'G26', + 'brand': 'BMW', + 'color': 4284245350, + 'countryOfOrigin': 'DE', + 'driveTrain': 'ELECTRIC', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitRaw': 'HU_MGU', + 'headUnitType': 'MGU', + 'hmiVersion': 'ID8', + 'lastFetched': '2023-01-04T14:57:06.019Z', + 'model': 'i4 eDrive40', + 'softwareVersionCurrent': dict({ + 'iStep': 470, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'G026', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 470, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'G026', + }), + 'telematicsUnit': 'WAVE01', + 'year': 2021, + }), + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'lmmStatusReasons': list([ + ]), + 'mappingStatus': 'CONFIRMED', + }), + 'vin': '**REDACTED**', + }), + dict({ + 'appVehicleType': 'CONNECTED', + 'attributes': dict({ + 'a4aType': 'USB_ONLY', + 'bodyType': 'I01', + 'brand': 'BMW_I', + 'color': 4284110934, + 'countryOfOrigin': 'CZ', + 'driveTrain': 'ELECTRIC_WITH_RANGE_EXTENDER', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitType': 'NBT', + 'hmiVersion': 'ID4', + 'lastFetched': '2022-07-10T09:25:53.104Z', + 'model': 'i3 (+ REX)', + 'softwareVersionCurrent': dict({ + 'iStep': 510, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'I001', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 502, + 'puStep': dict({ + 'month': 3, + 'year': 15, + }), + 'seriesCluster': 'I001', + }), + 'year': 2015, + }), + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'mappingStatus': 'CONFIRMED', + }), + 'vin': '**REDACTED**', + }), + ]), + 'filename': 'bmw-eadrax-vcs_v4_vehicles.json', + }), + dict({ + 'content': list([ + ]), + 'filename': 'mini-eadrax-vcs_v4_vehicles.json', + }), + dict({ + 'content': dict({ + 'capabilities': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'checkSustainabilityDPP': False, + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'digitalKey': dict({ + 'bookedServicePackage': 'SMACC_1_5', + 'readerGraphics': 'readerGraphics', + 'state': 'ACTIVATED', + }), + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': True, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': True, + 'isChargingLoudnessEnabled': True, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': True, + 'isChargingSettingsEnabled': True, + 'isChargingTargetSocEnabled': True, + 'isClimateTimerWeeklyActive': False, + 'isCustomerEsimSupported': True, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': True, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isPersonalPictureUploadSupported': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': True, + 'isSustainabilityAccumulatedViewEnabled': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remote360': True, + 'remoteChargingCommands': dict({ + }), + 'remoteSoftwareUpgrade': True, + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'speechThirdPartyAlexa': False, + 'speechThirdPartyAlexaSDK': False, + 'unlock': True, + 'vehicleFinder': True, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'IMMEDIATE_CHARGING', + 'chargingPreference': 'NO_PRESELECTION', + 'chargingSettings': dict({ + 'acCurrentLimit': 16, + 'hospitality': 'NO_ACTION', + 'idcc': 'UNLIMITED_LOUD', + 'targetSoc': 80, + }), + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + ]), + }), + 'checkControlMessages': list([ + dict({ + 'severity': 'LOW', + 'type': 'TIRE_PRESSURE', + }), + ]), + 'climateControlState': dict({ + 'activity': 'STANDBY', + }), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': False, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + ]), + 'combustionFuelLevel': dict({ + }), + 'currentMileage': 1121, + 'doorsState': dict({ + 'combinedSecurityState': 'LOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'UNKNOWN', + 'chargingLevelPercent': 80, + 'chargingStatus': 'INVALID', + 'chargingTarget': 80, + 'isChargerConnected': False, + 'range': 472, + 'remainingChargingMinutes': 10, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2023-01-04T14:57:06.386Z', + 'lastUpdatedAt': '2023-01-04T14:57:06.407Z', + 'location': dict({ + 'address': dict({ + 'formatted': '**REDACTED**', + }), + 'coordinates': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'heading': '**REDACTED**', + }), + 'range': 472, + 'requiredServices': list([ + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_REAR', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_FRONT', + }), + ]), + 'tireState': dict({ + 'frontLeft': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 241, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'frontRight': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 2419, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 255, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'rearLeft': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 324, + 'pressureStatus': 0, + 'targetPressure': 303, + 'wearStatus': 0, + }), + }), + 'rearRight': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 331, + 'pressureStatus': 0, + 'targetPressure': 303, + 'wearStatus': 0, + }), + }), + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + }), + }), + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBA0FINGERPRINT01.json', + }), + dict({ + 'content': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'chargingMode': 'Sofort laden', + 'chargingModeSemantics': 'Sofort laden', + 'departureTimer': list([ + 'Aus', + ]), + 'departureTimerSemantics': 'Aus', + 'preconditionForDeparture': 'Aus', + 'showDepartureTimers': False, + }), + 'chargingFlap': dict({ + 'permanentlyUnlockLabel': 'Aus', + }), + 'chargingSettings': dict({ + 'acCurrentLimitLabel': '16A', + 'acCurrentLimitLabelSemantics': '16 Ampere', + 'chargingTargetLabel': '80%', + 'dcLoudnessLabel': 'Nicht begrenzt', + 'unlockCableAutomaticallyLabel': 'Aus', + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'NO_PRESELECTION', + 'endTimeSlot': '0001-01-01T00:00:00', + 'startTimeSlot': '0001-01-01T00:00:00', + 'type': 'CHARGING_IMMEDIATELY', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 1, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 2, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'chargingFlapDetail': dict({ + 'isPermanentlyUnlock': False, + }), + 'chargingSettingsDetail': dict({ + 'acLimit': dict({ + 'current': dict({ + 'unit': 'A', + 'value': 16, + }), + 'isUnlimited': False, + 'max': 32, + 'min': 6, + 'values': list([ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 20, + 32, + ]), + }), + 'chargingTarget': 80, + 'dcLoudness': 'UNLIMITED_LOUD', + 'isUnlockCableActive': False, + 'minChargingTargetToWarning': 0, + }), + 'servicePack': 'WAVE_01', + }), + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBA0FINGERPRINT01.json', + }), + dict({ + 'content': dict({ + 'capabilities': dict({ + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'climateTimerTrigger': 'DEPARTURE_TIMER', + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': False, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': False, + 'isChargingLoudnessEnabled': False, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': False, + 'isChargingSettingsEnabled': False, + 'isChargingTargetSocEnabled': False, + 'isClimateTimerSupported': True, + 'isCustomerEsimSupported': False, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': False, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remoteChargingCommands': dict({ + }), + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'unlock': True, + 'vehicleFinder': False, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'DELAYED_CHARGING', + 'chargingPreference': 'CHARGING_WINDOW', + 'chargingSettings': dict({ + 'hospitality': 'NO_ACTION', + 'idcc': 'NO_ACTION', + 'targetSoc': 100, + }), + 'climatisationOn': False, + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timerWeekDays': list([ + ]), + }), + ]), + 'reductionOfChargeCurrent': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, + }), + }), + }), + 'checkControlMessages': list([ + ]), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 6, + 'minute': 40, + }), + 'isWeeklyTimer': True, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'THURSDAY', + 'SUNDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 12, + 'minute': 50, + }), + 'isWeeklyTimer': False, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'MONDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 18, + 'minute': 59, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + 'WEDNESDAY', + ]), + }), + ]), + 'combustionFuelLevel': dict({ + 'range': 105, + 'remainingFuelLiters': 6, + 'remainingFuelPercent': 65, + }), + 'currentMileage': 137009, + 'doorsState': dict({ + 'combinedSecurityState': 'UNLOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'CONDUCTIVE', + 'chargingLevelPercent': 82, + 'chargingStatus': 'WAITING_FOR_CHARGING', + 'chargingTarget': 100, + 'isChargerConnected': True, + 'range': 174, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2022-06-22T14:24:23.982Z', + 'lastUpdatedAt': '2022-06-22T13:58:52Z', + 'range': 174, + 'requiredServices': list([ + dict({ + 'dateTime': '2022-10-01T00:00:00.000Z', + 'description': 'Next service due by the specified date.', + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next vehicle check due after the specified distance or date.', + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next state inspection due by the specified date.', + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + ]), + 'roofState': dict({ + 'roofState': 'CLOSED', + 'roofStateType': 'SUN_ROOF', + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'rightFront': 'CLOSED', + }), + }), + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT02.json', + }), + dict({ + 'content': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'showDepartureTimers': False, + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'CHARGING_WINDOW', + 'endTimeSlot': '0001-01-01T01:30:00', + 'startTimeSlot': '0001-01-01T18:01:00', + 'type': 'TIME_SLOT', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + 'id': 1, + 'time': '0001-01-01T07:35:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + 'id': 2, + 'time': '0001-01-01T18:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T07:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'servicePack': 'TCB1', + }), + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT02.json', }), ]), 'info': dict({ diff --git a/tests/components/bmw_connected_drive/snapshots/test_select.ambr b/tests/components/bmw_connected_drive/snapshots/test_select.ambr new file mode 100644 index 0000000000..e6902fbacf --- /dev/null +++ b/tests/components/bmw_connected_drive/snapshots/test_select.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_entity_state_attrs + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Target SoC', + 'icon': 'mdi:battery-charging-medium', + 'options': list([ + '20', + '25', + '30', + '35', + '40', + '45', + '50', + '55', + '60', + '65', + '70', + '75', + '80', + '85', + '90', + '95', + '100', + ]), + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'select.i4_edrive40_target_soc', + 'last_changed': , + 'last_updated': , + 'state': '80', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 AC Charging Limit', + 'icon': 'mdi:current-ac', + 'options': list([ + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '20', + '32', + ]), + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'select.i4_edrive40_ac_charging_limit', + 'last_changed': , + 'last_updated': , + 'state': '16', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Charging Mode', + 'icon': 'mdi:vector-point-select', + 'options': list([ + 'IMMEDIATE_CHARGING', + 'DELAYED_CHARGING', + ]), + }), + 'context': , + 'entity_id': 'select.i4_edrive40_charging_mode', + 'last_changed': , + 'last_updated': , + 'state': 'IMMEDIATE_CHARGING', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Charging Mode', + 'icon': 'mdi:vector-point-select', + 'options': list([ + 'IMMEDIATE_CHARGING', + 'DELAYED_CHARGING', + ]), + }), + 'context': , + 'entity_id': 'select.i3_rex_charging_mode', + 'last_changed': , + 'last_updated': , + 'state': 'DELAYED_CHARGING', + }), + ]) +# --- diff --git a/tests/components/bmw_connected_drive/test_select.py b/tests/components/bmw_connected_drive/test_select.py new file mode 100644 index 0000000000..92daf157a7 --- /dev/null +++ b/tests/components/bmw_connected_drive/test_select.py @@ -0,0 +1,84 @@ +"""Test BMW selects.""" +from bimmer_connected.vehicle.remote_services import RemoteServices +import pytest +import respx +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_mocked_integration + + +async def test_entity_state_attrs( + hass: HomeAssistant, + bmw_fixture: respx.Router, + snapshot: SnapshotAssertion, +) -> None: + """Test select options and values..""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Get all select entities + assert hass.states.async_all("select") == snapshot + + +@pytest.mark.parametrize( + ("entity_id", "value"), + [ + ("select.i3_rex_charging_mode", "IMMEDIATE_CHARGING"), + ("select.i4_edrive40_ac_charging_limit", "16"), + ("select.i4_edrive40_target_soc", "80"), + ("select.i4_edrive40_charging_mode", "DELAYED_CHARGING"), + ], +) +async def test_update_triggers_success( + hass: HomeAssistant, + entity_id: str, + value: str, + bmw_fixture: respx.Router, +) -> None: + """Test allowed values for select inputs.""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Test + await hass.services.async_call( + "select", + "select_option", + service_data={"option": value}, + blocking=True, + target={"entity_id": entity_id}, + ) + assert RemoteServices.trigger_remote_service.call_count == 1 + + +@pytest.mark.parametrize( + ("entity_id", "value"), + [ + ("select.i4_edrive40_ac_charging_limit", "17"), + ("select.i4_edrive40_target_soc", "81"), + ], +) +async def test_update_triggers_fail( + hass: HomeAssistant, + entity_id: str, + value: str, + bmw_fixture: respx.Router, +) -> None: + """Test not allowed values for select inputs.""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Test + with pytest.raises(ValueError): + await hass.services.async_call( + "select", + "select_option", + service_data={"option": value}, + blocking=True, + target={"entity_id": entity_id}, + ) + assert RemoteServices.trigger_remote_service.call_count == 0 From 6f8939025189fe7b712273150f3fca6f0ae275f9 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 30 Mar 2023 19:48:21 +0200 Subject: [PATCH 1476/2810] Update frontend to 20230330.0 (#90524) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 8c3fb8c143..6a2a904833 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20230329.0"] + "requirements": ["home-assistant-frontend==20230330.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0ed98c78e1..342942f0dd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.0.0 hass-nabucasa==0.63.1 hassil==1.0.6 home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230329.0 +home-assistant-frontend==20230330.0 home-assistant-intents==2023.3.29 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index cae40bd2c6..3cbd6bd365 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -907,7 +907,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230329.0 +home-assistant-frontend==20230330.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 314cd2efdf..9bdb5c485e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -693,7 +693,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230329.0 +home-assistant-frontend==20230330.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 From 6b0c98045ef79394766b4aa8738410ab1d042d7f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Mar 2023 14:53:47 -1000 Subject: [PATCH 1477/2810] Handle garbage in the context_id column during migration (#90544) * Handle garbage in the context_id column during migration * Update homeassistant/components/recorder/migration.py * lint --- .../components/recorder/migration.py | 14 ++++-- tests/components/recorder/test_migrate.py | 45 ++++++++++++++++++- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 4be0132765..3e7f9aa592 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1355,10 +1355,16 @@ def _context_id_to_bytes(context_id: str | None) -> bytes | None: """Convert a context_id to bytes.""" if context_id is None: return None - if len(context_id) == 32: - return UUID(context_id).bytes - if len(context_id) == 26: - return ulid_to_bytes(context_id) + with contextlib.suppress(ValueError): + # There may be garbage in the context_id column + # from custom integrations that are not UUIDs or + # ULIDs that filled the column to the max length + # so we need to catch the ValueError and return + # None if it happens + if len(context_id) == 32: + return UUID(context_id).bytes + if len(context_id) == 26: + return ulid_to_bytes(context_id) return None diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index fe4f1e016f..efe2a51b83 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -671,6 +671,19 @@ async def test_migrate_events_context_ids( context_parent_id=None, context_parent_id_bin=None, ), + Events( + event_type="garbage_context_id_event", + event_data=None, + origin_idx=0, + time_fired=None, + time_fired_ts=1677721632.552529, + context_id="adapt_lgt:b'5Cf*':interval:b'0R'", + context_id_bin=None, + context_user_id=None, + context_user_id_bin=None, + context_parent_id=None, + context_parent_id_bin=None, + ), ) ) @@ -695,12 +708,13 @@ async def test_migrate_events_context_ids( "empty_context_id_event", "ulid_context_id_event", "invalid_context_id_event", + "garbage_context_id_event", ] ) ) .all() ) - assert len(events) == 4 + assert len(events) == 5 return {event.event_type: _object_as_dict(event) for event in events} events_by_type = await instance.async_add_executor_job(_fetch_migrated_events) @@ -746,6 +760,14 @@ async def test_migrate_events_context_ids( assert invalid_context_id_event["context_user_id_bin"] is None assert invalid_context_id_event["context_parent_id_bin"] is None + garbage_context_id_event = events_by_type["garbage_context_id_event"] + assert garbage_context_id_event["context_id"] is None + assert garbage_context_id_event["context_user_id"] is None + assert garbage_context_id_event["context_parent_id"] is None + assert garbage_context_id_event["context_id_bin"] == b"\x00" * 16 + assert garbage_context_id_event["context_user_id_bin"] is None + assert garbage_context_id_event["context_parent_id_bin"] is None + @pytest.mark.parametrize("enable_migrate_context_ids", [True]) async def test_migrate_states_context_ids( @@ -803,6 +825,16 @@ async def test_migrate_states_context_ids( context_parent_id=None, context_parent_id_bin=None, ), + States( + entity_id="state.garbage_context_id", + last_updated_ts=1677721632.552529, + context_id="adapt_lgt:b'5Cf*':interval:b'0R'", + context_id_bin=None, + context_user_id=None, + context_user_id_bin=None, + context_parent_id=None, + context_parent_id_bin=None, + ), ) ) @@ -827,12 +859,13 @@ async def test_migrate_states_context_ids( "state.empty_context_id", "state.ulid_context_id", "state.invalid_context_id", + "state.garbage_context_id", ] ) ) .all() ) - assert len(events) == 4 + assert len(events) == 5 return {state.entity_id: _object_as_dict(state) for state in events} states_by_entity_id = await instance.async_add_executor_job(_fetch_migrated_states) @@ -877,6 +910,14 @@ async def test_migrate_states_context_ids( assert invalid_context_id["context_user_id_bin"] is None assert invalid_context_id["context_parent_id_bin"] is None + garbage_context_id = states_by_entity_id["state.garbage_context_id"] + assert garbage_context_id["context_id"] is None + assert garbage_context_id["context_user_id"] is None + assert garbage_context_id["context_parent_id"] is None + assert garbage_context_id["context_id_bin"] == b"\x00" * 16 + assert garbage_context_id["context_user_id_bin"] is None + assert garbage_context_id["context_parent_id_bin"] is None + @pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) async def test_migrate_event_type_ids( From a2efe2445aa5b441a1103b8e92a061b0b469b493 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Mar 2023 14:54:13 -1000 Subject: [PATCH 1478/2810] Fix migration when encountering a NULL entity_id/event_type (#90542) * Fix migration when encountering a NULL entity_id/event_type reported in #beta on discord * simplify --- .../components/recorder/migration.py | 30 ++-- tests/components/recorder/test_migrate.py | 150 +++++++++++++++++- 2 files changed, 168 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 3e7f9aa592..23382a9aeb 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1445,12 +1445,15 @@ def migrate_event_type_ids(instance: Recorder) -> bool: with session_scope(session=session_maker()) as session: if events := session.execute(find_event_type_to_migrate()).all(): event_types = {event_type for _, event_type in events} + if None in event_types: + # event_type should never be None but we need to be defensive + # so we don't fail the migration because of a bad state + event_types.remove(None) + event_types.add(_EMPTY_EVENT_TYPE) + event_type_to_id = event_type_manager.get_many(event_types, session) if missing_event_types := { - # We should never see see None for the event_Type in the events table - # but we need to be defensive so we don't fail the migration - # because of a bad event - _EMPTY_EVENT_TYPE if event_type is None else event_type + event_type for event_type, event_id in event_type_to_id.items() if event_id is None }: @@ -1476,7 +1479,9 @@ def migrate_event_type_ids(instance: Recorder) -> bool: { "event_id": event_id, "event_type": None, - "event_type_id": event_type_to_id[event_type], + "event_type_id": event_type_to_id[ + _EMPTY_EVENT_TYPE if event_type is None else event_type + ], } for event_id, event_type in events ], @@ -1508,14 +1513,17 @@ def migrate_entity_ids(instance: Recorder) -> bool: with session_scope(session=instance.get_session()) as session: if states := session.execute(find_entity_ids_to_migrate()).all(): entity_ids = {entity_id for _, entity_id in states} + if None in entity_ids: + # entity_id should never be None but we need to be defensive + # so we don't fail the migration because of a bad state + entity_ids.remove(None) + entity_ids.add(_EMPTY_ENTITY_ID) + entity_id_to_metadata_id = states_meta_manager.get_many( entity_ids, session, True ) if missing_entity_ids := { - # We should never see _EMPTY_ENTITY_ID in the states table - # but we need to be defensive so we don't fail the migration - # because of a bad state - _EMPTY_ENTITY_ID if entity_id is None else entity_id + entity_id for entity_id, metadata_id in entity_id_to_metadata_id.items() if metadata_id is None }: @@ -1543,7 +1551,9 @@ def migrate_entity_ids(instance: Recorder) -> bool: # the history queries still need to work while the # migration is in progress and we will do this in # post_migrate_entity_ids - "metadata_id": entity_id_to_metadata_id[entity_id], + "metadata_id": entity_id_to_metadata_id[ + _EMPTY_ENTITY_ID if entity_id is None else entity_id + ], } for state_id, entity_id in states ], diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index efe2a51b83..b75d536d15 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -998,7 +998,7 @@ async def test_migrate_entity_ids( instance = await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) - def _insert_events(): + def _insert_states(): with session_scope(hass=hass) as session: session.add_all( ( @@ -1020,7 +1020,7 @@ async def test_migrate_entity_ids( ) ) - await instance.async_add_executor_job(_insert_events) + await instance.async_add_executor_job(_insert_states) await async_wait_recording_done(hass) # This is a threadsafe way to add a task to the recorder @@ -1106,3 +1106,149 @@ async def test_post_migrate_entity_ids( assert states_by_state["one_1"] is None assert states_by_state["two_2"] is None assert states_by_state["two_1"] is None + + +@pytest.mark.parametrize("enable_migrate_entity_ids", [True]) +async def test_migrate_null_entity_ids( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test we can migrate entity_ids to the StatesMeta table.""" + instance = await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + def _insert_states(): + with session_scope(hass=hass) as session: + session.add( + States( + entity_id="sensor.one", + state="one_1", + last_updated_ts=1.452529, + ), + ) + session.add_all( + States( + entity_id=None, + state="empty", + last_updated_ts=time + 1.452529, + ) + for time in range(1000) + ) + session.add( + States( + entity_id="sensor.one", + state="one_1", + last_updated_ts=2.452529, + ), + ) + + await instance.async_add_executor_job(_insert_states) + + await async_wait_recording_done(hass) + # This is a threadsafe way to add a task to the recorder + instance.queue_task(EntityIDMigrationTask()) + await async_recorder_block_till_done(hass) + await async_recorder_block_till_done(hass) + + def _fetch_migrated_states(): + with session_scope(hass=hass) as session: + states = ( + session.query( + States.state, + States.metadata_id, + States.last_updated_ts, + StatesMeta.entity_id, + ) + .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) + .all() + ) + assert len(states) == 1002 + result = {} + for state in states: + result.setdefault(state.entity_id, []).append( + { + "state_id": state.entity_id, + "last_updated_ts": state.last_updated_ts, + "state": state.state, + } + ) + return result + + states_by_entity_id = await instance.async_add_executor_job(_fetch_migrated_states) + assert len(states_by_entity_id[migration._EMPTY_ENTITY_ID]) == 1000 + assert len(states_by_entity_id["sensor.one"]) == 2 + + +@pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) +async def test_migrate_null_event_type_ids( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test we can migrate event_types to the EventTypes table when the event_type is NULL.""" + instance = await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + def _insert_events(): + with session_scope(hass=hass) as session: + session.add( + Events( + event_type="event_type_one", + origin_idx=0, + time_fired_ts=1.452529, + ), + ) + session.add_all( + Events( + event_type=None, + origin_idx=0, + time_fired_ts=time + 1.452529, + ) + for time in range(1000) + ) + session.add( + Events( + event_type="event_type_one", + origin_idx=0, + time_fired_ts=2.452529, + ), + ) + + await instance.async_add_executor_job(_insert_events) + + await async_wait_recording_done(hass) + # This is a threadsafe way to add a task to the recorder + + instance.queue_task(EventTypeIDMigrationTask()) + await async_recorder_block_till_done(hass) + await async_recorder_block_till_done(hass) + + def _fetch_migrated_events(): + with session_scope(hass=hass) as session: + events = ( + session.query(Events.event_id, Events.time_fired, EventTypes.event_type) + .filter( + Events.event_type_id.in_( + select_event_type_ids( + ( + "event_type_one", + migration._EMPTY_EVENT_TYPE, + ) + ) + ) + ) + .outerjoin(EventTypes, Events.event_type_id == EventTypes.event_type_id) + .all() + ) + assert len(events) == 1002 + result = {} + for event in events: + result.setdefault(event.event_type, []).append( + { + "event_id": event.event_id, + "time_fired": event.time_fired, + "event_type": event.event_type, + } + ) + return result + + events_by_type = await instance.async_add_executor_job(_fetch_migrated_events) + assert len(events_by_type["event_type_one"]) == 2 + assert len(events_by_type[migration._EMPTY_EVENT_TYPE]) == 1000 From 47af325a88db585e96299deced3098ed369e89ca Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 02:54:31 +0200 Subject: [PATCH 1479/2810] Add entity name translations to LaMetric (#90538) * Add entity name translations to LaMetric * Consistency --- homeassistant/components/lametric/button.py | 8 +++--- homeassistant/components/lametric/select.py | 3 +-- homeassistant/components/lametric/sensor.py | 1 + .../components/lametric/strings.json | 25 +++++++++++++++++++ homeassistant/components/lametric/switch.py | 2 +- 5 files changed, 32 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/lametric/button.py b/homeassistant/components/lametric/button.py index 74edd9e0af..18a0c2f8f7 100644 --- a/homeassistant/components/lametric/button.py +++ b/homeassistant/components/lametric/button.py @@ -36,28 +36,28 @@ class LaMetricButtonEntityDescription( BUTTONS = [ LaMetricButtonEntityDescription( key="app_next", - name="Next app", + translation_key="app_next", icon="mdi:arrow-right-bold", entity_category=EntityCategory.CONFIG, press_fn=lambda api: api.app_next(), ), LaMetricButtonEntityDescription( key="app_previous", - name="Previous app", + translation_key="app_previous", icon="mdi:arrow-left-bold", entity_category=EntityCategory.CONFIG, press_fn=lambda api: api.app_previous(), ), LaMetricButtonEntityDescription( key="dismiss_current", - name="Dismiss current notification", + translation_key="dismiss_current", icon="mdi:bell-cancel", entity_category=EntityCategory.CONFIG, press_fn=lambda api: api.dismiss_current_notification(), ), LaMetricButtonEntityDescription( key="dismiss_all", - name="Dismiss all notifications", + translation_key="dismiss_all", icon="mdi:bell-cancel", entity_category=EntityCategory.CONFIG, press_fn=lambda api: api.dismiss_all_notifications(), diff --git a/homeassistant/components/lametric/select.py b/homeassistant/components/lametric/select.py index 295003c853..b7c0e55745 100644 --- a/homeassistant/components/lametric/select.py +++ b/homeassistant/components/lametric/select.py @@ -37,11 +37,10 @@ class LaMetricSelectEntityDescription( SELECTS = [ LaMetricSelectEntityDescription( key="brightness_mode", - name="Brightness mode", + translation_key="brightness_mode", icon="mdi:brightness-auto", entity_category=EntityCategory.CONFIG, options=["auto", "manual"], - translation_key="brightness_mode", current_fn=lambda device: device.display.brightness_mode.value, select_fn=lambda api, opt: api.display(brightness_mode=BrightnessMode(opt)), ), diff --git a/homeassistant/components/lametric/sensor.py b/homeassistant/components/lametric/sensor.py index c12d368efd..0c26d2c7dd 100644 --- a/homeassistant/components/lametric/sensor.py +++ b/homeassistant/components/lametric/sensor.py @@ -38,6 +38,7 @@ class LaMetricSensorEntityDescription( SENSORS = [ LaMetricSensorEntityDescription( key="rssi", + translation_key="rssi", name="Wi-Fi signal", icon="mdi:wifi", entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index eb90b21ff2..21cebe46f2 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -45,13 +45,38 @@ } }, "entity": { + "button": { + "app_next": { + "name": "Next app" + }, + "app_previous": { + "name": "Previous app" + }, + "dismiss_current": { + "name": "Dismiss current notification" + }, + "dismiss_all": { + "name": "Dismiss all notifications" + } + }, + "sensor": { + "rssi": { + "name": "Wi-Fi signal" + } + }, "select": { "brightness_mode": { + "name": "Brightness mode", "state": { "auto": "Automatic", "manual": "Manual" } } + }, + "switch": { + "bluetooth": { + "name": "Bluetooth" + } } } } diff --git a/homeassistant/components/lametric/switch.py b/homeassistant/components/lametric/switch.py index f6807648b7..c33ec16d61 100644 --- a/homeassistant/components/lametric/switch.py +++ b/homeassistant/components/lametric/switch.py @@ -39,7 +39,7 @@ class LaMetricSwitchEntityDescription( SWITCHES = [ LaMetricSwitchEntityDescription( key="bluetooth", - name="Bluetooth", + translation_key="bluetooth", icon="mdi:bluetooth", entity_category=EntityCategory.CONFIG, available_fn=lambda device: device.bluetooth.available, From 3a3c7389457204d10697fbfd2147034319b6fa38 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 30 Mar 2023 20:55:01 -0400 Subject: [PATCH 1480/2810] Bump ZHA dependencies (#90547) * Bump ZHA dependencies * Ensure the network is formed on channel 15 when multi-PAN is in use --- homeassistant/components/zha/core/const.py | 2 ++ homeassistant/components/zha/core/gateway.py | 16 +++++++++++ homeassistant/components/zha/manifest.json | 10 +++---- requirements_all.txt | 10 +++---- requirements_test_all.txt | 10 +++---- tests/components/zha/test_gateway.py | 29 ++++++++++++++++++++ 6 files changed, 62 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 4c10a2328a..6423723d32 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -137,6 +137,8 @@ CONF_GROUP_MEMBERS_ASSUME_STATE = "group_members_assume_state" CONF_ENABLE_IDENTIFY_ON_JOIN = "enable_identify_on_join" CONF_ENABLE_QUIRKS = "enable_quirks" CONF_FLOWCONTROL = "flow_control" +CONF_NWK = "network" +CONF_NWK_CHANNEL = "channel" CONF_RADIO_TYPE = "radio_type" CONF_USB_PATH = "usb_path" CONF_USE_THREAD = "use_thread" diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 3f9ada1ed0..8858ea6959 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -41,6 +41,8 @@ from .const import ( ATTR_TYPE, CONF_DATABASE, CONF_DEVICE_PATH, + CONF_NWK, + CONF_NWK_CHANNEL, CONF_RADIO_TYPE, CONF_USE_THREAD, CONF_ZIGPY, @@ -172,6 +174,20 @@ class ZHAGateway: ): app_config[CONF_USE_THREAD] = False + # Local import to avoid circular dependencies + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( + is_multiprotocol_url, + ) + + # Until we have a way to coordinate channels with the Thread half of multi-PAN, + # stick to the old zigpy default of channel 15 instead of dynamically scanning + if ( + is_multiprotocol_url(app_config[CONF_DEVICE][CONF_DEVICE_PATH]) + and app_config.get(CONF_NWK, {}).get(CONF_NWK_CHANNEL) is None + ): + app_config.setdefault(CONF_NWK, {})[CONF_NWK_CHANNEL] = 15 + return app_controller_cls, app_controller_cls.SCHEMA(app_config) async def async_initialize(self) -> None: diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index d82fe5ed0f..bc5bf6a6d4 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -20,15 +20,15 @@ "zigpy_znp" ], "requirements": [ - "bellows==0.34.10", + "bellows==0.35.0", "pyserial==3.5", "pyserial-asyncio==0.6", "zha-quirks==0.0.95", - "zigpy-deconz==0.19.2", - "zigpy==0.53.2", - "zigpy-xbee==0.16.2", + "zigpy-deconz==0.20.0", + "zigpy==0.54.0", + "zigpy-xbee==0.17.0", "zigpy-zigate==0.10.3", - "zigpy-znp==0.9.3" + "zigpy-znp==0.10.0" ], "usb": [ { diff --git a/requirements_all.txt b/requirements_all.txt index 3cbd6bd365..8706e4e5f9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ beautifulsoup4==4.11.1 # beewi_smartclim==0.0.10 # homeassistant.components.zha -bellows==0.34.10 +bellows==0.35.0 # homeassistant.components.bmw_connected_drive bimmer_connected==0.13.0 @@ -2710,19 +2710,19 @@ zhong_hong_hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-deconz==0.19.2 +zigpy-deconz==0.20.0 # homeassistant.components.zha -zigpy-xbee==0.16.2 +zigpy-xbee==0.17.0 # homeassistant.components.zha zigpy-zigate==0.10.3 # homeassistant.components.zha -zigpy-znp==0.9.3 +zigpy-znp==0.10.0 # homeassistant.components.zha -zigpy==0.53.2 +zigpy==0.54.0 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9bdb5c485e..01b780f4cf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -355,7 +355,7 @@ base36==0.1.1 beautifulsoup4==4.11.1 # homeassistant.components.zha -bellows==0.34.10 +bellows==0.35.0 # homeassistant.components.bmw_connected_drive bimmer_connected==0.13.0 @@ -1944,19 +1944,19 @@ zeversolar==0.3.1 zha-quirks==0.0.95 # homeassistant.components.zha -zigpy-deconz==0.19.2 +zigpy-deconz==0.20.0 # homeassistant.components.zha -zigpy-xbee==0.16.2 +zigpy-xbee==0.17.0 # homeassistant.components.zha zigpy-zigate==0.10.3 # homeassistant.components.zha -zigpy-znp==0.9.3 +zigpy-znp==0.10.0 # homeassistant.components.zha -zigpy==0.53.2 +zigpy==0.54.0 # homeassistant.components.zwave_js zwave-js-server-python==0.47.1 diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index 392c589ea1..be53b22be6 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -323,3 +323,32 @@ async def test_gateway_initialize_bellows_thread( await zha_gateway.async_initialize() assert mock_new.mock_calls[0].args[0]["use_thread"] is thread_state + + +@pytest.mark.parametrize( + ("device_path", "config_override", "expected_channel"), + [ + ("/dev/ttyUSB0", {}, None), + ("socket://192.168.1.123:9999", {}, None), + ("socket://192.168.1.123:9999", {"network": {"channel": 20}}, 20), + ("socket://core-silabs-multiprotocol:9999", {}, 15), + ("socket://core-silabs-multiprotocol:9999", {"network": {"channel": 20}}, 20), + ], +) +async def test_gateway_force_multi_pan_channel( + device_path: str, + config_override: dict, + expected_channel: int | None, + hass: HomeAssistant, + coordinator, +) -> None: + """Test ZHA disabling the UART thread when connecting to a TCP coordinator.""" + zha_gateway = get_zha_gateway(hass) + assert zha_gateway is not None + + zha_gateway.config_entry.data = dict(zha_gateway.config_entry.data) + zha_gateway.config_entry.data["device"]["path"] = device_path + zha_gateway._config.setdefault("zigpy_config", {}).update(config_override) + + _, config = zha_gateway.get_application_controller_data() + assert config["network"]["channel"] == expected_channel From 38aff23be50f0f645fdfcd15e6eec2af1f88cdae Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 30 Mar 2023 17:15:12 +0200 Subject: [PATCH 1481/2810] Migrate old ZHA IasZone sensor state to zigpy cache (#90508) * Migrate old ZHA IasZone sensor state to zigpy cache * Use correct type for ZoneStatus * Test that migration happens * Test that migration only happens once * Fix parametrize --- homeassistant/components/zha/binary_sensor.py | 35 ++++++- tests/components/zha/test_binary_sensor.py | 92 +++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index b277b3fe26..4e3c7166bf 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -2,13 +2,16 @@ from __future__ import annotations import functools +from typing import Any + +from zigpy.zcl.clusters.security import IasZone from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, Platform +from homeassistant.const import STATE_ON, EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -164,6 +167,36 @@ class IASZone(BinarySensor): """Parse the raw attribute into a bool state.""" return BinarySensor.parse(value & 3) # use only bit 0 and 1 for alarm state + # temporary code to migrate old IasZone sensors to update attribute cache state once + # remove in 2024.4.0 + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return state attributes.""" + return {"migrated_to_cache": True} # writing new state means we're migrated + + # temporary migration code + @callback + def async_restore_last_state(self, last_state): + """Restore previous state.""" + # trigger migration if extra state attribute is not present + if "migrated_to_cache" not in last_state.attributes: + self.migrate_to_zigpy_cache(last_state) + + # temporary migration code + @callback + def migrate_to_zigpy_cache(self, last_state): + """Save old IasZone sensor state to attribute cache.""" + # previous HA versions did not update the attribute cache for IasZone sensors, so do it once here + # a HA state write is triggered shortly afterwards and writes the "migrated_to_cache" extra state attribute + if last_state.state == STATE_ON: + migrated_state = IasZone.ZoneStatus.Alarm_1 + else: + migrated_state = IasZone.ZoneStatus(0) + + self._channel.cluster.update_attribute( + IasZone.attributes_by_name[self.SENSOR_ATTR].id, migrated_state + ) + @MULTI_MATCH( channel_names="tuya_manufacturer", diff --git a/tests/components/zha/test_binary_sensor.py b/tests/components/zha/test_binary_sensor.py index d633e9173e..ec25295ed5 100644 --- a/tests/components/zha/test_binary_sensor.py +++ b/tests/components/zha/test_binary_sensor.py @@ -8,12 +8,15 @@ import zigpy.zcl.clusters.security as security from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import restore_state +from homeassistant.util import dt as dt_util from .common import ( async_enable_traffic, async_test_rejoin, find_entity_id, send_attributes_report, + update_attribute_cache, ) from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE @@ -120,3 +123,92 @@ async def test_binary_sensor( # test rejoin await async_test_rejoin(hass, zigpy_device, [cluster], reporting) assert hass.states.get(entity_id).state == STATE_OFF + + +@pytest.fixture +def core_rs(hass_storage): + """Core.restore_state fixture.""" + + def _storage(entity_id, attributes, state): + now = dt_util.utcnow().isoformat() + + hass_storage[restore_state.STORAGE_KEY] = { + "version": restore_state.STORAGE_VERSION, + "key": restore_state.STORAGE_KEY, + "data": [ + { + "state": { + "entity_id": entity_id, + "state": str(state), + "attributes": attributes, + "last_changed": now, + "last_updated": now, + "context": { + "id": "3c2243ff5f30447eb12e7348cfd5b8ff", + "user_id": None, + }, + }, + "last_seen": now, + } + ], + } + return + + return _storage + + +@pytest.mark.parametrize( + "restored_state", + [ + STATE_ON, + STATE_OFF, + ], +) +async def test_binary_sensor_migration_not_migrated( + hass: HomeAssistant, + zigpy_device_mock, + core_rs, + zha_device_restored, + restored_state, +) -> None: + """Test temporary ZHA IasZone binary_sensor migration to zigpy cache.""" + + entity_id = "binary_sensor.fakemanufacturer_fakemodel_iaszone" + core_rs(entity_id, state=restored_state, attributes={}) # migration sensor state + + zigpy_device = zigpy_device_mock(DEVICE_IAS) + zha_device = await zha_device_restored(zigpy_device) + entity_id = await find_entity_id(Platform.BINARY_SENSOR, zha_device, hass) + + assert entity_id is not None + assert hass.states.get(entity_id).state == restored_state + + # confirm migration extra state attribute was set to True + assert hass.states.get(entity_id).attributes["migrated_to_cache"] + + +async def test_binary_sensor_migration_already_migrated( + hass: HomeAssistant, + zigpy_device_mock, + core_rs, + zha_device_restored, +) -> None: + """Test temporary ZHA IasZone binary_sensor migration doesn't migrate multiple times.""" + + entity_id = "binary_sensor.fakemanufacturer_fakemodel_iaszone" + core_rs(entity_id, state=STATE_OFF, attributes={"migrated_to_cache": True}) + + zigpy_device = zigpy_device_mock(DEVICE_IAS) + + cluster = zigpy_device.endpoints.get(1).ias_zone + cluster.PLUGGED_ATTR_READS = { + "zone_status": security.IasZone.ZoneStatus.Alarm_1, + } + update_attribute_cache(cluster) + + zha_device = await zha_device_restored(zigpy_device) + entity_id = await find_entity_id(Platform.BINARY_SENSOR, zha_device, hass) + + assert entity_id is not None + assert hass.states.get(entity_id).state == STATE_ON # matches attribute cache + assert hass.states.get(entity_id).attributes["migrated_to_cache"] From 8a99d2a566cd281155506d2c0222a2925fb1eee3 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 30 Mar 2023 19:48:21 +0200 Subject: [PATCH 1482/2810] Update frontend to 20230330.0 (#90524) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 8c3fb8c143..6a2a904833 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20230329.0"] + "requirements": ["home-assistant-frontend==20230330.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0ed98c78e1..342942f0dd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.0.0 hass-nabucasa==0.63.1 hassil==1.0.6 home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230329.0 +home-assistant-frontend==20230330.0 home-assistant-intents==2023.3.29 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index cae40bd2c6..3cbd6bd365 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -907,7 +907,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230329.0 +home-assistant-frontend==20230330.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 75b7dfeb3b..b2b78b4541 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -693,7 +693,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230329.0 +home-assistant-frontend==20230330.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 From 9478518937eb1fb6b3cca8a8646eeb9aac2945e2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 02:54:31 +0200 Subject: [PATCH 1483/2810] Add entity name translations to LaMetric (#90538) * Add entity name translations to LaMetric * Consistency --- homeassistant/components/lametric/button.py | 8 +++--- homeassistant/components/lametric/select.py | 3 +-- homeassistant/components/lametric/sensor.py | 1 + .../components/lametric/strings.json | 25 +++++++++++++++++++ homeassistant/components/lametric/switch.py | 2 +- 5 files changed, 32 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/lametric/button.py b/homeassistant/components/lametric/button.py index 74edd9e0af..18a0c2f8f7 100644 --- a/homeassistant/components/lametric/button.py +++ b/homeassistant/components/lametric/button.py @@ -36,28 +36,28 @@ class LaMetricButtonEntityDescription( BUTTONS = [ LaMetricButtonEntityDescription( key="app_next", - name="Next app", + translation_key="app_next", icon="mdi:arrow-right-bold", entity_category=EntityCategory.CONFIG, press_fn=lambda api: api.app_next(), ), LaMetricButtonEntityDescription( key="app_previous", - name="Previous app", + translation_key="app_previous", icon="mdi:arrow-left-bold", entity_category=EntityCategory.CONFIG, press_fn=lambda api: api.app_previous(), ), LaMetricButtonEntityDescription( key="dismiss_current", - name="Dismiss current notification", + translation_key="dismiss_current", icon="mdi:bell-cancel", entity_category=EntityCategory.CONFIG, press_fn=lambda api: api.dismiss_current_notification(), ), LaMetricButtonEntityDescription( key="dismiss_all", - name="Dismiss all notifications", + translation_key="dismiss_all", icon="mdi:bell-cancel", entity_category=EntityCategory.CONFIG, press_fn=lambda api: api.dismiss_all_notifications(), diff --git a/homeassistant/components/lametric/select.py b/homeassistant/components/lametric/select.py index 295003c853..b7c0e55745 100644 --- a/homeassistant/components/lametric/select.py +++ b/homeassistant/components/lametric/select.py @@ -37,11 +37,10 @@ class LaMetricSelectEntityDescription( SELECTS = [ LaMetricSelectEntityDescription( key="brightness_mode", - name="Brightness mode", + translation_key="brightness_mode", icon="mdi:brightness-auto", entity_category=EntityCategory.CONFIG, options=["auto", "manual"], - translation_key="brightness_mode", current_fn=lambda device: device.display.brightness_mode.value, select_fn=lambda api, opt: api.display(brightness_mode=BrightnessMode(opt)), ), diff --git a/homeassistant/components/lametric/sensor.py b/homeassistant/components/lametric/sensor.py index c12d368efd..0c26d2c7dd 100644 --- a/homeassistant/components/lametric/sensor.py +++ b/homeassistant/components/lametric/sensor.py @@ -38,6 +38,7 @@ class LaMetricSensorEntityDescription( SENSORS = [ LaMetricSensorEntityDescription( key="rssi", + translation_key="rssi", name="Wi-Fi signal", icon="mdi:wifi", entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index eb90b21ff2..21cebe46f2 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -45,13 +45,38 @@ } }, "entity": { + "button": { + "app_next": { + "name": "Next app" + }, + "app_previous": { + "name": "Previous app" + }, + "dismiss_current": { + "name": "Dismiss current notification" + }, + "dismiss_all": { + "name": "Dismiss all notifications" + } + }, + "sensor": { + "rssi": { + "name": "Wi-Fi signal" + } + }, "select": { "brightness_mode": { + "name": "Brightness mode", "state": { "auto": "Automatic", "manual": "Manual" } } + }, + "switch": { + "bluetooth": { + "name": "Bluetooth" + } } } } diff --git a/homeassistant/components/lametric/switch.py b/homeassistant/components/lametric/switch.py index f6807648b7..c33ec16d61 100644 --- a/homeassistant/components/lametric/switch.py +++ b/homeassistant/components/lametric/switch.py @@ -39,7 +39,7 @@ class LaMetricSwitchEntityDescription( SWITCHES = [ LaMetricSwitchEntityDescription( key="bluetooth", - name="Bluetooth", + translation_key="bluetooth", icon="mdi:bluetooth", entity_category=EntityCategory.CONFIG, available_fn=lambda device: device.bluetooth.available, From e32d89215d39287f61a41e7c66d0681b1fe10c05 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Mar 2023 14:54:13 -1000 Subject: [PATCH 1484/2810] Fix migration when encountering a NULL entity_id/event_type (#90542) * Fix migration when encountering a NULL entity_id/event_type reported in #beta on discord * simplify --- .../components/recorder/migration.py | 30 ++-- tests/components/recorder/test_migrate.py | 150 +++++++++++++++++- 2 files changed, 168 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 4be0132765..fe1d7fdf91 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1439,12 +1439,15 @@ def migrate_event_type_ids(instance: Recorder) -> bool: with session_scope(session=session_maker()) as session: if events := session.execute(find_event_type_to_migrate()).all(): event_types = {event_type for _, event_type in events} + if None in event_types: + # event_type should never be None but we need to be defensive + # so we don't fail the migration because of a bad state + event_types.remove(None) + event_types.add(_EMPTY_EVENT_TYPE) + event_type_to_id = event_type_manager.get_many(event_types, session) if missing_event_types := { - # We should never see see None for the event_Type in the events table - # but we need to be defensive so we don't fail the migration - # because of a bad event - _EMPTY_EVENT_TYPE if event_type is None else event_type + event_type for event_type, event_id in event_type_to_id.items() if event_id is None }: @@ -1470,7 +1473,9 @@ def migrate_event_type_ids(instance: Recorder) -> bool: { "event_id": event_id, "event_type": None, - "event_type_id": event_type_to_id[event_type], + "event_type_id": event_type_to_id[ + _EMPTY_EVENT_TYPE if event_type is None else event_type + ], } for event_id, event_type in events ], @@ -1502,14 +1507,17 @@ def migrate_entity_ids(instance: Recorder) -> bool: with session_scope(session=instance.get_session()) as session: if states := session.execute(find_entity_ids_to_migrate()).all(): entity_ids = {entity_id for _, entity_id in states} + if None in entity_ids: + # entity_id should never be None but we need to be defensive + # so we don't fail the migration because of a bad state + entity_ids.remove(None) + entity_ids.add(_EMPTY_ENTITY_ID) + entity_id_to_metadata_id = states_meta_manager.get_many( entity_ids, session, True ) if missing_entity_ids := { - # We should never see _EMPTY_ENTITY_ID in the states table - # but we need to be defensive so we don't fail the migration - # because of a bad state - _EMPTY_ENTITY_ID if entity_id is None else entity_id + entity_id for entity_id, metadata_id in entity_id_to_metadata_id.items() if metadata_id is None }: @@ -1537,7 +1545,9 @@ def migrate_entity_ids(instance: Recorder) -> bool: # the history queries still need to work while the # migration is in progress and we will do this in # post_migrate_entity_ids - "metadata_id": entity_id_to_metadata_id[entity_id], + "metadata_id": entity_id_to_metadata_id[ + _EMPTY_ENTITY_ID if entity_id is None else entity_id + ], } for state_id, entity_id in states ], diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index fe4f1e016f..6e54513830 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -957,7 +957,7 @@ async def test_migrate_entity_ids( instance = await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) - def _insert_events(): + def _insert_states(): with session_scope(hass=hass) as session: session.add_all( ( @@ -979,7 +979,7 @@ async def test_migrate_entity_ids( ) ) - await instance.async_add_executor_job(_insert_events) + await instance.async_add_executor_job(_insert_states) await async_wait_recording_done(hass) # This is a threadsafe way to add a task to the recorder @@ -1065,3 +1065,149 @@ async def test_post_migrate_entity_ids( assert states_by_state["one_1"] is None assert states_by_state["two_2"] is None assert states_by_state["two_1"] is None + + +@pytest.mark.parametrize("enable_migrate_entity_ids", [True]) +async def test_migrate_null_entity_ids( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test we can migrate entity_ids to the StatesMeta table.""" + instance = await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + def _insert_states(): + with session_scope(hass=hass) as session: + session.add( + States( + entity_id="sensor.one", + state="one_1", + last_updated_ts=1.452529, + ), + ) + session.add_all( + States( + entity_id=None, + state="empty", + last_updated_ts=time + 1.452529, + ) + for time in range(1000) + ) + session.add( + States( + entity_id="sensor.one", + state="one_1", + last_updated_ts=2.452529, + ), + ) + + await instance.async_add_executor_job(_insert_states) + + await async_wait_recording_done(hass) + # This is a threadsafe way to add a task to the recorder + instance.queue_task(EntityIDMigrationTask()) + await async_recorder_block_till_done(hass) + await async_recorder_block_till_done(hass) + + def _fetch_migrated_states(): + with session_scope(hass=hass) as session: + states = ( + session.query( + States.state, + States.metadata_id, + States.last_updated_ts, + StatesMeta.entity_id, + ) + .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) + .all() + ) + assert len(states) == 1002 + result = {} + for state in states: + result.setdefault(state.entity_id, []).append( + { + "state_id": state.entity_id, + "last_updated_ts": state.last_updated_ts, + "state": state.state, + } + ) + return result + + states_by_entity_id = await instance.async_add_executor_job(_fetch_migrated_states) + assert len(states_by_entity_id[migration._EMPTY_ENTITY_ID]) == 1000 + assert len(states_by_entity_id["sensor.one"]) == 2 + + +@pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) +async def test_migrate_null_event_type_ids( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test we can migrate event_types to the EventTypes table when the event_type is NULL.""" + instance = await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + def _insert_events(): + with session_scope(hass=hass) as session: + session.add( + Events( + event_type="event_type_one", + origin_idx=0, + time_fired_ts=1.452529, + ), + ) + session.add_all( + Events( + event_type=None, + origin_idx=0, + time_fired_ts=time + 1.452529, + ) + for time in range(1000) + ) + session.add( + Events( + event_type="event_type_one", + origin_idx=0, + time_fired_ts=2.452529, + ), + ) + + await instance.async_add_executor_job(_insert_events) + + await async_wait_recording_done(hass) + # This is a threadsafe way to add a task to the recorder + + instance.queue_task(EventTypeIDMigrationTask()) + await async_recorder_block_till_done(hass) + await async_recorder_block_till_done(hass) + + def _fetch_migrated_events(): + with session_scope(hass=hass) as session: + events = ( + session.query(Events.event_id, Events.time_fired, EventTypes.event_type) + .filter( + Events.event_type_id.in_( + select_event_type_ids( + ( + "event_type_one", + migration._EMPTY_EVENT_TYPE, + ) + ) + ) + ) + .outerjoin(EventTypes, Events.event_type_id == EventTypes.event_type_id) + .all() + ) + assert len(events) == 1002 + result = {} + for event in events: + result.setdefault(event.event_type, []).append( + { + "event_id": event.event_id, + "time_fired": event.time_fired, + "event_type": event.event_type, + } + ) + return result + + events_by_type = await instance.async_add_executor_job(_fetch_migrated_events) + assert len(events_by_type["event_type_one"]) == 2 + assert len(events_by_type[migration._EMPTY_EVENT_TYPE]) == 1000 From aad1f4b7662811311ec70f6046325b025f0ee9ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Mar 2023 14:53:47 -1000 Subject: [PATCH 1485/2810] Handle garbage in the context_id column during migration (#90544) * Handle garbage in the context_id column during migration * Update homeassistant/components/recorder/migration.py * lint --- .../components/recorder/migration.py | 14 ++++-- tests/components/recorder/test_migrate.py | 45 ++++++++++++++++++- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index fe1d7fdf91..23382a9aeb 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1355,10 +1355,16 @@ def _context_id_to_bytes(context_id: str | None) -> bytes | None: """Convert a context_id to bytes.""" if context_id is None: return None - if len(context_id) == 32: - return UUID(context_id).bytes - if len(context_id) == 26: - return ulid_to_bytes(context_id) + with contextlib.suppress(ValueError): + # There may be garbage in the context_id column + # from custom integrations that are not UUIDs or + # ULIDs that filled the column to the max length + # so we need to catch the ValueError and return + # None if it happens + if len(context_id) == 32: + return UUID(context_id).bytes + if len(context_id) == 26: + return ulid_to_bytes(context_id) return None diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index 6e54513830..b75d536d15 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -671,6 +671,19 @@ async def test_migrate_events_context_ids( context_parent_id=None, context_parent_id_bin=None, ), + Events( + event_type="garbage_context_id_event", + event_data=None, + origin_idx=0, + time_fired=None, + time_fired_ts=1677721632.552529, + context_id="adapt_lgt:b'5Cf*':interval:b'0R'", + context_id_bin=None, + context_user_id=None, + context_user_id_bin=None, + context_parent_id=None, + context_parent_id_bin=None, + ), ) ) @@ -695,12 +708,13 @@ async def test_migrate_events_context_ids( "empty_context_id_event", "ulid_context_id_event", "invalid_context_id_event", + "garbage_context_id_event", ] ) ) .all() ) - assert len(events) == 4 + assert len(events) == 5 return {event.event_type: _object_as_dict(event) for event in events} events_by_type = await instance.async_add_executor_job(_fetch_migrated_events) @@ -746,6 +760,14 @@ async def test_migrate_events_context_ids( assert invalid_context_id_event["context_user_id_bin"] is None assert invalid_context_id_event["context_parent_id_bin"] is None + garbage_context_id_event = events_by_type["garbage_context_id_event"] + assert garbage_context_id_event["context_id"] is None + assert garbage_context_id_event["context_user_id"] is None + assert garbage_context_id_event["context_parent_id"] is None + assert garbage_context_id_event["context_id_bin"] == b"\x00" * 16 + assert garbage_context_id_event["context_user_id_bin"] is None + assert garbage_context_id_event["context_parent_id_bin"] is None + @pytest.mark.parametrize("enable_migrate_context_ids", [True]) async def test_migrate_states_context_ids( @@ -803,6 +825,16 @@ async def test_migrate_states_context_ids( context_parent_id=None, context_parent_id_bin=None, ), + States( + entity_id="state.garbage_context_id", + last_updated_ts=1677721632.552529, + context_id="adapt_lgt:b'5Cf*':interval:b'0R'", + context_id_bin=None, + context_user_id=None, + context_user_id_bin=None, + context_parent_id=None, + context_parent_id_bin=None, + ), ) ) @@ -827,12 +859,13 @@ async def test_migrate_states_context_ids( "state.empty_context_id", "state.ulid_context_id", "state.invalid_context_id", + "state.garbage_context_id", ] ) ) .all() ) - assert len(events) == 4 + assert len(events) == 5 return {state.entity_id: _object_as_dict(state) for state in events} states_by_entity_id = await instance.async_add_executor_job(_fetch_migrated_states) @@ -877,6 +910,14 @@ async def test_migrate_states_context_ids( assert invalid_context_id["context_user_id_bin"] is None assert invalid_context_id["context_parent_id_bin"] is None + garbage_context_id = states_by_entity_id["state.garbage_context_id"] + assert garbage_context_id["context_id"] is None + assert garbage_context_id["context_user_id"] is None + assert garbage_context_id["context_parent_id"] is None + assert garbage_context_id["context_id_bin"] == b"\x00" * 16 + assert garbage_context_id["context_user_id_bin"] is None + assert garbage_context_id["context_parent_id_bin"] is None + @pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) async def test_migrate_event_type_ids( From 4bf10c01f0df524c8048985c354f2159102eadb3 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 30 Mar 2023 20:55:01 -0400 Subject: [PATCH 1486/2810] Bump ZHA dependencies (#90547) * Bump ZHA dependencies * Ensure the network is formed on channel 15 when multi-PAN is in use --- homeassistant/components/zha/core/const.py | 2 ++ homeassistant/components/zha/core/gateway.py | 16 +++++++++++ homeassistant/components/zha/manifest.json | 10 +++---- requirements_all.txt | 10 +++---- requirements_test_all.txt | 10 +++---- tests/components/zha/test_gateway.py | 29 ++++++++++++++++++++ 6 files changed, 62 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 4c10a2328a..6423723d32 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -137,6 +137,8 @@ CONF_GROUP_MEMBERS_ASSUME_STATE = "group_members_assume_state" CONF_ENABLE_IDENTIFY_ON_JOIN = "enable_identify_on_join" CONF_ENABLE_QUIRKS = "enable_quirks" CONF_FLOWCONTROL = "flow_control" +CONF_NWK = "network" +CONF_NWK_CHANNEL = "channel" CONF_RADIO_TYPE = "radio_type" CONF_USB_PATH = "usb_path" CONF_USE_THREAD = "use_thread" diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 3f9ada1ed0..8858ea6959 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -41,6 +41,8 @@ from .const import ( ATTR_TYPE, CONF_DATABASE, CONF_DEVICE_PATH, + CONF_NWK, + CONF_NWK_CHANNEL, CONF_RADIO_TYPE, CONF_USE_THREAD, CONF_ZIGPY, @@ -172,6 +174,20 @@ class ZHAGateway: ): app_config[CONF_USE_THREAD] = False + # Local import to avoid circular dependencies + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( + is_multiprotocol_url, + ) + + # Until we have a way to coordinate channels with the Thread half of multi-PAN, + # stick to the old zigpy default of channel 15 instead of dynamically scanning + if ( + is_multiprotocol_url(app_config[CONF_DEVICE][CONF_DEVICE_PATH]) + and app_config.get(CONF_NWK, {}).get(CONF_NWK_CHANNEL) is None + ): + app_config.setdefault(CONF_NWK, {})[CONF_NWK_CHANNEL] = 15 + return app_controller_cls, app_controller_cls.SCHEMA(app_config) async def async_initialize(self) -> None: diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index d82fe5ed0f..bc5bf6a6d4 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -20,15 +20,15 @@ "zigpy_znp" ], "requirements": [ - "bellows==0.34.10", + "bellows==0.35.0", "pyserial==3.5", "pyserial-asyncio==0.6", "zha-quirks==0.0.95", - "zigpy-deconz==0.19.2", - "zigpy==0.53.2", - "zigpy-xbee==0.16.2", + "zigpy-deconz==0.20.0", + "zigpy==0.54.0", + "zigpy-xbee==0.17.0", "zigpy-zigate==0.10.3", - "zigpy-znp==0.9.3" + "zigpy-znp==0.10.0" ], "usb": [ { diff --git a/requirements_all.txt b/requirements_all.txt index 3cbd6bd365..8706e4e5f9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ beautifulsoup4==4.11.1 # beewi_smartclim==0.0.10 # homeassistant.components.zha -bellows==0.34.10 +bellows==0.35.0 # homeassistant.components.bmw_connected_drive bimmer_connected==0.13.0 @@ -2710,19 +2710,19 @@ zhong_hong_hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-deconz==0.19.2 +zigpy-deconz==0.20.0 # homeassistant.components.zha -zigpy-xbee==0.16.2 +zigpy-xbee==0.17.0 # homeassistant.components.zha zigpy-zigate==0.10.3 # homeassistant.components.zha -zigpy-znp==0.9.3 +zigpy-znp==0.10.0 # homeassistant.components.zha -zigpy==0.53.2 +zigpy==0.54.0 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b2b78b4541..4b1ce6ec33 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -355,7 +355,7 @@ base36==0.1.1 beautifulsoup4==4.11.1 # homeassistant.components.zha -bellows==0.34.10 +bellows==0.35.0 # homeassistant.components.bmw_connected_drive bimmer_connected==0.13.0 @@ -1938,19 +1938,19 @@ zeversolar==0.3.1 zha-quirks==0.0.95 # homeassistant.components.zha -zigpy-deconz==0.19.2 +zigpy-deconz==0.20.0 # homeassistant.components.zha -zigpy-xbee==0.16.2 +zigpy-xbee==0.17.0 # homeassistant.components.zha zigpy-zigate==0.10.3 # homeassistant.components.zha -zigpy-znp==0.9.3 +zigpy-znp==0.10.0 # homeassistant.components.zha -zigpy==0.53.2 +zigpy==0.54.0 # homeassistant.components.zwave_js zwave-js-server-python==0.47.1 diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index 392c589ea1..be53b22be6 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -323,3 +323,32 @@ async def test_gateway_initialize_bellows_thread( await zha_gateway.async_initialize() assert mock_new.mock_calls[0].args[0]["use_thread"] is thread_state + + +@pytest.mark.parametrize( + ("device_path", "config_override", "expected_channel"), + [ + ("/dev/ttyUSB0", {}, None), + ("socket://192.168.1.123:9999", {}, None), + ("socket://192.168.1.123:9999", {"network": {"channel": 20}}, 20), + ("socket://core-silabs-multiprotocol:9999", {}, 15), + ("socket://core-silabs-multiprotocol:9999", {"network": {"channel": 20}}, 20), + ], +) +async def test_gateway_force_multi_pan_channel( + device_path: str, + config_override: dict, + expected_channel: int | None, + hass: HomeAssistant, + coordinator, +) -> None: + """Test ZHA disabling the UART thread when connecting to a TCP coordinator.""" + zha_gateway = get_zha_gateway(hass) + assert zha_gateway is not None + + zha_gateway.config_entry.data = dict(zha_gateway.config_entry.data) + zha_gateway.config_entry.data["device"]["path"] = device_path + zha_gateway._config.setdefault("zigpy_config", {}).update(config_override) + + _, config = zha_gateway.get_application_controller_data() + assert config["network"]["channel"] == expected_channel From e7e2532c6897854c8a4a6ca0fa9e709d5c50e01f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 Mar 2023 20:55:55 -0400 Subject: [PATCH 1487/2810] Bumped version to 2023.4.0b2 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index fba1d65459..b47e1d9fb5 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -8,7 +8,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 4 -PATCH_VERSION: Final = "0b1" +PATCH_VERSION: Final = "0b2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 73d680092f..76c1f18616 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.4.0b1" +version = "2023.4.0b2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From ed673a1b352add4dba73008c4669c68e86d3a34c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Mar 2023 21:05:56 -1000 Subject: [PATCH 1488/2810] Avoid creating a task on callback in owntracks when using mqtt (#90548) Nothing was being awaited in the callback. It did not need to be a coro --- homeassistant/components/owntracks/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py index 6086ee1efd..560493888d 100644 --- a/homeassistant/components/owntracks/__init__.py +++ b/homeassistant/components/owntracks/__init__.py @@ -24,6 +24,7 @@ from homeassistant.helpers.dispatcher import ( ) from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_when_setup +from homeassistant.util.json import json_loads from .config_flow import CONF_SECRET from .const import DOMAIN @@ -133,10 +134,11 @@ async def async_connect_mqtt(hass, component): """Subscribe to MQTT topic.""" context = hass.data[DOMAIN]["context"] - async def async_handle_mqtt_message(msg): + @callback + def async_handle_mqtt_message(msg): """Handle incoming OwnTracks message.""" try: - message = json.loads(msg.payload) + message = json_loads(msg.payload) except ValueError: # If invalid JSON _LOGGER.error("Unable to parse payload as JSON: %s", msg.payload) From 2e0ecf9bd9a5414683f04d0334c4c2ed35d1ad9a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Mar 2023 21:10:55 -1000 Subject: [PATCH 1489/2810] Avoid more task creation in the discovery helper (#90552) * Avoid more task creation in the discovery helper There is no longer a reason to awaiti the jobs being dispatched since nothing was using the result and there is no risk of job being garbage collected prematurely anymore since the task revamp * Update homeassistant/helpers/discovery.py --- homeassistant/helpers/discovery.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/homeassistant/helpers/discovery.py b/homeassistant/helpers/discovery.py index 824b1de701..b7db5ba69f 100644 --- a/homeassistant/helpers/discovery.py +++ b/homeassistant/helpers/discovery.py @@ -46,16 +46,15 @@ def async_listen( """ job = core.HassJob(callback, f"discovery listener {service}") - async def discovery_event_listener(discovered: DiscoveryDict) -> None: + @core.callback + def _async_discovery_event_listener(discovered: DiscoveryDict) -> None: """Listen for discovery events.""" - task = hass.async_run_hass_job( - job, discovered["service"], discovered["discovered"] - ) - if task: - await task + hass.async_run_hass_job(job, discovered["service"], discovered["discovered"]) async_dispatcher_connect( - hass, SIGNAL_PLATFORM_DISCOVERED.format(service), discovery_event_listener + hass, + SIGNAL_PLATFORM_DISCOVERED.format(service), + _async_discovery_event_listener, ) @@ -105,17 +104,17 @@ def async_listen_platform( service = EVENT_LOAD_PLATFORM.format(component) job = core.HassJob(callback, f"platform loaded {component}") - async def discovery_platform_listener(discovered: DiscoveryDict) -> None: + @core.callback + def _async_discovery_platform_listener(discovered: DiscoveryDict) -> None: """Listen for platform discovery events.""" if not (platform := discovered["platform"]): return - - task = hass.async_run_hass_job(job, platform, discovered.get("discovered")) - if task: - await task + hass.async_run_hass_job(job, platform, discovered.get("discovered")) return async_dispatcher_connect( - hass, SIGNAL_PLATFORM_DISCOVERED.format(service), discovery_platform_listener + hass, + SIGNAL_PLATFORM_DISCOVERED.format(service), + _async_discovery_platform_listener, ) From d0c38c1e12db38bc62d8286ced4fe1dd14b7ef2b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 31 Mar 2023 09:34:17 +0200 Subject: [PATCH 1490/2810] Move icon constants to entity attributes (#90518) * Move icon constants to attribute * Adjust test --- homeassistant/components/currencylayer/sensor.py | 7 +------ .../components/dublin_bus_transport/sensor.py | 8 ++------ homeassistant/components/fixer/sensor.py | 10 ++-------- homeassistant/components/gitter/sensor.py | 8 ++------ .../homekit_controller/alarm_control_panel.py | 8 +------- .../components/irish_rail_transport/sensor.py | 8 ++------ homeassistant/components/lastfm/sensor.py | 7 +------ homeassistant/components/london_underground/sensor.py | 7 +------ .../components/nederlandse_spoorwegen/sensor.py | 7 +------ homeassistant/components/neurio_energy/sensor.py | 8 ++------ homeassistant/components/numato/sensor.py | 9 ++------- homeassistant/components/oasa_telematics/sensor.py | 8 ++------ homeassistant/components/otp/sensor.py | 7 +------ homeassistant/components/pocketcasts/sensor.py | 8 ++------ homeassistant/components/random/sensor.py | 8 ++------ homeassistant/components/rejseplanen/sensor.py | 8 ++------ homeassistant/components/simulated/sensor.py | 9 ++------- homeassistant/components/smappee/switch.py | 8 ++------ homeassistant/components/srp_energy/const.py | 4 +--- homeassistant/components/srp_energy/sensor.py | 10 ++-------- homeassistant/components/starlingbank/sensor.py | 9 +++------ .../components/swiss_public_transport/sensor.py | 7 +------ homeassistant/components/tmb/sensor.py | 8 +------- homeassistant/components/utility_meter/sensor.py | 7 +------ homeassistant/components/vasttrafik/sensor.py | 7 +------ homeassistant/components/xbox_live/sensor.py | 7 +------ homeassistant/components/yandex_transport/sensor.py | 8 ++------ homeassistant/components/zestimate/sensor.py | 7 +------ tests/components/srp_energy/test_sensor.py | 6 ++---- 29 files changed, 46 insertions(+), 177 deletions(-) diff --git a/homeassistant/components/currencylayer/sensor.py b/homeassistant/components/currencylayer/sensor.py index 9905228c26..b4a3339289 100644 --- a/homeassistant/components/currencylayer/sensor.py +++ b/homeassistant/components/currencylayer/sensor.py @@ -20,7 +20,6 @@ _RESOURCE = "http://apilayer.net/api/live" DEFAULT_BASE = "USD" DEFAULT_NAME = "CurrencyLayer Sensor" -ICON = "mdi:currency" SCAN_INTERVAL = timedelta(hours=4) @@ -60,6 +59,7 @@ class CurrencylayerSensor(SensorEntity): """Implementing the Currencylayer sensor.""" _attr_attribution = "Data provided by currencylayer.com" + _attr_icon = "mdi:currency" def __init__(self, rest, base, quote): """Initialize the sensor.""" @@ -78,11 +78,6 @@ class CurrencylayerSensor(SensorEntity): """Return the name of the sensor.""" return self._base - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - @property def native_value(self): """Return the state of the sensor.""" diff --git a/homeassistant/components/dublin_bus_transport/sensor.py b/homeassistant/components/dublin_bus_transport/sensor.py index 60d058220a..b50bd60476 100644 --- a/homeassistant/components/dublin_bus_transport/sensor.py +++ b/homeassistant/components/dublin_bus_transport/sensor.py @@ -32,7 +32,7 @@ CONF_STOP_ID = "stopid" CONF_ROUTE = "route" DEFAULT_NAME = "Next Bus" -ICON = "mdi:bus" + SCAN_INTERVAL = timedelta(minutes=1) TIME_STR_FORMAT = "%H:%M" @@ -77,6 +77,7 @@ class DublinPublicTransportSensor(SensorEntity): """Implementation of an Dublin public transport sensor.""" _attr_attribution = "Data provided by data.dublinked.ie" + _attr_icon = "mdi:bus" def __init__(self, data, stop, route, name): """Initialize the sensor.""" @@ -118,11 +119,6 @@ class DublinPublicTransportSensor(SensorEntity): """Return the unit this state is expressed in.""" return UnitOfTime.MINUTES - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data from opendata.ch and update the states.""" self.data.update() diff --git a/homeassistant/components/fixer/sensor.py b/homeassistant/components/fixer/sensor.py index 234f03812f..8091f8981e 100644 --- a/homeassistant/components/fixer/sensor.py +++ b/homeassistant/components/fixer/sensor.py @@ -19,12 +19,10 @@ _LOGGER = logging.getLogger(__name__) ATTR_EXCHANGE_RATE = "Exchange rate" ATTR_TARGET = "Target currency" -ATTRIBUTION = "Data provided by the European Central Bank (ECB)" DEFAULT_BASE = "USD" DEFAULT_NAME = "Exchange rate" -ICON = "mdi:currency-usd" SCAN_INTERVAL = timedelta(days=1) @@ -61,7 +59,8 @@ def setup_platform( class ExchangeRateSensor(SensorEntity): """Representation of a Exchange sensor.""" - _attr_attribution = ATTRIBUTION + _attr_attribution = "Data provided by the European Central Bank (ECB)" + _attr_icon = "mdi:currency-usd" def __init__(self, data, name, target): """Initialize the sensor.""" @@ -94,11 +93,6 @@ class ExchangeRateSensor(SensorEntity): ATTR_TARGET: self._target, } - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data and updates the states.""" self.data.update() diff --git a/homeassistant/components/gitter/sensor.py b/homeassistant/components/gitter/sensor.py index 514cb9e0ad..db5b189d5e 100644 --- a/homeassistant/components/gitter/sensor.py +++ b/homeassistant/components/gitter/sensor.py @@ -23,7 +23,6 @@ ATTR_USERNAME = "username" DEFAULT_NAME = "Gitter messages" DEFAULT_ROOM = "home-assistant/home-assistant" -ICON = "mdi:message-cog" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -59,6 +58,8 @@ def setup_platform( class GitterSensor(SensorEntity): """Representation of a Gitter sensor.""" + _attr_icon = "mdi:message-cog" + def __init__(self, data, room, name, username): """Initialize the sensor.""" self._name = name @@ -93,11 +94,6 @@ class GitterSensor(SensorEntity): ATTR_MENTION: self._mention, } - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data and updates the state.""" diff --git a/homeassistant/components/homekit_controller/alarm_control_panel.py b/homeassistant/components/homekit_controller/alarm_control_panel.py index a466d15db5..a741cf5492 100644 --- a/homeassistant/components/homekit_controller/alarm_control_panel.py +++ b/homeassistant/components/homekit_controller/alarm_control_panel.py @@ -27,8 +27,6 @@ from . import KNOWN_DEVICES from .connection import HKDevice from .entity import HomeKitEntity -ICON = "mdi:security" - CURRENT_STATE_MAP = { 0: STATE_ALARM_ARMED_HOME, 1: STATE_ALARM_ARMED_AWAY, @@ -72,6 +70,7 @@ async def async_setup_entry( class HomeKitAlarmControlPanelEntity(HomeKitEntity, AlarmControlPanelEntity): """Representation of a Homekit Alarm Control Panel.""" + _attr_icon = "mdi:security" _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY @@ -86,11 +85,6 @@ class HomeKitAlarmControlPanelEntity(HomeKitEntity, AlarmControlPanelEntity): CharacteristicsTypes.BATTERY_LEVEL, ] - @property - def icon(self) -> str: - """Return icon.""" - return ICON - @property def state(self) -> str: """Return the state of the device.""" diff --git a/homeassistant/components/irish_rail_transport/sensor.py b/homeassistant/components/irish_rail_transport/sensor.py index 7ac30cc5a2..70b53b80d9 100644 --- a/homeassistant/components/irish_rail_transport/sensor.py +++ b/homeassistant/components/irish_rail_transport/sensor.py @@ -30,7 +30,7 @@ CONF_DIRECTION = "direction" CONF_STOPS_AT = "stops_at" DEFAULT_NAME = "Next Train" -ICON = "mdi:train" + SCAN_INTERVAL = timedelta(minutes=2) TIME_STR_FORMAT = "%H:%M" @@ -76,6 +76,7 @@ class IrishRailTransportSensor(SensorEntity): """Implementation of an irish rail public transport sensor.""" _attr_attribution = "Data provided by Irish Rail" + _attr_icon = "mdi:train" def __init__(self, data, station, direction, destination, stops_at, name): """Initialize the sensor.""" @@ -128,11 +129,6 @@ class IrishRailTransportSensor(SensorEntity): """Return the unit this state is expressed in.""" return UnitOfTime.MINUTES - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data and update the states.""" self.data.update() diff --git a/homeassistant/components/lastfm/sensor.py b/homeassistant/components/lastfm/sensor.py index 497ccf817b..70f4c22cad 100644 --- a/homeassistant/components/lastfm/sensor.py +++ b/homeassistant/components/lastfm/sensor.py @@ -26,7 +26,6 @@ STATE_NOT_SCROBBLING = "Not Scrobbling" CONF_USERS = "users" -ICON = "mdi:radio-fm" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -64,6 +63,7 @@ class LastfmSensor(SensorEntity): """A class for the Last.fm account.""" _attr_attribution = "Data provided by Last.fm" + _attr_icon = "mdi:radio-fm" def __init__(self, user, lastfm_api): """Initialize the sensor.""" @@ -127,8 +127,3 @@ class LastfmSensor(SensorEntity): def entity_picture(self): """Avatar of the user.""" return self._cover - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return ICON diff --git a/homeassistant/components/london_underground/sensor.py b/homeassistant/components/london_underground/sensor.py index 2cad8e9a10..8217b3913a 100644 --- a/homeassistant/components/london_underground/sensor.py +++ b/homeassistant/components/london_underground/sensor.py @@ -26,7 +26,6 @@ DOMAIN = "london_underground" CONF_LINE = "line" -ICON = "mdi:subway" SCAN_INTERVAL = timedelta(seconds=30) @@ -100,6 +99,7 @@ class LondonTubeSensor(CoordinatorEntity[LondonTubeCoordinator], SensorEntity): """Sensor that reads the status of a line from Tube Data.""" _attr_attribution = "Powered by TfL Open Data" + _attr_icon = "mdi:subway" def __init__(self, coordinator, name): """Initialize the London Underground sensor.""" @@ -116,11 +116,6 @@ class LondonTubeSensor(CoordinatorEntity[LondonTubeCoordinator], SensorEntity): """Return the state of the sensor.""" return self.coordinator.data[self.name]["State"] - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - @property def extra_state_attributes(self): """Return other details about the sensor state.""" diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py index 7f4fbdfae7..f0c782bc1b 100644 --- a/homeassistant/components/nederlandse_spoorwegen/sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py @@ -26,7 +26,6 @@ CONF_TO = "to" CONF_VIA = "via" CONF_TIME = "time" -ICON = "mdi:train" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) @@ -104,6 +103,7 @@ class NSDepartureSensor(SensorEntity): """Implementation of a NS Departure Sensor.""" _attr_attribution = "Data provided by NS" + _attr_icon = "mdi:train" def __init__(self, nsapi, name, departure, heading, via, time): """Initialize the sensor.""" @@ -121,11 +121,6 @@ class NSDepartureSensor(SensorEntity): """Return the name of the sensor.""" return self._name - @property - def icon(self): - """Return the icon for the frontend.""" - return ICON - @property def native_value(self): """Return the next departure time.""" diff --git a/homeassistant/components/neurio_energy/sensor.py b/homeassistant/components/neurio_energy/sensor.py index 52f6d1d722..a9023ffca2 100644 --- a/homeassistant/components/neurio_energy/sensor.py +++ b/homeassistant/components/neurio_energy/sensor.py @@ -33,7 +33,6 @@ DAILY_NAME = "Daily Energy Usage" ACTIVE_TYPE = "active" DAILY_TYPE = "daily" -ICON = "mdi:flash" MIN_TIME_BETWEEN_DAILY_UPDATES = timedelta(seconds=150) MIN_TIME_BETWEEN_ACTIVE_UPDATES = timedelta(seconds=10) @@ -140,6 +139,8 @@ class NeurioData: class NeurioEnergy(SensorEntity): """Implementation of a Neurio energy sensor.""" + _attr_icon = "mdi:flash" + def __init__(self, data, name, sensor_type, update_call): """Initialize the sensor.""" self._name = name @@ -172,11 +173,6 @@ class NeurioEnergy(SensorEntity): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data, update state.""" self.update_sensor() diff --git a/homeassistant/components/numato/sensor.py b/homeassistant/components/numato/sensor.py index 4ac28e0761..44adb78e6a 100644 --- a/homeassistant/components/numato/sensor.py +++ b/homeassistant/components/numato/sensor.py @@ -23,8 +23,6 @@ from . import ( _LOGGER = logging.getLogger(__name__) -ICON = "mdi:gauge" - def setup_platform( hass: HomeAssistant, @@ -71,6 +69,8 @@ def setup_platform( class NumatoGpioAdc(SensorEntity): """Represents an ADC port of a Numato USB GPIO expander.""" + _attr_icon = "mdi:gauge" + def __init__(self, name, device_id, port, src_range, dst_range, dst_unit, api): """Initialize the sensor.""" self._name = name @@ -97,11 +97,6 @@ class NumatoGpioAdc(SensorEntity): """Return the unit the value is expressed in.""" return self._unit_of_measurement - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data and updates the state.""" try: diff --git a/homeassistant/components/oasa_telematics/sensor.py b/homeassistant/components/oasa_telematics/sensor.py index 664ad033cf..b910964594 100644 --- a/homeassistant/components/oasa_telematics/sensor.py +++ b/homeassistant/components/oasa_telematics/sensor.py @@ -34,7 +34,7 @@ CONF_STOP_ID = "stop_id" CONF_ROUTE_ID = "route_id" DEFAULT_NAME = "OASA Telematics" -ICON = "mdi:bus" + SCAN_INTERVAL = timedelta(seconds=60) @@ -67,6 +67,7 @@ class OASATelematicsSensor(SensorEntity): """Implementation of the OASA Telematics sensor.""" _attr_attribution = "Data retrieved from telematics.oasa.gr" + _attr_icon = "mdi:bus" def __init__(self, data, stop_id, route_id, name): """Initialize the sensor.""" @@ -121,11 +122,6 @@ class OASATelematicsSensor(SensorEntity): ) return {k: v for k, v in params.items() if v} - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data from OASA API and update the states.""" self.data.update() diff --git a/homeassistant/components/otp/sensor.py b/homeassistant/components/otp/sensor.py index 499c9b129f..7c7c30df97 100644 --- a/homeassistant/components/otp/sensor.py +++ b/homeassistant/components/otp/sensor.py @@ -17,7 +17,6 @@ DEFAULT_NAME = "OTP Sensor" TIME_STEP = 30 # Default time step assumed by Google Authenticator -ICON = "mdi:update" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -44,6 +43,7 @@ async def async_setup_platform( class TOTPSensor(SensorEntity): """Representation of a TOTP sensor.""" + _attr_icon = "mdi:update" _attr_should_poll = False def __init__(self, name, token): @@ -76,8 +76,3 @@ class TOTPSensor(SensorEntity): def native_value(self): """Return the state of the sensor.""" return self._state - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return ICON diff --git a/homeassistant/components/pocketcasts/sensor.py b/homeassistant/components/pocketcasts/sensor.py index 3962ae4c06..c541e2cc0f 100644 --- a/homeassistant/components/pocketcasts/sensor.py +++ b/homeassistant/components/pocketcasts/sensor.py @@ -16,7 +16,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) -ICON = "mdi:rss" SENSOR_NAME = "Pocketcasts unlistened episodes" @@ -48,6 +47,8 @@ def setup_platform( class PocketCastsSensor(SensorEntity): """Representation of a pocket casts sensor.""" + _attr_icon = "mdi:rss" + def __init__(self, api): """Initialize the sensor.""" self._api = api @@ -63,11 +64,6 @@ class PocketCastsSensor(SensorEntity): """Return the sensor state.""" return self._state - @property - def icon(self): - """Return the icon for the sensor.""" - return ICON - def update(self) -> None: """Update sensor values.""" try: diff --git a/homeassistant/components/random/sensor.py b/homeassistant/components/random/sensor.py index 19cf403eab..d4db30fd61 100644 --- a/homeassistant/components/random/sensor.py +++ b/homeassistant/components/random/sensor.py @@ -24,7 +24,6 @@ DEFAULT_NAME = "Random Sensor" DEFAULT_MIN = 0 DEFAULT_MAX = 20 -ICON = "mdi:hanger" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -54,6 +53,8 @@ async def async_setup_platform( class RandomSensor(SensorEntity): """Representation of a Random number sensor.""" + _attr_icon = "mdi:hanger" + def __init__(self, name, minimum, maximum, unit_of_measurement): """Initialize the Random sensor.""" self._name = name @@ -72,11 +73,6 @@ class RandomSensor(SensorEntity): """Return the state of the device.""" return self._state - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - @property def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" diff --git a/homeassistant/components/rejseplanen/sensor.py b/homeassistant/components/rejseplanen/sensor.py index 9db7c6ff10..135205aa95 100644 --- a/homeassistant/components/rejseplanen/sensor.py +++ b/homeassistant/components/rejseplanen/sensor.py @@ -42,7 +42,7 @@ CONF_DIRECTION = "direction" CONF_DEPARTURE_TYPE = "departure_type" DEFAULT_NAME = "Next departure" -ICON = "mdi:bus" + SCAN_INTERVAL = timedelta(minutes=1) @@ -98,6 +98,7 @@ class RejseplanenTransportSensor(SensorEntity): """Implementation of Rejseplanen transport sensor.""" _attr_attribution = "Data provided by rejseplanen.dk" + _attr_icon = "mdi:bus" def __init__(self, data, stop_id, route, direction, name): """Initialize the sensor.""" @@ -143,11 +144,6 @@ class RejseplanenTransportSensor(SensorEntity): """Return the unit this state is expressed in.""" return UnitOfTime.MINUTES - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self) -> None: """Get the latest data from rejseplanen.dk and update the states.""" self.data.update() diff --git a/homeassistant/components/simulated/sensor.py b/homeassistant/components/simulated/sensor.py index f2e64655ac..0f9db48e78 100644 --- a/homeassistant/components/simulated/sensor.py +++ b/homeassistant/components/simulated/sensor.py @@ -34,8 +34,6 @@ DEFAULT_SEED = 999 DEFAULT_UNIT = "value" DEFAULT_RELATIVE_TO_EPOCH = True -ICON = "mdi:chart-line" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_AMP, default=DEFAULT_AMP): vol.Coerce(float), @@ -79,6 +77,8 @@ def setup_platform( class SimulatedSensor(SensorEntity): """Class for simulated sensor.""" + _attr_icon = "mdi:chart-line" + def __init__( self, name, unit, amp, mean, period, phase, fwhm, seed, relative_to_epoch ): @@ -135,11 +135,6 @@ class SimulatedSensor(SensorEntity): """Return the state of the sensor.""" return self._state - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - @property def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" diff --git a/homeassistant/components/smappee/switch.py b/homeassistant/components/smappee/switch.py index b179daaf1a..828e4a6812 100644 --- a/homeassistant/components/smappee/switch.py +++ b/homeassistant/components/smappee/switch.py @@ -10,7 +10,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN SWITCH_PREFIX = "Switch" -ICON = "mdi:toggle-switch" async def async_setup_entry( @@ -55,6 +54,8 @@ async def async_setup_entry( class SmappeeActuator(SwitchEntity): """Representation of a Smappee Comport Plug.""" + _attr_icon = "mdi:toggle-switch" + def __init__( self, smappee_base, @@ -105,11 +106,6 @@ class SmappeeActuator(SwitchEntity): # Switch or comfort plug return self._state == "ON_ON" - @property - def icon(self): - """Icon to use in the frontend.""" - return ICON - def turn_on(self, **kwargs: Any) -> None: """Turn on Comport Plug.""" if self._actuator_type in ("SWITCH", "COMFORT_PLUG"): diff --git a/homeassistant/components/srp_energy/const.py b/homeassistant/components/srp_energy/const.py index 527a1ed78b..cbc7078616 100644 --- a/homeassistant/components/srp_energy/const.py +++ b/homeassistant/components/srp_energy/const.py @@ -6,10 +6,8 @@ DEFAULT_NAME = "SRP Energy" CONF_IS_TOU = "is_tou" -ATTRIBUTION = "Powered by SRP Energy" + MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1440) SENSOR_NAME = "Usage" SENSOR_TYPE = "usage" - -ICON = "mdi:flash" diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py index 1aaf5175e5..a919bba1b2 100644 --- a/homeassistant/components/srp_energy/sensor.py +++ b/homeassistant/components/srp_energy/sensor.py @@ -17,9 +17,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( - ATTRIBUTION, DEFAULT_NAME, - ICON, MIN_TIME_BETWEEN_UPDATES, SENSOR_NAME, SENSOR_TYPE, @@ -83,7 +81,8 @@ async def async_setup_entry( class SrpEntity(SensorEntity): """Implementation of a Srp Energy Usage sensor.""" - _attr_attribution = ATTRIBUTION + _attr_attribution = "Powered by SRP Energy" + _attr_icon = "mdi:flash" _attr_should_poll = False def __init__(self, coordinator): @@ -116,11 +115,6 @@ class SrpEntity(SensorEntity): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement - @property - def icon(self): - """Return icon.""" - return ICON - @property def usage(self): """Return entity state.""" diff --git a/homeassistant/components/starlingbank/sensor.py b/homeassistant/components/starlingbank/sensor.py index 350c420d5d..f4a8783787 100644 --- a/homeassistant/components/starlingbank/sensor.py +++ b/homeassistant/components/starlingbank/sensor.py @@ -26,7 +26,7 @@ CONF_SANDBOX = "sandbox" DEFAULT_SANDBOX = False DEFAULT_ACCOUNT_NAME = "Starling" -ICON = "mdi:currency-gbp" + SCAN_INTERVAL = timedelta(seconds=180) ACCOUNT_SCHEMA = vol.Schema( @@ -76,6 +76,8 @@ def setup_platform( class StarlingBalanceSensor(SensorEntity): """Representation of a Starling balance sensor.""" + _attr_icon = "mdi:currency-gbp" + def __init__(self, starling_account, account_name, balance_data_type): """Initialize the sensor.""" self._starling_account = starling_account @@ -100,11 +102,6 @@ class StarlingBalanceSensor(SensorEntity): """Return the unit of measurement.""" return self._starling_account.currency - @property - def icon(self): - """Return the entity icon.""" - return ICON - def update(self) -> None: """Fetch new state data for the sensor.""" self._starling_account.update_balance_data() diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py index 8735726f89..12007e1741 100644 --- a/homeassistant/components/swiss_public_transport/sensor.py +++ b/homeassistant/components/swiss_public_transport/sensor.py @@ -35,7 +35,6 @@ CONF_START = "from" DEFAULT_NAME = "Next Departure" -ICON = "mdi:bus" SCAN_INTERVAL = timedelta(seconds=90) @@ -79,6 +78,7 @@ class SwissPublicTransportSensor(SensorEntity): """Implementation of an Swiss public transport sensor.""" _attr_attribution = "Data provided by transport.opendata.ch" + _attr_icon = "mdi:bus" def __init__(self, opendata, start, destination, name): """Initialize the sensor.""" @@ -125,11 +125,6 @@ class SwissPublicTransportSensor(SensorEntity): ATTR_DELAY: self._opendata.connections[0]["delay"], } - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - async def async_update(self) -> None: """Get the latest data from opendata.ch and update the states.""" diff --git a/homeassistant/components/tmb/sensor.py b/homeassistant/components/tmb/sensor.py index dd94b4c11b..7fe8630cc9 100644 --- a/homeassistant/components/tmb/sensor.py +++ b/homeassistant/components/tmb/sensor.py @@ -18,8 +18,6 @@ from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -ICON = "mdi:bus-clock" - CONF_APP_ID = "app_id" CONF_APP_KEY = "app_key" CONF_LINE = "line" @@ -74,6 +72,7 @@ class TMBSensor(SensorEntity): """Implementation of a TMB line/stop Sensor.""" _attr_attribution = "Data provided by Transport Metropolitans de Barcelona" + _attr_icon = "mdi:bus-clock" def __init__(self, ibus_client, stop, line, name): """Initialize the sensor.""" @@ -89,11 +88,6 @@ class TMBSensor(SensorEntity): """Return the name of the sensor.""" return self._name - @property - def icon(self): - """Return the icon for the frontend.""" - return ICON - @property def native_unit_of_measurement(self): """Return the unit of measurement.""" diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index dad2d8dfaf..dd0fb685ba 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -94,7 +94,6 @@ DEVICE_CLASS_MAP = { UnitOfEnergy.KILO_WATT_HOUR: SensorDeviceClass.ENERGY, } -ICON = "mdi:counter" PRECISION = 3 PAUSED = "paused" @@ -323,6 +322,7 @@ class UtilitySensorExtraStoredData(SensorExtraStoredData): class UtilityMeterSensor(RestoreSensor): """Representation of an utility meter sensor.""" + _attr_icon = "mdi:counter" _attr_should_poll = False def __init__( @@ -659,11 +659,6 @@ class UtilityMeterSensor(RestoreSensor): return state_attr - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - @property def extra_restore_state_data(self) -> UtilitySensorExtraStoredData: """Return sensor specific state data to be restored.""" diff --git a/homeassistant/components/vasttrafik/sensor.py b/homeassistant/components/vasttrafik/sensor.py index 118d04d3c1..711f66ea03 100644 --- a/homeassistant/components/vasttrafik/sensor.py +++ b/homeassistant/components/vasttrafik/sensor.py @@ -32,7 +32,6 @@ CONF_SECRET = "secret" DEFAULT_DELAY = 0 -ICON = "mdi:train" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) @@ -83,6 +82,7 @@ class VasttrafikDepartureSensor(SensorEntity): """Implementation of a Vasttrafik Departure Sensor.""" _attr_attribution = "Data provided by Västtrafik" + _attr_icon = "mdi:train" def __init__(self, planner, name, departure, heading, lines, delay): """Initialize the sensor.""" @@ -110,11 +110,6 @@ class VasttrafikDepartureSensor(SensorEntity): """Return the name of the sensor.""" return self._name - @property - def icon(self): - """Return the icon for the frontend.""" - return ICON - @property def extra_state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/xbox_live/sensor.py b/homeassistant/components/xbox_live/sensor.py index d95031a646..2ad3f75468 100644 --- a/homeassistant/components/xbox_live/sensor.py +++ b/homeassistant/components/xbox_live/sensor.py @@ -20,7 +20,6 @@ _LOGGER = logging.getLogger(__name__) CONF_XUID = "xuid" -ICON = "mdi:microsoft-xbox" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -98,6 +97,7 @@ def get_user_gamercard(api, xuid): class XboxSensor(SensorEntity): """A class for the Xbox account.""" + _attr_icon = "mdi:microsoft-xbox" _attr_should_poll = False def __init__(self, api, xuid, gamercard, interval): @@ -138,11 +138,6 @@ class XboxSensor(SensorEntity): """Avatar of the account.""" return self._picture - @property - def icon(self): - """Return the icon to use in the frontend.""" - return ICON - async def async_added_to_hass(self) -> None: """Start custom polling.""" diff --git a/homeassistant/components/yandex_transport/sensor.py b/homeassistant/components/yandex_transport/sensor.py index d3e7e48815..1fbae6c88a 100644 --- a/homeassistant/components/yandex_transport/sensor.py +++ b/homeassistant/components/yandex_transport/sensor.py @@ -29,7 +29,7 @@ CONF_STOP_ID = "stop_id" CONF_ROUTE = "routes" DEFAULT_NAME = "Yandex Transport" -ICON = "mdi:bus" + SCAN_INTERVAL = timedelta(minutes=1) @@ -70,6 +70,7 @@ class DiscoverYandexTransport(SensorEntity): """Implementation of yandex_transport sensor.""" _attr_attribution = "Data provided by maps.yandex.ru" + _attr_icon = "mdi:bus" def __init__(self, requester: YandexMapsRequester, stop_id, routes, name) -> None: """Initialize sensor.""" @@ -168,8 +169,3 @@ class DiscoverYandexTransport(SensorEntity): def extra_state_attributes(self): """Return the state attributes.""" return self._attrs - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON diff --git a/homeassistant/components/zestimate/sensor.py b/homeassistant/components/zestimate/sensor.py index 3c6b7c7186..9b520c4681 100644 --- a/homeassistant/components/zestimate/sensor.py +++ b/homeassistant/components/zestimate/sensor.py @@ -24,7 +24,6 @@ DEFAULT_NAME = "Zestimate" NAME = "zestimate" ZESTIMATE = f"{DEFAULT_NAME}:{NAME}" -ICON = "mdi:home-variant" ATTR_AMOUNT = "amount" ATTR_CHANGE = "amount_change_30_days" @@ -67,6 +66,7 @@ class ZestimateDataSensor(SensorEntity): """Implementation of a Zestimate sensor.""" _attr_attribution = "Data provided by Zillow.com" + _attr_icon = "mdi:home-variant" def __init__(self, name, params): """Initialize the sensor.""" @@ -103,11 +103,6 @@ class ZestimateDataSensor(SensorEntity): attributes["address"] = self.address return attributes - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - def update(self): """Get the latest data and update the states.""" diff --git a/tests/components/srp_energy/test_sensor.py b/tests/components/srp_energy/test_sensor.py index 4493088606..0f59474ffb 100644 --- a/tests/components/srp_energy/test_sensor.py +++ b/tests/components/srp_energy/test_sensor.py @@ -3,9 +3,7 @@ from unittest.mock import MagicMock from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.components.srp_energy.const import ( - ATTRIBUTION, DEFAULT_NAME, - ICON, SENSOR_NAME, SENSOR_TYPE, SRP_ENERGY_DOMAIN, @@ -91,10 +89,10 @@ async def test_srp_entity(hass: HomeAssistant) -> None: assert srp_entity.unique_id == SENSOR_TYPE assert srp_entity.state is None assert srp_entity.unit_of_measurement == UnitOfEnergy.KILO_WATT_HOUR - assert srp_entity.icon == ICON + assert srp_entity.icon == "mdi:flash" assert srp_entity.usage == "2.00" assert srp_entity.should_poll is False - assert srp_entity.attribution == ATTRIBUTION + assert srp_entity.attribution == "Powered by SRP Energy" assert srp_entity.available is not None assert srp_entity.device_class is SensorDeviceClass.ENERGY assert srp_entity.state_class is SensorStateClass.TOTAL_INCREASING From b3887a633d2f57458687c2fee3503f0656d0205f Mon Sep 17 00:00:00 2001 From: Hans Oischinger Date: Fri, 31 Mar 2023 09:44:30 +0200 Subject: [PATCH 1491/2810] Bump PyVicare to 2.25.0 (#90536) --- homeassistant/components/vicare/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index f031e7a131..ae578492a1 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.21.0"] + "requirements": ["PyViCare==2.25.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8706e4e5f9..2edc33de9b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -50,7 +50,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.6.7 # homeassistant.components.vicare -PyViCare==2.21.0 +PyViCare==2.25.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 01b780f4cf..1a29f26409 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -46,7 +46,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.6.7 # homeassistant.components.vicare -PyViCare==2.21.0 +PyViCare==2.25.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 From b9f0701336221569a503f593d0a25a5eb35bfae1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 11:43:28 +0200 Subject: [PATCH 1492/2810] Update ruff to v0.0.260 (#90566) --- .pre-commit-config.yaml | 2 +- homeassistant/components/mysensors/light.py | 4 ++-- requirements_test_pre_commit.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fd196f19db..ffdd8904af 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.256 + rev: v0.0.260 hooks: - id: ruff args: diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py index 68f8bb566f..213e268696 100644 --- a/homeassistant/components/mysensors/light.py +++ b/homeassistant/components/mysensors/light.py @@ -172,7 +172,7 @@ class MySensorsLightRGB(MySensorsLight): new_rgb: tuple[int, int, int] | None = kwargs.get(ATTR_RGB_COLOR) if new_rgb is None: return - hex_color = "%02x%02x%02x" % new_rgb + hex_color = "{:02x}{:02x}{:02x}".format(*new_rgb) self.gateway.set_child_value( self.node_id, self.child_id, self.value_type, hex_color, ack=1 ) @@ -219,7 +219,7 @@ class MySensorsLightRGBW(MySensorsLightRGB): new_rgbw: tuple[int, int, int, int] | None = kwargs.get(ATTR_RGBW_COLOR) if new_rgbw is None: return - hex_color = "%02x%02x%02x%02x" % new_rgbw + hex_color = "{:02x}{:02x}{:02x}{:02x}".format(*new_rgbw) self.gateway.set_child_value( self.node_id, self.child_id, self.value_type, hex_color, ack=1 ) diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index a1faadfea4..c46ed5e4e2 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -4,5 +4,5 @@ bandit==1.7.4 black==23.1.0 codespell==2.2.2 isort==5.12.0 -ruff==0.0.256 +ruff==0.0.260 yamllint==1.28.0 From b24a5750c39b6f36dee0027dad027dde586615df Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 12:19:45 +0200 Subject: [PATCH 1493/2810] Add CI timeout to codecov job (#90572) --- .github/workflows/ci.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8464b1a299..03a6f2da92 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1091,6 +1091,7 @@ jobs: needs: - info - pytest + timeout-minutes: 10 steps: - name: Check out code from GitHub uses: actions/checkout@v3.5.0 From 6bad5f02c6b2cd1edf37129330c58ebad6a59546 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 12:20:08 +0200 Subject: [PATCH 1494/2810] Update black to 23.3.0 (#90569) --- .pre-commit-config.yaml | 2 +- requirements_test_pre_commit.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ffdd8904af..88bb4a703a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,7 @@ repos: args: - --fix - repo: https://github.com/psf/black - rev: 23.1.0 + rev: 23.3.0 hooks: - id: black args: diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index c46ed5e4e2..d76382c5c9 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,7 +1,7 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit bandit==1.7.4 -black==23.1.0 +black==23.3.0 codespell==2.2.2 isort==5.12.0 ruff==0.0.260 From c7e8fc9f9d365a395ae2bbc5e4e83ca50a2978fd Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Fri, 31 Mar 2023 12:38:23 +0200 Subject: [PATCH 1495/2810] Use more meaningful states for snapcast groups and clients (#77449) * Show muted snapcast groups as idle and use playing/idle state instead of on state for clients * New module constant STREAM_STATUS * Fix return type hint in snapcast --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/snapcast/media_player.py | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index 6f965155bb..4fd7c587d4 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -42,6 +42,12 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( {vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_PORT): cv.port} ) +STREAM_STATUS = { + "idle": MediaPlayerState.IDLE, + "playing": MediaPlayerState.PLAYING, + "unknown": None, +} + def register_services(): """Register snapcast services.""" @@ -157,11 +163,9 @@ class SnapcastGroupDevice(MediaPlayerEntity): @property def state(self) -> MediaPlayerState | None: """Return the state of the player.""" - return { - "idle": MediaPlayerState.IDLE, - "playing": MediaPlayerState.PLAYING, - "unknown": None, - }.get(self._group.stream_status) + if self.is_volume_muted: + return MediaPlayerState.IDLE + return STREAM_STATUS.get(self._group.stream_status) @property def unique_id(self): @@ -289,11 +293,13 @@ class SnapcastClientDevice(MediaPlayerEntity): return list(self._client.group.streams_by_name().keys()) @property - def state(self) -> MediaPlayerState: + def state(self) -> MediaPlayerState | None: """Return the state of the player.""" if self._client.connected: - return MediaPlayerState.ON - return MediaPlayerState.OFF + if self.is_volume_muted or self._client.group.muted: + return MediaPlayerState.IDLE + return STREAM_STATUS.get(self._client.group.stream_status) + return MediaPlayerState.STANDBY @property def extra_state_attributes(self): From 6153f17155f0d54a1bb5c4e87123e305042851d4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 13:58:53 +0200 Subject: [PATCH 1496/2810] Update sentry-sdk to 1.18.0 (#90571) --- homeassistant/components/sentry/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index 95eff4e7a5..066549e262 100644 --- a/homeassistant/components/sentry/manifest.json +++ b/homeassistant/components/sentry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sentry", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["sentry-sdk==1.16.0"] + "requirements": ["sentry-sdk==1.18.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2edc33de9b..2aab38d76a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2325,7 +2325,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.16.0 +sentry-sdk==1.18.0 # homeassistant.components.sfr_box sfrbox-api==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1a29f26409..7779f3a5f7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1661,7 +1661,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.16.0 +sentry-sdk==1.18.0 # homeassistant.components.sfr_box sfrbox-api==0.0.6 From 28736e2ce430e3d6d40ad21848e9fd3a3768ac40 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 13:59:49 +0200 Subject: [PATCH 1497/2810] Update orjson to 3.8.9 (#90570) --- 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 342942f0dd..a7815bdf91 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ ifaddr==0.1.7 janus==1.0.0 jinja2==3.1.2 lru-dict==1.1.8 -orjson==3.8.7 +orjson==3.8.9 paho-mqtt==1.6.1 pillow==9.4.0 pip>=21.0,<23.1 diff --git a/pyproject.toml b/pyproject.toml index d409ef188d..fbdee78422 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "cryptography==40.0.1", # pyOpenSSL 23.1.0 is required to work with cryptography 39+ "pyOpenSSL==23.1.0", - "orjson==3.8.7", + "orjson==3.8.9", "pip>=21.0,<23.1", "python-slugify==4.0.1", "pyyaml==6.0", diff --git a/requirements.txt b/requirements.txt index 84726cb49d..2dd936f706 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,7 @@ lru-dict==1.1.8 PyJWT==2.6.0 cryptography==40.0.1 pyOpenSSL==23.1.0 -orjson==3.8.7 +orjson==3.8.9 pip>=21.0,<23.1 python-slugify==4.0.1 pyyaml==6.0 From 2e26b6e0ccb2cbf059aefcb5cf1f4f449ad66187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timm=20Sch=C3=A4uble?= Date: Fri, 31 Mar 2023 14:10:12 +0200 Subject: [PATCH 1498/2810] Add attachments to simplepush (#81033) * Add attachments * Fix looking for attachment keywords in values * Improve attachment input format * Implement better approach to attachment parsing * Make ruff happy * Adjust attachment format and implementation according to comment from emontnemery --- homeassistant/components/simplepush/const.py | 1 + homeassistant/components/simplepush/notify.py | 34 +++++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/simplepush/const.py b/homeassistant/components/simplepush/const.py index 6195a5fd1d..101e7cb35f 100644 --- a/homeassistant/components/simplepush/const.py +++ b/homeassistant/components/simplepush/const.py @@ -6,6 +6,7 @@ DOMAIN: Final = "simplepush" DEFAULT_NAME: Final = "simplepush" DATA_HASS_CONFIG: Final = "simplepush_hass_config" +ATTR_ATTACHMENTS: Final = "attachments" ATTR_ENCRYPTED: Final = "encrypted" ATTR_EVENT: Final = "event" diff --git a/homeassistant/components/simplepush/notify.py b/homeassistant/components/simplepush/notify.py index b1c2eb5680..3e7fad8863 100644 --- a/homeassistant/components/simplepush/notify.py +++ b/homeassistant/components/simplepush/notify.py @@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import ATTR_EVENT, CONF_DEVICE_KEY, CONF_SALT, DOMAIN +from .const import ATTR_ATTACHMENTS, ATTR_EVENT, CONF_DEVICE_KEY, CONF_SALT, DOMAIN # Configuring Simplepush under the notify has been removed in 2022.9.0 PLATFORM_SCHEMA = BASE_PLATFORM_SCHEMA @@ -61,11 +61,34 @@ class SimplePushNotificationService(BaseNotificationService): """Send a message to a Simplepush user.""" title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) + attachments = None # event can now be passed in the service data event = None if data := kwargs.get(ATTR_DATA): event = data.get(ATTR_EVENT) + attachments_data = data.get(ATTR_ATTACHMENTS) + if isinstance(attachments_data, list): + attachments = [] + for attachment in attachments_data: + if not ( + isinstance(attachment, dict) + and ( + "image" in attachment + or "video" in attachment + or ("video" in attachment and "thumbnail" in attachment) + ) + ): + _LOGGER.error("Attachment format is incorrect") + return + + if "video" in attachment and "thumbnail" in attachment: + attachments.append(attachment) + elif "video" in attachment: + attachments.append(attachment["video"]) + elif "image" in attachment: + attachments.append(attachment["image"]) + # use event from config until YAML config is removed event = event or self._event @@ -77,10 +100,17 @@ class SimplePushNotificationService(BaseNotificationService): salt=self._salt, title=title, message=message, + attachments=attachments, event=event, ) else: - send(key=self._device_key, title=title, message=message, event=event) + send( + key=self._device_key, + title=title, + message=message, + attachments=attachments, + event=event, + ) except BadRequest: _LOGGER.error("Bad request. Title or message are too long") From ab699d17a50acf14ca0183e7cf5a5ecbd4572a5f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 31 Mar 2023 14:12:51 +0200 Subject: [PATCH 1499/2810] Ensure numeric sensors have a valid value (#85605) * Ensure numeric sensors have a valid value * Flake8 --- homeassistant/components/sensor/__init__.py | 37 +++++---------------- tests/components/sensor/test_init.py | 16 ++++----- 2 files changed, 16 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 4f56be77a9..d0fdc8a088 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -167,7 +167,6 @@ class SensorEntity(Entity): _attr_unit_of_measurement: None = ( None # Subclasses of SensorEntity should not set this ) - _invalid_numeric_value_reported = False _invalid_state_class_reported = False _invalid_unit_of_measurement_reported = False _last_reset_reported = False @@ -463,7 +462,7 @@ class SensorEntity(Entity): @final @property - def state(self) -> Any: # noqa: C901 + def state(self) -> Any: """Return the state of the sensor and perform unit conversions, if needed.""" native_unit_of_measurement = self.native_unit_of_measurement unit_of_measurement = self.unit_of_measurement @@ -581,33 +580,13 @@ class SensorEntity(Entity): else: numerical_value = float(value) # type:ignore[arg-type] except (TypeError, ValueError) as err: - # Raise if precision is not None, for other cases log a warning - if suggested_precision is not None: - raise ValueError( - f"Sensor {self.entity_id} has device class {device_class}, " - f"state class {state_class} unit {unit_of_measurement} and " - f"suggested precision {suggested_precision} thus indicating it " - f"has a numeric value; however, it has the non-numeric value: " - f"{value} ({type(value)})" - ) from err - # This should raise in Home Assistant Core 2023.4 - if not self._invalid_numeric_value_reported: - self._invalid_numeric_value_reported = True - report_issue = self._suggest_report_issue() - _LOGGER.warning( - "Sensor %s has device class %s, state class %s and unit %s " - "thus indicating it has a numeric value; however, it has the " - "non-numeric value: %s (%s); Please update your configuration " - "if your entity is manually configured, otherwise %s", - self.entity_id, - device_class, - state_class, - unit_of_measurement, - value, - type(value), - report_issue, - ) - return value + raise ValueError( + f"Sensor {self.entity_id} has device class {device_class}, " + f"state class {state_class} unit {unit_of_measurement} and " + f"suggested precision {suggested_precision} thus indicating it " + f"has a numeric value; however, it has the non-numeric value: " + f"{value} ({type(value)})" + ) from err else: numerical_value = value diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 8be15f1c7c..82ea25b5a1 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -1803,20 +1803,20 @@ async def test_device_classes_with_invalid_unit_of_measurement( ], ) @pytest.mark.parametrize( - ("native_value", "expected"), + "native_value", [ - ("abc", "abc"), - ("13.7.1", "13.7.1"), - (datetime(2012, 11, 10, 7, 35, 1), "2012-11-10 07:35:01"), - (date(2012, 11, 10), "2012-11-10"), + "", + "abc", + "13.7.1", + datetime(2012, 11, 10, 7, 35, 1), + date(2012, 11, 10), ], ) -async def test_non_numeric_validation_warn( +async def test_non_numeric_validation_error( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, enable_custom_integrations: None, native_value: Any, - expected: str, device_class: SensorDeviceClass | None, state_class: SensorStateClass | None, unit: str | None, @@ -1837,7 +1837,7 @@ async def test_non_numeric_validation_warn( await hass.async_block_till_done() state = hass.states.get(entity0.entity_id) - assert state.state == expected + assert state is None assert ( "thus indicating it has a numeric value; " From a616ac2b60c85ebf6d4416e31c0530ed2217e120 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 31 Mar 2023 14:25:49 +0200 Subject: [PATCH 1500/2810] Move attribution constants to entity attributes (#90519) * Move attribution constants to entity attributes * Adjust meteo france * Adjust meteoclimatic * Adjust nws --- homeassistant/components/ampio/air_quality.py | 9 +++------ homeassistant/components/ampio/const.py | 1 - homeassistant/components/met/weather.py | 12 ++++-------- homeassistant/components/met_eireann/const.py | 2 -- .../components/met_eireann/weather.py | 8 ++------ .../components/meteo_france/weather.py | 6 +----- .../components/meteoclimatic/weather.py | 6 +----- homeassistant/components/nilu/air_quality.py | 8 ++------ .../components/norway_air/air_quality.py | 18 +++++++----------- homeassistant/components/nws/weather.py | 6 +----- .../components/opensensemap/air_quality.py | 8 ++------ 11 files changed, 23 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/ampio/air_quality.py b/homeassistant/components/ampio/air_quality.py index f8119e9c1b..a423a62836 100644 --- a/homeassistant/components/ampio/air_quality.py +++ b/homeassistant/components/ampio/air_quality.py @@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle -from .const import ATTRIBUTION, CONF_STATION_ID, SCAN_INTERVAL +from .const import CONF_STATION_ID, SCAN_INTERVAL _LOGGER: Final = logging.getLogger(__name__) @@ -54,6 +54,8 @@ async def async_setup_platform( class AmpioSmogQuality(AirQualityEntity): """Implementation of an Ampio Smog air quality entity.""" + _attr_attribution = "Data provided by Ampio" + def __init__( self, api: AmpioSmogMapData, station_id: str, name: str | None ) -> None: @@ -82,11 +84,6 @@ class AmpioSmogQuality(AirQualityEntity): """Return the particulate matter 10 level.""" return self._ampio.api.pm10 # type: ignore[no-any-return] - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - async def async_update(self) -> None: """Get the latest data from the AmpioMap API.""" await self._ampio.async_update() diff --git a/homeassistant/components/ampio/const.py b/homeassistant/components/ampio/const.py index 3162308ff4..b1a13ce941 100644 --- a/homeassistant/components/ampio/const.py +++ b/homeassistant/components/ampio/const.py @@ -2,6 +2,5 @@ from datetime import timedelta from typing import Final -ATTRIBUTION: Final = "Data provided by Ampio" CONF_STATION_ID: Final = "station_id" SCAN_INTERVAL: Final = timedelta(minutes=10) diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index f507cf8cf3..a6dcb23cc4 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -35,9 +35,6 @@ from homeassistant.util.unit_system import METRIC_SYSTEM from . import MetDataUpdateCoordinator from .const import ATTR_MAP, CONDITIONS_MAP, CONF_TRACK_HOME, DOMAIN, FORECAST_MAP -ATTRIBUTION = ( - "Weather forecast from met.no, delivered by the Norwegian Meteorological Institute." -) DEFAULT_NAME = "Met.no" @@ -74,6 +71,10 @@ def format_condition(condition: str) -> str: class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): """Implementation of a Met.no weather condition.""" + _attr_attribution = ( + "Weather forecast from met.no, delivered by the Norwegian " + "Meteorological Institute." + ) _attr_has_entity_name = True _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS @@ -173,11 +174,6 @@ class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): ATTR_MAP[ATTR_WEATHER_WIND_BEARING] ) - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - @property def forecast(self) -> list[Forecast] | None: """Return the forecast array.""" diff --git a/homeassistant/components/met_eireann/const.py b/homeassistant/components/met_eireann/const.py index efe80cb9d1..1cab9c9099 100644 --- a/homeassistant/components/met_eireann/const.py +++ b/homeassistant/components/met_eireann/const.py @@ -20,8 +20,6 @@ from homeassistant.components.weather import ( DOMAIN as WEATHER_DOMAIN, ) -ATTRIBUTION = "Data provided by Met Éireann" - DEFAULT_NAME = "Met Éireann" DOMAIN = "met_eireann" diff --git a/homeassistant/components/met_eireann/weather.py b/homeassistant/components/met_eireann/weather.py index c4d8763efa..cce35731c7 100644 --- a/homeassistant/components/met_eireann/weather.py +++ b/homeassistant/components/met_eireann/weather.py @@ -23,7 +23,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from .const import ATTRIBUTION, CONDITION_MAP, DEFAULT_NAME, DOMAIN, FORECAST_MAP +from .const import CONDITION_MAP, DEFAULT_NAME, DOMAIN, FORECAST_MAP _LOGGER = logging.getLogger(__name__) @@ -55,6 +55,7 @@ async def async_setup_entry( class MetEireannWeather(CoordinatorEntity, WeatherEntity): """Implementation of a Met Éireann weather condition.""" + _attr_attribution = "Data provided by Met Éireann" _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS _attr_native_pressure_unit = UnitOfPressure.HPA _attr_native_temperature_unit = UnitOfTemperature.CELSIUS @@ -125,11 +126,6 @@ class MetEireannWeather(CoordinatorEntity, WeatherEntity): """Return the wind direction.""" return self.coordinator.data.current_weather_data.get("wind_bearing") - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION - @property def forecast(self): """Return the forecast array.""" diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index 95972a95bb..e1a530eef9 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -83,6 +83,7 @@ class MeteoFranceWeather( ): """Representation of a weather condition.""" + _attr_attribution = ATTRIBUTION _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS _attr_native_pressure_unit = UnitOfPressure.HPA @@ -203,8 +204,3 @@ class MeteoFranceWeather( } ) return forecast_data - - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION diff --git a/homeassistant/components/meteoclimatic/weather.py b/homeassistant/components/meteoclimatic/weather.py index 14b953663d..11346ab18f 100644 --- a/homeassistant/components/meteoclimatic/weather.py +++ b/homeassistant/components/meteoclimatic/weather.py @@ -38,6 +38,7 @@ async def async_setup_entry( class MeteoclimaticWeather(CoordinatorEntity, WeatherEntity): """Representation of a weather condition.""" + _attr_attribution = ATTRIBUTION _attr_native_pressure_unit = UnitOfPressure.HPA _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR @@ -98,8 +99,3 @@ class MeteoclimaticWeather(CoordinatorEntity, WeatherEntity): def wind_bearing(self): """Return the wind bearing.""" return self.coordinator.data["weather"].wind_bearing - - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION diff --git a/homeassistant/components/nilu/air_quality.py b/homeassistant/components/nilu/air_quality.py index 5c3f9c5946..3745c6bae6 100644 --- a/homeassistant/components/nilu/air_quality.py +++ b/homeassistant/components/nilu/air_quality.py @@ -39,7 +39,6 @@ _LOGGER = logging.getLogger(__name__) ATTR_AREA = "area" ATTR_POLLUTION_INDEX = "nilu_pollution_index" -ATTRIBUTION = "Data provided by luftkvalitet.info and nilu.no" CONF_AREA = "area" CONF_STATION = "stations" @@ -173,6 +172,8 @@ class NiluData: class NiluSensor(AirQualityEntity): """Single nilu station air sensor.""" + _attr_attribution = "Data provided by luftkvalitet.info and nilu.no" + def __init__(self, api_data: NiluData, name: str, show_on_map: bool) -> None: """Initialize the sensor.""" self._api = api_data @@ -184,11 +185,6 @@ class NiluSensor(AirQualityEntity): self._attrs[CONF_LATITUDE] = api_data.data.latitude self._attrs[CONF_LONGITUDE] = api_data.data.longitude - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - @property def extra_state_attributes(self) -> dict: """Return other details about the sensor state.""" diff --git a/homeassistant/components/norway_air/air_quality.py b/homeassistant/components/norway_air/air_quality.py index b4acdc3bdc..1a3d3661a1 100644 --- a/homeassistant/components/norway_air/air_quality.py +++ b/homeassistant/components/norway_air/air_quality.py @@ -17,12 +17,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = ( - "Air quality from " - "https://luftkvalitet.miljostatus.no/, " - "delivered by the Norwegian Meteorological Institute." -) -# https://api.met.no/license_data.html CONF_FORECAST = "forecast" @@ -81,6 +75,13 @@ def round_state(func): class AirSensor(AirQualityEntity): """Representation of an air quality sensor.""" + # https://api.met.no/license_data.html + _attr_attribution = ( + "Air quality from " + "https://luftkvalitet.miljostatus.no/, " + "delivered by the Norwegian Meteorological Institute." + ) + def __init__(self, name, coordinates, forecast, session): """Initialize the sensor.""" self._name = name @@ -88,11 +89,6 @@ class AirSensor(AirQualityEntity): coordinates, forecast, session, api_url=OVERRIDE_URL ) - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - @property def extra_state_attributes(self) -> dict: """Return other details about the sensor state.""" diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index ecb95a1f9e..9edf6e6175 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -108,6 +108,7 @@ if TYPE_CHECKING: class NWSWeather(WeatherEntity): """Representation of a weather condition.""" + _attr_attribution = ATTRIBUTION _attr_should_poll = False def __init__( @@ -154,11 +155,6 @@ class NWSWeather(WeatherEntity): self.async_write_ha_state() - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - @property def name(self) -> str: """Return the name of the station.""" diff --git a/homeassistant/components/opensensemap/air_quality.py b/homeassistant/components/opensensemap/air_quality.py index 5999eb9158..0e918103cd 100644 --- a/homeassistant/components/opensensemap/air_quality.py +++ b/homeassistant/components/opensensemap/air_quality.py @@ -20,7 +20,6 @@ from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = "Data provided by openSenseMap" CONF_STATION_ID = "station_id" @@ -59,6 +58,8 @@ async def async_setup_platform( class OpenSenseMapQuality(AirQualityEntity): """Implementation of an openSenseMap air quality entity.""" + _attr_attribution = "Data provided by openSenseMap" + def __init__(self, name, osm): """Initialize the air quality entity.""" self._name = name @@ -79,11 +80,6 @@ class OpenSenseMapQuality(AirQualityEntity): """Return the particulate matter 10 level.""" return self._osm.api.pm10 - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION - async def async_update(self): """Get the latest data from the openSenseMap API.""" await self._osm.async_update() From 8cbe3940283043f583437d0994104fa9c5a0d77d Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Fri, 31 Mar 2023 15:27:37 +0300 Subject: [PATCH 1501/2810] Use `get_ha_sensor_data` method to update glances sensors (#83983) * Use `get_ha_sensor_data` method to update sensor state * update tests * Use `get_ha_sensor_data` to validate connection * Update test_sensor.py --------- Co-authored-by: Erik Montnemery --- .../components/glances/config_flow.py | 2 +- .../components/glances/coordinator.py | 3 +- homeassistant/components/glances/sensor.py | 231 ++++-------------- tests/components/glances/__init__.py | 97 +++++++- tests/components/glances/conftest.py | 7 +- tests/components/glances/test_config_flow.py | 2 +- tests/components/glances/test_init.py | 2 +- tests/components/glances/test_sensor.py | 69 ++++++ 8 files changed, 224 insertions(+), 189 deletions(-) create mode 100644 tests/components/glances/test_sensor.py diff --git a/homeassistant/components/glances/config_flow.py b/homeassistant/components/glances/config_flow.py index cf55118a91..04e133248a 100644 --- a/homeassistant/components/glances/config_flow.py +++ b/homeassistant/components/glances/config_flow.py @@ -45,7 +45,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: """Validate the user input allows us to connect.""" api = get_api(hass, data) try: - await api.get_data("all") + await api.get_ha_sensor_data() except GlancesApiError as err: raise CannotConnect from err diff --git a/homeassistant/components/glances/coordinator.py b/homeassistant/components/glances/coordinator.py index 8ffd2a2da6..01e498a889 100644 --- a/homeassistant/components/glances/coordinator.py +++ b/homeassistant/components/glances/coordinator.py @@ -36,7 +36,6 @@ class GlancesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def _async_update_data(self) -> dict[str, Any]: """Get the latest data from the Glances REST API.""" try: - await self.api.get_data("all") + return await self.api.get_ha_sensor_data() except exceptions.GlancesApiError as err: raise UpdateFailed from err - return self.api.data diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index b8b5d80a20..8b836fba3e 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -15,7 +15,6 @@ from homeassistant.const import ( CONF_NAME, PERCENTAGE, REVOLUTIONS_PER_MINUTE, - STATE_UNAVAILABLE, Platform, UnitOfInformation, UnitOfTemperature, @@ -45,8 +44,8 @@ class GlancesSensorEntityDescription( """Describe Glances sensor entity.""" -SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( - GlancesSensorEntityDescription( +SENSOR_TYPES = { + ("fs", "disk_use_percent"): GlancesSensorEntityDescription( key="disk_use_percent", type="fs", name_suffix="used percent", @@ -54,7 +53,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("fs", "disk_use"): GlancesSensorEntityDescription( key="disk_use", type="fs", name_suffix="used", @@ -63,7 +62,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("fs", "disk_free"): GlancesSensorEntityDescription( key="disk_free", type="fs", name_suffix="free", @@ -72,7 +71,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("mem", "memory_use_percent"): GlancesSensorEntityDescription( key="memory_use_percent", type="mem", name_suffix="RAM used percent", @@ -80,7 +79,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("mem", "memory_use"): GlancesSensorEntityDescription( key="memory_use", type="mem", name_suffix="RAM used", @@ -89,7 +88,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("mem", "memory_free"): GlancesSensorEntityDescription( key="memory_free", type="mem", name_suffix="RAM free", @@ -98,7 +97,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("memswap", "swap_use_percent"): GlancesSensorEntityDescription( key="swap_use_percent", type="memswap", name_suffix="Swap used percent", @@ -106,7 +105,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("memswap", "swap_use"): GlancesSensorEntityDescription( key="swap_use", type="memswap", name_suffix="Swap used", @@ -115,7 +114,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("memswap", "swap_free"): GlancesSensorEntityDescription( key="swap_free", type="memswap", name_suffix="Swap free", @@ -124,42 +123,42 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("load", "processor_load"): GlancesSensorEntityDescription( key="processor_load", type="load", name_suffix="CPU load", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("processcount", "process_running"): GlancesSensorEntityDescription( key="process_running", type="processcount", name_suffix="Running", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("processcount", "process_total"): GlancesSensorEntityDescription( key="process_total", type="processcount", name_suffix="Total", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("processcount", "process_thread"): GlancesSensorEntityDescription( key="process_thread", type="processcount", name_suffix="Thread", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("processcount", "process_sleeping"): GlancesSensorEntityDescription( key="process_sleeping", type="processcount", name_suffix="Sleeping", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("cpu", "cpu_use_percent"): GlancesSensorEntityDescription( key="cpu_use_percent", type="cpu", name_suffix="CPU used", @@ -167,7 +166,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("sensors", "temperature_core"): GlancesSensorEntityDescription( key="temperature_core", type="sensors", name_suffix="Temperature", @@ -175,7 +174,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("sensors", "temperature_hdd"): GlancesSensorEntityDescription( key="temperature_hdd", type="sensors", name_suffix="Temperature", @@ -183,7 +182,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("sensors", "fan_speed"): GlancesSensorEntityDescription( key="fan_speed", type="sensors", name_suffix="Fan speed", @@ -191,7 +190,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:fan", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("sensors", "battery"): GlancesSensorEntityDescription( key="battery", type="sensors", name_suffix="Charge", @@ -200,14 +199,14 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:battery", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("docker", "docker_active"): GlancesSensorEntityDescription( key="docker_active", type="docker", name_suffix="Containers active", icon="mdi:docker", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("docker", "docker_cpu_use"): GlancesSensorEntityDescription( key="docker_cpu_use", type="docker", name_suffix="Containers CPU used", @@ -215,7 +214,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:docker", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("docker", "docker_memory_use"): GlancesSensorEntityDescription( key="docker_memory_use", type="docker", name_suffix="Containers RAM used", @@ -224,21 +223,21 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( icon="mdi:docker", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("raid", "used"): GlancesSensorEntityDescription( key="used", type="raid", name_suffix="Raid used", icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), - GlancesSensorEntityDescription( + ("raid", "available"): GlancesSensorEntityDescription( key="available", type="raid", name_suffix="Raid available", icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), -) +} async def async_setup_entry( @@ -266,64 +265,40 @@ async def async_setup_entry( entity_id, new_unique_id=f"{config_entry.entry_id}-{new_key}" ) - for description in SENSOR_TYPES: - if description.type == "fs": - # fs will provide a list of disks attached - for disk in coordinator.data[description.type]: - _migrate_old_unique_ids( - hass, - f"{coordinator.host}-{name} {disk['mnt_point']} {description.name_suffix}", - f"{disk['mnt_point']}-{description.key}", - ) - entities.append( - GlancesSensor( - coordinator, - name, - disk["mnt_point"], - description, - ) - ) - elif description.type == "sensors": - # sensors will provide temp for different devices - for sensor in coordinator.data[description.type]: - if sensor["type"] == description.key: + for sensor_type, sensors in coordinator.data.items(): + if sensor_type in ["fs", "sensors", "raid"]: + for sensor_label, params in sensors.items(): + for param in params: + sensor_description = SENSOR_TYPES[(sensor_type, param)] _migrate_old_unique_ids( hass, - f"{coordinator.host}-{name} {sensor['label']} {description.name_suffix}", - f"{sensor['label']}-{description.key}", + f"{coordinator.host}-{name} {sensor_label} {sensor_description.name_suffix}", + f"{sensor_label}-{sensor_description.key}", ) entities.append( GlancesSensor( coordinator, name, - sensor["label"], - description, + sensor_label, + sensor_description, ) ) - elif description.type == "raid": - for raid_device in coordinator.data[description.type]: + else: + for sensor in sensors: + sensor_description = SENSOR_TYPES[(sensor_type, sensor)] _migrate_old_unique_ids( hass, - f"{coordinator.host}-{name} {raid_device} {description.name_suffix}", - f"{raid_device}-{description.key}", + f"{coordinator.host}-{name} {sensor_description.name_suffix}", + f"-{sensor_description.key}", ) entities.append( - GlancesSensor(coordinator, name, raid_device, description) + GlancesSensor( + coordinator, + name, + "", + sensor_description, + ) ) - elif coordinator.data[description.type]: - _migrate_old_unique_ids( - hass, - f"{coordinator.host}-{name} {description.name_suffix}", - f"-{description.key}", - ) - entities.append( - GlancesSensor( - coordinator, - name, - "", - description, - ) - ) async_add_entities(entities) @@ -354,114 +329,10 @@ class GlancesSensor(CoordinatorEntity[GlancesDataUpdateCoordinator], SensorEntit self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{sensor_name_prefix}-{description.key}" @property - def native_value(self) -> StateType: # noqa: C901 + def native_value(self) -> StateType: """Return the state of the resources.""" - if (value := self.coordinator.data) is None: - return None - state: StateType = None - if self.entity_description.type == "fs": - for var in value["fs"]: - if var["mnt_point"] == self._sensor_name_prefix: - disk = var - break - if self.entity_description.key == "disk_free": - try: - state = round(disk["free"] / 1024**3, 1) - except KeyError: - state = round( - (disk["size"] - disk["used"]) / 1024**3, - 1, - ) - elif self.entity_description.key == "disk_use": - state = round(disk["used"] / 1024**3, 1) - elif self.entity_description.key == "disk_use_percent": - state = disk["percent"] - elif self.entity_description.key == "battery": - for sensor in value["sensors"]: - if ( - sensor["type"] == "battery" - and sensor["label"] == self._sensor_name_prefix - ): - state = sensor["value"] - elif self.entity_description.key == "fan_speed": - for sensor in value["sensors"]: - if ( - sensor["type"] == "fan_speed" - and sensor["label"] == self._sensor_name_prefix - ): - state = sensor["value"] - elif self.entity_description.key == "temperature_core": - for sensor in value["sensors"]: - if ( - sensor["type"] == "temperature_core" - and sensor["label"] == self._sensor_name_prefix - ): - state = sensor["value"] - elif self.entity_description.key == "temperature_hdd": - for sensor in value["sensors"]: - if ( - sensor["type"] == "temperature_hdd" - and sensor["label"] == self._sensor_name_prefix - ): - state = sensor["value"] - elif self.entity_description.key == "memory_use_percent": - state = value["mem"]["percent"] - elif self.entity_description.key == "memory_use": - state = round(value["mem"]["used"] / 1024**2, 1) - elif self.entity_description.key == "memory_free": - state = round(value["mem"]["free"] / 1024**2, 1) - elif self.entity_description.key == "swap_use_percent": - state = value["memswap"]["percent"] - elif self.entity_description.key == "swap_use": - state = round(value["memswap"]["used"] / 1024**3, 1) - elif self.entity_description.key == "swap_free": - state = round(value["memswap"]["free"] / 1024**3, 1) - elif self.entity_description.key == "processor_load": - # Windows systems don't provide load details - try: - state = value["load"]["min15"] - except KeyError: - state = value["cpu"]["total"] - elif self.entity_description.key == "process_running": - state = value["processcount"]["running"] - elif self.entity_description.key == "process_total": - state = value["processcount"]["total"] - elif self.entity_description.key == "process_thread": - state = value["processcount"]["thread"] - elif self.entity_description.key == "process_sleeping": - state = value["processcount"]["sleeping"] - elif self.entity_description.key == "cpu_use_percent": - state = value["quicklook"]["cpu"] - elif self.entity_description.key == "docker_active": - count = 0 - try: - for container in value["docker"]["containers"]: - if container["Status"] == "running" or "Up" in container["Status"]: - count += 1 - state = count - except KeyError: - state = count - elif self.entity_description.key == "docker_cpu_use": - cpu_use = 0.0 - try: - for container in value["docker"]["containers"]: - if container["Status"] == "running" or "Up" in container["Status"]: - cpu_use += container["cpu"]["total"] - state = round(cpu_use, 1) - except KeyError: - state = STATE_UNAVAILABLE - elif self.entity_description.key == "docker_memory_use": - mem_use = 0.0 - try: - for container in value["docker"]["containers"]: - if container["Status"] == "running" or "Up" in container["Status"]: - mem_use += container["memory"]["usage"] - state = round(mem_use / 1024**2, 1) - except KeyError: - state = STATE_UNAVAILABLE - elif self.entity_description.type == "raid": - for raid_device, raid in value["raid"].items(): - if raid_device == self._sensor_name_prefix: - state = raid[self.entity_description.key] + value = self.coordinator.data[self.entity_description.type] - return state + if isinstance(value.get(self._sensor_name_prefix), dict): + return value[self._sensor_name_prefix][self.entity_description.key] + return value[self.entity_description.key] diff --git a/tests/components/glances/__init__.py b/tests/components/glances/__init__.py index 4818e9258d..8c9394ae84 100644 --- a/tests/components/glances/__init__.py +++ b/tests/components/glances/__init__.py @@ -1,6 +1,8 @@ """Tests for Glances.""" -MOCK_USER_INPUT = { +from typing import Any + +MOCK_USER_INPUT: dict[str, Any] = { "host": "0.0.0.0", "username": "username", "password": "password", @@ -30,6 +32,85 @@ MOCK_DATA = { "key": "disk_name", }, ], + "docker": { + "containers": [ + { + "key": "name", + "name": "container1", + "Status": "running", + "cpu": {"total": 50.94973493230174}, + "cpu_percent": 50.94973493230174, + "memory": { + "usage": 1120321536, + "limit": 3976318976, + "rss": 480641024, + "cache": 580915200, + "max_usage": 1309597696, + }, + "memory_usage": 539406336, + }, + { + "key": "name", + "name": "container2", + "Status": "running", + "cpu": {"total": 26.23567931034483}, + "cpu_percent": 26.23567931034483, + "memory": { + "usage": 85139456, + "limit": 3976318976, + "rss": 33677312, + "cache": 35012608, + "max_usage": 87650304, + }, + "memory_usage": 50126848, + }, + ] + }, + "fs": [ + { + "device_name": "/dev/sda8", + "fs_type": "ext4", + "mnt_point": "/ssl", + "size": 511320748032, + "used": 32910458880, + "free": 457917374464, + "percent": 6.7, + "key": "mnt_point", + }, + { + "device_name": "/dev/sda8", + "fs_type": "ext4", + "mnt_point": "/media", + "size": 511320748032, + "used": 32910458880, + "free": 457917374464, + "percent": 6.7, + "key": "mnt_point", + }, + ], + "mem": { + "total": 3976318976, + "available": 2878337024, + "percent": 27.6, + "used": 1097981952, + "free": 2878337024, + "active": 567971840, + "inactive": 1679704064, + "buffers": 149807104, + "cached": 1334816768, + "shared": 1499136, + }, + "sensors": [ + { + "label": "cpu_thermal 1", + "value": 59, + "warning": None, + "critical": None, + "unit": "C", + "type": "temperature_core", + "key": "label", + } + ], "system": { "os_name": "Linux", "hostname": "fedora-35", @@ -40,3 +121,17 @@ MOCK_DATA = { }, "uptime": "3 days, 10:25:20", } + +HA_SENSOR_DATA: dict[str, Any] = { + "fs": { + "/ssl": {"disk_use": 30.7, "disk_use_percent": 6.7, "disk_free": 426.5}, + "/media": {"disk_use": 30.7, "disk_use_percent": 6.7, "disk_free": 426.5}, + }, + "sensors": {"cpu_thermal 1": {"temperature_core": 59}}, + "mem": { + "memory_use_percent": 27.6, + "memory_use": 1047.1, + "memory_free": 2745.0, + }, + "docker": {"docker_active": 2, "docker_cpu_use": 77.2, "docker_memory_use": 1149.6}, +} diff --git a/tests/components/glances/conftest.py b/tests/components/glances/conftest.py index d92d3cc33d..9f4590ab5e 100644 --- a/tests/components/glances/conftest.py +++ b/tests/components/glances/conftest.py @@ -3,13 +3,14 @@ from unittest.mock import AsyncMock, patch import pytest -from . import MOCK_DATA +from . import HA_SENSOR_DATA @pytest.fixture(autouse=True) def mock_api(): """Mock glances api.""" with patch("homeassistant.components.glances.Glances") as mock_api: - mock_api.return_value.get_data = AsyncMock(return_value=None) - mock_api.return_value.data.return_value = MOCK_DATA + mock_api.return_value.get_ha_sensor_data = AsyncMock( + return_value=HA_SENSOR_DATA + ) yield mock_api diff --git a/tests/components/glances/test_config_flow.py b/tests/components/glances/test_config_flow.py index ab64205505..187e319fe0 100644 --- a/tests/components/glances/test_config_flow.py +++ b/tests/components/glances/test_config_flow.py @@ -42,7 +42,7 @@ async def test_form(hass: HomeAssistant) -> None: async def test_form_cannot_connect(hass: HomeAssistant, mock_api: MagicMock) -> None: """Test to return error if we cannot connect.""" - mock_api.return_value.get_data.side_effect = GlancesApiConnectionError + mock_api.return_value.get_ha_sensor_data.side_effect = GlancesApiConnectionError result = await hass.config_entries.flow.async_init( glances.DOMAIN, context={"source": config_entries.SOURCE_USER} ) diff --git a/tests/components/glances/test_init.py b/tests/components/glances/test_init.py index 944d9d55ae..546f57ac3d 100644 --- a/tests/components/glances/test_init.py +++ b/tests/components/glances/test_init.py @@ -29,7 +29,7 @@ async def test_conn_error(hass: HomeAssistant, mock_api: MagicMock) -> None: entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT) entry.add_to_hass(hass) - mock_api.return_value.get_data.side_effect = GlancesApiConnectionError + mock_api.return_value.get_ha_sensor_data.side_effect = GlancesApiConnectionError await hass.config_entries.async_setup(entry.entry_id) assert entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/glances/test_sensor.py b/tests/components/glances/test_sensor.py new file mode 100644 index 0000000000..e5aadc9215 --- /dev/null +++ b/tests/components/glances/test_sensor.py @@ -0,0 +1,69 @@ +"""Tests for glances sensors.""" +import pytest + +from homeassistant.components.glances.const import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import HA_SENSOR_DATA, MOCK_USER_INPUT + +from tests.common import MockConfigEntry + + +async def test_sensor_states(hass: HomeAssistant) -> None: + """Test sensor states are correctly collected from library.""" + + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + + if state := hass.states.get("sensor.0_0_0_0_ssl_disk_use"): + assert state.state == HA_SENSOR_DATA["fs"]["/ssl"]["disk_use"] + + if state := hass.states.get("sensor.0_0_0_0_cpu_thermal_1"): + assert state.state == HA_SENSOR_DATA["sensors"]["cpu_thermal 1"] + + +@pytest.mark.parametrize( + ("object_id", "old_unique_id", "new_unique_id"), + [ + ( + "glances_ssl_used_percent", + "0.0.0.0-Glances /ssl used percent", + "/ssl-disk_use_percent", + ), + ( + "glances_cpu_thermal_1_temperature", + "0.0.0.0-Glances cpu_thermal 1 Temperature", + "cpu_thermal 1-temperature_core", + ), + ], +) +async def test_migrate_unique_id( + hass: HomeAssistant, object_id: str, old_unique_id: str, new_unique_id: str +): + """Test unique id migration.""" + old_config_data = {**MOCK_USER_INPUT, "name": "Glances"} + entry = MockConfigEntry(domain=DOMAIN, data=old_config_data) + entry.add_to_hass(hass) + + ent_reg = er.async_get(hass) + + entity: er.RegistryEntry = ent_reg.async_get_or_create( + suggested_object_id=object_id, + disabled_by=None, + domain=SENSOR_DOMAIN, + platform=DOMAIN, + unique_id=old_unique_id, + config_entry=entry, + ) + assert entity.unique_id == old_unique_id + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_migrated = ent_reg.async_get(entity.entity_id) + assert entity_migrated + assert entity_migrated.unique_id == f"{entry.entry_id}-{new_unique_id}" From ea32cc5d9213e2a78cad302dd3102469a59de36b Mon Sep 17 00:00:00 2001 From: Hans Oischinger Date: Fri, 31 Mar 2023 14:33:58 +0200 Subject: [PATCH 1502/2810] Refactor vicare config_flow tests (#90568) * Refactor vicare config_flow tests * Address review comments * Remove unused parameters --- tests/components/vicare/__init__.py | 12 +- tests/components/vicare/conftest.py | 16 ++ .../vicare/snapshots/test_config_flow.ambr | 17 ++ tests/components/vicare/test_config_flow.py | 153 +++++++++--------- 4 files changed, 106 insertions(+), 92 deletions(-) create mode 100644 tests/components/vicare/conftest.py create mode 100644 tests/components/vicare/snapshots/test_config_flow.ambr diff --git a/tests/components/vicare/__init__.py b/tests/components/vicare/__init__.py index 66cbfdc1d2..9e59c52940 100644 --- a/tests/components/vicare/__init__.py +++ b/tests/components/vicare/__init__.py @@ -1,16 +1,6 @@ """Test for ViCare.""" from __future__ import annotations -from typing import Final - -from homeassistant.components.vicare.const import CONF_HEATING_TYPE -from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME - -ENTRY_CONFIG: Final[dict[str, str]] = { - CONF_USERNAME: "foo@bar.com", - CONF_PASSWORD: "1234", - CONF_CLIENT_ID: "5678", - CONF_HEATING_TYPE: "auto", -} +MODULE = "homeassistant.components.vicare" MOCK_MAC = "B874241B7B9" diff --git a/tests/components/vicare/conftest.py b/tests/components/vicare/conftest.py new file mode 100644 index 0000000000..2ecd4f4309 --- /dev/null +++ b/tests/components/vicare/conftest.py @@ -0,0 +1,16 @@ +"""Fixtures for ViCare integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from . import MODULE + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch(f"{MODULE}.async_setup_entry", return_value=True) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/vicare/snapshots/test_config_flow.ambr b/tests/components/vicare/snapshots/test_config_flow.ambr new file mode 100644 index 0000000000..e99eda8234 --- /dev/null +++ b/tests/components/vicare/snapshots/test_config_flow.ambr @@ -0,0 +1,17 @@ +# serializer version: 1 +# name: test_form_dhcp + dict({ + 'client_id': '5678', + 'heating_type': 'auto', + 'password': '1234', + 'username': 'foo@bar.com', + }) +# --- +# name: test_user_create_entry + dict({ + 'client_id': '5678', + 'heating_type': 'auto', + 'password': '1234', + 'username': 'foo@bar.com', + }) +# --- diff --git a/tests/components/vicare/test_config_flow.py b/tests/components/vicare/test_config_flow.py index 10b7861ef7..72fb8d0d0b 100644 --- a/tests/components/vicare/test_config_flow.py +++ b/tests/components/vicare/test_config_flow.py @@ -1,132 +1,123 @@ """Test the ViCare config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from PyViCare.PyViCareUtils import PyViCareInvalidCredentialsError +import pytest +from syrupy.assertion import SnapshotAssertion -from homeassistant import config_entries, data_entry_flow from homeassistant.components import dhcp from homeassistant.components.vicare.const import DOMAIN +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType -from . import ENTRY_CONFIG, MOCK_MAC +from . import MOCK_MAC, MODULE from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" +VALID_CONFIG = { + CONF_USERNAME: "foo@bar.com", + CONF_PASSWORD: "1234", + CONF_CLIENT_ID: "5678", +} + +DHCP_INFO = dhcp.DhcpServiceInfo( + ip="1.1.1.1", + hostname="mock_hostname", + macaddress=MOCK_MAC, +) + + +async def test_user_create_entry( + hass: HomeAssistant, mock_setup_entry: AsyncMock, snapshot: SnapshotAssertion +) -> None: + """Test that the user step works.""" + # start user flow result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert len(result["errors"]) == 0 + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + # test PyViCareInvalidCredentialsError with patch( - "homeassistant.components.vicare.config_flow.vicare_login", - return_value=None, - ), patch( - "homeassistant.components.vicare.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "foo@bar.com", - CONF_PASSWORD: "1234", - CONF_CLIENT_ID: "5678", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result2["title"] == "ViCare" - assert result2["data"] == ENTRY_CONFIG - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_invalid_login(hass: HomeAssistant) -> None: - """Test a flow with an invalid Vicare login.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.vicare.config_flow.vicare_login", + f"{MODULE}.config_flow.vicare_login", side_effect=PyViCareInvalidCredentialsError, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_USERNAME: "foo@bar.com", - CONF_PASSWORD: "1234", - CONF_CLIENT_ID: "5678", - }, + VALID_CONFIG, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + # test success + with patch( + f"{MODULE}.config_flow.vicare_login", + return_value=None, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, ) await hass.async_block_till_done() - assert result2["type"] == data_entry_flow.FlowResultType.FORM - assert result2["step_id"] == "user" - assert result2["errors"] == {"base": "invalid_auth"} + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "ViCare" + assert result["data"] == snapshot + mock_setup_entry.assert_called_once() -async def test_form_dhcp(hass: HomeAssistant) -> None: +async def test_form_dhcp( + hass: HomeAssistant, mock_setup_entry: AsyncMock, snapshot: SnapshotAssertion +) -> None: """Test we can setup from dhcp.""" result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( - ip="1.1.1.1", - hostname="mock_hostname", - macaddress=MOCK_MAC, - ), + context={"source": SOURCE_DHCP}, + data=DHCP_INFO, ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {} with patch( - "homeassistant.components.vicare.config_flow.vicare_login", + f"{MODULE}.config_flow.vicare_login", return_value=None, - ), patch( - "homeassistant.components.vicare.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( + ): + result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_USERNAME: "foo@bar.com", - CONF_PASSWORD: "1234", - CONF_CLIENT_ID: "5678", - }, + VALID_CONFIG, ) await hass.async_block_till_done() - assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result2["title"] == "ViCare" - assert result2["data"] == ENTRY_CONFIG - assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "ViCare" + assert result["data"] == snapshot + mock_setup_entry.assert_called_once() async def test_dhcp_single_instance_allowed(hass: HomeAssistant) -> None: """Test that configuring more than one instance is rejected.""" mock_entry = MockConfigEntry( domain=DOMAIN, - data=ENTRY_CONFIG, + data=VALID_CONFIG, ) mock_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( - ip="1.1.1.1", - hostname="mock_hostname", - macaddress=MOCK_MAC, - ), + context={"source": SOURCE_DHCP}, + data=DHCP_INFO, ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" @@ -135,12 +126,12 @@ async def test_user_input_single_instance_allowed(hass: HomeAssistant) -> None: mock_entry = MockConfigEntry( domain=DOMAIN, unique_id="ViCare", - data=ENTRY_CONFIG, + data=VALID_CONFIG, ) mock_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" From 4f54e33f670e33e618c443c6a5f76885d38b9db8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 31 Mar 2023 14:34:20 +0200 Subject: [PATCH 1503/2810] Allow removal of sensor settings in scrape (#90412) * Allow removal of sensor settings in scrape * Adjust * Adjust * Add comment * Simplify * Simplify * Adjust * Don't allow empty string * Only allow None * Use default as None * Use sentinel "none" * Not needed * Adjust unit of measurement * Add translation keys for "none" * Use translations * Sort * Add enum and timestamp * Use translation references * Remove default and set suggested_values * Disallow enum device class * Adjust tests * Adjust _strip_sentinel --- .../components/scrape/config_flow.py | 39 +++- homeassistant/components/scrape/strings.json | 67 ++++++ tests/components/scrape/conftest.py | 13 +- tests/components/scrape/test_config_flow.py | 193 +++++++++++++++++- 4 files changed, 294 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/scrape/config_flow.py b/homeassistant/components/scrape/config_flow.py index 1e3635a010..3ca13e56b2 100644 --- a/homeassistant/components/scrape/config_flow.py +++ b/homeassistant/components/scrape/config_flow.py @@ -95,6 +95,8 @@ RESOURCE_SETUP = { vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): TextSelector(), } +NONE_SENTINEL = "none" + SENSOR_SETUP = { vol.Required(CONF_SELECT): TextSelector(), vol.Optional(CONF_INDEX, default=0): NumberSelector( @@ -102,28 +104,45 @@ SENSOR_SETUP = { ), vol.Optional(CONF_ATTRIBUTE): TextSelector(), vol.Optional(CONF_VALUE_TEMPLATE): TemplateSelector(), - vol.Optional(CONF_DEVICE_CLASS): SelectSelector( + vol.Required(CONF_DEVICE_CLASS): SelectSelector( SelectSelectorConfig( - options=[cls.value for cls in SensorDeviceClass], + options=[NONE_SENTINEL] + + sorted( + [ + cls.value + for cls in SensorDeviceClass + if cls != SensorDeviceClass.ENUM + ] + ), mode=SelectSelectorMode.DROPDOWN, + translation_key="device_class", ) ), - vol.Optional(CONF_STATE_CLASS): SelectSelector( + vol.Required(CONF_STATE_CLASS): SelectSelector( SelectSelectorConfig( - options=[cls.value for cls in SensorStateClass], + options=[NONE_SENTINEL] + sorted([cls.value for cls in SensorStateClass]), mode=SelectSelectorMode.DROPDOWN, + translation_key="state_class", ) ), - vol.Optional(CONF_UNIT_OF_MEASUREMENT): SelectSelector( + vol.Required(CONF_UNIT_OF_MEASUREMENT): SelectSelector( SelectSelectorConfig( - options=[cls.value for cls in UnitOfTemperature], + options=[NONE_SENTINEL] + sorted([cls.value for cls in UnitOfTemperature]), custom_value=True, mode=SelectSelectorMode.DROPDOWN, + translation_key="unit_of_measurement", ) ), } +def _strip_sentinel(options: dict[str, Any]) -> None: + """Convert sentinel to None.""" + for key in (CONF_DEVICE_CLASS, CONF_STATE_CLASS, CONF_UNIT_OF_MEASUREMENT): + if options[key] == NONE_SENTINEL: + options.pop(key) + + async def validate_rest_setup( handler: SchemaCommonFlowHandler, user_input: dict[str, Any] ) -> dict[str, Any]: @@ -150,6 +169,7 @@ async def validate_sensor_setup( # Standard behavior is to merge the result with the options. # In this case, we want to add a sub-item so we update the options directly. sensors: list[dict[str, Any]] = handler.options.setdefault(SENSOR_DOMAIN, []) + _strip_sentinel(user_input) sensors.append(user_input) return {} @@ -181,7 +201,11 @@ async def get_edit_sensor_suggested_values( ) -> dict[str, Any]: """Return suggested values for sensor editing.""" idx: int = handler.flow_state["_idx"] - return cast(dict[str, Any], handler.options[SENSOR_DOMAIN][idx]) + suggested_values: dict[str, Any] = dict(handler.options[SENSOR_DOMAIN][idx]) + for key in (CONF_DEVICE_CLASS, CONF_STATE_CLASS, CONF_UNIT_OF_MEASUREMENT): + if not suggested_values.get(key): + suggested_values[key] = NONE_SENTINEL + return suggested_values async def validate_sensor_edit( @@ -194,6 +218,7 @@ async def validate_sensor_edit( # In this case, we want to add a sub-item so we update the options directly. idx: int = handler.flow_state["_idx"] handler.options[SENSOR_DOMAIN][idx].update(user_input) + _strip_sentinel(handler.options[SENSOR_DOMAIN][idx]) return {} diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index 052ef22848..857d53eb52 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -125,5 +125,72 @@ } } } + }, + "selector": { + "device_class": { + "options": { + "none": "No device class", + "date": "[%key:component::sensor::entity_component::date::name%]", + "duration": "[%key:component::sensor::entity_component::duration::name%]", + "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", + "aqi": "[%key:component::sensor::entity_component::aqi::name%]", + "atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]", + "battery": "[%key:component::sensor::entity_component::battery::name%]", + "carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", + "carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", + "current": "[%key:component::sensor::entity_component::current::name%]", + "data_rate": "[%key:component::sensor::entity_component::data_rate::name%]", + "data_size": "[%key:component::sensor::entity_component::data_size::name%]", + "distance": "[%key:component::sensor::entity_component::distance::name%]", + "energy": "[%key:component::sensor::entity_component::energy::name%]", + "energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]", + "frequency": "[%key:component::sensor::entity_component::frequency::name%]", + "gas": "[%key:component::sensor::entity_component::gas::name%]", + "humidity": "[%key:component::sensor::entity_component::humidity::name%]", + "illuminance": "[%key:component::sensor::entity_component::illuminance::name%]", + "irradiance": "[%key:component::sensor::entity_component::irradiance::name%]", + "moisture": "[%key:component::sensor::entity_component::moisture::name%]", + "monetary": "[%key:component::sensor::entity_component::monetary::name%]", + "nitrogen_dioxide": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]", + "nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]", + "nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]", + "ozone": "[%key:component::sensor::entity_component::ozone::name%]", + "pm1": "[%key:component::sensor::entity_component::pm1::name%]", + "pm10": "[%key:component::sensor::entity_component::pm10::name%]", + "pm25": "[%key:component::sensor::entity_component::pm25::name%]", + "power_factor": "[%key:component::sensor::entity_component::power_factor::name%]", + "power": "[%key:component::sensor::entity_component::power::name%]", + "precipitation": "[%key:component::sensor::entity_component::precipitation::name%]", + "precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]", + "pressure": "[%key:component::sensor::entity_component::pressure::name%]", + "reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]", + "signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]", + "sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]", + "speed": "[%key:component::sensor::entity_component::speed::name%]", + "sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]", + "temperature": "[%key:component::sensor::entity_component::temperature::name%]", + "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", + "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", + "voltage": "[%key:component::sensor::entity_component::voltage::name%]", + "volume": "[%key:component::sensor::entity_component::volume::name%]", + "volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]", + "water": "[%key:component::sensor::entity_component::water::name%]", + "weight": "[%key:component::sensor::entity_component::weight::name%]", + "wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]" + } + }, + "state_class": { + "options": { + "none": "No state class", + "measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]", + "total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]", + "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]" + } + }, + "unit_of_measurement": { + "options": { + "none": "No unit of measurement" + } + } } } diff --git a/tests/components/scrape/conftest.py b/tests/components/scrape/conftest.py index 5ad4f39844..026daeea38 100644 --- a/tests/components/scrape/conftest.py +++ b/tests/components/scrape/conftest.py @@ -1,8 +1,9 @@ """Fixtures for the Scrape integration.""" from __future__ import annotations +from collections.abc import Generator from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import uuid import pytest @@ -32,6 +33,16 @@ from . import MockRestData from tests.common import MockConfigEntry +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Automatically path uuid generator.""" + with patch( + "homeassistant.components.scrape.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(name="get_config") async def get_config_to_integration_load() -> dict[str, Any]: """Return default minimal configuration. diff --git a/tests/components/scrape/test_config_flow.py b/tests/components/scrape/test_config_flow.py index e508937fed..9c6c5e0b4d 100644 --- a/tests/components/scrape/test_config_flow.py +++ b/tests/components/scrape/test_config_flow.py @@ -1,13 +1,14 @@ """Test the Scrape config flow.""" from __future__ import annotations -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import uuid from homeassistant import config_entries from homeassistant.components.rest.data import DEFAULT_TIMEOUT from homeassistant.components.rest.schema import DEFAULT_METHOD from homeassistant.components.scrape import DOMAIN +from homeassistant.components.scrape.config_flow import NONE_SENTINEL from homeassistant.components.scrape.const import ( CONF_ENCODING, CONF_INDEX, @@ -15,14 +16,18 @@ from homeassistant.components.scrape.const import ( DEFAULT_ENCODING, DEFAULT_VERIFY_SSL, ) +from homeassistant.components.sensor import CONF_STATE_CLASS from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_METHOD, CONF_NAME, CONF_PASSWORD, CONF_RESOURCE, CONF_TIMEOUT, CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, + CONF_VALUE_TEMPLATE, CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant @@ -34,7 +39,9 @@ from . import MockRestData from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None: +async def test_form( + hass: HomeAssistant, get_data: MockRestData, mock_setup_entry: AsyncMock +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -46,10 +53,7 @@ async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None: with patch( "homeassistant.components.rest.RestData", return_value=get_data, - ) as mock_data, patch( - "homeassistant.components.scrape.async_setup_entry", - return_value=True, - ) as mock_setup_entry: + ) as mock_data: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -66,6 +70,9 @@ async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None: CONF_NAME: "Current version", CONF_SELECT: ".current-version h1", CONF_INDEX: 0.0, + CONF_DEVICE_CLASS: NONE_SENTINEL, + CONF_STATE_CLASS: NONE_SENTINEL, + CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -92,7 +99,9 @@ async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_flow_fails(hass: HomeAssistant, get_data: MockRestData) -> None: +async def test_flow_fails( + hass: HomeAssistant, get_data: MockRestData, mock_setup_entry: AsyncMock +) -> None: """Test config flow error.""" result = await hass.config_entries.flow.async_init( @@ -137,9 +146,6 @@ async def test_flow_fails(hass: HomeAssistant, get_data: MockRestData) -> None: with patch( "homeassistant.components.rest.RestData", return_value=get_data, - ), patch( - "homeassistant.components.scrape.async_setup_entry", - return_value=True, ): result3 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -157,6 +163,9 @@ async def test_flow_fails(hass: HomeAssistant, get_data: MockRestData) -> None: CONF_NAME: "Current version", CONF_SELECT: ".current-version h1", CONF_INDEX: 0.0, + CONF_DEVICE_CLASS: NONE_SENTINEL, + CONF_STATE_CLASS: NONE_SENTINEL, + CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -278,6 +287,9 @@ async def test_options_add_remove_sensor_flow( CONF_NAME: "Template", CONF_SELECT: "template", CONF_INDEX: 0.0, + CONF_DEVICE_CLASS: NONE_SENTINEL, + CONF_STATE_CLASS: NONE_SENTINEL, + CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -405,6 +417,9 @@ async def test_options_edit_sensor_flow( user_input={ CONF_SELECT: "template", CONF_INDEX: 0.0, + CONF_DEVICE_CLASS: NONE_SENTINEL, + CONF_STATE_CLASS: NONE_SENTINEL, + CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -434,3 +449,161 @@ async def test_options_edit_sensor_flow( # Check the state of the entity has changed as expected state = hass.states.get("sensor.current_version") assert state.state == "Trying to get" + + +async def test_sensor_options_add_device_class( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test options flow to edit a sensor.""" + entry = MockConfigEntry( + domain=DOMAIN, + options={ + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: DEFAULT_METHOD, + CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, + CONF_TIMEOUT: DEFAULT_TIMEOUT, + CONF_ENCODING: DEFAULT_ENCODING, + "sensor": [ + { + CONF_NAME: "Current Temp", + CONF_SELECT: ".current-temp h3", + CONF_INDEX: 0, + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002", + } + ], + }, + entry_id="1", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "select_edit_sensor"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "select_edit_sensor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"index": "0"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "edit_sensor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_SELECT: ".current-temp h3", + CONF_INDEX: 0.0, + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_DEVICE_CLASS: "temperature", + CONF_STATE_CLASS: "measurement", + CONF_UNIT_OF_MEASUREMENT: "°C", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: "GET", + CONF_VERIFY_SSL: True, + CONF_TIMEOUT: 10, + CONF_ENCODING: "UTF-8", + "sensor": [ + { + CONF_NAME: "Current Temp", + CONF_SELECT: ".current-temp h3", + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_INDEX: 0, + CONF_DEVICE_CLASS: "temperature", + CONF_STATE_CLASS: "measurement", + CONF_UNIT_OF_MEASUREMENT: "°C", + CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002", + }, + ], + } + + +async def test_sensor_options_remove_device_class( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test options flow to edit a sensor.""" + entry = MockConfigEntry( + domain=DOMAIN, + options={ + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: DEFAULT_METHOD, + CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, + CONF_TIMEOUT: DEFAULT_TIMEOUT, + CONF_ENCODING: DEFAULT_ENCODING, + "sensor": [ + { + CONF_NAME: "Current Temp", + CONF_SELECT: ".current-temp h3", + CONF_INDEX: 0, + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_DEVICE_CLASS: "temperature", + CONF_STATE_CLASS: "measurement", + CONF_UNIT_OF_MEASUREMENT: "°C", + CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002", + } + ], + }, + entry_id="1", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "select_edit_sensor"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "select_edit_sensor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"index": "0"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "edit_sensor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_SELECT: ".current-temp h3", + CONF_INDEX: 0.0, + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_DEVICE_CLASS: NONE_SENTINEL, + CONF_STATE_CLASS: NONE_SENTINEL, + CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: "GET", + CONF_VERIFY_SSL: True, + CONF_TIMEOUT: 10, + CONF_ENCODING: "UTF-8", + "sensor": [ + { + CONF_NAME: "Current Temp", + CONF_SELECT: ".current-temp h3", + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_INDEX: 0, + CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002", + }, + ], + } From 23372e8bc4629a1912e7615514b4f0d4a3e04982 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 14:55:48 +0200 Subject: [PATCH 1504/2810] Add arming/disarming state to Verisure (#90577) --- homeassistant/components/verisure/alarm_control_panel.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/verisure/alarm_control_panel.py b/homeassistant/components/verisure/alarm_control_panel.py index 0cfd6ebb81..9615404a9a 100644 --- a/homeassistant/components/verisure/alarm_control_panel.py +++ b/homeassistant/components/verisure/alarm_control_panel.py @@ -9,6 +9,7 @@ from homeassistant.components.alarm_control_panel import ( CodeFormat, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ALARM_ARMING, STATE_ALARM_DISARMING from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -83,18 +84,24 @@ class VerisureAlarm( async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" + self._attr_state = STATE_ALARM_DISARMING + self.async_write_ha_state() await self._async_set_arm_state( "DISARMED", self.coordinator.verisure.disarm(code) ) async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" + self._attr_state = STATE_ALARM_ARMING + self.async_write_ha_state() await self._async_set_arm_state( "ARMED_HOME", self.coordinator.verisure.arm_home(code) ) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" + self._attr_state = STATE_ALARM_ARMING + self.async_write_ha_state() await self._async_set_arm_state( "ARMED_AWAY", self.coordinator.verisure.arm_away(code) ) From 1ca7f0dc6a6db63586d9fdffebc8cfc582d10c0e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Mar 2023 15:50:49 +0200 Subject: [PATCH 1505/2810] Tweak yalexs_ble translations (#90582) --- homeassistant/components/yalexs_ble/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/yalexs_ble/strings.json b/homeassistant/components/yalexs_ble/strings.json index 0f1f138fd6..c2d1a2155c 100644 --- a/homeassistant/components/yalexs_ble/strings.json +++ b/homeassistant/components/yalexs_ble/strings.json @@ -22,7 +22,7 @@ } }, "error": { - "no_longer_in_range": "The lock is no longer in Bluetooth range. Move the lock or adapter and again.", + "no_longer_in_range": "The lock is no longer in Bluetooth range. Move the lock or adapter and try again.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]", From 3467f4674e02630cd6f20fa9055a962a35f378fd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Mar 2023 15:53:35 +0200 Subject: [PATCH 1506/2810] Remove unnecessary calls to `async_update_entry` from `async_migrate_entry` (#90575) --- homeassistant/components/airvisual/__init__.py | 1 - homeassistant/components/ambient_station/__init__.py | 1 - homeassistant/components/axis/__init__.py | 1 - homeassistant/components/landisgyr_heat_meter/__init__.py | 1 - homeassistant/components/samsungtv/__init__.py | 1 - homeassistant/components/velbus/__init__.py | 2 -- 6 files changed, 7 deletions(-) diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index 793b787927..21be2e5d66 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -380,7 +380,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) else: entry.version = version - hass.config_entries.async_update_entry(entry) LOGGER.info("Migration to version %s successful", version) diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 5dd8f0fb2f..f68ae3df11 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -117,7 +117,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: en_reg.async_clear_config_entry(entry.entry_id) version = entry.version = 2 - hass.config_entries.async_update_entry(entry) LOGGER.info("Migration to version %s successful", version) diff --git a/homeassistant/components/axis/__init__.py b/homeassistant/components/axis/__init__.py index c4c05f1c51..65a425fa5c 100644 --- a/homeassistant/components/axis/__init__.py +++ b/homeassistant/components/axis/__init__.py @@ -51,7 +51,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if config_entry.version != 3: # Home Assistant 2023.2 config_entry.version = 3 - hass.config_entries.async_update_entry(config_entry) _LOGGER.info("Migration to version %s successful", config_entry.version) diff --git a/homeassistant/components/landisgyr_heat_meter/__init__.py b/homeassistant/components/landisgyr_heat_meter/__init__.py index 3a44267bd4..0279af2e61 100644 --- a/homeassistant/components/landisgyr_heat_meter/__init__.py +++ b/homeassistant/components/landisgyr_heat_meter/__init__.py @@ -67,7 +67,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> await async_migrate_entries( hass, config_entry.entry_id, update_entity_unique_id ) - hass.config_entries.async_update_entry(config_entry) _LOGGER.info("Migration to version %s successful", config_entry.version) diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index 0d90157f76..3406185b96 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -340,7 +340,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> en_reg.async_clear_config_entry(config_entry.entry_id) version = config_entry.version = 2 - hass.config_entries.async_update_entry(config_entry) LOGGER.debug("Migration to version %s successful", version) return True diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index 554b16877c..b2b1cb3162 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -210,8 +210,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> await hass.async_add_executor_job(shutil.rmtree, cache_path) # set the new version config_entry.version = 2 - # update the entry - hass.config_entries.async_update_entry(config_entry) _LOGGER.debug("Migration to version %s successful", config_entry.version) return True From 9a17c437ad7a94f71311670e6688f820f5d2ac39 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Mar 2023 15:59:48 +0200 Subject: [PATCH 1507/2810] Remove some dead code from google_assistant (#90581) --- homeassistant/components/google_assistant/trait.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index b248ffbac2..3752574f31 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -75,7 +75,6 @@ from homeassistant.util.percentage import ( from homeassistant.util.unit_conversion import TemperatureConverter from .const import ( - CHALLENGE_ACK_NEEDED, CHALLENGE_FAILED_PIN_NEEDED, CHALLENGE_PIN_NEEDED, ERR_ALREADY_ARMED, @@ -2131,14 +2130,6 @@ def _verify_pin_challenge(data, state, challenge): raise ChallengeNeeded(CHALLENGE_FAILED_PIN_NEEDED) -def _verify_ack_challenge(data, state, challenge): - """Verify an ack challenge.""" - if not data.config.should_2fa(state): - return - if not challenge or not challenge.get("ack"): - raise ChallengeNeeded(CHALLENGE_ACK_NEEDED) - - MEDIA_COMMAND_SUPPORT_MAPPING = { COMMAND_MEDIA_NEXT: media_player.SUPPORT_NEXT_TRACK, COMMAND_MEDIA_PAUSE: media_player.SUPPORT_PAUSE, From 8e77d215e7b6572cca86e8d554a02bf8406ebe87 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 16:08:02 +0200 Subject: [PATCH 1508/2810] Raise on invalid (dis)arm code in manual mqtt alarm (#90584) --- .../manual_mqtt/alarm_control_panel.py | 51 +++++++------------ .../manual_mqtt/test_alarm_control_panel.py | 20 +++++--- 2 files changed, 29 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/manual_mqtt/alarm_control_panel.py b/homeassistant/components/manual_mqtt/alarm_control_panel.py index d6b4a58c41..fd6adb009a 100644 --- a/homeassistant/components/manual_mqtt/alarm_control_panel.py +++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py @@ -29,6 +29,7 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( @@ -345,56 +346,34 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - if not self._async_validate_code(code, STATE_ALARM_DISARMED): - return - + self._async_validate_code(code, STATE_ALARM_DISARMED) self._state = STATE_ALARM_DISARMED self._state_ts = dt_util.utcnow() self.async_schedule_update_ha_state() async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_HOME - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_HOME) self._async_update_state(STATE_ALARM_ARMED_HOME) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_AWAY - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_AWAY) self._async_update_state(STATE_ALARM_ARMED_AWAY) async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_NIGHT - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_NIGHT) self._async_update_state(STATE_ALARM_ARMED_NIGHT) async def async_alarm_arm_vacation(self, code: str | None = None) -> None: """Send arm vacation command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_VACATION - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_VACATION) self._async_update_state(STATE_ALARM_ARMED_VACATION) async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None: """Send arm custom bypass command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_CUSTOM_BYPASS - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_CUSTOM_BYPASS) self._async_update_state(STATE_ALARM_ARMED_CUSTOM_BYPASS) async def async_alarm_trigger(self, code: str | None = None) -> None: @@ -436,18 +415,22 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): def _async_validate_code(self, code, state): """Validate given code.""" - if self._code is None: - return True + if ( + state != STATE_ALARM_DISARMED and not self.code_arm_required + ) or self._code is None: + return + if isinstance(self._code, str): alarm_code = self._code else: alarm_code = self._code.async_render( from_state=self._state, to_state=state, parse_result=False ) - check = not alarm_code or code == alarm_code - if not check: - _LOGGER.warning("Invalid code given for %s", state) - return check + + if not alarm_code or code == alarm_code: + return + + raise HomeAssistantError("Invalid alarm code provided") @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/tests/components/manual_mqtt/test_alarm_control_panel.py b/tests/components/manual_mqtt/test_alarm_control_panel.py index 8aaccad105..549fa99517 100644 --- a/tests/components/manual_mqtt/test_alarm_control_panel.py +++ b/tests/components/manual_mqtt/test_alarm_control_panel.py @@ -24,6 +24,7 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -280,12 +281,13 @@ async def test_with_invalid_code( assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - await hass.services.async_call( - alarm_control_panel.DOMAIN, - service, - {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: f"{CODE}2"}, - blocking=True, - ) + with pytest.raises(HomeAssistantError, match=r"^Invalid alarm code provided$"): + await hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: f"{CODE}2"}, + blocking=True, + ) assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED @@ -881,7 +883,8 @@ async def test_disarm_during_trigger_with_invalid_code( assert hass.states.get(entity_id).state == STATE_ALARM_PENDING - await common.async_alarm_disarm(hass, entity_id=entity_id) + with pytest.raises(HomeAssistantError, match=r"Invalid alarm code provided$"): + await common.async_alarm_disarm(hass, entity_id=entity_id) assert hass.states.get(entity_id).state == STATE_ALARM_PENDING @@ -1307,7 +1310,8 @@ async def test_disarm_with_template_code( state = hass.states.get(entity_id) assert state.state == STATE_ALARM_ARMED_HOME - await common.async_alarm_disarm(hass, "def") + with pytest.raises(HomeAssistantError, match=r"Invalid alarm code provided$"): + await common.async_alarm_disarm(hass, "def") state = hass.states.get(entity_id) assert state.state == STATE_ALARM_ARMED_HOME From 469321157d67ce8afa8564a0b139a5a77f44034a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 16:08:16 +0200 Subject: [PATCH 1509/2810] Raise on invalid (dis)arm code in manual alarm (#90579) --- .../components/manual/alarm_control_panel.py | 51 +++++++------------ .../manual/test_alarm_control_panel.py | 23 ++++++--- 2 files changed, 32 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py index f0436ba1d6..da77aea6c4 100644 --- a/homeassistant/components/manual/alarm_control_panel.py +++ b/homeassistant/components/manual/alarm_control_panel.py @@ -29,6 +29,7 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_time @@ -285,56 +286,34 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - if not self._async_validate_code(code, STATE_ALARM_DISARMED): - return - + self._async_validate_code(code, STATE_ALARM_DISARMED) self._state = STATE_ALARM_DISARMED self._state_ts = dt_util.utcnow() self.async_write_ha_state() async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_HOME - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_HOME) self._async_update_state(STATE_ALARM_ARMED_HOME) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_AWAY - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_AWAY) self._async_update_state(STATE_ALARM_ARMED_AWAY) async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_NIGHT - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_NIGHT) self._async_update_state(STATE_ALARM_ARMED_NIGHT) async def async_alarm_arm_vacation(self, code: str | None = None) -> None: """Send arm vacation command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_VACATION - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_VACATION) self._async_update_state(STATE_ALARM_ARMED_VACATION) async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None: """Send arm custom bypass command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_CUSTOM_BYPASS - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_CUSTOM_BYPASS) self._async_update_state(STATE_ALARM_ARMED_CUSTOM_BYPASS) async def async_alarm_trigger(self, code: str | None = None) -> None: @@ -383,18 +362,22 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): def _async_validate_code(self, code, state): """Validate given code.""" - if self._code is None: - return True + if ( + state != STATE_ALARM_DISARMED and not self.code_arm_required + ) or self._code is None: + return + if isinstance(self._code, str): alarm_code = self._code else: alarm_code = self._code.async_render( parse_result=False, from_state=self._state, to_state=state ) - check = not alarm_code or code == alarm_code - if not check: - _LOGGER.warning("Invalid code given for %s", state) - return check + + if not alarm_code or code == alarm_code: + return + + raise HomeAssistantError("Invalid alarm code provided") @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/tests/components/manual/test_alarm_control_panel.py b/tests/components/manual/test_alarm_control_panel.py index 21cbc95d4e..f1a4b2da2e 100644 --- a/tests/components/manual/test_alarm_control_panel.py +++ b/tests/components/manual/test_alarm_control_panel.py @@ -26,6 +26,7 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import CoreState, HomeAssistant, State +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -224,12 +225,16 @@ async def test_with_invalid_code(hass: HomeAssistant, service, expected_state) - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - await hass.services.async_call( - alarm_control_panel.DOMAIN, - service, - {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: CODE + "2"}, - blocking=True, - ) + with pytest.raises(HomeAssistantError, match=r"^Invalid alarm code provided$"): + await hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + { + ATTR_ENTITY_ID: "alarm_control_panel.test", + ATTR_CODE: f"{CODE}2", + }, + blocking=True, + ) assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED @@ -1082,7 +1087,8 @@ async def test_disarm_during_trigger_with_invalid_code(hass: HomeAssistant) -> N assert hass.states.get(entity_id).state == STATE_ALARM_PENDING - await common.async_alarm_disarm(hass, entity_id=entity_id) + with pytest.raises(HomeAssistantError, match=r"^Invalid alarm code provided$"): + await common.async_alarm_disarm(hass, entity_id=entity_id) assert hass.states.get(entity_id).state == STATE_ALARM_PENDING @@ -1125,7 +1131,8 @@ async def test_disarm_with_template_code(hass: HomeAssistant) -> None: state = hass.states.get(entity_id) assert state.state == STATE_ALARM_ARMED_HOME - await common.async_alarm_disarm(hass, "def") + with pytest.raises(HomeAssistantError, match=r"^Invalid alarm code provided$"): + await common.async_alarm_disarm(hass, "def") state = hass.states.get(entity_id) assert state.state == STATE_ALARM_ARMED_HOME From 149e610bca452b20f099ac790473aee2175be00c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Mar 2023 17:03:02 +0200 Subject: [PATCH 1510/2810] Drop __eq__ dunder method from Entity (#90585) --- homeassistant/helpers/entity.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 9d9e685d6a..eaac35286d 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -945,25 +945,6 @@ class Entity(ABC): self.entity_id = self.registry_entry.entity_id await self.platform.async_add_entities([self]) - def __eq__(self, other: Any) -> bool: - """Return the comparison.""" - if not isinstance(other, self.__class__): - return False - - # Can only decide equality if both have a unique id - if self.unique_id is None or other.unique_id is None: - return False - - # Ensure they belong to the same platform - if self.platform is not None or other.platform is not None: - if self.platform is None or other.platform is None: - return False - - if self.platform.platform != other.platform.platform: - return False - - return self.unique_id == other.unique_id - def __repr__(self) -> str: """Return the representation.""" return f"" From c566303edbfd2180d3c068aa776e65d8136eb21b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Mar 2023 06:23:05 -1000 Subject: [PATCH 1511/2810] Avoid writing state to all esphome entities at shutdown (#90555) --- homeassistant/components/esphome/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 192a19e480..58659a671f 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -349,7 +349,12 @@ async def async_setup_entry( # noqa: C901 # the next state update of that type when the device reconnects for state_keys in entry_data.state.values(): state_keys.clear() - entry_data.async_update_device_state(hass) + if not hass.is_stopping: + # Avoid marking every esphome entity as unavailable on shutdown + # since it generates a lot of state changed events and database + # writes when we already know we're shutting down and the state + # will be cleared anyway. + entry_data.async_update_device_state(hass) async def on_connect_error(err: Exception) -> None: """Start reauth flow if appropriate connect error type.""" From 03137feba5ef13772672eab51934c0bf21e74461 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 31 Mar 2023 20:15:49 +0200 Subject: [PATCH 1512/2810] Update frontend to 20230331.0 (#90594) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 6a2a904833..114760923e 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20230330.0"] + "requirements": ["home-assistant-frontend==20230331.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a7815bdf91..ab00d9ca81 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.0.0 hass-nabucasa==0.63.1 hassil==1.0.6 home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230330.0 +home-assistant-frontend==20230331.0 home-assistant-intents==2023.3.29 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index 2aab38d76a..2906cda9a4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -907,7 +907,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230330.0 +home-assistant-frontend==20230331.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7779f3a5f7..c1966555f7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -693,7 +693,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230330.0 +home-assistant-frontend==20230331.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 From 611d4135fd8666ee3a0c8da773eb0acf66480fcd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 31 Mar 2023 20:19:58 +0200 Subject: [PATCH 1513/2810] Add ComponentProtocol to improve type checking (#90586) --- homeassistant/config.py | 4 +- homeassistant/config_entries.py | 10 ++-- homeassistant/loader.py | 70 ++++++++++++++++++++--- homeassistant/setup.py | 2 +- pylint/plugins/hass_enforce_type_hints.py | 8 +++ 5 files changed, 77 insertions(+), 17 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index 283f8726e2..0a5da91d94 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -61,7 +61,7 @@ from .helpers import ( ) from .helpers.entity_values import EntityValues from .helpers.typing import ConfigType -from .loader import Integration, IntegrationNotFound +from .loader import ComponentProtocol, Integration, IntegrationNotFound from .requirements import RequirementsNotFound, async_get_integration_with_requirements from .util.package import is_docker_env from .util.unit_system import get_unit_system, validate_unit_system @@ -681,7 +681,7 @@ def _log_pkg_error(package: str, component: str, config: dict, message: str) -> _LOGGER.error(message) -def _identify_config_schema(module: ModuleType) -> str | None: +def _identify_config_schema(module: ComponentProtocol) -> str | None: """Extract the schema and identify list or dict based.""" if not isinstance(module.CONFIG_SCHEMA, vol.Schema): return None diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 454cfeade2..3731f5fa9a 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -383,7 +383,7 @@ class ConfigEntry: result = await component.async_setup_entry(hass, self) if not isinstance(result, bool): - _LOGGER.error( + _LOGGER.error( # type: ignore[unreachable] "%s.async_setup_entry did not return boolean", integration.domain ) result = False @@ -546,8 +546,7 @@ class ConfigEntry: await self._async_process_on_unload() - # https://github.com/python/mypy/issues/11839 - return result # type: ignore[no-any-return] + return result except Exception as ex: # pylint: disable=broad-except _LOGGER.exception( "Error unloading entry %s for %s", self.title, integration.domain @@ -628,15 +627,14 @@ class ConfigEntry: try: result = await component.async_migrate_entry(hass, self) if not isinstance(result, bool): - _LOGGER.error( + _LOGGER.error( # type: ignore[unreachable] "%s.async_migrate_entry did not return boolean", self.domain ) return False if result: # pylint: disable-next=protected-access hass.config_entries._async_schedule_save() - # https://github.com/python/mypy/issues/11839 - return result # type: ignore[no-any-return] + return result except Exception: # pylint: disable=broad-except _LOGGER.exception( "Error migrating entry %s for %s", self.title, self.domain diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 600fef5a13..11f551b37e 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -15,13 +15,14 @@ import logging import pathlib import sys from types import ModuleType -from typing import TYPE_CHECKING, Any, Literal, TypedDict, TypeVar, cast +from typing import TYPE_CHECKING, Any, Literal, Protocol, TypedDict, TypeVar, cast from awesomeversion import ( AwesomeVersion, AwesomeVersionException, AwesomeVersionStrategy, ) +import voluptuous as vol from . import generated from .generated.application_credentials import APPLICATION_CREDENTIALS @@ -35,7 +36,10 @@ from .util.json import JSON_DECODE_EXCEPTIONS, json_loads # Typing imports that create a circular dependency if TYPE_CHECKING: + from .config_entries import ConfigEntry from .core import HomeAssistant + from .helpers import device_registry as dr + from .helpers.typing import ConfigType _CallableT = TypeVar("_CallableT", bound=Callable[..., Any]) @@ -260,6 +264,52 @@ async def async_get_config_flows( return flows +class ComponentProtocol(Protocol): + """Define the format of an integration.""" + + CONFIG_SCHEMA: vol.Schema + DOMAIN: str + + async def async_setup_entry( + self, hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up a config entry.""" + + async def async_unload_entry( + self, hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Unload a config entry.""" + + async def async_migrate_entry( + self, hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Migrate an old config entry.""" + + async def async_remove_entry( + self, hass: HomeAssistant, config_entry: ConfigEntry + ) -> None: + """Remove a config entry.""" + + async def async_remove_config_entry_device( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + device_entry: dr.DeviceEntry, + ) -> bool: + """Remove a config entry device.""" + + async def async_reset_platform( + self, hass: HomeAssistant, integration_name: str + ) -> None: + """Release resources.""" + + async def async_setup(self, hass: HomeAssistant, config: ConfigType) -> bool: + """Set up integration.""" + + def setup(self, hass: HomeAssistant, config: ConfigType) -> bool: + """Set up integration.""" + + async def async_get_integration_descriptions( hass: HomeAssistant, ) -> dict[str, Any]: @@ -750,14 +800,18 @@ class Integration: return self._all_dependencies_resolved - def get_component(self) -> ModuleType: + def get_component(self) -> ComponentProtocol: """Return the component.""" - cache: dict[str, ModuleType] = self.hass.data.setdefault(DATA_COMPONENTS, {}) + cache: dict[str, ComponentProtocol] = self.hass.data.setdefault( + DATA_COMPONENTS, {} + ) if self.domain in cache: return cache[self.domain] try: - cache[self.domain] = importlib.import_module(self.pkg_path) + cache[self.domain] = cast( + ComponentProtocol, importlib.import_module(self.pkg_path) + ) except ImportError: raise except Exception as err: @@ -922,7 +976,7 @@ class CircularDependency(LoaderError): def _load_file( hass: HomeAssistant, comp_or_platform: str, base_paths: list[str] -) -> ModuleType | None: +) -> ComponentProtocol | None: """Try to load specified file. Looks in config dir first, then built-in components. @@ -957,7 +1011,7 @@ def _load_file( cache[comp_or_platform] = module - return module + return cast(ComponentProtocol, module) except ImportError as err: # This error happens if for example custom_components/switch @@ -981,7 +1035,7 @@ def _load_file( class ModuleWrapper: """Class to wrap a Python module and auto fill in hass argument.""" - def __init__(self, hass: HomeAssistant, module: ModuleType) -> None: + def __init__(self, hass: HomeAssistant, module: ComponentProtocol) -> None: """Initialize the module wrapper.""" self._hass = hass self._module = module @@ -1010,7 +1064,7 @@ class Components: integration = self._hass.data.get(DATA_INTEGRATIONS, {}).get(comp_name) if isinstance(integration, Integration): - component: ModuleType | None = integration.get_component() + component: ComponentProtocol | None = integration.get_component() else: # Fallback to importing old-school component = _load_file(self._hass, comp_name, _lookup_path(self._hass)) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index ce502116cf..f217aa297e 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -236,7 +236,7 @@ async def _async_setup_component( SLOW_SETUP_WARNING, ) - task = None + task: Awaitable[bool] | None = None result: Any | bool = True try: if hasattr(component, "async_setup"): diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index a84d578cf5..b917ba2f3c 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -202,6 +202,14 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { }, return_type="bool", ), + TypeHintMatch( + function_name="async_reset_platform", + arg_types={ + 0: "HomeAssistant", + 1: "str", + }, + return_type=None, + ), ], "__any_platform__": [ TypeHintMatch( From 8256d9b47224044e0ae67e42872b030e15b00b67 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Fri, 31 Mar 2023 20:30:04 +0200 Subject: [PATCH 1514/2810] Remove xbox_live integration (#90592) --- .coveragerc | 1 - CODEOWNERS | 1 - homeassistant/brands/microsoft.json | 3 +- .../components/xbox_live/__init__.py | 1 - .../components/xbox_live/manifest.json | 9 - homeassistant/components/xbox_live/sensor.py | 156 ------------------ .../components/xbox_live/strings.json | 8 - homeassistant/generated/integrations.json | 6 - requirements_all.txt | 3 - 9 files changed, 1 insertion(+), 187 deletions(-) delete mode 100644 homeassistant/components/xbox_live/__init__.py delete mode 100644 homeassistant/components/xbox_live/manifest.json delete mode 100644 homeassistant/components/xbox_live/sensor.py delete mode 100644 homeassistant/components/xbox_live/strings.json diff --git a/.coveragerc b/.coveragerc index d313da55dd..87886b8412 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1438,7 +1438,6 @@ omit = homeassistant/components/xbox/media_player.py homeassistant/components/xbox/remote.py homeassistant/components/xbox/sensor.py - homeassistant/components/xbox_live/sensor.py homeassistant/components/xeoma/camera.py homeassistant/components/xiaomi/camera.py homeassistant/components/xiaomi_aqara/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index 0e918caade..88df4edd6f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1367,7 +1367,6 @@ build.json @home-assistant/supervisor /tests/components/ws66i/ @ssaenger /homeassistant/components/xbox/ @hunterjm /tests/components/xbox/ @hunterjm -/homeassistant/components/xbox_live/ @MartinHjelmare /homeassistant/components/xiaomi_aqara/ @danielhiversen @syssi /tests/components/xiaomi_aqara/ @danielhiversen @syssi /homeassistant/components/xiaomi_ble/ @Jc2k @Ernst79 diff --git a/homeassistant/brands/microsoft.json b/homeassistant/brands/microsoft.json index d28932082a..9da24e76f1 100644 --- a/homeassistant/brands/microsoft.json +++ b/homeassistant/brands/microsoft.json @@ -10,7 +10,6 @@ "microsoft_face", "microsoft", "msteams", - "xbox", - "xbox_live" + "xbox" ] } diff --git a/homeassistant/components/xbox_live/__init__.py b/homeassistant/components/xbox_live/__init__.py deleted file mode 100644 index cc9e8ac351..0000000000 --- a/homeassistant/components/xbox_live/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The xbox_live component.""" diff --git a/homeassistant/components/xbox_live/manifest.json b/homeassistant/components/xbox_live/manifest.json deleted file mode 100644 index bf3e798da0..0000000000 --- a/homeassistant/components/xbox_live/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "xbox_live", - "name": "Xbox Live", - "codeowners": ["@MartinHjelmare"], - "documentation": "https://www.home-assistant.io/integrations/xbox_live", - "iot_class": "cloud_polling", - "loggers": ["xboxapi"], - "requirements": ["xboxapi==2.0.1"] -} diff --git a/homeassistant/components/xbox_live/sensor.py b/homeassistant/components/xbox_live/sensor.py deleted file mode 100644 index 2ad3f75468..0000000000 --- a/homeassistant/components/xbox_live/sensor.py +++ /dev/null @@ -1,156 +0,0 @@ -"""Sensor for Xbox Live account status.""" -from __future__ import annotations - -from datetime import timedelta -import logging - -import voluptuous as vol -from xboxapi import Client - -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import CONF_API_KEY, CONF_SCAN_INTERVAL -from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.issue_registry import IssueSeverity, create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - -_LOGGER = logging.getLogger(__name__) - -CONF_XUID = "xuid" - - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_XUID): vol.All(cv.ensure_list, [cv.string]), - } -) - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Xbox platform.""" - create_issue( - hass, - "xbox_live", - "pending_removal", - breaks_in_ha_version="2023.2.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="pending_removal", - ) - _LOGGER.warning( - "The Xbox Live integration is deprecated " - "and will be removed in Home Assistant 2023.2" - ) - api = Client(api_key=config[CONF_API_KEY]) - entities = [] - - # request profile info to check api connection - response = api.api_get("profile") - if not response.ok: - _LOGGER.error( - ( - "Can't setup X API connection. Check your account or " - "api key on xapi.us. Code: %s Description: %s " - ), - response.status_code, - response.reason, - ) - return - - users = config[CONF_XUID] - - interval = timedelta(minutes=1 * len(users)) - interval = config.get(CONF_SCAN_INTERVAL, interval) - - for xuid in users: - if (gamercard := get_user_gamercard(api, xuid)) is None: - continue - entities.append(XboxSensor(api, xuid, gamercard, interval)) - - add_entities(entities, True) - - -def get_user_gamercard(api, xuid): - """Get profile info.""" - gamercard = api.gamer(gamertag="", xuid=xuid).get("gamercard") - _LOGGER.debug("User gamercard: %s", gamercard) - - if gamercard.get("success", True) and gamercard.get("code") is None: - return gamercard - _LOGGER.error( - "Can't get user profile %s. Error Code: %s Description: %s", - xuid, - gamercard.get("code", "unknown"), - gamercard.get("description", "unknown"), - ) - return None - - -class XboxSensor(SensorEntity): - """A class for the Xbox account.""" - - _attr_icon = "mdi:microsoft-xbox" - _attr_should_poll = False - - def __init__(self, api, xuid, gamercard, interval): - """Initialize the sensor.""" - self._state = None - self._presence = [] - self._xuid = xuid - self._api = api - self._gamertag = gamercard["gamertag"] - self._gamerscore = gamercard["gamerscore"] - self._interval = interval - self._picture = gamercard["gamerpicSmallSslImagePath"] - self._tier = gamercard["tier"] - - @property - def name(self): - """Return the name of the sensor.""" - return self._gamertag - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - attributes = {"gamerscore": self._gamerscore, "tier": self._tier} - - for device in self._presence: - for title in device["titles"]: - attributes[f'{device["type"]} {title["placement"]}'] = title["name"] - - return attributes - - @property - def entity_picture(self): - """Avatar of the account.""" - return self._picture - - async def async_added_to_hass(self) -> None: - """Start custom polling.""" - - @callback - def async_update(event_time=None): - """Update the entity.""" - self.async_schedule_update_ha_state(True) - - async_track_time_interval(self.hass, async_update, self._interval) - - def update(self) -> None: - """Update state data from Xbox API.""" - presence = self._api.gamer(gamertag="", xuid=self._xuid).get("presence") - _LOGGER.debug("User presence: %s", presence) - self._state = presence["state"] - self._presence = presence.get("devices", []) diff --git a/homeassistant/components/xbox_live/strings.json b/homeassistant/components/xbox_live/strings.json deleted file mode 100644 index 0f73f851bd..0000000000 --- a/homeassistant/components/xbox_live/strings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "issues": { - "pending_removal": { - "title": "The Xbox Live integration is being removed", - "description": "The Xbox Live integration is pending removal from Home Assistant and will no longer be available as of Home Assistant 2023.2.\n\nThe integration is being removed, because it is only useful for the legacy device Xbox 360 and the upstream API now requires a paid subscription. Newer consoles are supported by the Xbox integration for free.\n\nRemove the Xbox Live YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } - } -} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 02273b8d97..9e5155f0cb 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3293,12 +3293,6 @@ "config_flow": true, "iot_class": "cloud_polling", "name": "Xbox" - }, - "xbox_live": { - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_polling", - "name": "Xbox Live" } } }, diff --git a/requirements_all.txt b/requirements_all.txt index 2906cda9a4..9a6aaec298 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2643,9 +2643,6 @@ wolf_smartset==0.1.11 # homeassistant.components.xbox xbox-webapi==2.0.11 -# homeassistant.components.xbox_live -xboxapi==2.0.1 - # homeassistant.components.xiaomi_ble xiaomi-ble==0.16.4 From 09d54428c980f21f5e477241ef7be96a126bfe7b Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 31 Mar 2023 20:31:04 +0200 Subject: [PATCH 1515/2810] Bump reolink-aio to 0.5.9 (#90590) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 79fc15c571..b8de6cd839 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.5.8"] + "requirements": ["reolink-aio==0.5.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9a6aaec298..cf5ca371c7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2234,7 +2234,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.8 +reolink-aio==0.5.9 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c1966555f7..edb2c970bc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1600,7 +1600,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.8 +reolink-aio==0.5.9 # homeassistant.components.python_script restrictedpython==6.0 From 44eaf70625948b0e5fb5f77df7b953c6c2c3b7fb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Mar 2023 08:33:44 -1000 Subject: [PATCH 1516/2810] Make sonos activity check a background task (#90553) Ensures the task is canceled at shutdown if the device is offline and the ping is still in progress --- homeassistant/components/sonos/speaker.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index f97d134c9c..638ede722f 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -591,13 +591,20 @@ class SonosSpeaker: self.async_write_entity_states() self.hass.async_create_task(self.async_subscribe()) - async def async_check_activity(self, now: datetime.datetime) -> None: + @callback + def async_check_activity(self, now: datetime.datetime) -> None: """Validate availability of the speaker based on recent activity.""" if not self.available: return if time.monotonic() - self._last_activity < AVAILABILITY_TIMEOUT: return + # Ensure the ping is canceled at shutdown + self.hass.async_create_background_task( + self._async_check_activity(), f"sonos {self.uid} {self.zone_name} ping" + ) + async def _async_check_activity(self) -> None: + """Validate availability of the speaker based on recent activity.""" try: await self.hass.async_add_executor_job(self.ping) except SonosUpdateError: From 8018be28eef93b3f3cb93092f27bef8af94e2896 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 Mar 2023 14:34:42 -0400 Subject: [PATCH 1517/2810] TTS: allow resolving engine and test supported options (#90539) TTS: allow resolving engine --- homeassistant/components/tts/__init__.py | 38 ++++++++++++++++++++ homeassistant/components/tts/media_source.py | 12 +++---- tests/components/tts/test_init.py | 24 +++++++++++++ 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index aa8864ad23..119a013ebf 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -136,6 +136,44 @@ class TTSCache(TypedDict): voice: bytes +@callback +def async_resolve_engine(hass: HomeAssistant, engine: str | None) -> str | None: + """Resolve engine. + + Returns None if no engines found or invalid engine passed in. + """ + manager: SpeechManager = hass.data[DOMAIN] + + if engine is not None: + if engine not in manager.providers: + return None + return engine + + if not manager.providers: + return None + + if "cloud" in manager.providers: + return "cloud" + + return next(iter(manager.providers)) + + +async def async_support_options( + hass: HomeAssistant, + engine: str, + language: str | None = None, + options: dict | None = None, +) -> bool: + """Return if an engine supports options.""" + manager: SpeechManager = hass.data[DOMAIN] + try: + manager.process_options(engine, language, options) + except HomeAssistantError: + return False + + return True + + async def async_get_media_source_audio( hass: HomeAssistant, media_source_id: str, diff --git a/homeassistant/components/tts/media_source.py b/homeassistant/components/tts/media_source.py index c197632c11..f52292e809 100644 --- a/homeassistant/components/tts/media_source.py +++ b/homeassistant/components/tts/media_source.py @@ -40,16 +40,12 @@ def generate_media_source_id( cache: bool | None = None, ) -> str: """Generate a media source ID for text-to-speech.""" + from . import async_resolve_engine # pylint: disable=import-outside-toplevel + manager: SpeechManager = hass.data[DOMAIN] - if engine is not None: - pass - elif not manager.providers: - raise HomeAssistantError("No TTS providers available") - elif "cloud" in manager.providers: - engine = "cloud" - else: - engine = next(iter(manager.providers)) + if (engine := async_resolve_engine(hass, engine)) is None: + raise HomeAssistantError("Invalid TTS provider selected") manager.process_options(engine, language, options) params = { diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 251ed9b30c..694c9ff676 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -1,6 +1,7 @@ """The tests for the TTS component.""" from http import HTTPStatus from typing import Any +from unittest.mock import patch import pytest import voluptuous as vol @@ -972,3 +973,26 @@ async def test_generate_media_source_id_invalid_options( """Test generating a media source ID.""" with pytest.raises(HomeAssistantError): tts.generate_media_source_id(hass, "msg", engine, language, options, None) + + +def test_resolve_engine(hass: HomeAssistant, setup_tts) -> None: + """Test resolving engine.""" + assert tts.async_resolve_engine(hass, None) == "test" + assert tts.async_resolve_engine(hass, "test") == "test" + assert tts.async_resolve_engine(hass, "non-existing") is None + + with patch.dict(hass.data[tts.DOMAIN].providers, {}, clear=True): + assert tts.async_resolve_engine(hass, "test") is None + + with patch.dict(hass.data[tts.DOMAIN].providers, {"cloud": object()}): + assert tts.async_resolve_engine(hass, None) == "cloud" + + +async def test_support_options(hass: HomeAssistant, setup_tts) -> None: + """Test supporting options.""" + assert await tts.async_support_options(hass, "test", "en") is True + assert await tts.async_support_options(hass, "test", "nl") is False + assert ( + await tts.async_support_options(hass, "test", "en", {"invalid_option": "yo"}) + is False + ) From ad26317b75df41eefba857691d1b4a0ae135ce79 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 Mar 2023 14:36:39 -0400 Subject: [PATCH 1518/2810] Conversation: allow getting agent info (#90540) * Conversation: allow getting agent info * Add unset agenet back --- .../components/conversation/__init__.py | 43 +++++++++++++++++-- .../conversation/snapshots/test_init.ambr | 34 +++++++++++++++ tests/components/conversation/test_init.py | 28 ++++++++---- 3 files changed, 94 insertions(+), 11 deletions(-) create mode 100644 tests/components/conversation/snapshots/test_init.ambr diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index e2e00a2652..5009530dc3 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio import logging import re -from typing import Any +from typing import Any, TypedDict import voluptuous as vol @@ -20,6 +20,15 @@ from homeassistant.loader import bind_hass from .agent import AbstractConversationAgent, ConversationInput, ConversationResult from .default_agent import DefaultAgent +__all__ = [ + "DOMAIN", + "async_converse", + "async_get_agent_info", + "async_set_agent", + "async_unset_agent", + "async_setup", +] + _LOGGER = logging.getLogger(__name__) ATTR_TEXT = "text" @@ -270,6 +279,31 @@ class ConversationProcessView(http.HomeAssistantView): return self.json(result.as_dict()) +class AgentInfo(TypedDict): + """Dictionary holding agent info.""" + + id: str + name: str + + +@core.callback +def async_get_agent_info( + hass: core.HomeAssistant, + agent_id: str | None = None, +) -> AgentInfo | None: + """Get information on the agent or None if not found.""" + manager = _get_agent_manager(hass) + + if agent_id is None: + agent_id = manager.default_agent + + for agent_info in manager.async_get_agent_info(): + if agent_info["id"] == agent_id: + return agent_info + + return None + + async def async_converse( hass: core.HomeAssistant, text: str, @@ -332,12 +366,15 @@ class AgentManager: return self._builtin_agent + if agent_id not in self._agents: + raise ValueError(f"Agent {agent_id} not found") + return self._agents[agent_id] @core.callback - def async_get_agent_info(self) -> list[dict[str, Any]]: + def async_get_agent_info(self) -> list[AgentInfo]: """List all agents.""" - agents = [ + agents: list[AgentInfo] = [ { "id": AgentManager.HOME_ASSISTANT_AGENT, "name": "Home Assistant", diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr new file mode 100644 index 0000000000..1547b5b5e8 --- /dev/null +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -0,0 +1,34 @@ +# serializer version: 1 +# name: test_get_agent_info + dict({ + 'id': 'mock-entry', + 'name': 'Mock Title', + }) +# --- +# name: test_get_agent_info.1 + dict({ + 'id': 'homeassistant', + 'name': 'Home Assistant', + }) +# --- +# name: test_get_agent_info.2 + dict({ + 'id': 'mock-entry', + 'name': 'Mock Title', + }) +# --- +# name: test_get_agent_list + dict({ + 'agents': list([ + dict({ + 'id': 'homeassistant', + 'name': 'Home Assistant', + }), + dict({ + 'id': 'mock-entry', + 'name': 'Mock Title', + }), + ]), + 'default_agent': 'mock-entry', + }) +# --- diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 55a345bd60..eb38d875bf 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -4,6 +4,7 @@ from typing import Any from unittest.mock import patch import pytest +from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant.components import conversation @@ -929,7 +930,11 @@ async def test_agent_id_validator_invalid_agent(hass: HomeAssistant) -> None: async def test_get_agent_list( - hass: HomeAssistant, init_components, mock_agent, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + init_components, + mock_agent, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test getting agent info.""" client = await hass_ws_client(hass) @@ -940,10 +945,17 @@ async def test_get_agent_list( assert msg["id"] == 5 assert msg["type"] == "result" assert msg["success"] - assert msg["result"] == { - "agents": [ - {"id": "homeassistant", "name": "Home Assistant"}, - {"id": "mock-entry", "name": "Mock Title"}, - ], - "default_agent": "mock-entry", - } + assert msg["result"] == snapshot + + +async def test_get_agent_info( + hass: HomeAssistant, init_components, mock_agent, snapshot: SnapshotAssertion +) -> None: + """Test get agent info.""" + agent_info = conversation.async_get_agent_info(hass) + # Test it's the default + assert agent_info["id"] == mock_agent.agent_id + assert agent_info == snapshot + assert conversation.async_get_agent_info(hass, "homeassistant") == snapshot + assert conversation.async_get_agent_info(hass, mock_agent.agent_id) == snapshot + assert conversation.async_get_agent_info(hass, "not exist") is None From 84eb9c5f97fffdbb5312fe406e48ea19571e35e0 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 31 Mar 2023 14:53:42 -0400 Subject: [PATCH 1519/2810] Fix ZHA definition error on received command (#90602) * Fix use of deprecated command schema access * Add a unit test --- .../components/zha/core/channels/base.py | 10 +++++++--- tests/components/zha/test_base.py | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 tests/components/zha/test_base.py diff --git a/homeassistant/components/zha/core/channels/base.py b/homeassistant/components/zha/core/channels/base.py index ae5980cd63..6d4899be37 100644 --- a/homeassistant/components/zha/core/channels/base.py +++ b/homeassistant/components/zha/core/channels/base.py @@ -58,15 +58,19 @@ class AttrReportConfig(TypedDict, total=True): def parse_and_log_command(channel, tsn, command_id, args): """Parse and log a zigbee cluster command.""" - cmd = channel.cluster.server_commands.get(command_id, [command_id])[0] + try: + name = channel.cluster.server_commands[command_id].name + except KeyError: + name = f"0x{command_id:02X}" + channel.debug( "received '%s' command with %s args on cluster_id '%s' tsn '%s'", - cmd, + name, args, channel.cluster.cluster_id, tsn, ) - return cmd + return name def decorate_command(channel, command): diff --git a/tests/components/zha/test_base.py b/tests/components/zha/test_base.py new file mode 100644 index 0000000000..fbb25f1cbd --- /dev/null +++ b/tests/components/zha/test_base.py @@ -0,0 +1,19 @@ +"""Test ZHA base channel module.""" + +from homeassistant.components.zha.core.channels.base import parse_and_log_command + +from tests.components.zha.test_channels import ( # noqa: F401 + channel_pool, + poll_control_ch, + zigpy_coordinator_device, +) + + +def test_parse_and_log_command(poll_control_ch): # noqa: F811 + """Test that `parse_and_log_command` correctly parses a known command.""" + assert parse_and_log_command(poll_control_ch, 0x00, 0x01, []) == "fast_poll_stop" + + +def test_parse_and_log_command_unknown(poll_control_ch): # noqa: F811 + """Test that `parse_and_log_command` correctly parses an unknown command.""" + assert parse_and_log_command(poll_control_ch, 0x00, 0xAB, []) == "0xAB" From 01a05340c69cb3f5a6159e520b19bef65a13c5e7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 Mar 2023 15:04:22 -0400 Subject: [PATCH 1520/2810] Voice Assistant: improve error handling (#90541) Co-authored-by: Michael Hansen --- homeassistant/components/stt/__init__.py | 16 +- .../components/voice_assistant/pipeline.py | 169 ++++++++++++------ .../voice_assistant/websocket_api.py | 53 +++--- .../snapshots/test_websocket.ambr | 25 ++- .../voice_assistant/test_websocket.py | 29 +-- 5 files changed, 178 insertions(+), 114 deletions(-) diff --git a/homeassistant/components/stt/__init__.py b/homeassistant/components/stt/__init__.py index 6319940219..b858cc743a 100644 --- a/homeassistant/components/stt/__init__.py +++ b/homeassistant/components/stt/__init__.py @@ -36,12 +36,20 @@ _LOGGER = logging.getLogger(__name__) @callback -def async_get_provider(hass: HomeAssistant, domain: str | None = None) -> Provider: +def async_get_provider( + hass: HomeAssistant, domain: str | None = None +) -> Provider | None: """Return provider.""" - if domain is None: - domain = next(iter(hass.data[DOMAIN])) + if domain: + return hass.data[DOMAIN].get(domain) - return hass.data[DOMAIN][domain] + if not hass.data[DOMAIN]: + return None + + if "cloud" in hass.data[DOMAIN]: + return hass.data[DOMAIN]["cloud"] + + return next(iter(hass.data[DOMAIN].values())) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: diff --git a/homeassistant/components/voice_assistant/pipeline.py b/homeassistant/components/voice_assistant/pipeline.py index 806a603f5e..ef13d54e6a 100644 --- a/homeassistant/components/voice_assistant/pipeline.py +++ b/homeassistant/components/voice_assistant/pipeline.py @@ -8,7 +8,7 @@ import logging from typing import Any from homeassistant.backports.enum import StrEnum -from homeassistant.components import conversation, media_source, stt +from homeassistant.components import conversation, media_source, stt, tts from homeassistant.components.tts.media_source import ( generate_media_source_id as tts_generate_media_source_id, ) @@ -17,8 +17,6 @@ from homeassistant.util.dt import utcnow from .const import DOMAIN -DEFAULT_TIMEOUT = 30 # seconds - _LOGGER = logging.getLogger(__name__) @@ -151,6 +149,9 @@ class PipelineRun: event_callback: Callable[[PipelineEvent], None] language: str = None # type: ignore[assignment] runner_data: Any | None = None + stt_provider: stt.Provider | None = None + intent_agent: str | None = None + tts_engine: str | None = None def __post_init__(self): """Set language for pipeline.""" @@ -181,13 +182,39 @@ class PipelineRun: ) ) + async def prepare_speech_to_text(self, metadata: stt.SpeechMetadata) -> None: + """Prepare speech to text.""" + stt_provider = stt.async_get_provider(self.hass, self.pipeline.stt_engine) + + if stt_provider is None: + engine = self.pipeline.stt_engine or "default" + raise SpeechToTextError( + code="stt-provider-missing", + message=f"No speech to text provider for: {engine}", + ) + + if not stt_provider.check_metadata(metadata): + raise SpeechToTextError( + code="stt-provider-unsupported-metadata", + message=( + f"Provider {engine} does not support input speech " + "to text metadata" + ), + ) + + self.stt_provider = stt_provider + async def speech_to_text( self, metadata: stt.SpeechMetadata, stream: AsyncIterable[bytes], ) -> str: """Run speech to text portion of pipeline. Returns the spoken text.""" - engine = self.pipeline.stt_engine or "default" + if self.stt_provider is None: + raise RuntimeError("Speech to text was not prepared") + + engine = self.stt_provider.name + self.event_callback( PipelineEvent( PipelineEventType.STT_START, @@ -198,28 +225,11 @@ class PipelineRun: ) ) - try: - # Load provider - stt_provider: stt.Provider = stt.async_get_provider( - self.hass, self.pipeline.stt_engine - ) - assert stt_provider is not None - except Exception as src_error: - _LOGGER.exception("No speech to text provider for %s", engine) - raise SpeechToTextError( - code="stt-provider-missing", - message=f"No speech to text provider for: {engine}", - ) from src_error - - if not stt_provider.check_metadata(metadata): - raise SpeechToTextError( - code="stt-provider-unsupported-metadata", - message=f"Provider {engine} does not support input speech to text metadata", - ) - try: # Transcribe audio stream - result = await stt_provider.async_process_audio_stream(metadata, stream) + result = await self.stt_provider.async_process_audio_stream( + metadata, stream + ) except Exception as src_error: _LOGGER.exception("Unexpected error during speech to text") raise SpeechToTextError( @@ -253,15 +263,33 @@ class PipelineRun: return result.text + async def prepare_recognize_intent(self) -> None: + """Prepare recognizing an intent.""" + agent_info = conversation.async_get_agent_info( + self.hass, self.pipeline.conversation_engine + ) + + if agent_info is None: + engine = self.pipeline.conversation_engine or "default" + raise IntentRecognitionError( + code="intent-not-supported", + message=f"Intent recognition engine {engine} is not found", + ) + + self.intent_agent = agent_info["id"] + async def recognize_intent( self, intent_input: str, conversation_id: str | None ) -> str: """Run intent recognition portion of pipeline. Returns text to speak.""" + if self.intent_agent is None: + raise RuntimeError("Recognize intent was not prepared") + self.event_callback( PipelineEvent( PipelineEventType.INTENT_START, { - "engine": self.pipeline.conversation_engine or "default", + "engine": self.intent_agent, "intent_input": intent_input, }, ) @@ -274,7 +302,7 @@ class PipelineRun: conversation_id=conversation_id, context=self.context, language=self.language, - agent_id=self.pipeline.conversation_engine, + agent_id=self.intent_agent, ) except Exception as src_error: _LOGGER.exception("Unexpected error during intent recognition") @@ -296,13 +324,38 @@ class PipelineRun: return speech + async def prepare_text_to_speech(self) -> None: + """Prepare text to speech.""" + engine = tts.async_resolve_engine(self.hass, self.pipeline.tts_engine) + + if engine is None: + engine = self.pipeline.tts_engine or "default" + raise TextToSpeechError( + code="tts-not-supported", + message=f"Text to speech engine '{engine}' not found", + ) + + if not await tts.async_support_options(self.hass, engine, self.language): + raise TextToSpeechError( + code="tts-not-supported", + message=( + f"Text to speech engine {engine} " + f"does not support language {self.language}" + ), + ) + + self.tts_engine = engine + async def text_to_speech(self, tts_input: str) -> str: """Run text to speech portion of pipeline. Returns URL of TTS audio.""" + if self.tts_engine is None: + raise RuntimeError("Text to speech was not prepared") + self.event_callback( PipelineEvent( PipelineEventType.TTS_START, { - "engine": self.pipeline.tts_engine or "default", + "engine": self.tts_engine, "tts_input": tts_input, }, ) @@ -315,7 +368,8 @@ class PipelineRun: tts_generate_media_source_id( self.hass, tts_input, - engine=self.pipeline.tts_engine, + engine=self.tts_engine, + language=self.language, ), ) except Exception as src_error: @@ -341,6 +395,8 @@ class PipelineRun: class PipelineInput: """Input to a pipeline run.""" + run: PipelineRun + stt_metadata: stt.SpeechMetadata | None = None """Metadata of stt input audio. Required when start_stage = stt.""" @@ -355,21 +411,10 @@ class PipelineInput: conversation_id: str | None = None - async def execute( - self, run: PipelineRun, timeout: int | float | None = DEFAULT_TIMEOUT - ): - """Run pipeline with optional timeout.""" - await asyncio.wait_for( - self._execute(run), - timeout=timeout, - ) - - async def _execute(self, run: PipelineRun): - self._validate(run.start_stage) - - # stt -> intent -> tts - run.start() - current_stage = run.start_stage + async def execute(self): + """Run pipeline.""" + self.run.start() + current_stage = self.run.start_stage try: # Speech to text @@ -377,29 +422,29 @@ class PipelineInput: if current_stage == PipelineStage.STT: assert self.stt_metadata is not None assert self.stt_stream is not None - intent_input = await run.speech_to_text( + intent_input = await self.run.speech_to_text( self.stt_metadata, self.stt_stream, ) current_stage = PipelineStage.INTENT - if run.end_stage != PipelineStage.STT: + if self.run.end_stage != PipelineStage.STT: tts_input = self.tts_input if current_stage == PipelineStage.INTENT: assert intent_input is not None - tts_input = await run.recognize_intent( + tts_input = await self.run.recognize_intent( intent_input, self.conversation_id ) current_stage = PipelineStage.TTS - if run.end_stage != PipelineStage.INTENT: + if self.run.end_stage != PipelineStage.INTENT: if current_stage == PipelineStage.TTS: assert tts_input is not None - await run.text_to_speech(tts_input) + await self.run.text_to_speech(tts_input) except PipelineError as err: - run.event_callback( + self.run.event_callback( PipelineEvent( PipelineEventType.ERROR, {"code": err.code, "message": err.message}, @@ -407,11 +452,11 @@ class PipelineInput: ) return - run.end() + self.run.end() - def _validate(self, stage: PipelineStage): + async def validate(self): """Validate pipeline input against start stage.""" - if stage == PipelineStage.STT: + if self.run.start_stage == PipelineStage.STT: if self.stt_metadata is None: raise PipelineRunValidationError( "stt_metadata is required for speech to text" @@ -421,13 +466,29 @@ class PipelineInput: raise PipelineRunValidationError( "stt_stream is required for speech to text" ) - elif stage == PipelineStage.INTENT: + elif self.run.start_stage == PipelineStage.INTENT: if self.intent_input is None: raise PipelineRunValidationError( "intent_input is required for intent recognition" ) - elif stage == PipelineStage.TTS: + elif self.run.start_stage == PipelineStage.TTS: if self.tts_input is None: raise PipelineRunValidationError( "tts_input is required for text to speech" ) + + start_stage_index = PIPELINE_STAGE_ORDER.index(self.run.start_stage) + + prepare_tasks = [] + + if start_stage_index <= PIPELINE_STAGE_ORDER.index(PipelineStage.STT): + prepare_tasks.append(self.run.prepare_speech_to_text(self.stt_metadata)) + + if start_stage_index <= PIPELINE_STAGE_ORDER.index(PipelineStage.INTENT): + prepare_tasks.append(self.run.prepare_recognize_intent()) + + if start_stage_index <= PIPELINE_STAGE_ORDER.index(PipelineStage.TTS): + prepare_tasks.append(self.run.prepare_text_to_speech()) + + if prepare_tasks: + await asyncio.gather(*prepare_tasks) diff --git a/homeassistant/components/voice_assistant/websocket_api.py b/homeassistant/components/voice_assistant/websocket_api.py index 28cafb7a35..aa295ad5c6 100644 --- a/homeassistant/components/voice_assistant/websocket_api.py +++ b/homeassistant/components/voice_assistant/websocket_api.py @@ -5,13 +5,13 @@ from collections.abc import Callable import logging from typing import Any +import async_timeout import voluptuous as vol from homeassistant.components import stt, websocket_api from homeassistant.core import HomeAssistant, callback from .pipeline import ( - DEFAULT_TIMEOUT, PipelineError, PipelineEvent, PipelineEventType, @@ -21,6 +21,8 @@ from .pipeline import ( async_get_pipeline, ) +DEFAULT_TIMEOUT = 30 + _LOGGER = logging.getLogger(__name__) _VAD_ENERGY_THRESHOLD = 1000 @@ -155,37 +157,40 @@ async def websocket_run( # Input to text to speech system input_args["tts_input"] = msg["input"]["text"] - run_task = hass.async_create_task( - PipelineInput(**input_args).execute( - PipelineRun( - hass, - context=connection.context(msg), - pipeline=pipeline, - start_stage=start_stage, - end_stage=end_stage, - event_callback=lambda event: connection.send_event( - msg["id"], event.as_dict() - ), - runner_data={ - "stt_binary_handler_id": handler_id, - }, - ), - timeout=timeout, - ) + input_args["run"] = PipelineRun( + hass, + context=connection.context(msg), + pipeline=pipeline, + start_stage=start_stage, + end_stage=end_stage, + event_callback=lambda event: connection.send_event(msg["id"], event.as_dict()), + runner_data={ + "stt_binary_handler_id": handler_id, + "timeout": timeout, + }, ) - # Cancel pipeline if user unsubscribes - connection.subscriptions[msg["id"]] = run_task.cancel + pipeline_input = PipelineInput(**input_args) + + try: + await pipeline_input.validate() + except PipelineError as error: + # Report more specific error when possible + connection.send_error(msg["id"], error.code, error.message) + return # Confirm subscription connection.send_result(msg["id"]) + run_task = hass.async_create_task(pipeline_input.execute()) + + # Cancel pipeline if user unsubscribes + connection.subscriptions[msg["id"]] = run_task.cancel + try: # Task contains a timeout - await run_task - except PipelineError as error: - # Report more specific error when possible - connection.send_error(msg["id"], error.code, error.message) + async with async_timeout.timeout(timeout): + await run_task except asyncio.TimeoutError: connection.send_event( msg["id"], diff --git a/tests/components/voice_assistant/snapshots/test_websocket.ambr b/tests/components/voice_assistant/snapshots/test_websocket.ambr index c18af44b21..a5812d170f 100644 --- a/tests/components/voice_assistant/snapshots/test_websocket.ambr +++ b/tests/components/voice_assistant/snapshots/test_websocket.ambr @@ -5,12 +5,13 @@ 'pipeline': 'en-US', 'runner_data': dict({ 'stt_binary_handler_id': 1, + 'timeout': 30, }), }) # --- # name: test_audio_pipeline.1 dict({ - 'engine': 'default', + 'engine': 'test', 'metadata': dict({ 'bit_rate': 16, 'channel': 1, @@ -30,7 +31,7 @@ # --- # name: test_audio_pipeline.3 dict({ - 'engine': 'default', + 'engine': 'homeassistant', 'intent_input': 'test transcript', }) # --- @@ -58,7 +59,7 @@ # --- # name: test_audio_pipeline.5 dict({ - 'engine': 'default', + 'engine': 'test', 'tts_input': "Sorry, I couldn't understand that", }) # --- @@ -66,7 +67,7 @@ dict({ 'tts_output': dict({ 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en_-_test.mp3', + 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_-_test.mp3', }), }) # --- @@ -76,12 +77,13 @@ 'pipeline': 'en-US', 'runner_data': dict({ 'stt_binary_handler_id': None, + 'timeout': 30, }), }) # --- # name: test_intent_failed.1 dict({ - 'engine': 'default', + 'engine': 'homeassistant', 'intent_input': 'Are the lights on?', }) # --- @@ -91,12 +93,13 @@ 'pipeline': 'en-US', 'runner_data': dict({ 'stt_binary_handler_id': None, + 'timeout': 0.1, }), }) # --- # name: test_intent_timeout.1 dict({ - 'engine': 'default', + 'engine': 'homeassistant', 'intent_input': 'Are the lights on?', }) # --- @@ -112,6 +115,7 @@ 'pipeline': 'en-US', 'runner_data': dict({ 'stt_binary_handler_id': 1, + 'timeout': 30, }), }) # --- @@ -134,12 +138,13 @@ 'pipeline': 'en-US', 'runner_data': dict({ 'stt_binary_handler_id': 1, + 'timeout': 30, }), }) # --- # name: test_stt_stream_failed.1 dict({ - 'engine': 'default', + 'engine': 'test', 'metadata': dict({ 'bit_rate': 16, 'channel': 1, @@ -156,12 +161,13 @@ 'pipeline': 'en-US', 'runner_data': dict({ 'stt_binary_handler_id': None, + 'timeout': 30, }), }) # --- # name: test_text_only_pipeline.1 dict({ - 'engine': 'default', + 'engine': 'homeassistant', 'intent_input': 'Are the lights on?', }) # --- @@ -199,12 +205,13 @@ 'pipeline': 'en-US', 'runner_data': dict({ 'stt_binary_handler_id': None, + 'timeout': 30, }), }) # --- # name: test_tts_failed.1 dict({ - 'engine': 'default', + 'engine': 'test', 'tts_input': 'Lights are on.', }) # --- diff --git a/tests/components/voice_assistant/test_websocket.py b/tests/components/voice_assistant/test_websocket.py index 149d896dcf..ce87655032 100644 --- a/tests/components/voice_assistant/test_websocket.py +++ b/tests/components/voice_assistant/test_websocket.py @@ -93,7 +93,7 @@ class MockTTSProvider(tts.Provider): @property def supported_languages(self) -> list[str]: """Return list of supported languages.""" - return ["en"] + return ["en-US"] @property def supported_options(self) -> list[str]: @@ -264,7 +264,7 @@ async def test_intent_timeout( "start_stage": "intent", "end_stage": "intent", "input": {"text": "Are the lights on?"}, - "timeout": 0.00001, + "timeout": 0.1, } ) @@ -301,7 +301,7 @@ async def test_text_pipeline_timeout( await asyncio.sleep(3600) with patch( - "homeassistant.components.voice_assistant.pipeline.PipelineInput._execute", + "homeassistant.components.voice_assistant.pipeline.PipelineInput.execute", new=sleepy_run, ): await client.send_json( @@ -381,7 +381,7 @@ async def test_audio_pipeline_timeout( await asyncio.sleep(3600) with patch( - "homeassistant.components.voice_assistant.pipeline.PipelineInput._execute", + "homeassistant.components.voice_assistant.pipeline.PipelineInput.execute", new=sleepy_run, ): await client.send_json( @@ -427,25 +427,8 @@ async def test_stt_provider_missing( # result msg = await client.receive_json() - assert msg["success"] - - # run start - msg = await client.receive_json() - assert msg["event"]["type"] == "run-start" - assert msg["event"]["data"] == snapshot - - # stt - msg = await client.receive_json() - assert msg["event"]["type"] == "stt-start" - assert msg["event"]["data"] == snapshot - - # End of audio stream (handler id + empty payload) - await client.send_bytes(b"1") - - # stt error - msg = await client.receive_json() - assert msg["event"]["type"] == "error" - assert msg["event"]["data"]["code"] == "stt-provider-missing" + assert not msg["success"] + assert msg["error"]["code"] == "stt-provider-missing" async def test_stt_stream_failed( From 3f398818c56c982e3e1ec060e033c10b91c5d79c Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 31 Mar 2023 15:37:00 -0400 Subject: [PATCH 1521/2810] Perform an energy scan when downloading ZHA diagnostics (#90605) --- homeassistant/components/zha/diagnostics.py | 9 +++++++++ tests/components/zha/test_diagnostics.py | 20 ++++++++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zha/diagnostics.py b/homeassistant/components/zha/diagnostics.py index 2e0653b47e..966f35fe98 100644 --- a/homeassistant/components/zha/diagnostics.py +++ b/homeassistant/components/zha/diagnostics.py @@ -7,6 +7,7 @@ from typing import Any from zigpy.config import CONF_NWK_EXTENDED_PAN_ID from zigpy.profiles import PROFILES +from zigpy.types import Channels from zigpy.zcl import Cluster from homeassistant.components.diagnostics.util import async_redact_data @@ -67,11 +68,19 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" config: dict = hass.data[DATA_ZHA].get(DATA_ZHA_CONFIG, {}) gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + + energy_scan = await gateway.application_controller.energy_scan( + channels=Channels.ALL_CHANNELS, duration_exp=4, count=1 + ) + return async_redact_data( { "config": config, "config_entry": config_entry.as_dict(), "application_state": shallow_asdict(gateway.application_controller.state), + "energy_scan": { + channel: 100 * energy / 255 for channel, energy in energy_scan.items() + }, "versions": { "bellows": version("bellows"), "zigpy": version("zigpy"), diff --git a/tests/components/zha/test_diagnostics.py b/tests/components/zha/test_diagnostics.py index 61f855af9a..5ec555d88d 100644 --- a/tests/components/zha/test_diagnostics.py +++ b/tests/components/zha/test_diagnostics.py @@ -6,6 +6,7 @@ import zigpy.profiles.zha as zha import zigpy.zcl.clusters.security as security from homeassistant.components.diagnostics import REDACTED +from homeassistant.components.zha.core.const import DATA_ZHA, DATA_ZHA_GATEWAY from homeassistant.components.zha.core.device import ZHADevice from homeassistant.components.zha.diagnostics import KEYS_TO_REDACT from homeassistant.const import Platform @@ -62,14 +63,25 @@ async def test_diagnostics_for_config_entry( ) -> None: """Test diagnostics for config entry.""" await zha_device_joined(zigpy_device) - diagnostics_data = await get_diagnostics_for_config_entry( - hass, hass_client, config_entry - ) - assert diagnostics_data + + gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + scan = {c: c for c in range(11, 26 + 1)} + + with patch.object(gateway.application_controller, "energy_scan", return_value=scan): + diagnostics_data = await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) + for key in CONFIG_ENTRY_DIAGNOSTICS_KEYS: assert key in diagnostics_data assert diagnostics_data[key] is not None + # Energy scan results are presented as a percentage. JSON object keys also must be + # strings, not integers. + assert diagnostics_data["energy_scan"] == { + str(k): 100 * v / 255 for k, v in scan.items() + } + async def test_diagnostics_for_device( hass: HomeAssistant, From 6db96847d557896ec34ba47eebcc0524daa8528e Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 31 Mar 2023 15:39:08 -0400 Subject: [PATCH 1522/2810] Bump zwave-js-server-python to 0.47.3 (#90606) * Bump zwave-js-server-python to 0.47.2 * Bump zwave-js-server-python to 0.47.3 --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 5fb7726577..d41ee0272a 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -8,7 +8,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.47.1"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.47.3"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index cf5ca371c7..935d7bdb69 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2725,7 +2725,7 @@ zigpy==0.54.0 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.47.1 +zwave-js-server-python==0.47.3 # homeassistant.components.zwave_me zwave_me_ws==0.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index edb2c970bc..27d71f3d56 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1959,7 +1959,7 @@ zigpy-znp==0.10.0 zigpy==0.54.0 # homeassistant.components.zwave_js -zwave-js-server-python==0.47.1 +zwave-js-server-python==0.47.3 # homeassistant.components.zwave_me zwave_me_ws==0.3.6 From ab66664f20f9a9d547b3b3b7e35608e245f7b54c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 31 Mar 2023 14:34:20 +0200 Subject: [PATCH 1523/2810] Allow removal of sensor settings in scrape (#90412) * Allow removal of sensor settings in scrape * Adjust * Adjust * Add comment * Simplify * Simplify * Adjust * Don't allow empty string * Only allow None * Use default as None * Use sentinel "none" * Not needed * Adjust unit of measurement * Add translation keys for "none" * Use translations * Sort * Add enum and timestamp * Use translation references * Remove default and set suggested_values * Disallow enum device class * Adjust tests * Adjust _strip_sentinel --- .../components/scrape/config_flow.py | 39 +++- homeassistant/components/scrape/strings.json | 67 ++++++ tests/components/scrape/conftest.py | 13 +- tests/components/scrape/test_config_flow.py | 193 +++++++++++++++++- 4 files changed, 294 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/scrape/config_flow.py b/homeassistant/components/scrape/config_flow.py index 1e3635a010..3ca13e56b2 100644 --- a/homeassistant/components/scrape/config_flow.py +++ b/homeassistant/components/scrape/config_flow.py @@ -95,6 +95,8 @@ RESOURCE_SETUP = { vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): TextSelector(), } +NONE_SENTINEL = "none" + SENSOR_SETUP = { vol.Required(CONF_SELECT): TextSelector(), vol.Optional(CONF_INDEX, default=0): NumberSelector( @@ -102,28 +104,45 @@ SENSOR_SETUP = { ), vol.Optional(CONF_ATTRIBUTE): TextSelector(), vol.Optional(CONF_VALUE_TEMPLATE): TemplateSelector(), - vol.Optional(CONF_DEVICE_CLASS): SelectSelector( + vol.Required(CONF_DEVICE_CLASS): SelectSelector( SelectSelectorConfig( - options=[cls.value for cls in SensorDeviceClass], + options=[NONE_SENTINEL] + + sorted( + [ + cls.value + for cls in SensorDeviceClass + if cls != SensorDeviceClass.ENUM + ] + ), mode=SelectSelectorMode.DROPDOWN, + translation_key="device_class", ) ), - vol.Optional(CONF_STATE_CLASS): SelectSelector( + vol.Required(CONF_STATE_CLASS): SelectSelector( SelectSelectorConfig( - options=[cls.value for cls in SensorStateClass], + options=[NONE_SENTINEL] + sorted([cls.value for cls in SensorStateClass]), mode=SelectSelectorMode.DROPDOWN, + translation_key="state_class", ) ), - vol.Optional(CONF_UNIT_OF_MEASUREMENT): SelectSelector( + vol.Required(CONF_UNIT_OF_MEASUREMENT): SelectSelector( SelectSelectorConfig( - options=[cls.value for cls in UnitOfTemperature], + options=[NONE_SENTINEL] + sorted([cls.value for cls in UnitOfTemperature]), custom_value=True, mode=SelectSelectorMode.DROPDOWN, + translation_key="unit_of_measurement", ) ), } +def _strip_sentinel(options: dict[str, Any]) -> None: + """Convert sentinel to None.""" + for key in (CONF_DEVICE_CLASS, CONF_STATE_CLASS, CONF_UNIT_OF_MEASUREMENT): + if options[key] == NONE_SENTINEL: + options.pop(key) + + async def validate_rest_setup( handler: SchemaCommonFlowHandler, user_input: dict[str, Any] ) -> dict[str, Any]: @@ -150,6 +169,7 @@ async def validate_sensor_setup( # Standard behavior is to merge the result with the options. # In this case, we want to add a sub-item so we update the options directly. sensors: list[dict[str, Any]] = handler.options.setdefault(SENSOR_DOMAIN, []) + _strip_sentinel(user_input) sensors.append(user_input) return {} @@ -181,7 +201,11 @@ async def get_edit_sensor_suggested_values( ) -> dict[str, Any]: """Return suggested values for sensor editing.""" idx: int = handler.flow_state["_idx"] - return cast(dict[str, Any], handler.options[SENSOR_DOMAIN][idx]) + suggested_values: dict[str, Any] = dict(handler.options[SENSOR_DOMAIN][idx]) + for key in (CONF_DEVICE_CLASS, CONF_STATE_CLASS, CONF_UNIT_OF_MEASUREMENT): + if not suggested_values.get(key): + suggested_values[key] = NONE_SENTINEL + return suggested_values async def validate_sensor_edit( @@ -194,6 +218,7 @@ async def validate_sensor_edit( # In this case, we want to add a sub-item so we update the options directly. idx: int = handler.flow_state["_idx"] handler.options[SENSOR_DOMAIN][idx].update(user_input) + _strip_sentinel(handler.options[SENSOR_DOMAIN][idx]) return {} diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index 052ef22848..857d53eb52 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -125,5 +125,72 @@ } } } + }, + "selector": { + "device_class": { + "options": { + "none": "No device class", + "date": "[%key:component::sensor::entity_component::date::name%]", + "duration": "[%key:component::sensor::entity_component::duration::name%]", + "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", + "aqi": "[%key:component::sensor::entity_component::aqi::name%]", + "atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]", + "battery": "[%key:component::sensor::entity_component::battery::name%]", + "carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", + "carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", + "current": "[%key:component::sensor::entity_component::current::name%]", + "data_rate": "[%key:component::sensor::entity_component::data_rate::name%]", + "data_size": "[%key:component::sensor::entity_component::data_size::name%]", + "distance": "[%key:component::sensor::entity_component::distance::name%]", + "energy": "[%key:component::sensor::entity_component::energy::name%]", + "energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]", + "frequency": "[%key:component::sensor::entity_component::frequency::name%]", + "gas": "[%key:component::sensor::entity_component::gas::name%]", + "humidity": "[%key:component::sensor::entity_component::humidity::name%]", + "illuminance": "[%key:component::sensor::entity_component::illuminance::name%]", + "irradiance": "[%key:component::sensor::entity_component::irradiance::name%]", + "moisture": "[%key:component::sensor::entity_component::moisture::name%]", + "monetary": "[%key:component::sensor::entity_component::monetary::name%]", + "nitrogen_dioxide": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]", + "nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]", + "nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]", + "ozone": "[%key:component::sensor::entity_component::ozone::name%]", + "pm1": "[%key:component::sensor::entity_component::pm1::name%]", + "pm10": "[%key:component::sensor::entity_component::pm10::name%]", + "pm25": "[%key:component::sensor::entity_component::pm25::name%]", + "power_factor": "[%key:component::sensor::entity_component::power_factor::name%]", + "power": "[%key:component::sensor::entity_component::power::name%]", + "precipitation": "[%key:component::sensor::entity_component::precipitation::name%]", + "precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]", + "pressure": "[%key:component::sensor::entity_component::pressure::name%]", + "reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]", + "signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]", + "sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]", + "speed": "[%key:component::sensor::entity_component::speed::name%]", + "sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]", + "temperature": "[%key:component::sensor::entity_component::temperature::name%]", + "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", + "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", + "voltage": "[%key:component::sensor::entity_component::voltage::name%]", + "volume": "[%key:component::sensor::entity_component::volume::name%]", + "volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]", + "water": "[%key:component::sensor::entity_component::water::name%]", + "weight": "[%key:component::sensor::entity_component::weight::name%]", + "wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]" + } + }, + "state_class": { + "options": { + "none": "No state class", + "measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]", + "total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]", + "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]" + } + }, + "unit_of_measurement": { + "options": { + "none": "No unit of measurement" + } + } } } diff --git a/tests/components/scrape/conftest.py b/tests/components/scrape/conftest.py index 5ad4f39844..026daeea38 100644 --- a/tests/components/scrape/conftest.py +++ b/tests/components/scrape/conftest.py @@ -1,8 +1,9 @@ """Fixtures for the Scrape integration.""" from __future__ import annotations +from collections.abc import Generator from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import uuid import pytest @@ -32,6 +33,16 @@ from . import MockRestData from tests.common import MockConfigEntry +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Automatically path uuid generator.""" + with patch( + "homeassistant.components.scrape.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(name="get_config") async def get_config_to_integration_load() -> dict[str, Any]: """Return default minimal configuration. diff --git a/tests/components/scrape/test_config_flow.py b/tests/components/scrape/test_config_flow.py index e508937fed..9c6c5e0b4d 100644 --- a/tests/components/scrape/test_config_flow.py +++ b/tests/components/scrape/test_config_flow.py @@ -1,13 +1,14 @@ """Test the Scrape config flow.""" from __future__ import annotations -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import uuid from homeassistant import config_entries from homeassistant.components.rest.data import DEFAULT_TIMEOUT from homeassistant.components.rest.schema import DEFAULT_METHOD from homeassistant.components.scrape import DOMAIN +from homeassistant.components.scrape.config_flow import NONE_SENTINEL from homeassistant.components.scrape.const import ( CONF_ENCODING, CONF_INDEX, @@ -15,14 +16,18 @@ from homeassistant.components.scrape.const import ( DEFAULT_ENCODING, DEFAULT_VERIFY_SSL, ) +from homeassistant.components.sensor import CONF_STATE_CLASS from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_METHOD, CONF_NAME, CONF_PASSWORD, CONF_RESOURCE, CONF_TIMEOUT, CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, + CONF_VALUE_TEMPLATE, CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant @@ -34,7 +39,9 @@ from . import MockRestData from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None: +async def test_form( + hass: HomeAssistant, get_data: MockRestData, mock_setup_entry: AsyncMock +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -46,10 +53,7 @@ async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None: with patch( "homeassistant.components.rest.RestData", return_value=get_data, - ) as mock_data, patch( - "homeassistant.components.scrape.async_setup_entry", - return_value=True, - ) as mock_setup_entry: + ) as mock_data: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -66,6 +70,9 @@ async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None: CONF_NAME: "Current version", CONF_SELECT: ".current-version h1", CONF_INDEX: 0.0, + CONF_DEVICE_CLASS: NONE_SENTINEL, + CONF_STATE_CLASS: NONE_SENTINEL, + CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -92,7 +99,9 @@ async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_flow_fails(hass: HomeAssistant, get_data: MockRestData) -> None: +async def test_flow_fails( + hass: HomeAssistant, get_data: MockRestData, mock_setup_entry: AsyncMock +) -> None: """Test config flow error.""" result = await hass.config_entries.flow.async_init( @@ -137,9 +146,6 @@ async def test_flow_fails(hass: HomeAssistant, get_data: MockRestData) -> None: with patch( "homeassistant.components.rest.RestData", return_value=get_data, - ), patch( - "homeassistant.components.scrape.async_setup_entry", - return_value=True, ): result3 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -157,6 +163,9 @@ async def test_flow_fails(hass: HomeAssistant, get_data: MockRestData) -> None: CONF_NAME: "Current version", CONF_SELECT: ".current-version h1", CONF_INDEX: 0.0, + CONF_DEVICE_CLASS: NONE_SENTINEL, + CONF_STATE_CLASS: NONE_SENTINEL, + CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -278,6 +287,9 @@ async def test_options_add_remove_sensor_flow( CONF_NAME: "Template", CONF_SELECT: "template", CONF_INDEX: 0.0, + CONF_DEVICE_CLASS: NONE_SENTINEL, + CONF_STATE_CLASS: NONE_SENTINEL, + CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -405,6 +417,9 @@ async def test_options_edit_sensor_flow( user_input={ CONF_SELECT: "template", CONF_INDEX: 0.0, + CONF_DEVICE_CLASS: NONE_SENTINEL, + CONF_STATE_CLASS: NONE_SENTINEL, + CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -434,3 +449,161 @@ async def test_options_edit_sensor_flow( # Check the state of the entity has changed as expected state = hass.states.get("sensor.current_version") assert state.state == "Trying to get" + + +async def test_sensor_options_add_device_class( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test options flow to edit a sensor.""" + entry = MockConfigEntry( + domain=DOMAIN, + options={ + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: DEFAULT_METHOD, + CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, + CONF_TIMEOUT: DEFAULT_TIMEOUT, + CONF_ENCODING: DEFAULT_ENCODING, + "sensor": [ + { + CONF_NAME: "Current Temp", + CONF_SELECT: ".current-temp h3", + CONF_INDEX: 0, + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002", + } + ], + }, + entry_id="1", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "select_edit_sensor"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "select_edit_sensor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"index": "0"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "edit_sensor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_SELECT: ".current-temp h3", + CONF_INDEX: 0.0, + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_DEVICE_CLASS: "temperature", + CONF_STATE_CLASS: "measurement", + CONF_UNIT_OF_MEASUREMENT: "°C", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: "GET", + CONF_VERIFY_SSL: True, + CONF_TIMEOUT: 10, + CONF_ENCODING: "UTF-8", + "sensor": [ + { + CONF_NAME: "Current Temp", + CONF_SELECT: ".current-temp h3", + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_INDEX: 0, + CONF_DEVICE_CLASS: "temperature", + CONF_STATE_CLASS: "measurement", + CONF_UNIT_OF_MEASUREMENT: "°C", + CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002", + }, + ], + } + + +async def test_sensor_options_remove_device_class( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test options flow to edit a sensor.""" + entry = MockConfigEntry( + domain=DOMAIN, + options={ + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: DEFAULT_METHOD, + CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, + CONF_TIMEOUT: DEFAULT_TIMEOUT, + CONF_ENCODING: DEFAULT_ENCODING, + "sensor": [ + { + CONF_NAME: "Current Temp", + CONF_SELECT: ".current-temp h3", + CONF_INDEX: 0, + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_DEVICE_CLASS: "temperature", + CONF_STATE_CLASS: "measurement", + CONF_UNIT_OF_MEASUREMENT: "°C", + CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002", + } + ], + }, + entry_id="1", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "select_edit_sensor"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "select_edit_sensor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"index": "0"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "edit_sensor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_SELECT: ".current-temp h3", + CONF_INDEX: 0.0, + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_DEVICE_CLASS: NONE_SENTINEL, + CONF_STATE_CLASS: NONE_SENTINEL, + CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: "GET", + CONF_VERIFY_SSL: True, + CONF_TIMEOUT: 10, + CONF_ENCODING: "UTF-8", + "sensor": [ + { + CONF_NAME: "Current Temp", + CONF_SELECT: ".current-temp h3", + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_INDEX: 0, + CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002", + }, + ], + } From de9e7e47feb0baf23ca20b579c0a729a7170366f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Mar 2023 08:33:44 -1000 Subject: [PATCH 1524/2810] Make sonos activity check a background task (#90553) Ensures the task is canceled at shutdown if the device is offline and the ping is still in progress --- homeassistant/components/sonos/speaker.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index f97d134c9c..638ede722f 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -591,13 +591,20 @@ class SonosSpeaker: self.async_write_entity_states() self.hass.async_create_task(self.async_subscribe()) - async def async_check_activity(self, now: datetime.datetime) -> None: + @callback + def async_check_activity(self, now: datetime.datetime) -> None: """Validate availability of the speaker based on recent activity.""" if not self.available: return if time.monotonic() - self._last_activity < AVAILABILITY_TIMEOUT: return + # Ensure the ping is canceled at shutdown + self.hass.async_create_background_task( + self._async_check_activity(), f"sonos {self.uid} {self.zone_name} ping" + ) + async def _async_check_activity(self) -> None: + """Validate availability of the speaker based on recent activity.""" try: await self.hass.async_add_executor_job(self.ping) except SonosUpdateError: From 89dc6db5a76fbe27c9acfcf4187e0305e421f752 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 14:55:48 +0200 Subject: [PATCH 1525/2810] Add arming/disarming state to Verisure (#90577) --- homeassistant/components/verisure/alarm_control_panel.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/verisure/alarm_control_panel.py b/homeassistant/components/verisure/alarm_control_panel.py index 0cfd6ebb81..9615404a9a 100644 --- a/homeassistant/components/verisure/alarm_control_panel.py +++ b/homeassistant/components/verisure/alarm_control_panel.py @@ -9,6 +9,7 @@ from homeassistant.components.alarm_control_panel import ( CodeFormat, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ALARM_ARMING, STATE_ALARM_DISARMING from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -83,18 +84,24 @@ class VerisureAlarm( async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" + self._attr_state = STATE_ALARM_DISARMING + self.async_write_ha_state() await self._async_set_arm_state( "DISARMED", self.coordinator.verisure.disarm(code) ) async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" + self._attr_state = STATE_ALARM_ARMING + self.async_write_ha_state() await self._async_set_arm_state( "ARMED_HOME", self.coordinator.verisure.arm_home(code) ) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" + self._attr_state = STATE_ALARM_ARMING + self.async_write_ha_state() await self._async_set_arm_state( "ARMED_AWAY", self.coordinator.verisure.arm_away(code) ) From 88a407361cd798a213b52f8f309ed43f096ab7b9 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 16:08:16 +0200 Subject: [PATCH 1526/2810] Raise on invalid (dis)arm code in manual alarm (#90579) --- .../components/manual/alarm_control_panel.py | 51 +++++++------------ .../manual/test_alarm_control_panel.py | 23 ++++++--- 2 files changed, 32 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py index f0436ba1d6..da77aea6c4 100644 --- a/homeassistant/components/manual/alarm_control_panel.py +++ b/homeassistant/components/manual/alarm_control_panel.py @@ -29,6 +29,7 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_time @@ -285,56 +286,34 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - if not self._async_validate_code(code, STATE_ALARM_DISARMED): - return - + self._async_validate_code(code, STATE_ALARM_DISARMED) self._state = STATE_ALARM_DISARMED self._state_ts = dt_util.utcnow() self.async_write_ha_state() async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_HOME - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_HOME) self._async_update_state(STATE_ALARM_ARMED_HOME) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_AWAY - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_AWAY) self._async_update_state(STATE_ALARM_ARMED_AWAY) async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_NIGHT - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_NIGHT) self._async_update_state(STATE_ALARM_ARMED_NIGHT) async def async_alarm_arm_vacation(self, code: str | None = None) -> None: """Send arm vacation command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_VACATION - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_VACATION) self._async_update_state(STATE_ALARM_ARMED_VACATION) async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None: """Send arm custom bypass command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_CUSTOM_BYPASS - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_CUSTOM_BYPASS) self._async_update_state(STATE_ALARM_ARMED_CUSTOM_BYPASS) async def async_alarm_trigger(self, code: str | None = None) -> None: @@ -383,18 +362,22 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): def _async_validate_code(self, code, state): """Validate given code.""" - if self._code is None: - return True + if ( + state != STATE_ALARM_DISARMED and not self.code_arm_required + ) or self._code is None: + return + if isinstance(self._code, str): alarm_code = self._code else: alarm_code = self._code.async_render( parse_result=False, from_state=self._state, to_state=state ) - check = not alarm_code or code == alarm_code - if not check: - _LOGGER.warning("Invalid code given for %s", state) - return check + + if not alarm_code or code == alarm_code: + return + + raise HomeAssistantError("Invalid alarm code provided") @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/tests/components/manual/test_alarm_control_panel.py b/tests/components/manual/test_alarm_control_panel.py index 21cbc95d4e..f1a4b2da2e 100644 --- a/tests/components/manual/test_alarm_control_panel.py +++ b/tests/components/manual/test_alarm_control_panel.py @@ -26,6 +26,7 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import CoreState, HomeAssistant, State +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -224,12 +225,16 @@ async def test_with_invalid_code(hass: HomeAssistant, service, expected_state) - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - await hass.services.async_call( - alarm_control_panel.DOMAIN, - service, - {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: CODE + "2"}, - blocking=True, - ) + with pytest.raises(HomeAssistantError, match=r"^Invalid alarm code provided$"): + await hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + { + ATTR_ENTITY_ID: "alarm_control_panel.test", + ATTR_CODE: f"{CODE}2", + }, + blocking=True, + ) assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED @@ -1082,7 +1087,8 @@ async def test_disarm_during_trigger_with_invalid_code(hass: HomeAssistant) -> N assert hass.states.get(entity_id).state == STATE_ALARM_PENDING - await common.async_alarm_disarm(hass, entity_id=entity_id) + with pytest.raises(HomeAssistantError, match=r"^Invalid alarm code provided$"): + await common.async_alarm_disarm(hass, entity_id=entity_id) assert hass.states.get(entity_id).state == STATE_ALARM_PENDING @@ -1125,7 +1131,8 @@ async def test_disarm_with_template_code(hass: HomeAssistant) -> None: state = hass.states.get(entity_id) assert state.state == STATE_ALARM_ARMED_HOME - await common.async_alarm_disarm(hass, "def") + with pytest.raises(HomeAssistantError, match=r"^Invalid alarm code provided$"): + await common.async_alarm_disarm(hass, "def") state = hass.states.get(entity_id) assert state.state == STATE_ALARM_ARMED_HOME From 499962f4eeccd86867de75d9e5640b5e3aa1daf6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Mar 2023 15:50:49 +0200 Subject: [PATCH 1527/2810] Tweak yalexs_ble translations (#90582) --- homeassistant/components/yalexs_ble/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/yalexs_ble/strings.json b/homeassistant/components/yalexs_ble/strings.json index 0f1f138fd6..c2d1a2155c 100644 --- a/homeassistant/components/yalexs_ble/strings.json +++ b/homeassistant/components/yalexs_ble/strings.json @@ -22,7 +22,7 @@ } }, "error": { - "no_longer_in_range": "The lock is no longer in Bluetooth range. Move the lock or adapter and again.", + "no_longer_in_range": "The lock is no longer in Bluetooth range. Move the lock or adapter and try again.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]", From 2d482f1f5741da39cb7e71c0f22f2dee22b1b48e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 16:08:02 +0200 Subject: [PATCH 1528/2810] Raise on invalid (dis)arm code in manual mqtt alarm (#90584) --- .../manual_mqtt/alarm_control_panel.py | 51 +++++++------------ .../manual_mqtt/test_alarm_control_panel.py | 20 +++++--- 2 files changed, 29 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/manual_mqtt/alarm_control_panel.py b/homeassistant/components/manual_mqtt/alarm_control_panel.py index d6b4a58c41..fd6adb009a 100644 --- a/homeassistant/components/manual_mqtt/alarm_control_panel.py +++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py @@ -29,6 +29,7 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( @@ -345,56 +346,34 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - if not self._async_validate_code(code, STATE_ALARM_DISARMED): - return - + self._async_validate_code(code, STATE_ALARM_DISARMED) self._state = STATE_ALARM_DISARMED self._state_ts = dt_util.utcnow() self.async_schedule_update_ha_state() async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_HOME - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_HOME) self._async_update_state(STATE_ALARM_ARMED_HOME) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_AWAY - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_AWAY) self._async_update_state(STATE_ALARM_ARMED_AWAY) async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_NIGHT - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_NIGHT) self._async_update_state(STATE_ALARM_ARMED_NIGHT) async def async_alarm_arm_vacation(self, code: str | None = None) -> None: """Send arm vacation command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_VACATION - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_VACATION) self._async_update_state(STATE_ALARM_ARMED_VACATION) async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None: """Send arm custom bypass command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_CUSTOM_BYPASS - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_CUSTOM_BYPASS) self._async_update_state(STATE_ALARM_ARMED_CUSTOM_BYPASS) async def async_alarm_trigger(self, code: str | None = None) -> None: @@ -436,18 +415,22 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): def _async_validate_code(self, code, state): """Validate given code.""" - if self._code is None: - return True + if ( + state != STATE_ALARM_DISARMED and not self.code_arm_required + ) or self._code is None: + return + if isinstance(self._code, str): alarm_code = self._code else: alarm_code = self._code.async_render( from_state=self._state, to_state=state, parse_result=False ) - check = not alarm_code or code == alarm_code - if not check: - _LOGGER.warning("Invalid code given for %s", state) - return check + + if not alarm_code or code == alarm_code: + return + + raise HomeAssistantError("Invalid alarm code provided") @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/tests/components/manual_mqtt/test_alarm_control_panel.py b/tests/components/manual_mqtt/test_alarm_control_panel.py index 8aaccad105..549fa99517 100644 --- a/tests/components/manual_mqtt/test_alarm_control_panel.py +++ b/tests/components/manual_mqtt/test_alarm_control_panel.py @@ -24,6 +24,7 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -280,12 +281,13 @@ async def test_with_invalid_code( assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - await hass.services.async_call( - alarm_control_panel.DOMAIN, - service, - {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: f"{CODE}2"}, - blocking=True, - ) + with pytest.raises(HomeAssistantError, match=r"^Invalid alarm code provided$"): + await hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: f"{CODE}2"}, + blocking=True, + ) assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED @@ -881,7 +883,8 @@ async def test_disarm_during_trigger_with_invalid_code( assert hass.states.get(entity_id).state == STATE_ALARM_PENDING - await common.async_alarm_disarm(hass, entity_id=entity_id) + with pytest.raises(HomeAssistantError, match=r"Invalid alarm code provided$"): + await common.async_alarm_disarm(hass, entity_id=entity_id) assert hass.states.get(entity_id).state == STATE_ALARM_PENDING @@ -1307,7 +1310,8 @@ async def test_disarm_with_template_code( state = hass.states.get(entity_id) assert state.state == STATE_ALARM_ARMED_HOME - await common.async_alarm_disarm(hass, "def") + with pytest.raises(HomeAssistantError, match=r"Invalid alarm code provided$"): + await common.async_alarm_disarm(hass, "def") state = hass.states.get(entity_id) assert state.state == STATE_ALARM_ARMED_HOME From a20771f57155e2a74839c3f45ecf0df26d2cfa8e Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 31 Mar 2023 20:31:04 +0200 Subject: [PATCH 1529/2810] Bump reolink-aio to 0.5.9 (#90590) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 79fc15c571..b8de6cd839 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.5.8"] + "requirements": ["reolink-aio==0.5.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8706e4e5f9..1a6737c99d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2234,7 +2234,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.8 +reolink-aio==0.5.9 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4b1ce6ec33..9d44d8dabd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1597,7 +1597,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.8 +reolink-aio==0.5.9 # homeassistant.components.python_script restrictedpython==6.0 From c63f8e714ee33c8994393d364de785b044aacf04 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 31 Mar 2023 20:15:49 +0200 Subject: [PATCH 1530/2810] Update frontend to 20230331.0 (#90594) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 6a2a904833..114760923e 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20230330.0"] + "requirements": ["home-assistant-frontend==20230331.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 342942f0dd..cde6be3c20 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.0.0 hass-nabucasa==0.63.1 hassil==1.0.6 home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230330.0 +home-assistant-frontend==20230331.0 home-assistant-intents==2023.3.29 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index 1a6737c99d..9843965125 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -907,7 +907,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230330.0 +home-assistant-frontend==20230331.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9d44d8dabd..a249c7c7db 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -693,7 +693,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230330.0 +home-assistant-frontend==20230331.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 From f56ccf90d91b79113bf29f897d1b02e36ccc43b3 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 31 Mar 2023 14:53:42 -0400 Subject: [PATCH 1531/2810] Fix ZHA definition error on received command (#90602) * Fix use of deprecated command schema access * Add a unit test --- .../components/zha/core/channels/base.py | 10 +++++++--- tests/components/zha/test_base.py | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 tests/components/zha/test_base.py diff --git a/homeassistant/components/zha/core/channels/base.py b/homeassistant/components/zha/core/channels/base.py index ae5980cd63..6d4899be37 100644 --- a/homeassistant/components/zha/core/channels/base.py +++ b/homeassistant/components/zha/core/channels/base.py @@ -58,15 +58,19 @@ class AttrReportConfig(TypedDict, total=True): def parse_and_log_command(channel, tsn, command_id, args): """Parse and log a zigbee cluster command.""" - cmd = channel.cluster.server_commands.get(command_id, [command_id])[0] + try: + name = channel.cluster.server_commands[command_id].name + except KeyError: + name = f"0x{command_id:02X}" + channel.debug( "received '%s' command with %s args on cluster_id '%s' tsn '%s'", - cmd, + name, args, channel.cluster.cluster_id, tsn, ) - return cmd + return name def decorate_command(channel, command): diff --git a/tests/components/zha/test_base.py b/tests/components/zha/test_base.py new file mode 100644 index 0000000000..fbb25f1cbd --- /dev/null +++ b/tests/components/zha/test_base.py @@ -0,0 +1,19 @@ +"""Test ZHA base channel module.""" + +from homeassistant.components.zha.core.channels.base import parse_and_log_command + +from tests.components.zha.test_channels import ( # noqa: F401 + channel_pool, + poll_control_ch, + zigpy_coordinator_device, +) + + +def test_parse_and_log_command(poll_control_ch): # noqa: F811 + """Test that `parse_and_log_command` correctly parses a known command.""" + assert parse_and_log_command(poll_control_ch, 0x00, 0x01, []) == "fast_poll_stop" + + +def test_parse_and_log_command_unknown(poll_control_ch): # noqa: F811 + """Test that `parse_and_log_command` correctly parses an unknown command.""" + assert parse_and_log_command(poll_control_ch, 0x00, 0xAB, []) == "0xAB" From 590db0fa74c931728db2ed0074ddae36a8c6dec6 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 31 Mar 2023 15:37:00 -0400 Subject: [PATCH 1532/2810] Perform an energy scan when downloading ZHA diagnostics (#90605) --- homeassistant/components/zha/diagnostics.py | 9 +++++++++ tests/components/zha/test_diagnostics.py | 20 ++++++++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zha/diagnostics.py b/homeassistant/components/zha/diagnostics.py index 2e0653b47e..966f35fe98 100644 --- a/homeassistant/components/zha/diagnostics.py +++ b/homeassistant/components/zha/diagnostics.py @@ -7,6 +7,7 @@ from typing import Any from zigpy.config import CONF_NWK_EXTENDED_PAN_ID from zigpy.profiles import PROFILES +from zigpy.types import Channels from zigpy.zcl import Cluster from homeassistant.components.diagnostics.util import async_redact_data @@ -67,11 +68,19 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" config: dict = hass.data[DATA_ZHA].get(DATA_ZHA_CONFIG, {}) gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + + energy_scan = await gateway.application_controller.energy_scan( + channels=Channels.ALL_CHANNELS, duration_exp=4, count=1 + ) + return async_redact_data( { "config": config, "config_entry": config_entry.as_dict(), "application_state": shallow_asdict(gateway.application_controller.state), + "energy_scan": { + channel: 100 * energy / 255 for channel, energy in energy_scan.items() + }, "versions": { "bellows": version("bellows"), "zigpy": version("zigpy"), diff --git a/tests/components/zha/test_diagnostics.py b/tests/components/zha/test_diagnostics.py index 61f855af9a..5ec555d88d 100644 --- a/tests/components/zha/test_diagnostics.py +++ b/tests/components/zha/test_diagnostics.py @@ -6,6 +6,7 @@ import zigpy.profiles.zha as zha import zigpy.zcl.clusters.security as security from homeassistant.components.diagnostics import REDACTED +from homeassistant.components.zha.core.const import DATA_ZHA, DATA_ZHA_GATEWAY from homeassistant.components.zha.core.device import ZHADevice from homeassistant.components.zha.diagnostics import KEYS_TO_REDACT from homeassistant.const import Platform @@ -62,14 +63,25 @@ async def test_diagnostics_for_config_entry( ) -> None: """Test diagnostics for config entry.""" await zha_device_joined(zigpy_device) - diagnostics_data = await get_diagnostics_for_config_entry( - hass, hass_client, config_entry - ) - assert diagnostics_data + + gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + scan = {c: c for c in range(11, 26 + 1)} + + with patch.object(gateway.application_controller, "energy_scan", return_value=scan): + diagnostics_data = await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) + for key in CONFIG_ENTRY_DIAGNOSTICS_KEYS: assert key in diagnostics_data assert diagnostics_data[key] is not None + # Energy scan results are presented as a percentage. JSON object keys also must be + # strings, not integers. + assert diagnostics_data["energy_scan"] == { + str(k): 100 * v / 255 for k, v in scan.items() + } + async def test_diagnostics_for_device( hass: HomeAssistant, From b3348c3e6ffbfc04bdd5e695cd0342793d2ce296 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 31 Mar 2023 15:39:08 -0400 Subject: [PATCH 1533/2810] Bump zwave-js-server-python to 0.47.3 (#90606) * Bump zwave-js-server-python to 0.47.2 * Bump zwave-js-server-python to 0.47.3 --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 5fb7726577..d41ee0272a 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -8,7 +8,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.47.1"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.47.3"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index 9843965125..ee45c2b387 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2728,7 +2728,7 @@ zigpy==0.54.0 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.47.1 +zwave-js-server-python==0.47.3 # homeassistant.components.zwave_me zwave_me_ws==0.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a249c7c7db..5e9d6cc001 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1953,7 +1953,7 @@ zigpy-znp==0.10.0 zigpy==0.54.0 # homeassistant.components.zwave_js -zwave-js-server-python==0.47.1 +zwave-js-server-python==0.47.3 # homeassistant.components.zwave_me zwave_me_ws==0.3.6 From 03f085d7be71d3a08733dc10949a498168479088 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 Mar 2023 15:41:37 -0400 Subject: [PATCH 1534/2810] Bumped version to 2023.4.0b3 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index b47e1d9fb5..38c243997a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -8,7 +8,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 4 -PATCH_VERSION: Final = "0b2" +PATCH_VERSION: Final = "0b3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 76c1f18616..f000a293db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.4.0b2" +version = "2023.4.0b3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From f4c341253b98df575e00e9f10e5868af1bd833af Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Mar 2023 11:27:55 -1000 Subject: [PATCH 1535/2810] Avoid sorting domain/all states in templates (#90608) --- homeassistant/helpers/template.py | 6 ++-- tests/helpers/test_event.py | 4 ++- tests/helpers/test_template.py | 47 ++++++++++++++++++------------- 3 files changed, 33 insertions(+), 24 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 36e0a597b8..8e5951488b 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -13,7 +13,7 @@ from functools import cache, lru_cache, partial, wraps import json import logging import math -from operator import attrgetter, contains +from operator import contains import pathlib import random import re @@ -983,7 +983,7 @@ def _state_generator( hass: HomeAssistant, domain: str | None ) -> Generator[TemplateState, None, None]: """State generator for a domain or all states.""" - for state in sorted(hass.states.async_all(domain), key=attrgetter("entity_id")): + for state in hass.states.async_all(domain): yield _template_state_no_collect(hass, state) @@ -1097,7 +1097,7 @@ def expand(hass: HomeAssistant, *args: Any) -> Iterable[State]: _collect_state(hass, entity_id) found[entity_id] = entity - return sorted(found.values(), key=lambda a: a.entity_id) + return list(found.values()) def device_entities(hass: HomeAssistant, _device_id: str) -> Iterable[str]: diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 7e84d634ef..a482e1b63b 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -3043,7 +3043,9 @@ async def test_async_track_template_result_multiple_templates_mixing_domain( template_1 = Template("{{ states.switch.test.state == 'on' }}") template_2 = Template("{{ states.switch.test.state == 'on' }}") template_3 = Template("{{ states.switch.test.state == 'off' }}") - template_4 = Template("{{ states.switch | map(attribute='entity_id') | list }}") + template_4 = Template( + "{{ states.switch | sort(attribute='entity_id') | map(attribute='entity_id') | list }}" + ) refresh_runs = [] diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index f185191d1b..4b3b9488bd 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -185,7 +185,7 @@ def test_raise_exception_on_error(hass: HomeAssistant) -> None: def test_iterating_all_states(hass: HomeAssistant) -> None: """Test iterating all states.""" - tmpl_str = "{% for state in states %}{{ state.state }}{% endfor %}" + tmpl_str = "{% for state in states | sort(attribute='entity_id') %}{{ state.state }}{% endfor %}" info = render_to_info(hass, tmpl_str) assert_result_info(info, "", all_states=True) @@ -2511,20 +2511,22 @@ async def test_expand(hass: HomeAssistant) -> None: hass.states.async_set("test.object", "happy") info = render_to_info( - hass, "{{ expand('test.object') | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand('test.object') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "test.object", ["test.object"]) assert info.rate_limit is None info = render_to_info( hass, - "{{ expand('group.new_group') | map(attribute='entity_id') | join(', ') }}", + "{{ expand('group.new_group') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "", ["group.new_group"]) assert info.rate_limit is None info = render_to_info( - hass, "{{ expand(states.group) | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand(states.group) | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "", [], ["group"]) assert info.rate_limit == template.DOMAIN_STATES_RATE_LIMIT @@ -2535,13 +2537,14 @@ async def test_expand(hass: HomeAssistant) -> None: info = render_to_info( hass, - "{{ expand('group.new_group') | map(attribute='entity_id') | join(', ') }}", + "{{ expand('group.new_group') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "test.object", {"group.new_group", "test.object"}) assert info.rate_limit is None info = render_to_info( - hass, "{{ expand(states.group) | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand(states.group) | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "test.object", {"test.object"}, ["group"]) assert info.rate_limit == template.DOMAIN_STATES_RATE_LIMIT @@ -2550,7 +2553,7 @@ async def test_expand(hass: HomeAssistant) -> None: hass, ( "{{ expand('group.new_group', 'test.object')" - " | map(attribute='entity_id') | join(', ') }}" + " | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" ), ) assert_result_info(info, "test.object", {"test.object", "group.new_group"}) @@ -2559,7 +2562,7 @@ async def test_expand(hass: HomeAssistant) -> None: hass, ( "{{ ['group.new_group', 'test.object'] | expand" - " | map(attribute='entity_id') | join(', ') }}" + " | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" ), ) assert_result_info(info, "test.object", {"test.object", "group.new_group"}) @@ -2579,7 +2582,7 @@ async def test_expand(hass: HomeAssistant) -> None: hass, ( "{{ states.group.power_sensors.attributes.entity_id | expand " - "| map(attribute='state')|map('float')|sum }}" + "| sort(attribute='entity_id') | map(attribute='state')|map('float')|sum }}" ), ) assert_result_info( @@ -2607,7 +2610,8 @@ async def test_expand(hass: HomeAssistant) -> None: await hass.async_block_till_done() info = render_to_info( - hass, "{{ expand('light.grouped') | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand('light.grouped') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info( info, @@ -2629,7 +2633,8 @@ async def test_expand(hass: HomeAssistant) -> None: }, ) info = render_to_info( - hass, "{{ expand('zone.test') | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand('zone.test') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info( info, @@ -2644,7 +2649,8 @@ async def test_expand(hass: HomeAssistant) -> None: await hass.async_block_till_done() info = render_to_info( - hass, "{{ expand('zone.test') | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand('zone.test') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info( info, @@ -2659,7 +2665,8 @@ async def test_expand(hass: HomeAssistant) -> None: await hass.async_block_till_done() info = render_to_info( - hass, "{{ expand('zone.test') | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand('zone.test') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info( info, @@ -2709,7 +2716,7 @@ async def test_device_entities( hass, ( f"{{{{ device_entities('{device_entry.id}') | expand " - "| map(attribute='entity_id') | join(', ') }}" + "| sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" ), ) assert_result_info(info, "", ["light.hue_5678"]) @@ -2721,7 +2728,7 @@ async def test_device_entities( hass, ( f"{{{{ device_entities('{device_entry.id}') | expand " - "| map(attribute='entity_id') | join(', ') }}" + "| sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" ), ) assert_result_info(info, "light.hue_5678", ["light.hue_5678"]) @@ -2743,7 +2750,7 @@ async def test_device_entities( hass, ( f"{{{{ device_entities('{device_entry.id}') | expand " - "| map(attribute='entity_id') | join(', ') }}" + "| sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" ), ) assert_result_info( @@ -3384,7 +3391,7 @@ def test_async_render_to_info_with_complex_branching(hass: HomeAssistant) -> Non {% elif states.light.a == "on" %} {{ states[domain] | list }} {% elif states('light.b') == "on" %} - {{ states[otherdomain] | map(attribute='entity_id') | list }} + {{ states[otherdomain] | sort(attribute='entity_id') | map(attribute='entity_id') | list }} {% elif states.light.a == "on" %} {{ states["nonexist"] | list }} {% else %} @@ -4205,7 +4212,7 @@ async def test_lights(hass: HomeAssistant) -> None: """Test we can sort lights.""" tmpl = """ - {% set lights_on = states.light|selectattr('state','eq','on')|map(attribute='name')|list %} + {% set lights_on = states.light|selectattr('state','eq','on')|sort(attribute='entity_id')|map(attribute='name')|list %} {% if lights_on|length == 0 %} No lights on. Sleep well.. {% elif lights_on|length == 1 %} @@ -4308,7 +4315,7 @@ async def test_unavailable_states(hass: HomeAssistant) -> None: tpl = template.Template( ( "{{ states | selectattr('state', 'in', ['unavailable','unknown','none']) " - "| map(attribute='entity_id') | list | join(', ') }}" + "| sort(attribute='entity_id') | map(attribute='entity_id') | list | join(', ') }}" ), hass, ) @@ -4318,7 +4325,7 @@ async def test_unavailable_states(hass: HomeAssistant) -> None: ( "{{ states.light " "| selectattr('state', 'in', ['unavailable','unknown','none']) " - "| map(attribute='entity_id') | list " + "| sort(attribute='entity_id') | map(attribute='entity_id') | list " "| join(', ') }}" ), hass, From 3e59687902ad3a1819d43ec3cea54905094bc941 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Fri, 31 Mar 2023 23:57:39 +0200 Subject: [PATCH 1536/2810] Only limit stats to started add-ons (#90611) --- homeassistant/components/hassio/__init__.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index d5449cf927..e6ff9888b1 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -870,23 +870,25 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): self.hassio.get_os_info(), ) - addons = [ - addon - for addon in self.hass.data[DATA_SUPERVISOR_INFO].get("addons", []) - if addon[ATTR_STATE] == ATTR_STARTED + all_addons = self.hass.data[DATA_SUPERVISOR_INFO].get("addons", []) + started_addons = [ + addon for addon in all_addons if addon[ATTR_STATE] == ATTR_STARTED ] stats_data = await asyncio.gather( - *[self._update_addon_stats(addon[ATTR_SLUG]) for addon in addons] + *[self._update_addon_stats(addon[ATTR_SLUG]) for addon in started_addons] ) self.hass.data[DATA_ADDONS_STATS] = dict(stats_data) self.hass.data[DATA_ADDONS_CHANGELOGS] = dict( await asyncio.gather( - *[self._update_addon_changelog(addon[ATTR_SLUG]) for addon in addons] + *[ + self._update_addon_changelog(addon[ATTR_SLUG]) + for addon in all_addons + ] ) ) self.hass.data[DATA_ADDONS_INFO] = dict( await asyncio.gather( - *[self._update_addon_info(addon[ATTR_SLUG]) for addon in addons] + *[self._update_addon_info(addon[ATTR_SLUG]) for addon in all_addons] ) ) From 3e94f2a5029ae9d675ba9832a3e0e5cfe3b753fa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Mar 2023 15:15:36 -1000 Subject: [PATCH 1537/2810] Small speed up to _collection_changed (#90621) attrgetter builds a fast method which happens in native code https://github.com/python/cpython/blob/4664a7cf689946f0c9854cadee7c6aa9c276a8cf/Modules/_operator.c#L1413 --- homeassistant/helpers/collection.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index 437cd41871..9da6f84207 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -7,6 +7,7 @@ from collections.abc import Awaitable, Callable, Coroutine, Iterable from dataclasses import dataclass from itertools import groupby import logging +from operator import attrgetter from typing import Any, cast import voluptuous as vol @@ -410,9 +411,8 @@ def sync_entity_lifecycle( # Create a new bucket every time we have a different change type # to ensure operations happen in order. We only group # the same change type. - for _, grouped in groupby( - change_sets, lambda change_set: change_set.change_type - ): + groupby_key = attrgetter("change_type") + for _, grouped in groupby(change_sets, groupby_key): new_entities = [ entity for entity in await asyncio.gather( From 44b35fea47107d45b2b18d0a6453d4936ac4bfc7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Mar 2023 15:18:29 -1000 Subject: [PATCH 1538/2810] Speed up entity filter when there are many glob matchers (#90615) * Speed up entity filter when there are many glob matchers Since we do no care about which glob matches we can combine all the translated globs into a single regex which reduces the overhead * delete unused code * preen --- homeassistant/helpers/entityfilter.py | 63 ++++++++++++--------------- tests/helpers/test_entityfilter.py | 2 +- 2 files changed, 29 insertions(+), 36 deletions(-) diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py index d8b827bd24..057e8f0955 100644 --- a/homeassistant/helpers/entityfilter.py +++ b/homeassistant/helpers/entityfilter.py @@ -33,26 +33,20 @@ class EntityFilter: self._exclude_e = set(config[CONF_EXCLUDE_ENTITIES]) self._include_d = set(config[CONF_INCLUDE_DOMAINS]) self._exclude_d = set(config[CONF_EXCLUDE_DOMAINS]) - self._include_eg = _convert_globs_to_pattern_list( - config[CONF_INCLUDE_ENTITY_GLOBS] - ) - self._exclude_eg = _convert_globs_to_pattern_list( - config[CONF_EXCLUDE_ENTITY_GLOBS] - ) + self._include_eg = _convert_globs_to_pattern(config[CONF_INCLUDE_ENTITY_GLOBS]) + self._exclude_eg = _convert_globs_to_pattern(config[CONF_EXCLUDE_ENTITY_GLOBS]) self._filter: Callable[[str], bool] | None = None def explicitly_included(self, entity_id: str) -> bool: """Check if an entity is explicitly included.""" return entity_id in self._include_e or ( - bool(self._include_eg) - and _test_against_patterns(self._include_eg, entity_id) + bool(self._include_eg and self._include_eg.match(entity_id)) ) def explicitly_excluded(self, entity_id: str) -> bool: """Check if an entity is explicitly excluded.""" return entity_id in self._exclude_e or ( - bool(self._exclude_eg) - and _test_against_patterns(self._exclude_eg, entity_id) + bool(self._exclude_eg and self._exclude_eg.match(entity_id)) ) def __call__(self, entity_id: str) -> bool: @@ -140,19 +134,22 @@ INCLUDE_EXCLUDE_FILTER_SCHEMA = vol.All( ) -def _glob_to_re(glob: str) -> re.Pattern[str]: - """Translate and compile glob string into pattern.""" - return re.compile(fnmatch.translate(glob)) - - -def _test_against_patterns(patterns: list[re.Pattern[str]], entity_id: str) -> bool: - """Test entity against list of patterns, true if any match.""" - return any(pattern.match(entity_id) for pattern in patterns) - - -def _convert_globs_to_pattern_list(globs: list[str] | None) -> list[re.Pattern[str]]: +def _convert_globs_to_pattern(globs: list[str] | None) -> re.Pattern[str] | None: """Convert a list of globs to a re pattern list.""" - return list(map(_glob_to_re, set(globs or []))) + if globs is None: + return None + + translated_patterns: list[str] = [] + for glob in set(globs): + if pattern := fnmatch.translate(glob): + translated_patterns.append(pattern) + + if not translated_patterns: + return None + + inner = "|".join(translated_patterns) + combined = f"(?:{inner})" + return re.compile(combined) def generate_filter( @@ -169,8 +166,8 @@ def generate_filter( set(include_entities), set(exclude_domains), set(exclude_entities), - _convert_globs_to_pattern_list(include_entity_globs), - _convert_globs_to_pattern_list(exclude_entity_globs), + _convert_globs_to_pattern(include_entity_globs), + _convert_globs_to_pattern(exclude_entity_globs), ) @@ -179,8 +176,8 @@ def _generate_filter_from_sets_and_pattern_lists( include_e: set[str], exclude_d: set[str], exclude_e: set[str], - include_eg: list[re.Pattern[str]], - exclude_eg: list[re.Pattern[str]], + include_eg: re.Pattern[str] | None, + exclude_eg: re.Pattern[str] | None, ) -> Callable[[str], bool]: """Generate a filter from pre-comuted sets and pattern lists.""" have_exclude = bool(exclude_e or exclude_d or exclude_eg) @@ -191,7 +188,7 @@ def _generate_filter_from_sets_and_pattern_lists( return ( entity_id in include_e or domain in include_d - or (bool(include_eg) and _test_against_patterns(include_eg, entity_id)) + or (bool(include_eg and include_eg.match(entity_id))) ) def entity_excluded(domain: str, entity_id: str) -> bool: @@ -199,7 +196,7 @@ def _generate_filter_from_sets_and_pattern_lists( return ( entity_id in exclude_e or domain in exclude_d - or (bool(exclude_eg) and _test_against_patterns(exclude_eg, entity_id)) + or (bool(exclude_eg and exclude_eg.match(entity_id))) ) # Case 1 - No filter @@ -249,12 +246,10 @@ def _generate_filter_from_sets_and_pattern_lists( return entity_id in include_e or ( entity_id not in exclude_e and ( - (include_eg and _test_against_patterns(include_eg, entity_id)) + bool(include_eg and include_eg.match(entity_id)) or ( split_entity_id(entity_id)[0] in include_d - and not ( - exclude_eg and _test_against_patterns(exclude_eg, entity_id) - ) + and not (exclude_eg and exclude_eg.match(entity_id)) ) ) ) @@ -272,9 +267,7 @@ def _generate_filter_from_sets_and_pattern_lists( def entity_filter_4b(entity_id: str) -> bool: """Return filter function for case 4b.""" domain = split_entity_id(entity_id)[0] - if domain in exclude_d or ( - exclude_eg and _test_against_patterns(exclude_eg, entity_id) - ): + if domain in exclude_d or bool(exclude_eg and exclude_eg.match(entity_id)): return entity_id in include_e return entity_id not in exclude_e diff --git a/tests/helpers/test_entityfilter.py b/tests/helpers/test_entityfilter.py index 9888704702..2141c28691 100644 --- a/tests/helpers/test_entityfilter.py +++ b/tests/helpers/test_entityfilter.py @@ -369,7 +369,7 @@ def test_filter_schema_include_exclude() -> None: assert not filt.empty_filter -def test_exlictly_included() -> None: +def test_explicitly_included() -> None: """Test if an entity is explicitly included.""" conf = { "include": { From 90d81e9844747fd5ea75e894e7c7b998fb54532a Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 31 Mar 2023 22:55:07 -0500 Subject: [PATCH 1539/2810] Use webrcvad to detect silence in pipelines (#90610) * Add webrtcvad requirement * Use webrcvad for voice command segmenting * Add vad test --- .../components/voice_assistant/manifest.json | 3 +- .../components/voice_assistant/vad.py | 128 ++++++++++++++++++ .../voice_assistant/websocket_api.py | 40 +----- requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/voice_assistant/test_vad.py | 38 ++++++ .../voice_assistant/test_websocket.py | 2 +- 7 files changed, 180 insertions(+), 37 deletions(-) create mode 100644 homeassistant/components/voice_assistant/vad.py create mode 100644 tests/components/voice_assistant/test_vad.py diff --git a/homeassistant/components/voice_assistant/manifest.json b/homeassistant/components/voice_assistant/manifest.json index 644c49e945..f4a17bf52e 100644 --- a/homeassistant/components/voice_assistant/manifest.json +++ b/homeassistant/components/voice_assistant/manifest.json @@ -5,5 +5,6 @@ "dependencies": ["conversation", "stt", "tts"], "documentation": "https://www.home-assistant.io/integrations/voice_assistant", "iot_class": "local_push", - "quality_scale": "internal" + "quality_scale": "internal", + "requirements": ["webrtcvad==2.0.10"] } diff --git a/homeassistant/components/voice_assistant/vad.py b/homeassistant/components/voice_assistant/vad.py new file mode 100644 index 0000000000..e86579b975 --- /dev/null +++ b/homeassistant/components/voice_assistant/vad.py @@ -0,0 +1,128 @@ +"""Voice activity detection.""" +from dataclasses import dataclass, field + +import webrtcvad + +_SAMPLE_RATE = 16000 + + +@dataclass +class VoiceCommandSegmenter: + """Segments an audio stream into voice commands using webrtcvad.""" + + vad_mode: int = 3 + """Aggressiveness in filtering out non-speech. 3 is the most aggressive.""" + + vad_frames: int = 480 # 30 ms + """Must be 10, 20, or 30 ms at 16Khz.""" + + speech_seconds: float = 0.3 + """Seconds of speech before voice command has started.""" + + silence_seconds: float = 0.5 + """Seconds of silence after voice command has ended.""" + + timeout_seconds: float = 15.0 + """Maximum number of seconds before stopping with timeout=True.""" + + reset_seconds: float = 1.0 + """Seconds before reset start/stop time counters.""" + + _in_command: bool = False + """True if inside voice command.""" + + _speech_seconds_left: float = 0.0 + """Seconds left before considering voice command as started.""" + + _silence_seconds_left: float = 0.0 + """Seconds left before considering voice command as stopped.""" + + _timeout_seconds_left: float = 0.0 + """Seconds left before considering voice command timed out.""" + + _reset_seconds_left: float = 0.0 + """Seconds left before resetting start/stop time counters.""" + + _vad: webrtcvad.Vad = None + _audio_buffer: bytes = field(default_factory=bytes) + _bytes_per_chunk: int = 480 * 2 # 16-bit samples + _seconds_per_chunk: float = 0.03 # 30 ms + + def __post_init__(self): + """Initialize VAD.""" + self._vad = webrtcvad.Vad(self.vad_mode) + self._bytes_per_chunk = self.vad_frames * 2 + self._seconds_per_chunk = self.vad_frames / _SAMPLE_RATE + self.reset() + + def reset(self): + """Reset all counters and state.""" + self._audio_buffer = b"" + self._speech_seconds_left = self.speech_seconds + self._silence_seconds_left = self.silence_seconds + self._timeout_seconds_left = self.timeout_seconds + self._reset_seconds_left = self.reset_seconds + self._in_command = False + + def process(self, samples: bytes) -> bool: + """Process a 16-bit 16Khz mono audio samples. + + Returns False when command is done. + """ + self._audio_buffer += samples + + # Process in 10, 20, or 30 ms chunks. + num_chunks = len(self._audio_buffer) // self._bytes_per_chunk + for chunk_idx in range(num_chunks): + chunk_offset = chunk_idx * self._bytes_per_chunk + chunk = self._audio_buffer[ + chunk_offset : chunk_offset + self._bytes_per_chunk + ] + if not self._process_chunk(chunk): + self.reset() + return False + + if num_chunks > 0: + # Remove from buffer + self._audio_buffer = self._audio_buffer[ + num_chunks * self._bytes_per_chunk : + ] + + return True + + def _process_chunk(self, chunk: bytes) -> bool: + """Process a single chunk of 16-bit 16Khz mono audio. + + Returns False when command is done. + """ + is_speech = self._vad.is_speech(chunk, _SAMPLE_RATE) + + self._timeout_seconds_left -= self._seconds_per_chunk + if self._timeout_seconds_left <= 0: + return False + + if not self._in_command: + if is_speech: + self._reset_seconds_left = self.reset_seconds + self._speech_seconds_left -= self._seconds_per_chunk + if self._speech_seconds_left <= 0: + # Inside voice command + self._in_command = True + else: + # Reset if enough silence + self._reset_seconds_left -= self._seconds_per_chunk + if self._reset_seconds_left <= 0: + self._speech_seconds_left = self.speech_seconds + else: + if not is_speech: + self._reset_seconds_left = self.reset_seconds + self._silence_seconds_left -= self._seconds_per_chunk + if self._silence_seconds_left <= 0: + return False + else: + # Reset if enough speech + self._reset_seconds_left -= self._seconds_per_chunk + if self._reset_seconds_left <= 0: + self._silence_seconds_left = self.silence_seconds + + return True diff --git a/homeassistant/components/voice_assistant/websocket_api.py b/homeassistant/components/voice_assistant/websocket_api.py index aa295ad5c6..718989f661 100644 --- a/homeassistant/components/voice_assistant/websocket_api.py +++ b/homeassistant/components/voice_assistant/websocket_api.py @@ -20,15 +20,12 @@ from .pipeline import ( PipelineStage, async_get_pipeline, ) +from .vad import VoiceCommandSegmenter DEFAULT_TIMEOUT = 30 _LOGGER = logging.getLogger(__name__) -_VAD_ENERGY_THRESHOLD = 1000 -_VAD_SPEECH_FRAMES = 25 -_VAD_SILENCE_FRAMES = 25 - @callback def async_register_websocket_api(hass: HomeAssistant) -> None: @@ -36,17 +33,6 @@ def async_register_websocket_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_run) -def _get_debiased_energy(audio_data: bytes, width: int = 2) -> float: - """Compute RMS of debiased audio.""" - energy = -audioop.rms(audio_data, width) - energy_bytes = bytes([energy & 0xFF, (energy >> 8) & 0xFF]) - debiased_energy = audioop.rms( - audioop.add(audio_data, energy_bytes * (len(audio_data) // width), width), width - ) - - return debiased_energy - - @websocket_api.websocket_command( { vol.Required("type"): "voice_assistant/run", @@ -105,30 +91,14 @@ async def websocket_run( async def stt_stream(): state = None - speech_count = 0 - in_voice_command = False + segmenter = VoiceCommandSegmenter() # Yield until we receive an empty chunk while chunk := await audio_queue.get(): chunk, state = audioop.ratecv(chunk, 2, 1, 44100, 16000, state) - is_speech = _get_debiased_energy(chunk) > _VAD_ENERGY_THRESHOLD - - if in_voice_command: - if is_speech: - speech_count += 1 - else: - speech_count -= 1 - - if speech_count <= -_VAD_SILENCE_FRAMES: - _LOGGER.info("Voice command stopped") - break - else: - if is_speech: - speech_count += 1 - - if speech_count >= _VAD_SPEECH_FRAMES: - in_voice_command = True - _LOGGER.info("Voice command started") + if not segmenter.process(chunk): + # Voice command is finished + break yield chunk diff --git a/requirements_all.txt b/requirements_all.txt index 935d7bdb69..e01e1c23e8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2619,6 +2619,9 @@ waterfurnace==1.1.0 # homeassistant.components.cisco_webex_teams webexteamssdk==1.1.1 +# homeassistant.components.voice_assistant +webrtcvad==2.0.10 + # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 27d71f3d56..89b84246ff 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1877,6 +1877,9 @@ wallbox==0.4.12 # homeassistant.components.folder_watcher watchdog==2.3.1 +# homeassistant.components.voice_assistant +webrtcvad==2.0.10 + # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.2 diff --git a/tests/components/voice_assistant/test_vad.py b/tests/components/voice_assistant/test_vad.py new file mode 100644 index 0000000000..4285f78d51 --- /dev/null +++ b/tests/components/voice_assistant/test_vad.py @@ -0,0 +1,38 @@ +"""Tests for webrtcvad voice command segmenter.""" +from unittest.mock import patch + +from homeassistant.components.voice_assistant.vad import VoiceCommandSegmenter + +_ONE_SECOND = 16000 * 2 # 16Khz 16-bit + + +def test_silence() -> None: + """Test that 3 seconds of silence does not trigger a voice command.""" + segmenter = VoiceCommandSegmenter() + + # True return value indicates voice command has not finished + assert segmenter.process(bytes(_ONE_SECOND * 3)) + + +def test_speech() -> None: + """Test that silence + speech + silence triggers a voice command.""" + + def is_speech(self, chunk, sample_rate): + """Anything non-zero is speech.""" + return sum(chunk) > 0 + + with patch( + "webrtcvad.Vad.is_speech", + new=is_speech, + ): + segmenter = VoiceCommandSegmenter() + + # silence + assert segmenter.process(bytes(_ONE_SECOND)) + + # "speech" + assert segmenter.process(bytes([255] * _ONE_SECOND)) + + # silence + # False return value indicates voice command is finished + assert not segmenter.process(bytes(_ONE_SECOND)) diff --git a/tests/components/voice_assistant/test_websocket.py b/tests/components/voice_assistant/test_websocket.py index ce87655032..54fe51a7a2 100644 --- a/tests/components/voice_assistant/test_websocket.py +++ b/tests/components/voice_assistant/test_websocket.py @@ -75,7 +75,7 @@ class MockSTT: hass: HomeAssistant, config: ConfigType, discovery_info: DiscoveryInfoType | None = None, - ) -> tts.Provider: + ) -> stt.Provider: """Set up a mock speech component.""" return MockSttProvider(hass, _TRANSCRIPT) From e94c11371d49d61615ece0f4441d75f9dec9d1bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Apr 2023 04:22:26 -1000 Subject: [PATCH 1540/2810] Bump securetar to 2023.3.0 (#90612) changelog: https://github.com/pvizeli/securetar/compare/2022.02.0...2023.3.0 --- homeassistant/components/backup/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/backup/manifest.json b/homeassistant/components/backup/manifest.json index 7b495912f5..fb7e9eff78 100644 --- a/homeassistant/components/backup/manifest.json +++ b/homeassistant/components/backup/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["securetar==2022.2.0"] + "requirements": ["securetar==2023.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e01e1c23e8..b91775525d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2306,7 +2306,7 @@ screenlogicpy==0.8.2 scsgate==0.1.0 # homeassistant.components.backup -securetar==2022.2.0 +securetar==2023.3.0 # homeassistant.components.sendgrid sendgrid==6.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 89b84246ff..709155b3ee 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1645,7 +1645,7 @@ scapy==2.5.0 screenlogicpy==0.8.2 # homeassistant.components.backup -securetar==2022.2.0 +securetar==2023.3.0 # homeassistant.components.emulated_kasa # homeassistant.components.sense From f1fa63281e850e75041754f016a0612b557eda92 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Apr 2023 04:24:02 -1000 Subject: [PATCH 1541/2810] Adjust context id variable names in the logbook processor to improve readability (#90617) Adjust some variable names in the logbook process to improve readablity There were some places were we used context_id that should have been context_id_bin --- homeassistant/components/logbook/processor.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index 32301e9835..7d0eec5eb6 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -204,7 +204,7 @@ def _humanify( # Process rows for row in rows: - context_id = context_lookup.memorize(row) + context_id_bin = context_lookup.memorize(row) if row.context_only: continue event_type = row.event_type @@ -232,7 +232,7 @@ def _humanify( if icon := row.icon or row.old_format_icon: data[LOGBOOK_ENTRY_ICON] = icon - context_augmenter.augment(data, row, context_id) + context_augmenter.augment(data, row, context_id_bin) yield data elif event_type in external_events: @@ -240,7 +240,7 @@ def _humanify( data = describe_event(event_cache.get(row)) data[LOGBOOK_ENTRY_WHEN] = format_time(row) data[LOGBOOK_ENTRY_DOMAIN] = domain - context_augmenter.augment(data, row, context_id) + context_augmenter.augment(data, row, context_id_bin) yield data elif event_type == EVENT_LOGBOOK_ENTRY: @@ -259,7 +259,7 @@ def _humanify( LOGBOOK_ENTRY_DOMAIN: entry_domain, LOGBOOK_ENTRY_ENTITY_ID: entry_entity_id, } - context_augmenter.augment(data, row, context_id) + context_augmenter.augment(data, row, context_id_bin) yield data @@ -302,11 +302,11 @@ class ContextAugmenter: self.include_entity_name = logbook_run.include_entity_name def _get_context_row( - self, context_id: bytes | None, row: Row | EventAsRow + self, context_id_bin: bytes | None, row: Row | EventAsRow ) -> Row | EventAsRow | None: """Get the context row from the id or row context.""" - if context_id: - return self.context_lookup.get(context_id) + if context_id_bin: + return self.context_lookup.get(context_id_bin) if (context := getattr(row, "context", None)) is not None and ( origin_event := context.origin_event ) is not None: @@ -314,13 +314,13 @@ class ContextAugmenter: return None def augment( - self, data: dict[str, Any], row: Row | EventAsRow, context_id: bytes | None + self, data: dict[str, Any], row: Row | EventAsRow, context_id_bin: bytes | None ) -> None: """Augment data from the row and cache.""" if context_user_id_bin := row.context_user_id_bin: data[CONTEXT_USER_ID] = bytes_to_uuid_hex_or_none(context_user_id_bin) - if not (context_row := self._get_context_row(context_id, row)): + if not (context_row := self._get_context_row(context_id_bin, row)): return if _rows_match(row, context_row): From 9cab05c4b91262c34e4d3317ee1ce23aef73adb6 Mon Sep 17 00:00:00 2001 From: nono Date: Sat, 1 Apr 2023 17:45:24 +0200 Subject: [PATCH 1542/2810] Fix Rest switch init was not retrying if unreachable at setup (#90627) * Fix Rest switch init was not retrying if unreachable at setup * pass error log to platformnotready prevents spamming the same message in logs. --- homeassistant/components/rest/switch.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py index cda35d1f91..9e016db037 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -28,6 +28,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -97,8 +98,8 @@ async def async_setup_platform( "Missing resource or schema in configuration. " "Add http:// or https:// to your URL" ) - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.error("No route to resource/endpoint: %s", resource) + except (asyncio.TimeoutError, aiohttp.ClientError) as exc: + raise PlatformNotReady(f"No route to resource/endpoint: {resource}") from exc class RestSwitch(TemplateEntity, SwitchEntity): From b47ac524eadfed9c6be988c48792098b99677b86 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sat, 1 Apr 2023 17:47:31 +0200 Subject: [PATCH 1543/2810] Use async_timeout instead of asyncio.wait_for (#90496) * Use async_timeout instead of asyncio.wait_for * fix imports * fix imports * break out Event.wait patch * Update tests/components/reolink/conftest.py Co-authored-by: Martin Hjelmare * Simplify --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/reolink/host.py | 8 +++++-- tests/components/reolink/conftest.py | 9 ++++++-- tests/components/reolink/test_config_flow.py | 4 +++- tests/components/reolink/test_init.py | 22 ++++++++++++-------- 4 files changed, 29 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index f781074648..e6c9034322 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -8,6 +8,7 @@ from typing import Any import aiohttp from aiohttp.web import Request +import async_timeout from reolink_aio.api import Host from reolink_aio.exceptions import ReolinkError, SubscriptionError @@ -23,6 +24,7 @@ from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DOMAIN from .exceptions import ReolinkSetupException, ReolinkWebhookException, UserNotAdmin DEFAULT_TIMEOUT = 60 +FIRST_ONVIF_TIMEOUT = 15 SUBSCRIPTION_RENEW_THRESHOLD = 300 _LOGGER = logging.getLogger(__name__) @@ -146,11 +148,13 @@ class ReolinkHost: "Waiting for initial ONVIF state on webhook '%s'", self._webhook_url ) try: - await asyncio.wait_for(self._webhook_reachable.wait(), timeout=15) + async with async_timeout.timeout(FIRST_ONVIF_TIMEOUT): + await self._webhook_reachable.wait() except asyncio.TimeoutError: _LOGGER.debug( - "Did not receive initial ONVIF state on webhook '%s' after 15 seconds", + "Did not receive initial ONVIF state on webhook '%s' after %i seconds", self._webhook_url, + FIRST_ONVIF_TIMEOUT, ) ir.async_create_issue( self._hass, diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index be748ef2c4..d36aea905f 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -39,8 +39,6 @@ def reolink_connect(mock_get_source_ip: None) -> Generator[MagicMock, None, None with patch( "homeassistant.components.reolink.host.webhook.async_register", return_value=True, - ), patch( - "homeassistant.components.reolink.host.asyncio.Event.wait", AsyncMock() ), patch( "homeassistant.components.reolink.host.Host", autospec=True ) as host_mock_class: @@ -65,6 +63,13 @@ def reolink_connect(mock_get_source_ip: None) -> Generator[MagicMock, None, None yield host_mock +@pytest.fixture +def reolink_ONVIF_wait() -> Generator[None, None, None]: + """Mock reolink connection.""" + with patch("homeassistant.components.reolink.host.asyncio.Event.wait", AsyncMock()): + yield + + @pytest.fixture def reolink_platforms(mock_get_source_ip: None) -> Generator[None, None, None]: """Mock reolink entry setup.""" diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index b3abb793a9..7d25fd6281 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -28,7 +28,9 @@ from .conftest import ( from tests.common import MockConfigEntry -pytestmark = pytest.mark.usefixtures("mock_setup_entry", "reolink_connect") +pytestmark = pytest.mark.usefixtures( + "mock_setup_entry", "reolink_connect", "reolink_ONVIF_wait" +) async def test_config_flow_manual_success(hass: HomeAssistant) -> None: diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 57d0dbd7cb..8dd6db270f 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -1,5 +1,4 @@ """Test the Reolink init.""" -import asyncio from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch @@ -55,6 +54,7 @@ pytestmark = pytest.mark.usefixtures("reolink_connect", "reolink_platforms") async def test_failures_parametrized( hass: HomeAssistant, reolink_connect: MagicMock, + reolink_ONVIF_wait: MagicMock, config_entry: MockConfigEntry, attr: str, value: Any, @@ -71,7 +71,10 @@ async def test_failures_parametrized( async def test_entry_reloading( - hass: HomeAssistant, config_entry: MockConfigEntry, reolink_connect: MagicMock + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + reolink_ONVIF_wait: MagicMock, ) -> None: """Test the entry is reloaded correctly when settings change.""" assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -88,7 +91,7 @@ async def test_entry_reloading( async def test_no_repair_issue( - hass: HomeAssistant, config_entry: MockConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry, reolink_ONVIF_wait: MagicMock ) -> None: """Test no repairs issue is raised when http local url is used.""" await async_process_ha_core_config( @@ -106,7 +109,7 @@ async def test_no_repair_issue( async def test_https_repair_issue( - hass: HomeAssistant, config_entry: MockConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry, reolink_ONVIF_wait: MagicMock ) -> None: """Test repairs issue is raised when https local url is used.""" await async_process_ha_core_config( @@ -125,6 +128,7 @@ async def test_port_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry, reolink_connect: MagicMock, + reolink_ONVIF_wait: MagicMock, protocol: str, ) -> None: """Test repairs issue is raised when auto enable of ports fails.""" @@ -144,10 +148,7 @@ async def test_webhook_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test repairs issue is raised when the webhook url is unreachable.""" - with patch( - "homeassistant.components.reolink.host.asyncio.Event.wait", - AsyncMock(side_effect=asyncio.TimeoutError()), - ): + with patch("homeassistant.components.reolink.host.FIRST_ONVIF_TIMEOUT", new=0): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -156,7 +157,10 @@ async def test_webhook_repair_issue( async def test_firmware_repair_issue( - hass: HomeAssistant, config_entry: MockConfigEntry, reolink_connect: MagicMock + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + reolink_ONVIF_wait: MagicMock, ) -> None: """Test firmware issue is raised when too old firmware is used.""" reolink_connect.sw_version_update_required = True From 00a4279d64ffb3dcd6e10a20d64b31788d3d7694 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Apr 2023 09:14:59 -1000 Subject: [PATCH 1544/2810] Speed up backups (#90613) --- homeassistant/components/backup/manager.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 69df310bd5..f48a71a78c 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -23,6 +23,8 @@ from homeassistant.util.json import json_loads_object from .const import DOMAIN, EXCLUDE_FROM_BACKUP, LOGGER +BUF_SIZE = 2**20 * 4 # 4MB + @dataclass class Backup: @@ -99,7 +101,7 @@ class BackupManager: backups: dict[str, Backup] = {} for backup_path in self.backup_dir.glob("*.tar"): try: - with tarfile.open(backup_path, "r:") as backup_file: + with tarfile.open(backup_path, "r:", bufsize=BUF_SIZE) as backup_file: if data_file := backup_file.extractfile("./backup.json"): data = json_loads_object(data_file.read()) backup = Backup( @@ -227,7 +229,7 @@ class BackupManager: self.backup_dir.mkdir() with TemporaryDirectory() as tmp_dir, SecureTarFile( - tar_file_path, "w", gzip=False + tar_file_path, "w", gzip=False, bufsize=BUF_SIZE ) as tar_file: tmp_dir_path = Path(tmp_dir) save_json( @@ -237,6 +239,7 @@ class BackupManager: with SecureTarFile( tmp_dir_path.joinpath("./homeassistant.tar.gz").as_posix(), "w", + bufsize=BUF_SIZE, ) as core_tar: atomic_contents_add( tar_file=core_tar, From 8263c3de2358548a14b75c714aa7db22d35fbb1a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Apr 2023 09:15:17 -1000 Subject: [PATCH 1545/2810] Bump zeroconf to 0.51.0 (#90622) * Bump zeroconf to 0.50.0 changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.47.4...0.50.0 * bump to 51 --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index b7a643bb46..36c2fcc127 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.47.4"] + "requirements": ["zeroconf==0.51.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ab00d9ca81..1a5bc9407a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -50,7 +50,7 @@ ulid-transform==0.5.1 voluptuous-serialize==2.6.0 voluptuous==0.13.1 yarl==1.8.1 -zeroconf==0.47.4 +zeroconf==0.51.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index b91775525d..a7617899c4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2695,7 +2695,7 @@ zamg==0.2.2 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.47.4 +zeroconf==0.51.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 709155b3ee..be8342a51d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1938,7 +1938,7 @@ youless-api==1.0.1 zamg==0.2.2 # homeassistant.components.zeroconf -zeroconf==0.47.4 +zeroconf==0.51.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 9965d9d81db8f83c43c3a6f965c74ba45043b18b Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 1 Apr 2023 21:17:53 +0200 Subject: [PATCH 1546/2810] Fix mqtt device_tracker is not reloading yaml (#90639) --- homeassistant/components/mqtt/const.py | 1 + tests/components/mqtt/test_device_tracker.py | 21 ++++++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index bb6b8ed497..41fd353359 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -113,6 +113,7 @@ RELOADABLE_PLATFORMS = [ Platform.CAMERA, Platform.CLIMATE, Platform.COVER, + Platform.DEVICE_TRACKER, Platform.FAN, Platform.HUMIDIFIER, Platform.LIGHT, diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py index a8c45f8cd7..a0ac73953b 100644 --- a/tests/components/mqtt/test_device_tracker.py +++ b/tests/components/mqtt/test_device_tracker.py @@ -10,10 +10,17 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from .test_common import help_test_setting_blocked_attribute_via_mqtt_json_message +from .test_common import ( + help_test_reloadable, + help_test_setting_blocked_attribute_via_mqtt_json_message, +) from tests.common import async_fire_mqtt_message -from tests.typing import MqttMockHAClientGenerator, WebSocketGenerator +from tests.typing import ( + MqttMockHAClientGenerator, + MqttMockPahoClient, + WebSocketGenerator, +) DEFAULT_CONFIG = { mqtt.DOMAIN: { @@ -603,3 +610,13 @@ async def test_setup_with_modern_schema( dev_id = "jan" entity_id = f"{device_tracker.DOMAIN}.{dev_id}" assert hass.states.get(entity_id) is not None + + +async def test_reloadable( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, +) -> None: + """Test reloading the MQTT platform.""" + domain = device_tracker.DOMAIN + config = DEFAULT_CONFIG + await help_test_reloadable(hass, mqtt_client_mock, domain, config) From 2852fe6786f70c204c6b3f191682913912bc7506 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Sat, 1 Apr 2023 21:21:51 +0200 Subject: [PATCH 1547/2810] Update frontend to 20230401.0 (#90646) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 114760923e..6468bd6daa 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20230331.0"] + "requirements": ["home-assistant-frontend==20230401.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1a5bc9407a..8c49308503 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.0.0 hass-nabucasa==0.63.1 hassil==1.0.6 home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230331.0 +home-assistant-frontend==20230401.0 home-assistant-intents==2023.3.29 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index a7617899c4..fedca63529 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -907,7 +907,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230331.0 +home-assistant-frontend==20230401.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index be8342a51d..d637999fb3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -693,7 +693,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230331.0 +home-assistant-frontend==20230401.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 From 6242dd2214c42ca403c2932f1b3f748cced7a069 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Mar 2023 11:27:55 -1000 Subject: [PATCH 1548/2810] Avoid sorting domain/all states in templates (#90608) --- homeassistant/helpers/template.py | 6 ++-- tests/helpers/test_event.py | 4 ++- tests/helpers/test_template.py | 47 ++++++++++++++++++------------- 3 files changed, 33 insertions(+), 24 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 36e0a597b8..8e5951488b 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -13,7 +13,7 @@ from functools import cache, lru_cache, partial, wraps import json import logging import math -from operator import attrgetter, contains +from operator import contains import pathlib import random import re @@ -983,7 +983,7 @@ def _state_generator( hass: HomeAssistant, domain: str | None ) -> Generator[TemplateState, None, None]: """State generator for a domain or all states.""" - for state in sorted(hass.states.async_all(domain), key=attrgetter("entity_id")): + for state in hass.states.async_all(domain): yield _template_state_no_collect(hass, state) @@ -1097,7 +1097,7 @@ def expand(hass: HomeAssistant, *args: Any) -> Iterable[State]: _collect_state(hass, entity_id) found[entity_id] = entity - return sorted(found.values(), key=lambda a: a.entity_id) + return list(found.values()) def device_entities(hass: HomeAssistant, _device_id: str) -> Iterable[str]: diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 7e84d634ef..a482e1b63b 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -3043,7 +3043,9 @@ async def test_async_track_template_result_multiple_templates_mixing_domain( template_1 = Template("{{ states.switch.test.state == 'on' }}") template_2 = Template("{{ states.switch.test.state == 'on' }}") template_3 = Template("{{ states.switch.test.state == 'off' }}") - template_4 = Template("{{ states.switch | map(attribute='entity_id') | list }}") + template_4 = Template( + "{{ states.switch | sort(attribute='entity_id') | map(attribute='entity_id') | list }}" + ) refresh_runs = [] diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index f185191d1b..4b3b9488bd 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -185,7 +185,7 @@ def test_raise_exception_on_error(hass: HomeAssistant) -> None: def test_iterating_all_states(hass: HomeAssistant) -> None: """Test iterating all states.""" - tmpl_str = "{% for state in states %}{{ state.state }}{% endfor %}" + tmpl_str = "{% for state in states | sort(attribute='entity_id') %}{{ state.state }}{% endfor %}" info = render_to_info(hass, tmpl_str) assert_result_info(info, "", all_states=True) @@ -2511,20 +2511,22 @@ async def test_expand(hass: HomeAssistant) -> None: hass.states.async_set("test.object", "happy") info = render_to_info( - hass, "{{ expand('test.object') | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand('test.object') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "test.object", ["test.object"]) assert info.rate_limit is None info = render_to_info( hass, - "{{ expand('group.new_group') | map(attribute='entity_id') | join(', ') }}", + "{{ expand('group.new_group') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "", ["group.new_group"]) assert info.rate_limit is None info = render_to_info( - hass, "{{ expand(states.group) | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand(states.group) | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "", [], ["group"]) assert info.rate_limit == template.DOMAIN_STATES_RATE_LIMIT @@ -2535,13 +2537,14 @@ async def test_expand(hass: HomeAssistant) -> None: info = render_to_info( hass, - "{{ expand('group.new_group') | map(attribute='entity_id') | join(', ') }}", + "{{ expand('group.new_group') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "test.object", {"group.new_group", "test.object"}) assert info.rate_limit is None info = render_to_info( - hass, "{{ expand(states.group) | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand(states.group) | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "test.object", {"test.object"}, ["group"]) assert info.rate_limit == template.DOMAIN_STATES_RATE_LIMIT @@ -2550,7 +2553,7 @@ async def test_expand(hass: HomeAssistant) -> None: hass, ( "{{ expand('group.new_group', 'test.object')" - " | map(attribute='entity_id') | join(', ') }}" + " | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" ), ) assert_result_info(info, "test.object", {"test.object", "group.new_group"}) @@ -2559,7 +2562,7 @@ async def test_expand(hass: HomeAssistant) -> None: hass, ( "{{ ['group.new_group', 'test.object'] | expand" - " | map(attribute='entity_id') | join(', ') }}" + " | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" ), ) assert_result_info(info, "test.object", {"test.object", "group.new_group"}) @@ -2579,7 +2582,7 @@ async def test_expand(hass: HomeAssistant) -> None: hass, ( "{{ states.group.power_sensors.attributes.entity_id | expand " - "| map(attribute='state')|map('float')|sum }}" + "| sort(attribute='entity_id') | map(attribute='state')|map('float')|sum }}" ), ) assert_result_info( @@ -2607,7 +2610,8 @@ async def test_expand(hass: HomeAssistant) -> None: await hass.async_block_till_done() info = render_to_info( - hass, "{{ expand('light.grouped') | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand('light.grouped') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info( info, @@ -2629,7 +2633,8 @@ async def test_expand(hass: HomeAssistant) -> None: }, ) info = render_to_info( - hass, "{{ expand('zone.test') | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand('zone.test') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info( info, @@ -2644,7 +2649,8 @@ async def test_expand(hass: HomeAssistant) -> None: await hass.async_block_till_done() info = render_to_info( - hass, "{{ expand('zone.test') | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand('zone.test') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info( info, @@ -2659,7 +2665,8 @@ async def test_expand(hass: HomeAssistant) -> None: await hass.async_block_till_done() info = render_to_info( - hass, "{{ expand('zone.test') | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand('zone.test') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info( info, @@ -2709,7 +2716,7 @@ async def test_device_entities( hass, ( f"{{{{ device_entities('{device_entry.id}') | expand " - "| map(attribute='entity_id') | join(', ') }}" + "| sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" ), ) assert_result_info(info, "", ["light.hue_5678"]) @@ -2721,7 +2728,7 @@ async def test_device_entities( hass, ( f"{{{{ device_entities('{device_entry.id}') | expand " - "| map(attribute='entity_id') | join(', ') }}" + "| sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" ), ) assert_result_info(info, "light.hue_5678", ["light.hue_5678"]) @@ -2743,7 +2750,7 @@ async def test_device_entities( hass, ( f"{{{{ device_entities('{device_entry.id}') | expand " - "| map(attribute='entity_id') | join(', ') }}" + "| sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" ), ) assert_result_info( @@ -3384,7 +3391,7 @@ def test_async_render_to_info_with_complex_branching(hass: HomeAssistant) -> Non {% elif states.light.a == "on" %} {{ states[domain] | list }} {% elif states('light.b') == "on" %} - {{ states[otherdomain] | map(attribute='entity_id') | list }} + {{ states[otherdomain] | sort(attribute='entity_id') | map(attribute='entity_id') | list }} {% elif states.light.a == "on" %} {{ states["nonexist"] | list }} {% else %} @@ -4205,7 +4212,7 @@ async def test_lights(hass: HomeAssistant) -> None: """Test we can sort lights.""" tmpl = """ - {% set lights_on = states.light|selectattr('state','eq','on')|map(attribute='name')|list %} + {% set lights_on = states.light|selectattr('state','eq','on')|sort(attribute='entity_id')|map(attribute='name')|list %} {% if lights_on|length == 0 %} No lights on. Sleep well.. {% elif lights_on|length == 1 %} @@ -4308,7 +4315,7 @@ async def test_unavailable_states(hass: HomeAssistant) -> None: tpl = template.Template( ( "{{ states | selectattr('state', 'in', ['unavailable','unknown','none']) " - "| map(attribute='entity_id') | list | join(', ') }}" + "| sort(attribute='entity_id') | map(attribute='entity_id') | list | join(', ') }}" ), hass, ) @@ -4318,7 +4325,7 @@ async def test_unavailable_states(hass: HomeAssistant) -> None: ( "{{ states.light " "| selectattr('state', 'in', ['unavailable','unknown','none']) " - "| map(attribute='entity_id') | list " + "| sort(attribute='entity_id') | map(attribute='entity_id') | list " "| join(', ') }}" ), hass, From d5d5bb0732b8cf131e4d7934552c6522259da744 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Fri, 31 Mar 2023 23:57:39 +0200 Subject: [PATCH 1549/2810] Only limit stats to started add-ons (#90611) --- homeassistant/components/hassio/__init__.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index d5449cf927..e6ff9888b1 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -870,23 +870,25 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): self.hassio.get_os_info(), ) - addons = [ - addon - for addon in self.hass.data[DATA_SUPERVISOR_INFO].get("addons", []) - if addon[ATTR_STATE] == ATTR_STARTED + all_addons = self.hass.data[DATA_SUPERVISOR_INFO].get("addons", []) + started_addons = [ + addon for addon in all_addons if addon[ATTR_STATE] == ATTR_STARTED ] stats_data = await asyncio.gather( - *[self._update_addon_stats(addon[ATTR_SLUG]) for addon in addons] + *[self._update_addon_stats(addon[ATTR_SLUG]) for addon in started_addons] ) self.hass.data[DATA_ADDONS_STATS] = dict(stats_data) self.hass.data[DATA_ADDONS_CHANGELOGS] = dict( await asyncio.gather( - *[self._update_addon_changelog(addon[ATTR_SLUG]) for addon in addons] + *[ + self._update_addon_changelog(addon[ATTR_SLUG]) + for addon in all_addons + ] ) ) self.hass.data[DATA_ADDONS_INFO] = dict( await asyncio.gather( - *[self._update_addon_info(addon[ATTR_SLUG]) for addon in addons] + *[self._update_addon_info(addon[ATTR_SLUG]) for addon in all_addons] ) ) From 1189b2ad70bddae4714a9df3fd41dcc3e9c50f78 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Mar 2023 15:15:36 -1000 Subject: [PATCH 1550/2810] Small speed up to _collection_changed (#90621) attrgetter builds a fast method which happens in native code https://github.com/python/cpython/blob/4664a7cf689946f0c9854cadee7c6aa9c276a8cf/Modules/_operator.c#L1413 --- homeassistant/helpers/collection.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index 437cd41871..9da6f84207 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -7,6 +7,7 @@ from collections.abc import Awaitable, Callable, Coroutine, Iterable from dataclasses import dataclass from itertools import groupby import logging +from operator import attrgetter from typing import Any, cast import voluptuous as vol @@ -410,9 +411,8 @@ def sync_entity_lifecycle( # Create a new bucket every time we have a different change type # to ensure operations happen in order. We only group # the same change type. - for _, grouped in groupby( - change_sets, lambda change_set: change_set.change_type - ): + groupby_key = attrgetter("change_type") + for _, grouped in groupby(change_sets, groupby_key): new_entities = [ entity for entity in await asyncio.gather( From 75694307e2ac768d021e613cea372a92d5d57b83 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Apr 2023 09:15:17 -1000 Subject: [PATCH 1551/2810] Bump zeroconf to 0.51.0 (#90622) * Bump zeroconf to 0.50.0 changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.47.4...0.50.0 * bump to 51 --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index b7a643bb46..36c2fcc127 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.47.4"] + "requirements": ["zeroconf==0.51.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cde6be3c20..da8e3ca387 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -50,7 +50,7 @@ ulid-transform==0.5.1 voluptuous-serialize==2.6.0 voluptuous==0.13.1 yarl==1.8.1 -zeroconf==0.47.4 +zeroconf==0.51.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index ee45c2b387..541b841acc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2695,7 +2695,7 @@ zamg==0.2.2 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.47.4 +zeroconf==0.51.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5e9d6cc001..7bf409b71f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1929,7 +1929,7 @@ youless-api==1.0.1 zamg==0.2.2 # homeassistant.components.zeroconf -zeroconf==0.47.4 +zeroconf==0.51.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From bacd77a03addbe094deea7bd296395c5a5216ef6 Mon Sep 17 00:00:00 2001 From: nono Date: Sat, 1 Apr 2023 17:45:24 +0200 Subject: [PATCH 1552/2810] Fix Rest switch init was not retrying if unreachable at setup (#90627) * Fix Rest switch init was not retrying if unreachable at setup * pass error log to platformnotready prevents spamming the same message in logs. --- homeassistant/components/rest/switch.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py index cda35d1f91..9e016db037 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -28,6 +28,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -97,8 +98,8 @@ async def async_setup_platform( "Missing resource or schema in configuration. " "Add http:// or https:// to your URL" ) - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.error("No route to resource/endpoint: %s", resource) + except (asyncio.TimeoutError, aiohttp.ClientError) as exc: + raise PlatformNotReady(f"No route to resource/endpoint: {resource}") from exc class RestSwitch(TemplateEntity, SwitchEntity): From c006b3b1df81203b9aa822dc6292a2df3d1378ce Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 1 Apr 2023 21:17:53 +0200 Subject: [PATCH 1553/2810] Fix mqtt device_tracker is not reloading yaml (#90639) --- homeassistant/components/mqtt/const.py | 1 + tests/components/mqtt/test_device_tracker.py | 21 ++++++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index bb6b8ed497..41fd353359 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -113,6 +113,7 @@ RELOADABLE_PLATFORMS = [ Platform.CAMERA, Platform.CLIMATE, Platform.COVER, + Platform.DEVICE_TRACKER, Platform.FAN, Platform.HUMIDIFIER, Platform.LIGHT, diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py index a8c45f8cd7..a0ac73953b 100644 --- a/tests/components/mqtt/test_device_tracker.py +++ b/tests/components/mqtt/test_device_tracker.py @@ -10,10 +10,17 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from .test_common import help_test_setting_blocked_attribute_via_mqtt_json_message +from .test_common import ( + help_test_reloadable, + help_test_setting_blocked_attribute_via_mqtt_json_message, +) from tests.common import async_fire_mqtt_message -from tests.typing import MqttMockHAClientGenerator, WebSocketGenerator +from tests.typing import ( + MqttMockHAClientGenerator, + MqttMockPahoClient, + WebSocketGenerator, +) DEFAULT_CONFIG = { mqtt.DOMAIN: { @@ -603,3 +610,13 @@ async def test_setup_with_modern_schema( dev_id = "jan" entity_id = f"{device_tracker.DOMAIN}.{dev_id}" assert hass.states.get(entity_id) is not None + + +async def test_reloadable( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, +) -> None: + """Test reloading the MQTT platform.""" + domain = device_tracker.DOMAIN + config = DEFAULT_CONFIG + await help_test_reloadable(hass, mqtt_client_mock, domain, config) From 2a28d40dc88627195b9d926d679f77a90699ee67 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Sat, 1 Apr 2023 21:21:51 +0200 Subject: [PATCH 1554/2810] Update frontend to 20230401.0 (#90646) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 114760923e..6468bd6daa 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20230331.0"] + "requirements": ["home-assistant-frontend==20230401.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index da8e3ca387..4763b3ab94 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.0.0 hass-nabucasa==0.63.1 hassil==1.0.6 home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230331.0 +home-assistant-frontend==20230401.0 home-assistant-intents==2023.3.29 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index 541b841acc..bfb35d4658 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -907,7 +907,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230331.0 +home-assistant-frontend==20230401.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7bf409b71f..a0cc8fe7df 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -693,7 +693,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230331.0 +home-assistant-frontend==20230401.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 From aa6cf3d2083babbef62e23106effa5063e1b7978 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 1 Apr 2023 15:23:53 -0400 Subject: [PATCH 1555/2810] Bumped version to 2023.4.0b4 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 38c243997a..039f5bcc7b 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -8,7 +8,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 4 -PATCH_VERSION: Final = "0b3" +PATCH_VERSION: Final = "0b4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index f000a293db..bce981eb6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.4.0b3" +version = "2023.4.0b4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 5fc103947f73e5f5dd733f2722704c61051caa9d Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 2 Apr 2023 03:39:46 +0200 Subject: [PATCH 1556/2810] Add entity name translations to Brother (#90634) * Add entity name translations * Fix sensor name * Update tests * Suggested change --- homeassistant/components/brother/sensor.py | 68 +++++------ homeassistant/components/brother/strings.json | 106 ++++++++++++++++++ tests/components/brother/test_sensor.py | 42 +++---- 3 files changed, 161 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py index 274576f0f3..191bfff249 100644 --- a/homeassistant/components/brother/sensor.py +++ b/homeassistant/components/brother/sensor.py @@ -53,14 +53,14 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="status", icon="mdi:printer", - name="Status", + translation_key="status", entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.status, ), BrotherSensorEntityDescription( key="page_counter", icon="mdi:file-document-outline", - name="Page counter", + translation_key="page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -69,7 +69,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="bw_counter", icon="mdi:file-document-outline", - name="B/W counter", + translation_key="bw_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -78,7 +78,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="color_counter", icon="mdi:file-document-outline", - name="Color counter", + translation_key="color_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -87,7 +87,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="duplex_unit_pages_counter", icon="mdi:file-document-outline", - name="Duplex unit pages counter", + translation_key="duplex_unit_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -96,7 +96,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="drum_remaining_life", icon="mdi:chart-donut", - name="Drum remaining life", + translation_key="drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -105,7 +105,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="drum_remaining_pages", icon="mdi:chart-donut", - name="Drum remaining pages", + translation_key="drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -114,7 +114,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="drum_counter", icon="mdi:chart-donut", - name="Drum counter", + translation_key="drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -123,7 +123,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="black_drum_remaining_life", icon="mdi:chart-donut", - name="Black drum remaining life", + translation_key="black_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -132,7 +132,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="black_drum_remaining_pages", icon="mdi:chart-donut", - name="Black drum remaining pages", + translation_key="black_drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -141,7 +141,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="black_drum_counter", icon="mdi:chart-donut", - name="Black drum counter", + translation_key="black_drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -150,7 +150,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="cyan_drum_remaining_life", icon="mdi:chart-donut", - name="Cyan drum remaining life", + translation_key="cyan_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -159,7 +159,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="cyan_drum_remaining_pages", icon="mdi:chart-donut", - name="Cyan drum remaining pages", + translation_key="cyan_drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -168,7 +168,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="cyan_drum_counter", icon="mdi:chart-donut", - name="Cyan drum counter", + translation_key="cyan_drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -177,7 +177,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="magenta_drum_remaining_life", icon="mdi:chart-donut", - name="Magenta drum remaining life", + translation_key="magenta_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -186,7 +186,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="magenta_drum_remaining_pages", icon="mdi:chart-donut", - name="Magenta drum remaining pages", + translation_key="magenta_drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -195,7 +195,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="magenta_drum_counter", icon="mdi:chart-donut", - name="Magenta drum counter", + translation_key="magenta_drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -204,7 +204,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="yellow_drum_remaining_life", icon="mdi:chart-donut", - name="Yellow drum remaining life", + translation_key="yellow_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -213,7 +213,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="yellow_drum_remaining_pages", icon="mdi:chart-donut", - name="Yellow drum remaining pages", + translation_key="yellow_drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -222,7 +222,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="yellow_drum_counter", icon="mdi:chart-donut", - name="Yellow drum counter", + translation_key="yellow_drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -231,7 +231,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="belt_unit_remaining_life", icon="mdi:current-ac", - name="Belt unit remaining life", + translation_key="belt_unit_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -240,7 +240,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="fuser_remaining_life", icon="mdi:water-outline", - name="Fuser remaining life", + translation_key="fuser_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -249,7 +249,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="laser_remaining_life", icon="mdi:spotlight-beam", - name="Laser remaining life", + translation_key="laser_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -258,7 +258,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="pf_kit_1_remaining_life", icon="mdi:printer-3d", - name="PF Kit 1 remaining life", + translation_key="pf_kit_1_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -267,7 +267,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="pf_kit_mp_remaining_life", icon="mdi:printer-3d", - name="PF Kit MP remaining life", + translation_key="pf_kit_mp_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -276,7 +276,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="black_toner_remaining", icon="mdi:printer-3d-nozzle", - name="Black toner remaining", + translation_key="black_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -285,7 +285,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="cyan_toner_remaining", icon="mdi:printer-3d-nozzle", - name="Cyan toner remaining", + translation_key="cyan_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -294,7 +294,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="magenta_toner_remaining", icon="mdi:printer-3d-nozzle", - name="Magenta toner remaining", + translation_key="magenta_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -303,7 +303,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="yellow_toner_remaining", icon="mdi:printer-3d-nozzle", - name="Yellow toner remaining", + translation_key="yellow_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -312,7 +312,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="black_ink_remaining", icon="mdi:printer-3d-nozzle", - name="Black ink remaining", + translation_key="black_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -321,7 +321,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="cyan_ink_remaining", icon="mdi:printer-3d-nozzle", - name="Cyan ink remaining", + translation_key="cyan_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -330,7 +330,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="magenta_ink_remaining", icon="mdi:printer-3d-nozzle", - name="Magenta ink remaining", + translation_key="magenta_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -339,7 +339,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="yellow_ink_remaining", icon="mdi:printer-3d-nozzle", - name="Yellow ink remaining", + translation_key="yellow_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -347,7 +347,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="uptime", - name="Uptime", + translation_key="last_restart", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/brother/strings.json b/homeassistant/components/brother/strings.json index 9d7d42abef..3ee3fe7609 100644 --- a/homeassistant/components/brother/strings.json +++ b/homeassistant/components/brother/strings.json @@ -25,5 +25,111 @@ "unsupported_model": "This printer model is not supported.", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "status": { + "name": "Status" + }, + "page_counter": { + "name": "Page counter" + }, + "bw_pages": { + "name": "B/W pages" + }, + "color_pages": { + "name": "Color pages" + }, + "duplex_unit_page_counter": { + "name": "Duplex unit page counter" + }, + "drum_remaining_life": { + "name": "Drum remaining life" + }, + "drum_remaining_pages": { + "name": "Drum remaining pages" + }, + "drum_page_counter": { + "name": "Drum page counter" + }, + "black_drum_remaining_life": { + "name": "Black drum remaining life" + }, + "black_drum_remaining_pages": { + "name": "Black drum remaining pages" + }, + "black_drum_page_counter": { + "name": "Black drum page counter" + }, + "cyan_drum_remaining_life": { + "name": "Cyan drum remaining life" + }, + "cyan_drum_remaining_pages": { + "name": "Cyan drum remaining pages" + }, + "cyan_drum_page_counter": { + "name": "Cyan drum page counter" + }, + "magenta_drum_remaining_life": { + "name": "Magenta drum remaining life" + }, + "magenta_drum_remaining_pages": { + "name": "Magenta drum remaining pages" + }, + "magenta_drum_page_counter": { + "name": "Magenta drum page counter" + }, + "yellow_drum_remaining_life": { + "name": "Yellow drum remaining life" + }, + "yellow_drum_remaining_pages": { + "name": "Yellow drum remaining pages" + }, + "yellow_drum_page_counter": { + "name": "Yellow drum page counter" + }, + "belt_unit_remaining_life": { + "name": "Belt unit remaining life" + }, + "fuser_remaining_life": { + "name": "Fuser remaining life" + }, + "laser_remaining_life": { + "name": "Laser remaining life" + }, + "pf_kit_1_remaining_life": { + "name": "PF Kit 1 remaining life" + }, + "pf_kit_mp_remaining_life": { + "name": "PF Kit MP remaining life" + }, + "black_toner_remaining": { + "name": "Black toner remaining" + }, + "cyan_toner_remaining": { + "name": "Cyan toner remaining" + }, + "magenta_toner_remaining": { + "name": "Magenta toner remaining" + }, + "yellow_toner_remaining": { + "name": "Yellow toner remaining" + }, + "black_ink_remaining": { + "name": "Black ink remaining" + }, + "cyan_ink_remaining": { + "name": "Cyan ink remaining" + }, + "magenta_ink_remaining": { + "name": "Magenta ink remaining" + }, + "yellow_ink_remaining": { + "name": "Yellow ink remaining" + }, + "last_restart": { + "name": "Last restart" + } + } } } diff --git a/tests/components/brother/test_sensor.py b/tests/components/brother/test_sensor.py index 6769d21940..e05fce9df3 100644 --- a/tests/components/brother/test_sensor.py +++ b/tests/components/brother/test_sensor.py @@ -43,7 +43,7 @@ async def test_sensors(hass: HomeAssistant) -> None: SENSOR_DOMAIN, DOMAIN, "0123456789_uptime", - suggested_object_id="hl_l2340dw_uptime", + suggested_object_id="hl_l2340dw_last_restart", disabled_by=None, ) test_time = datetime(2019, 11, 11, 9, 10, 32, tzinfo=UTC) @@ -132,14 +132,14 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_drum_remaining_pages" - state = hass.states.get("sensor.hl_l2340dw_drum_counter") + state = hass.states.get("sensor.hl_l2340dw_drum_page_counter") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "986" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_drum_counter") + entry = registry.async_get("sensor.hl_l2340dw_drum_page_counter") assert entry assert entry.unique_id == "0123456789_drum_counter" @@ -165,14 +165,14 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_black_drum_remaining_pages" - state = hass.states.get("sensor.hl_l2340dw_black_drum_counter") + state = hass.states.get("sensor.hl_l2340dw_black_drum_page_counter") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "1611" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_black_drum_counter") + entry = registry.async_get("sensor.hl_l2340dw_black_drum_page_counter") assert entry assert entry.unique_id == "0123456789_black_drum_counter" @@ -198,14 +198,14 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_cyan_drum_remaining_pages" - state = hass.states.get("sensor.hl_l2340dw_cyan_drum_counter") + state = hass.states.get("sensor.hl_l2340dw_cyan_drum_page_counter") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "1611" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_cyan_drum_counter") + entry = registry.async_get("sensor.hl_l2340dw_cyan_drum_page_counter") assert entry assert entry.unique_id == "0123456789_cyan_drum_counter" @@ -231,14 +231,14 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_magenta_drum_remaining_pages" - state = hass.states.get("sensor.hl_l2340dw_magenta_drum_counter") + state = hass.states.get("sensor.hl_l2340dw_magenta_drum_page_counter") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "1611" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_magenta_drum_counter") + entry = registry.async_get("sensor.hl_l2340dw_magenta_drum_page_counter") assert entry assert entry.unique_id == "0123456789_magenta_drum_counter" @@ -264,14 +264,14 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_yellow_drum_remaining_pages" - state = hass.states.get("sensor.hl_l2340dw_yellow_drum_counter") + state = hass.states.get("sensor.hl_l2340dw_yellow_drum_page_counter") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "1611" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_yellow_drum_counter") + entry = registry.async_get("sensor.hl_l2340dw_yellow_drum_page_counter") assert entry assert entry.unique_id == "0123456789_yellow_drum_counter" @@ -319,40 +319,40 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_page_counter" - state = hass.states.get("sensor.hl_l2340dw_duplex_unit_pages_counter") + state = hass.states.get("sensor.hl_l2340dw_duplex_unit_page_counter") assert state assert state.attributes.get(ATTR_ICON) == "mdi:file-document-outline" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "538" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_duplex_unit_pages_counter") + entry = registry.async_get("sensor.hl_l2340dw_duplex_unit_page_counter") assert entry assert entry.unique_id == "0123456789_duplex_unit_pages_counter" - state = hass.states.get("sensor.hl_l2340dw_b_w_counter") + state = hass.states.get("sensor.hl_l2340dw_b_w_pages") assert state assert state.attributes.get(ATTR_ICON) == "mdi:file-document-outline" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "709" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_b_w_counter") + entry = registry.async_get("sensor.hl_l2340dw_b_w_pages") assert entry assert entry.unique_id == "0123456789_bw_counter" - state = hass.states.get("sensor.hl_l2340dw_color_counter") + state = hass.states.get("sensor.hl_l2340dw_color_pages") assert state assert state.attributes.get(ATTR_ICON) == "mdi:file-document-outline" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "902" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_color_counter") + entry = registry.async_get("sensor.hl_l2340dw_color_pages") assert entry assert entry.unique_id == "0123456789_color_counter" - state = hass.states.get("sensor.hl_l2340dw_uptime") + state = hass.states.get("sensor.hl_l2340dw_last_restart") assert state assert state.attributes.get(ATTR_ICON) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None @@ -360,7 +360,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "2019-09-24T12:14:56+00:00" assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.hl_l2340dw_uptime") + entry = registry.async_get("sensor.hl_l2340dw_last_restart") assert entry assert entry.unique_id == "0123456789_uptime" @@ -370,10 +370,10 @@ async def test_disabled_by_default_sensors(hass: HomeAssistant) -> None: await init_integration(hass) registry = er.async_get(hass) - state = hass.states.get("sensor.hl_l2340dw_uptime") + state = hass.states.get("sensor.hl_l2340dw_last_restart") assert state is None - entry = registry.async_get("sensor.hl_l2340dw_uptime") + entry = registry.async_get("sensor.hl_l2340dw_last_restart") assert entry assert entry.unique_id == "0123456789_uptime" assert entry.disabled From 84292d4797367e1a153c90874386eefed67f035a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Apr 2023 15:40:14 -1000 Subject: [PATCH 1557/2810] Cleanup some duplicate code in recorder statistics (#90549) * Cleanup some duplicate code in recorder statistics * more cleanup * reduce * reduce --- .../components/recorder/statistics.py | 106 +++++++++--------- tests/components/recorder/test_statistics.py | 26 ++--- 2 files changed, 60 insertions(+), 72 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 0122ba4464..70e82fad5d 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -1034,18 +1034,19 @@ def _reduce_statistics_per_month( def _generate_statistics_during_period_stmt( - columns: Select, start_time: datetime, end_time: datetime | None, metadata_ids: list[int] | None, table: type[StatisticsBase], + types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], ) -> StatementLambdaElement: """Prepare a database query for statistics during a given period. This prepares a lambda_stmt query, so we don't insert the parameters yet. """ start_time_ts = start_time.timestamp() - stmt = lambda_stmt(lambda: columns.filter(table.start_ts >= start_time_ts)) + stmt = _generate_select_columns_for_types_stmt(table, types) + 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) @@ -1491,6 +1492,33 @@ def statistic_during_period( return {key: convert(value) if convert else value for key, value in result.items()} +_type_column_mapping = { + "last_reset": "last_reset_ts", + "max": "max", + "mean": "mean", + "min": "min", + "state": "state", + "sum": "sum", +} + + +def _generate_select_columns_for_types_stmt( + table: type[StatisticsBase], + types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], +) -> StatementLambdaElement: + columns = select(table.metadata_id, table.start_ts) + track_on: list[str | None] = [ + table.__tablename__, # type: ignore[attr-defined] + ] + for key, column in _type_column_mapping.items(): + if key in types: + columns = columns.add_columns(getattr(table, column)) + track_on.append(column) + else: + track_on.append(None) + return lambda_stmt(lambda: columns, track_on=track_on) + + def _statistics_during_period_with_session( hass: HomeAssistant, session: Session, @@ -1525,21 +1553,8 @@ def _statistics_during_period_with_session( table: type[Statistics | StatisticsShortTerm] = ( Statistics if period != "5minute" else StatisticsShortTerm ) - 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 + start_time, end_time, metadata_ids, table, types ) stats = cast(Sequence[Row], execute_stmt_lambda_element(session, stmt)) @@ -1771,34 +1786,34 @@ def get_latest_short_term_statistics( def _generate_statistics_at_time_stmt( - columns: Select, table: type[StatisticsBase], metadata_ids: set[int], start_time_ts: float, + types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], ) -> 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() + stmt = _generate_select_columns_for_types_stmt(table, types) + stmt += lambda q: q.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"), ) - ), - 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() + ) + ), + and_( + table.start_ts == most_recent_statistic_ids.c.max_start_ts, + table.metadata_id == most_recent_statistic_ids.c.max_metadata_id, + ), ) + return stmt def _statistics_at_time( @@ -1809,23 +1824,8 @@ def _statistics_at_time( types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], ) -> Sequence[Row] | None: """Return last known statistics, earlier than start_time, for the metadata_ids.""" - 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) start_time_ts = start_time.timestamp() - stmt = _generate_statistics_at_time_stmt( - columns, table, metadata_ids, start_time_ts - ) + stmt = _generate_statistics_at_time_stmt(table, metadata_ids, start_time_ts, types) 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 ff42979431..25890fe475 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -1244,28 +1244,21 @@ def test_monthly_statistics( def test_cache_key_for_generate_statistics_during_period_stmt() -> None: """Test cache key for _generate_statistics_during_period_stmt.""" - columns = select(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start_ts) stmt = _generate_statistics_during_period_stmt( - columns, dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm + dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm, set() ) cache_key_1 = stmt._generate_cache_key() stmt2 = _generate_statistics_during_period_stmt( - columns, dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm + dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm, set() ) 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, + {"sum", "mean"}, ) cache_key_3 = stmt3._generate_cache_key() assert cache_key_1 != cache_key_3 @@ -1321,18 +1314,13 @@ def test_cache_key_for_generate_max_mean_min_statistic_in_sub_period_stmt() -> N def test_cache_key_for_generate_statistics_at_time_stmt() -> None: """Test cache key for _generate_statistics_at_time_stmt.""" - columns = select(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start_ts) - stmt = _generate_statistics_at_time_stmt(columns, StatisticsShortTerm, {0}, 0.0) + stmt = _generate_statistics_at_time_stmt(StatisticsShortTerm, {0}, 0.0, set()) cache_key_1 = stmt._generate_cache_key() - stmt2 = _generate_statistics_at_time_stmt(columns, StatisticsShortTerm, {0}, 0.0) + stmt2 = _generate_statistics_at_time_stmt(StatisticsShortTerm, {0}, 0.0, set()) 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( + StatisticsShortTerm, {0}, 0.0, {"sum", "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 4a4d3201f5dbcbd1911b2929ef140e40ec62bc2f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 1 Apr 2023 23:34:52 -0400 Subject: [PATCH 1558/2810] Fix voice assistant error variable (#90658) --- homeassistant/components/voice_assistant/pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/voice_assistant/pipeline.py b/homeassistant/components/voice_assistant/pipeline.py index ef13d54e6a..b41ab8ef9f 100644 --- a/homeassistant/components/voice_assistant/pipeline.py +++ b/homeassistant/components/voice_assistant/pipeline.py @@ -197,7 +197,7 @@ class PipelineRun: raise SpeechToTextError( code="stt-provider-unsupported-metadata", message=( - f"Provider {engine} does not support input speech " + f"Provider {stt_provider.name} does not support input speech " "to text metadata" ), ) From 17270979e6d3627aaf2edbecdea28ad827006c3c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Apr 2023 08:09:44 -1000 Subject: [PATCH 1559/2810] Bump zeroconf to 0.52.0 (#90660) * Bump zeroconf to 0.52.0 Switch to using the new ip_addresses_by_version which avoids all the ip address conversions * updates --- homeassistant/components/zeroconf/__init__.py | 37 +++++-------------- .../components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 13 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index badc124271..a3a055b29c 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -564,14 +564,19 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: if isinstance(value, bytes): properties[key] = value.decode("utf-8") - if not (addresses := service.addresses or service.parsed_addresses()): + if not (ip_addresses := service.ip_addresses_by_version(IPVersion.All)): return None - if (host := _first_non_link_local_address(addresses)) is None: + host: str | None = None + for ip_addr in ip_addresses: + if not ip_addr.is_link_local and not ip_addr.is_unspecified: + host = str(ip_addr) + break + if not host: return None return ZeroconfServiceInfo( - host=str(host), - addresses=service.parsed_addresses(), + host=host, + addresses=[str(ip_addr) for ip_addr in ip_addresses], port=service.port, hostname=service.server, type=service.type, @@ -580,30 +585,6 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: ) -def _first_non_link_local_address( - addresses: list[bytes] | list[str], -) -> str | None: - """Return the first ipv6 or non-link local ipv4 address, preferring IPv4.""" - for address in addresses: - ip_addr = ip_address(address) - if ( - not ip_addr.is_link_local - and not ip_addr.is_unspecified - and ip_addr.version == 4 - ): - return str(ip_addr) - # If we didn't find a good IPv4 address, check for IPv6 addresses. - for address in addresses: - ip_addr = ip_address(address) - if ( - not ip_addr.is_link_local - and not ip_addr.is_unspecified - and ip_addr.version == 6 - ): - return str(ip_addr) - return None - - def _suppress_invalid_properties(properties: dict) -> None: """Suppress any properties that will cause zeroconf to fail to startup.""" diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 36c2fcc127..09fc07684c 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.51.0"] + "requirements": ["zeroconf==0.52.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8c49308503..704ebd9965 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -50,7 +50,7 @@ ulid-transform==0.5.1 voluptuous-serialize==2.6.0 voluptuous==0.13.1 yarl==1.8.1 -zeroconf==0.51.0 +zeroconf==0.52.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index fedca63529..885ccd97b3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2695,7 +2695,7 @@ zamg==0.2.2 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.51.0 +zeroconf==0.52.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d637999fb3..7f28d06d55 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1938,7 +1938,7 @@ youless-api==1.0.1 zamg==0.2.2 # homeassistant.components.zeroconf -zeroconf==0.51.0 +zeroconf==0.52.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From b52fab0f6d40296f3e5f38565cbebe451ec75a3b Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 2 Apr 2023 11:22:16 -0700 Subject: [PATCH 1560/2810] Rename Android TV to Android Debug Bridge (#90657) * Rename Android TV to Android debug bridge * More renaming --- .../components/androidtv/__init__.py | 10 +- .../components/androidtv/config_flow.py | 11 ++- homeassistant/components/androidtv/const.py | 2 +- .../components/androidtv/manifest.json | 2 +- .../components/androidtv/media_player.py | 18 ++-- .../components/androidtv/services.yaml | 12 +-- .../components/androidtv/strings.json | 4 +- homeassistant/generated/integrations.json | 2 +- .../components/androidtv/test_config_flow.py | 6 +- .../components/androidtv/test_media_player.py | 92 +++++++++---------- 10 files changed, 80 insertions(+), 79 deletions(-) diff --git a/homeassistant/components/androidtv/__init__.py b/homeassistant/components/androidtv/__init__.py index d10b1161da..4a1ad55e0b 100644 --- a/homeassistant/components/androidtv/__init__.py +++ b/homeassistant/components/androidtv/__init__.py @@ -1,4 +1,4 @@ -"""Support for functionality to interact with Android TV/Fire TV devices.""" +"""Support for functionality to interact with Android/Fire TV devices.""" from __future__ import annotations from collections.abc import Mapping @@ -135,11 +135,11 @@ async def async_connect_androidtv( if not aftv.available: # Determine the name that will be used for the device in the log if config[CONF_DEVICE_CLASS] == DEVICE_ANDROIDTV: - device_name = "Android TV device" + device_name = "Android device" elif config[CONF_DEVICE_CLASS] == DEVICE_FIRETV: device_name = "Fire TV device" else: - device_name = "Android TV / Fire TV device" + device_name = "Android / Fire TV device" error_message = f"Could not connect to {device_name} at {address} {adb_log}" return None, error_message @@ -148,7 +148,7 @@ async def async_connect_androidtv( async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Android TV platform.""" + """Set up Android Debug Bridge platform.""" state_det_rules = entry.options.get(CONF_STATE_DETECTION_RULES) if CONF_ADB_SERVER_IP not in entry.data: @@ -167,7 +167,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady(error_message) async def async_close_connection(event): - """Close Android TV connection on HA Stop.""" + """Close Android Debug Bridge connection on HA Stop.""" await aftv.adb_close() entry.async_on_unload( diff --git a/homeassistant/components/androidtv/config_flow.py b/homeassistant/components/androidtv/config_flow.py index bac5a9aec6..7e2b1e85f3 100644 --- a/homeassistant/components/androidtv/config_flow.py +++ b/homeassistant/components/androidtv/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow to configure the Android TV integration.""" +"""Config flow to configure the Android Debug Bridge integration.""" from __future__ import annotations import logging @@ -114,13 +114,14 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN): async def _async_check_connection( self, user_input: dict[str, Any] ) -> tuple[str | None, str | None]: - """Attempt to connect the Android TV.""" + """Attempt to connect the Android device.""" try: aftv, error_message = await async_connect_androidtv(self.hass, user_input) except Exception: # pylint: disable=broad-except _LOGGER.exception( - "Unknown error connecting with Android TV at %s", user_input[CONF_HOST] + "Unknown error connecting with Android device at %s", + user_input[CONF_HOST], ) return RESULT_UNKNOWN, None @@ -130,7 +131,7 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN): dev_prop = aftv.device_properties _LOGGER.info( - "Android TV at %s: %s = %r, %s = %r", + "Android device at %s: %s = %r, %s = %r", user_input[CONF_HOST], PROP_ETHMAC, dev_prop.get(PROP_ETHMAC), @@ -184,7 +185,7 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(OptionsFlowWithConfigEntry): - """Handle an option flow for Android TV.""" + """Handle an option flow for Android Debug Bridge.""" def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" diff --git a/homeassistant/components/androidtv/const.py b/homeassistant/components/androidtv/const.py index 7f1e128851..1793642168 100644 --- a/homeassistant/components/androidtv/const.py +++ b/homeassistant/components/androidtv/const.py @@ -1,4 +1,4 @@ -"""Android TV component constants.""" +"""Android Debug Bridge component constants.""" DOMAIN = "androidtv" ANDROID_DEV = DOMAIN diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json index 2de47c65ad..f782db7987 100644 --- a/homeassistant/components/androidtv/manifest.json +++ b/homeassistant/components/androidtv/manifest.json @@ -1,6 +1,6 @@ { "domain": "androidtv", - "name": "Android TV", + "name": "Android Debug Bridge", "codeowners": ["@JeffLIrion", "@ollo69"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/androidtv", diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index fb01ffce77..563b8f07b2 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -1,4 +1,4 @@ -"""Support for functionality to interact with Android TV / Fire TV devices.""" +"""Support for functionality to interact with Android / Fire TV devices.""" from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine @@ -87,7 +87,7 @@ async def async_setup_entry( entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the Android TV entity.""" + """Set up the Android Debug Bridge entity.""" aftv = hass.data[DOMAIN][entry.entry_id][ANDROID_DEV] device_class = aftv.DEVICE_CLASS device_type = ( @@ -201,7 +201,7 @@ def adb_decorator( class ADBDevice(MediaPlayerEntity): - """Representation of an Android TV or Fire TV device.""" + """Representation of an Android or Fire TV device.""" _attr_device_class = MediaPlayerDeviceClass.TV @@ -214,7 +214,7 @@ class ADBDevice(MediaPlayerEntity): entry_id, entry_data, ): - """Initialize the Android TV / Fire TV device.""" + """Initialize the Android / Fire TV device.""" self.aftv = aftv self._attr_name = name self._attr_unique_id = unique_id @@ -384,7 +384,7 @@ class ADBDevice(MediaPlayerEntity): @adb_decorator() async def adb_command(self, command): - """Send an ADB command to an Android TV / Fire TV device.""" + """Send an ADB command to an Android / Fire TV device.""" if key := KEYS.get(command): await self.aftv.adb_shell(f"input keyevent {key}") return @@ -422,13 +422,13 @@ class ADBDevice(MediaPlayerEntity): persistent_notification.async_create( self.hass, msg, - title="Android TV", + title="Android Debug Bridge", ) _LOGGER.info("%s", msg) @adb_decorator() async def service_download(self, device_path, local_path): - """Download a file from your Android TV / Fire TV device to your Home Assistant instance.""" + """Download a file from your Android / Fire TV device to your Home Assistant instance.""" if not self.hass.config.is_allowed_path(local_path): _LOGGER.warning("'%s' is not secure to load data from!", local_path) return @@ -437,7 +437,7 @@ class ADBDevice(MediaPlayerEntity): @adb_decorator() async def service_upload(self, device_path, local_path): - """Upload a file from your Home Assistant instance to an Android TV / Fire TV device.""" + """Upload a file from your Home Assistant instance to an Android / Fire TV device.""" if not self.hass.config.is_allowed_path(local_path): _LOGGER.warning("'%s' is not secure to load data from!", local_path) return @@ -446,7 +446,7 @@ class ADBDevice(MediaPlayerEntity): class AndroidTVDevice(ADBDevice): - """Representation of an Android TV device.""" + """Representation of an Android device.""" _attr_supported_features = ( MediaPlayerEntityFeature.PAUSE diff --git a/homeassistant/components/androidtv/services.yaml b/homeassistant/components/androidtv/services.yaml index fef06266e5..4482f50f3e 100644 --- a/homeassistant/components/androidtv/services.yaml +++ b/homeassistant/components/androidtv/services.yaml @@ -1,8 +1,8 @@ -# Describes the format for available Android TV and Fire TV services +# Describes the format for available Android and Fire TV services adb_command: name: ADB command - description: Send an ADB command to an Android TV / Fire TV device. + description: Send an ADB command to an Android / Fire TV device. target: entity: integration: androidtv @@ -17,7 +17,7 @@ adb_command: text: download: name: Download - description: Download a file from your Android TV / Fire TV device to your Home Assistant instance. + description: Download a file from your Android / Fire TV device to your Home Assistant instance. target: entity: integration: androidtv @@ -25,7 +25,7 @@ download: fields: device_path: name: Device path - description: The filepath on the Android TV / Fire TV device. + description: The filepath on the Android / Fire TV device. required: true example: "/storage/emulated/0/Download/example.txt" selector: @@ -39,7 +39,7 @@ download: text: upload: name: Upload - description: Upload a file from your Home Assistant instance to an Android TV / Fire TV device. + description: Upload a file from your Home Assistant instance to an Android / Fire TV device. target: entity: integration: androidtv @@ -47,7 +47,7 @@ upload: fields: device_path: name: Device path - description: The filepath on the Android TV / Fire TV device. + description: The filepath on the Android / Fire TV device. required: true example: "/storage/emulated/0/Download/example.txt" selector: diff --git a/homeassistant/components/androidtv/strings.json b/homeassistant/components/androidtv/strings.json index 7a46228bd4..e7d06a9f62 100644 --- a/homeassistant/components/androidtv/strings.json +++ b/homeassistant/components/androidtv/strings.json @@ -38,7 +38,7 @@ } }, "apps": { - "title": "Configure Android TV Apps", + "title": "Configure Android Apps", "description": "Configure application id {app_id}", "data": { "app_name": "Application Name", @@ -47,7 +47,7 @@ } }, "rules": { - "title": "Configure Android TV state detection rules", + "title": "Configure Android state detection rules", "description": "Configure detection rule for application id {rule_id}", "data": { "rule_id": "Application ID", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9e5155f0cb..311f8414f9 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -241,7 +241,7 @@ "iot_class": "local_polling" }, "androidtv": { - "name": "Android TV", + "name": "Android Debug Bridge", "integration_type": "device", "config_flow": true, "iot_class": "local_polling" diff --git a/tests/components/androidtv/test_config_flow.py b/tests/components/androidtv/test_config_flow.py index ed118bc827..ad7d3be290 100644 --- a/tests/components/androidtv/test_config_flow.py +++ b/tests/components/androidtv/test_config_flow.py @@ -48,14 +48,14 @@ INVALID_MAC = "ff:ff:ff:ff:ff:ff" HOST = "127.0.0.1" VALID_DETECT_RULE = [{"paused": {"media_session_state": 3}}] -# Android TV device with Python ADB implementation +# Android device with Python ADB implementation CONFIG_PYTHON_ADB = { CONF_HOST: HOST, CONF_PORT: DEFAULT_PORT, CONF_DEVICE_CLASS: DEVICE_ANDROIDTV, } -# Android TV device with ADB server +# Android device with ADB server CONFIG_ADB_SERVER = { CONF_HOST: HOST, CONF_PORT: DEFAULT_PORT, @@ -70,7 +70,7 @@ CONNECT_METHOD = ( class MockConfigDevice: - """Mock class to emulate Android TV device.""" + """Mock class to emulate Android device.""" def __init__(self, eth_mac=ETH_MAC, wifi_mac=None): """Initialize a fake device to test config flow.""" diff --git a/tests/components/androidtv/test_media_player.py b/tests/components/androidtv/test_media_player.py index 3ecbd5b05f..59c7ce751a 100644 --- a/tests/components/androidtv/test_media_player.py +++ b/tests/components/androidtv/test_media_player.py @@ -95,8 +95,8 @@ MSG_RECONNECT = { SHELL_RESPONSE_OFF = "" SHELL_RESPONSE_STANDBY = "1" -# Android TV device with Python ADB implementation -CONFIG_ANDROIDTV_PYTHON_ADB = { +# Android device with Python ADB implementation +CONFIG_ANDROID_PYTHON_ADB = { ADB_PATCH_KEY: patchers.KEY_PYTHON, TEST_ENTITY_NAME: f"{PREFIX_ANDROIDTV} {HOST}", DOMAIN: { @@ -106,28 +106,28 @@ CONFIG_ANDROIDTV_PYTHON_ADB = { }, } -# Android TV device with Python ADB implementation imported from YAML -CONFIG_ANDROIDTV_PYTHON_ADB_YAML = { +# Android device with Python ADB implementation imported from YAML +CONFIG_ANDROID_PYTHON_ADB_YAML = { ADB_PATCH_KEY: patchers.KEY_PYTHON, TEST_ENTITY_NAME: "ADB yaml import", DOMAIN: { CONF_NAME: "ADB yaml import", - **CONFIG_ANDROIDTV_PYTHON_ADB[DOMAIN], + **CONFIG_ANDROID_PYTHON_ADB[DOMAIN], }, } -# Android TV device with Python ADB implementation with custom adbkey -CONFIG_ANDROIDTV_PYTHON_ADB_KEY = { +# Android device with Python ADB implementation with custom adbkey +CONFIG_ANDROID_PYTHON_ADB_KEY = { ADB_PATCH_KEY: patchers.KEY_PYTHON, - TEST_ENTITY_NAME: CONFIG_ANDROIDTV_PYTHON_ADB[TEST_ENTITY_NAME], + TEST_ENTITY_NAME: CONFIG_ANDROID_PYTHON_ADB[TEST_ENTITY_NAME], DOMAIN: { - **CONFIG_ANDROIDTV_PYTHON_ADB[DOMAIN], + **CONFIG_ANDROID_PYTHON_ADB[DOMAIN], CONF_ADBKEY: "user_provided_adbkey", }, } -# Android TV device with ADB server -CONFIG_ANDROIDTV_ADB_SERVER = { +# Android device with ADB server +CONFIG_ANDROID_ADB_SERVER = { ADB_PATCH_KEY: patchers.KEY_SERVER, TEST_ENTITY_NAME: f"{PREFIX_ANDROIDTV} {HOST}", DOMAIN: { @@ -163,7 +163,7 @@ CONFIG_FIRETV_ADB_SERVER = { }, } -CONFIG_ANDROIDTV_DEFAULT = CONFIG_ANDROIDTV_PYTHON_ADB +CONFIG_ANDROID_DEFAULT = CONFIG_ANDROID_PYTHON_ADB CONFIG_FIRETV_DEFAULT = CONFIG_FIRETV_PYTHON_ADB @@ -213,10 +213,10 @@ def _setup(config): @pytest.mark.parametrize( "config", [ - CONFIG_ANDROIDTV_PYTHON_ADB, - CONFIG_ANDROIDTV_PYTHON_ADB_YAML, + CONFIG_ANDROID_PYTHON_ADB, + CONFIG_ANDROID_PYTHON_ADB_YAML, CONFIG_FIRETV_PYTHON_ADB, - CONFIG_ANDROIDTV_ADB_SERVER, + CONFIG_ANDROID_ADB_SERVER, CONFIG_FIRETV_ADB_SERVER, ], ) @@ -275,9 +275,9 @@ async def test_reconnect( @pytest.mark.parametrize( "config", [ - CONFIG_ANDROIDTV_PYTHON_ADB, + CONFIG_ANDROID_PYTHON_ADB, CONFIG_FIRETV_PYTHON_ADB, - CONFIG_ANDROIDTV_ADB_SERVER, + CONFIG_ANDROID_ADB_SERVER, CONFIG_FIRETV_ADB_SERVER, ], ) @@ -313,7 +313,7 @@ async def test_adb_shell_returns_none( async def test_setup_with_adbkey(hass: HomeAssistant) -> None: """Test that setup succeeds when using an ADB key.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_PYTHON_ADB_KEY) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_PYTHON_ADB_KEY) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key], patchers.patch_shell( @@ -331,12 +331,12 @@ async def test_setup_with_adbkey(hass: HomeAssistant) -> None: @pytest.mark.parametrize( "config", [ - CONFIG_ANDROIDTV_DEFAULT, + CONFIG_ANDROID_DEFAULT, CONFIG_FIRETV_DEFAULT, ], ) async def test_sources(hass: HomeAssistant, config: dict[str, Any]) -> None: - """Test that sources (i.e., apps) are handled correctly for Android TV and Fire TV devices.""" + """Test that sources (i.e., apps) are handled correctly for Android and Fire TV devices.""" conf_apps = { "com.app.test1": "TEST 1", "com.app.test3": None, @@ -397,7 +397,7 @@ async def test_sources(hass: HomeAssistant, config: dict[str, Any]) -> None: @pytest.mark.parametrize( ("config", "expected_sources"), [ - (CONFIG_ANDROIDTV_DEFAULT, ["TEST 1"]), + (CONFIG_ANDROID_DEFAULT, ["TEST 1"]), (CONFIG_FIRETV_DEFAULT, ["TEST 1"]), ], ) @@ -503,7 +503,7 @@ async def test_select_source_androidtv( "com.app.test3": None, } await _test_select_source( - hass, CONFIG_ANDROIDTV_DEFAULT, conf_apps, source, expected_arg, method_patch + hass, CONFIG_ANDROID_DEFAULT, conf_apps, source, expected_arg, method_patch ) @@ -517,7 +517,7 @@ async def test_androidtv_select_source_overridden_app_name(hass: HomeAssistant) assert "com.youtube.test" not in ANDROIDTV_APPS await _test_select_source( hass, - CONFIG_ANDROIDTV_PYTHON_ADB, + CONFIG_ANDROID_PYTHON_ADB, conf_apps, "YouTube", "com.youtube.test", @@ -554,9 +554,9 @@ async def test_select_source_firetv( @pytest.mark.parametrize( ("config", "connect"), [ - (CONFIG_ANDROIDTV_DEFAULT, False), + (CONFIG_ANDROID_DEFAULT, False), (CONFIG_FIRETV_DEFAULT, False), - (CONFIG_ANDROIDTV_DEFAULT, True), + (CONFIG_ANDROID_DEFAULT, True), (CONFIG_FIRETV_DEFAULT, True), ], ) @@ -581,7 +581,7 @@ async def test_setup_fail( async def test_adb_command(hass: HomeAssistant) -> None: """Test sending a command via the `androidtv.adb_command` service.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) command = "test command" response = "test response" @@ -610,7 +610,7 @@ async def test_adb_command(hass: HomeAssistant) -> None: async def test_adb_command_unicode_decode_error(hass: HomeAssistant) -> None: """Test sending a command via the `androidtv.adb_command` service that raises a UnicodeDecodeError exception.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) command = "test command" response = b"test response" @@ -639,7 +639,7 @@ async def test_adb_command_unicode_decode_error(hass: HomeAssistant) -> None: async def test_adb_command_key(hass: HomeAssistant) -> None: """Test sending a key command via the `androidtv.adb_command` service.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) command = "HOME" response = None @@ -668,7 +668,7 @@ async def test_adb_command_key(hass: HomeAssistant) -> None: async def test_adb_command_get_properties(hass: HomeAssistant) -> None: """Test sending the "GET_PROPERTIES" command via the `androidtv.adb_command` service.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) command = "GET_PROPERTIES" response = {"test key": "test value"} @@ -698,7 +698,7 @@ async def test_adb_command_get_properties(hass: HomeAssistant) -> None: async def test_learn_sendevent(hass: HomeAssistant) -> None: """Test the `androidtv.learn_sendevent` service.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) response = "sendevent 1 2 3 4" @@ -727,7 +727,7 @@ async def test_learn_sendevent(hass: HomeAssistant) -> None: async def test_update_lock_not_acquired(hass: HomeAssistant) -> None: """Test that the state does not get updated when a `LockNotAcquiredException` is raised.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key], patchers.patch_shell( @@ -760,7 +760,7 @@ async def test_update_lock_not_acquired(hass: HomeAssistant) -> None: async def test_download(hass: HomeAssistant) -> None: """Test the `androidtv.download` service.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) device_path = "device/path" local_path = "local/path" @@ -806,7 +806,7 @@ async def test_download(hass: HomeAssistant) -> None: async def test_upload(hass: HomeAssistant) -> None: """Test the `androidtv.upload` service.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) device_path = "device/path" local_path = "local/path" @@ -851,8 +851,8 @@ async def test_upload(hass: HomeAssistant) -> None: async def test_androidtv_volume_set(hass: HomeAssistant) -> None: - """Test setting the volume for an Android TV device.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + """Test setting the volume for an Android device.""" + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key], patchers.patch_shell( @@ -881,7 +881,7 @@ async def test_get_image_http( This is based on `test_get_image_http` in tests/components/media_player/test_init.py. """ - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key], patchers.patch_shell( @@ -894,7 +894,7 @@ async def test_get_image_http( await async_update_entity(hass, entity_id) media_player_name = "media_player." + slugify( - CONFIG_ANDROIDTV_DEFAULT[TEST_ENTITY_NAME] + CONFIG_ANDROID_DEFAULT[TEST_ENTITY_NAME] ) state = hass.states.get(media_player_name) assert "entity_picture_local" not in state.attributes @@ -923,7 +923,7 @@ async def test_get_image_http( async def test_get_image_disabled(hass: HomeAssistant) -> None: """Test that the screencap option can disable entity_picture.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( config_entry, options={CONF_SCREENCAP: False} @@ -939,7 +939,7 @@ async def test_get_image_disabled(hass: HomeAssistant) -> None: await async_update_entity(hass, entity_id) media_player_name = "media_player." + slugify( - CONFIG_ANDROIDTV_DEFAULT[TEST_ENTITY_NAME] + CONFIG_ANDROID_DEFAULT[TEST_ENTITY_NAME] ) state = hass.states.get(media_player_name) assert "entity_picture_local" not in state.attributes @@ -954,7 +954,7 @@ async def _test_service( additional_service_data=None, return_value=None, ): - """Test generic Android TV media player entity service.""" + """Test generic Android media player entity service.""" service_data = {ATTR_ENTITY_ID: entity_id} if additional_service_data: service_data.update(additional_service_data) @@ -977,8 +977,8 @@ async def _test_service( async def test_services_androidtv(hass: HomeAssistant) -> None: - """Test media player services for an Android TV device.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + """Test media player services for an Android device.""" + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key]: @@ -1042,7 +1042,7 @@ async def test_services_firetv(hass: HomeAssistant) -> None: async def test_volume_mute(hass: HomeAssistant) -> None: """Test the volume mute service.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key]: @@ -1085,7 +1085,7 @@ async def test_volume_mute(hass: HomeAssistant) -> None: async def test_connection_closed_on_ha_stop(hass: HomeAssistant) -> None: """Test that the ADB socket connection is closed when HA stops.""" - patch_key, _, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, _, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key], patchers.patch_shell( @@ -1105,7 +1105,7 @@ async def test_exception(hass: HomeAssistant) -> None: HA will attempt to reconnect on the next update. """ - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key], patchers.patch_shell( @@ -1135,7 +1135,7 @@ async def test_exception(hass: HomeAssistant) -> None: async def test_options_reload(hass: HomeAssistant) -> None: """Test changing an option that will cause integration reload.""" - patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_DEFAULT) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) config_entry.add_to_hass(hass) with patchers.patch_connect(True)[patch_key], patchers.patch_shell( From d32fb7c22f645b0abca92e0d5dbda427828ac8fc Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 2 Apr 2023 20:24:40 +0200 Subject: [PATCH 1561/2810] Add entity name translations to Airly (#90656) Add entity name translations --- homeassistant/components/airly/sensor.py | 22 ++++++------ homeassistant/components/airly/strings.json | 37 +++++++++++++++++++++ 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index 754471c9d8..53e15c651a 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -68,7 +68,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_CAQI, icon="mdi:air-filter", - name=ATTR_API_CAQI, + translation_key="caqi", native_unit_of_measurement="CAQI", suggested_display_precision=0, attrs=lambda data: { @@ -80,7 +80,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_PM1, device_class=SensorDeviceClass.PM1, - name="PM1.0", + translation_key="pm1", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -88,7 +88,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_PM25, device_class=SensorDeviceClass.PM25, - name="PM2.5", + translation_key="pm25", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -100,7 +100,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_PM10, device_class=SensorDeviceClass.PM10, - name=ATTR_API_PM10, + translation_key="pm10", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -112,7 +112,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_HUMIDITY, device_class=SensorDeviceClass.HUMIDITY, - name=ATTR_API_HUMIDITY.capitalize(), + translation_key="humidity", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, @@ -120,7 +120,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_PRESSURE, device_class=SensorDeviceClass.PRESSURE, - name=ATTR_API_PRESSURE.capitalize(), + translation_key="pressure", native_unit_of_measurement=UnitOfPressure.HPA, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -128,14 +128,14 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, - name=ATTR_API_TEMPERATURE.capitalize(), + translation_key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, ), AirlySensorEntityDescription( key=ATTR_API_CO, - name="Carbon monoxide", + translation_key="co", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -147,7 +147,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_NO2, device_class=SensorDeviceClass.NITROGEN_DIOXIDE, - name="Nitrogen dioxide", + translation_key="no2", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -159,7 +159,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_SO2, device_class=SensorDeviceClass.SULPHUR_DIOXIDE, - name="Sulphur dioxide", + translation_key="so2", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -171,7 +171,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_O3, device_class=SensorDeviceClass.OZONE, - name="Ozone", + translation_key="o3", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, diff --git a/homeassistant/components/airly/strings.json b/homeassistant/components/airly/strings.json index 4f95f26afc..93fcffa571 100644 --- a/homeassistant/components/airly/strings.json +++ b/homeassistant/components/airly/strings.json @@ -26,5 +26,42 @@ "requests_remaining": "Remaining allowed requests", "requests_per_day": "Allowed requests per day" } + }, + "entity": { + "sensor": { + "caqi": { + "name": "CAQI" + }, + "pm1": { + "name": "PM1.0" + }, + "pm25": { + "name": "PM2.5" + }, + "pm10": { + "name": "PM10" + }, + "humidity": { + "name": "Humidity" + }, + "pressure": { + "name": "Pressure" + }, + "temperature": { + "name": "Temperature" + }, + "co": { + "name": "Carbon monoxide" + }, + "no2": { + "name": "Nitrogen dioxide" + }, + "so2": { + "name": "Sulphur dioxide" + }, + "o3": { + "name": "Ozone" + } + } } } From fc81b829326281ac53d92025ce754cec651f40a1 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 2 Apr 2023 20:25:38 +0200 Subject: [PATCH 1562/2810] Add entity name translations to GIOS (#90655) * Add entity name translations * Update tests --- homeassistant/components/gios/sensor.py | 20 ++++-------- homeassistant/components/gios/strings.json | 27 +++++++++++++++ tests/components/gios/test_sensor.py | 38 +++++++++++----------- 3 files changed, 53 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index 7cf4b7e7c6..f078cc074e 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -60,7 +60,6 @@ class GiosSensorEntityDescription(SensorEntityDescription, GiosSensorRequiredKey SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( GiosSensorEntityDescription( key=ATTR_AQI, - name="AQI", value=lambda sensors: sensors.aqi.value if sensors.aqi else None, icon="mdi:air-filter", device_class=SensorDeviceClass.ENUM, @@ -69,35 +68,34 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( ), GiosSensorEntityDescription( key=ATTR_C6H6, - name="C6H6", value=lambda sensors: sensors.c6h6.value if sensors.c6h6 else None, suggested_display_precision=0, icon="mdi:molecule", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="c6h6", ), GiosSensorEntityDescription( key=ATTR_CO, - name="CO", value=lambda sensors: sensors.co.value if sensors.co else None, suggested_display_precision=0, icon="mdi:molecule", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="co", ), GiosSensorEntityDescription( key=ATTR_NO2, - name="NO2", value=lambda sensors: sensors.no2.value if sensors.no2 else None, suggested_display_precision=0, device_class=SensorDeviceClass.NITROGEN_DIOXIDE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="no2", ), GiosSensorEntityDescription( key=ATTR_NO2, subkey="index", - name="NO2 index", value=lambda sensors: sensors.no2.index if sensors.no2 else None, icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, @@ -106,17 +104,16 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( ), GiosSensorEntityDescription( key=ATTR_O3, - name="O3", value=lambda sensors: sensors.o3.value if sensors.o3 else None, suggested_display_precision=0, device_class=SensorDeviceClass.OZONE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="o3", ), GiosSensorEntityDescription( key=ATTR_O3, subkey="index", - name="O3 index", value=lambda sensors: sensors.o3.index if sensors.o3 else None, icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, @@ -125,17 +122,16 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( ), GiosSensorEntityDescription( key=ATTR_PM10, - name="PM10", value=lambda sensors: sensors.pm10.value if sensors.pm10 else None, suggested_display_precision=0, device_class=SensorDeviceClass.PM10, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="pm10", ), GiosSensorEntityDescription( key=ATTR_PM10, subkey="index", - name="PM10 index", value=lambda sensors: sensors.pm10.index if sensors.pm10 else None, icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, @@ -144,17 +140,16 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( ), GiosSensorEntityDescription( key=ATTR_PM25, - name="PM2.5", value=lambda sensors: sensors.pm25.value if sensors.pm25 else None, suggested_display_precision=0, device_class=SensorDeviceClass.PM25, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="pm25", ), GiosSensorEntityDescription( key=ATTR_PM25, subkey="index", - name="PM2.5 index", value=lambda sensors: sensors.pm25.index if sensors.pm25 else None, icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, @@ -163,17 +158,16 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( ), GiosSensorEntityDescription( key=ATTR_SO2, - name="SO2", value=lambda sensors: sensors.so2.value if sensors.so2 else None, suggested_display_precision=0, device_class=SensorDeviceClass.SULPHUR_DIOXIDE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="so2", ), GiosSensorEntityDescription( key=ATTR_SO2, subkey="index", - name="SO2 index", value=lambda sensors: sensors.so2.index if sensors.so2 else None, icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, diff --git a/homeassistant/components/gios/strings.json b/homeassistant/components/gios/strings.json index 53e7dd78a8..bbbd1c3e6c 100644 --- a/homeassistant/components/gios/strings.json +++ b/homeassistant/components/gios/strings.json @@ -26,6 +26,7 @@ "entity": { "sensor": { "aqi": { + "name": "AQI", "state": { "very_bad": "Very bad", "bad": "Bad", @@ -35,7 +36,17 @@ "very_good": "Very good" } }, + "c6h6": { + "name": "Benzene" + }, + "co": { + "name": "Carbon monoxide" + }, + "no2": { + "name": "Nitrogen dioxide" + }, "no2_index": { + "name": "Nitrogen dioxide index", "state": { "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", @@ -45,7 +56,11 @@ "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" } }, + "o3": { + "name": "Ozone" + }, "o3_index": { + "name": "Ozone index", "state": { "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", @@ -55,7 +70,11 @@ "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" } }, + "pm10": { + "name": "PM10" + }, "pm10_index": { + "name": "PM10 index", "state": { "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", @@ -65,7 +84,11 @@ "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" } }, + "pm25": { + "name": "PM2.5" + }, "pm25_index": { + "name": "PM2.5 index", "state": { "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", @@ -75,7 +98,11 @@ "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" } }, + "so2": { + "name": "Sulphur dioxide" + }, "so2_index": { + "name": "Sulphur dioxide index", "state": { "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", diff --git a/tests/components/gios/test_sensor.py b/tests/components/gios/test_sensor.py index 48f0e23840..2eb74ec121 100644 --- a/tests/components/gios/test_sensor.py +++ b/tests/components/gios/test_sensor.py @@ -35,7 +35,7 @@ async def test_sensor(hass: HomeAssistant) -> None: await init_integration(hass) registry = er.async_get(hass) - state = hass.states.get("sensor.home_c6h6") + state = hass.states.get("sensor.home_benzene") assert state assert state.state == "0.23789" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -46,11 +46,11 @@ async def test_sensor(hass: HomeAssistant) -> None: ) assert state.attributes.get(ATTR_ICON) == "mdi:molecule" - entry = registry.async_get("sensor.home_c6h6") + entry = registry.async_get("sensor.home_benzene") assert entry assert entry.unique_id == "123-c6h6" - state = hass.states.get("sensor.home_co") + state = hass.states.get("sensor.home_carbon_monoxide") assert state assert state.state == "251.874" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -61,11 +61,11 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.home_co") + entry = registry.async_get("sensor.home_carbon_monoxide") assert entry assert entry.unique_id == "123-co" - state = hass.states.get("sensor.home_no2") + state = hass.states.get("sensor.home_nitrogen_dioxide") assert state assert state.state == "7.13411" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -76,11 +76,11 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.home_no2") + entry = registry.async_get("sensor.home_nitrogen_dioxide") assert entry assert entry.unique_id == "123-no2" - state = hass.states.get("sensor.home_no2_index") + state = hass.states.get("sensor.home_nitrogen_dioxide_index") assert state assert state.state == "good" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -94,11 +94,11 @@ async def test_sensor(hass: HomeAssistant) -> None: "very_good", ] - entry = registry.async_get("sensor.home_no2_index") + entry = registry.async_get("sensor.home_nitrogen_dioxide_index") assert entry assert entry.unique_id == "123-no2-index" - state = hass.states.get("sensor.home_o3") + state = hass.states.get("sensor.home_ozone") assert state assert state.state == "95.7768" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -109,11 +109,11 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.home_o3") + entry = registry.async_get("sensor.home_ozone") assert entry assert entry.unique_id == "123-o3" - state = hass.states.get("sensor.home_o3_index") + state = hass.states.get("sensor.home_ozone_index") assert state assert state.state == "good" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -127,7 +127,7 @@ async def test_sensor(hass: HomeAssistant) -> None: "very_good", ] - entry = registry.async_get("sensor.home_o3_index") + entry = registry.async_get("sensor.home_ozone_index") assert entry assert entry.unique_id == "123-o3-index" @@ -197,7 +197,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "123-pm25-index" - state = hass.states.get("sensor.home_so2") + state = hass.states.get("sensor.home_sulphur_dioxide") assert state assert state.state == "4.35478" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -208,11 +208,11 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.home_so2") + entry = registry.async_get("sensor.home_sulphur_dioxide") assert entry assert entry.unique_id == "123-so2" - state = hass.states.get("sensor.home_so2_index") + state = hass.states.get("sensor.home_sulphur_dioxide_index") assert state assert state.state == "very_good" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -226,7 +226,7 @@ async def test_sensor(hass: HomeAssistant) -> None: "very_good", ] - entry = registry.async_get("sensor.home_so2_index") + entry = registry.async_get("sensor.home_sulphur_dioxide_index") assert entry assert entry.unique_id == "123-so2-index" @@ -341,11 +341,11 @@ async def test_invalid_indexes(hass: HomeAssistant) -> None: """Test states of the sensor when API returns invalid indexes.""" await init_integration(hass, invalid_indexes=True) - state = hass.states.get("sensor.home_no2_index") + state = hass.states.get("sensor.home_nitrogen_dioxide_index") assert state assert state.state == STATE_UNAVAILABLE - state = hass.states.get("sensor.home_o3_index") + state = hass.states.get("sensor.home_ozone_index") assert state assert state.state == STATE_UNAVAILABLE @@ -357,7 +357,7 @@ async def test_invalid_indexes(hass: HomeAssistant) -> None: assert state assert state.state == STATE_UNAVAILABLE - state = hass.states.get("sensor.home_so2_index") + state = hass.states.get("sensor.home_sulphur_dioxide_index") assert state assert state.state == STATE_UNAVAILABLE From c5a87addc165f234788f598fb5b0bb99efe24088 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 2 Apr 2023 14:28:52 -0400 Subject: [PATCH 1563/2810] Fix frontend test (#90679) --- tests/components/frontend/test_init.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index 69643b10ec..dcff80d359 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -141,7 +141,7 @@ async def test_frontend_and_static(mock_http_client, mock_onboarded) -> None: text = await resp.text() # Test we can retrieve frontend.js - frontendjs = re.search(r"(?P\/frontend_es5\/app.[A-Za-z0-9]{8}.js)", text) + frontendjs = re.search(r"(?P\/frontend_es5\/app.[A-Za-z0-9_]{11}.js)", text) assert frontendjs is not None, text resp = await mock_http_client.get(frontendjs.groups(0)[0]) @@ -546,7 +546,7 @@ async def test_auth_authorize(mock_http_client) -> None: # Test we can retrieve authorize.js authorizejs = re.search( - r"(?P\/frontend_latest\/authorize.[A-Za-z0-9]{8}.js)", text + r"(?P\/frontend_latest\/authorize.[A-Za-z0-9_]{11}.js)", text ) assert authorizejs is not None, text From 368d1c9b54878c20014eeac2aacc575b2bc74333 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Apr 2023 13:32:00 -1000 Subject: [PATCH 1564/2810] Bump zeroconf to 0.53.0 (#90682) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 09fc07684c..551471b41e 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.52.0"] + "requirements": ["zeroconf==0.53.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 704ebd9965..c8cd41b307 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -50,7 +50,7 @@ ulid-transform==0.5.1 voluptuous-serialize==2.6.0 voluptuous==0.13.1 yarl==1.8.1 -zeroconf==0.52.0 +zeroconf==0.53.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index 885ccd97b3..362797c37f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2695,7 +2695,7 @@ zamg==0.2.2 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.52.0 +zeroconf==0.53.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7f28d06d55..1fd34a61a5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1938,7 +1938,7 @@ youless-api==1.0.1 zamg==0.2.2 # homeassistant.components.zeroconf -zeroconf==0.52.0 +zeroconf==0.53.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 22fd6138bd9f11e19dd657126f9d239193ecc703 Mon Sep 17 00:00:00 2001 From: Patrick ZAJDA Date: Mon, 3 Apr 2023 02:19:03 +0200 Subject: [PATCH 1565/2810] Add entity name translations for Nest sensors (#90677) Signed-off-by: Patrick ZAJDA --- homeassistant/components/nest/sensor_sdm.py | 4 ++-- homeassistant/components/nest/strings.json | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nest/sensor_sdm.py b/homeassistant/components/nest/sensor_sdm.py index 187ac0ee8c..8eb607b205 100644 --- a/homeassistant/components/nest/sensor_sdm.py +++ b/homeassistant/components/nest/sensor_sdm.py @@ -79,7 +79,7 @@ class TemperatureSensor(SensorBase): _attr_device_class = SensorDeviceClass.TEMPERATURE _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS - _attr_name = "Temperature" + _attr_translation_key = "temperature" @property def native_value(self) -> float: @@ -96,7 +96,7 @@ class HumiditySensor(SensorBase): _attr_device_class = SensorDeviceClass.HUMIDITY _attr_native_unit_of_measurement = PERCENTAGE - _attr_name = "Humidity" + _attr_translation_key = "humidity" @property def native_value(self) -> int: diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index bf68d1988d..c0c7042423 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -98,5 +98,15 @@ "title": "Nest Authentication Credentials must be updated", "description": "To improve security and reduce phishing risk Google has deprecated the authentication method used by Home Assistant.\n\n**This requires action by you to resolve** ([more info]({more_info_url}))\n\n1. Visit the integrations page\n1. Click Reconfigure on the Nest integration.\n1. Home Assistant will walk you through the steps to upgrade to Web Authentication.\n\nSee the Nest [integration instructions]({documentation_url}) for troubleshooting information." } + }, + "entity": { + "sensor": { + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "humidity": { + "name": "[%key:component::sensor::entity_component::humidity::name%]" + } + } } } From 6a6b6cf826939c699dc1de1b3215519941fb231f Mon Sep 17 00:00:00 2001 From: Michael Davie Date: Sun, 2 Apr 2023 20:20:11 -0400 Subject: [PATCH 1566/2810] Bump env_canada to v0.5.30 (#90644) --- 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 c2c2485d94..8e1f17492f 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.29"] + "requirements": ["env_canada==0.5.30"] } diff --git a/requirements_all.txt b/requirements_all.txt index 362797c37f..b4def3e903 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.29 +env_canada==0.5.30 # homeassistant.components.enphase_envoy envoy_reader==0.20.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1fd34a61a5..1c292cc65b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -517,7 +517,7 @@ energyzero==0.4.1 enocean==0.50 # homeassistant.components.environment_canada -env_canada==0.5.29 +env_canada==0.5.30 # homeassistant.components.enphase_envoy envoy_reader==0.20.1 From 0198c751b450bf3a71654e2bc63a6f4bb6e6e768 Mon Sep 17 00:00:00 2001 From: mletenay Date: Mon, 3 Apr 2023 02:25:29 +0200 Subject: [PATCH 1567/2810] Update goodwe library to v0.2.30 (#90607) --- homeassistant/components/goodwe/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/goodwe/manifest.json b/homeassistant/components/goodwe/manifest.json index 8dad8454d6..45d02dcd2e 100644 --- a/homeassistant/components/goodwe/manifest.json +++ b/homeassistant/components/goodwe/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/goodwe", "iot_class": "local_polling", "loggers": ["goodwe"], - "requirements": ["goodwe==0.2.29"] + "requirements": ["goodwe==0.2.30"] } diff --git a/requirements_all.txt b/requirements_all.txt index b4def3e903..4b60930b9e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -798,7 +798,7 @@ glances_api==0.4.1 goalzero==0.2.1 # homeassistant.components.goodwe -goodwe==0.2.29 +goodwe==0.2.30 # homeassistant.components.google_mail google-api-python-client==2.71.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1c292cc65b..4d983d61f5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -614,7 +614,7 @@ glances_api==0.4.1 goalzero==0.2.1 # homeassistant.components.goodwe -goodwe==0.2.29 +goodwe==0.2.30 # homeassistant.components.google_mail google-api-python-client==2.71.0 From 17719663f090263920403017208de3c9b14f3c16 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Apr 2023 14:51:25 -1000 Subject: [PATCH 1568/2810] Fix memory churn in state templates (#90685) * Fix memory churn in state templates The LRU for state templates was limited to 512 states. As soon as it was exaused, system performance would tank as each template that iterated all states would have to create and GC any state > 512 * does it scale? * avoid copy on all * comment * preen * cover * cover * comments * comments * comments * preen * preen --- homeassistant/bootstrap.py | 1 + homeassistant/helpers/template.py | 98 +++++++++++++++++++++++++++---- tests/helpers/test_template.py | 40 ++++++++++++- 3 files changed, 128 insertions(+), 11 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 445ff35793..d98680c70d 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -239,6 +239,7 @@ async def load_registries(hass: core.HomeAssistant) -> None: # Load the registries and cache the result of platform.uname().processor entity.async_setup(hass) + template.async_setup(hass) await asyncio.gather( area_registry.async_load(hass), device_registry.async_load(hass), diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 8e5951488b..fb693d6957 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -5,7 +5,7 @@ from ast import literal_eval import asyncio import base64 import collections.abc -from collections.abc import Callable, Collection, Generator, Iterable +from collections.abc import Callable, Collection, Generator, Iterable, MutableMapping from contextlib import contextmanager, suppress from contextvars import ContextVar from datetime import datetime, timedelta @@ -41,6 +41,7 @@ from jinja2 import pass_context, pass_environment, pass_eval_context from jinja2.runtime import AsyncLoopContext, LoopContext from jinja2.sandbox import ImmutableSandboxedEnvironment from jinja2.utils import Namespace +from lru import LRU # pylint: disable=no-name-in-module import voluptuous as vol from homeassistant.const import ( @@ -49,6 +50,8 @@ from homeassistant.const import ( ATTR_LONGITUDE, ATTR_PERSONS, ATTR_UNIT_OF_MEASUREMENT, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfLength, @@ -121,11 +124,77 @@ template_cv: ContextVar[tuple[str, str] | None] = ContextVar( "template_cv", default=None ) +# +# CACHED_TEMPLATE_STATES is a rough estimate of the number of entities +# on a typical system. It is used as the initial size of the LRU cache +# for TemplateState objects. +# +# If the cache is too small we will end up creating and destroying +# TemplateState objects too often which will cause a lot of GC activity +# and slow down the system. For systems with a lot of entities and +# templates, this can reach 100000s of object creations and destructions +# per minute. +# +# Since entity counts may grow over time, we will increase +# the size if the number of entities grows via _async_adjust_lru_sizes +# at the start of the system and every 10 minutes if needed. +# CACHED_TEMPLATE_STATES = 512 EVAL_CACHE_SIZE = 512 MAX_CUSTOM_TEMPLATE_SIZE = 5 * 1024 * 1024 +CACHED_TEMPLATE_LRU: MutableMapping[State, TemplateState] = LRU(CACHED_TEMPLATE_STATES) +CACHED_TEMPLATE_NO_COLLECT_LRU: MutableMapping[State, TemplateState] = LRU( + CACHED_TEMPLATE_STATES +) +ENTITY_COUNT_GROWTH_FACTOR = 1.2 + + +def _template_state_no_collect(hass: HomeAssistant, state: State) -> TemplateState: + """Return a TemplateState for a state without collecting.""" + if template_state := CACHED_TEMPLATE_NO_COLLECT_LRU.get(state): + return template_state + template_state = _create_template_state_no_collect(hass, state) + CACHED_TEMPLATE_NO_COLLECT_LRU[state] = template_state + return template_state + + +def _template_state(hass: HomeAssistant, state: State) -> TemplateState: + """Return a TemplateState for a state that collects.""" + if template_state := CACHED_TEMPLATE_LRU.get(state): + return template_state + template_state = TemplateState(hass, state) + CACHED_TEMPLATE_LRU[state] = template_state + return template_state + + +def async_setup(hass: HomeAssistant) -> bool: + """Set up tracking the template LRUs.""" + + @callback + def _async_adjust_lru_sizes(_: Any) -> None: + """Adjust the lru cache sizes.""" + new_size = int( + round(hass.states.async_entity_ids_count() * ENTITY_COUNT_GROWTH_FACTOR) + ) + for lru in (CACHED_TEMPLATE_LRU, CACHED_TEMPLATE_NO_COLLECT_LRU): + # There is no typing for LRU + current_size = lru.get_size() # type: ignore[attr-defined] + if new_size > current_size: + lru.set_size(new_size) # type: ignore[attr-defined] + + from .event import ( # pylint: disable=import-outside-toplevel + async_track_time_interval, + ) + + cancel = async_track_time_interval( + hass, _async_adjust_lru_sizes, timedelta(minutes=10) + ) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_adjust_lru_sizes) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, callback(lambda _: cancel())) + return True + @bind_hass def attach(hass: HomeAssistant, obj: Any) -> None: @@ -969,21 +1038,33 @@ class TemplateStateFromEntityId(TemplateStateBase): return f"